diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index 91a0bfc5..0b9b930e 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -688,17 +688,6 @@ Pagination is cursor-based rather than offset-based, so it works consistently ac **Direction defaults follow SQL.** A sort that names a field runs `asc` unless `direction` says otherwise, whether the field is native or a content field, so `sort: { contentField: 'name' }` reads A→Z. A query with no sort answers by `createdAt`, newest first, the order a feed-style listing reads in. The two meet in one visible quirk: `sort: { field: 'createdAt' }` returns oldest first, the opposite of the unsorted default on the same field. No single default direction fits both a `name` and a `date`, and core cannot tell them apart at query time (a query can span Types), so the rule keys on nothing but whether a sort was named. **`Stack` resolves every default before an adapter sees the query** — the sort, its direction and the page size — so `queryRecords()` and `queryReadable()` receive a complete query (`AdapterQuery`, built by `resolveQuery()`) and no adapter carries a default of its own. Nor does an adapter see `presentAt`: `Stack` applies it to the results, and the type has no room for it. A query with no sort reaches an adapter as `{ field: 'createdAt', direction: 'desc' }`. -**Every list's order is stated here.** - -| Call | Order | -| ------------------------ | -------------------------------------------------------------------- | -| `query()`, no sort | `createdAt`, newest first | -| `query()` with a sort | `direction`, default `asc` | -| `getVersions()` | newest first ([Versioning](./versioning.md)) | -| `getJournal()` | oldest first ([Journal](./journal.md)) | -| `getAttachmentRecords()` | first-recorded order, oldest first ([Attachments](./attachments.md)) | -| `listTypeGrants()` | newest first | - **A sort names exactly one of a native column, a content field or a content field per Type**, and a request naming more than one, or none — a `direction` alone — is rejected with `StackBadRequestError` (**400**) rather than resolved in one direction or dropped. They are two members rather than one widened `field` because a content field may be named `version`, `createdAt` or `updatedAt`, and a `'content.publishedAt'` prefix would collide with the [path separator](#filter) a filter key is split on. **`sort.field` and `sort.direction` are validated, not merely typed.** The `'asc' | 'desc'` and three-field types are a compile-time contract only; a request arriving over the wire (a server mapping `?sort=`/`?direction=`) or from a delegated app supplies raw strings. A SQLite record adapter interpolates the direction into its `ORDER BY`, so `Stack.query()`/`ScopedStack.query()` reject a field or direction outside the enumerated set with `StackBadRequestError` (**400**, `bad_request`) before it reaches an adapter — the same posture as a malformed cursor. The value never reaches SQL as anything but one of the two keywords. **Every `limit` is a positive integer** — on `query()`, `getVersions()` and `getJournal()` alike — and anything else is refused the same way, since the adapters would read it differently: a negative one drops entries from a JavaScript slice and empties or lifts a SQL `LIMIT`. @@ -711,6 +700,19 @@ Pagination is cursor-based rather than offset-based, so it works consistently ac A caller that genuinely needs a count follows `cursor` to exhaustion and counts what it receives, which is the only number that was ever true for that requester anyway. +#### List order + +**A list starts at the end its reader needs first, and a paged list's cursor walks away from that end.** Every list's order is stated here, each with the reader it serves; a sorted query is the case where the caller names that end itself. + +| Call | Order | Why | +| ------------------------ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `query()`, no sort | `createdAt`, newest first | The order a feed-style listing reads in. | +| `query()` with a sort | `direction`, default `asc` | The caller names the end it wants. | +| `getVersions()` | newest first, `beforeVersion` pages back | A restore point is almost always a recent one ([Versioning § Version history](./versioning.md#version-history)). | +| `getJournal()` | oldest first, `afterSeq` pages forward | A log is tailed from the last `seq` seen and replayed in order; a backward cursor cannot catch up ([Journal § Reading it](./journal.md#reading-it)). | +| `getAttachmentRecords()` | first-recorded order, oldest first | `records[0]` is the record that establishes the `mimeType` ([Attachments](./attachments.md)). | +| `listTypeGrants()` | newest first | A `query()` over `_grant` Records with no sort, so it reads in that default. | + ### Sorting by a content field `sort.contentField` orders by a **top-level scalar** content field — an article listing by `publishedAt`, a contact list by `name`. Without it, a consumer wanting a bounded page in a meaningful order has to read the whole matched set and sort it in memory, a cost that grows with the Stack while the page stays the same size. Content filtering already reaches into `content`; this closes the asymmetry for ordering. diff --git a/docs/spec/journal.md b/docs/spec/journal.md index d6a6bd23..a7a265cd 100644 --- a/docs/spec/journal.md +++ b/docs/spec/journal.md @@ -100,7 +100,7 @@ A purged record's id is therefore a pointer to nothing, wherever it survives — ## Reading it -- `stack.getJournal(recordId, { afterSeq?, limit? })` — the log, oldest first. +- `stack.getJournal(recordId, { afterSeq?, limit? })` — the log, oldest first, since its reader tails it from the last `seq` it saw or replays it in the order it happened, and a backward cursor can only reach further into the past. See [Data model § List order](./data-model.md#list-order). **A record that does not exist is `StackNotFoundError`, never an empty log.** A purged record is gone, so it is the same refusal. diff --git a/docs/spec/versioning.md b/docs/spec/versioning.md index 90f41008..6672e029 100644 --- a/docs/spec/versioning.md +++ b/docs/spec/versioning.md @@ -39,7 +39,7 @@ type RecordVersion = { **API surface:** -- `stack.getVersions(recordId, query?)` — retrieve version history, **newest first** on every adapter (the same order [`GET /records/:id/versions`](./wire-format.md#versions) answers in), so the restore point just before the current version comes first and a page of history starts at the useful end. [`getJournal()`](./journal.md) runs the other way: an append-only log tailed forward from a cursor reads oldest first. +- `stack.getVersions(recordId, query?)` — retrieve version history, **newest first** on every adapter (the same order [`GET /records/:id/versions`](./wire-format.md#versions) answers in), since a reader choosing a restore point almost always wants a recent one: `getVersions(id, { limit: n })` is the last `n`. See [Data model § List order](./data-model.md#list-order). `query` is a `VersionsQuery` — `beforeVersion` (exclusive: versions strictly older than it) and `limit` — in the shape of the journal's `afterSeq` and `limit`. Omitting both reads every version, as omitting them on `getJournal()` reads the whole log. Paging walks every version exactly once: send the last version of a page as the next `beforeVersion`. diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index 1a4b0164..e0fcb097 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -476,7 +476,7 @@ POST /records/:id/restore/:version — restore a version (creates new version Both `GET` endpoints require the requester to hold the same mutate-surface authorization as a write to the record (write access, or owner/creator, or a Group's admin) — **not** plain read access; a read-only requester gets `403`. Every requester who passes that gate gets the same body. See [Versioning & deletion](./versioning.md#history-access) for the rationale. -**The list answers an envelope, `{ "versions": [...], "cursor": ... }`, paged like [the journal](#journal) but newest first.** `limit` bounds the page and `beforeVersion` is exclusive. `cursor` is the only end-of-history signal: a server MAY answer a page shorter than the `limit` asked for, so a short page must not be read as an exhausted history. It carries the `version` to send back as `beforeVersion`, and is `null` once nothing follows. `APIAdapter.getVersions()` follows it to the end when no `limit` is given, so the library contract that omitting `limit` reads every version survives whatever page size a server picks. Neither param has a default, and `@haverstack/core/wire` exports `parseVersionsParams()` to decode them. +**The list answers an envelope, `{ "versions": [...], "cursor": ... }`, newest first ([Data model § List order](./data-model.md#list-order)).** `limit` bounds the page and `beforeVersion` is exclusive. `cursor` is the only end-of-history signal: a server MAY answer a page shorter than the `limit` asked for, so a short page must not be read as an exhausted history. It carries the `version` to send back as `beforeVersion`, and is `null` once nothing follows. `APIAdapter.getVersions()` follows it to the end when no `limit` is given, so the library contract that omitting `limit` reads every version survives whatever page size a server picks. Neither param has a default, and `@haverstack/core/wire` exports `parseVersionsParams()` to decode them. **A snapshot body carries no `associations`, `permissions`, `parentId` or `unlistedAt`** — no version has ever captured any of the four (see [Versioning § Version history](./versioning.md#version-history)). `WireVersion` has no such field, so a server that emits one is writing a key every client drops. diff --git a/packages/core/tests/wire-request.test.ts b/packages/core/tests/wire-request.test.ts index 44e4681b..092119bd 100644 --- a/packages/core/tests/wire-request.test.ts +++ b/packages/core/tests/wire-request.test.ts @@ -4,6 +4,7 @@ import { parseQueryBody, parseChangeParams, parseJournalParams, + parseVersionsParams, parseGetRecordParams, parseDeleteParams, parseDownloadParams, @@ -520,7 +521,7 @@ describe('parseJournalParams', () => { }); }); - test('accepts zero for either, which assertValidJournalQuery also allows', () => { + test('accepts zero for either, leaving the window to assertValidJournalQuery', () => { expect(parseJournalParams(journal('/records/r1/journal?afterSeq=0&limit=0'))).toEqual({ afterSeq: 0, limit: 0, @@ -536,6 +537,44 @@ describe('parseJournalParams', () => { }); }); +describe('parseVersionsParams', () => { + const versions = (qs: string) => new URL(`https://s.example/records/r1/versions${qs}`); + + test('reads an empty query as every version, imposing no default page size', () => { + expect(parseVersionsParams(versions(''))).toEqual({}); + }); + + test('round-trips a window', () => { + expect(parseVersionsParams(versions('?beforeVersion=4&limit=10'))).toEqual({ + beforeVersion: 4, + limit: 10, + }); + }); + + test('an unrecognized param is refused rather than ignored', () => { + expect(() => parseVersionsParams(versions('?afterVersion=4'))).toThrow(/Unknown query param/); + }); + + test('a repeated param is refused', () => { + expect(() => parseVersionsParams(versions('?beforeVersion=4&beforeVersion=9'))).toThrow( + /Repeated query param/, + ); + }); + + test('accepts zero for either, leaving the window to assertValidVersionsQuery', () => { + expect(parseVersionsParams(versions('?beforeVersion=0&limit=0'))).toEqual({ + beforeVersion: 0, + limit: 0, + }); + }); + + test('refuses a value that is not a bare non-negative integer', () => { + for (const qs of ['?limit=-1', '?limit=1.5', '?beforeVersion=-1', '?beforeVersion=1e3']) { + expect(() => parseVersionsParams(versions(qs))).toThrow(StackBadRequestError); + } + }); +}); + describe('parseDate', () => { test('decodes an ISO string and reports anything else as undefined', () => { expect(parseDate('2024-06-15T12:00:00.000Z')?.toISOString()).toBe('2024-06-15T12:00:00.000Z');