diff --git a/docs/spec.md b/docs/spec.md index ea155f2..19da773 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -578,7 +578,7 @@ type Query = { }; ``` -Pagination is cursor-based rather than offset-based, so it works consistently across adapters and doesn't drift when records are inserted mid-page. +Pagination is cursor-based rather than offset-based, so it works consistently across adapters and doesn't drift when records are inserted mid-page. A `cursor` that can't be decoded — an unknown sort field, a non-numeric sort value, or a corrupted/malformed blob — is a structurally malformed request, not a content-validation failure: adapters throw `StackQueryError`, which maps to **400** (code `bad_request`), not 422 and not a bare 500. ### Adapter capabilities @@ -680,18 +680,39 @@ Authorization: Bearer Standard HTTP status codes are used throughout: -| Status | Meaning | When | -| ------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **400** | Bad request | Structurally malformed request — missing a required field, unparseable query parameter, invalid JSON, a malformed or reserved-prefix record `id` | -| **401** | Unauthorized | Missing or invalid bearer token | -| **403** | Forbidden | `StackPermissionError` — record exists but the requester lacks access | -| **404** | Not found | `StackNotFoundError` — record or version does not exist | -| **409** | Conflict | `StackConflictError` — operation blocked by a constraint violation (e.g. deleting an attachment still referenced by a record, a client-supplied `id` that already exists) | -| **413** | Request entity too large | Attachment upload exceeds the server's size limit | -| **422** | Unprocessable entity | `StackValidationError` — request is syntactically valid but content fails schema validation (e.g. a required field has the wrong type) | +| Status | Meaning | When | +| ------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **400** | Bad request | `StackQueryError` (code `bad_request`) where the library can identify the malformed input itself — e.g. an undecodable pagination cursor; otherwise a lower-level parse failure (missing required field, invalid JSON) with no core-taxonomy equivalent | +| **401** | Unauthorized | Missing or invalid bearer token | +| **403** | Forbidden | `StackPermissionError` — record exists but the requester lacks access | +| **404** | Not found | `StackNotFoundError` — record or version does not exist | +| **409** | Conflict | `StackConflictError` — operation blocked by a constraint violation (e.g. deleting an attachment still referenced by a record, a client-supplied `id` that already exists) | +| **413** | Request entity too large | Attachment upload exceeds the server's size limit | +| **422** | Unprocessable entity | `StackValidationError` — request is syntactically valid but content fails schema validation (e.g. a required field has the wrong type) | +| **500** | Internal server error | Reserved for `StackMigrationError` (code `migration`) — migration-graph corruption. No current code path produces this over the wire (migration-graph errors are thrown during client-side migration registration, never as a server response); the mapping exists for forward compatibility only. **Not** used as a generic catch-all: an unrelated server crash is a plain 500 with no wire error body, and clients must not infer `StackMigrationError` from status 500 alone (see below). | 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. +#### Wire error body + +Every non-2xx response whose failure maps to the core error taxonomy carries a JSON body of the shape: + +```json +{ + "error": { + "code": "permission" | "not_found" | "conflict" | "validation" | "migration" | "bad_request", + "message": "human-readable description", + "details": [ { "path": "title", "message": "expected string, got number" } ] + } +} +``` + +`details` is only present for `code: "validation"`, carrying `StackValidationError.errors`. + +`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. When a response has no parseable wire error body (a foreign or legacy server, or a proxy that strips bodies but preserves status), `APIAdapter` falls back to reconstructing from status alone for the unambiguous statuses above (400/403/404/409/422) — **not** for 500, since that status is a generic "unhandled server exception" signal and would misclassify ordinary server bugs as `StackMigrationError`. When neither the body nor the status yields a typed error, `APIAdapter` throws its own generic `APIAdapterError`. + +This mapping is pinned by the shared conformance fixtures (`@haverstack/conformance-fixtures`) so `APIAdapter` and any server implementation can't drift on it independently. + ### Records ``` diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index 71b222b..01a5af3 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -29,6 +29,7 @@ import type { FileId, } from '@haverstack/core'; import type { WireRecord, WireType, WireVersion } from '@haverstack/wire-types'; +import { isWireError, deserializeError, errorForStatus } from '@haverstack/wire-types'; // ------------------------------------------------------- // Public option types @@ -229,6 +230,27 @@ export class APIAdapter implements StackAdapter { // Request helpers // ------------------------------------------------------- + /** + * Build the typed error for a failed response. `code` in the wire error + * body is authoritative and reconstructs the corresponding core error + * class (StackPermissionError, StackNotFoundError, StackConflictError, + * StackValidationError, StackQueryError, StackMigrationError). Falls back + * to status-based reconstruction when the body is missing or foreign + * (unrecognized shape), then to a generic APIAdapterError when neither + * yields an unambiguous mapping. + */ + private async errorForResponse(res: Response, method: string, path: string): Promise { + let body: unknown; + try { + body = await res.json(); + } catch { + body = undefined; + } + if (isWireError(body)) return deserializeError(body); + const message = `HTTP ${res.status}: ${method} ${path}`; + return errorForStatus(res.status, message) ?? new APIAdapterError(message, res.status); + } + private async request( method: string, path: string, @@ -253,7 +275,7 @@ export class APIAdapter implements StackAdapter { if (res.status === 401) throw new APIAdapterAuthError(); if (res.status === 404 && nullOn404) return null as T; - if (!res.ok) throw new APIAdapterError(`HTTP ${res.status}: ${method} ${path}`, res.status); + if (!res.ok) throw await this.errorForResponse(res, method, path); if (res.status === 204) return undefined as T; return res.json() as Promise; } @@ -271,7 +293,7 @@ export class APIAdapter implements StackAdapter { } if (res.status === 401) throw new APIAdapterAuthError(); - if (!res.ok) throw new APIAdapterError(`HTTP ${res.status}: GET ${path}`, res.status); + if (!res.ok) throw await this.errorForResponse(res, 'GET', path); return new Uint8Array(await res.arrayBuffer()); } @@ -296,7 +318,7 @@ export class APIAdapter implements StackAdapter { } if (res.status === 401) throw new APIAdapterAuthError(); - if (!res.ok) throw new APIAdapterError(`HTTP ${res.status}: POST ${path}`, res.status); + if (!res.ok) throw await this.errorForResponse(res, 'POST', path); return res.json() as Promise>; } diff --git a/packages/adapter-api/tests/api.test.ts b/packages/adapter-api/tests/api.test.ts index 5f392ee..3a89f4d 100644 --- a/packages/adapter-api/tests/api.test.ts +++ b/packages/adapter-api/tests/api.test.ts @@ -6,6 +6,14 @@ import { APIAdapterError, } from '../src/index.js'; import type { StackRecord, StackType, RecordVersion, Association } from '@haverstack/core'; +import { + StackPermissionError, + StackNotFoundError, + StackConflictError, + StackValidationError, + StackQueryError, + StackMigrationError, +} from '@haverstack/core'; // ------------------------------------------------------- // Test fixtures @@ -766,3 +774,114 @@ describe('error propagation on subsequent requests', () => { await expect(adapter.listTypes()).rejects.toThrow(APIAdapterError); }); }); + +// ------------------------------------------------------- +// Error taxonomy reconstruction (#53) +// ------------------------------------------------------- + +describe('error taxonomy reconstruction', () => { + test('reconstructs StackPermissionError from a 403 wire error body', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ error: { code: 'permission', message: 'Permission denied' } }, 403), + ); + await expect(adapter.patchContent('rec-1', { title: 'x' })).rejects.toThrow( + StackPermissionError, + ); + }); + + test('reconstructs StackNotFoundError from a 404 wire error body', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ error: { code: 'not_found', message: 'Record "rec-1" not found.' } }, 404), + ); + await expect(adapter.patchContent('rec-1', { title: 'x' })).rejects.toThrow(StackNotFoundError); + }); + + test('reconstructs StackConflictError from a 409 wire error body', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ error: { code: 'conflict', message: 'Record "rec-1" already exists.' } }, 409), + ); + const record: StackRecord = { + id: 'rec-1', + typeId: 'com.example/note@1', + createdAt: new Date(), + updatedAt: new Date(), + content: {}, + version: 1, + }; + await expect(adapter.createRecord(record)).rejects.toThrow(StackConflictError); + }); + + test('reconstructs StackValidationError from a 422 wire error body, preserving details as .errors', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce( + jsonResponse( + { + error: { + code: 'validation', + message: 'Content validation failed', + details: [{ path: 'title', message: 'expected string, got number' }], + }, + }, + 422, + ), + ); + let caught: unknown; + try { + await adapter.patchContent('rec-1', { title: 42 }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(StackValidationError); + expect((caught as StackValidationError).errors).toEqual([ + { path: 'title', message: 'expected string, got number' }, + ]); + }); + + test('reconstructs StackQueryError from a 400 wire error body', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ error: { code: 'bad_request', message: 'Invalid cursor' } }, 400), + ); + await expect(adapter.queryRecords({ cursor: 'garbage' })).rejects.toThrow(StackQueryError); + }); + + test('falls back to status-based reconstruction when the body is not a wire error', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 403 })); + await expect(adapter.patchContent('rec-1', { title: 'x' })).rejects.toThrow( + StackPermissionError, + ); + }); + + test('falls back to generic APIAdapterError for a status with no unambiguous code', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 418 })); + await expect(adapter.patchContent('rec-1', { title: 'x' })).rejects.toThrow(APIAdapterError); + }); + + test('does not reconstruct StackMigrationError from a bare 500 status', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 })); + let caught: unknown; + try { + await adapter.patchContent('rec-1', { title: 'x' }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(APIAdapterError); + expect(caught).not.toBeInstanceOf(StackMigrationError); + }); + + test('reconstructs StackMigrationError from an explicit 500 wire error body', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce( + jsonResponse({ error: { code: 'migration', message: 'Migration graph corrupted' } }, 500), + ); + await expect(adapter.patchContent('rec-1', { title: 'x' })).rejects.toThrow( + StackMigrationError, + ); + }); +}); diff --git a/packages/adapter-api/tests/conformance.test.ts b/packages/adapter-api/tests/conformance.test.ts index 17b7301..4cb949d 100644 --- a/packages/adapter-api/tests/conformance.test.ts +++ b/packages/adapter-api/tests/conformance.test.ts @@ -18,8 +18,16 @@ import { setPermissionsFixtures, restoreVersionFixtures, commitMigrationFixtures, + errorResponseFixtures, } from '@haverstack/conformance-fixtures'; import type { Association } from '@haverstack/core'; +import { + StackPermissionError, + StackNotFoundError, + StackConflictError, + StackValidationError, + StackQueryError, +} from '@haverstack/core'; const BASE_URL = 'https://stack.example.com'; @@ -217,3 +225,57 @@ describe('commitMigration fixtures', () => { }); } }); + +// ------------------------------------------------------- +// Error responses (#53) — pins that APIAdapter reconstructs the documented +// core error class from each fixture's wire error body. +// ------------------------------------------------------- + +const ERROR_CLASS_FOR_CODE = { + permission: StackPermissionError, + not_found: StackNotFoundError, + conflict: StackConflictError, + validation: StackValidationError, + bad_request: StackQueryError, +} as const; + +describe('error response fixtures', () => { + for (const fixture of errorResponseFixtures) { + test(fixture.name, async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus)); + + const dispatch = (): Promise => { + if (fixture.method === 'POST' && fixture.path === '/records') { + const body = fixture.requestBody as Record; + return adapter.createRecord({ + id: body.id as string, + typeId: body.typeId as string, + createdAt: new Date(body.createdAt as string), + updatedAt: new Date(body.updatedAt as string), + content: body.content as Record, + version: body.version as number, + }); + } + if (fixture.method === 'POST' && fixture.path === '/records/query') { + return adapter.queryRecords(fixture.requestBody as never); + } + if (fixture.method === 'PATCH') { + return adapter.patchContent( + idFromPath(fixture.path), + fixture.requestBody as Record, + ); + } + throw new Error(`no dispatch wired for error fixture "${fixture.name}"`); + }; + + const code = (fixture.responseBody as { error: { code: keyof typeof ERROR_CLASS_FOR_CODE } }) + .error.code; + await expect(dispatch()).rejects.toThrow(ERROR_CLASS_FOR_CODE[code]); + + const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${fixture.path}`); + expect(init.method).toBe(fixture.method); + }); + } +}); diff --git a/packages/adapter-api/vitest.config.ts b/packages/adapter-api/vitest.config.ts index 19d747f..3c6ef71 100644 --- a/packages/adapter-api/vitest.config.ts +++ b/packages/adapter-api/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ resolve: { alias: { '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), + '@haverstack/wire-types': resolve(__dirname, '../wire-types/src/index.ts'), '@haverstack/conformance-fixtures': resolve( __dirname, '../conformance-fixtures/src/index.ts', diff --git a/packages/conformance-fixtures/src/index.ts b/packages/conformance-fixtures/src/index.ts index b47f9e9..0c61cf6 100644 --- a/packages/conformance-fixtures/src/index.ts +++ b/packages/conformance-fixtures/src/index.ts @@ -23,7 +23,7 @@ * prior state — that's the consumer's test setup. */ -import type { WireRecord } from '@haverstack/wire-types'; +import type { WireRecord, WireError } from '@haverstack/wire-types'; export type WireMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; @@ -262,6 +262,102 @@ export const commitMigrationFixtures: ConformanceFixture< }, ]; +// ------------------------------------------------------- +// Error responses +// ------------------------------------------------------- +// +// Pins the wire error body contract (#53): every failed request returns +// { error: { code, message, details? } }, with `code` as the authoritative +// discriminator — status is a transport hint. Each fixture assumes the +// server is already in the state its description names (a missing record, +// a requester without a grant, ...); these fixtures pin the error shape a +// server must produce and APIAdapter must reconstruct, not how a server +// gets into that state. + +export const errorResponseFixtures: ConformanceFixture[] = [ + { + name: 'error-permission-denied', + description: + 'A write from a requester without the required grant on the record returns 403 with ' + + 'code "permission" — reconstructed client-side as StackPermissionError.', + method: 'PATCH', + path: '/records/rec-1', + requestBody: { title: 'New title' }, + responseStatus: 403, + responseBody: { error: { code: 'permission', message: 'Permission denied' } }, + }, + { + name: 'error-not-found', + description: + 'A write (e.g. PATCH) against a record id that does not exist — deleted or never ' + + 'created — returns 404 with code "not_found", reconstructed as StackNotFoundError. ' + + '(GET /records/:id is deliberately excluded here: APIAdapter treats a 404 there as ' + + '"absent", resolving to null rather than throwing — see nullOn404 in getRecord.) Must be ' + + 'indistinguishable from the "exists but forbidden" case only in error *shape*, never in ' + + 'status/code (see #51 anti-oracle rule).', + method: 'PATCH', + path: '/records/rec-does-not-exist', + requestBody: { title: 'New title' }, + responseStatus: 404, + responseBody: { + error: { code: 'not_found', message: 'Record "rec-does-not-exist" not found.' }, + }, + }, + { + name: 'error-conflict-duplicate-id', + description: + 'POST /records with a client-supplied id that already exists in the stack returns 409 ' + + 'with code "conflict" — reconstructed as StackConflictError, never a silent overwrite.', + method: 'POST', + path: '/records', + requestBody: { + id: 'rec-1', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { title: 'Duplicate' }, + version: 1, + }, + responseStatus: 409, + responseBody: { error: { code: 'conflict', message: 'Record "rec-1" already exists.' } }, + }, + { + name: 'error-validation-failed', + description: + 'PATCH content that fails the target type schema returns 422 with code "validation" and ' + + 'field-level details — reconstructed as StackValidationError, with `details` populating ' + + '`.errors`.', + method: 'PATCH', + path: '/records/rec-1', + requestBody: { title: 42 }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [{ path: 'title', message: 'expected string, got number' }], + }, + }, + }, + { + name: 'error-bad-request-malformed-cursor', + description: + 'A query with an undecodable pagination cursor returns 400 with code "bad_request" — a ' + + 'structurally malformed request, distinct from a 422 content-validation failure. ' + + 'Reconstructed as StackQueryError (see the cursor codec fix in record-adapter-sqljs, #53).', + method: 'POST', + path: '/records/query', + requestBody: { cursor: 'not-a-valid-cursor' }, + responseStatus: 400, + responseBody: { + error: { + code: 'bad_request', + message: 'Invalid cursor: unknown sort field "not-a-valid-cursor"', + }, + }, + }, +]; + // ------------------------------------------------------- // All fixtures // ------------------------------------------------------- @@ -277,4 +373,5 @@ export const allConformanceFixtures: ConformanceFixture[] = [ ...setPermissionsFixtures, ...restoreVersionFixtures, ...commitMigrationFixtures, + ...errorResponseFixtures, ]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0660d33..8e7ad56 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,7 @@ export { StackPermissionError, StackNotFoundError, StackConflictError, + StackQueryError, } from './stack.js'; export type { StackClient, diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index f220c6f..d7de5d9 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -85,6 +85,7 @@ export type DefineTypeOptions = { }; export class StackValidationError extends Error { + static readonly code = 'validation' as const; constructor(public readonly errors: ValidationError[]) { super( `Content validation failed:\n` + errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'), @@ -94,6 +95,7 @@ export class StackValidationError extends Error { } export class StackMigrationError extends Error { + static readonly code = 'migration' as const; constructor(message: string) { super(message); this.name = 'StackMigrationError'; @@ -102,6 +104,7 @@ export class StackMigrationError extends Error { /** Thrown by ScopedStack when a requester lacks permission for the operation. */ export class StackPermissionError extends Error { + static readonly code = 'permission' as const; constructor(message = 'Permission denied') { super(message); this.name = 'StackPermissionError'; @@ -110,6 +113,7 @@ export class StackPermissionError extends Error { /** Thrown when a record (or specific version) does not exist. */ export class StackNotFoundError extends Error { + static readonly code = 'not_found' as const; constructor(message: string) { super(message); this.name = 'StackNotFoundError'; @@ -118,12 +122,27 @@ 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 { + static readonly code = 'conflict' as const; constructor(message: string) { super(message); this.name = 'StackConflictError'; } } +/** + * Thrown when a request is structurally malformed — not a content-validation + * failure, but input the adapter/server can't even interpret (e.g. an + * undecodable pagination cursor). Distinct from StackValidationError, which + * means the request was well-formed but content failed schema validation. + */ +export class StackQueryError extends Error { + static readonly code = 'bad_request' as const; + constructor(message: string) { + super(message); + this.name = 'StackQueryError'; + } +} + // ------------------------------------------------------- // Record ID validation // ------------------------------------------------------- diff --git a/packages/record-adapter-sqljs/src/index.ts b/packages/record-adapter-sqljs/src/index.ts index f362a5c..a44c1bb 100644 --- a/packages/record-adapter-sqljs/src/index.ts +++ b/packages/record-adapter-sqljs/src/index.ts @@ -31,7 +31,7 @@ import type { Permission, AdapterCapabilities, } from '@haverstack/core'; -import { applyMergePatch } from '@haverstack/core'; +import { applyMergePatch, StackQueryError } from '@haverstack/core'; // ------------------------------------------------------- // Types @@ -399,13 +399,16 @@ const buildWhereClause = (query: StackQuery): { sql: string; params: unknown[] } parts.length === 3 ? (parts as [string, string, string]) : (['createdAt', parts[0], parts[1]] as [string, string, string]); + if (cursorField === undefined || cursorValue === undefined || cursorId === undefined) { + throw new StackQueryError(`Invalid cursor: malformed "${query.cursor}"`); + } const validSortFields: SortField[] = ['createdAt', 'updatedAt', 'version']; if (!validSortFields.includes(cursorField as SortField)) { - throw new Error(`Invalid cursor: unknown sort field "${cursorField}"`); + throw new StackQueryError(`Invalid cursor: unknown sort field "${cursorField}"`); } const numericValue = Number(cursorValue); if (!isFinite(numericValue)) { - throw new Error(`Invalid cursor: non-numeric sort value`); + throw new StackQueryError(`Invalid cursor: non-numeric sort value`); } const col = getSortColumn(cursorField as SortField); const sortDir = query.sort?.direction ?? 'desc'; diff --git a/packages/record-adapter-sqljs/tests/record.test.ts b/packages/record-adapter-sqljs/tests/record.test.ts index 97127d1..708398c 100644 --- a/packages/record-adapter-sqljs/tests/record.test.ts +++ b/packages/record-adapter-sqljs/tests/record.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { SQLiteRecordAdapter } from '../src/index.js'; +import { StackQueryError } from '@haverstack/core'; import type { StackRecord } from '@haverstack/core'; // ------------------------------------------------------- @@ -602,6 +603,29 @@ describe('records — queries', () => { expect(allIds.size).toBe(5); }); + test('malformed cursor with unknown sort field throws StackQueryError', async () => { + const adapter = await initAdapter(); + await adapter.createRecord(makeRecord({ id: 'r1' })); + const badCursor = Buffer.from('bogusField|123|r1').toString('base64'); + await expect(adapter.queryRecords({ cursor: badCursor })).rejects.toThrow(StackQueryError); + }); + + test('malformed cursor with non-numeric sort value throws StackQueryError', async () => { + const adapter = await initAdapter(); + await adapter.createRecord(makeRecord({ id: 'r1' })); + const badCursor = Buffer.from('createdAt|not-a-number|r1').toString('base64'); + await expect(adapter.queryRecords({ cursor: badCursor })).rejects.toThrow(StackQueryError); + }); + + test('corrupted/garbage cursor throws StackQueryError instead of a generic Error', async () => { + const adapter = await initAdapter(); + await adapter.createRecord(makeRecord({ id: 'r1' })); + // Not base64-decodable into a "field|value|id" or "value|id" shape at all. + await expect(adapter.queryRecords({ cursor: '!!!not-a-cursor!!!' })).rejects.toThrow( + StackQueryError, + ); + }); + test('sort by createdAt descending (default)', async () => { const adapter = await initAdapter(); await adapter.createRecord(makeRecord({ id: 'r1', createdAt: new Date(1000) })); diff --git a/packages/wire-types/src/index.ts b/packages/wire-types/src/index.ts index de5e813..6ca0aaa 100644 --- a/packages/wire-types/src/index.ts +++ b/packages/wire-types/src/index.ts @@ -4,6 +4,15 @@ import type { RecordVersion, Association, Permission, + ValidationError, +} from '@haverstack/core'; +import { + StackValidationError, + StackPermissionError, + StackNotFoundError, + StackConflictError, + StackMigrationError, + StackQueryError, } from '@haverstack/core'; export type WireRecord = { @@ -91,3 +100,152 @@ export function parseDate(val: unknown): Date | undefined { const d = new Date(val); return isNaN(d.getTime()) ? undefined : d; } + +// ------------------------------------------------------- +// Error responses +// ------------------------------------------------------- +// +// core defines a typed error taxonomy (StackValidationError, +// StackPermissionError, StackNotFoundError, StackConflictError, +// StackMigrationError, StackQueryError), each with a static `code`. The +// wire error body below is the round-trip contract: a server serializes a +// caught core error to { status, body } via serializeError(), and +// APIAdapter reconstructs the same class via deserializeError(). `code` is +// the authoritative discriminator — status is a transport hint that +// proxies and legacy servers are more likely to preserve than a body. + +export type WireErrorCode = + | 'bad_request' + | 'permission' + | 'not_found' + | 'conflict' + | 'validation' + | 'migration'; + +export type WireError = { + error: { + code: WireErrorCode; + message: string; + /** Field-level validation failures. Only present for code: 'validation'. */ + details?: ValidationError[]; + }; +}; + +/** Canonical HTTP status for each wire error code. */ +export const WIRE_ERROR_STATUS: Record = { + bad_request: 400, + permission: 403, + not_found: 404, + conflict: 409, + validation: 422, + /** + * No core code path currently produces a StackMigrationError over the + * wire (migration-graph errors are thrown during client-side migration + * registration, never as a server response) — this entry exists so a + * future server-side migration-graph check has a defined status to use. + */ + migration: 500, +}; + +/** + * Statuses that unambiguously imply a wire error code, for reconstructing + * an error from status alone when a response has no parseable wire error + * body (a foreign/legacy server, or a proxy that strips bodies but + * preserves status). 500 is deliberately excluded: it's the generic + * "unhandled server exception" status, not a reliable signal that a + * StackMigrationError specifically occurred — status-only reconstruction + * would misclassify ordinary server bugs. + */ +export const STATUS_TO_CODE: Partial> = { + 400: 'bad_request', + 403: 'permission', + 404: 'not_found', + 409: 'conflict', + 422: 'validation', +}; + +const KNOWN_CODES = new Set(Object.keys(WIRE_ERROR_STATUS)); + +/** Type guard: does this parsed JSON body look like a WireError? */ +export function isWireError(body: unknown): body is WireError { + if (!body || typeof body !== 'object') return false; + const err = (body as Record).error; + if (!err || typeof err !== 'object') return false; + const code = (err as Record).code; + const message = (err as Record).message; + return typeof code === 'string' && KNOWN_CODES.has(code) && typeof message === 'string'; +} + +/** + * 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. + */ +export function serializeError(err: unknown): { status: number; body: WireError } | null { + 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 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 } }, + }; + } + return null; +} + +/** Reconstruct the core error a WireError body describes. */ +export function deserializeError(body: WireError): Error { + const { code, message, details } = body.error; + switch (code) { + case 'validation': + return new StackValidationError(details ?? []); + case 'permission': + return new StackPermissionError(message); + case 'not_found': + return new StackNotFoundError(message); + case 'conflict': + return new StackConflictError(message); + case 'bad_request': + return new StackQueryError(message); + case 'migration': + return new StackMigrationError(message); + } +} + +/** + * Reconstruct a core error from an HTTP status alone (no usable wire error + * body). Returns null for statuses with no unambiguous code — callers + * should fall back to a generic adapter-level error. + */ +export function errorForStatus(status: number, message: string): Error | null { + const code = STATUS_TO_CODE[status]; + return code ? deserializeError({ error: { code, message } }) : null; +}