From cf3fda96eb854b96cba598fe276192c00aee8f72 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 19:01:01 +0000 Subject: [PATCH 1/5] Adopt core 0.30's change-set envelope for PATCH /records/:id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core 0.29 replaced `update()`, `setPermissions()`, `setUnlisted()` and `setParent()` with a single `mutate(id, changes)`, and moved the wire format to match: `PATCH /records/:id` now carries a change set naming any combination of `contentPatch`, `parentId`, `permissions`, `associations` and `unlisted`, and `PUT .../permissions`, `PUT .../unlisted` and `PUT .../parent` are gone. One `If-Match` fences a whole multi-aspect edit, where each aspect previously cost a version and a round trip of its own and could not be fenced together at all. `changesFromWireBody()` from `@haverstack/core/wire` owns the whole read of that envelope — which keys it may carry, the 400 an unrecognized or wholly absent key earns, the 422 a malformed value earns — so the route parses nothing itself, the same way `createOptionsFromWireRecord()` already owns `POST /records`. The two retired PUT handlers each carried a hand-rolled shape check that the envelope parser now makes redundant. `PATCH /entity` keeps its own `{ content }` envelope and moves to `patchContent()`, the content-only spelling `update()` left behind. Change-feed frames carry `ops: ChangeOp[]` in place of `op`, with `update` renamed `patch` and `reparent` added; a reparent is matched against both containers a move concerns, so a subscription filtered on the origin learns the record left it. Conformance fixtures rename with the endpoints they exercise (`setPermissionsFixtures` → `permissionsChangeFixtures`, and likewise for unlisted and parent), and the reparenting work adds five fixtures: the two parent-change ones, the reparent feed frame, and the snapshot/restore pair that settles a record's container. All are covered here except two, which are skipped with the reason in full at the skip site: a caller-named `parentId` cannot reach core's format and existence checks through a `ScopedStack`, because `canReadReferent()` refuses a missing referent before either runs — right for a non-owner, since it is what stops the gate being an existence oracle, but it also catches the owner, who is documented as exempt. Nothing this server can do fixes it; the fix belongs in core. Haverstack dependencies move in lockstep: core 0.30.0, wire-types 0.29.0, commons 0.24.0, adapter-local 0.29.0, conformance-fixtures 0.23.0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KeyEysH8Rr3LwiLTZBTDDb --- docs/api.md | 48 ++++-- package.json | 10 +- pnpm-lock.yaml | 78 +++++----- pnpm-workspace.yaml | 14 +- src/routes/entity.ts | 2 +- src/routes/records.ts | 60 +++----- tests/app.test.ts | 2 +- tests/conformance.test.ts | 249 ++++++++++++++++++++++++++------ tests/lib/resumeBuffer.test.ts | 2 +- tests/routes/changes.test.ts | 28 ++-- tests/routes/records.test.ts | 121 ++++++++++------ tests/routes/tombstones.test.ts | 2 +- tests/routes/versions.test.ts | 8 +- 13 files changed, 406 insertions(+), 218 deletions(-) diff --git a/docs/api.md b/docs/api.md index 9cbe4b8..0b8a935 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**: content is reachable by a write-holder or an `update-*` grantee, while `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). + +`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. + +This replaces the four single-aspect endpoints a previous version of this server exposed — `PUT /records/:id/permissions`, `PUT /records/:id/unlisted`, `PUT /records/:id/parent`, and a `PATCH` whose body was the bare content patch. Each aspect used to cost a version and a round trip of its own, and could not be fenced by one `If-Match`, since the second call had to be pinned against a version only the first call's response could supply. `GET /records/:id/permissions` stays. + 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,23 @@ 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. + +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. ### The feed transitions @@ -189,7 +215,7 @@ Resume is per-connection state, not global: each distinct (session, filter) pair 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`. -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..99939e8 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.29.0", + "@haverstack/commons": "^0.24.0", + "@haverstack/core": "^0.30.0", + "@haverstack/wire-types": "^0.29.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.23.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..cfff90a 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.29.0 + version: 0.29.0 '@haverstack/commons': - specifier: ^0.21.0 - version: 0.21.0 + specifier: ^0.24.0 + version: 0.24.0 '@haverstack/core': - specifier: ^0.27.0 - version: 0.27.0 + specifier: ^0.30.0 + version: 0.30.0 '@haverstack/wire-types': - specifier: ^0.26.0 - version: 0.26.0 + specifier: ^0.29.0 + version: 0.29.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.23.0 + version: 0.23.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.29.0': + resolution: {integrity: sha512-etEQE4ZysLmwipwGYagSggwlQWLmj/86kRZawwmmeYXLVfWQpxLqSC+hVv15t9suwa7WgqALTQv61BIPvCXQFw==} 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.28.0': + resolution: {integrity: sha512-BoPJzI2nas9IIiAQcBouDRWnRSkzWJNnaH1HDEfuV4ZV4zMCHdVbQ4+DA8wdmGss7IrHWF2YRbpAGu4D0KTnHA==} - '@haverstack/commons@0.21.0': - resolution: {integrity: sha512-DXFOXtGyWR6RSrFKDXy8A4c9bESCcKEwujjgyn7i/72/S1WJFUb9DyqpzHWjAJokR+1wHWxsaEKU0IdU0/2x3A==} + '@haverstack/commons@0.24.0': + resolution: {integrity: sha512-AnvkgphMkvMqUf1KUx4gVDvjrwaLzKiWWVpWqicwxcNoyU+MMrKdHZzRi9J+m1Jk4ogqJ4uOR1UFbz8okN+TgA==} - '@haverstack/conformance-fixtures@0.20.0': - resolution: {integrity: sha512-or1hQR2hiIF9/vAWtBnNUqcUA2BnfEqskZfo0zDUE9FjAlDBLeDHV57sXVZr9lP4Qvv3IWq2fbiOKl2FU/s5NA==} + '@haverstack/conformance-fixtures@0.23.0': + resolution: {integrity: sha512-ogmhhqDEZg16hPFIigEP97M/ZB2C0qe3qxFnloO4ge72UJLCbSwslaxtsLvafrDNgXgWUBXEv3Ovyq4q6+nImA==} - '@haverstack/core@0.27.0': - resolution: {integrity: sha512-++qnrnmgCAIA6vvUiwkTC8OifurCIC9/klQoQW9vfHNEujh03XAFiAuPm6doNvKYpP4zTanZGNHdkoyiSJgKGQ==} + '@haverstack/core@0.30.0': + resolution: {integrity: sha512-5jeKjREqX0XnXnz/AIjJaRQ+dhV7Ps+Boz3iJjXLr8raIrgnnb4QERNC/xB/OOTWLFYMcMzBGISqqvwRxEcQGQ==} - '@haverstack/record-adapter-sqlite@0.19.0': - resolution: {integrity: sha512-/ju4wrNtaIp1JdbC56zG8KqE/mcQqyZvzPmNjudpVb+EwGCIfTUXgWvG+GzoQfthjqLNNzXyRYVN0ufG4b3Rog==} + '@haverstack/record-adapter-sqlite@0.22.0': + resolution: {integrity: sha512-lvEYCAIcQS2+tdVUCBzmZRUsragAFY+mPTlB7AGfVn8NR7A0p3vMrYeBtUW3DEsZatn9htHo2nIOl+5En+DoGQ==} engines: {node: '>=22.5.0'} - '@haverstack/wire-types@0.26.0': - resolution: {integrity: sha512-anOV4QRRGing1J3u9kpaFj39A6MeOgYMsBPiHrt23TPNLRhFNp4slhPRhTP621BSFGJ3tf5XL1fZcfOQNTBVmw==} + '@haverstack/wire-types@0.29.0': + resolution: {integrity: sha512-dggrLjQa0XcxgFBY+KufuGZs3VGjNJiVl/SXPva9NpUfZUmF5d+c8ZpBR4xTQbHAxDVBCpfg+O8m/3OnLUjYOA==} '@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.29.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.28.0 + '@haverstack/core': 0.30.0 + '@haverstack/record-adapter-sqlite': 0.22.0 - '@haverstack/blob-adapter-disk@0.25.0': + '@haverstack/blob-adapter-disk@0.28.0': dependencies: - '@haverstack/core': 0.27.0 + '@haverstack/core': 0.30.0 - '@haverstack/commons@0.21.0': + '@haverstack/commons@0.24.0': dependencies: - '@haverstack/core': 0.27.0 + '@haverstack/core': 0.30.0 - '@haverstack/conformance-fixtures@0.20.0': + '@haverstack/conformance-fixtures@0.23.0': dependencies: - '@haverstack/wire-types': 0.26.0 + '@haverstack/wire-types': 0.29.0 - '@haverstack/core@0.27.0': {} + '@haverstack/core@0.30.0': {} - '@haverstack/record-adapter-sqlite@0.19.0': + '@haverstack/record-adapter-sqlite@0.22.0': dependencies: - '@haverstack/core': 0.27.0 + '@haverstack/core': 0.30.0 - '@haverstack/wire-types@0.26.0': + '@haverstack/wire-types@0.29.0': dependencies: - '@haverstack/core': 0.27.0 + '@haverstack/core': 0.30.0 '@hono/node-server@2.1.1(hono@4.13.7)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fc26e4d..824173a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,10 +1,10 @@ allowBuilds: esbuild: true 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/adapter-local@0.20.0 || 0.25.0 || 0.26.0 || 0.29.0' + - '@haverstack/blob-adapter-disk@0.19.0 || 0.24.0 || 0.25.0 || 0.28.0' + - '@haverstack/commons@0.15.0 || 0.20.0 || 0.21.0 || 0.24.0' + - '@haverstack/conformance-fixtures@0.14.0 || 0.19.0 || 0.20.0 || 0.23.0' + - '@haverstack/core@0.21.0 || 0.26.0 || 0.27.0 || 0.30.0' + - '@haverstack/record-adapter-sqlite@0.13.0 || 0.18.0 || 0.19.0 || 0.22.0' + - '@haverstack/wire-types@0.20.0 || 0.25.0 || 0.26.0 || 0.29.0' 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..0d95b7d 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), @@ -958,8 +1007,30 @@ describe('version lifecycle fixtures', () => { // ------------------------------------------------------- describe('error response fixtures', () => { - // Every fixture in this block is dispatched — nothing here is skipped. - const SKIPPED = new Set(); + const SKIPPED = new Set([ + // Both need a `parentId` a caller names to reach the format and + // existence checks in `Stack.mutate()`, which answer exactly these two + // fixtures — `StackQueryError` with "Invalid parentId ..." and + // `StackConflictError` with "no such record". Every request here goes + // through a `ScopedStack`, and its reference gate runs first: + // `canReadReferent()` resolves the destination and treats missing and + // unreadable alike, so both answer 403 `permission` before either check + // is reached. That indistinguishability is deliberate and right for a + // non-owner — it is what stops the gate being an existence oracle — but + // it also catches the owner, who is documented as exempt from the + // parentId gate (ScopedStack's own suite: "the owner is exempt from + // parentId and relationship gates") and is entitled to be told a + // container is not there. The exemption is keyed on readability, and a + // record that does not exist is unreadable by everyone, so the owner + // never reaches the conflict either. + // + // Nothing this server can do fixes it: pre-checking existence here + // would be the oracle for non-owners that core is right to refuse. + // Tracked for core — `canReadReferent()` has to exempt the owner + // acting alone before it resolves the record. Un-skip both once it does. + 'error-bad-request-malformed-parent-id', + 'error-conflict-parent-does-not-exist', + ]); const handled = new Set(); function find(name: string) { @@ -1022,11 +1093,7 @@ 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); }); @@ -1377,7 +1444,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 +1470,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 +1496,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 +1521,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 +1546,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 +1575,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 +1605,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 +1617,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 +1661,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 +1681,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 +1705,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 +1728,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 +1795,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 +1809,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 +2035,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 +2067,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 +2077,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..60a08bb 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,39 @@ 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); + }); + + 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 +1551,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 +1561,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 +1569,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 +1579,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 +1594,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 +1611,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 +1650,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); From 0e74b3c3330c87f5c69b6990e35f2eda22e587c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 21:37:25 +0000 Subject: [PATCH 2/5] Point the two skipped parent fixtures at haverstack/core#276 The skip reason was written before the core issue existed. Filed now, so the comment names it and a reader can check whether it is still true. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KeyEysH8Rr3LwiLTZBTDDb --- tests/conformance.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 0d95b7d..9a0ed06 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -1026,8 +1026,9 @@ describe('error response fixtures', () => { // // Nothing this server can do fixes it: pre-checking existence here // would be the oracle for non-owners that core is right to refuse. - // Tracked for core — `canReadReferent()` has to exempt the owner - // acting alone before it resolves the record. Un-skip both once it does. + // Tracked as haverstack/core#276 — `canReadReferent()` has to exempt + // the owner acting alone before it resolves the record. Un-skip both + // once it does. 'error-bad-request-malformed-parent-id', 'error-conflict-parent-does-not-exist', ]); From 720814c85435ab5174b114d2d2ed308f892d5f87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 03:00:59 +0000 Subject: [PATCH 3/5] Upgrade to core 0.31, and un-skip the two parent fixtures it unblocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core 0.31.0 exempts the owner acting alone from the reference-creation gate (#276, fixed in core#279), so `canReadReferent()` no longer refuses a missing destination ahead of the checks a caller-named `parentId` is owed. Both fixtures skipped in the previous commit now pass unchanged, and their dispatch tests come back: error-bad-request-malformed-parent-id 400 bad_request error-conflict-parent-does-not-exist 409 conflict The anti-oracle property is intact for everyone else, which the docs now state rather than leaving implied — probed against adapter-local, a non-owner write-holder naming either an empty or a nonexistent parentId still gets one indistinguishable 403, where the owner gets the 400 and the 409. `docs/api.md` § The change set carries that split, since the section previously promised both answers to every caller. core 0.31.0 also lands #278 — `StackQueryError.capability`, the exported `MissingCapability` and `filtersContent()`, and `assertQueryTravels()` in `@haverstack/core/wire`. None of it reaches this server: `capability` does not travel (`serializeError()` does not carry it), `assertQueryTravels()` is the build-side half of a refusal this server's parsers already apply, and discovery spreads `stack.features` rather than re-deriving any of it. No change needed, recorded here so the next reader need not re-check. Dependencies move in lockstep: core 0.31.0, wire-types 0.30.0, commons 0.25.0, adapter-local 0.30.0, conformance-fixtures 0.24.0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KeyEysH8Rr3LwiLTZBTDDb --- docs/api.md | 2 ++ package.json | 10 +++--- pnpm-lock.yaml | 76 +++++++++++++++++++-------------------- pnpm-workspace.yaml | 14 ++++---- tests/conformance.test.ts | 48 ++++++++++++------------- 5 files changed, 75 insertions(+), 75 deletions(-) diff --git a/docs/api.md b/docs/api.md index 0b8a935..28bc21d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -114,6 +114,8 @@ Authority resolves **per key, against the record as it stands**: content is reac 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. This replaces the four single-aspect endpoints a previous version of this server exposed — `PUT /records/:id/permissions`, `PUT /records/:id/unlisted`, `PUT /records/:id/parent`, and a `PATCH` whose body was the bare content patch. Each aspect used to cost a version and a round trip of its own, and could not be fenced by one `If-Match`, since the second call had to be pinned against a version only the first call's response could supply. `GET /records/:id/permissions` stays. diff --git a/package.json b/package.json index 99939e8..ba17a60 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "format:check": "prettier --check ." }, "dependencies": { - "@haverstack/adapter-local": "^0.29.0", - "@haverstack/commons": "^0.24.0", - "@haverstack/core": "^0.30.0", - "@haverstack/wire-types": "^0.29.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.23.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 cfff90a..438e843 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: dependencies: '@haverstack/adapter-local': - specifier: ^0.29.0 - version: 0.29.0 + specifier: ^0.30.0 + version: 0.30.0 '@haverstack/commons': - specifier: ^0.24.0 - version: 0.24.0 + specifier: ^0.25.0 + version: 0.25.0 '@haverstack/core': + specifier: ^0.31.0 + version: 0.31.0 + '@haverstack/wire-types': specifier: ^0.30.0 version: 0.30.0 - '@haverstack/wire-types': - specifier: ^0.29.0 - version: 0.29.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.23.0 - version: 0.23.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.29.0': - resolution: {integrity: sha512-etEQE4ZysLmwipwGYagSggwlQWLmj/86kRZawwmmeYXLVfWQpxLqSC+hVv15t9suwa7WgqALTQv61BIPvCXQFw==} + '@haverstack/adapter-local@0.30.0': + resolution: {integrity: sha512-18dqqtB36qoS44hxO/14aqKo+ngajZOFJ0nVZ2qh+BmmBPgNNaG9lNhXO9PIvVEPvJtE4kxdJC9/+uCmtUCbOg==} engines: {node: '>=22.5.0'} - '@haverstack/blob-adapter-disk@0.28.0': - resolution: {integrity: sha512-BoPJzI2nas9IIiAQcBouDRWnRSkzWJNnaH1HDEfuV4ZV4zMCHdVbQ4+DA8wdmGss7IrHWF2YRbpAGu4D0KTnHA==} + '@haverstack/blob-adapter-disk@0.29.0': + resolution: {integrity: sha512-P77ell2zy4UAoPj1ZRRBOwfwpdURusCqU93mBcVIav+hf2kRR0D1Hq+KdxPX2Ft7c5jdMFh5z+IaGyXpMpxcAw==} - '@haverstack/commons@0.24.0': - resolution: {integrity: sha512-AnvkgphMkvMqUf1KUx4gVDvjrwaLzKiWWVpWqicwxcNoyU+MMrKdHZzRi9J+m1Jk4ogqJ4uOR1UFbz8okN+TgA==} + '@haverstack/commons@0.25.0': + resolution: {integrity: sha512-1MwoTPcaEuOv75bm8dbYWg/PVHI4oABpmTVZ6XpervUGyYJLDuLDW5pRgz2/LsGJZM5NHRBNQyca52d99zgPcA==} - '@haverstack/conformance-fixtures@0.23.0': - resolution: {integrity: sha512-ogmhhqDEZg16hPFIigEP97M/ZB2C0qe3qxFnloO4ge72UJLCbSwslaxtsLvafrDNgXgWUBXEv3Ovyq4q6+nImA==} + '@haverstack/conformance-fixtures@0.24.0': + resolution: {integrity: sha512-XXG9WYKK/dtHwhU1/kzWeZn0Viflj0YmNwZ6cVbKPIMDiF5iKFB/d2GS55pzz0tiNQO4IpGbe0wT5AzPG+P5KA==} - '@haverstack/core@0.30.0': - resolution: {integrity: sha512-5jeKjREqX0XnXnz/AIjJaRQ+dhV7Ps+Boz3iJjXLr8raIrgnnb4QERNC/xB/OOTWLFYMcMzBGISqqvwRxEcQGQ==} + '@haverstack/core@0.31.0': + resolution: {integrity: sha512-r/cCuTE+39oEJ7ib6gLc2yoMrK2+9QfVWDX2QhdlG77msxZDLjijcx6djNrDbaZRUo1heCIDpgJunSUhjOVG+Q==} - '@haverstack/record-adapter-sqlite@0.22.0': - resolution: {integrity: sha512-lvEYCAIcQS2+tdVUCBzmZRUsragAFY+mPTlB7AGfVn8NR7A0p3vMrYeBtUW3DEsZatn9htHo2nIOl+5En+DoGQ==} + '@haverstack/record-adapter-sqlite@0.23.0': + resolution: {integrity: sha512-z+Cd6qp4D3U/2ezlf5U3R5nFYTFMpF4Ax8ipfjc0oXK1yQdRc9zWquvO5sKI8sEELBYv5r8V8rjJwqkkn54NOA==} engines: {node: '>=22.5.0'} - '@haverstack/wire-types@0.29.0': - resolution: {integrity: sha512-dggrLjQa0XcxgFBY+KufuGZs3VGjNJiVl/SXPva9NpUfZUmF5d+c8ZpBR4xTQbHAxDVBCpfg+O8m/3OnLUjYOA==} + '@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.29.0': + '@haverstack/adapter-local@0.30.0': dependencies: - '@haverstack/blob-adapter-disk': 0.28.0 - '@haverstack/core': 0.30.0 - '@haverstack/record-adapter-sqlite': 0.22.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.28.0': + '@haverstack/blob-adapter-disk@0.29.0': dependencies: - '@haverstack/core': 0.30.0 + '@haverstack/core': 0.31.0 - '@haverstack/commons@0.24.0': + '@haverstack/commons@0.25.0': dependencies: - '@haverstack/core': 0.30.0 + '@haverstack/core': 0.31.0 - '@haverstack/conformance-fixtures@0.23.0': + '@haverstack/conformance-fixtures@0.24.0': dependencies: - '@haverstack/wire-types': 0.29.0 + '@haverstack/wire-types': 0.30.0 - '@haverstack/core@0.30.0': {} + '@haverstack/core@0.31.0': {} - '@haverstack/record-adapter-sqlite@0.22.0': + '@haverstack/record-adapter-sqlite@0.23.0': dependencies: - '@haverstack/core': 0.30.0 + '@haverstack/core': 0.31.0 - '@haverstack/wire-types@0.29.0': + '@haverstack/wire-types@0.30.0': dependencies: - '@haverstack/core': 0.30.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 824173a..16c67bf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,10 +1,10 @@ allowBuilds: esbuild: true minimumReleaseAgeExclude: - - '@haverstack/adapter-local@0.20.0 || 0.25.0 || 0.26.0 || 0.29.0' - - '@haverstack/blob-adapter-disk@0.19.0 || 0.24.0 || 0.25.0 || 0.28.0' - - '@haverstack/commons@0.15.0 || 0.20.0 || 0.21.0 || 0.24.0' - - '@haverstack/conformance-fixtures@0.14.0 || 0.19.0 || 0.20.0 || 0.23.0' - - '@haverstack/core@0.21.0 || 0.26.0 || 0.27.0 || 0.30.0' - - '@haverstack/record-adapter-sqlite@0.13.0 || 0.18.0 || 0.19.0 || 0.22.0' - - '@haverstack/wire-types@0.20.0 || 0.25.0 || 0.26.0 || 0.29.0' + - '@haverstack/adapter-local@0.20.0 || 0.25.0 || 0.26.0 || 0.29.0 || 0.30.0' + - '@haverstack/blob-adapter-disk@0.19.0 || 0.24.0 || 0.25.0 || 0.28.0 || 0.29.0' + - '@haverstack/commons@0.15.0 || 0.20.0 || 0.21.0 || 0.24.0 || 0.25.0' + - '@haverstack/conformance-fixtures@0.14.0 || 0.19.0 || 0.20.0 || 0.23.0 || 0.24.0' + - '@haverstack/core@0.21.0 || 0.26.0 || 0.27.0 || 0.30.0 || 0.31.0' + - '@haverstack/record-adapter-sqlite@0.13.0 || 0.18.0 || 0.19.0 || 0.22.0 || 0.23.0' + - '@haverstack/wire-types@0.20.0 || 0.25.0 || 0.26.0 || 0.29.0 || 0.30.0' diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 9a0ed06..325a5f1 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -1007,31 +1007,8 @@ describe('version lifecycle fixtures', () => { // ------------------------------------------------------- describe('error response fixtures', () => { - const SKIPPED = new Set([ - // Both need a `parentId` a caller names to reach the format and - // existence checks in `Stack.mutate()`, which answer exactly these two - // fixtures — `StackQueryError` with "Invalid parentId ..." and - // `StackConflictError` with "no such record". Every request here goes - // through a `ScopedStack`, and its reference gate runs first: - // `canReadReferent()` resolves the destination and treats missing and - // unreadable alike, so both answer 403 `permission` before either check - // is reached. That indistinguishability is deliberate and right for a - // non-owner — it is what stops the gate being an existence oracle — but - // it also catches the owner, who is documented as exempt from the - // parentId gate (ScopedStack's own suite: "the owner is exempt from - // parentId and relationship gates") and is entitled to be told a - // container is not there. The exemption is keyed on readability, and a - // record that does not exist is unreadable by everyone, so the owner - // never reaches the conflict either. - // - // Nothing this server can do fixes it: pre-checking existence here - // would be the oracle for non-owners that core is right to refuse. - // Tracked as haverstack/core#276 — `canReadReferent()` has to exempt - // the owner acting alone before it resolves the record. Un-skip both - // once it does. - 'error-bad-request-malformed-parent-id', - 'error-conflict-parent-does-not-exist', - ]); + // Every fixture in this block is dispatched — nothing here is skipped. + const SKIPPED = new Set(); const handled = new Set(); function find(name: string) { @@ -1098,6 +1075,27 @@ describe('error response fixtures', () => { 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. Reaching + // it at all takes core 0.31's owner exemption from the reference gate + // (core#276): before that, the gate refused a missing destination ahead + // of this check and the answer was a 403. + const { status, data } = await dispatch(fixture, TEST_TOKEN, `/records/${record.id}`); + expectError(status, data, fixture); + }); + test('error-permission-denied-versions-read-only — can read, cannot write', async () => { const fixture = find('error-permission-denied-versions-read-only'); const record = await t.ctx.stack.create( From 2334059b8af10c8baedee294ba9e4de6bb88c8b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 03:19:06 +0000 Subject: [PATCH 4/5] Address review: present-tense docs, and stop the exclude list growing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review comments. The change-set section closed on a paragraph contrasting the envelope with the four single-aspect endpoints it replaced. Removed: docs/api.md describes the server as it is, and the endpoint table already says which routes exist. Nothing else in the section was written that way. One test comment narrated the pre-0.31 gate behavior on the same grounds and now states the present rule instead — which requester reaches the conflict, and what everyone else gets. `minimumReleaseAgeExclude` was growing because pnpm writes to it. pnpm 11 defaults `minimumReleaseAge` to 1440, and an install that has to hold a too-new package appends it to this list (no prompt unless `minimumReleaseAgeStrict` is set). Haverstack releases in lockstep, so every sync lands the whole scope on its release day and pnpm wrote all seven back, one version at a time — which is the churn every previous sync commit shows. A scope pattern ends it: pattern support landed in pnpm 10.17 and this repo is on 11.24. Verified against the registry rather than assumed — with no exclusions, a clean resolve appends all seven (core@0.31.0 among them, published hours ago); with `'@haverstack/*'`, the same resolve completes and the file is untouched. The lockfile still passes the supply-chain check, and third-party dependencies keep the cooldown. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KeyEysH8Rr3LwiLTZBTDDb --- docs/api.md | 2 -- pnpm-workspace.yaml | 17 ++++++++++------- tests/conformance.test.ts | 8 ++++---- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/api.md b/docs/api.md index 28bc21d..034b906 100644 --- a/docs/api.md +++ b/docs/api.md @@ -118,8 +118,6 @@ An unrecognized top-level key is `400`; a key whose value is the wrong shape is `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. -This replaces the four single-aspect endpoints a previous version of this server exposed — `PUT /records/:id/permissions`, `PUT /records/:id/unlisted`, `PUT /records/:id/parent`, and a `PATCH` whose body was the bare content patch. Each aspect used to cost a version and a round trip of its own, and could not be fenced by one `If-Match`, since the second call had to be pinned against a version only the first call's response could supply. `GET /records/:id/permissions` stays. - 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`. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 16c67bf..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 || 0.29.0 || 0.30.0' - - '@haverstack/blob-adapter-disk@0.19.0 || 0.24.0 || 0.25.0 || 0.28.0 || 0.29.0' - - '@haverstack/commons@0.15.0 || 0.20.0 || 0.21.0 || 0.24.0 || 0.25.0' - - '@haverstack/conformance-fixtures@0.14.0 || 0.19.0 || 0.20.0 || 0.23.0 || 0.24.0' - - '@haverstack/core@0.21.0 || 0.26.0 || 0.27.0 || 0.30.0 || 0.31.0' - - '@haverstack/record-adapter-sqlite@0.13.0 || 0.18.0 || 0.19.0 || 0.22.0 || 0.23.0' - - '@haverstack/wire-types@0.20.0 || 0.25.0 || 0.26.0 || 0.29.0 || 0.30.0' + - '@haverstack/*' diff --git a/tests/conformance.test.ts b/tests/conformance.test.ts index 325a5f1..0973845 100644 --- a/tests/conformance.test.ts +++ b/tests/conformance.test.ts @@ -1088,10 +1088,10 @@ describe('error response fixtures', () => { 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. Reaching - // it at all takes core 0.31's owner exemption from the reference gate - // (core#276): before that, the gate refused a missing destination ahead - // of this check and the answer was a 403. + // 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); }); From 83d2784c6b9815c59e23f2705e8b08540258c564 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 03:25:02 +0000 Subject: [PATCH 5/5] feat: document per-key change-set authority and the reparent frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the change-set docs and tests. The per-key authority paragraph gave gates for contentPatch, permissions and unlisted but said nothing about the two keys left over. Measured against adapter-local rather than inferred: a non-owner write-holder may set associations, and may set parentId only when it can also read the destination — naming one it cannot read is the same indistinguishable 403 the paragraph below already describes. "A filtered connection never receives a frame outside its filter" was false as written. A reparent is matched against both containers the move concerns, so a connection filtered on the origin receives a frame whose own parentId is the destination — it matched, but not on what it carries. The qualification existed but sat under ## Unlisted, three sections from the filtering rule it qualifies, where no feed implementer would find it. It now follows that rule, and the rule claims what the server actually guarantees: never a frame it did not match. Only unlisted had a shape-error test, so a core bump that quietly stopped rejecting a malformed permissions or associations value would have passed the suite on the boolean case alone. Both are covered now. The `feat:` prefix is what sets this PR's version bump, and is the reason it sits on a docs-and-tests commit. release.yml picks the bump from every commit since the last tag; the commits adopting the envelope landed without a conventional prefix, so the release would otherwise have gone out as 0.7.9 — a patch — though PATCH /records/:id no longer accepts a bare content patch and two PUT endpoints are gone. `feat:` lands it as 0.8.0 instead: on a 0.x package the minor is the compatibility boundary, and a `!` would only have forced 1.0.0, which this is not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XmcYMq3PtT1UCYJ4xNzauL --- docs/api.md | 8 ++++---- tests/routes/records.test.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/api.md b/docs/api.md index 034b906..527abd9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -110,7 +110,7 @@ Keys are read for **presence, not truthiness** — `unlisted: false` and `parent `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**: content is reachable by a write-holder or an `update-*` grantee, while `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. +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). @@ -187,8 +187,6 @@ Answers `200` with the updated record — it bumps `version` like any other muta 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. -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. - ### The feed transitions `unlist` and `list` are new change-feed ops (`unlist` maps to `kind: "deleted"`, `list` to `kind: "changed"` — see [Change feed](#change-feed)). Only the `unlist` transition needs special-casing; every other row falls out of checking the record's current `unlistedAt` against the subscriber's `includeUnlisted`: @@ -213,7 +211,9 @@ 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`, `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. diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts index 60a08bb..9f901a0 100644 --- a/tests/routes/records.test.ts +++ b/tests/routes/records.test.ts @@ -1524,6 +1524,25 @@ describe('Records', () => { 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}`, {