Skip to content

feat(core): add StackError root with instance-level code - #154

Merged
cuibonobo merged 1 commit into
mainfrom
claude/issue-145-plan-hjut4w
Aug 8, 2026
Merged

feat(core): add StackError root with instance-level code#154
cuibonobo merged 1 commit into
mainfrom
claude/issue-145-plan-hjut4w

Conversation

@cuibonobo

Copy link
Copy Markdown
Member

Summary

Closes #145.

All nine Stack-domain error classes extended Error directly, so "is this a domain error or a bug?" — the first question a server's error middleware asks — could only be answered by exhausting a nine-arm instanceof ladder. serializeError() was exactly that ladder, and it carried an ordering hazard: StackVersionConflictError had to be tested before StackConflictError or a 412 would silently serialize as 409.

  • StackError root. Abstract, extends Error, with an abstract instance code. The nine leaves are re-parented; each derives its instance code from its own static (override readonly code = StackValidationError.code), so the literal is still written once per class and the static the spec documents keeps working. Membership now carries a guarantee: a StackError always has a code, and every code has a status — which is what lets serializeError do a lookup instead of a chain of tests.
  • No hierarchy beyond the root. StackVersionConflictError stays a sibling of StackConflictError; the 409/412 split is untouched. IdGenerationError and InvalidDidError have no wire mapping and deliberately stay outside.
  • StackErrorCode moves to core, with wire-types keeping its public WireErrorCode name as an alias. core has no dependencies and can't import from wire-types, and the union was already duplicated there — every literal existed as a static code on a core class. This revises the open question in APIAdapter collapses the error taxonomy: Stack* errors never survive the wire round trip #53, which guessed wire-types was the right home.
  • serializeError() collapses onto err.code: a status lookup plus an instanceof for the three classes carrying structured payload (details, versionConflict, schemaDrift). Those three are leaves with no subtype relation, so the ordering trap is gone for good. Output is byte-identical, key order included.

Spec

Yes — error mapping. docs/spec/wire-format.md:

  • New § The taxonomy root under § Error responses: what instanceof StackError guarantees, why IdGenerationError/InvalidDidError are excluded, and that the root adds no other structure.
  • § Wire error body updated: code is now exposed per-instance as well as per-class, and the vocabulary's home is named. Also states the honest limitation that an instance code discriminates but doesn't narrow — TypeScript won't refine a StackError to a subclass from a literal check, so reaching payload fields still means an instanceof.

No wire shape changed, so @haverstack/conformance-fixtures needed no update — the existing fixtures pass unmodified, which is itself part of the evidence that serialization output is unchanged.

Verification

All five, from a clean install:

pnpm run format:check   ✅  all matched files use Prettier style
pnpm run lint           ✅  9 packages
pnpm test               ✅  core 544, wire-types 9, adapter-api 123, adapter-local 21,
                            record-adapter-sqlite 96, record-adapter-sqljs 97, + rest
pnpm run build          ✅  9 packages
pnpm run typecheck      ✅  9 packages (after build)

New tests:

  • packages/core/tests/stack.test.ts § error taxonomy — all nine descend from StackError; instance code equals the static for each; codes are distinct; StackVersionConflictError is not a StackConflictError (and vice versa); StackSchemaDriftError is not either; IdGenerationError/InvalidDidError/plain Error are outside; and errors thrown by real Stack operations are catchable as StackError.
  • packages/wire-types/tests/errors.test.ts — new test setup for the package (vitest config + script; serializeError previously had no direct coverage, only indirect exercise via adapter-api's conformance tests). Pins code/status/message for every taxonomy member, null for non-StackError input, 409-vs-412 separation, payload fields present only for the codes that define them, full serialize→deserialize class round trip, and errorForStatus behavior including the bodyless-409 degradation and the refusal to infer StackMigrationError from a bare 500.

Checked by hand:

  • Mutation check — reverting one class to extends Error fails 1 core test and 3 wire-types tests; restored and re-ran green. The tests pin the invariant, not the current text.
  • Emitted JS — confirmed field-initializer ordering in dist/stack.js: super()code = '...'this.name = '...'. The base deliberately has no constructor and never reads this.code, since subclass field initializers run after super() returns.

Notes for reviewers

  • Why a field per leaf rather than a base getter. get code() { return this.constructor.code } would avoid the per-class line, but TypeScript can't enforce abstract statics, so a future subclass that forgot its static would silently return undefined. The abstract instance member is enforced at compile time.
  • Considered and rejected: setting this.name = new.target.name in the base to delete nine this.name assignments. It makes a user-visible value depend on whether a bundler mangles class names. name isn't part of the wire contract, but the risk buys nothing.
  • Follow-up worth filing: IdGenerationOverflowError extends IdGenerationError (packages/core/src/id.ts:40) is an existing hierarchy outside the taxonomy and unaffected here — but neither IdGenerationError nor InvalidDidError is exported from core's index, so the wire-types test can't reference them (the core test imports them by relative path). If they're meant to be catchable by apps, that's a separate export gap.

Generated by Claude Code

All nine Stack-domain error classes extended Error directly, so "is this a
domain error or a bug?" — the first question a server's error middleware
asks — could only be answered by exhausting a nine-arm instanceof ladder.
serializeError() was exactly that ladder, and it carried an ordering hazard:
StackVersionConflictError had to be tested before StackConflictError or 412
would silently serialize as 409.

Add an abstract StackError root with an abstract instance `code`, and
re-parent the nine leaves. Each leaf derives its instance code from its own
static, so the literal is still written once per class and the static the
spec documents keeps working. Membership in the hierarchy now carries a
guarantee: a StackError always has a code, and every code has a status.
IdGenerationError and InvalidDidError have no wire mapping and stay outside
for that reason.

No hierarchy beyond the root — StackVersionConflictError remains a sibling
of StackConflictError, and the 409/412 split is unchanged.

The code vocabulary moves to core as StackErrorCode, with wire-types keeping
its public WireErrorCode name as an alias. core has no dependencies and
cannot import from wire-types, and the union was already duplicated there:
every literal existed as a static `code` on a core class. This revises the
open question in #53, which guessed wire-types was the right home.

serializeError() collapses onto err.code: the ladder becomes a status lookup
plus an instanceof for the three classes carrying structured payload. Those
three are leaves with no subtype relation, so the ordering trap is gone for
good. Output is byte-identical, key order included.

wire-types had no test setup and serializeError was covered only indirectly,
through adapter-api's conformance tests. Add vitest to the package and pin
the round trip directly, one case per taxonomy member.

Closes #145

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014iKbdWJzDFtBzp1Jg7BTS1
@cuibonobo
cuibonobo merged commit 3f4b149 into main Aug 8, 2026
5 checks passed
@cuibonobo
cuibonobo deleted the claude/issue-145-plan-hjut4w branch August 8, 2026 20:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shared StackError base class with instance-level code

2 participants