From 28af1679ebfedc535406dc48674385b87363f088 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 20:00:56 +0000 Subject: [PATCH] feat(core): add StackError root with instance-level code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014iKbdWJzDFtBzp1Jg7BTS1 --- docs/spec/wire-format.md | 10 +- packages/core/src/index.ts | 2 + packages/core/src/stack.ts | 64 +++++++++-- packages/core/tests/stack.test.ts | 77 ++++++++++++- packages/wire-types/package.json | 6 +- packages/wire-types/src/index.ts | 105 +++++------------- packages/wire-types/tests/errors.test.ts | 133 +++++++++++++++++++++++ packages/wire-types/tsconfig.build.json | 3 +- packages/wire-types/tsconfig.json | 2 +- packages/wire-types/vitest.config.ts | 13 +++ pnpm-lock.yaml | 3 + 11 files changed, 323 insertions(+), 95 deletions(-) create mode 100644 packages/wire-types/tests/errors.test.ts create mode 100644 packages/wire-types/vitest.config.ts diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index a6f8150..5b27cb3 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -50,6 +50,12 @@ Authorization: Bearer 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: @@ -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`. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 518040a..31d43b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,7 @@ export { Stack, ScopedStack, + StackError, StackValidationError, StackMigrationError, StackPermissionError, @@ -24,6 +25,7 @@ export { assertQueryCapabilities, } from './stack.js'; export type { + StackErrorCode, StackClient, CreateRecordOptions, StackOptions, diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index a58373f..daa0688 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -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'), @@ -161,8 +199,9 @@ 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'; @@ -170,8 +209,9 @@ export class StackMigrationError extends Error { } /** 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'; @@ -179,8 +219,9 @@ export class StackPermissionError extends Error { } /** 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'; @@ -188,8 +229,9 @@ export class StackNotFoundError extends Error { } /** 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'; @@ -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, @@ -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'; @@ -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'; @@ -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[], diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index 56688bb..5e6107f 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -1,8 +1,10 @@ import { describe, test, expect, beforeEach, vi } from 'vitest'; import { Stack, + StackError, StackValidationError, StackMigrationError, + StackPermissionError, StackNotFoundError, StackConflictError, StackVersionConflictError, @@ -10,7 +12,8 @@ import { 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'; @@ -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); + }); +}); diff --git a/packages/wire-types/package.json b/packages/wire-types/package.json index 5b883f5..41900a7 100644 --- a/packages/wire-types/package.json +++ b/packages/wire-types/package.json @@ -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" } } diff --git a/packages/wire-types/src/index.ts b/packages/wire-types/src/index.ts index 1831329..82eeb15 100644 --- a/packages/wire-types/src/index.ts +++ b/packages/wire-types/src/index.ts @@ -6,8 +6,10 @@ import type { Permission, ValidationError, SchemaDriftViolation, + StackErrorCode, } from '@haverstack/core'; import { + StackError, StackValidationError, StackPermissionError, StackNotFoundError, @@ -117,16 +119,12 @@ export function parseDate(val: unknown): Date | undefined { // authoritative discriminator; status is a transport hint. See // docs/spec/wire-format.md § Error responses. -export type WireErrorCode = - | 'bad_request' - | 'permission' - | 'not_found' - | 'conflict' - | 'version_conflict' - | 'validation' - | 'migration' - | 'schema_drift' - | 'payload_too_large'; +/** + * Alias of core's StackErrorCode: the vocabulary belongs with the classes + * that carry it, and StackError.code is typed by it, so re-declaring the + * union here would be a second copy to drift. + */ +export type WireErrorCode = StackErrorCode; export type WireError = { error: { @@ -211,81 +209,28 @@ export function isWireError(body: unknown): body is WireError { /** * Convert a thrown core error into its wire response. Used by server - * implementations. Returns null for errors outside the core taxonomy — - * callers fall back to their own generic error handling. + * implementations. Returns null for anything that isn't a StackError — + * callers fall back to their own generic error handling, so an ordinary bug + * stays a bare 500 rather than being dressed as a protocol error. */ export function serializeError(err: unknown): { status: number; body: WireError } | null { + if (!(err instanceof StackError)) return null; + const error: WireError['error'] = { code: err.code, message: err.message }; + // The three classes carrying structured payload still need instanceof: a + // literal `code` doesn't narrow a class type to its subclass in TypeScript. + // Order-independent, since these are leaves with no subtype relation. if (err instanceof StackValidationError) { - return { - status: WIRE_ERROR_STATUS.validation, - body: { error: { code: 'validation', message: err.message, details: err.errors } }, - }; - } - if (err instanceof StackPermissionError) { - return { - status: WIRE_ERROR_STATUS.permission, - body: { error: { code: 'permission', message: err.message } }, - }; - } - if (err instanceof StackNotFoundError) { - return { - status: WIRE_ERROR_STATUS.not_found, - body: { error: { code: 'not_found', message: err.message } }, - }; - } - if (err instanceof StackVersionConflictError) { - return { - status: WIRE_ERROR_STATUS.version_conflict, - body: { - error: { - code: 'version_conflict', - message: err.message, - versionConflict: { - recordId: err.recordId, - expectedVersion: err.expectedVersion, - actualVersion: err.actualVersion, - }, - }, - }, - }; - } - if (err instanceof StackConflictError) { - return { - status: WIRE_ERROR_STATUS.conflict, - body: { error: { code: 'conflict', message: err.message } }, - }; - } - if (err instanceof StackQueryError) { - return { - status: WIRE_ERROR_STATUS.bad_request, - body: { error: { code: 'bad_request', message: err.message } }, - }; - } - if (err instanceof StackMigrationError) { - return { - status: WIRE_ERROR_STATUS.migration, - body: { error: { code: 'migration', message: err.message } }, - }; - } - if (err instanceof StackSchemaDriftError) { - return { - status: WIRE_ERROR_STATUS.schema_drift, - body: { - error: { - code: 'schema_drift', - message: err.message, - schemaDrift: { typeId: err.typeId, violations: err.violations }, - }, - }, - }; - } - if (err instanceof StackPayloadTooLargeError) { - return { - status: WIRE_ERROR_STATUS.payload_too_large, - body: { error: { code: 'payload_too_large', message: err.message } }, + error.details = err.errors; + } else if (err instanceof StackVersionConflictError) { + error.versionConflict = { + recordId: err.recordId, + expectedVersion: err.expectedVersion, + actualVersion: err.actualVersion, }; + } else if (err instanceof StackSchemaDriftError) { + error.schemaDrift = { typeId: err.typeId, violations: err.violations }; } - return null; + return { status: WIRE_ERROR_STATUS[err.code], body: { error } }; } /** Reconstruct the core error a WireError body describes. */ diff --git a/packages/wire-types/tests/errors.test.ts b/packages/wire-types/tests/errors.test.ts new file mode 100644 index 0000000..4b2a8dc --- /dev/null +++ b/packages/wire-types/tests/errors.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { + StackError, + StackValidationError, + StackMigrationError, + StackPermissionError, + StackNotFoundError, + StackConflictError, + StackVersionConflictError, + StackQueryError, + StackSchemaDriftError, + StackPayloadTooLargeError, +} from '@haverstack/core'; +import { + serializeError, + deserializeError, + errorForStatus, + isWireError, + WIRE_ERROR_STATUS, +} from '../src/index.js'; + +/** One instance per taxonomy member, with the class its code must rebuild. */ +const everyStackError = [ + new StackValidationError([{ path: 'title', message: 'expected string' }]), + new StackMigrationError('no migration path from note@1 to note@3'), + 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@1', [{ path: 'title', message: 'type changed' }]), + new StackPayloadTooLargeError('Attachment exceeds the 50000000-byte limit.'), +]; + +describe('serializeError', () => { + it('serializes every StackError to its declared code and status', () => { + for (const err of everyStackError) { + const wire = serializeError(err); + expect(wire, err.name).not.toBeNull(); + expect(wire!.body.error.code, err.name).toBe(err.code); + expect(wire!.status, err.name).toBe(WIRE_ERROR_STATUS[err.code]); + expect(wire!.body.error.message, err.name).toBe(err.message); + expect(isWireError(wire!.body), err.name).toBe(true); + } + }); + + it('returns null for errors outside the taxonomy', () => { + expect(serializeError(new Error('ordinary bug'))).toBeNull(); + expect(serializeError(new TypeError('undefined is not a function'))).toBeNull(); + expect(serializeError('not an error at all')).toBeNull(); + expect(serializeError(undefined)).toBeNull(); + }); + + it('keeps version_conflict at 412, distinct from conflict at 409', () => { + const conflict = serializeError(new StackConflictError('blocked'))!; + const version = serializeError( + new StackVersionConflictError('mismatch', '1hk153x0a00b', 3, 5), + )!; + expect(conflict.status).toBe(409); + expect(version.status).toBe(412); + expect(version.body.error.versionConflict).toEqual({ + recordId: '1hk153x0a00b', + expectedVersion: 3, + actualVersion: 5, + }); + }); + + it('carries structured payload only for the codes that define it', () => { + const validation = serializeError( + new StackValidationError([{ path: 'title', message: 'expected string' }]), + )!; + expect(validation.body.error.details).toEqual([{ path: 'title', message: 'expected string' }]); + expect(validation.body.error.versionConflict).toBeUndefined(); + expect(validation.body.error.schemaDrift).toBeUndefined(); + + const drift = serializeError( + new StackSchemaDriftError('note@1', [{ path: 'title', message: 'type changed' }]), + )!; + expect(drift.body.error.schemaDrift).toEqual({ + typeId: 'note@1', + violations: [{ path: 'title', message: 'type changed' }], + }); + expect(drift.body.error.details).toBeUndefined(); + + const permission = serializeError(new StackPermissionError())!; + expect(permission.body.error.details).toBeUndefined(); + expect(permission.body.error.versionConflict).toBeUndefined(); + expect(permission.body.error.schemaDrift).toBeUndefined(); + }); +}); + +describe('error round trip', () => { + it('rebuilds the same class and code for every taxonomy member', () => { + for (const err of everyStackError) { + const rebuilt = deserializeError(serializeError(err)!.body); + expect(rebuilt.constructor, err.name).toBe(err.constructor); + expect(rebuilt, err.name).toBeInstanceOf(StackError); + expect((rebuilt as StackError).code, err.name).toBe(err.code); + } + }); + + it('preserves the fields an ifVersion retry loop needs', () => { + const rebuilt = deserializeError( + serializeError(new StackVersionConflictError('mismatch', '1hk153x0a00b', 3, 5))!.body, + ); + expect(rebuilt).toBeInstanceOf(StackVersionConflictError); + const conflict = rebuilt as StackVersionConflictError; + expect(conflict.recordId).toBe('1hk153x0a00b'); + expect(conflict.expectedVersion).toBe(3); + expect(conflict.actualVersion).toBe(5); + }); +}); + +describe('errorForStatus', () => { + it('recovers a typed error from the unambiguous statuses', () => { + expect(errorForStatus(403, 'nope')).toBeInstanceOf(StackPermissionError); + expect(errorForStatus(404, 'nope')).toBeInstanceOf(StackNotFoundError); + expect(errorForStatus(412, 'nope')).toBeInstanceOf(StackVersionConflictError); + expect(errorForStatus(413, 'nope')).toBeInstanceOf(StackPayloadTooLargeError); + expect(errorForStatus(422, 'nope')).toBeInstanceOf(StackValidationError); + expect(errorForStatus(400, 'nope')).toBeInstanceOf(StackQueryError); + }); + + it('degrades a bodyless 409 to the generic conflict class', () => { + const err = errorForStatus(409, 'nope'); + expect(err).toBeInstanceOf(StackConflictError); + expect(err).not.toBeInstanceOf(StackSchemaDriftError); + }); + + it('refuses to infer a migration error from a bare 500', () => { + expect(errorForStatus(500, 'nope')).toBeNull(); + }); +}); diff --git a/packages/wire-types/tsconfig.build.json b/packages/wire-types/tsconfig.build.json index 299c632..57d0596 100644 --- a/packages/wire-types/tsconfig.build.json +++ b/packages/wire-types/tsconfig.build.json @@ -3,5 +3,6 @@ "compilerOptions": { "rootDir": "src", "noEmit": false - } + }, + "exclude": ["tests/**/*.ts"] } diff --git a/packages/wire-types/tsconfig.json b/packages/wire-types/tsconfig.json index fda526d..dbd41f4 100644 --- a/packages/wire-types/tsconfig.json +++ b/packages/wire-types/tsconfig.json @@ -4,5 +4,5 @@ "outDir": "dist", "noEmit": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts"] } diff --git a/packages/wire-types/vitest.config.ts b/packages/wire-types/vitest.config.ts new file mode 100644 index 0000000..e0ec2fb --- /dev/null +++ b/packages/wire-types/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), + }, + }, + test: { + environment: 'node', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e0f755..ea68072 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: typescript: specifier: ^5.5.0 version: 5.9.3 + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@22.19.17) packages: