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
41 changes: 31 additions & 10 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -680,18 +680,39 @@ Authorization: Bearer <token>

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

```
Expand Down
28 changes: 25 additions & 3 deletions packages/adapter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Error> {
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<T>(
method: string,
path: string,
Expand All @@ -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<T>;
}
Expand All @@ -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());
}

Expand All @@ -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<Record<string, unknown>>;
}

Expand Down
119 changes: 119 additions & 0 deletions packages/adapter-api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
);
});
});
62 changes: 62 additions & 0 deletions packages/adapter-api/tests/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<unknown> => {
if (fixture.method === 'POST' && fixture.path === '/records') {
const body = fixture.requestBody as Record<string, unknown>;
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<string, unknown>,
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<string, unknown>,
);
}
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);
});
}
});
Loading
Loading