Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/spec/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ Authorization: Bearer <token>

The distinction between **400** and **422** matters for write endpoints (`POST /records`, `PATCH /records/:id`, `POST /records/:id/migrate`, `POST /types`): a 400 means the request couldn't be parsed at all; a 422 means the server understood the request but the content didn't satisfy the type schema.

### The taxonomy root

Every class in the table above extends the abstract `StackError`, so `err instanceof StackError` answers the one question a server's error middleware asks first: is this a Stack-domain failure with a wire representation, or an ordinary bug that should surface as a bare 500? Membership is exactly that guarantee — a `StackError` always has a `code`, and every code has a status. Errors with no wire mapping (`IdGenerationError`, `InvalidDidError`) stay outside the hierarchy for that reason.

The root adds no other structure. `StackVersionConflictError` remains a sibling of `StackConflictError` rather than a subtype (see the 409/412 rows above), and no other pair is related either, so catching a leaf class never catches a different failure by accident.

### Wire error body

Every non-2xx response whose failure maps to the core error taxonomy carries a JSON body of the shape:
Expand All @@ -68,7 +74,9 @@ Every non-2xx response whose failure maps to the core error taxonomy carries a J

Each error code that carries extra structured data gets its own uniquely-named, uniquely-typed field, present only for that code — `details` for `code: "validation"` (`StackValidationError.errors`), `versionConflict` for `code: "version_conflict"` (the data an `ifVersion` retry loop needs: which record, what it expected, what actually won the race), `schemaDrift` for `code: "schema_drift"` (which Type, and which specific fields made the change non-additive). This keeps each field's shape fixed rather than making any one field polymorphic across codes.

`code` is the authoritative discriminator — HTTP status is a transport hint (proxies and intermediaries rewrite statuses more often than bodies). Each core error class exposes the mapping as a static `code` (e.g. `StackPermissionError.code === 'permission'`), so a server serializes a caught error mechanically rather than via a hand-maintained switch, and `APIAdapter` reconstructs the same class from the response.
`code` is the authoritative discriminator — HTTP status is a transport hint (proxies and intermediaries rewrite statuses more often than bodies). Each core error class exposes the mapping both as a static (`StackPermissionError.code === 'permission'`) and on every instance (`err.code`), so serializing a caught error is a status lookup on the instance rather than a hand-maintained chain of class tests, and `APIAdapter` reconstructs the same class from the response. The vocabulary itself is `StackErrorCode` in `@haverstack/core` — it lives with the classes that carry it, and `@haverstack/wire-types` re-exports it as `WireErrorCode`.

Note that an instance `code` discriminates but doesn't narrow: TypeScript won't refine a `StackError` to a subclass from a literal `code` check, so reaching the payload fields (`errors`, `versionConflict` state, `violations`) still means an `instanceof` on the three classes that define them. Those three are leaves with no subtype relation, so unlike a full class ladder the checks are order-independent.

