diff --git a/.changeset/typed-collections.md b/.changeset/typed-collections.md new file mode 100644 index 00000000..a4ef7995 --- /dev/null +++ b/.changeset/typed-collections.md @@ -0,0 +1,8 @@ +--- +'@haverstack/core': minor +'@haverstack/commons': patch +--- + +Typed access goes through collections. `client.collection(handle)` returns a `Collection` typed by the handle's content, with one signature per verb — `get`, `query`, `subscribe`, `create`, `mutate`, `patchContent`, `associate`, `dissociate`, `amendAssociations`, `grantAccess`, `revokeAccess`, `amendAccess`, `delete`, `undelete` and `restoreVersion` — every one returning a typed record without a re-read. A collection spans the handle's whole family and migrates each record in memory to the handle's version. A record stored at a newer version is a misfit: `query()` reports it in `misfits` and keeps the page and cursor, `get()` throws `MisfitError`, and `subscribe()` carries it as `misfit`. A content write to a record at an older version commits its migration as a write of its own first. `collection()` refuses a handle whose `migratesFrom` has no migration passed to `Stack.open()`. + +The handle-first overloads of `get`, `query`, `create`, `mutate`, `patchContent` and `subscribe` are removed, with `TypedRecord`, `TypedQuery`, `TypedChange`, `TypedChangeSet` and `TypedSubscribeOptions`. `PatchOf` is removed: a collection's patch type is `ContentPatch`, derived from its content type, and `StoredContent` is what its reads return. diff --git a/README.md b/README.md index ff71e81b..81813265 100644 --- a/README.md +++ b/README.md @@ -148,17 +148,20 @@ const Note = typeHandle({ }); await stack.defineType(Note); +// Typed access to the Note family +const notes = stack.collection(Note); + // Create a record -const note = await stack.create(Note, { +const note = await notes.create({ text: 'Hello, Haverstack!', title: 'My first note', }); // Update its content (partial merge — only changed fields needed) -await stack.patchContent(Note, note.id, { title: 'Updated title' }); +await notes.patchContent(note.id, { title: 'Updated title' }); // Read it back, typed: `content.text` is a string -const same = await stack.get(Note, note.id); +const same = await notes.get(note.id); // Tag it await stack.associate(note.id, [{ kind: 'tag', label: 'favourite' }]); @@ -172,7 +175,7 @@ await stack.mutate(note.id, { // Query. With no filter, query() returns every record you can read, from every // app and system types included, so filter by typeId, baseId or appId to get your own. -const notes = await stack.query({ +const favourites = await stack.query({ filter: { typeId: 'com.example.myapp/note@1', tags: ['favourite'] }, sort: { field: 'createdAt', direction: 'desc' }, }); @@ -191,7 +194,8 @@ An app has two layers, because the stack draws a line between them: Migrations belong to neither. They are passed to `Stack.open()` on **every startup**, so the chain is complete before the first read. ```ts -import { Stack, migration, typeHandle, type StackClient } from '@haverstack/core'; +import { Stack, migration, typeHandle } from '@haverstack/core'; +import type { Collection, ContentOf, StackClient } from '@haverstack/core'; const NoteV1 = typeHandle({ id: 'com.example.myapp/note@1', @@ -216,15 +220,18 @@ export const noteMigrations = [ // Data layer: whoever the stack lets in. export class Notes { - constructor(private readonly client: StackClient) {} + private readonly notes: Collection>; + constructor(client: StackClient) { + this.notes = client.collection(Note); + } add(text: string) { - return this.client.create(Note, { text, pinned: false }); + return this.notes.create({ text, pinned: false }); } pin(id: string) { - return this.client.patchContent(Note, id, { pinned: true }); + return this.notes.patchContent(id, { pinned: true }); } list() { - return this.client.query(Note); + return this.notes.query(); } } diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index ac569eaa..f47dbac8 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -358,7 +358,7 @@ A field's kind is read-compatible with a required kind per this table (row = req `string` and `text` are mutually read-compatible — the distinction is presentation/indexing intent — and each also accepts an enum. A required enum takes the subset rule below. Every other kind requires an exact match; notably `date` is not compatible with `string`, since `date` carries a parse/validity guarantee a plain string doesn't. -**An enum reads as a string, and a required enum accepts only a narrower enum** (⊆ above): a candidate `enum` whose `values` are all in the required list. A consumer expecting an enum has handled exactly the values it lists, so a `string` or `text` candidate, which can hold anything, is refused, as is an enum listing a value the consumer does not. The check is as of the Type it is given: an enum may [gain values in place](#additive-evolution-within-a-version), or become a plain `string`, so a candidate compatible today can later hold a value the consumer does not list. A consumer handles such a value, as a [typed read](#type-handles) does. +**An enum reads as a string, and a required enum accepts only a narrower enum** (⊆ above): a candidate `enum` whose `values` are all in the required list. A consumer expecting an enum has handled exactly the values it lists, so a `string` or `text` candidate, which can hold anything, is refused, as is an enum listing a value the consumer does not. The check is as of the Type it is given: an enum may [gain values in place](#additive-evolution-within-a-version), or become a plain `string`, so a candidate compatible today can later hold a value the consumer does not list. A consumer handles such a value, as a [collection](#type-handles) does. Apps that care about semantics filter by exact `typeId`; apps that want flexibility (e.g. "any Type with a required `text` field") use `isCompatible()` — see `packages/core/src/schema.ts` for the authoritative implementation and `packages/core/tests/schema.test.ts` for its behavior under nesting and the string/text equivalence. @@ -389,7 +389,7 @@ The migration registry is **per-stack-instance** and lives in memory — differe - **`get()` and `query()` return Records exactly as stored** — their own `typeId` and `content`, with no implicit migration. This is what makes `query()` a reliable way to find every record of a family regardless of migration state. - **`presentAt: 'latest'`** — an explicit opt-in on both `get()` and `query()` that applies the registered migration chain in memory before returning. Nothing is written to disk; this is a read-time convenience, never a persistence mechanism. It is a property of the app instance that registered the chain, so it never travels: a server [rejects a request carrying `presentAt`](./wire-format.md#records) rather than dropping it. Throws `StackMigrationError` when a matched Record's version can't be reconciled with what this app instance has registered (see stale-writer behavior below). -- **A content patch never migrates.** `mutate()` validates the merged content against the Record's _own current_ stored Type — never the latest — and writes back at the same `typeId`. An unrelated content edit can never fold an invisible schema rewrite into the same version-history entry. This is also why content read through `presentAt: 'latest'` is not writable back wholesale, and why [the content key is a patch](#mutations). +- **A content patch never migrates.** `mutate()` validates the merged content against the Record's _own current_ stored Type — never the latest — and writes back at the same `typeId`. An unrelated content edit can never fold an invisible schema rewrite into the same version-history entry. This is also why content read through `presentAt: 'latest'` is not writable back wholesale, and why [the content key is a patch](#mutations). A [collection](#type-handles)'s content write to a Record at an older version commits the migration as a write of its own first, then applies the patch. - **Path composition** — migrations between adjacent versions are automatically chained (v1→v2→v3), so apps only ever register one step at a time. - **`migrateAll("com.example.myapp/note")`** eagerly commits all pending migrations for a type family in one deliberate pass, taking each Record to the end of the registered chain rather than to a version the caller names — which is why it takes a `BaseId` and refuses a versioned `TypeId` — call it at app startup, or after a schema change. It sweeps soft-deleted and unlisted Records (`includeDeleted`/`includeUnlisted` are not caller options in either direction — see [Deletion](./versioning.md#deletion) and [Unlisted records](./unlisted.md)); the one narrower sweep is `{ sweep: 'listed' }`, for an installed app that can reach neither (see [App installs § Migrating an installed app's types](./apps.md#migrating-an-installed-apps-types)). It validates each migrated result against the target Type's schema before writing, and aborts immediately on the first validation failure (a buggy migration function is a bug to surface, not to paper over by skipping the offending records) — anything already committed earlier in the pass stays committed. Previous content is snapshotted to version history before each write. - **`commitMigration(id, toTypeId, content)`** is the single-record counterpart, changing one Record's `typeId` and `content` together in one step. Unlike `migrateAll()`, `content` here is supplied by the caller rather than produced by a registered `Migration` function — the client-side app that owns `toTypeId` computes it, and the library validates it against `toTypeId`'s schema exactly as `create()`/`mutate()` validate against a schema. This is what backs the wire's `POST /records/:id/migrate` (see [Wire format](./wire-format.md#records)). Under `ScopedStack` it is **owner-acting-alone**, matching `migrateAll()`'s own absence from `StackClient` — no grant or record-level `write` substitutes for it (see [Access control](./access-control.md#type-level-grants)) — except for an installed app within its own families (see [App installs](./apps.md#migrating-an-installed-apps-types)). Previous content and `typeId` are snapshotted to version history first, same as `migrateAll()`. @@ -402,22 +402,35 @@ The migration registry is **per-stack-instance** and lives in memory — differe ### Type handles -A schema written once as a literal gives the compiler everything it needs to type content. `typeHandle({ id, name, schema, migratesFrom })` returns a plain, frozen value carrying the fields `defineType()` takes — the `TypeId` (`id`), the display `name`, the `schema` and, for a version after the first, `migratesFrom` — plus its family (`baseId`). `migratesFrom` may be given as the previous version's handle or its `TypeId`, and is stored as the `TypeId`. `ContentOf` derives the content type a write supplies, `StoredContentOf` the one a read returns, and `PatchOf` the `contentPatch` type. A required field is present in `ContentOf`; every other field is optional. `PatchOf` makes every field optional and allows `null` only on a field that is not required, since a required field can be replaced but not removed. +A schema written once as a literal gives the compiler everything it needs to type content. `typeHandle({ id, name, schema, migratesFrom })` returns a plain, frozen value carrying the fields `defineType()` takes — the `TypeId` (`id`), the display `name`, the `schema` and, for a version after the first, `migratesFrom` — plus its family (`baseId`). `migratesFrom` may be given as the previous version's handle or its `TypeId`, and is stored as the `TypeId`. `ContentOf` derives the content type a write supplies, and `StoredContentOf` the one a read returns. A required field is present in `ContentOf`; every other field is optional. A [collection](#type-handles) derives the rest from its content type `C`: `StoredContent` is what its reads return, the same as `StoredContentOf`, and `ContentPatch` is the `contentPatch` it takes, which makes every field optional and allows `null` only on a field that is not required, since a required field can be replaced but not removed. A handle is a `DefineTypeOptions`, so `defineType(handle)` and a manifest's `types` take it as written, and `name` and `migratesFrom` are written once. It carries no migration function, which keeps it data: see [Type migrations](#type-migrations). A handle names exactly one version. A call that means the whole family takes `handle.baseId`, never `handle.id`. -`get()`, `query()`, `create()`, `mutate()`, `patchContent()` and `subscribe()` each have an overload taking the handle first. A typed read: +`client.collection(handle)` returns a `Collection` typed by the handle's content, `ContentOf`: every verb that addresses records of the family, each with one signature that takes and returns the handle's types — `get`, `query` and `subscribe`; `create`, `mutate`, `patchContent`, `associate`, `dissociate`, `amendAssociations`, `grantAccess`, `revokeAccess`, `amendAccess`, `delete`, `undelete` and `restoreVersion`. History (`getVersions()`, `getVersion()`, `getJournal()`) spans versions by nature and stays on `StackClient`, which is otherwise unchanged and remains the API for a Type known only at runtime. A collection is built on the public `StackClient` verbs, so it reaches nothing its client could not. The outer types (`Collection`, `CollectionRecord`, `CollectionPage`, `CollectionQuery`, `CollectionChange`) never flatten `C`, so an app that names its content with an `interface` sees that name. -- reads at [`presentAt: 'latest'`](#type-migrations), so the stale-writer error applies; -- throws `StackBadRequestError` unless the record's `typeId` is exactly the handle's `id`, so a record of another Type, or one whose family has moved past the handle's version, is refused rather than cast; -- types an `enum` field as its listed values or any other string (`'want' | 'reading' | 'finished' | UnlistedValue`). An enum may gain values, or become a plain `string`, within a version ([additive evolution](#additive-evolution-within-a-version)), so a reader older than the writer can meet any string there. Records are shared across apps that upgrade independently, so a closed union would promise what no handle can check; the reader handles a value it does not list, as a `default` branch does; -- sees live records only. `includeDeleted` is not offered and is refused at runtime, so a tombstone, whose `content` is `{}`, is never typed as the handle's content. A caller who wants tombstones uses the untyped read. +`collection()` refuses a handle whose `migratesFrom` has no migration passed to `Stack.open()`, with `StackMigrationError` naming the pair. It runs in the app's own process, which has to hold that migration; `defineType()` cannot ask, since an owner applying another app's manifest defines types whose migrations live in that app. -A typed `subscribe()` delivers only changes to records of exactly the handle's `id`, with `record`, when present, typed as the content. An event carries its record as stored, which a subscription cannot migrate, so other versions of the family are not delivered. A record that [`migrateAll()`](#type-migrations) moves off the handle's version still matches the filter by its previous `typeId`, and arrives without its `record`, as it does wherever the emitter cannot supply one; the typed `get()` then says why. The change filter takes every key but `typeId` and `baseId`. +**The scope is the handle's whole family**, for reads and subscriptions alike. Each record is read as stored and migrated in memory to the **handle's** version, one record at a time, through the opening `Stack`'s migrations — not through [`presentAt: 'latest'`](#type-migrations), which migrates to the newest version the instance has defined and throws for a whole page. A record of another family is not in the collection: `get()` answers `null` for it, as for a missing record or a tombstone. A collection reads live records only; `includeDeleted` is not offered and is refused at runtime, so a tombstone, whose `content` is `{}` under `ScopedStack`, is never typed as the handle's content. -A typed `query()` matches the handle's whole family, then applies the checks above to every record. A typed write is checked against the read the write already makes, before anything lands. A record of another family is not found. A record of the family stored at another `typeId` is refused with `StackBadRequestError`, since a patch is validated against the record's own stored Type and the result could not be typed. A typed write takes only the values the handle lists, in `ContentOf` and `PatchOf`; the stored schema's runtime validation decides what lands. A deleted record, or a stale `ifVersion`, is refused first, as the untyped write refuses it. +A read types an `enum` field as its listed values or any other string (`'want' | 'reading' | 'finished' | UnlistedValue`). An enum may gain values, or become a plain `string`, within a version ([additive evolution](#additive-evolution-within-a-version)), so a reader older than the writer can meet any string there. Records are shared across apps that upgrade independently, so a closed union would promise what no handle can check; the reader handles a value it does not list, as a `default` branch does. A write takes only the values the handle lists, in `ContentOf` and `ContentPatch`; the stored schema's runtime validation decides what lands. + +**A misfit is a record of the family the handle cannot represent**: one stored at a version newer than the handle's (`reason: 'newer-version'`), which cannot be migrated down. A misfit is reported, never thrown for a page: + +- `query()` returns `{ records, misfits, cursor }`. Misfits do not count as records, so a page can hold fewer than `limit` records with a cursor still set: `cursor === null` ends the results, a short page does not. The page and its cursor survive one record the app cannot read. +- `get()` is a read the app asked for by ID, so it throws `MisfitError`, carrying the `reason` and the record as stored. +- `subscribe()` delivers a change whose record does not fit with `misfit` in place of `record`. + +A record at an older version that no passed migration carries to the handle's is not a misfit: that is the app's setup, and a read of it throws `StackMigrationError`, as it throws whatever a failing migration function throws. A subscription cannot throw at its emitter, so such a change arrives without its `record`, as it does wherever the emitter cannot supply one, and `get()` says why. `MisfitError` is raised only in the app's process and has no wire representation, so it sits [outside the taxonomy](./wire-format.md#the-taxonomy-root). + +**A subscription covers the family.** A change's `record`, when present, is migrated to the handle's version; the change's own `typeId` stays the stored one, as `RecordChange.typeId` documents. A change whose record has `deletedAt` set carries neither `record` nor `misfit`, on either surface, and neither does a change that [migrates the record out](#type-migrations) of the family, which the filter matches by its previous `typeId`. The change filter takes every key but `typeId` and `baseId`. + +**Content filters and sorts apply to content as stored.** The adapter runs them before any record is migrated, so a field a migration renamed or derived is matched and ordered under its stored name: filtering an `@2` collection on a field `@1` spelled differently does not find `@1` records. `query()` takes neither `typeId` nor `presentAt`, which would change which records a page holds or the shape they arrive in, and drops either if an untyped caller passes it; `subscribe()` drops a filter's `typeId` the same way. + +**Every verb that returns a record returns it as `get()` would.** The write already returned the record, so narrowing it costs no round trip, and a `restoreVersion()` to a pre-migration snapshot reads back migrated, like any older record. A write is checked against the read it already makes, before anything lands: a record outside the family is refused with `StackNotFoundError`, the answer `get()` gives, and so is a `restoreVersion()` to a snapshot taken before the record migrated into the family, which is not a version of a record in the collection. A content write to a record stored at a newer version is refused with `MisfitError` before anything is written, since its patch would be validated against a schema the handle does not know. Any other write that leaves a misfit lands, then throws `MisfitError` carrying the stored record, so the app knows the write landed. A deleted record, or a stale `ifVersion`, is refused first, as the untyped write refuses it. + +**A content write to a record stored at an older version migrates it first, as its own write.** A patch is validated against the record's stored Type, and a [content patch never migrates](#type-migrations), so the collection commits the migration with `commitMigration()` and then applies the patch: two versions and two journal entries, `migrate` then `patch` — the history `migrateAll()` followed by the patch would leave. The caller's `ifVersion` fences the migration, since that is the state they read, and the patch is fenced to the version the migration produced, so a write landing in between fails the patch with `StackVersionConflictError`; the migration that landed is what `migrateAll()` would have done anyway. The same holds when the patch fails for any other reason, so a caller retrying after a failure reads the record again first: the `ifVersion` it held now names the state before the migration. A caller who may not commit the migration — anyone but the owner and an installed app within its own families — is refused with `StackPermissionError` naming the stored version, and nothing is written. The usual way a record reaches an older version is [restoring](./versioning.md#restore-semantics) a pre-migration snapshot, which keeps that snapshot's `typeId`, so a revert followed by an edit is an ordinary sequence. The derived types are a convenience over [runtime validation](#types), which stays the guarantee. A Type known only at runtime has no static shape and stays `Record`. diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index a5217dfc..e2c5f625 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -181,7 +181,7 @@ Empty is not an encoding of anything — nor is a literal `null`. Absence is `40 | **413** | Request entity too large | `StackPayloadTooLargeError` (code `payload_too_large`) — an attachment upload, a record body, or a PATCH body exceeds the server's size limit (see [Request size limits](#request-size-limits)). Unambiguous status (no other code shares 413), so status-only reconstruction recovers this class even without a parseable body. `putAttachment()` also pre-checks the declared `limits.attachmentBytes` ceiling client-side and throws the same class before sending, so this status is a backstop, not the only enforcement point | | **422** | Unprocessable entity | `StackValidationError` — request is syntactically valid but content fails schema validation (e.g. a required field has the wrong type) | | **503** | Service unavailable | `StackTimeoutError` (code `timeout`) — the server abandoned an operation for taking too long, in practice a full-text search (see [Bounding query cost](#bounding-query-cost)). Nothing was applied, and the request is worth retrying — which is why this is not `bad_request`: that code tells a client its request was malformed and retrying won't help. Status-only reconstruction deliberately does **not** map 503 to this class: a bodyless 503 is usually a load balancer or a restarting process, and reporting that as a timeout would tell an app its query was too expensive when the server never ran it | -| **500** | Internal server error | Reserved for `StackMigrationError` (code `migration`). Core throws it client-side — registering migrations, reading with `presentAt: 'latest'`, `migrateAll()` — and never from a server, so no response carries this code; it has a status because every code does. **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) | +| **500** | Internal server error | Reserved for `StackMigrationError` (code `migration`). Core throws it client-side — opening a stack with migrations it cannot walk, reading with `presentAt: 'latest'`, `migrateAll()`, `collection()` for a handle whose migration is missing and a collection's read of a record no migration reaches — and never from a server, so no response carries this code; it has a status because every code does. **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 covers a request that couldn't be parsed at all, or that is well-formed but addresses something that doesn't exist (an undefined `typeId`, a malformed TypeId, search text the engine cannot parse); a 422 means the server understood the request and what it addressed, but the content didn't satisfy the type schema. The distinguishing question is whether there was a schema to fail against: `StackValidationError.details` names the fields that broke, and an unknown type has no such detail to report — `Stack.create()` resolves the type and throws before the validation block runs for exactly this reason. @@ -221,7 +221,7 @@ The body parsers split errors the way [Records](#records) does for a create: a b ### The taxonomy root -Every class in the table above extends the abstract `StackError`, so `err instanceof StackError` answers the one question a server's error middleware asks first: is this a Stack-domain failure with a wire representation, or an ordinary bug that should surface as a bare 500? Membership is exactly that guarantee — a `StackError` always has a `code`, and every code has a status. Errors with no wire mapping (`IdGenerationError`, `InvalidDidError`, `InvalidAuthChallengeError`, `UseAfterCloseError`, `InvalidAdapterError`, `OwnerMismatchError`, `RelayScopeError`) stay outside the hierarchy for that reason. +Every class in the table above extends the abstract `StackError`, so `err instanceof StackError` answers the one question a server's error middleware asks first: is this a Stack-domain failure with a wire representation, or an ordinary bug that should surface as a bare 500? Membership is exactly that guarantee — a `StackError` always has a `code`, and every code has a status. Errors with no wire mapping (`IdGenerationError`, `InvalidDidError`, `InvalidAuthChallengeError`, `UseAfterCloseError`, `InvalidAdapterError`, `OwnerMismatchError`, `RelayScopeError`, `MisfitError`) stay outside the hierarchy for that reason. **The `Stack` prefix is reserved for members.** A class named `Stack…Error` extends `StackError`, and no error outside the hierarchy carries the prefix, so the name alone says whether a server can serialize it. diff --git a/packages/commons/README.md b/packages/commons/README.md index 3a0bc171..5b8328ec 100644 --- a/packages/commons/README.md +++ b/packages/commons/README.md @@ -32,15 +32,15 @@ import { defineCommonsTypes, NOTE, TASK } from '@haverstack/commons'; await defineCommonsTypes(stack, [NOTE, TASK]); // Each constant is a type handle, so content is typed from the schema. -const note = await stack.create(NOTE, { text: 'Hello, Haverstack!' }); -const tasks = await stack.query(TASK); // records: { content: { title: string; done: boolean; ... } }[] +const note = await stack.collection(NOTE).create({ text: 'Hello, Haverstack!' }); +const tasks = await stack.collection(TASK).query(); // records: { content: { title: string; done: boolean; ... } }[] ``` Each export (`NOTE`, `BOOKMARK`, `TASK`, `CONTACT`, `ARTICLE`, `PLACE`, `PAGE`, `IMAGE`, `POST`, `SITE`) is a [type handle](https://github.com/haverstack/core/blob/main/docs/spec/data-model.md#type-handles) — `{ id, baseId, name, schema }` — mirroring its type's `stack.defineType(...)` block in -[`docs/commons/`](https://github.com/haverstack/core/tree/main/docs/commons) exactly. Pass it to the typed overloads of `get`, `query`, `create`, `mutate`, -`patchContent` and `subscribe`; use `NOTE.baseId` for calls about the whole family. +[`docs/commons/`](https://github.com/haverstack/core/tree/main/docs/commons) exactly. Pass it to `stack.collection()` for typed access to its family; use `NOTE.baseId` for +untyped calls about the whole family. `defineCommonsTypes()` is a thin loop over `stack.defineType()` — calling it again with types already registered is the ordinary idempotent no-op path (see `defineType`'s own docs), so it's safe to call on every app startup. diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index 793aac8d..43713e74 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -10,9 +10,9 @@ * markdown, which is the drift the Schema Commons' governance process exists * to prevent. * - * Each constant is also a type handle, so `stack.create(NOTE, …)` and the - * other typed overloads derive content types from the same literal. See - * docs/spec/data-model.md § Type handles. + * Each constant is also a type handle, so `stack.collection(NOTE)` derives + * its content type from the same literal. See docs/spec/data-model.md + * § Type handles. * * Only Draft-status types are exported here. Proposed types (not yet backed * by a concrete intended writer) stay docs-only until they graduate. diff --git a/packages/commons/tests/index.test.ts b/packages/commons/tests/index.test.ts index ea3ecaa0..15b66115 100644 --- a/packages/commons/tests/index.test.ts +++ b/packages/commons/tests/index.test.ts @@ -118,13 +118,14 @@ describe('typed handles', () => { test('typed create and get round-trip through the constant', async () => { await defineCommonsTypes(stack, [TASK]); - const created = await stack.create(TASK, { title: 'Write tests', done: false }); + const tasks = stack.collection(TASK); + const created = await tasks.create({ title: 'Write tests', done: false }); expectTypeOf(created.content.done).toEqualTypeOf(); - const read = await stack.get(TASK, created.id); + const read = await tasks.get(created.id); expect(read?.content).toEqual({ title: 'Write tests', done: false }); // @ts-expect-error a required field is missing - await expect(stack.create(TASK, { title: 'No done flag' })).rejects.toThrow(); + await expect(tasks.create({ title: 'No done flag' })).rejects.toThrow(); }); }); diff --git a/packages/core/README.md b/packages/core/README.md index 26df516e..cd7d4955 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -58,17 +58,20 @@ const Note = typeHandle({ }); await stack.defineType(Note); +// Typed access to the Note family +const notes = stack.collection(Note); + // Create a record -const note = await stack.create(Note, { +const note = await notes.create({ text: 'Hello, Haverstack!', title: 'My first note', }); // Update its content (partial merge — only changed fields needed) -await stack.patchContent(Note, note.id, { title: 'Updated title' }); +await notes.patchContent(note.id, { title: 'Updated title' }); // Read it back, typed: `content.text` is a string -const same = await stack.get(Note, note.id); +const same = await notes.get(note.id); // Tag it await stack.associate(note.id, [{ kind: 'tag', label: 'favourite' }]); @@ -82,7 +85,7 @@ await stack.mutate(note.id, { // Query. With no filter, query() returns every record you can read, from every // app and system types included, so filter by typeId, baseId or appId to get your own. -const notes = await stack.query({ +const favourites = await stack.query({ filter: { typeId: 'com.example.myapp/note@1', tags: ['favourite'] }, sort: { field: 'createdAt', direction: 'desc' }, }); @@ -101,7 +104,8 @@ An app has two layers, because the stack draws a line between them: Migrations belong to neither. They are passed to `Stack.open()` on **every startup**, so the chain is complete before the first read. ```ts -import { Stack, migration, typeHandle, type StackClient } from '@haverstack/core'; +import { Stack, migration, typeHandle } from '@haverstack/core'; +import type { Collection, ContentOf, StackClient } from '@haverstack/core'; const NoteV1 = typeHandle({ id: 'com.example.myapp/note@1', @@ -126,15 +130,18 @@ export const noteMigrations = [ // Data layer: whoever the stack lets in. export class Notes { - constructor(private readonly client: StackClient) {} + private readonly notes: Collection>; + constructor(client: StackClient) { + this.notes = client.collection(Note); + } add(text: string) { - return this.client.create(Note, { text, pinned: false }); + return this.notes.create({ text, pinned: false }); } pin(id: string) { - return this.client.patchContent(Note, id, { pinned: true }); + return this.notes.patchContent(id, { pinned: true }); } list() { - return this.client.query(Note); + return this.notes.query(); } } diff --git a/packages/core/src/collection.ts b/packages/core/src/collection.ts new file mode 100644 index 00000000..9ddf9676 --- /dev/null +++ b/packages/core/src/collection.ts @@ -0,0 +1,401 @@ +/** + * Collections — typed access to one type family + * ------------------------------------------------------- + * `client.collection(handle)` returns a `Collection`: every verb that + * addresses records of the handle's family, each taking and returning the + * handle's content type with one signature. Implemented once over the + * public `StackClient` verbs, so `Stack` and `ScopedStack` share it and a + * collection can do nothing its client could not. + * + * The collection's scope is the handle's whole family. Each record is read + * as stored and migrated in memory to the handle's version, one at a time, + * so a record the handle cannot represent — one stored at a newer version — + * is reported as a misfit rather than failing the page it sits on. A + * content write to a record at an older version commits its migration as a + * write of its own first. See docs/spec/data-model.md § Type handles. + */ + +import { StackBadRequestError, StackMigrationError, StackPermissionError } from './errors.js'; +import { baseIdOf, parseTypeId } from './schema.js'; +import { StoredVersionError, WRITE_EXPECTATION } from './stack/write-expectation.js'; +import type { ExpectingClient, WriteExpectation } from './stack/write-expectation.js'; +import type { MigrationRegistry } from './stack/migrations.js'; +import type { + CreateRecordOptions, + DeleteRecordOptions, + DeleteResult, + StackClient, +} from './stack/client.js'; +import type { ContentOf, ReadonlyTypeSchema, TypeHandle, UnlistedValue } from './type-handle.js'; +import type { + AssociationEdit, + AuthorityAssociation, + ChangeFilter, + DataAssociation, + IfVersionOptions, + RecordChange, + RecordChangeSet, + RecordFilter, + RecordId, + StackQuery, + StackRecord, + SubscribeOptions, + Unsubscribe, +} from './types/index.js'; + +// ------------------------------------------------------- +// Types +// ------------------------------------------------------- + +/** + * Content `C` as a read returns it: an enum may hold a value the handle + * does not list, since an enum can widen in place. Never flattens `C`, so + * an app that names its content with an `interface` keeps that name. + */ +export type StoredContent = C extends string + ? string extends C + ? C + : C | UnlistedValue + : C extends readonly (infer I)[] + ? StoredContent[] + : C extends object + ? { [K in keyof C]: StoredContent } + : C; + +/** + * The `contentPatch` content `C` accepts: every field optional, and `null` + * (removal) only on a field that is optional in `C`. + */ +export type ContentPatch = { + [K in keyof C]?: Pick extends Required> ? C[K] : Exclude | null; +}; + +/** A record of the collection, migrated to the handle's version. */ +export interface CollectionRecord extends Omit { + content: StoredContent; +} + +/** + * A record in the handle's family that the handle cannot represent. Stored + * at a version newer than the handle's, it cannot be migrated down. + */ +export type Misfit = { + id: RecordId; + reason: 'newer-version'; + /** Untyped, as stored. */ + record: StackRecord; +}; + +/** + * A page of a collection. Misfits do not count as records, so a page can + * hold fewer than `limit` records with a cursor still set: the results end + * when `cursor` is `null`, not when a page comes back short. + */ +export type CollectionPage = { + records: CollectionRecord[]; + misfits: Misfit[]; + cursor: string | null; +}; + +/** + * A collection reads live records of its own family only, as stored, so + * none of `typeId`, `baseId`, `includeDeleted` or `presentAt` is offered. + * `C` is reserved for typing content filters and sorts. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type CollectionQuery = Omit & { + filter?: Omit; +}; + +/** The family is the collection's, so neither `typeId` nor `baseId` is a filter key. */ +export type CollectionSubscribeOptions = Omit & { + filter?: Omit; +}; + +/** + * A change to a record of the family. `record`, when present, is migrated + * to the handle's version; `typeId` stays the stored one. A change whose + * record does not fit carries `misfit` instead, and one whose record is a + * tombstone carries neither. + */ +export type CollectionChange = Omit & { + record?: CollectionRecord; + misfit?: Misfit; +}; + +export type CollectionChangeSet = Omit & { + contentPatch?: ContentPatch; +}; + +/** + * Typed access to one type family. History (`getVersions`, `getVersion`, + * `getJournal`) spans versions by nature and stays on `StackClient`. See + * docs/spec/data-model.md § Type handles. + */ +export interface Collection { + /** `null` for a missing record, a tombstone, or a record of another family. */ + get(id: RecordId): Promise | null>; + query(query?: CollectionQuery): Promise>; + subscribe( + handler: (change: CollectionChange) => void, + opts?: CollectionSubscribeOptions, + ): Promise; + create(content: C, opts?: CreateRecordOptions): Promise>; + mutate( + id: RecordId, + changes: CollectionChangeSet, + opts?: IfVersionOptions, + ): Promise>; + patchContent( + id: RecordId, + patch: ContentPatch, + opts?: IfVersionOptions, + ): Promise>; + associate(id: RecordId, associations: DataAssociation[]): Promise>; + dissociate(id: RecordId, associations: DataAssociation[]): Promise>; + amendAssociations(id: RecordId, changes: AssociationEdit[]): Promise>; + grantAccess(id: RecordId, permissions: AuthorityAssociation[]): Promise>; + revokeAccess(id: RecordId, permissions: AuthorityAssociation[]): Promise>; + amendAccess(id: RecordId, changes: AssociationEdit[]): Promise>; + delete(id: RecordId, opts?: DeleteRecordOptions): Promise; + undelete(id: RecordId, opts?: IfVersionOptions): Promise>; + restoreVersion( + id: RecordId, + version: number, + opts?: IfVersionOptions, + ): Promise>; +} + +/** + * A record the collection asked for does not fit the handle. Raised only + * in the app's own process and never on the wire, so it sits outside the + * `StackError` taxonomy. A write that raises it after writing says so. + * See docs/spec/wire-format.md § The taxonomy root. + */ +export class MisfitError extends Error { + readonly reason: Misfit['reason']; + readonly record: StackRecord; + + constructor(misfit: Misfit, handle: TypeHandle, written = false) { + super( + `Record "${misfit.id}" is stored at ${misfit.record.typeId}, newer than ${handle.id}` + + (written ? '; the write landed.' : '.'), + ); + this.name = 'MisfitError'; + this.reason = misfit.reason; + this.record = misfit.record; + } +} + +// ------------------------------------------------------- +// Implementation +// ------------------------------------------------------- + +const versionOf = (typeId: string): number => parseTypeId(typeId)!.version; + +/** + * A filter's `typeId` would narrow the family to one version, so an untyped + * caller passing one has it dropped rather than honoured. + */ +const withoutTypeId = (filter: F | undefined): Omit => { + const { typeId: _typeId, ...rest } = (filter ?? {}) as F & { typeId?: unknown }; + return rest; +}; + +/** + * The collection over `handle`, shared by `Stack.collection()` and + * `ScopedStack.collection()`. `registry` is the opening `Stack`'s, which + * the app's own process filled. + */ +export const createCollection = ( + client: StackClient, + registry: MigrationRegistry, + handle: TypeHandle, +): Collection> => { + type C = ContentOf; + if (handle.migratesFrom !== undefined && !registry.path(handle.migratesFrom, handle.id)) { + throw new StackMigrationError( + `No migration from "${handle.migratesFrom}" to "${handle.id}" was passed to Stack.open().`, + ); + } + + const writer = client as ExpectingClient; + const family = { baseId: handle.baseId }; + const expect = (expectation: WriteExpectation = family) => ({ + [WRITE_EXPECTATION]: expectation, + }); + const handleVersion = versionOf(handle.id); + + /** + * One record of the family, migrated to the handle's version, or the + * misfit it is. An older record with no migration to bridge the gap is + * the app's setup, not the record's, and throws. + */ + const classify = (record: StackRecord): { record: CollectionRecord } | { misfit: Misfit } => { + if (record.typeId === handle.id) return { record: record as CollectionRecord }; + if (versionOf(record.typeId) > handleVersion) { + return { misfit: { id: record.id, reason: 'newer-version', record } }; + } + const migrate = registry.path(record.typeId, handle.id); + if (!migrate) { + throw new StackMigrationError( + `Record "${record.id}" is at "${record.typeId}", and no migration passed to ` + + `Stack.open() carries it to "${handle.id}".`, + ); + } + const content = migrate(record.content); + return { record: { ...record, typeId: handle.id, content } as CollectionRecord }; + }; + + /** A record a write returned, narrowed as `get()` narrows it. */ + const narrow = (record: StackRecord, written: boolean): CollectionRecord => { + const result = classify(record); + if ('misfit' in result) throw new MisfitError(result.misfit, handle, written); + return result.record; + }; + + /** + * A content write to a record stored at an older version: commit its + * migration as a write of its own, fenced to the state the caller read, + * then apply the write fenced to the version the migration produced. A + * patch never folds a migration in. See docs/spec/data-model.md § Type + * migrations. + */ + const migrateThenMutate = async ( + stored: StackRecord, + changes: RecordChangeSet, + opts: IfVersionOptions, + ): Promise> => { + const migrated = classify(stored); + if ('misfit' in migrated) throw new MisfitError(migrated.misfit, handle); + let committed: StackRecord; + try { + committed = await client.commitMigration( + stored.id, + handle.id, + migrated.record.content as Record, + { + ifVersion: stored.version, + }, + ); + } catch (err) { + if (!(err instanceof StackPermissionError)) throw err; + throw new StackPermissionError( + `Record "${stored.id}" is stored at ${stored.typeId} and must be migrated to ` + + `${handle.id} before this write: ${err.message}`, + ); + } + const written = await writer.mutate(stored.id, changes, { + ...opts, + ifVersion: committed.version, + ...expect({ ...family, typeId: handle.id }), + }); + return narrow(written, true); + }; + + const mutate = async ( + id: RecordId, + changes: RecordChangeSet, + opts: IfVersionOptions = {}, + ): Promise> => { + try { + const written = await writer.mutate(id, changes, { + ...opts, + ...expect({ ...family, typeId: handle.id }), + }); + return narrow(written, true); + } catch (err) { + if (!(err instanceof StoredVersionError)) throw err; + return migrateThenMutate(err.record, changes, opts); + } + }; + + return { + async get(id) { + const record = await client.get(id); + if (!record || baseIdOf(record.typeId) !== handle.baseId) return null; + return narrow(record, false); + }, + + async query(query = {}) { + if ((query.filter as RecordFilter | undefined)?.includeDeleted) { + throw new StackBadRequestError( + 'A collection reads live records only; use the untyped query() to include soft-deleted records.', + ); + } + // Stripped as well as untyped: either would change which records a + // page holds, or the shape they arrive in. + const { presentAt: _presentAt, filter, ...rest } = query as StackQuery; + const { records, cursor } = await client.query({ + ...rest, + filter: { ...withoutTypeId(filter), baseId: handle.baseId }, + }); + const page: CollectionPage = { records: [], misfits: [], cursor }; + for (const stored of records) { + const result = classify(stored); + if ('misfit' in result) page.misfits.push(result.misfit); + else page.records.push(result.record); + } + return page; + }, + + subscribe(handler, opts = {}) { + return client.subscribe( + (change) => { + const { record, ...rest } = change; + // A tombstone's content is `{}` under ScopedStack, so it is never + // typed as the handle's content on either surface. A record that + // migrated out of the family matches by its previous typeId. + if (!record || record.deletedAt || baseIdOf(record.typeId) !== handle.baseId) { + return handler(rest); + } + let result: ReturnType; + try { + result = classify(record); + } catch { + // A missing or failing migration: delivered as wherever the + // emitter cannot supply the record, and get() says why. + return handler(rest); + } + handler({ ...rest, ...result }); + }, + { ...opts, filter: { ...withoutTypeId(opts.filter), baseId: handle.baseId } }, + ); + }, + + async create(content, opts) { + return (await client.create(handle.id, content, opts)) as CollectionRecord; + }, + + mutate: (id, changes, opts) => mutate(id, changes as RecordChangeSet, opts), + + patchContent: (id, patch, opts) => + mutate(id, { contentPatch: patch as Record }, opts), + + async associate(id, associations) { + return narrow(await writer.associate(id, associations, expect()), true); + }, + async dissociate(id, associations) { + return narrow(await writer.dissociate(id, associations, expect()), true); + }, + async amendAssociations(id, changes) { + return narrow(await writer.amendAssociations(id, changes, expect()), true); + }, + async grantAccess(id, permissions) { + return narrow(await writer.grantAccess(id, permissions, expect()), true); + }, + async revokeAccess(id, permissions) { + return narrow(await writer.revokeAccess(id, permissions, expect()), true); + }, + async amendAccess(id, changes) { + return narrow(await writer.amendAccess(id, changes, expect()), true); + }, + delete: (id, opts) => writer.delete(id, { ...opts, ...expect() }), + async undelete(id, opts) { + return narrow(await writer.undelete(id, { ...opts, ...expect() }), true); + }, + async restoreVersion(id, version, opts) { + return narrow(await writer.restoreVersion(id, version, { ...opts, ...expect() }), true); + }, + }; +}; diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 4032d0fa..d8678d21 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -8,8 +8,8 @@ * * Several errors sit deliberately outside that root (UseAfterCloseError, * InvalidAdapterError, OwnerMismatchError, RelayScopeError, and — in their own - * modules — IdGenerationError, InvalidDidError, InvalidAuthChallengeError and - * StoredVersionError): they report a local programming error, malformed + * modules — IdGenerationError, InvalidDidError, InvalidAuthChallengeError, + * StoredVersionError and MisfitError): they report a local programming error, malformed * local input or an assembled topology, not a state a request can be in, so * no server ever responds with one. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c19e47d8..52a0963f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,16 +46,25 @@ export type { ContentOf, StoredContentOf, UnlistedValue, - PatchOf, - TypedRecord, - TypedQuery, - TypedChange, - TypedChangeSet, - TypedSubscribeOptions, ReadonlyFieldDef, ReadonlyTypeSchema, } from './type-handle.js'; +// Collections +export { MisfitError } from './collection.js'; +export type { + Collection, + CollectionRecord, + CollectionPage, + CollectionQuery, + CollectionChange, + CollectionChangeSet, + CollectionSubscribeOptions, + ContentPatch, + Misfit, + StoredContent, +} from './collection.js'; + // The error taxonomy export { StackError, diff --git a/packages/core/src/scoped-stack/scoped-stack.ts b/packages/core/src/scoped-stack/scoped-stack.ts index 6650c94a..93e105c6 100644 --- a/packages/core/src/scoped-stack/scoped-stack.ts +++ b/packages/core/src/scoped-stack/scoped-stack.ts @@ -103,25 +103,10 @@ import { presentDeleted, withoutAuthorityChanges, } from '../record-changes.js'; -import { - isTypeHandle, - typedCreate, - typedGet, - typedMutate, - typedQuery, - typedSubscribe, -} from '../type-handle.js'; -import type { - ContentOf, - PatchOf, - ReadonlyTypeSchema, - TypedChange, - TypedChangeSet, - TypedQuery, - TypedSubscribeOptions, - TypedRecord, - TypeHandle, -} from '../type-handle.js'; +import type { ContentOf, ReadonlyTypeSchema, TypeHandle } from '../type-handle.js'; +import { createCollection } from '../collection.js'; +import type { Collection } from '../collection.js'; +import type { MigrationRegistry } from '../stack/migrations.js'; import { uploadContent } from '../stack/attachments.js'; // Type-only: a ScopedStack never constructs a Stack, so nothing here // closes a runtime cycle with stack.ts, which does construct a ScopedStack. @@ -164,6 +149,8 @@ export type ScopedStackInit = { assertOpen: () => void; /** Whether changes reach the stack from elsewhere — see subscribe(). */ relaysChanges: boolean; + /** The app's migrations, which collection() migrates records through. */ + migrations: MigrationRegistry; }; /** @@ -180,6 +167,7 @@ export class ScopedStack implements StackClient { private readonly changes: ChangeEmitter; private readonly assertStackOpen: () => void; private readonly relaysChanges: boolean; + private readonly migrations: MigrationRegistry; /** Not callable from outside the package — see ScopedStackInit. */ constructor(token: typeof scopeToken, init: ScopedStackInit) { @@ -193,6 +181,12 @@ export class ScopedStack implements StackClient { this.changes = init.changes; this.assertStackOpen = init.assertOpen; this.relaysChanges = init.relaysChanges; + this.migrations = init.migrations; + } + + collection(handle: TypeHandle): Collection> { + this.assertStackOpen(); + return createCollection(this, this.migrations, handle); } /** @@ -267,7 +261,7 @@ export class ScopedStack implements StackClient { const record = await this.stack.get(id, { includeDeleted: true }); if (!record) throw new StackNotFoundError(`Record not found: "${id}"`); // Ahead of the gate, so another family's record is not found whatever - // this requester may do to it — the answer a typed get() gives. + // this requester may do to it — the answer a collection's get() gives. checkFamily(record, opts.expect); if (opts.fenceGrantRecord) await this.requireOwnerForGrantRecord(record); if (isGroupRecord(record)) { @@ -363,25 +357,11 @@ export class ScopedStack implements StackClient { * refused rather than dropped, so an app never believes it published * something it didn't. See docs/spec/data-model.md § Backdating on import. */ - async create( - handle: TypeHandle, - content: ContentOf, - opts?: BackdatableCreateRecordOptions, - ): Promise>; async create = Record>( typeId: TypeId, content: T, - opts?: BackdatableCreateRecordOptions, - ): Promise; - async create( - typeIdOrHandle: TypeId | TypeHandle, - content: Record, opts: BackdatableCreateRecordOptions = {}, - ): Promise { - if (isTypeHandle(typeIdOrHandle)) { - return typedCreate(this, typeIdOrHandle, content as never, opts); - } - const typeId = typeIdOrHandle; + ): Promise { const principal = this.#authority.principalId; if (!principal) throw new StackPermissionError('Anonymous requesters cannot create records'); // A grantee is exactly the untrusted actor the `id` skew check below @@ -452,18 +432,7 @@ export class ScopedStack implements StackClient { * one does, so a caller learns only what it may read. * See docs/spec/disclosure.md § Which refusal a Record answers with. */ - async get( - handle: TypeHandle, - id: RecordId, - ): Promise | null>; - async get(id: RecordId, opts?: GetRecordOptions): Promise; - async get( - idOrHandle: RecordId | TypeHandle, - idOrOpts: RecordId | GetRecordOptions = {}, - ): Promise { - if (isTypeHandle(idOrHandle)) return typedGet(this, idOrHandle, idOrOpts as RecordId); - const id = idOrHandle; - const opts = idOrOpts as GetRecordOptions; + async get(id: RecordId, opts: GetRecordOptions = {}): Promise { const record = await this.stack.get(id, opts); if (!record) return null; if (!(await this.#authority.canRead(record))) return null; @@ -495,17 +464,7 @@ export class ScopedStack implements StackClient { * never skips a record. Grants are prefetched once, cursor-walked to * exhaustion. */ - async query( - handle: TypeHandle, - query?: TypedQuery, - ): Promise<{ records: TypedRecord[]; cursor: string | null }>; - async query(query?: StackQuery): Promise; - async query( - first: TypeHandle | StackQuery = {}, - typedQueryArg?: TypedQuery, - ): Promise { - if (isTypeHandle(first)) return typedQuery(this, first, typedQueryArg); - const query = first; + async query(query: StackQuery = {}): Promise { assertValidSort(query.sort); assertSortCapability(query.sort, this.stack.capabilities); assertValidAssociationFilters(query.filter); @@ -549,35 +508,11 @@ export class ScopedStack implements StackClient { * refused key refuses the call. See docs/spec/access-control.md * § Composing a change set. */ - async mutate( - handle: TypeHandle, - id: RecordId, - changes: TypedChangeSet, - opts?: IfVersionOptions, - ): Promise>; async mutate( id: RecordId, changes: RecordChangeSet, - opts?: IfVersionOptions, - ): Promise; - async mutate( - first: RecordId | TypeHandle, - second: RecordId | RecordChangeSet, - third?: RecordChangeSet | IfVersionOptions, - fourth: IfVersionOptions = {}, + opts: IfVersionOptions & ExpectationOptions = {}, ): Promise { - if (isTypeHandle(first)) { - return typedMutate( - this, - first, - second as RecordId, - third as TypedChangeSet, - fourth, - ); - } - const id = first; - const changes = second as RecordChangeSet; - const opts = (third ?? {}) as IfVersionOptions & ExpectationOptions; // Ahead of every gate: a malformed change set is a validation error // for every requester, rather than one for the owner and a permission // refusal for everyone else. @@ -667,27 +602,12 @@ export class ScopedStack implements StackClient { return this.stack.mutate(id, authorized, { ...opts, ifVersion, ...this.actor }); } - async patchContent( - handle: TypeHandle, - id: RecordId, - patch: PatchOf, - opts?: IfVersionOptions, - ): Promise>; async patchContent( id: RecordId, patch: Record, - opts?: IfVersionOptions, - ): Promise; - async patchContent( - first: RecordId | TypeHandle, - second: RecordId | Record, - third?: Record | IfVersionOptions, - fourth?: IfVersionOptions, + opts?: IfVersionOptions & ExpectationOptions, ): Promise { - if (isTypeHandle(first)) { - return this.mutate(first, second as RecordId, { contentPatch: third as never }, fourth); - } - return this.mutate(first, { contentPatch: second as Record }, third); + return this.mutate(id, { contentPatch: patch }, opts); } /** @@ -1092,30 +1012,10 @@ export class ScopedStack implements StackClient { * no event at all, since a change's existence is itself a disclosure. * See docs/spec/events.md § Permission scoping. */ - async subscribe( - handle: TypeHandle, - handler: (change: TypedChange) => void, - opts?: TypedSubscribeOptions, - ): Promise; async subscribe( handler: (change: RecordChange) => void, - opts?: SubscribeOptions, - ): Promise; - async subscribe( - first: TypeHandle | ((change: RecordChange) => void), - second?: ((change: TypedChange) => void) | SubscribeOptions, - third?: TypedSubscribeOptions, + opts: SubscribeOptions = {}, ): Promise { - if (isTypeHandle(first)) { - return typedSubscribe( - this, - first, - second as (change: TypedChange) => void, - third, - ); - } - const handler = first; - const opts = (second ?? {}) as SubscribeOptions; this.assertStackOpen(); if (this.relaysChanges) { throw new RelayScopeError( diff --git a/packages/core/src/stack/client.ts b/packages/core/src/stack/client.ts index b8adba05..e4683176 100644 --- a/packages/core/src/stack/client.ts +++ b/packages/core/src/stack/client.ts @@ -32,18 +32,8 @@ import type { Unsubscribe, VersionsQuery, } from '../types/index.js'; -import type { - ContentOf, - Migration, - PatchOf, - ReadonlyTypeSchema, - TypedChange, - TypedChangeSet, - TypedQuery, - TypedRecord, - TypedSubscribeOptions, - TypeHandle, -} from '../type-handle.js'; +import type { ContentOf, Migration, ReadonlyTypeSchema, TypeHandle } from '../type-handle.js'; +import type { Collection } from '../collection.js'; export type CreateRecordOptions = { /** @@ -220,33 +210,19 @@ export type DefineTypeOptions = { */ export interface StackClient { readonly capabilities: StackCapabilities; - create( - handle: TypeHandle, - content: ContentOf, - opts?: CreateRecordOptions, - ): Promise>; + /** + * Typed access to `handle`'s whole family, every record migrated in + * memory to the handle's version. Throws `StackMigrationError` when the + * handle's `migratesFrom` has no migration passed to `Stack.open()`. + * See docs/spec/data-model.md § Type handles. + */ + collection(handle: TypeHandle): Collection>; create = Record>( typeId: TypeId, content: T, opts?: CreateRecordOptions, ): Promise; - /** - * Typed read: the record at `presentAt: 'latest'`, checked to be exactly - * the handle's Type, else it throws. An enum field may hold a value the - * handle does not list. Live records only — a tombstone reads as `null`, - * and there is no `includeDeleted`. - * See docs/spec/data-model.md § Type handles. - */ - get( - handle: TypeHandle, - id: RecordId, - ): Promise | null>; get(id: RecordId, opts?: GetRecordOptions): Promise; - /** Typed read of the handle's whole family — see the typed `get()`. */ - query( - handle: TypeHandle, - query?: TypedQuery, - ): Promise<{ records: TypedRecord[]; cursor: string | null }>; query(query?: StackQuery): Promise; /** * Resolve a DID to its `_entity` card — family-wide and soft-deleted @@ -264,20 +240,8 @@ export interface StackClient { * it names `contentPatch`. Keys are read for presence; under ScopedStack * one refused key refuses the call. See docs/spec/data-model.md § Mutations. */ - mutate( - handle: TypeHandle, - id: RecordId, - changes: TypedChangeSet, - opts?: IfVersionOptions, - ): Promise>; mutate(id: RecordId, changes: RecordChangeSet, opts?: IfVersionOptions): Promise; /** mutate() with `contentPatch` alone — the common case, named for it. */ - patchContent( - handle: TypeHandle, - id: RecordId, - patch: PatchOf, - opts?: IfVersionOptions, - ): Promise>; patchContent( id: RecordId, patch: Record, @@ -354,15 +318,5 @@ export interface StackClient { collectAttachmentGarbage( opts?: CollectAttachmentGarbageOptions, ): Promise; - /** - * Typed subscription: delivers only changes to records of exactly the - * handle's Type, each `record` typed as its content. See - * docs/spec/data-model.md § Type handles. - */ - subscribe( - handle: TypeHandle, - handler: (change: TypedChange) => void, - opts?: TypedSubscribeOptions, - ): Promise; subscribe(handler: (change: RecordChange) => void, opts?: SubscribeOptions): Promise; } diff --git a/packages/core/src/stack/migrations.ts b/packages/core/src/stack/migrations.ts index 0c7f1735..c0327350 100644 --- a/packages/core/src/stack/migrations.ts +++ b/packages/core/src/stack/migrations.ts @@ -4,8 +4,9 @@ * The migrations a Stack was opened with, and what this app process * understands of each type family. Answers two questions for the Stack: * which chain carries a record from one TypeId to another (for - * migrateAll()), and how a stored record reads at `presentAt: 'latest'` - * — never written back. See docs/spec/data-model.md § Type migrations. + * migrateAll() and a collection's in-memory reads), and how a stored + * record reads at `presentAt: 'latest'` — never written back. See + * docs/spec/data-model.md § Type migrations. */ import { parseTypeId } from '../schema.js'; diff --git a/packages/core/src/stack/stack.ts b/packages/core/src/stack/stack.ts index aa8bf57d..618b3bef 100644 --- a/packages/core/src/stack/stack.ts +++ b/packages/core/src/stack/stack.ts @@ -168,26 +168,15 @@ import type { StackOptions, } from './client.js'; import { - isTypeHandle, - typedCreate, - typedGet, - typedMutate, - typedQuery, - typedSubscribe, -} from '../type-handle.js'; -import { checkFamily, checkStoredVersion, WRITE_EXPECTATION } from './write-expectation.js'; + checkFamily, + checkSnapshotFamily, + checkStoredVersion, + WRITE_EXPECTATION, +} from './write-expectation.js'; import type { ExpectationOptions } from './write-expectation.js'; -import type { - ContentOf, - PatchOf, - ReadonlyTypeSchema, - TypedChange, - TypedChangeSet, - TypedQuery, - TypedSubscribeOptions, - TypedRecord, - TypeHandle, -} from '../type-handle.js'; +import type { ContentOf, ReadonlyTypeSchema, TypeHandle } from '../type-handle.js'; +import { createCollection } from '../collection.js'; +import type { Collection } from '../collection.js'; // ------------------------------------------------------- // Supporting definitions @@ -400,9 +389,15 @@ export class Stack implements StackClient { changes: this.changes, assertOpen: () => this.assertOpen(), relaysChanges: this.relaysChanges, + migrations: this.migrations, }); } + collection(handle: TypeHandle): Collection> { + this.assertOpen(); + return createCollection(this, this.migrations, handle); + } + // ------------------------------------------------------- // Types // ------------------------------------------------------- @@ -580,24 +575,11 @@ export class Stack implements StackClient { * Backdating is accepted unconditionally here; ScopedStack decides who may. * See docs/spec/data-model.md § Backdating on import. */ - async create( - handle: TypeHandle, - content: ContentOf, - opts?: BackdatableCreateRecordOptions, - ): Promise>; async create = Record>( typeId: TypeId, - content: T, - opts?: BackdatableCreateRecordOptions, - ): Promise; - async create( - typeIdOrHandle: TypeId | TypeHandle, - input: Record, + input: T, opts: BackdatableCreateRecordOptions = {}, - ): Promise { - if (isTypeHandle(typeIdOrHandle)) - return typedCreate(this, typeIdOrHandle, input as never, opts); - const typeId = typeIdOrHandle; + ): Promise { this.assertOpen(); const type = await this.getTypeCached(typeId); if (!type) { @@ -723,7 +705,7 @@ export class Stack implements StackClient { const change = new PendingChange('create', { actor: Stack.createActor(record) }); const created = await this.adapter.createRecord(record, { journal: change.journal }); this.announce(change, created); - return created; + return created as StackRecord & { content: T }; } /** @@ -732,18 +714,7 @@ export class Stack implements StackClient { * commits migrations to disk. A soft-deleted record answers `null` unless * { includeDeleted: true } is passed. */ - async get( - handle: TypeHandle, - id: RecordId, - ): Promise | null>; - async get(id: RecordId, opts?: GetRecordOptions): Promise; - async get( - idOrHandle: RecordId | TypeHandle, - idOrOpts: RecordId | GetRecordOptions = {}, - ): Promise { - if (isTypeHandle(idOrHandle)) return typedGet(this, idOrHandle, idOrOpts as RecordId); - const id = idOrHandle; - const opts = idOrOpts as GetRecordOptions; + async get(id: RecordId, opts: GetRecordOptions = {}): Promise { this.assertOpen(); const record = await this.adapter.getRecord(id); if (!record) return null; @@ -757,35 +728,11 @@ export class Stack implements StackClient { * against the record's current stored type. * See docs/spec/data-model.md § Mutations. */ - async mutate( - handle: TypeHandle, - id: RecordId, - changes: TypedChangeSet, - opts?: IfVersionOptions & ActorOptions, - ): Promise>; async mutate( id: RecordId, changes: RecordChangeSet, - opts?: IfVersionOptions & ActorOptions, - ): Promise; - async mutate( - first: RecordId | TypeHandle, - second: RecordId | RecordChangeSet | TypedChangeSet, - third?: (IfVersionOptions & ActorOptions) | RecordChangeSet, - fourth: IfVersionOptions & ActorOptions = {}, + opts: IfVersionOptions & ActorOptions & ExpectationOptions = {}, ): Promise { - if (isTypeHandle(first)) { - return typedMutate( - this, - first, - second as RecordId, - third as TypedChangeSet, - fourth, - ); - } - const id = first; - const changes = second as RecordChangeSet; - const opts = (third ?? {}) as IfVersionOptions & ActorOptions & ExpectationOptions; this.assertOpen(); assertNonEmptyChangeSet(changes); @@ -848,27 +795,12 @@ export class Stack implements StackClient { * and named for what it does rather than for a symmetry with create() * that a patch does not have. */ - async patchContent( - handle: TypeHandle, - id: RecordId, - patch: PatchOf, - opts?: IfVersionOptions & ActorOptions, - ): Promise>; async patchContent( id: RecordId, patch: Record, - opts?: IfVersionOptions & ActorOptions, - ): Promise; - async patchContent( - first: RecordId | TypeHandle, - second: RecordId | Record, - third?: Record | (IfVersionOptions & ActorOptions), - fourth?: IfVersionOptions & ActorOptions, + opts?: IfVersionOptions & ActorOptions & ExpectationOptions, ): Promise { - if (isTypeHandle(first)) { - return this.mutate(first, second as RecordId, { contentPatch: third as never }, fourth); - } - return this.mutate(first, { contentPatch: second as Record }, third); + return this.mutate(id, { contentPatch: patch }, opts); } /** @@ -1260,17 +1192,7 @@ export class Stack implements StackClient { * stored; pass presentAt: 'latest' to migrate in memory. See * docs/spec/data-model.md § Queries. */ - async query( - handle: TypeHandle, - query?: TypedQuery, - ): Promise<{ records: TypedRecord[]; cursor: string | null }>; - async query(query?: StackQuery): Promise; - async query( - first: TypeHandle | StackQuery = {}, - typedQueryArg?: TypedQuery, - ): Promise { - if (isTypeHandle(first)) return typedQuery(this, first, typedQueryArg); - const query = first; + async query(query: StackQuery = {}): Promise { this.assertOpen(); const { presentAt, filter, limit: rawLimit, ...rest } = query; assertQueryCapabilities(filter, this.adapter.capabilities); @@ -1371,7 +1293,8 @@ export class Stack implements StackClient { this.assertOpen(); const existing = await this.requireRecord(id); // A snapshot is validated against its own stored Type, never the - // record's current one, so only the family is held to the expectation. + // record's current one, so only the family is held to the expectation: + // the record's here, and the snapshot's once it is read. checkFamily(existing, opts); this.refuseIfDeleted(existing); this.checkIfVersion(existing, opts.ifVersion); @@ -1380,6 +1303,7 @@ export class Stack implements StackClient { if (!target) { throw new StackNotFoundError(`Version ${version} not found for record "${id}"`); } + checkSnapshotFamily(id, target, opts); const type = await this.getTypeCached(target.typeId); if (!type) { @@ -1659,30 +1583,10 @@ export class Stack implements StackClient { * remote stack resolves once its feed is live, making subscribe-then-query * gap-free. See docs/spec/events.md § Subscribing. */ - async subscribe( - handle: TypeHandle, - handler: (change: TypedChange) => void, - opts?: TypedSubscribeOptions, - ): Promise; async subscribe( handler: (change: RecordChange) => void, - opts?: SubscribeOptions, - ): Promise; - async subscribe( - first: TypeHandle | ((change: RecordChange) => void), - second?: ((change: TypedChange) => void) | SubscribeOptions, - third?: TypedSubscribeOptions, + opts: SubscribeOptions = {}, ): Promise { - if (isTypeHandle(first)) { - return typedSubscribe( - this, - first, - second as (change: TypedChange) => void, - third, - ); - } - const handler = first; - const opts = (second ?? {}) as SubscribeOptions; this.assertOpen(); assertSinceUsable(opts.since, this.relaysChanges); assertValidBaseIdFilter(opts.filter); diff --git a/packages/core/src/stack/write-expectation.ts b/packages/core/src/stack/write-expectation.ts index 934558a8..b5b3c053 100644 --- a/packages/core/src/stack/write-expectation.ts +++ b/packages/core/src/stack/write-expectation.ts @@ -18,7 +18,19 @@ import { StackNotFoundError } from '../errors.js'; import { baseIdOf } from '../schema.js'; -import type { BaseId, StackRecord, TypeId } from '../types/index.js'; +import type { DeleteRecordOptions, DeleteResult, StackClient } from './client.js'; +import type { + AssociationEdit, + AuthorityAssociation, + BaseId, + DataAssociation, + IfVersionOptions, + RecordChangeSet, + RecordId, + RecordVersion, + StackRecord, + TypeId, +} from '../types/index.js'; export const WRITE_EXPECTATION = Symbol('haverstack.writeExpectation'); @@ -55,6 +67,22 @@ export const checkFamily = (record: StackRecord, opts: ExpectationOptions | unde } }; +/** + * A restore puts back the snapshot's `typeId`, so the snapshot is held to + * the family too: one from before the record migrated into it is not a + * version of a record in that family. + */ +export const checkSnapshotFamily = ( + id: RecordId, + snapshot: RecordVersion, + opts: ExpectationOptions | undefined, +): void => { + const expected = opts?.[WRITE_EXPECTATION]; + if (expected && baseIdOf(snapshot.typeId) !== expected.baseId) { + throw new StackNotFoundError(`Version ${snapshot.version} not found for record "${id}"`); + } +}; + /** * The exact version, on a write that patches content: a patch is validated * against the record's stored Type, so one written for another version @@ -78,3 +106,72 @@ export const storedVersionDiffers = ( if (expected?.typeId === undefined || record.typeId === expected.typeId) return false; return patchesContent || expected.exact === true; }; + +/** + * The write verbs of `StackClient` as `Stack` and `ScopedStack` implement + * them, each taking an expectation. Internal: a collection casts its client + * to this once, so the key stays off the public surface and every call that + * passes one is still type-checked. + */ +export type ExpectingClient = Omit< + StackClient, + | 'mutate' + | 'patchContent' + | 'associate' + | 'dissociate' + | 'amendAssociations' + | 'grantAccess' + | 'revokeAccess' + | 'amendAccess' + | 'delete' + | 'undelete' + | 'restoreVersion' +> & { + mutate( + id: RecordId, + changes: RecordChangeSet, + opts?: IfVersionOptions & ExpectationOptions, + ): Promise; + patchContent( + id: RecordId, + patch: Record, + opts?: IfVersionOptions & ExpectationOptions, + ): Promise; + associate( + id: RecordId, + associations: DataAssociation[], + opts?: ExpectationOptions, + ): Promise; + dissociate( + id: RecordId, + associations: DataAssociation[], + opts?: ExpectationOptions, + ): Promise; + amendAssociations( + id: RecordId, + changes: AssociationEdit[], + opts?: ExpectationOptions, + ): Promise; + grantAccess( + id: RecordId, + permissions: AuthorityAssociation[], + opts?: ExpectationOptions, + ): Promise; + revokeAccess( + id: RecordId, + permissions: AuthorityAssociation[], + opts?: ExpectationOptions, + ): Promise; + amendAccess( + id: RecordId, + changes: AssociationEdit[], + opts?: ExpectationOptions, + ): Promise; + delete(id: RecordId, opts?: DeleteRecordOptions & ExpectationOptions): Promise; + undelete(id: RecordId, opts?: IfVersionOptions & ExpectationOptions): Promise; + restoreVersion( + id: RecordId, + version: number, + opts?: IfVersionOptions & ExpectationOptions, + ): Promise; +}; diff --git a/packages/core/src/type-handle.ts b/packages/core/src/type-handle.ts index 69e94b25..03243c3e 100644 --- a/packages/core/src/type-handle.ts +++ b/packages/core/src/type-handle.ts @@ -2,34 +2,18 @@ * Stack — Type handles * ------------------------------------------------------- * A Type's schema written once as a literal, from which the compiler derives - * the content type: `ContentOf` for a create, `StoredContentOf` for a read, - * `PatchOf` for a `contentPatch`. A handle is a plain value, so `StackClient` + * the content type: `ContentOf` for a create, `StoredContentOf` for a read. + * A handle is a plain value, so `StackClient` * code can use it wherever it has no `defineType()` result. * * The derived types layer over runtime validation, which stays the guarantee. - * What a typed read adds is a runtime check that the record is the handle's - * Type. See docs/spec/data-model.md § Type handles. + * A handle reaches records through `client.collection(handle)`; see + * collection.ts and docs/spec/data-model.md § Type handles. */ import { StackBadRequestError, StackMigrationError } from './errors.js'; import { lineageProblem, parseTypeId } from './schema.js'; -import { StoredVersionError, WRITE_EXPECTATION } from './stack/write-expectation.js'; -import type { CreateRecordOptions, StackClient } from './stack/client.js'; -import type { - BaseId, - ChangeFilter, - IfVersionOptions, - RecordChange, - RecordChangeSet, - RecordId, - RecordFilter, - ScalarFieldKind, - StackQuery, - StackRecord, - SubscribeOptions, - TypeId, - Unsubscribe, -} from './types/index.js'; +import type { BaseId, ScalarFieldKind, TypeId } from './types/index.js'; // ------------------------------------------------------- // Schema literal types @@ -135,22 +119,6 @@ export type ContentOf = ContentAs; /** `ContentOf`, as a read returns it: an enum may hold an unlisted value. */ export type StoredContentOf = ContentAs; -/** - * The `contentPatch` a schema accepts: every field optional, and `null` - * (removal) only on a field that is not required — a required field can be - * replaced but not removed. - */ -export type PatchOf = Simplify<{ - -readonly [K in keyof S]?: IsRequired extends true - ? ValueOf - : ValueOf | null; -}>; - -/** A record whose content is the handle's, as a typed read returns it. */ -export type TypedRecord = Omit & { - content: StoredContentOf; -}; - // ------------------------------------------------------- // The handle // ------------------------------------------------------- @@ -171,7 +139,7 @@ export type TypeHandle = { /** * Name a Type, its display name, its schema and the version it migrates * from in one literal. Pass the result to `defineType()`, a manifest's - * `types` and the typed overloads of `StackClient`. A handle names exactly + * `types` and `StackClient.collection()`. A handle names exactly * one version: `Book.baseId` is the argument a call about the whole family * takes. */ @@ -205,13 +173,6 @@ export const typeHandle = ({ }); }; -export const isTypeHandle = (value: unknown): value is TypeHandle => - typeof value === 'object' && - value !== null && - typeof (value as TypeHandle).id === 'string' && - typeof (value as TypeHandle).baseId === 'string' && - typeof (value as TypeHandle).schema === 'object'; - // ------------------------------------------------------- // Migrations // ------------------------------------------------------- @@ -248,141 +209,3 @@ export const migration = & { - filter?: Omit; -}; - -/** - * A change whose `record`, when present, is the handle's content. Absent - * where an untyped change's would be, and when the record has migrated off - * the handle's version; the typed `get()` then says why. - */ -export type TypedChange = Omit & { - record?: TypedRecord; -}; - -/** The family and version are the handle's, so neither is a filter key. */ -export type TypedSubscribeOptions = Omit & { - filter?: Omit; -}; - -export type TypedChangeSet = Omit & { - contentPatch?: PatchOf; -}; - -// ------------------------------------------------------- -// Runtime narrowing -// ------------------------------------------------------- - -/** - * Narrow a record to a handle's content type: it must be exactly the - * handle's Type. A record of another Type throws, and so does one whose - * family has moved past the handle's version. - */ -const narrowRecord = ( - handle: TypeHandle, - record: StackRecord, -): TypedRecord => { - if (record.typeId !== handle.id) { - throw new StackBadRequestError(`Record "${record.id}" is ${record.typeId}, not ${handle.id}`); - } - return record as TypedRecord; -}; - -/** The ways a typed read can be asked to see a tombstone. */ -const assertLiveFilter = (filter: object | undefined): void => { - if ((filter as RecordFilter | undefined)?.includeDeleted) { - throw new StackBadRequestError( - 'A typed read sees live records only; use the untyped read to include soft-deleted records.', - ); - } -}; - -// ------------------------------------------------------- -// Typed operations, shared by Stack and ScopedStack -// ------------------------------------------------------- - -type TypedOps = Pick; - -export const typedGet = async ( - client: TypedOps, - handle: TypeHandle, - id: RecordId, -): Promise | null> => { - const record = await client.get(id, { presentAt: 'latest' }); - return record && narrowRecord(handle, record); -}; - -export const typedQuery = async ( - client: TypedOps, - handle: TypeHandle, - query: TypedQuery = {}, -): Promise<{ records: TypedRecord[]; cursor: string | null }> => { - assertLiveFilter(query.filter); - // The family, not the handle's version: a record still at an older - // version must be migrated up, or refused as stale, never skipped. - const result = await client.query({ - ...query, - filter: { ...query.filter, baseId: handle.baseId }, - presentAt: 'latest', - }); - return { ...result, records: result.records.map((r) => narrowRecord(handle, r)) }; -}; - -export const typedCreate = async ( - client: TypedOps, - handle: TypeHandle, - content: ContentOf, - opts?: CreateRecordOptions, -): Promise> => client.create(handle.id, content, opts) as Promise>; - -export const typedMutate = async ( - client: TypedOps, - handle: TypeHandle, - id: RecordId, - changes: TypedChangeSet, - opts?: IfVersionOptions, -): Promise> => { - // Checked against the read the write already makes, so nothing lands on - // a record the handle does not type. See docs/spec/data-model.md § Type handles. - const expect = { - [WRITE_EXPECTATION]: { baseId: handle.baseId, typeId: handle.id, exact: true }, - }; - try { - return narrowRecord(handle, await client.mutate(id, changes, { ...opts, ...expect } as never)); - } catch (err) { - if (!(err instanceof StoredVersionError)) throw err; - throw new StackBadRequestError(`Record "${id}" is ${err.record.typeId}, not ${handle.id}`); - } -}; - -export const typedSubscribe = ( - client: TypedOps, - handle: TypeHandle, - handler: (change: TypedChange) => void, - opts: TypedSubscribeOptions = {}, -): Promise => - // Exactly the handle's version: an event carries its record as stored, so - // a subscription cannot migrate it the way a typed read does, and a - // record of another version has no honest typing here. - client.subscribe( - (change) => { - const { record, ...rest } = change; - // A record migrated off the handle's version still matches the filter - // by its previous typeId, and arrives without `record`. - handler( - record?.typeId === handle.id ? { ...rest, record: narrowRecord(handle, record) } : rest, - ); - }, - { ...opts, filter: { ...opts.filter, typeId: handle.id } }, - ); diff --git a/packages/core/tests/collection.test.ts b/packages/core/tests/collection.test.ts new file mode 100644 index 00000000..62ae42e7 --- /dev/null +++ b/packages/core/tests/collection.test.ts @@ -0,0 +1,582 @@ +import { describe, test, expect, expectTypeOf, beforeEach, vi } from 'vitest'; +import { Stack } from '../src/stack/stack.js'; +import { MemoryAdapter } from '../src/testing.js'; +import { migration, typeHandle } from '../src/type-handle.js'; +import type { ContentOf, UnlistedValue } from '../src/type-handle.js'; +import { MisfitError } from '../src/collection.js'; +import type { + Collection, + CollectionChange, + CollectionPage, + CollectionRecord, + ContentPatch, + StoredContent, +} from '../src/collection.js'; +import { + StackBadRequestError, + StackMigrationError, + StackNotFoundError, + StackPermissionError, + StackVersionConflictError, +} from '../src/errors.js'; +import type { StackClient } from '../src/stack/client.js'; +import type { AuthorityAssociation, DataAssociation } from '../src/types/index.js'; + +const OWNER = 'owner-123'; +const MEMBER = 'member-456'; + +// Scoped delivery is asynchronous: the permission check is. +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const BookV1 = typeHandle({ + id: 'com.example.reading/book@1', + name: 'Book', + schema: { + title: { kind: 'string', required: true }, + status: { kind: 'enum', values: ['want', 'reading', 'done'], required: true }, + }, +}); + +const Book = typeHandle({ + id: 'com.example.reading/book@2', + name: 'Book', + migratesFrom: BookV1, + schema: { + title: { kind: 'string', required: true }, + status: { kind: 'enum', values: ['want', 'reading', 'finished', 'abandoned'], required: true }, + pages: { kind: 'number' }, + }, +}); + +const BookV3 = typeHandle({ + id: 'com.example.reading/book@3', + name: 'Book', + migratesFrom: Book, + schema: { ...Book.schema, isbn: { kind: 'string' } }, +}); + +const Shelf = typeHandle({ + id: 'com.example.reading/shelf@1', + name: 'Shelf', + schema: { name: { kind: 'string', required: true } }, +}); + +const bookV1ToV2 = migration(BookV1, Book, (c) => ({ + ...c, + status: c.status === 'done' ? 'finished' : c.status, +})); + +// The naming an app does: an interface keeps its name where a type alias would not. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface BookContent extends ContentOf {} + +const TAG: DataAssociation = { kind: 'tag', label: 'fantasy' }; +const share = (label: 'read' | 'write'): AuthorityAssociation => ({ + kind: 'permission', + label, + grantee: { kind: 'entity', entityId: MEMBER }, +}); + +describe.each([ + ['Stack', (s: Stack): StackClient => s], + ['ScopedStack', (s: Stack): StackClient => s.asEntity(OWNER)], +] as const)('a collection on %s', (_name, view) => { + let adapter: MemoryAdapter; + let stack: Stack; + let books: Collection; + + beforeEach(async () => { + adapter = await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'UTC' }); + stack = await Stack.open(adapter, { migrations: [bookV1ToV2] }); + await stack.defineType(BookV1); + await stack.defineType(Book); + await stack.defineType(BookV3); + await stack.defineType(Shelf); + books = view(stack).collection(Book); + }); + + /** A record stored at `@1`, written past the collection. */ + const createV1 = (title = 'Dune', status = 'done') => stack.create(BookV1.id, { title, status }); + + // ------------------------------------------------------- + // Typing + // ------------------------------------------------------- + + test('is typed by content, so an interface naming it is kept', () => { + expectTypeOf(view(stack).collection(Book)).toEqualTypeOf< + Collection> + >(); + expectTypeOf< + Awaited> + >().toEqualTypeOf | null>(); + }); + + test('derives the patch and the stored content from the content type', () => { + expectTypeOf>().toEqualTypeOf<{ + title?: string; + status?: 'want' | 'reading' | 'finished' | 'abandoned'; + pages?: number | null; + }>(); + expectTypeOf['status']>().toEqualTypeOf< + 'want' | 'reading' | 'finished' | 'abandoned' | UnlistedValue + >(); + }); + + test('ContentPatch refuses a typo, a null on a required field, and a bad enum', () => { + const ok: ContentPatch = { pages: null, status: 'finished' }; + // @ts-expect-error a typo'd key + const typo: ContentPatch = { titel: 'x' }; + // @ts-expect-error a required field can be replaced but not removed + const nullRequired: ContentPatch = { title: null }; + // @ts-expect-error a value outside the enum + const wrongEnum: ContentPatch = { status: 'paused' }; + expect([ok, typo, nullRequired, wrongEnum]).toHaveLength(4); + }); + + test('create() refuses content outside the schema at compile time', async () => { + // @ts-expect-error a value outside the enum + await books.create({ title: 'x', status: 'paused' }).catch(() => undefined); + // @ts-expect-error a typo'd key + await books.create({ title: 'x', stauts: 'want' }).catch(() => undefined); + }); + + test('patchContent() refuses a typo, a null on a required field and a bad enum', async () => { + const book = await books.create({ title: 'Dune', status: 'want' }); + // @ts-expect-error a typo'd key + await books.patchContent(book.id, { titel: 'x' }).catch(() => undefined); + // @ts-expect-error null on a required field + await books.patchContent(book.id, { title: null }).catch(() => undefined); + // @ts-expect-error a value outside the enum + await books.patchContent(book.id, { status: 'paused' }).catch(() => undefined); + }); + + test('every verb that returns a record returns it typed', async () => { + const book = await books.create({ title: 'Dune', status: 'want' }); + expectTypeOf(book).toEqualTypeOf>(); + const results = [ + await books.mutate(book.id, { contentPatch: { pages: 412 } }), + await books.patchContent(book.id, { pages: 413 }), + await books.associate(book.id, [TAG]), + await books.dissociate(book.id, [TAG]), + await books.amendAssociations(book.id, [{ op: 'add', association: TAG }]), + await books.grantAccess(book.id, [share('read')]), + await books.revokeAccess(book.id, [share('read')]), + await books.amendAccess(book.id, [{ op: 'add', association: share('read') }]), + await books.undelete(book.id), + await books.restoreVersion(book.id, 1), + ]; + expectTypeOf(results).toEqualTypeOf[]>(); + expect(results.at(-1)?.content).toEqual({ title: 'Dune', status: 'want' }); + }); + + // ------------------------------------------------------- + // Reads + // ------------------------------------------------------- + + test('get() round-trips a record', async () => { + const created = await books.create({ title: 'Dune', status: 'want' }); + expect((await books.get(created.id))?.content).toEqual({ title: 'Dune', status: 'want' }); + }); + + test('get() answers null for a missing record, a tombstone and another Type', async () => { + const book = await books.create({ title: 'Dune', status: 'want' }); + await books.delete(book.id); + const shelf = await stack.create(Shelf.id, { name: 'Fiction' }); + expect(await books.get('nope')).toBeNull(); + expect(await books.get(book.id)).toBeNull(); + expect(await books.get(shelf.id)).toBeNull(); + }); + + test("get() migrates an older record to the handle's version in memory", async () => { + const old = await createV1(); + const got = await books.get(old.id); + expect(got?.typeId).toBe(Book.id); + expect(got?.content).toEqual({ title: 'Dune', status: 'finished' }); + expect(await adapter.getRecord(old.id)).toEqual(old); + }); + + test('get() throws MisfitError for a record newer than the handle', async () => { + const newer = await stack.create(BookV3.id, { title: 'Dune', status: 'want' }); + const err = await books.get(newer.id).catch((e: unknown) => e); + expect(err).toBeInstanceOf(MisfitError); + expect((err as MisfitError).reason).toBe('newer-version'); + expect((err as MisfitError).record).toEqual(newer); + }); + + test('get() returns an enum value the handle does not list', async () => { + const book = await books.create({ title: 'Dune', status: 'want' }); + await stack.defineType({ + id: Book.id, + name: 'Book', + schema: { ...Book.schema, status: { kind: 'string', required: true } }, + }); + await stack.patchContent(book.id, { status: 'paused' }); + expect((await books.get(book.id))?.content.status).toBe('paused'); + }); + + test('query() returns the family, migrated, and hides tombstones and other Types', async () => { + const old = await createV1(); + const live = await books.create({ title: 'Emma', status: 'want' }); + const gone = await books.create({ title: 'Ulysses', status: 'want' }); + await books.delete(gone.id); + await stack.create(Shelf.id, { name: 'Fiction' }); + + const page = await books.query({ sort: { field: 'createdAt', direction: 'asc' } }); + expectTypeOf(page).toEqualTypeOf>(); + expect(page.records.map((r) => [r.id, r.typeId, r.content.status])).toEqual([ + [old.id, Book.id, 'finished'], + [live.id, Book.id, 'want'], + ]); + expect(page.misfits).toEqual([]); + }); + + test('query() reports a newer record as a misfit and keeps the rest of the page', async () => { + const before = await books.create({ title: 'Dune', status: 'want' }); + const newer = await stack.create(BookV3.id, { title: 'Emma', status: 'want' }); + const after = await books.create({ title: 'Ulysses', status: 'want' }); + + const page = await books.query({ sort: { field: 'createdAt', direction: 'asc' } }); + expect(page.records.map((r) => r.id)).toEqual([before.id, after.id]); + expect(page.misfits).toEqual([{ id: newer.id, reason: 'newer-version', record: newer }]); + }); + + test('query() pages past misfits to every record', async () => { + const ids = []; + for (const title of ['a', 'b', 'c', 'd', 'e']) { + const handle = title === 'b' || title === 'c' ? BookV3.id : Book.id; + ids.push((await stack.create(handle, { title, status: 'want' })).id); + } + const seen: string[] = []; + let misfits = 0; + let cursor: string | null | undefined; + do { + const page = await books.query({ limit: 2, ...(cursor && { cursor }) }); + seen.push(...page.records.map((r) => r.id), ...page.misfits.map((m) => m.id)); + misfits += page.misfits.length; + cursor = page.cursor; + } while (cursor); + expect(seen.sort()).toEqual([...ids].sort()); + expect(misfits).toBe(2); + }); + + test('query() filters on content', async () => { + await books.create({ title: 'Dune', status: 'want' }); + await books.create({ title: 'Emma', status: 'reading' }); + const { records } = await books.query({ filter: { content: { status: 'reading' } } }); + expect(records.map((r) => r.content.title)).toEqual(['Emma']); + }); + + test('query() drops typeId and presentAt from an untyped caller', async () => { + const book = await books.create({ title: 'Dune', status: 'want' }); + const newer = await stack.create(BookV3.id, { title: 'Emma', status: 'want' }); + const page = await books.query({ + presentAt: 'latest', + filter: { typeId: Shelf.id }, + } as never); + expect(page.records.map((r) => r.id)).toEqual([book.id]); + expect(page.misfits.map((m) => m.id)).toEqual([newer.id]); + }); + + test('query() refuses includeDeleted', async () => { + await expect( + // @ts-expect-error a collection does not offer includeDeleted + books.query({ filter: { includeDeleted: true } }), + ).rejects.toThrow(StackBadRequestError); + }); + + test('a read of an older record with no migration to bridge the gap throws', async () => { + const reopened = await Stack.open(adapter, { + migrations: [migration(Book, BookV3, (c) => c)], + }); + const old = await createV1(); + const v3 = view(reopened).collection(BookV3); + await expect(v3.get(old.id)).rejects.toThrow(StackMigrationError); + await expect(v3.query()).rejects.toThrow(StackMigrationError); + }); + + // ------------------------------------------------------- + // Writes + // ------------------------------------------------------- + + test('a write to a record outside the family is not found and writes nothing', async () => { + const shelf = await stack.create(Shelf.id, { name: 'Fiction' }); + await expect(books.patchContent(shelf.id, { title: 'x' })).rejects.toThrow(StackNotFoundError); + await expect(books.associate(shelf.id, [TAG])).rejects.toThrow(StackNotFoundError); + await expect(books.delete(shelf.id)).rejects.toThrow(StackNotFoundError); + expect(await adapter.getRecord(shelf.id)).toEqual(shelf); + }); + + test('a write reads the record no more often than an untyped one', async () => { + const book = await books.create({ title: 'Dune', status: 'want' }); + const spy = vi.spyOn(adapter, 'getRecord'); + await view(stack).patchContent(book.id, { title: 'Emma' }); + const untyped = spy.mock.calls.length; + spy.mockClear(); + await books.patchContent(book.id, { title: 'Dune' }); + expect(spy.mock.calls.length).toBe(untyped); + spy.mockRestore(); + }); + + test('a write to a tombstone is refused as an untyped one is', async () => { + const book = await books.create({ title: 'Dune', status: 'want' }); + await books.delete(book.id); + await expect(books.patchContent(book.id, { title: 'x' })).rejects.toThrow(/soft-deleted/); + }); + + test('mutate() carries a typed contentPatch beside the other keys', async () => { + const book = await books.create({ title: 'Dune', status: 'want' }); + const updated = await books.mutate(book.id, { + contentPatch: { status: 'finished' }, + unlisted: true, + }); + expect(updated.content.status).toBe('finished'); + expect(updated.unlistedAt).toBeDefined(); + }); + + test('a write that leaves content alone returns an older record migrated, unwritten', async () => { + const old = await createV1(); + const tagged = await books.associate(old.id, [TAG]); + expect(tagged.typeId).toBe(Book.id); + expect(tagged.content).toEqual({ title: 'Dune', status: 'finished' }); + expect((await adapter.getRecord(old.id))?.typeId).toBe(BookV1.id); + }); + + test('a patch to an older record commits the migration first, as its own write', async () => { + const old = await createV1(); + const patched = await books.patchContent(old.id, { pages: 412 }); + + expect(patched.typeId).toBe(Book.id); + expect(patched.content).toEqual({ title: 'Dune', status: 'finished', pages: 412 }); + expect(patched.version).toBe(3); + const journal = await stack.getJournal(old.id); + expect(journal.map((e) => e.ops)).toEqual([['create'], ['migrate'], ['patch']]); + }); + + test('a stale ifVersion fences the migration, and nothing is written', async () => { + const old = await createV1(); + await stack.patchContent(old.id, { title: 'Dune Messiah' }); + await expect(books.patchContent(old.id, { pages: 412 }, { ifVersion: 1 })).rejects.toThrow( + StackVersionConflictError, + ); + expect((await adapter.getRecord(old.id))?.typeId).toBe(BookV1.id); + expect(await stack.getJournal(old.id)).toHaveLength(2); + }); + + test('a write landing between the migration and the patch fails the patch', async () => { + const old = await createV1(); + const commit = stack.commitMigration.bind(stack); + vi.spyOn(stack, 'commitMigration').mockImplementationOnce(async (...args) => { + const migrated = await commit(...args); + await stack.patchContent(old.id, { title: 'Dune Messiah' }); + return migrated; + }); + await expect(books.patchContent(old.id, { pages: 412 })).rejects.toThrow( + StackVersionConflictError, + ); + expect((await adapter.getRecord(old.id))?.typeId).toBe(Book.id); + }); + + test('a revert to a pre-migration snapshot can be patched', async () => { + const old = await createV1(); + await stack.migrateAll(Book.baseId); + const reverted = await books.restoreVersion(old.id, 1); + expect(reverted.typeId).toBe(Book.id); + expect(reverted.content).toEqual({ title: 'Dune', status: 'finished' }); + expect((await adapter.getRecord(old.id))?.typeId).toBe(BookV1.id); + + const patched = await books.patchContent(old.id, { pages: 412 }); + expect(patched.content).toEqual({ title: 'Dune', status: 'finished', pages: 412 }); + expect((await stack.getJournal(old.id)).map((e) => e.ops[0])).toEqual([ + 'create', + 'migrate', + 'restore', + 'migrate', + 'patch', + ]); + }); + + test('a content write to a newer record throws MisfitError before writing', async () => { + const newer = await stack.create(BookV3.id, { title: 'Dune', status: 'want' }); + const err = await books.patchContent(newer.id, { pages: 412 }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(MisfitError); + expect(await adapter.getRecord(newer.id)).toEqual(newer); + }); + + test('a write that leaves a newer record lands, then throws MisfitError', async () => { + const newer = await stack.create(BookV3.id, { title: 'Dune', status: 'want' }); + const err = await books.associate(newer.id, [TAG]).catch((e: unknown) => e); + expect(err).toBeInstanceOf(MisfitError); + expect((err as MisfitError).record.associations).toEqual([TAG]); + expect((err as Error).message).toMatch(/the write landed/); + }); + + // ------------------------------------------------------- + // Subscriptions + // ------------------------------------------------------- + + test('subscribe() covers the family, migrating older records and reporting misfits', async () => { + const seen: CollectionChange[] = []; + const stop = await books.subscribe((change) => seen.push(change), { includeRecords: true }); + await stack.create(Shelf.id, { name: 'Fiction' }); + const old = await createV1(); + const book = await books.create({ title: 'Emma', status: 'want' }); + const newer = await stack.create(BookV3.id, { title: 'Ulysses', status: 'want' }); + await settle(); + stop(); + + expect(seen.map((c) => c.recordId)).toEqual([old.id, book.id, newer.id]); + expect(seen[0].typeId).toBe(BookV1.id); + expect(seen[0].record?.typeId).toBe(Book.id); + expect(seen[0].record?.content).toEqual({ title: 'Dune', status: 'finished' }); + expect(seen[1].record?.content).toEqual({ title: 'Emma', status: 'want' }); + expect(seen[2].record).toBeUndefined(); + expect(seen[2].misfit).toEqual({ id: newer.id, reason: 'newer-version', record: newer }); + }); + + test('subscribe() never types a tombstone', async () => { + const book = await books.create({ title: 'Dune', status: 'want' }); + const seen: CollectionChange[] = []; + const stop = await books.subscribe((change) => seen.push(change), { includeRecords: true }); + await books.delete(book.id); + await settle(); + stop(); + + expect(seen.map((c) => c.kind)).toEqual(['removed']); + expect(seen[0].record).toBeUndefined(); + expect(seen[0].misfit).toBeUndefined(); + }); + + test('subscribe() drops typeId from an untyped caller', async () => { + const seen: string[] = []; + const stop = await books.subscribe((c) => seen.push(c.recordId), { + filter: { typeId: Shelf.id }, + } as never); + const book = await books.create({ title: 'Dune', status: 'want' }); + await settle(); + stop(); + expect(seen).toEqual([book.id]); + }); + + test('subscribe() takes the rest of the change filter', async () => { + const seen: string[] = []; + const stop = await books.subscribe((c) => seen.push(c.kind), { + filter: { kinds: ['removed'] }, + }); + const book = await books.create({ title: 'Dune', status: 'want' }); + await books.delete(book.id); + await settle(); + stop(); + expect(seen).toEqual(['removed']); + }); +}); + +describe.each([ + ['Stack', (s: Stack): StackClient => s], + ['ScopedStack', (s: Stack): StackClient => s.asEntity(OWNER)], +] as const)('a collection whose records migrate out of the family, on %s', (_name, view) => { + let stack: Stack; + + beforeEach(async () => { + const adapter = await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'UTC' }); + stack = await Stack.open(adapter, { + migrations: [bookV1ToV2, migration(Shelf, Book, (c) => ({ title: c.name, status: 'want' }))], + }); + await stack.defineType(BookV1); + await stack.defineType(Book); + await stack.defineType(Shelf); + }); + + test('subscribe() delivers the change that moves a record out without its record', async () => { + const shelves = view(stack).collection(Shelf); + const seen: CollectionChange>[] = []; + const stop = await shelves.subscribe((change) => seen.push(change), { includeRecords: true }); + const shelf = await shelves.create({ name: 'Fiction' }); + await stack.migrateAll(Shelf.baseId); + await settle(); + stop(); + + expect(seen.map((c) => [c.recordId, c.ops])).toEqual([ + [shelf.id, ['create']], + [shelf.id, ['migrate']], + ]); + expect(seen[1].record).toBeUndefined(); + expect(seen[1].misfit).toBeUndefined(); + }); + + test('restoreVersion() refuses a snapshot from another family and writes nothing', async () => { + const shelf = await stack.create(Shelf.id, { name: 'Fiction' }); + await stack.migrateAll(Shelf.baseId); + const before = await stack.get(shelf.id); + const books = view(stack).collection(Book); + + await expect(books.restoreVersion(shelf.id, 1)).rejects.toThrow( + `Version 1 not found for record "${shelf.id}"`, + ); + expect(await stack.get(shelf.id)).toEqual(before); + }); +}); + +describe('a collection whose migration function throws', () => { + test('subscribe() still delivers the change, without its record', async () => { + const stack = await Stack.open( + await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'UTC' }), + { + migrations: [ + migration(BookV1, Book, () => { + throw new TypeError('broken migration'); + }), + ], + }, + ); + await stack.defineType(BookV1); + await stack.defineType(Book); + const books = stack.collection(Book); + const seen: CollectionChange[] = []; + const stop = await books.subscribe((change) => seen.push(change), { includeRecords: true }); + const old = await stack.create(BookV1.id, { title: 'Dune', status: 'want' }); + stop(); + + expect(seen.map((c) => c.recordId)).toEqual([old.id]); + expect(seen[0].record).toBeUndefined(); + await expect(books.get(old.id)).rejects.toThrow('broken migration'); + }); +}); + +describe('collection()', () => { + test('refuses a handle whose migratesFrom has no migration', async () => { + const stack = await Stack.open( + await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'UTC' }), + ); + expect(() => stack.collection(Book)).toThrow( + `No migration from "${BookV1.id}" to "${Book.id}" was passed to Stack.open().`, + ); + expect(() => stack.asEntity(OWNER).collection(Book)).toThrow(StackMigrationError); + expect(stack.collection(BookV1)).toBeDefined(); + }); +}); + +describe('a collection under delegation', () => { + test('refuses a patch needing a migration the requester may not commit', async () => { + const stack = await Stack.open( + await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'UTC' }), + { migrations: [bookV1ToV2] }, + ); + await stack.defineType(BookV1); + await stack.defineType(Book); + const old = await stack.create( + BookV1.id, + { title: 'Dune', status: 'want' }, + { permissions: [share('read'), share('write')] }, + ); + const books = stack.asEntity(MEMBER).collection(Book); + + await expect(books.patchContent(old.id, { pages: 412 })).rejects.toThrow(StackPermissionError); + await expect(books.patchContent(old.id, { pages: 412 })).rejects.toThrow( + `stored at ${BookV1.id}`, + ); + expect(await stack.get(old.id)).toEqual(old); + + await stack.migrateAll(Book.baseId); + expect((await books.patchContent(old.id, { pages: 412 })).content.pages).toBe(412); + }); +}); diff --git a/packages/core/tests/type-handle.test.ts b/packages/core/tests/type-handle.test.ts index 61897d11..99d23180 100644 --- a/packages/core/tests/type-handle.test.ts +++ b/packages/core/tests/type-handle.test.ts @@ -1,24 +1,13 @@ -import { describe, test, expect, expectTypeOf, beforeEach, vi } from 'vitest'; +import { describe, test, expect, expectTypeOf } from 'vitest'; import { Stack } from '../src/stack/stack.js'; import { MemoryAdapter } from '../src/testing.js'; import { migration, typeHandle } from '../src/type-handle.js'; -import type { - ContentOf, - PatchOf, - StoredContentOf, - TypedChange, - TypedRecord, - UnlistedValue, -} from '../src/type-handle.js'; -import { StackBadRequestError, StackMigrationError, StackNotFoundError } from '../src/errors.js'; -import type { StackClient } from '../src/stack/client.js'; +import type { ContentOf, StoredContentOf, UnlistedValue } from '../src/type-handle.js'; +import { StackBadRequestError, StackMigrationError } from '../src/errors.js'; import type { TypeId, TypeSchema } from '../src/types/index.js'; const OWNER = 'owner-123'; -// Scoped delivery is asynchronous: the permission check is. -const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); - const BookV1 = typeHandle({ id: 'com.example.reading/book@1', name: 'Book', @@ -146,27 +135,6 @@ describe('StoredContentOf', () => { }); }); -describe('PatchOf', () => { - test('makes every field optional and allows null only on optional fields', () => { - expectTypeOf>().toEqualTypeOf<{ - title?: string; - status?: 'want' | 'reading' | 'finished' | 'abandoned'; - pages?: number | null; - }>(); - }); - - test('rejects a typo, a null on a required field, and a value outside the enum', () => { - const ok: PatchOf = { pages: null, status: 'finished' }; - // @ts-expect-error a typo'd key - const typo: PatchOf = { titel: 'x' }; - // @ts-expect-error a required field can be replaced but not removed - const nullRequired: PatchOf = { title: null }; - // @ts-expect-error a value outside the enum - const wrongEnum: PatchOf = { status: 'paused' }; - expect([ok, typo, nullRequired, wrongEnum]).toHaveLength(4); - }); -}); - describe('typeHandle()', () => { test('carries the id, its family, the name, the schema and what it migrates from', () => { expect(Book.id).toBe('com.example.reading/book@2'); @@ -281,297 +249,6 @@ describe('migration()', () => { }); }); -// ------------------------------------------------------- -// Typed reads and writes -// ------------------------------------------------------- - -describe.each([ - ['Stack', (s: Stack): StackClient => s], - ['ScopedStack', (s: Stack): StackClient => s.asEntity(OWNER)], -] as const)('typed reads and writes on %s', (_name, view) => { - let adapter: MemoryAdapter; - let stack: Stack; - let client: ReturnType; - - beforeEach(async () => { - adapter = await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'UTC' }); - stack = await Stack.open(adapter); - await stack.defineType(Book); - await stack.defineType(Shelf); - client = view(stack); - }); - - test('create() returns content typed from the handle', async () => { - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - expectTypeOf(book).toEqualTypeOf>(); - expect(book.typeId).toBe(Book.id); - expect(book.content.title).toBe('Dune'); - }); - - test('create() refuses content outside the schema at compile time', async () => { - // @ts-expect-error a value outside the enum - await client.create(Book, { title: 'x', status: 'paused' }).catch(() => undefined); - }); - - test('get() round-trips a record as typed content', async () => { - const created = await client.create(Book, { title: 'Dune', status: 'want' }); - const got = await client.get(Book, created.id); - expectTypeOf(got).toEqualTypeOf | null>(); - expect(got?.content).toEqual({ title: 'Dune', status: 'want' }); - }); - - test('get() answers null for a missing record', async () => { - expect(await client.get(Book, 'nope')).toBeNull(); - }); - - test('get() throws for a record of another Type', async () => { - const shelf = await client.create(Shelf, { name: 'Fiction' }); - await expect(client.get(Book, shelf.id)).rejects.toThrow( - `Record "${shelf.id}" is com.example.reading/shelf@1, not com.example.reading/book@2`, - ); - }); - - test('get() throws once the family has moved past the handle', async () => { - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - const BookV3 = typeHandle({ - id: 'com.example.reading/book@3', - name: 'Book', - schema: { ...Book.schema, isbn: { kind: 'string' } }, - migratesFrom: Book, - }); - stack = await Stack.open(adapter, { migrations: [migration(Book, BookV3, (c) => c)] }); - await stack.defineType(BookV3); - client = view(stack); - await expect(client.get(Book, book.id)).rejects.toThrow(/book@3, not .*book@2/); - }); - - test('get() throws when the app was not passed the migration', async () => { - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - await stack.defineType({ - id: 'com.example.reading/book@3', - name: 'Book', - schema: Book.schema, - }); - await expect(client.get(Book, book.id)).rejects.toThrow(StackMigrationError); - }); - - test('get() returns an enum value the handle does not list', async () => { - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - // The writer's schema lists one more value than this reader's handle. - await stack.defineType({ - id: Book.id, - name: 'Book', - schema: { - ...Book.schema, - status: { - kind: 'enum', - values: ['want', 'reading', 'finished', 'abandoned', 'paused'], - required: true, - }, - }, - }); - await stack.patchContent(book.id, { status: 'paused' }); - expect((await client.get(Book, book.id))?.content.status).toBe('paused'); - }); - - test('mutate() keeps an enum value the handle does not list', async () => { - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - await stack.defineType({ - id: Book.id, - name: 'Book', - schema: { ...Book.schema, status: { kind: 'string', required: true } }, - }); - await stack.patchContent(book.id, { status: 'paused' }); - const updated = await client.mutate(Book, book.id, { - contentPatch: { title: 'Emma' }, - unlisted: true, - }); - expect(updated.content).toEqual({ title: 'Emma', status: 'paused' }); - expect(updated.unlistedAt).toBeDefined(); - }); - - test('get() reads a tombstone as null', async () => { - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - await client.delete(book.id); - expect(await client.get(Book, book.id)).toBeNull(); - }); - - test('query() returns typed records of the handle and hides tombstones', async () => { - const live = await client.create(Book, { title: 'Dune', status: 'want' }); - const gone = await client.create(Book, { title: 'Emma', status: 'want' }); - await client.create(Shelf, { name: 'Fiction' }); - await client.delete(gone.id); - - const { records } = await client.query(Book); - expectTypeOf(records).toEqualTypeOf[]>(); - expect(records.map((r) => r.id)).toEqual([live.id]); - }); - - test('query() filters on content', async () => { - await client.create(Book, { title: 'Dune', status: 'want' }); - await client.create(Book, { title: 'Emma', status: 'reading' }); - const { records } = await client.query(Book, { filter: { content: { status: 'reading' } } }); - expect(records.map((r) => r.content.title)).toEqual(['Emma']); - }); - - test('query() refuses includeDeleted', async () => { - await expect( - // @ts-expect-error typed reads do not offer includeDeleted - client.query(Book, { filter: { includeDeleted: true } }), - ).rejects.toThrow(StackBadRequestError); - }); - - test('query() throws on a record of the family at another version', async () => { - await client.create(Book, { title: 'Dune', status: 'want' }); - await stack.defineType({ id: 'com.example.reading/book@3', name: 'Book', schema: Book.schema }); - await expect(client.query(Book)).rejects.toThrow(StackMigrationError); - }); - - test('patchContent() takes a typed patch and returns the typed record', async () => { - const book = await client.create(Book, { title: 'Dune', status: 'want', pages: 412 }); - const updated = await client.patchContent(Book, book.id, { status: 'reading', pages: null }); - expectTypeOf(updated).toEqualTypeOf>(); - expect(updated.content).toEqual({ title: 'Dune', status: 'reading' }); - }); - - test('patchContent() refuses a typo, a null on a required field and a bad enum', async () => { - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - // @ts-expect-error a typo'd key - await client.patchContent(Book, book.id, { titel: 'x' }).catch(() => undefined); - // @ts-expect-error null on a required field - await client.patchContent(Book, book.id, { title: null }).catch(() => undefined); - // @ts-expect-error a value outside the enum - await client.patchContent(Book, book.id, { status: 'paused' }).catch(() => undefined); - }); - - test('mutate() carries a typed contentPatch beside the other keys', async () => { - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - const updated = await client.mutate(Book, book.id, { - contentPatch: { status: 'finished' }, - unlisted: true, - }); - expect(updated.content.status).toBe('finished'); - expect(updated.unlistedAt).toBeDefined(); - }); - - test("subscribe() delivers only the handle's Type, with the record typed", async () => { - const seen: TypedChange[] = []; - const stop = await client.subscribe(Book, (change) => seen.push(change), { - includeRecords: true, - }); - await client.create(Shelf, { name: 'Fiction' }); - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - await settle(); - stop(); - - expect(seen.map((c) => c.recordId)).toEqual([book.id]); - expectTypeOf(seen[0].record).toEqualTypeOf | undefined>(); - expect(seen[0].record?.content).toEqual({ title: 'Dune', status: 'want' }); - }); - - test('subscribe() takes the rest of the change filter', async () => { - const seen: string[] = []; - const stop = await client.subscribe(Book, (c) => seen.push(c.kind), { - filter: { kinds: ['removed'] }, - }); - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - await client.delete(book.id); - await settle(); - stop(); - expect(seen).toEqual(['removed']); - }); - - test('subscribe() delivers a record migrated off the handle without its record', async () => { - const BookV3 = typeHandle({ - id: 'com.example.reading/book@3', - name: 'Book', - schema: { ...Book.schema, isbn: { kind: 'string' } }, - migratesFrom: Book, - }); - stack = await Stack.open(adapter, { migrations: [migration(Book, BookV3, (c) => c)] }); - await stack.defineType(BookV3); - client = view(stack); - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - - const seen: TypedChange[] = []; - const stop = await client.subscribe(Book, (change) => seen.push(change), { - includeRecords: true, - }); - await stack.migrateAll(Book.baseId); - await settle(); - stop(); - - expect(seen).toHaveLength(1); - expect(seen[0].recordId).toBe(book.id); - expect(seen[0].record).toBeUndefined(); - }); - - test('subscribe() delivers a record holding an enum value the handle does not list', async () => { - const seen: TypedChange[] = []; - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - await stack.defineType({ - id: Book.id, - name: 'Book', - schema: { - ...Book.schema, - status: { - kind: 'enum', - values: ['want', 'reading', 'finished', 'abandoned', 'paused'], - required: true, - }, - }, - }); - const stop = await client.subscribe(Book, (change) => seen.push(change), { - includeRecords: true, - }); - await stack.patchContent(book.id, { status: 'paused' }); - await settle(); - stop(); - - expect(seen).toHaveLength(1); - expect(seen[0].record?.content).toEqual({ title: 'Dune', status: 'paused' }); - }); - - test('a typed write to a record of another family finds no record and writes nothing', async () => { - const shelf = await client.create(Shelf, { name: 'Fiction' }); - await expect(client.patchContent(Book, shelf.id, { title: 'x' })).rejects.toThrow( - StackNotFoundError, - ); - expect((await stack.get(shelf.id))?.version).toBe(shelf.version); - }); - - test('a typed write to another version of the family throws before writing', async () => { - await stack.defineType(BookV1); - const old = await client.create(BookV1, { title: 'Dune', status: 'want' }); - await expect(client.patchContent(Book, old.id, { title: 'x' })).rejects.toThrow( - `Record "${old.id}" is com.example.reading/book@1, not com.example.reading/book@2`, - ); - await expect( - client.mutate(Book, old.id, { associations: [{ kind: 'tag', label: 'x' }] }), - ).rejects.toThrow(StackBadRequestError); - expect(await adapter.getRecord(old.id)).toEqual(old); - }); - - test('a typed write reads the record no more often than an untyped one', async () => { - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - const spy = vi.spyOn(adapter, 'getRecord'); - await client.patchContent(book.id, { title: 'Emma' }); - const untyped = spy.mock.calls.length; - spy.mockClear(); - await client.patchContent(Book, book.id, { title: 'Dune' }); - expect(spy.mock.calls.length).toBe(untyped); - spy.mockRestore(); - }); - - test('a typed write to a tombstone is refused as an untyped one is', async () => { - const book = await client.create(Book, { title: 'Dune', status: 'want' }); - await client.delete(book.id); - await expect(client.patchContent(Book, book.id, { title: 'x' })).rejects.toThrow( - /soft-deleted/, - ); - }); -}); - describe('defineType()', () => { test('accepts a handle as it is', async () => { const stack = await Stack.open( diff --git a/packages/core/tests/write-expectation.test.ts b/packages/core/tests/write-expectation.test.ts index 7356d15a..485c6b2a 100644 --- a/packages/core/tests/write-expectation.test.ts +++ b/packages/core/tests/write-expectation.test.ts @@ -8,8 +8,11 @@ import { StackVersionConflictError, } from '../src/errors.js'; import { StoredVersionError, WRITE_EXPECTATION } from '../src/stack/write-expectation.js'; -import type { WriteExpectation } from '../src/stack/write-expectation.js'; -import type { ScopedStack } from '../src/scoped-stack/scoped-stack.js'; +import type { + ExpectationOptions, + ExpectingClient, + WriteExpectation, +} from '../src/stack/write-expectation.js'; import type { AuthorityAssociation, DataAssociation, RecordId } from '../src/types/index.js'; const OWNER = 'owner-123'; @@ -42,29 +45,28 @@ const SHARE: AuthorityAssociation = { }; // The options a collection passes; the verbs' public types never name the key. -const expecting = (expectation: WriteExpectation, rest: object = {}) => - ({ ...rest, [WRITE_EXPECTATION]: expectation }) as never; +const expecting = (expectation: WriteExpectation, rest?: O) => + ({ ...rest, [WRITE_EXPECTATION]: expectation }) as O & ExpectationOptions; const BOOK_FAMILY: WriteExpectation = { baseId: Book.baseId, typeId: Book.id }; -type Verb = (client: Stack | ScopedStack, id: RecordId, opts?: object) => Promise; +type Verb = (client: ExpectingClient, id: RecordId, opts?: ExpectationOptions) => Promise; // Each verb with arguments that would write had the family matched. A // call without options passes `undefined`, as an untyped caller would. const VERBS: Record = { - mutate: (c, id, o) => c.mutate(id, { contentPatch: { name: 'Moved' } }, o as never), - patchContent: (c, id, o) => c.patchContent(id, { name: 'Moved' }, o as never), - associate: (c, id, o) => c.associate(id, [TAG], o as never), - dissociate: (c, id, o) => c.dissociate(id, [TAG], o as never), - amendAssociations: (c, id, o) => - c.amendAssociations(id, [{ op: 'add', association: TAG }], o as never), - grantAccess: (c, id, o) => c.grantAccess(id, [SHARE], o as never), - revokeAccess: (c, id, o) => c.revokeAccess(id, [SHARE], o as never), - amendAccess: (c, id, o) => c.amendAccess(id, [{ op: 'add', association: SHARE }], o as never), - delete: (c, id, o) => c.delete(id, o as never), - purge: (c, id, o) => c.delete(id, { ...o, purge: true } as never), - undelete: (c, id, o) => c.undelete(id, o as never), - restoreVersion: (c, id, o) => c.restoreVersion(id, 1, o as never), + mutate: (c, id, o) => c.mutate(id, { contentPatch: { name: 'Moved' } }, o), + patchContent: (c, id, o) => c.patchContent(id, { name: 'Moved' }, o), + associate: (c, id, o) => c.associate(id, [TAG], o), + dissociate: (c, id, o) => c.dissociate(id, [TAG], o), + amendAssociations: (c, id, o) => c.amendAssociations(id, [{ op: 'add', association: TAG }], o), + grantAccess: (c, id, o) => c.grantAccess(id, [SHARE], o), + revokeAccess: (c, id, o) => c.revokeAccess(id, [SHARE], o), + amendAccess: (c, id, o) => c.amendAccess(id, [{ op: 'add', association: SHARE }], o), + delete: (c, id, o) => c.delete(id, o), + purge: (c, id, o) => c.delete(id, { ...o, purge: true }), + undelete: (c, id, o) => c.undelete(id, o), + restoreVersion: (c, id, o) => c.restoreVersion(id, 1, o), }; let adapter: MemoryAdapter; @@ -87,7 +89,7 @@ const seedShelf = async (): Promise => { return shelf.id; }; -const clients: Record Stack | ScopedStack> = { +const clients: Record ExpectingClient> = { Stack: () => stack, ScopedStack: () => stack.asEntity(OWNER), };