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
24 changes: 13 additions & 11 deletions docs/spec/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/spec/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion docs/spec/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
41 changes: 40 additions & 1 deletion packages/core/tests/wire-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
parseQueryBody,
parseChangeParams,
parseJournalParams,
parseVersionsParams,
parseGetRecordParams,
parseDeleteParams,
parseDownloadParams,
Expand Down Expand Up @@ -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,
Expand All @@ -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');
Expand Down
Loading