When a response has no parseable wire error body (a foreign server implementation, or a proxy that strips bodies but preserves status), `APIAdapter` still recovers the precise error from status alone for the unambiguous statuses (400/403/404/412/413/422) — **not** for 500, since that status is a generic "unhandled server exception" signal and would misclassify ordinary server bugs as `StackMigrationError`. `schema_drift` is the one deliberate exception to one-code-per-status: it shares **409** with `conflict` (both are "operation conflicts with a constraint" in HTTP terms), so status-only reconstruction of a bodyless 409 degrades to the generic `StackConflictError` — the precise class is only recoverable with a parseable body. When neither the body nor the status yields a typed error, `APIAdapter` throws its own generic `APIAdapterError`.

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
export {
Stack,
ScopedStack,
StackError,
StackValidationError,
StackMigrationError,
StackPermissionError,
Expand All @@ -24,6 +25,7 @@ export {
assertQueryCapabilities,
} from './stack.js';
export type {
StackErrorCode,
StackClient,
CreateRecordOptions,
StackOptions,
Expand Down
64 changes: 55 additions & 9 deletions packages/core/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,46 @@ export type DefineTypeOptions = {
migratesFrom?: TypeId;
};

export class StackValidationError extends Error {
/**
* The wire-protocol discriminator vocabulary, one code per Stack-domain
* error class. Lives here rather than in @haverstack/wire-types because the
* classes that carry these codes are defined here; wire-types re-exports it
* as WireErrorCode. See docs/spec/wire-format.md § Wire error body.
*/
export type StackErrorCode =
| 'bad_request'
| 'permission'
| 'not_found'
| 'conflict'
| 'version_conflict'
| 'validation'
| 'migration'
| 'schema_drift'
| 'payload_too_large';

/**
* Root of the Stack error taxonomy. A single `instanceof StackError` answers
* "is this a Stack-domain error or a bug?" — the question a server's error
* middleware asks before serializing a wire body, and one a nine-arm
* instanceof ladder answers only by exhaustion. Every subclass carries its
* discriminator as an instance `code`, so serialization is a lookup rather
* than a chain of class tests.
*
* Membership implies a wire mapping: every code has an entry in
* WIRE_ERROR_STATUS. Errors with no wire representation (IdGenerationError,
* InvalidDidError) deliberately stay outside this hierarchy.
*
* Subclassing adds no hierarchy beyond this root — notably
* StackVersionConflictError is a sibling of StackConflictError, not a
* subtype. See docs/spec/wire-format.md § Error responses.
*/
export abstract class StackError extends Error {
abstract readonly code: StackErrorCode;
}

export class StackValidationError extends StackError {
static readonly code = 'validation' as const;
override readonly code = StackValidationError.code;
constructor(public readonly errors: ValidationError[]) {
super(
`Content validation failed:\n` + errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'),
Expand All @@ -161,35 +199,39 @@ export class StackValidationError extends Error {
}
}

export class StackMigrationError extends Error {
export class StackMigrationError extends StackError {
static readonly code = 'migration' as const;
override readonly code = StackMigrationError.code;
constructor(message: string) {
super(message);
this.name = 'StackMigrationError';
}
}

/** Thrown by ScopedStack when a requester lacks permission for the operation. */
export class StackPermissionError extends Error {
export class StackPermissionError extends StackError {
static readonly code = 'permission' as const;
override readonly code = StackPermissionError.code;
constructor(message = 'Permission denied') {
super(message);
this.name = 'StackPermissionError';
}
}

/** Thrown when a record (or specific version) does not exist. */
export class StackNotFoundError extends Error {
export class StackNotFoundError extends StackError {
static readonly code = 'not_found' as const;
override readonly code = StackNotFoundError.code;
constructor(message: string) {
super(message);
this.name = 'StackNotFoundError';
}
}

/** Thrown when an operation cannot proceed due to a constraint violation (e.g. deleting an attachment that is still referenced). */
export class StackConflictError extends Error {
export class StackConflictError extends StackError {
static readonly code = 'conflict' as const;
override readonly code = StackConflictError.code;
constructor(message: string) {
super(message);
this.name = 'StackConflictError';
Expand All @@ -202,8 +244,9 @@ export class StackConflictError extends Error {
* different recovery stories and HTTP statuses (409 vs. 412). See
* docs/spec/versioning.md § Optimistic concurrency.
*/
export class StackVersionConflictError extends Error {
export class StackVersionConflictError extends StackError {
static readonly code = 'version_conflict' as const;
override readonly code = StackVersionConflictError.code;
constructor(
message: string,
readonly recordId: string,
Expand All @@ -221,8 +264,9 @@ export class StackVersionConflictError extends Error {
* undecodable pagination cursor). Distinct from StackValidationError, which
* means the request was well-formed but content failed schema validation.
*/
export class StackQueryError extends Error {
export class StackQueryError extends StackError {
static readonly code = 'bad_request' as const;
override readonly code = StackQueryError.code;
constructor(message: string) {
super(message);
this.name = 'StackQueryError';
Expand Down Expand Up @@ -258,8 +302,9 @@ export function assertQueryCapabilities(
* sent; a server still enforces 413 authoritatively regardless. See
* docs/spec/wire-format.md § Attachments.
*/
export class StackPayloadTooLargeError extends Error {
export class StackPayloadTooLargeError extends StackError {
static readonly code = 'payload_too_large' as const;
override readonly code = StackPayloadTooLargeError.code;
constructor(message: string) {
super(message);
this.name = 'StackPayloadTooLargeError';
Expand All @@ -281,8 +326,9 @@ function assertAttachmentSize(byteLength: number, maxAttachmentBytes: number | n
* in-place redefinition. See docs/spec/data-model.md § Schema drift
* detection.
*/
export class StackSchemaDriftError extends Error {
export class StackSchemaDriftError extends StackError {
static readonly code = 'schema_drift' as const;
override readonly code = StackSchemaDriftError.code;
constructor(
public readonly typeId: TypeId,
public readonly violations: SchemaDriftViolation[],
Expand Down
77 changes: 76 additions & 1 deletion packages/core/tests/stack.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import {
Stack,
StackError,
StackValidationError,
StackMigrationError,
StackPermissionError,
StackNotFoundError,
StackConflictError,
StackVersionConflictError,
StackSchemaDriftError,
StackQueryError,
StackPayloadTooLargeError,
} from '../src/stack.js';
import { generateId, crockford32Encode } from '../src/id.js';
import { generateId, crockford32Encode, IdGenerationError } from '../src/id.js';
import { InvalidDidError } from '../src/did.js';
import { MemoryAdapter, IncapableMemoryAdapter } from '../src/testing.js';
import type { BlobFileInfo, StackAdapter, StackRecord } from '../src/types.js';

Expand Down Expand Up @@ -2568,3 +2571,75 @@ describe('collectAttachmentGarbage', () => {
expect(result.reclaimedBytes).toBe(2);
});
});

// -------------------------------------------------------
// Error taxonomy
// -------------------------------------------------------

// One instance per member, constructed with the minimum each requires.
const everyStackError = (): StackError[] => [
new StackValidationError([{ path: 'text', message: 'expected string' }]),
new StackMigrationError('no migration path'),
new StackPermissionError(),
new StackNotFoundError('Record "1hk153x0a00b" not found.'),
new StackConflictError('Attachment is still referenced.'),
new StackVersionConflictError('Version mismatch.', '1hk153x0a00b', 3, 5),
new StackQueryError('Undecodable pagination cursor.'),
new StackSchemaDriftError(NOTE_V1, [{ path: 'text', message: 'type changed' }]),
new StackPayloadTooLargeError('Attachment exceeds the limit.'),
];

describe('error taxonomy', () => {
test('every Stack-domain error descends from StackError and from Error', () => {
for (const err of everyStackError()) {
expect(err, err.name).toBeInstanceOf(StackError);
expect(err, err.name).toBeInstanceOf(Error);
}
});

test('each class exposes the same code as an instance property and a static', () => {
const statics = [
StackValidationError,
StackMigrationError,
StackPermissionError,
StackNotFoundError,
StackConflictError,
StackVersionConflictError,
StackQueryError,
StackSchemaDriftError,
StackPayloadTooLargeError,
];
const instances = everyStackError();
for (const [i, cls] of statics.entries()) {
expect(instances[i].code, cls.name).toBe(cls.code);
}
});

test('codes are distinct, so a code identifies exactly one class', () => {
const codes = everyStackError().map((e) => e.code);
expect(new Set(codes).size).toBe(codes.length);
});

test('a version conflict is a sibling of a plain conflict, never a subtype', () => {
const version = new StackVersionConflictError('mismatch', '1hk153x0a00b', 3, 5);
expect(version).not.toBeInstanceOf(StackConflictError);
expect(new StackConflictError('blocked')).not.toBeInstanceOf(StackVersionConflictError);
});

test('a schema drift is a sibling of a plain conflict, never a subtype', () => {
const drift = new StackSchemaDriftError(NOTE_V1, [{ path: 'text', message: 'type changed' }]);
expect(drift).not.toBeInstanceOf(StackConflictError);
});

test('errors with no wire representation stay outside the taxonomy', () => {
expect(new IdGenerationError('clock went backwards')).not.toBeInstanceOf(StackError);
expect(new InvalidDidError('malformed did:key')).not.toBeInstanceOf(StackError);
expect(new Error('ordinary bug')).not.toBeInstanceOf(StackError);
});

test('errors thrown by real operations are catchable as StackError', async () => {
await expect(stack.create(NOTE_V1, { text: 42 })).rejects.toBeInstanceOf(StackError);
await expect(stack.get('1hk153x0a00b')).resolves.toBeNull();
await expect(stack.update('1hk153x0a00b', { text: 'x' })).rejects.toBeInstanceOf(StackError);
});
});
6 changes: 4 additions & 2 deletions packages/wire-types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@
"scripts": {
"prepublishOnly": "pnpm run build",
"build": "tsc -p tsconfig.build.json",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint src"
"lint": "eslint src tests"
},
"dependencies": {
"@haverstack/core": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.5.0"
"typescript": "^5.5.0",
"vitest": "^2.0.0"
}
}
Loading
Loading