diff --git a/docs/api.md b/docs/api.md index 9cbe4b8..527abd9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -80,12 +80,10 @@ The retryable column is why these carry a `code` at all rather than being bodyle | POST | `/records/query` | Optional | Query records with content filters | | POST | `/records` | Required | Create a record | | GET | `/records/:id` | Optional | Get a record by ID | -| PATCH | `/records/:id` | Required | Update record content (merge patch) | +| PATCH | `/records/:id` | Required | Apply a change set to a record | | DELETE | `/records/:id` | Required | Soft-delete (or hard with `?hard=true`) | | POST | `/records/:id/undelete` | Required | Reverse a soft delete | | GET | `/records/:id/permissions` | Optional | Get permissions | -| PUT | `/records/:id/permissions` | Required | Replace permissions | -| PUT | `/records/:id/unlisted` | Required | Withhold from enumeration, or relist | | GET | `/records/:id/associations` | Optional | List associations | | POST | `/records/:id/associations` | Required | Add an association | | POST | `/records/:id/associations/delete` | Required | Remove an association | @@ -94,6 +92,32 @@ The retryable column is why these carry a `code` at all rather than being bodyle | POST | `/records/:id/restore/:version` | Required | Restore a previous version | | POST | `/records/:id/migrate` | Owner only | Change a record's typeId | +### The change set + +`PATCH /records/:id` takes a **change set**: an envelope naming any combination of a record's native aspects, applied as one atomic write that produces exactly one version. + +```json +{ + "contentPatch": { "title": "Q3 plan" }, + "parentId": "1hk153x0000f", + "permissions": [{ "access": "public" }], + "associations": [{ "kind": "tag", "label": "planning" }], + "unlisted": false +} +``` + +Keys are read for **presence, not truthiness** — `unlisted: false` and `parentId: null` name aspects and are applied, while an omitted key is left alone. A change set naming no key at all is `400`: it addresses nothing, so there is nothing it could have failed to satisfy. One already satisfied in every key writes nothing and answers with the record unchanged. + +`contentPatch` is the one key that **merges**; every other key replaces the aspect it names. The merge is one level deep — a nested object in a patch replaces rather than merges — and a `null` field value removes the field. Because content is a key rather than the whole body, a `parentId` naming a container is unambiguously distinct from a content field of that name. + +Authority resolves **per key, against the record as it stands**: `contentPatch` and `associations` are reachable by a write-holder or an `update-*` grantee; `parentId` needs that same write access _and_ a destination the requester can read; `permissions` and `unlisted` need the owner or the record's own creator. A requester who may perform every key may perform the set; a requester who may not perform one of them may not perform any of it. Every gate reads the record's pre-change state, so a widened `permissions` in a change set never satisfies the read check on a `parentId` named in the same call. A refused key refuses the whole call — nothing is partially applied and no key is silently dropped. + +An unrecognized top-level key is `400`; a key whose value is the wrong shape is `422`, as is a key the type does not declare inside `contentPatch`. A `parentId` a caller names must be well-formed (`400` otherwise — the empty string is not a spelling of the root, `null` is) and must name a record that exists (`409` otherwise). + +**Which of those two a requester sees depends on who they are.** Both answers are the owner's: for anyone else, a destination they cannot read and one that is not there are a single indistinguishable `403`, which is what stops the gate being an existence oracle. So a non-owner write-holder naming a missing container gets `403`, not `409` — they learn they may not name it, never whether it exists. + +`associate()` / `dissociate()` keep their own endpoints: they amend the association set where the `associations` key replaces it, so two clients tagging one record both succeed through `POST .../associations` and race through the key. + Version history requires the same access `PATCH`/`DELETE` require — a write-holder, or the owner — not plain read. A read-only requester gets `403`. `POST /records/:id/migrate` is the only way a record's `typeId` changes after creation, and is owner-acting-alone only — a non-owner gets `403` regardless of any write grant or record-level permission they hold. Body is `{ toTypeId, content }`: `content` is the full post-migration content, computed client-side by the type's owning app; the server validates it against `toTypeId`'s schema before writing and leaves a pre-migration snapshot in version history. Accepts the same `If-Match` header as `PATCH`. @@ -108,7 +132,7 @@ A `relationship` association's `target` is a discriminated union naming which id { "kind": "relationship", "label": "syndicated-to", "target": { "scope": "external", "ns": "atproto", "id": "at://..." } } ``` -`PUT /records/:id/permissions` returns `204` with no body; the body and (when read back via `GET`) response both use the `{ "permissions": [...] }` envelope. An empty array makes the record private (owner-only). +A change set's `permissions` key replaces every permission on the record and answers `200` with the updated record. An empty array makes the record private (owner-only). `GET /records/:id/permissions` reads them back under the `{ "permissions": [...] }` envelope. `POST /records/:id/undelete` reverses a soft delete and returns the record as it now stands (`deletedAt` absent). Idempotent — a second call on an already-active record returns the same result. @@ -116,7 +140,7 @@ A `relationship` association's `target` is a discriminated union naming which id `createdAt`/`updatedAt` are honored only from the stack owner acting alone (undelegated, authenticated as the owner) — the same tier that gates hard delete, `commitMigration()`, and `includeUnlisted`. From anyone else, both are dropped rather than forwarded: forwarding them would otherwise turn an ordinary grantee's create into a `403`, since a non-owner backdating attempt is refused outright rather than silently ignored. For an owner-acting-alone create: `updatedAt` defaults to `createdAt` (never to now), so a plain import doesn't fabricate an edit; `updatedAt` earlier than `createdAt` is a `422` validation error, including when `createdAt` itself defaulted to now; omitting `id` derives it from `createdAt` instead of the current time; supplying both `id` and `createdAt` checks them against each other under the same clock-skew tolerance. An owner's plain `id`-only create (no `createdAt`) is unaffected — it still gets the ordinary id-vs-now check. A malformed `createdAt`/`updatedAt` value returns `422` with code `validation`. -`If-Match: ""` is accepted for optimistic concurrency on every endpoint that bumps a record's version — `PATCH /records/:id`, `DELETE /records/:id`, `POST /records/:id/undelete`, `POST /records/:id/restore/:version`, `POST /records/:id/migrate`, `POST /records/:id/associations`, `POST /records/:id/associations/delete`, `PUT /records/:id/permissions`, and `PUT /records/:id/unlisted`. A mismatch returns `412` with code `version_conflict` and a `versionConflict: { recordId, expectedVersion, actualVersion }` payload; omitting the header keeps last-writer-wins. A malformed value (not a bare, optionally quoted version — a weak comparator like `W/"5"` included) is `400`, not read as absent: a header sent to fence a write that silently degrades to unconditional last-writer-wins defeats the only thing it was sent to do. +`If-Match: ""` is accepted for optimistic concurrency on every endpoint that bumps a record's version — `PATCH /records/:id`, `DELETE /records/:id`, `POST /records/:id/undelete`, `POST /records/:id/restore/:version`, `POST /records/:id/migrate`, `POST /records/:id/associations`, and `POST /records/:id/associations/delete`. One `If-Match` fences a whole multi-aspect change set. A mismatch returns `412` with code `version_conflict` and a `versionConflict: { recordId, expectedVersion, actualVersion }` payload; omitting the header keeps last-writer-wins. A malformed value (not a bare, optionally quoted version — a weak comparator like `W/"5"` included) is `400`, not read as absent: a header sent to fence a write that silently degrades to unconditional last-writer-wins defeats the only thing it was sent to do. ### Query parameters @@ -147,21 +171,21 @@ A relationship association's target names one of three scopes — a Record, an i `DELETE /records/:id` without `?hard=true` leaves a **tombstone**: the record continues to exist and remains reachable by `id`, but its `content` is projected as `{}` and `deletedAt` is set. This applies uniformly everywhere the record is served with content attached — `GET /records/:id`, `GET`/`POST /records/query` with `filter.includeDeleted: true`, and a `deleted`-kind change feed frame with `?include=record` — so a client sees the same emptied shape regardless of which route it came through. `GET /records/:id/versions` and `GET /records/:id/versions/:version` are the deliberate exception: version history is never tombstoned and continues to serve full content. -A soft-deleted record refuses further mutation: `PATCH`, `POST .../associations`, `POST .../associations/delete`, `PUT .../permissions`, `PUT .../unlisted`, `POST .../migrate`, and `POST .../restore/:version` all return `409` with code `conflict` until `POST /records/:id/undelete` reverses the delete. `GET` and version-history reads are unaffected — the refusal applies only to mutation. +A soft-deleted record refuses further mutation: `PATCH`, `POST .../associations`, `POST .../associations/delete`, `POST .../migrate`, and `POST .../restore/:version` all return `409` with code `conflict` until `POST /records/:id/undelete` reverses the delete. `GET` and version-history reads are unaffected — the refusal applies only to mutation. ## Unlisted `unlistedAt` is orthogonal to `permissions`: it says nothing about who may read a record, only whether it is enumerable. A record with `unlistedAt` set is still reachable by `GET /records/:id` for anyone who may already read it — unlisted withholds a record from enumeration, never from access. ``` -PUT /records/:id/unlisted body: { "unlisted": boolean } +PATCH /records/:id body: { "unlisted": boolean } ``` -Answers `200` with the updated record — it bumps `version` like any other mutation, and is snapshotted to version history the same way — carrying `unlistedAt` when `true`, absent when `false`. Accepts the same optional `If-Match` precondition as every other mutating endpoint, and returns `409` on a soft-deleted record like the other mutating endpoints above. Gated exactly like `PUT .../permissions`: owner or creator, asked of both identities under delegation. +Answers `200` with the updated record — it bumps `version` like any other mutation, and is snapshotted to version history the same way — carrying `unlistedAt` when `true`, absent when `false`. Accepts the same optional `If-Match` precondition as every other mutating endpoint, and returns `409` on a soft-deleted record like the other mutating endpoints above. Gated exactly like the `permissions` key: owner or creator, asked of both identities under delegation. A non-boolean value is `422`, the shape error every change-set key answers with. **`includeUnlisted` is owner-only.** `GET /records`, `POST /records/query`, and `GET /changes` all accept it (excluded by default, like `includeDeleted`), and all three refuse it with `403` for any requester but the owner acting alone — enumeration standing rests on nothing but ownership, so no grant or delegation carries it. On `GET /changes` the refusal happens before the SSE stream opens. -A `POST /records` body may carry `unlistedAt` to create the record already unlisted, so there is no window where it exists and is enumerable before a later `PUT .../unlisted` catches up — presence of the field is what matters; its value is not honored (`unlistedAt` on the response is stamped to the real create time, like `version`). This is **not** owner-only, unlike `createdAt`/`updatedAt`: it's gated the same way the `permissions` field is at create time — refused with `403` for a delegated app acting for anyone but the owner, but available to any other creator holding a create grant for the type. +A `POST /records` body may carry `unlistedAt` to create the record already unlisted, so there is no window where it exists and is enumerable before a later change set catches up — presence of the field is what matters; its value is not honored (`unlistedAt` on the response is stamped to the real create time, like `version`). This is **not** owner-only, unlike `createdAt`/`updatedAt`: it's gated the same way the `permissions` field is at create time — refused with `403` for a delegated app acting for anyone but the owner, but available to any other creator holding a create grant for the type. ### The feed transitions @@ -187,9 +211,11 @@ This server mints resume cursors: `ready` carries the current head as `seq`, and Resume is per-connection state, not global: each distinct (session, filter) pairing gets its own buffer, retained for a bounded time and depth past the last connection using it — long enough for an ordinary reconnect, not indefinitely. A gap resumption can't close is exactly what `reset` exists for; a client's repair is the same either way — reconcile by querying `GET /records` / `POST /records/query`. A `purged` frame in a replayed backlog is never re-checked against current permissions (there is no record left to check, and the mutation-time decision is the only one that will ever exist); every other replayed frame is. -Query parameters, all optional and composable: `typeId` (repeatable, matched by baseId so a type-version bump never orphans a subscription), `parentId` (`"null"` selects root records, same as `GET /records`), `entityId` (the record's author, not who made the change), `kind` (repeatable: `created`, `changed`, `deleted`, `purged`), `include=record` (attach the record as of the change; ignored for `kind=purged`), `includeUnlisted` (owner-only — see [Unlisted](#unlisted)). Filtering is exact, not advisory — a filtered connection never receives a frame outside its filter, and an unrecognized `kind` or `include` value is a `400`. +Query parameters, all optional and composable: `typeId` (repeatable, matched by baseId so a type-version bump never orphans a subscription), `parentId` (`"null"` selects root records, same as `GET /records`), `entityId` (the record's author, not who made the change), `kind` (repeatable: `created`, `changed`, `deleted`, `purged`), `include=record` (attach the record as of the change; ignored for `kind=purged`), `includeUnlisted` (owner-only — see [Unlisted](#unlisted)). Filtering is exact, not advisory — a filtered connection never receives a frame it did not match, and an unrecognized `kind` or `include` value is a `400`. A `parentId` filter is the one place where matching and frame contents come apart, and a subscriber that reads the two as the same thing will misroute a move: + +A `reparent` is matched against **both** containers the move concerns, so a subscription filtered on the origin learns the record left it — the record's post-change state alone would answer only for the destination. The frame carries the destination in `parentId`, as every frame carries the record's state at the moment of the change, so a subscriber compares it against its own filter to tell a departure from an arrival. Its `kind` is `changed`, not `deleted`: the record is still there and still readable, only its container moved. -Every change (`event: record`) carries `kind`, `op`, `recordId`, `typeId`, `version`, `updatedAt`, and — when known — `actor` (who made the change; never the record's own author, which is what `entityId` filters on). `kind` is the coarse branch a handler can be complete on; `op` names the exact verb (`create`, `update`, `associate`, `dissociate`, `permissions`, `migrate`, `restore`, `delete`, `undelete`, `hard-delete`, `unlist`, `list`) for a consumer that distinguishes, say, a reshare from an edit. A `purged` frame — from a hard delete — carries none of `record`, `parentId`, or the record's own author, whatever the connection asked for: hard delete is the erasure primitive, and a frame naming what was destroyed is deliberately all that survives it. +Every change (`event: record`) carries `kind`, `ops`, `recordId`, `typeId`, `version`, `updatedAt`, and — when known — `actor` (who made the change; never the record's own author, which is what `entityId` filters on). `kind` is the coarse branch a handler can be complete on; `ops` names every aspect the version actually moved (`create`, `patch`, `associate`, `dissociate`, `permissions`, `reparent`, `migrate`, `restore`, `delete`, `undelete`, `hard-delete`, `unlist`, `list`) for a consumer that distinguishes, say, a reshare from an edit. It is derived by diffing the record against its prior state rather than read off the request, so a change set naming an aspect without moving it is never reported as moving it; it is never empty, and is multi-entry only for a change set, since every other op names a whole-record transition and is emitted alone. `kind` resolves to the most conservative entry — a change set carrying `unlist` is `deleted` whatever else it carries, because a subscriber holding the record still has to drop it. A `purged` frame — from a hard delete — carries none of `record`, `parentId`, or the record's own author, whatever the connection asked for: hard delete is the erasure primitive, and a frame naming what was destroyed is deliberately all that survives it. A record this connection may not read produces no frame at all — not an empty or redacted one — the same reasoning that keeps a count of the whole match off the query envelope: the existence of a change is itself a disclosure. @@ -248,4 +274,4 @@ A type-level grant names a specific entity (`grant(, ...)`) or no entity at ### Principal and subject -A token names two identities: the **principal**, who authenticated (governs authority — grant lookups, `setPermissions`), and the **subject**, who the principal acts for (governs attribution — `record.entityId` on writes, `-own` matching). They're equal unless the token was issued with `onBehalfOf`. A delegated write stamps both: `entityId` is the subject, and `principalId` appears on the record when the two differ. `GET /records` and `POST /records/query` can filter on either via `entityId`/`principalId`. Effective authority under delegation is the intersection of both parties' grants — a delegated app can't act beyond what the subject itself also permits. +A token names two identities: the **principal**, who authenticated (governs authority — grant lookups, a change set's `permissions` key), and the **subject**, who the principal acts for (governs attribution — `record.entityId` on writes, `-own` matching). They're equal unless the token was issued with `onBehalfOf`. A delegated write stamps both: `entityId` is the subject, and `principalId` appears on the record when the two differ. `GET /records` and `POST /records/query` can filter on either via `entityId`/`principalId`. Effective authority under delegation is the intersection of both parties' grants — a delegated app can't act beyond what the subject itself also permits. diff --git a/package.json b/package.json index 22d8f84..ba17a60 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "format:check": "prettier --check ." }, "dependencies": { - "@haverstack/adapter-local": "^0.26.0", - "@haverstack/commons": "^0.21.0", - "@haverstack/core": "^0.27.0", - "@haverstack/wire-types": "^0.26.0", + "@haverstack/adapter-local": "^0.30.0", + "@haverstack/commons": "^0.25.0", + "@haverstack/core": "^0.31.0", + "@haverstack/wire-types": "^0.30.0", "@hono/node-server": "^2.1.1", "hono": "^4.13.7", "pino": "^10.3.1", @@ -28,7 +28,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@haverstack/conformance-fixtures": "^0.20.0", + "@haverstack/conformance-fixtures": "^0.24.0", "@types/node": "^26.2.0", "eslint": "^10.8.1", "eslint-config-prettier": "^10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40a5c8f..438e843 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@haverstack/adapter-local': - specifier: ^0.26.0 - version: 0.26.0 + specifier: ^0.30.0 + version: 0.30.0 '@haverstack/commons': - specifier: ^0.21.0 - version: 0.21.0 + specifier: ^0.25.0 + version: 0.25.0 '@haverstack/core': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.31.0 + version: 0.31.0 '@haverstack/wire-types': - specifier: ^0.26.0 - version: 0.26.0 + specifier: ^0.30.0 + version: 0.30.0 '@hono/node-server': specifier: ^2.1.1 version: 2.1.1(hono@4.13.7) @@ -37,8 +37,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.8.1) '@haverstack/conformance-fixtures': - specifier: ^0.20.0 - version: 0.20.0 + specifier: ^0.24.0 + version: 0.24.0 '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -264,28 +264,28 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@haverstack/adapter-local@0.26.0': - resolution: {integrity: sha512-S7H7GSwKMJ+cB/9pyqf4278OOuQ63qtykJ0SeokR5X9bpLh887+nfwPHDVreV/TdAcsvAed/2Q7Tvw5rUSAWuA==} + '@haverstack/adapter-local@0.30.0': + resolution: {integrity: sha512-18dqqtB36qoS44hxO/14aqKo+ngajZOFJ0nVZ2qh+BmmBPgNNaG9lNhXO9PIvVEPvJtE4kxdJC9/+uCmtUCbOg==} engines: {node: '>=22.5.0'} - '@haverstack/blob-adapter-disk@0.25.0': - resolution: {integrity: sha512-fla7UDlf1j64d9pVsSMAqjUWDVxKgIZe/2pCzEyQ5ncTvV18vqTtGDjLP6X+Ju/pLeeuktnDBm427iM+nSJBJQ==} + '@haverstack/blob-adapter-disk@0.29.0': + resolution: {integrity: sha512-P77ell2zy4UAoPj1ZRRBOwfwpdURusCqU93mBcVIav+hf2kRR0D1Hq+KdxPX2Ft7c5jdMFh5z+IaGyXpMpxcAw==} - '@haverstack/commons@0.21.0': - resolution: {integrity: sha512-DXFOXtGyWR6RSrFKDXy8A4c9bESCcKEwujjgyn7i/72/S1WJFUb9DyqpzHWjAJokR+1wHWxsaEKU0IdU0/2x3A==} + '@haverstack/commons@0.25.0': + resolution: {integrity: sha512-1MwoTPcaEuOv75bm8dbYWg/PVHI4oABpmTVZ6XpervUGyYJLDuLDW5pRgz2/LsGJZM5NHRBNQyca52d99zgPcA==} - '@haverstack/conformance-fixtures@0.20.0': - resolution: {integrity: sha512-or1hQR2hiIF9/vAWtBnNUqcUA2BnfEqskZfo0zDUE9FjAlDBLeDHV57sXVZr9lP4Qvv3IWq2fbiOKl2FU/s5NA==} + '@haverstack/conformance-fixtures@0.24.0': + resolution: {integrity: sha512-XXG9WYKK/dtHwhU1/kzWeZn0Viflj0YmNwZ6cVbKPIMDiF5iKFB/d2GS55pzz0tiNQO4IpGbe0wT5AzPG+P5KA==} - '@haverstack/core@0.27.0': - resolution: {integrity: sha512-++qnrnmgCAIA6vvUiwkTC8OifurCIC9/klQoQW9vfHNEujh03XAFiAuPm6doNvKYpP4zTanZGNHdkoyiSJgKGQ==} + '@haverstack/core@0.31.0': + resolution: {integrity: sha512-r/cCuTE+39oEJ7ib6gLc2yoMrK2+9QfVWDX2QhdlG77msxZDLjijcx6djNrDbaZRUo1heCIDpgJunSUhjOVG+Q==} - '@haverstack/record-adapter-sqlite@0.19.0': - resolution: {integrity: sha512-/ju4wrNtaIp1JdbC56zG8KqE/mcQqyZvzPmNjudpVb+EwGCIfTUXgWvG+GzoQfthjqLNNzXyRYVN0ufG4b3Rog==} + '@haverstack/record-adapter-sqlite@0.23.0': + resolution: {integrity: sha512-z+Cd6qp4D3U/2ezlf5U3R5nFYTFMpF4Ax8ipfjc0oXK1yQdRc9zWquvO5sKI8sEELBYv5r8V8rjJwqkkn54NOA==} engines: {node: '>=22.5.0'} - '@haverstack/wire-types@0.26.0': - resolution: {integrity: sha512-anOV4QRRGing1J3u9kpaFj39A6MeOgYMsBPiHrt23TPNLRhFNp4slhPRhTP621BSFGJ3tf5XL1fZcfOQNTBVmw==} + '@haverstack/wire-types@0.30.0': + resolution: {integrity: sha512-SGMDol6lVi0JJUCnw4bGzohYu2HF0rSU/dmUPlCvh5/IapGr9SXIklPEcVhlH3ycf8e45rxWjksp4B/ajzPoLg==} '@hono/node-server@2.1.1': resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} @@ -1261,33 +1261,33 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@haverstack/adapter-local@0.26.0': + '@haverstack/adapter-local@0.30.0': dependencies: - '@haverstack/blob-adapter-disk': 0.25.0 - '@haverstack/core': 0.27.0 - '@haverstack/record-adapter-sqlite': 0.19.0 + '@haverstack/blob-adapter-disk': 0.29.0 + '@haverstack/core': 0.31.0 + '@haverstack/record-adapter-sqlite': 0.23.0 - '@haverstack/blob-adapter-disk@0.25.0': + '@haverstack/blob-adapter-disk@0.29.0': dependencies: - '@haverstack/core': 0.27.0 + '@haverstack/core': 0.31.0 - '@haverstack/commons@0.21.0': + '@haverstack/commons@0.25.0': dependencies: - '@haverstack/core': 0.27.0 + '@haverstack/core': 0.31.0 - '@haverstack/conformance-fixtures@0.20.0': + '@haverstack/conformance-fixtures@0.24.0': dependencies: - '@haverstack/wire-types': 0.26.0 + '@haverstack/wire-types': 0.30.0 - '@haverstack/core@0.27.0': {} + '@haverstack/core@0.31.0': {} - '@haverstack/record-adapter-sqlite@0.19.0': + '@haverstack/record-adapter-sqlite@0.23.0': dependencies: - '@haverstack/core': 0.27.0 + '@haverstack/core': 0.31.0 - '@haverstack/wire-types@0.26.0': + '@haverstack/wire-types@0.30.0': dependencies: - '@haverstack/core': 0.27.0 + '@haverstack/core': 0.31.0 '@hono/node-server@2.1.1(hono@4.13.7)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fc26e4d..596db8a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,10 +1,13 @@ allowBuilds: esbuild: true +# pnpm 11 waits `minimumReleaseAge` (default 1440 — a day) before it will +# resolve a newly published version, and appends every package it had to +# hold to this list on the install that hits one. Haverstack releases in +# lockstep, so a sync lands the whole scope on its release day and every +# one of them was being written back here, a version at a time. +# +# The scope pattern says once what those entries said one by one: these are +# this project's own packages, published by its own CI, and the cooldown is +# not the check that vets them. Third-party dependencies keep it. minimumReleaseAgeExclude: - - '@haverstack/adapter-local@0.20.0 || 0.25.0 || 0.26.0' - - '@haverstack/blob-adapter-disk@0.19.0 || 0.24.0 || 0.25.0' - - '@haverstack/commons@0.15.0 || 0.20.0 || 0.21.0' - - '@haverstack/conformance-fixtures@0.14.0 || 0.19.0 || 0.20.0' - - '@haverstack/core@0.21.0 || 0.26.0 || 0.27.0' - - '@haverstack/record-adapter-sqlite@0.13.0 || 0.18.0 || 0.19.0' - - '@haverstack/wire-types@0.20.0 || 0.25.0 || 0.26.0' + - '@haverstack/*' diff --git a/src/routes/entity.ts b/src/routes/entity.ts index 5acd067..2444224 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -25,7 +25,7 @@ export function entityRoutes(ctx: StackContext): Hono { const body = await readJson>(c); const updated = await stack .forSession(auth) - .update(record.id, (body.content ?? {}) as Record); + .patchContent(record.id, (body.content ?? {}) as Record); return c.json(serializeRecord(updated)); }); diff --git a/src/routes/records.ts b/src/routes/records.ts index bb4473c..eccf303 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -10,12 +10,13 @@ import { parsePositiveInt, parseIfMatch, createOptionsFromWireRecord, + changesFromWireBody, } from '@haverstack/core/wire'; import { clampLimit } from '../lib/queryLimit.js'; import { serializeRecord, serializeVersion } from '@haverstack/wire-types'; import type { WireQueryResponse } from '@haverstack/wire-types'; import { StackValidationError, StackQueryError, StackNotFoundError } from '@haverstack/core'; -import type { Association, Permission, TypeId } from '@haverstack/core'; +import type { Association, TypeId } from '@haverstack/core'; // --------------------------------------------------------------------------- // Route factory @@ -89,25 +90,32 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { const id = c.req.param('id'); const auth = c.get('auth')!; - const patch = await readJson>(c); + const changes = changesFromWireBody(await readJson(c)); const updated = await stack .forSession(auth) - .update(id, patch, { ifVersion: parseIfMatch(c.req.header('If-Match')) }); + .mutate(id, changes, { ifVersion: parseIfMatch(c.req.header('If-Match')) }); return c.json(serializeRecord(updated)); }); @@ -148,36 +156,6 @@ export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono { - const id = c.req.param('id'); - const auth = c.get('auth')!; - const body = await readJson<{ permissions: Permission[] }>(c); - if (!Array.isArray(body.permissions)) throw new StackQueryError('permissions must be an array'); - const updated = await stack.forSession(auth).setPermissions(id, body.permissions, { - ifVersion: parseIfMatch(c.req.header('If-Match')), - }); - return c.json(serializeRecord(updated)); - }); - - // ------------------------------------------------------------------ - // Unlisted - // ------------------------------------------------------------------ - - // PUT /records/:id/unlisted — withhold from enumeration, or relist. - // Orthogonal to permissions: decides whether the record is enumerable, - // never who may read it. The owner-or-creator gate and the 409 on a - // soft-deleted record both come from ScopedStack.setUnlisted(). - app.put('/:id/unlisted', requireAuth(), async (c) => { - const id = c.req.param('id'); - const auth = c.get('auth')!; - const body = await readJson<{ unlisted: boolean }>(c); - if (typeof body.unlisted !== 'boolean') throw new StackQueryError('unlisted must be a boolean'); - const updated = await stack.forSession(auth).setUnlisted(id, body.unlisted, { - ifVersion: parseIfMatch(c.req.header('If-Match')), - }); - return c.json(serializeRecord(updated)); - }); - // ------------------------------------------------------------------ // Associations // ------------------------------------------------------------------ diff --git a/tests/app.test.ts b/tests/app.test.ts index 11ee056..c9a93b3 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -36,7 +36,7 @@ describe('request body size limit', () => { const { status, data } = await req(smallApp, 'PATCH', `/records/${id}`, { token: TEST_TOKEN, - body: { body: 'x'.repeat(6000) }, + body: { contentPatch: { body: 'x'.repeat(6000) } }, }); expect(status).toBe(413); expect((data as { error: { code: string } }).error.code).toBe('payload_too_large'); diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index ea1bae1..0973845 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -38,7 +38,7 @@ import { undeleteRecordFixtures, associateFixtures, dissociateFixtures, - setPermissionsFixtures, + permissionsChangeFixtures, getVersionsFixtures, getVersionFixtures, getVersionsAfterMutateFixtures, @@ -50,7 +50,8 @@ import { authSequenceFixtures, changeFeedFixtures, changeFeedSequenceFixtures, - setUnlistedFixtures, + unlistedChangeFixtures, + parentChangeFixtures, attachmentDownloadFixtures, attachmentUploadFixtures, AUTH_FIXTURE_DID, @@ -762,14 +763,14 @@ describe('dissociate fixtures', () => { // Permissions // ------------------------------------------------------- -describe('setPermissions fixtures', () => { +describe('permissions change fixtures', () => { const handled = new Set(); test('set-permissions-public', async () => { - const fixture = setPermissionsFixtures.find((f) => f.name === 'set-permissions-public')!; + const fixture = permissionsChangeFixtures.find((f) => f.name === 'set-permissions-public')!; handled.add(fixture.name); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status } = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { + const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: fixture.requestBody, }); @@ -779,7 +780,7 @@ describe('setPermissions fixtures', () => { }); test('set-permissions-empty-is-private', async () => { - const fixture = setPermissionsFixtures.find( + const fixture = permissionsChangeFixtures.find( (f) => f.name === 'set-permissions-empty-is-private', )!; handled.add(fixture.name); @@ -788,7 +789,7 @@ describe('setPermissions fixtures', () => { { title: 'x' }, { permissions: [{ access: 'public' }] }, ); - const { status } = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { + const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: fixture.requestBody, }); @@ -802,7 +803,7 @@ describe('setPermissions fixtures', () => { test('coverage', () => { assertCoverage( - setPermissionsFixtures.map((f) => f.name), + permissionsChangeFixtures.map((f) => f.name), handled, new Set(), ); @@ -849,7 +850,7 @@ describe('version lifecycle fixtures', () => { // /versions returns historical snapshots, not the current live state — // a record still at its original version has no history yet, so bump // it once to give version 1 a snapshot to appear in. - await t.ctx.stack.update(record.id, { title: 'updated title' }); + await t.ctx.stack.patchContent(record.id, { title: 'updated title' }); const owner = await req(t.app, 'GET', `/records/${record.id}/versions`, { token: TEST_TOKEN }); expect(owner.status).toBe(ownerFixture.responseStatus); @@ -894,12 +895,12 @@ describe('version lifecycle fixtures', () => { // pre-restore auto-snapshot colliding with the create itself. await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { title: 'intermediate title' }, + body: { contentPatch: { title: 'intermediate title' } }, }); // v3 — the state restore-version will move away from await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { title: 'title before restore' }, + body: { contentPatch: { title: 'title before restore' } }, }); // v3 (auto pre-restore snapshot of v2) + v4 (restored content, from v1) @@ -941,6 +942,54 @@ describe('version lifecycle fixtures', () => { expect(v4Snapshot.typeId).toBe(NOTE_TYPE); }); + test('a snapshot carries its container, and a restore puts the record back in it', async () => { + const snapshotFixture = getVersionFixtures.find( + (f) => f.name === 'get-version-carries-the-container-the-snapshot-was-taken-in', + )!; + const restoreFixture = restoreVersionFixtures.find( + (f) => f.name === 'restore-version-puts-the-record-back-in-its-old-container', + )!; + handled.add(snapshotFixture.name); + handled.add(restoreFixture.name); + + const container = await t.ctx.stack.create(NOTE_TYPE, { title: 'Container' }); + // v1, in the container + const record = await t.ctx.stack.create( + NOTE_TYPE, + { title: 'original title' }, + { parentId: container.id }, + ); + // v2 — the state the restore below will return to, still in the container + await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: { contentPatch: { title: 'title before restore' } }, + }); + // v3 — moved to the root, which is what gives v2 a container to have been in + await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: { parentId: null }, + }); + + const snapshot = await req(t.app, 'GET', `/records/${record.id}/versions/2`, { + token: TEST_TOKEN, + }); + expect(snapshot.status).toBe(snapshotFixture.responseStatus); + const v2 = snapshot.data as { parentId?: string; content: unknown }; + expect(v2.parentId).toBe(container.id); + expect(v2.content).toEqual(snapshotFixture.responseBody!.content); + + const restore = await req(t.app, 'POST', `/records/${record.id}/restore/2`, { + token: TEST_TOKEN, + }); + expect(restore.status).toBe(restoreFixture.responseStatus); + const restored = restore.data as { parentId?: string; content: unknown; version: number }; + // A restore settles containment rather than leaving the record where it + // sits: the snapshot names the container, so the record goes back to it. + expect(restored.parentId).toBe(container.id); + expect(restored.content).toEqual(restoreFixture.responseBody!.content); + expect(restored.version).toBe(4); + }); + test('coverage', () => { const names = [ ...getVersionsFixtures.map((f) => f.name), @@ -1022,11 +1071,28 @@ describe('error response fixtures', () => { test('error-validation-permission-write-without-read', async () => { const fixture = find('error-validation-permission-write-without-read'); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); - const { status, data } = await dispatch( - fixture, - TEST_TOKEN, - `/records/${record.id}/permissions`, - ); + const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`); + expectError(status, data, fixture); + }); + + test('error-bad-request-malformed-parent-id', async () => { + const fixture = find('error-bad-request-malformed-parent-id'); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); + // Format is checked before existence, so the empty string answers for + // being malformed rather than for naming nothing. It is not a spelling + // of the root here — `null` is. + const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`); + expectError(status, data, fixture); + }); + + test('error-conflict-parent-does-not-exist', async () => { + const fixture = find('error-conflict-parent-does-not-exist'); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }); + // Well-formed and simply names nothing, which is the conflict. Only the + // owner sees it: the reference gate exempts them, so the existence check + // behind it is theirs to reach. Anyone else gets one indistinguishable + // 403 for a destination they cannot read and one that is not there. + const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`); expectError(status, data, fixture); }); @@ -1377,7 +1443,7 @@ describe('changeFeed fixtures', () => { expect(frame.id).toBe((frameData(frame) as { seq?: string }).seq); const data = frameData(frame); expect(data.kind).toBe('created'); - expect(data.op).toBe('create'); + expect(data.ops).toEqual(['create']); expect(data.recordId).toBe(recordId); expect(data.typeId).toBe(NOTE_TYPE); expect(data.version).toBe(1); @@ -1403,12 +1469,12 @@ describe('changeFeed fixtures', () => { await conn.waitForFrames(1); // ready await req(t.app, 'PATCH', `/records/${record.id}`, { token, - body: { title: 'edited by a contributor' }, + body: { contentPatch: { title: 'edited by a contributor' } }, }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); expect(data.kind).toBe('changed'); - expect(data.op).toBe('update'); + expect(data.ops).toEqual(['patch']); expect(data.recordId).toBe(record.id); expect((data.actor as { entityId: string }).entityId).toBe(CONTRIBUTOR_ID); } finally { @@ -1429,7 +1495,7 @@ describe('changeFeed fixtures', () => { const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); expect(data.kind).toBe('deleted'); - expect(data.op).toBe('delete'); + expect(data.ops).toEqual(['delete']); expect(data.recordId).toBe(record.id); } finally { await conn.close(); @@ -1454,7 +1520,7 @@ describe('changeFeed fixtures', () => { const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); expect(data.kind).toBe('purged'); - expect(data.op).toBe('hard-delete'); + expect(data.ops).toEqual(['hard-delete']); expect(data.recordId).toBe(record.id); expect(data.typeId).toBe(NOTE_TYPE); expect('record' in data).toBe(false); @@ -1479,7 +1545,7 @@ describe('changeFeed fixtures', () => { await conn.waitForFrames(1); // ready await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { title: 'Updated title' }, + body: { contentPatch: { title: 'Updated title' } }, }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); @@ -1508,11 +1574,11 @@ describe('changeFeed fixtures', () => { await conn.waitForFrames(1); // ready await req(t.app, 'PATCH', `/records/${privateRecord.id}`, { token: TEST_TOKEN, - body: { title: 'still private' }, + body: { contentPatch: { title: 'still private' } }, }); await req(t.app, 'PATCH', `/records/${sharedRecord.id}`, { token: TEST_TOKEN, - body: { title: 'shared, updated' }, + body: { contentPatch: { title: 'shared, updated' } }, }); // Exactly one more frame — the readable record's — proves the // private edit produced none rather than merely arriving later. @@ -1538,7 +1604,7 @@ describe('changeFeed fixtures', () => { await conn.waitForFrames(1); // ready await req(t.app, 'PATCH', `/records/${comment.id}`, { token: TEST_TOKEN, - body: { body: 'still unrelated' }, + body: { contentPatch: { body: 'still unrelated' } }, }); await req(t.app, 'POST', `/records/${note.id}/migrate`, { token: TEST_TOKEN, @@ -1550,7 +1616,7 @@ describe('changeFeed fixtures', () => { const data = frameData(frame); expect(data.recordId).toBe(note.id); expect(data.typeId).toBe(NOTE_TYPE_V2); - expect(data.op).toBe('migrate'); + expect(data.ops).toEqual(['migrate']); } finally { await conn.close(); } @@ -1594,14 +1660,14 @@ describe('changeFeed fixtures', () => { const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { unlisted: true }, }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); expect(data.kind).toBe('deleted'); - expect(data.op).toBe('unlist'); + expect(data.ops).toEqual(['unlist']); expect(data.recordId).toBe(record.id); } finally { await conn.close(); @@ -1614,18 +1680,18 @@ describe('changeFeed fixtures', () => { )!; handled.add(fixture.name); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }); - await t.ctx.stack.setUnlisted(record.id, true); + await t.ctx.stack.mutate(record.id, { unlisted: true }); const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { unlisted: false }, }); const [, frame] = await conn.waitForFrames(2); const data = frameData(frame); expect(data.kind).toBe('changed'); - expect(data.op).toBe('list'); + expect(data.ops).toEqual(['list']); expect(data.recordId).toBe(record.id); } finally { await conn.close(); @@ -1638,18 +1704,18 @@ describe('changeFeed fixtures', () => { )!; handled.add(fixture.name); const unlistedRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'Unlisted' }); - await t.ctx.stack.setUnlisted(unlistedRecord.id, true); + await t.ctx.stack.mutate(unlistedRecord.id, { unlisted: true }); const visibleRecord = await t.ctx.stack.create(NOTE_TYPE, { title: 'Visible' }); const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready await req(t.app, 'PATCH', `/records/${unlistedRecord.id}`, { token: TEST_TOKEN, - body: { title: 'Edited while unlisted' }, + body: { contentPatch: { title: 'Edited while unlisted' } }, }); await req(t.app, 'PATCH', `/records/${visibleRecord.id}`, { token: TEST_TOKEN, - body: { title: 'Edited, visible' }, + body: { contentPatch: { title: 'Edited, visible' } }, }); // Exactly one more frame — the visible record's — proves the // unlisted edit produced none rather than merely arriving later. @@ -1661,6 +1727,43 @@ describe('changeFeed fixtures', () => { } }); + test('change-feed-reparent-reaches-the-container-a-record-left', async () => { + const fixture = changeFeedFixtures.find( + (f) => f.name === 'change-feed-reparent-reaches-the-container-a-record-left', + )!; + handled.add(fixture.name); + + const origin = await t.ctx.stack.create(NOTE_TYPE, { title: 'Origin' }); + const destination = await t.ctx.stack.create(NOTE_TYPE, { title: 'Destination' }); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello' }, { parentId: origin.id }); + + // Filtered on the container the record is about to leave: the record's + // post-change state names only the destination, so a subscriber watching + // the origin hears about the departure only because a reparent is + // matched against both sides of the move. + const conn = await openChangeFeed(t.app, `/changes?parentId=${origin.id}`, { + token: TEST_TOKEN, + }); + try { + await conn.waitForFrames(1); // ready + await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: { parentId: destination.id }, + }); + const [, frame] = await conn.waitForFrames(2); + const data = frameData(frame); + // Still there and still readable — only its container moved. + expect(data.kind).toBe('changed'); + expect(data.ops).toEqual(['reparent']); + expect(data.recordId).toBe(record.id); + // The frame carries the destination, as every frame carries the + // record's state at the moment of the change. + expect(data.parentId).toBe(destination.id); + } finally { + await conn.close(); + } + }); + test('coverage', () => { assertCoverage( changeFeedFixtures.map((f) => f.name), @@ -1691,7 +1794,7 @@ describe('changeFeed sequence fixtures', () => { expect(frameData(ready).seq).toMatch(SEQ_PATTERN); await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { title: 'first' }, + body: { contentPatch: { title: 'first' } }, }); const [, changeFrame] = await first.waitForFrames(2); expect(changeFrame.event).toBe('record'); @@ -1705,7 +1808,7 @@ describe('changeFeed sequence fixtures', () => { // past disconnect) keeps collecting on its own. await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { title: 'second' }, + body: { contentPatch: { title: 'second' } }, }); // Step 2: reconnect presenting the last id seen. ready still leads, @@ -1931,24 +2034,26 @@ describe('auth handshake fixtures', () => { }); // ------------------------------------------------------- -// Records: setUnlisted (new in fixtures 0.8.0) +// Records: the unlisted change-set key // ------------------------------------------------------- -describe('setUnlisted fixtures', () => { +describe('unlisted change fixtures', () => { const handled = new Set(); // Run together against one seeded record, same as the versions block // below — set-unlisted-false-relists assumes set-unlisted-true already // ran (its own description says so). test('set-unlisted-true then set-unlisted-false-relists', async () => { - const trueFixture = setUnlistedFixtures.find((f) => f.name === 'set-unlisted-true')!; - const falseFixture = setUnlistedFixtures.find((f) => f.name === 'set-unlisted-false-relists')!; + const trueFixture = unlistedChangeFixtures.find((f) => f.name === 'set-unlisted-true')!; + const falseFixture = unlistedChangeFixtures.find( + (f) => f.name === 'set-unlisted-false-relists', + )!; handled.add(trueFixture.name); handled.add(falseFixture.name); const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); - const unlisted = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + const unlisted = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: trueFixture.requestBody, }); @@ -1961,7 +2066,7 @@ describe('setUnlisted fixtures', () => { (queried.data as { records: WireRecord[] }).records.some((r) => r.id === record.id), ).toBe(false); - const relisted = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + const relisted = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: falseFixture.requestBody, }); @@ -1971,7 +2076,60 @@ describe('setUnlisted fixtures', () => { test('coverage', () => { assertCoverage( - setUnlistedFixtures.map((f) => f.name), + unlistedChangeFixtures.map((f) => f.name), + handled, + new Set(), + ); + }); +}); + +// ------------------------------------------------------- +// Records: the parentId change-set key +// ------------------------------------------------------- + +describe('parent change fixtures', () => { + const handled = new Set(); + + // Run together against one seeded record: the null fixture moves a + // record back to the root, which only means anything once something + // has moved it off the root. + test('set-parent-moves-a-record-into-a-container then back to the root', async () => { + const intoFixture = parentChangeFixtures.find( + (f) => f.name === 'set-parent-moves-a-record-into-a-container', + )!; + const rootFixture = parentChangeFixtures.find( + (f) => f.name === 'set-parent-null-moves-a-record-to-the-root', + )!; + handled.add(intoFixture.name); + handled.add(rootFixture.name); + + // The destination has to exist: a caller-named parentId is checked for + // format and then for existence, so the fixture's own literal id would + // answer 409 here rather than moving anything. + const container = await t.ctx.stack.create(NOTE_TYPE, { title: 'Container' }); + const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'Hello', body: 'World' }); + + const moved = await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: { ...intoFixture.requestBody, parentId: container.id }, + }); + expect(moved.status).toBe(intoFixture.responseStatus); + expect((moved.data as WireRecord).parentId).toBe(container.id); + // A move touches containment and nothing else. + expect((moved.data as WireRecord).content).toEqual({ title: 'Hello', body: 'World' }); + + const toRoot = await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: rootFixture.requestBody, + }); + expect(toRoot.status).toBe(rootFixture.responseStatus); + // State spells the root by omission, where the input spelled it null. + expect((toRoot.data as WireRecord).parentId).toBeUndefined(); + }); + + test('coverage', () => { + assertCoverage( + parentChangeFixtures.map((f) => f.name), handled, new Set(), ); diff --git a/tests/lib/resumeBuffer.test.ts b/tests/lib/resumeBuffer.test.ts index bb00e45..91d3943 100644 --- a/tests/lib/resumeBuffer.test.ts +++ b/tests/lib/resumeBuffer.test.ts @@ -8,7 +8,7 @@ function change(overrides: Partial = {}): RecordChange { counter += 1; return { kind: 'changed', - op: 'update', + ops: ['patch'], recordId: `record-${counter}`, typeId: 'com.example/note@1', version: counter, diff --git a/tests/routes/changes.test.ts b/tests/routes/changes.test.ts index 1a31d40..261a40c 100644 --- a/tests/routes/changes.test.ts +++ b/tests/routes/changes.test.ts @@ -84,7 +84,7 @@ describe('GET /changes', () => { await conn.waitForFrames(1); // ready await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { title: 'still private' }, + body: { contentPatch: { title: 'still private' } }, }); // Prove the connection is otherwise live (so a missing frame above // isn't just a dead connection) by making a public edit land. @@ -95,7 +95,7 @@ describe('GET /changes', () => { ); await req(t.app, 'PATCH', `/records/${publicRecord.id}`, { token: TEST_TOKEN, - body: { title: 'still public' }, + body: { contentPatch: { title: 'still public' } }, }); const [, frame] = await conn.waitForFrames(2); expect((frame.data as { recordId: string }).recordId).toBe(publicRecord.id); @@ -209,9 +209,9 @@ describe('GET /changes', () => { // While disconnected: edit recordA (still readable at that moment, // so it's buffered), then revoke recordA's grant, then edit recordB // (still readable throughout). - await t.ctx.stack.update(recordA.id, { title: 'a, edited' }); - await t.ctx.stack.setPermissions(recordA.id, []); - await t.ctx.stack.update(recordB.id, { title: 'b, edited' }); + await t.ctx.stack.patchContent(recordA.id, { title: 'a, edited' }); + await t.ctx.stack.mutate(recordA.id, { permissions: [] }); + await t.ctx.stack.patchContent(recordB.id, { title: 'b, edited' }); const resumed = await openChangeFeed(t.app, '/changes', { token, @@ -277,7 +277,7 @@ describe('GET /changes', () => { // delivery through its own permission-check chain), so give each // append a moment to land before relying on the eviction it causes. for (const title of ['v2', 'v3', 'v4']) { - await t.ctx.stack.update(record.id, { title }); + await t.ctx.stack.patchContent(record.id, { title }); await new Promise((resolve) => setTimeout(resolve, 20)); } @@ -452,7 +452,7 @@ describe('GET /changes', () => { }); try { await conn.waitForFrames(1); // ready - await t.ctx.stack.update(record.id, { title: 'y' }); + await t.ctx.stack.patchContent(record.id, { title: 'y' }); const [, frame] = await conn.waitForFrames(2); expect((frame.data as { recordId: string }).recordId).toBe(record.id); } finally { @@ -509,12 +509,12 @@ describe('GET /changes', () => { const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await t.ctx.stack.setUnlisted(record.id, true); + await t.ctx.stack.mutate(record.id, { unlisted: true }); const [, frame] = await conn.waitForFrames(2); - const data = frame.data as { kind: string; op: string; recordId: string }; + const data = frame.data as { kind: string; ops: string[]; recordId: string }; expect(data.recordId).toBe(record.id); expect(data.kind).toBe('deleted'); - expect(data.op).toBe('unlist'); + expect(data.ops).toEqual(['unlist']); } finally { await conn.close(); } @@ -525,7 +525,7 @@ describe('GET /changes', () => { const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await t.ctx.stack.update(record.id, { title: 'edited' }); + await t.ctx.stack.patchContent(record.id, { title: 'edited' }); const proof = await t.ctx.stack.create(NOTE_TYPE, { title: 'public' }); const [, frame] = await conn.waitForFrames(2); expect((frame.data as { recordId: string }).recordId).toBe(proof.id); @@ -539,12 +539,12 @@ describe('GET /changes', () => { const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN }); try { await conn.waitForFrames(1); // ready - await t.ctx.stack.setUnlisted(record.id, false); + await t.ctx.stack.mutate(record.id, { unlisted: false }); const [, frame] = await conn.waitForFrames(2); - const data = frame.data as { kind: string; op: string; recordId: string }; + const data = frame.data as { kind: string; ops: string[]; recordId: string }; expect(data.recordId).toBe(record.id); expect(data.kind).toBe('changed'); - expect(data.op).toBe('list'); + expect(data.ops).toEqual(['list']); } finally { await conn.close(); } diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts index 584a223..9f901a0 100644 --- a/tests/routes/records.test.ts +++ b/tests/routes/records.test.ts @@ -501,7 +501,7 @@ describe('Records', () => { const record = await seedRecord(t.ctx, { body: 'original', title: 'My title' }); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { body: 'Updated body' }, + body: { contentPatch: { body: 'Updated body' } }, }); expect(status).toBe(200); const d = data as Record; @@ -515,7 +515,7 @@ describe('Records', () => { const record = await seedRecord(t.ctx); await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { body: 'v2' }, + body: { contentPatch: { body: 'v2' } }, }); const versions = await t.ctx.adapter.getVersions(record.id); expect(versions).toHaveLength(1); @@ -533,7 +533,7 @@ describe('Records', () => { const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token, - body: { body: 'hacked' }, + body: { contentPatch: { body: 'hacked' } }, }); expect(status).toBe(403); }); @@ -633,7 +633,7 @@ describe('Records', () => { const { record } = await seedAttachment(t.ctx); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { fileId: 'tampered-id' }, + body: { contentPatch: { fileId: 'tampered-id' } }, }); expect(status).toBe(422); expect(detailPaths(data)).toContain('fileId'); @@ -643,7 +643,7 @@ describe('Records', () => { const { record } = await seedAttachment(t.ctx); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { size: 9999 }, + body: { contentPatch: { size: 9999 } }, }); expect(status).toBe(422); expect(detailPaths(data)).toContain('size'); @@ -653,7 +653,7 @@ describe('Records', () => { const { record } = await seedAttachment(t.ctx); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { mimeType: 'text/html' }, + body: { contentPatch: { mimeType: 'text/html' } }, }); expect(status).toBe(422); expect(detailPaths(data)).toContain('mimeType'); @@ -663,7 +663,7 @@ describe('Records', () => { const { record } = await seedAttachment(t.ctx); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { fileId: 'x', size: 0, mimeType: 'text/html' }, + body: { contentPatch: { fileId: 'x', size: 0, mimeType: 'text/html' } }, }); expect(status).toBe(422); const paths = detailPaths(data); @@ -676,7 +676,7 @@ describe('Records', () => { const { record } = await seedAttachment(t.ctx); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { filename: 'renamed.txt' }, + body: { contentPatch: { filename: 'renamed.txt' } }, }); expect(status).toBe(200); const content = (data as { content: Record }).content; @@ -711,7 +711,7 @@ describe('Records', () => { const { status } = await req(t.app, 'PATCH', `/records/${grantRecord.id}`, { token, - body: { actions: ['read-any', 'update-any', 'delete-any'] }, + body: { contentPatch: { actions: ['read-any', 'update-any', 'delete-any'] } }, }); expect(status).toBe(403); }); @@ -745,7 +745,7 @@ describe('Records', () => { const { status, data } = await req(t.app, 'PATCH', `/records/${grantRecord.id}`, { token: TEST_TOKEN, - body: { actions: ['read-own', 'read-any'] }, + body: { contentPatch: { actions: ['read-own', 'read-any'] } }, }); expect(status).toBe(200); const content = (data as { content: Record }).content; @@ -781,7 +781,9 @@ describe('Records', () => { const { status } = await req(t.app, 'PATCH', `/records/${readGrant.id}`, { token, - body: { typeId: NOTE_TYPE_ID, actions: ['read-any', 'update-any', 'delete-any'] }, + body: { + contentPatch: { typeId: NOTE_TYPE_ID, actions: ['read-any', 'update-any', 'delete-any'] }, + }, }); expect(status).toBe(403); @@ -808,7 +810,7 @@ describe('Records', () => { it('refuses to change _config.entityId', async () => { const { status } = await req(t.app, 'PATCH', '/records/_config', { token: TEST_TOKEN, - body: { entityId: 'someone-else' }, + body: { contentPatch: { entityId: 'someone-else' } }, }); expect(status).toBe(409); }); @@ -882,19 +884,19 @@ describe('Records', () => { const { status: didChange } = await req(t.app, 'PATCH', `/records/${id}`, { token: TEST_TOKEN, - body: { did: 'did:key:zChanged' }, + body: { contentPatch: { did: 'did:key:zChanged' } }, }); expect(didChange).toBe(422); const { status: appIdChange } = await req(t.app, 'PATCH', `/records/${id}`, { token: TEST_TOKEN, - body: { appId: 'changed' }, + body: { contentPatch: { appId: 'changed' } }, }); expect(appIdChange).toBe(422); const { status: nameChange, data: renamed } = await req(t.app, 'PATCH', `/records/${id}`, { token: TEST_TOKEN, - body: { name: 'Renamed' }, + body: { contentPatch: { name: 'Renamed' } }, }); expect(nameChange).toBe(200); expect((renamed as { content: { name: string } }).content.name).toBe('Renamed'); @@ -1271,7 +1273,7 @@ describe('Records', () => { const record = await seedRecord(t.ctx); const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { body: 'updated' }, + body: { contentPatch: { body: 'updated' } }, headers: { 'If-Match': `"${record.version}"` }, }); expect(status).toBe(200); @@ -1281,7 +1283,7 @@ describe('Records', () => { const record = await seedRecord(t.ctx); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { body: 'updated' }, + body: { contentPatch: { body: 'updated' } }, headers: { 'If-Match': `"${record.version + 1}"` }, }); expect(status).toBe(412); @@ -1351,11 +1353,11 @@ describe('Records', () => { }); }); - describe('PUT/GET /records/:id/permissions', () => { - it('PUT replaces permissions and returns the updated record', async () => { + describe('the permissions change-set key, and GET /records/:id/permissions', () => { + it('the permissions key replaces permissions and returns the updated record', async () => { const record = await seedRecord(t.ctx); - const res = await t.app.request(`/records/${record.id}/permissions`, { - method: 'PUT', + const res = await t.app.request(`/records/${record.id}`, { + method: 'PATCH', headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ permissions: [{ access: 'public' }] }), }); @@ -1376,7 +1378,7 @@ describe('Records', () => { { body: 'was public' }, { permissions: [{ access: 'public' }] }, ); - await req(t.app, 'PUT', `/records/${record.id}/permissions`, { + await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { permissions: [] }, }); @@ -1401,7 +1403,7 @@ describe('Records', () => { expect((data as { permissions: unknown[] }).permissions).toEqual([{ access: 'public' }]); }); - it('returns 403 when a non-owner without write access tries to PUT', async () => { + it('returns 403 when a non-owner without write access names the key', async () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'x' }, @@ -1410,15 +1412,15 @@ describe('Records', () => { }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); - const { status } = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { + const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token, body: { permissions: [{ access: 'public' }] }, }); expect(status).toBe(403); }); - it("returns the record even when the PUT revokes the caller's own read access", async () => { - // setPermissions() is gated on mayReshare(), owner-or-creator rather + it("returns the record even when the change revokes the caller's own read access", async () => { + // The permissions key is gated on mayReshare(), owner-or-creator rather // than plain write access — OTHER_ENTITY_ID must be the record's // author, not merely a grantee, to reach it as a non-owner. const record = await t.ctx.stack.create( @@ -1427,7 +1429,7 @@ describe('Records', () => { { entityId: OTHER_ENTITY_ID }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); - const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token, body: { permissions: [] }, }); @@ -1435,14 +1437,14 @@ describe('Records', () => { expect((data as { id: string }).id).toBe(record.id); // Confirms the write actually landed and the caller lost read access — - // otherwise the 200 above wouldn't distinguish this from any other PUT. + // otherwise the 200 above wouldn't distinguish this from any other change. const { status: followUp } = await req(t.app, 'GET', `/records/${record.id}`, { token }); expect(followUp).toBe(404); }); - it('PUT succeeds when If-Match names the current version', async () => { + it('succeeds when If-Match names the current version', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { + const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { permissions: [{ access: 'public' }] }, headers: { 'If-Match': `"${record.version}"` }, @@ -1450,9 +1452,9 @@ describe('Records', () => { expect(status).toBe(200); }); - it('PUT returns 412 version_conflict on an If-Match mismatch', async () => { + it('returns 412 version_conflict on an If-Match mismatch', async () => { const record = await seedRecord(t.ctx); - const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/permissions`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { permissions: [{ access: 'public' }] }, headers: { 'If-Match': `"${record.version + 1}"` }, @@ -1462,14 +1464,14 @@ describe('Records', () => { }); }); - describe('PUT /records/:id/unlisted', () => { + describe('the unlisted change-set key', () => { it('withholds a record from enumeration without changing who may read it', async () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'x' }, { permissions: [{ access: 'public' }] }, ); - const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { unlisted: true }, }); @@ -1491,11 +1493,11 @@ describe('Records', () => { it('{ unlisted: false } relists a record', async () => { const record = await seedRecord(t.ctx); - await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { unlisted: true }, }); - const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { unlisted: false }, }); @@ -1508,16 +1510,58 @@ describe('Records', () => { ).toBe(true); }); - it('rejects a non-boolean unlisted value with 400', async () => { + // A key that addresses nothing is 400; a key whose value is the wrong + // shape is 422. `unlisted` is a change-set key the envelope recognizes, + // so a non-boolean is the second of those. + it('rejects a non-boolean unlisted value with 422', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { unlisted: 'true' }, }); + expect(status).toBe(422); + const details = (data as { error: { details: Array<{ path: string }> } }).error.details; + expect(details.some((d) => d.path === 'unlisted')).toBe(true); + }); + + // The same 422 is owed by every key carrying a structured value, not just + // the boolean one. `permissions` and `associations` are each replaced + // wholesale by the key, so a non-array is the shape error rather than a + // content one — and without this, a core bump that quietly stopped + // rejecting either would pass the whole suite on the `unlisted` case alone. + it.each([ + ['permissions', { permissions: 'nope' }], + ['associations', { associations: 'nope' }], + ])('rejects a non-array %s value with 422', async (key, body) => { + const record = await seedRecord(t.ctx); + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body, + }); + expect(status).toBe(422); + const details = (data as { error: { details: Array<{ path: string }> } }).error.details; + expect(details.some((d) => d.path === key)).toBe(true); + }); + + it('rejects an unrecognized change-set key with 400', async () => { + const record = await seedRecord(t.ctx); + const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: { unlistd: true }, + }); + expect(status).toBe(400); + }); + + it('rejects an empty change set with 400', async () => { + const record = await seedRecord(t.ctx); + const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: {}, + }); expect(status).toBe(400); }); - it('returns 403 when a non-owner without write access tries to PUT', async () => { + it('returns 403 when a non-owner without write access names the key', async () => { const record = await t.ctx.stack.create( NOTE_TYPE_ID, { body: 'x' }, @@ -1526,7 +1570,7 @@ describe('Records', () => { }, ); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); - const { status } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token, body: { unlisted: true }, }); @@ -1536,7 +1580,7 @@ describe('Records', () => { it('returns 409 on a soft-deleted record', async () => { const record = await seedRecord(t.ctx); await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); - const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { unlisted: true }, }); @@ -1544,9 +1588,9 @@ describe('Records', () => { expect((data as { error: { code: string } }).error.code).toBe('conflict'); }); - it('PUT succeeds when If-Match names the current version', async () => { + it('succeeds when If-Match names the current version', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { unlisted: true }, headers: { 'If-Match': `"${record.version}"` }, @@ -1554,9 +1598,9 @@ describe('Records', () => { expect(status).toBe(200); }); - it('PUT returns 412 version_conflict on an If-Match mismatch', async () => { + it('returns 412 version_conflict on an If-Match mismatch', async () => { const record = await seedRecord(t.ctx); - const { status, data } = await req(t.app, 'PUT', `/records/${record.id}/unlisted`, { + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, body: { unlisted: true }, headers: { 'If-Match': `"${record.version + 1}"` }, @@ -1569,7 +1613,7 @@ describe('Records', () => { describe('includeUnlisted', () => { it('is excluded by default from GET /records and POST /records/query', async () => { const record = await seedRecord(t.ctx); - await t.ctx.stack.setUnlisted(record.id, true); + await t.ctx.stack.mutate(record.id, { unlisted: true }); const getRes = await req(t.app, 'GET', '/records', { token: TEST_TOKEN }); expect( @@ -1586,7 +1630,7 @@ describe('Records', () => { it('surfaces unlisted records for the owner acting alone', async () => { const record = await seedRecord(t.ctx); - await t.ctx.stack.setUnlisted(record.id, true); + await t.ctx.stack.mutate(record.id, { unlisted: true }); const { data } = await req(t.app, 'GET', '/records?includeUnlisted=true', { token: TEST_TOKEN, @@ -1625,7 +1669,7 @@ describe('Records', () => { const body = JSON.parse(`{"${key}":{"evil":true}}`); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body, + body: { contentPatch: body }, }); expect(status).toBe(422); const details = (data as { error: { details: Array<{ path: string }> } }).error.details; diff --git a/tests/routes/tombstones.test.ts b/tests/routes/tombstones.test.ts index 5db602c..0ad2ef0 100644 --- a/tests/routes/tombstones.test.ts +++ b/tests/routes/tombstones.test.ts @@ -74,7 +74,7 @@ describe('tombstone projection and mutation refusal', () => { await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { title: 'edited' }, + body: { contentPatch: { title: 'edited' } }, }); expect(status).toBe(409); expect((data as { error: { code: string } }).error.code).toBe('conflict'); diff --git a/tests/routes/versions.test.ts b/tests/routes/versions.test.ts index c5eb4eb..95f3484 100644 --- a/tests/routes/versions.test.ts +++ b/tests/routes/versions.test.ts @@ -19,7 +19,7 @@ describe('Versions', () => { const record = await t.ctx.stack.create(TYPE_ID, { body: 'v1' }); await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { content: { body: 'v2' }, version: 2, updatedAt: new Date().toISOString() }, + body: { contentPatch: { body: 'v2' } }, }); return record; } @@ -109,7 +109,7 @@ describe('Versions', () => { permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write }], }, ); - await t.ctx.stack.update(record.id, { body: 'v2' }); + await t.ctx.stack.patchContent(record.id, { body: 'v2' }); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); return { record, token }; } @@ -144,7 +144,7 @@ describe('Versions', () => { permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], }, ); - await t.ctx.stack.update(record.id, { body: 'v2' }); + await t.ctx.stack.patchContent(record.id, { body: 'v2' }); const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); const { data } = await req(t.app, 'GET', `/records/${record.id}/versions`, { token }); @@ -165,7 +165,7 @@ describe('Versions', () => { await t.ctx.stack.associate(record.id, { kind: 'tag', label: 'starred' }); expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(2); - await t.ctx.stack.setPermissions(record.id, [{ access: 'public' }]); + await t.ctx.stack.mutate(record.id, { permissions: [{ access: 'public' }] }); expect((await t.ctx.adapter.getRecord(record.id))?.version).toBe(3); await t.ctx.stack.delete(record.id);