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
19 changes: 15 additions & 4 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ type StackType = {

`POST /types` (see [Types](#types-1) under the wire format) applies the same check server-side, so the wire path can't silently replace a Type either.

**Type cache.** `Stack` caches every Type it fetches or defines in memory, keyed by versioned `id` — populated by `getType()`, `defineType()`, and `listTypes()`. Since a Type's schema is immutable once defined (a legal schema change always gets a new `id` via a version bump; see schema drift above), the cache is never invalidated, only added to — `create()`, `update()`, and `restoreVersion()` validate against the cached entry instead of re-fetching the Type on every write. Over the API adapter this removes a `GET /types/:id` round trip from every write after a type's first use in the process. `listTypes()` refreshes the cache wholesale, which is the explicit way to pick up a `name`-only rename made by another writer (the one field `defineType()` permits changing without a version bump) since the cache otherwise has no way to learn of it.

**Type compatibility:** Structural/duck-typed — a Type is **read-compatible** with a required schema if, for every required field, the candidate declares that same field as required, at a read-compatible kind. Array and object fields recurse: their `items`/`properties` must themselves be read-compatible. This licenses _consuming_ Records, not writing them — a consumer writing through a "compatible" view still has to validate against the candidate's full schema (its other required fields, which compatibility checking never inspects).

**Two distinct relations, easy to conflate:** schema drift detection (above) answers _"may this schema replace that one under the same `id`?"_ — evolution legality. Type compatibility (below) answers _"may a consumer expecting this shape read Records of that Type?"_ — read compatibility. They deliberately disagree on `text`/`string`: read-compatible (both are strings at the value level) but **not** evolution-legal (changing a field's declared `kind` is drift, even to a read-compatible one) — a stored `kind: 'string'` field silently becoming `kind: 'text'` is exactly the kind of change a version bump should surface, even though every existing reader could still consume the value.
Expand Down Expand Up @@ -760,6 +762,7 @@ type AdapterCapabilities = {
fullTextSearch: boolean;
contentFieldQuery: boolean;
sortableFields: string[];
maxAttachmentBytes: number | null; // upload size ceiling, or null = unbounded
};
```

Expand All @@ -772,6 +775,8 @@ type AdapterCapabilities = {
- **sql.js adapter** (`record-adapter-sqljs`, browser-only) — same query support as the native adapter, but full-text search via FTS4 (the sql.js WASM build's dialect)
- **API adapter** — capabilities determined by the server; declared in a discovery endpoint

Local, embedded adapters (JSON, native SQLite, sql.js) declare `maxAttachmentBytes: null` — nothing at the storage layer imposes a ceiling. Only a server behind the API adapter enforces one, since it's the only adapter transporting attachment bytes over a connection with its own limits.

---

## Deletion
Expand Down Expand Up @@ -833,7 +838,8 @@ GET /.well-known/stack
"capabilities": {
"fullTextSearch": true,
"contentFieldQuery": true,
"sortableFields": ["createdAt", "updatedAt", "version"]
"sortableFields": ["createdAt", "updatedAt", "version"],
"maxAttachmentBytes": 52428800
}
}
```
Expand Down Expand Up @@ -917,6 +923,7 @@ POST /records/:id/migrate — commit a migration (change typeId + content tog
?hasAttachment=
?attachmentFileId=
?relatedTo=
?relatedToLabel= (only meaningful alongside ?relatedTo; narrows to that label)
?search=
?sort=createdAt|updatedAt|version
?direction=asc|desc
Expand All @@ -927,6 +934,8 @@ POST /records/:id/migrate — commit a migration (change typeId + content tog

`GET /records` covers all native field queries and is usable from a browser or simple HTTP client without a JSON body. `POST /records/query` is a superset — it accepts the full `Query` object as a JSON body and additionally supports `content` field filtering. A server that declares `contentFieldQuery: false` in discovery does not support the POST query endpoint.

**Filters gated by a capability fail loudly, not silently.** A `content` filter has no representation in `GET /records`' query params, and `search` behaves however the server does with an unsupported param — so `APIAdapter` checks `capabilities.contentFieldQuery`/`capabilities.fullTextSearch` before dispatching and throws `APIAdapterCapabilityError` locally, without sending a request, when the corresponding filter is used against a server that hasn't declared the capability. The alternative — degrading to an unfiltered (or partially filtered) result and returning it as if it were the requested query — is a superset silently presented as the filtered result, which is worse than an error for anything that trusts the filter (dedup checks, existence checks, selection-sensitive logic).

`PATCH /records/:id` accepts a partial content object. Omitted fields retain their current values. A field set to `null` is removed (RFC 7396 / JSON Merge Patch). Associations and permissions are managed via their own endpoints.

**Optimistic concurrency:** `PATCH`, `DELETE`, `POST .../undelete`, `POST .../restore/:version`, and the association/permission endpoints below all accept an optional `If-Match` header:
Expand Down Expand Up @@ -984,10 +993,12 @@ GET /records/:id/associations?kind=attachment
GET /records/:id/associations?kind=relationship
GET /records/:id/associations?label=avatar — filter by label across all kinds
POST /records/:id/associations — add an association
DELETE /records/:id/associations — remove an association (by body)
POST /records/:id/associations/delete — remove an association (by body)
```

`POST`/`DELETE` accept the same optional `If-Match` precondition described under [Records](#records).
Removing an association is a `POST` to a `/delete` sub-path, not a `DELETE` with a body — `DELETE` request bodies have no defined semantics (RFC 9110 §9.3.5: "has no generally defined semantics; ... might lead some implementations to reject the request"), and this protocol is meant to be implemented behind arbitrary proxies, gateways, and localhost setups that may drop or reject them. The discriminant (which association to remove) travels as a JSON body either way, so the endpoint is a `POST` like every other body-carrying mutation.

Both endpoints accept the same optional `If-Match` precondition described under [Records](#records).

Response shape is consistent regardless of kind:

Expand Down Expand Up @@ -1034,7 +1045,7 @@ Authorization: Bearer <token>

`POST /attachments` requires the same authorization as creating an `_attachment@1` record: `401` for anonymous/missing tokens, `403` if the requester lacks a `create` grant on `_attachment@1`. A bytes upload is only meaningful as a precursor to metadata creation, so the two share one authorization requirement.

Returns `413 Request Entity Too Large` if the payload exceeds the server's configured limit (default 50 MB, controlled by `MAX_ATTACHMENT_BYTES`).
Returns `413 Request Entity Too Large` if the payload exceeds the server's configured limit (default 50 MB, controlled by `MAX_ATTACHMENT_BYTES`). The same limit is exposed ahead of time as `maxAttachmentBytes` in [discovery](#discovery), so clients can pre-check and surface it in UI rather than learning the ceiling only by uploading the whole payload and getting a 413 back.

The SDK's `Stack.putAttachment()` and `ScopedStack.putAttachment()` perform both steps automatically. Direct HTTP callers must create the `_attachment@1` record separately if metadata is needed.

Expand Down
46 changes: 44 additions & 2 deletions packages/adapter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ export class APIAdapterConnectionError extends APIAdapterError {
}
}

/**
* Thrown locally — before any request is sent — when a query uses a filter
* the connected server has declared it doesn't support (`capabilities.
* contentFieldQuery` or `capabilities.fullTextSearch` is false). Servers
* without these capabilities have no endpoint that honors the filter at
* all, so silently sending it anyway would return an unfiltered superset
* presented as the filtered result (see #56) rather than erroring.
*/
export class APIAdapterCapabilityError extends APIAdapterError {
constructor(
public readonly capability: keyof AdapterCapabilities,
message: string,
) {
super(message);
this.name = 'APIAdapterCapabilityError';
}
}

// -------------------------------------------------------
// Discovery response shape
// -------------------------------------------------------
Expand Down Expand Up @@ -160,7 +178,10 @@ const buildQueryParams = (query: StackQuery): URLSearchParams => {
if (f.tags) for (const tag of f.tags) p.append('tag', tag);
if (f.hasAttachment) p.set('hasAttachment', f.hasAttachment);
if (f.attachmentFileId) p.set('attachmentFileId', f.attachmentFileId);
if (f.relatedTo) p.set('relatedTo', f.relatedTo.recordId);
if (f.relatedTo) {
p.set('relatedTo', f.relatedTo.recordId);
if (f.relatedTo.label) p.set('relatedToLabel', f.relatedTo.label);
}
if (f.search) p.set('search', f.search);
if (f.includeDeleted) p.set('includeDeleted', 'true');
if (query.sort?.field) p.set('sort', query.sort.field);
Expand Down Expand Up @@ -396,6 +417,25 @@ export class APIAdapter implements StackAdapter {
total: number | null;
};

// Fail loudly rather than silently widening the result set: a server
// that hasn't declared these capabilities has no endpoint that honors
// the corresponding filter, so sending it anyway would drop the filter
// without signal (see #56).
if (query.filter?.content && !this.capabilities.contentFieldQuery) {
throw new APIAdapterCapabilityError(
'contentFieldQuery',
'Query uses filter.content, but this server does not declare the contentFieldQuery ' +
'capability — there is no endpoint that would honor it.',
);
}
if (query.filter?.search && !this.capabilities.fullTextSearch) {
throw new APIAdapterCapabilityError(
'fullTextSearch',
'Query uses filter.search, but this server does not declare the fullTextSearch ' +
'capability — there is no endpoint that would honor it.',
);
}

let raw: Envelope;
if (this.capabilities.contentFieldQuery) {
// POST /records/query supports the full query shape including content field filters
Expand Down Expand Up @@ -433,7 +473,9 @@ export class APIAdapter implements StackAdapter {
association: Association,
opts: { expectedVersion?: number } = {},
): Promise<void> {
await this.request<void>('DELETE', `/records/${id}/associations`, association, {
// POST, not DELETE — a DELETE body has no defined semantics (RFC 9110
// §9.3.5) and proxies/gateways are free to drop or reject it. See #56.
await this.request<void>('POST', `/records/${id}/associations/delete`, association, {
ifMatch: opts.expectedVersion,
});
}
Expand Down
77 changes: 74 additions & 3 deletions packages/adapter-api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
APIAdapterAuthError,
APIAdapterConnectionError,
APIAdapterError,
APIAdapterCapabilityError,
} from '../src/index.js';
import type { StackRecord, StackType, RecordVersion, Association } from '@haverstack/core';
import {
Expand Down Expand Up @@ -31,6 +32,7 @@ const DISCOVERY = {
fullTextSearch: true,
contentFieldQuery: true,
sortableFields: ['createdAt', 'updatedAt', 'version'],
maxAttachmentBytes: 52428800,
},
};

Expand Down Expand Up @@ -117,6 +119,7 @@ describe('open', () => {
expect(adapter.capabilities.fullTextSearch).toBe(true);
expect(adapter.capabilities.contentFieldQuery).toBe(true);
expect(adapter.capabilities.sortableFields).toEqual(['createdAt', 'updatedAt', 'version']);
expect(adapter.capabilities.maxAttachmentBytes).toBe(52428800);
});

test('populates ownerEntityId from discovery response', async () => {
Expand Down Expand Up @@ -488,6 +491,64 @@ describe('queryRecords', () => {
const [url] = mockFetch.mock.lastCall as [string];
expect(url).toContain('parentId=null');
});

test('GET params include relatedToLabel alongside relatedTo (#56)', async () => {
const limitedDiscovery = {
...DISCOVERY,
capabilities: { ...DISCOVERY.capabilities, contentFieldQuery: false },
};
const adapter = await openAdapter(limitedDiscovery);
mockFetch.mockResolvedValueOnce(jsonResponse(queryEnvelope));
await adapter.queryRecords({ filter: { relatedTo: { recordId: 'rec-1', label: 'author' } } });
const [url] = mockFetch.mock.lastCall as [string];
expect(url).toContain('relatedTo=rec-1');
expect(url).toContain('relatedToLabel=author');
});

test('GET params omit relatedToLabel when no label is given', async () => {
const limitedDiscovery = {
...DISCOVERY,
capabilities: { ...DISCOVERY.capabilities, contentFieldQuery: false },
};
const adapter = await openAdapter(limitedDiscovery);
mockFetch.mockResolvedValueOnce(jsonResponse(queryEnvelope));
await adapter.queryRecords({ filter: { relatedTo: { recordId: 'rec-1' } } });
const [url] = mockFetch.mock.lastCall as [string];
expect(url).toContain('relatedTo=rec-1');
expect(url).not.toContain('relatedToLabel');
});

test('throws APIAdapterCapabilityError for filter.content without contentFieldQuery (#56)', async () => {
const limitedDiscovery = {
...DISCOVERY,
capabilities: { ...DISCOVERY.capabilities, contentFieldQuery: false },
};
const adapter = await openAdapter(limitedDiscovery);
await expect(adapter.queryRecords({ filter: { content: { slug: 'hello' } } })).rejects.toThrow(
APIAdapterCapabilityError,
);
expect(mockFetch).toHaveBeenCalledTimes(1); // only the discovery call — no request sent
});

test('throws APIAdapterCapabilityError for filter.search without fullTextSearch (#56)', async () => {
const limitedDiscovery = {
...DISCOVERY,
capabilities: { ...DISCOVERY.capabilities, fullTextSearch: false },
};
const adapter = await openAdapter(limitedDiscovery);
await expect(adapter.queryRecords({ filter: { search: 'hello' } })).rejects.toThrow(
APIAdapterCapabilityError,
);
expect(mockFetch).toHaveBeenCalledTimes(1); // only the discovery call — no request sent
});

test('does not throw for filter.content when contentFieldQuery is true', async () => {
const adapter = await openAdapter();
mockFetch.mockResolvedValueOnce(jsonResponse(queryEnvelope));
await expect(
adapter.queryRecords({ filter: { content: { slug: 'hello' } } }),
).resolves.toBeDefined();
});
});

// -------------------------------------------------------
Expand Down Expand Up @@ -517,16 +578,26 @@ describe('associate', () => {
});

describe('dissociate', () => {
test('sends DELETE /records/:id/associations', async () => {
// POST, not DELETE — a DELETE body has no defined wire semantics (#56).
test('sends POST /records/:id/associations/delete', async () => {
const adapter = await openAdapter();
mockFetch.mockResolvedValueOnce(noContent());
const assoc: Association = { kind: 'tag', label: 'starred' };
await adapter.dissociate('rec-abc123', assoc);
expect(mockFetch).toHaveBeenLastCalledWith(
`${BASE_URL}/records/rec-abc123/associations`,
expect.objectContaining({ method: 'DELETE' }),
`${BASE_URL}/records/rec-abc123/associations/delete`,
expect.objectContaining({ method: 'POST' }),
);
});

test('sends the association as JSON body', async () => {
const adapter = await openAdapter();
mockFetch.mockResolvedValueOnce(noContent());
const assoc: Association = { kind: 'tag', label: 'starred' };
await adapter.dissociate('rec-abc123', assoc);
const [, init] = mockFetch.mock.lastCall as [string, RequestInit];
expect(JSON.parse(init.body as string)).toEqual(assoc);
});
});

// -------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions packages/adapter-api/tests/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const DISCOVERY = {
fullTextSearch: true,
contentFieldQuery: true,
sortableFields: ['createdAt', 'updatedAt', 'version'],
maxAttachmentBytes: 52428800,
},
};

Expand Down
9 changes: 6 additions & 3 deletions packages/conformance-fixtures/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,12 @@ export const associateFixtures: ConformanceFixture<Record<string, unknown>, unde
export const dissociateFixtures: ConformanceFixture<Record<string, unknown>, undefined>[] = [
{
name: 'dissociate-tag',
description: 'DELETE /records/:id/associations removes an association and bumps version.',
method: 'DELETE',
path: '/records/rec-1/associations',
description:
'POST /records/:id/associations/delete removes an association and bumps version. POST, ' +
'not DELETE — a DELETE request body has no defined semantics (RFC 9110 §9.3.5) and is a ' +
'portability landmine for proxies/gateways that drop or reject it (#56).',
method: 'POST',
path: '/records/rec-1/associations/delete',
requestBody: { kind: 'tag', label: 'starred' },
responseStatus: 204,
},
Expand Down
Loading
Loading