diff --git a/CLAUDE.md b/CLAUDE.md index a44d514..4f6813e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,8 +11,10 @@ points that let a closed superset extend the core without forking it** (each shi that keeps OSS behaviour byte-identical). **S24** (inject persistence ports into the composition) and **S22** (tenant scope + access-policy seams — scope-aware repo reads, the `AccessPolicy` port deciding the one overridable `authenticated`-tier cell, and the allow-all sign-up option) are -**done**; **S19** (payload-retention seam) and **S23** (quota / payload-size / branding policies) -are **specced but not yet implemented**. **S25–S27** +**done**; **S19** is **half done** — its **data version pin** (AD9: every `putOwnDataEntry` +write stamps `DataEntry.authoredAgainstVersion` with the artefact's payload hash; advisory, +surfaced by `get_artefact_data`) has shipped, while its **payload-retention seam** (AH15) and +**S23** (quota / payload-size / branding policies) are **specced but not yet implemented**. **S25–S27** (**Collections & Bookmarks**) are **done**: an owner-only nestable folder tree whose **root's** access (all four tiers incl. `selected`) the contained artefacts **inherit at read time** (AH20/AH21 — own tier dormant, slug minted on effective share), archive/restore/permanent-delete @@ -66,8 +68,8 @@ registration, authorize/consent/token under `/api/auth/mcp/*`, OIDC tables existing Hosting commands (create/update/list/get/set-visibility/archive/restore), the S31 `set_artefact_data` write, plus the **S30 read-back pair** — `get_artefact_html` (the stored HTML) and `get_artefact_data` (the caller's **own** blob verbatim + the artefact's declared schema + the -`currentPayloadVersion`/`authoredAgainstVersion` pin, the latter reserved and `null` until -S19) — each attributed to the token's Account. Both read-back tools hard-error above a context +`currentPayloadVersion`/`authoredAgainstVersion` pin — the latter stamped on every data write by +S19, `null` for no entry or a pre-pin entry) — each attributed to the token's Account. Both read-back tools hard-error above a context cap (~1 MB HTML / 256 KB blob) instead of truncating, pointing at the GUI download. Because connector-only clients (e.g. Claude design) **can't load the `artefactor` Agent Skill**, the connector self-describes its authoring contract: the MCP server's `instructions` carry a compact persistence summary (ambient, present before any diff --git a/docs/specs/ddd/artefact-data.md b/docs/specs/ddd/artefact-data.md index d5a3794..42b2c15 100644 --- a/docs/specs/ddd/artefact-data.md +++ b/docs/specs/ddd/artefact-data.md @@ -195,8 +195,8 @@ saved** in the seconds between its read and its write — not merely that the ar After a successful agent write, an open tab keeps showing the old data until reloaded; its next save is refused and the host shell prompts the reload. -**AD9.** Because the tool goes through `putOwnDataEntry`, once S19 lands a connector write -stamps `authoredAgainstVersion` exactly as a shim write does — no connector-specific path. +**AD9.** Because the tool goes through `putOwnDataEntry`, a connector write stamps +`authoredAgainstVersion` exactly as a shim write does (S19) — no connector-specific path. ## Artefact runtime contract @@ -299,9 +299,9 @@ read-only (AD5). ## Amendment (post-v0.2) — payload version pin -> **Status:** DDD amendment (FDD slice **S19**). A small **additive** field on `DataEntry` — -> harmless in OSS, and the hook a superset's rollback uses to judge data compatibility. It does -> not weaken opacity. +> **Status:** **implemented** (FDD slice **S19**, AD9 half; the AH15 retention half of S19 is +> still pending). A small **additive** field on `DataEntry` — harmless in OSS, and the hook a +> superset's rollback uses to judge data compatibility. It does not weaken opacity. **Problem.** A `DataEntry.blob` is shaped by whatever artefact payload was live when it was written. When the payload later changes shape — edited in place, or (in the superset) rolled @@ -313,22 +313,27 @@ host can detect the mismatch. | Field | Type | Notes | |-------|------|-------| -| `authoredAgainstVersion` | ContentHash \| null | The artefact's **payload content hash** at the moment of the (upsert) write. `null` for entries written before this field existed. Opaque — it names a payload, never describes the blob. | +| `authoredAgainstVersion` | ContentHash \| null | The artefact's **payload content hash** at the moment of the (upsert) write, re-stamped on every write. `null` for entries written before this field existed (no backfill — which payload they were written against is unknowable). Opaque — it names a payload, never describes the blob. | -**AD9 — pin on write.** Every `PUT …/data/me` sets `authoredAgainstVersion` to the artefact's -*current* payload content hash. It is **advisory metadata only**: it never gates a read or write -(AD3–AD5 unchanged) and the backend still never interprets the blob (AD8 holds). +**AD9 — pin on write.** Every write through `putOwnDataEntry` — `PUT …/data/me` (the served +shim) and, identically, the connector's `set_artefact_data` (S31) — sets +`authoredAgainstVersion` to the artefact's *current* payload content hash. There is no +connector-specific path. It is **advisory metadata only**: it never gates a read or write +(AD3–AD5 unchanged), and the backend still never interprets the blob (AD8 holds). Because it is +a **content** hash, an edit that restores byte-identical HTML makes an older pin current again. +That is correct, since the blob matches that HTML. The pin is not exposed on the BFF +`DataEntryResponse` or the S12 author list; today only the connector's snapshot read consumes it. **Use.** - **OSS:** even with a single mutable payload, the host can tell whether a viewer's saved data **predates the current payload** (pin ≠ current hash) — a sharper form of the `dataAuthorCount` - breaking-change signal already exposed to the MCP connector. **S30 reserves this field - without depending on it**: the snapshot read already returns the pair - (`currentPayloadVersion`, `authoredAgainstVersion`), with the pin `null` until this slice - lands. That is sound precisely because AD9 is advisory and gates nothing — when S19 ships, - the pin populates with no change to the tool's shape and no rewrite of the doctrine written - against it (`null` ⇒ unknown, treat as possibly stale; ≠ current ⇒ written against older - HTML, migration owed; = current ⇒ matches what is deployed). + breaking-change signal already exposed to the MCP connector. **S30 reserved this field + without depending on it**: the snapshot read returned the pair (`currentPayloadVersion`, + `authoredAgainstVersion`) with the pin `null` before this slice existed. That was sound + precisely because AD9 is advisory and gates nothing. S19 populated the pin with no change to + the tool's shape and no rewrite of the doctrine written against it (`null` ⇒ no entry, or one + that predates the pin: unknown, so treat it as possibly stale; ≠ current ⇒ written against + older HTML, migration owed; = current ⇒ matches what is deployed). Do not conflate this pin with the **declared schema's** `version` (see "Declared data schema" above): this one is mechanical and backend-set, that one semantic and author-set. diff --git a/docs/specs/fdd/slice-dag.md b/docs/specs/fdd/slice-dag.md index 8558a72..29fb936 100644 --- a/docs/specs/fdd/slice-dag.md +++ b/docs/specs/fdd/slice-dag.md @@ -32,7 +32,8 @@ S1 Identity (BetterAuth — email+password for dev; Google OAuth added later) tools — needs S2, S4, S6, S11, S18) │ ┊ │ ┊ (optional sharpener, NOT a dependency) - │ ┄┄┄ S19 data version pin (AD9) + │ ┄┄┄ S19 data version pin (AD9 — done; + │ AH15 retention seam still pending) │ └──► S31 Agent edits data: set_artefact_data (needs S11, S18, S30; S19 likewise @@ -503,24 +504,42 @@ flagged (see S18). with `ddd/artefact-data.md`, the tools in `src/server/mcp/`, and the `instructions` summary in `src/server/mcp/authoring-guide.ts` — same no-drift rule as specs). -### S19 — Payload-retention seam + data version pin *(enabler; behaviour-preserving)* +### S19 — Payload-retention seam + data version pin *(enabler; behaviour-preserving)* — **data pin done; retention seam pending** The single core change that makes artefact **history / rollback** buildable by a superset, without adding versioning to OSS. (DDD amendments: `ddd/artefact-hosting.md` AH15, -`ddd/artefact-data.md` AD9.) -- **Hosting — retention seam.** Replace the unconditional delete of the superseded payload in - `edit-artefact.command.ts` with a **`PayloadRetentionPolicy`** port. OSS wires the default - `DiscardSupersededPayload` (deletes — **byte-identical behaviour**); the seam is the one place - a superset swaps in a retaining policy. The artefact still has exactly one head payload. *(AH 15)* -- **Data — version pin.** `DataEntry` gains `authoredAgainstVersion`; every `PUT …/data/me` - stamps it with the artefact's current payload content hash. Advisory only — opacity and the - read/write access rules are unchanged. *(AD 9)* -- **Acceptance:** edit still leaves exactly one payload file under the default policy (no orphan, - no retained file); a fresh data write records the current payload hash; an entry written before - a subsequent edit reads back a pin ≠ the new hash (the staleness signal); permanent delete still - erases payload + data, and the policy is given the chance to purge anything it retained. -- **Boundary:** this slice is **OSS** (the seam must live where the deletion does). The retaining - policy, the version store, and rollback are the **EE** *Artefact History* context — see - `ee/docs/specs/`. Migration adds the nullable `authoredAgainstVersion` column. +`ddd/artefact-data.md` AD9.) The two halves are independent and **ship apart**: the AD9 data +pin is **done** (ALI-269); the AH15 retention seam is **pending**. The EE *Artefact History* +context needs **both**, so the pin alone doesn't unblock E1. +- **Hosting — retention seam** *(pending)*. Replace the unconditional delete of the superseded + payload in `edit-artefact.command.ts` with a **`PayloadRetentionPolicy`** port. OSS wires the + default `DiscardSupersededPayload` (deletes — **byte-identical behaviour**); the seam is the one + place a superset swaps in a retaining policy. The artefact still has exactly one head payload. + *(AH 15)* + - **Acceptance:** edit still leaves exactly one payload file under the default policy (no + orphan, no retained file); permanent delete still erases payload + data, and the policy gets + the chance to purge anything it retained. +- **Data — version pin** *(done)*. `DataEntry` gains `authoredAgainstVersion`. `upsertDataEntry` + requires it, so no write path can skip it, and `putOwnDataEntry` stamps it with the resolved + artefact's `payloadHash`. That one site covers `PUT …/data/me` **and** `set_artefact_data` + (S31), with no connector-specific path. Advisory only: opacity and the read/write access rules + are unchanged. The Drizzle upsert's `ON CONFLICT DO UPDATE SET` carries the pin, so it + **re-stamps** on update and doesn't freeze at the first write. `get_artefact_data` now returns + the entry's pin in the field S30 reserved (same shape). Not exposed on the BFF + `DataEntryResponse` or the S12 author list (no consumer). *(AD 9)* + - **Acceptance:** a fresh data write records the current payload hash; an entry written before + a subsequent payload edit reads back a pin ≠ the new hash (the staleness signal), and the + next write re-stamps it; an entry predating the column reads `null` and is still read and + written normally (its next write stamps it); the pin never grants or refuses access (a stale + pin doesn't block its author; a current one doesn't admit a non-viewer); the Drizzle adapter + re-stamps on update; `get_artefact_data` returns `null` with no entry, `= currentPayloadVersion` + after a write, and `≠` after `update_artefact` replaces the HTML; `set_artefact_data` and a + direct `putOwnDataEntry` stamp the same pin. + - **Persistence:** migration `0008` adds the nullable `authored_against_version` column (no + backfill). The EE Postgres mirror (`pg-schema.ts` + `PgDataRepository`) carries the same + column and mapping (P3 parity). +- **Boundary:** this slice is **OSS** (the seam must live where the deletion does, and the pin + where the write does). The retaining policy, the version store, and rollback are the **EE** + *Artefact History* context — see `ee/docs/specs/`. ### S20 — Hide the data-context switcher for non-persisting artefacts Stop showing the "Data context" picker (S12 chrome) on artefacts that can't usefully use it. @@ -854,8 +873,8 @@ and the backend treats blobs as opaque (AD8), so it cannot migrate them. `schema` is parsed JSON when present and `null` when absent, malformed, or not valid JSON — **never** an error; a blob is never validated against a declared schema (AD8 holds); an artefact with a declared schema survives export → re-upload intact; `currentPayloadVersion` - equals `payloadHash` and `authoredAgainstVersion` is `null` while S19 is unbuilt (both - fields' presence and shape asserted). + equals `payloadHash`, and `authoredAgainstVersion` is present with its shape asserted (its + value was `null` until the S19 data pin; S19's own acceptance now covers the populated value). - **Archived stays inert (AH7)** — no owner carve-out. Restore → download → re-archive is one click, which is not worth an exception in AH7 for an escape hatch. - **On S19/AD9 — reserve, don't depend.** `get_artefact_data` returns the version-pin **pair** @@ -865,7 +884,8 @@ and the backend treats blobs as opaque (AD8), so it cannot migrate them. the edit command, which read-back has no business pulling in. `currentPayloadVersion` is free today (`payloadHash` is already on the aggregate). When S19 lands, the pin populates with **no tool-shape change and no doctrine rewrite** — the rule "pin present and ≠ current ⇒ that - user's data predates this payload" is written now and becomes true then. + user's data predates this payload" is written now and becomes true then. *(Borne out: the + S19 data pin shipped on its own, ahead of AH15, as a one-line change to this tool.)* - **Out of scope:** the download affordance for "shared with you" (`GalleryCard`/`GalleryRow`) and the `/a/:slug` shell toolbar (the endpoint already honours the matrix — widening is client-only); baking a data snapshot into the downloaded file; any data **write** tool (S31); @@ -927,8 +947,9 @@ quarter"), write the whole blob back. not found; a tool-written blob — including one built from the declared schema's `example` into an empty entry — is what the served artefact's localStorage shim seeds. - **On S19/AD9 — sharpener, not dependency** (as S30). The write path stamps the pin for free - once S19 exists, because it is the same `putOwnDataEntry`; S19's own tests assert it. The - doctrine holds either way. + once S19 exists, because it is the same `putOwnDataEntry`; S19's own tests assert it + (`set_artefact_data` stamps exactly as `PUT …/data/me` does, and this tool needed no change). + The doctrine holds either way. - **Open question, decided: owner-scoped v1.** `putOwnDataEntry` already permits writing your own blob on any viewable artefact, but a write reaching further than the owner-scoped read would break read-modify-write exactly where the reach was wanted. Widening read + write @@ -1068,7 +1089,8 @@ independent of the sharing branch and can proceed in parallel once S2 exists. ~~ keys) and ~~S17~~ (data merge-patch) are dropped — see the DAG note. S18 is the programmatic surface. **S19** (retention seam + data pin) depends only on **S3** (the edit/replace path) and **S11** (`DataEntry`); it is behaviour-preserving in OSS and is the sole core dependency of the -EE *Artefact History* context. **S22** (tenant scope + access-policy seam) depends on the repo + +EE *Artefact History* context. Its halves ship apart: the **S11**-side data pin is done, and the +**S3**-side retention seam is pending. **S22** (tenant scope + access-policy seam) depends on the repo + serving/access path (**S6/S10/S14**) and **S23** (EE policy seams) on the create/edit commands (**S2/S3**) + the S12 shell; both are behaviour-preserving enablers and the sole core dependencies of the EE **Tenancy/Organizations** and **Usage & Quota** contexts respectively. **S24** (inject diff --git a/src/domain/data/data-entry.test.ts b/src/domain/data/data-entry.test.ts index bebcc0f..13720b1 100644 --- a/src/domain/data/data-entry.test.ts +++ b/src/domain/data/data-entry.test.ts @@ -31,6 +31,7 @@ describe("upsertDataEntry (AD1)", () => { artefactId: "a1", authorId: "u1", blob: "{}", + authoredAgainstVersion: null, now, }); expect(e).toMatchObject({ id: "d1", artefactId: "a1", authorId: "u1", blob: "{}" }); @@ -44,6 +45,7 @@ describe("upsertDataEntry (AD1)", () => { artefactId: "a1", authorId: "u1", blob: "{}", + authoredAgainstVersion: null, now: new Date("2026-01-01T00:00:00Z"), }); const later = new Date("2026-02-01T00:00:00Z"); @@ -52,6 +54,7 @@ describe("upsertDataEntry (AD1)", () => { artefactId: "a1", authorId: "u1", blob: '{"v":2}', + authoredAgainstVersion: null, existing: created, now: later, }); @@ -63,26 +66,57 @@ describe("upsertDataEntry (AD1)", () => { it("validates the blob before upserting (AD8)", () => { expect(() => - upsertDataEntry({ id: "d", artefactId: "a", authorId: "u", blob: "nope" }), + upsertDataEntry({ id: "d", artefactId: "a", authorId: "u", blob: "nope", authoredAgainstVersion: null }), ).toThrow(InvalidBlob); }); }); +describe("upsertDataEntry — payload version pin (AD9)", () => { + const base = { id: "d1", artefactId: "a1", authorId: "u1", blob: "{}" }; + + it("stamps a new entry with the payload hash it was written against", () => { + const e = upsertDataEntry({ ...base, authoredAgainstVersion: "hash-1" }); + expect(e.authoredAgainstVersion).toBe("hash-1"); + }); + + it("re-stamps on update, replacing the older pin", () => { + const created = upsertDataEntry({ ...base, authoredAgainstVersion: "hash-1" }); + const updated = upsertDataEntry({ + ...base, + blob: '{"v":2}', + authoredAgainstVersion: "hash-2", + existing: created, + }); + expect(updated.authoredAgainstVersion).toBe("hash-2"); + }); + + it("stamps an entry that predates the pin (null) on its next write", () => { + const legacy = upsertDataEntry({ ...base, authoredAgainstVersion: null }); + expect(legacy.authoredAgainstVersion).toBeNull(); + const updated = upsertDataEntry({ + ...base, + authoredAgainstVersion: "hash-1", + existing: legacy, + }); + expect(updated.authoredAgainstVersion).toBe("hash-1"); + }); +}); + describe("InMemoryDataRepository (AD1)", () => { it("keeps one entry per (artefact, author) and upserts", async () => { const repo = new InMemoryDataRepository(); await repo.save( - upsertDataEntry({ id: "d1", artefactId: "a1", authorId: "u1", blob: "{}" }), + upsertDataEntry({ id: "d1", artefactId: "a1", authorId: "u1", blob: "{}", authoredAgainstVersion: null }), ); await repo.save( - upsertDataEntry({ id: "d2", artefactId: "a1", authorId: "u1", blob: '{"v":2}' }), + upsertDataEntry({ id: "d2", artefactId: "a1", authorId: "u1", blob: '{"v":2}', authoredAgainstVersion: null }), ); const found = await repo.findByArtefactAndAuthor("a1", "u1"); expect(found?.blob).toBe('{"v":2}'); // Different author → separate entry. await repo.save( - upsertDataEntry({ id: "d3", artefactId: "a1", authorId: "u2", blob: "[]" }), + upsertDataEntry({ id: "d3", artefactId: "a1", authorId: "u2", blob: "[]", authoredAgainstVersion: null }), ); expect((await repo.findByArtefactAndAuthor("a1", "u2"))?.blob).toBe("[]"); diff --git a/src/domain/data/data-entry.ts b/src/domain/data/data-entry.ts index 04fa835..bc7c6de 100644 --- a/src/domain/data/data-entry.ts +++ b/src/domain/data/data-entry.ts @@ -11,6 +11,11 @@ export interface DataEntry { artefactId: string; authorId: string; blob: string; + // AD9 — the artefact's payload content hash at the last write: which HTML this + // blob was written against. `null` for an entry written before the pin + // existed. Advisory only — it names a payload, never describes the blob, and + // never gates a read or write. + authoredAgainstVersion: string | null; createdAt: Date; updatedAt: Date; } @@ -34,6 +39,9 @@ export interface UpsertDataEntryInput { artefactId: string; authorId: string; blob: string; + // AD9 — the artefact's current payload hash. Required so no write path can + // forget the stamp; `null` only for seeding an entry that predates the pin. + authoredAgainstVersion: string | null; // The current entry for this (artefact, author), if one exists. existing?: DataEntry | null; now?: Date; @@ -41,19 +49,26 @@ export interface UpsertDataEntryInput { // Upsert the single entry for a (artefact, author) pair (AD1). Validates the // blob (AD8); on update, preserves identity + createdAt and bumps updatedAt. -// `authorId` is the authenticated writer — enforced by the caller (AD2, AD3). +// Every write re-stamps the version pin (AD9). `authorId` is the authenticated +// writer — enforced by the caller (AD2, AD3). export function upsertDataEntry(input: UpsertDataEntryInput): DataEntry { assertBlobWithinBounds(input.blob); const now = input.now ?? new Date(); if (input.existing) { - return { ...input.existing, blob: input.blob, updatedAt: now }; + return { + ...input.existing, + blob: input.blob, + authoredAgainstVersion: input.authoredAgainstVersion, + updatedAt: now, + }; } return { id: input.id, artefactId: input.artefactId, authorId: input.authorId, blob: input.blob, + authoredAgainstVersion: input.authoredAgainstVersion, createdAt: now, updatedAt: now, }; diff --git a/src/infra/db/data-repository.drizzle.ts b/src/infra/db/data-repository.drizzle.ts index 31518d1..d042bc6 100644 --- a/src/infra/db/data-repository.drizzle.ts +++ b/src/infra/db/data-repository.drizzle.ts @@ -39,7 +39,12 @@ export class DrizzleDataRepository implements DataRepository { .values(row) .onConflictDoUpdate({ target: [dataEntry.artefactId, dataEntry.authorId], - set: { blob: row.blob, updatedAt: row.updatedAt }, + // The pin re-stamps on every write (AD9), not only on insert. + set: { + blob: row.blob, + authoredAgainstVersion: row.authoredAgainstVersion, + updatedAt: row.updatedAt, + }, }); } @@ -76,6 +81,7 @@ function toRow(e: DataEntry): DataEntryRow { artefactId: e.artefactId, authorId: e.authorId, blob: e.blob, + authoredAgainstVersion: e.authoredAgainstVersion, createdAt: e.createdAt, updatedAt: e.updatedAt, }; @@ -87,6 +93,7 @@ function toEntry(row: DataEntryRow): DataEntry { artefactId: row.artefactId, authorId: row.authorId, blob: row.blob, + authoredAgainstVersion: row.authoredAgainstVersion, createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/src/infra/db/migrations/0008_loose_loki.sql b/src/infra/db/migrations/0008_loose_loki.sql new file mode 100644 index 0000000..f82351f --- /dev/null +++ b/src/infra/db/migrations/0008_loose_loki.sql @@ -0,0 +1 @@ +ALTER TABLE `data_entry` ADD `authored_against_version` text; \ No newline at end of file diff --git a/src/infra/db/migrations/meta/0008_snapshot.json b/src/infra/db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..b06ca3f --- /dev/null +++ b/src/infra/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,1531 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "512c10e7-a967-48c7-a97a-d132e988b5af", + "prevId": "2305e6ea-68f4-459c-abd3-a44827d32ff1", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "artefact": { + "name": "artefact", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "public_slug": { + "name": "public_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "payload_ref": { + "name": "payload_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_bytes": { + "name": "payload_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uses_storage": { + "name": "uses_storage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "artefact_public_slug_uq": { + "name": "artefact_public_slug_uq", + "columns": [ + "public_slug" + ], + "isUnique": true + }, + "artefact_owner_idx": { + "name": "artefact_owner_idx", + "columns": [ + "owner_id" + ], + "isUnique": false + }, + "artefact_status_visibility_idx": { + "name": "artefact_status_visibility_idx", + "columns": [ + "status", + "visibility" + ], + "isUnique": false + }, + "artefact_collection_idx": { + "name": "artefact_collection_idx", + "columns": [ + "collection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "artefact_owner_id_user_id_fk": { + "name": "artefact_owner_id_user_id_fk", + "tableFrom": "artefact", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "artefact_collection_id_collection_id_fk": { + "name": "artefact_collection_id_collection_id_fk", + "tableFrom": "artefact", + "tableTo": "collection", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "artefact_access": { + "name": "artefact_access", + "columns": { + "artefact_id": { + "name": "artefact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "artefact_access_user_idx": { + "name": "artefact_access_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "artefact_access_artefact_id_artefact_id_fk": { + "name": "artefact_access_artefact_id_artefact_id_fk", + "tableFrom": "artefact_access", + "tableTo": "artefact", + "columnsFrom": [ + "artefact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "artefact_access_user_id_user_id_fk": { + "name": "artefact_access_user_id_user_id_fk", + "tableFrom": "artefact_access", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "artefact_access_artefact_id_user_id_pk": { + "columns": [ + "artefact_id", + "user_id" + ], + "name": "artefact_access_artefact_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "artefact_bookmark": { + "name": "artefact_bookmark", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artefact_id": { + "name": "artefact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "artefact_bookmark_artefact_idx": { + "name": "artefact_bookmark_artefact_idx", + "columns": [ + "artefact_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "artefact_bookmark_user_id_user_id_fk": { + "name": "artefact_bookmark_user_id_user_id_fk", + "tableFrom": "artefact_bookmark", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "artefact_bookmark_artefact_id_artefact_id_fk": { + "name": "artefact_bookmark_artefact_id_artefact_id_fk", + "tableFrom": "artefact_bookmark", + "tableTo": "artefact", + "columnsFrom": [ + "artefact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "artefact_bookmark_user_id_artefact_id_pk": { + "columns": [ + "user_id", + "artefact_id" + ], + "name": "artefact_bookmark_user_id_artefact_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection": { + "name": "collection", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "root_id": { + "name": "root_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "collection_owner_idx": { + "name": "collection_owner_idx", + "columns": [ + "owner_id" + ], + "isUnique": false + }, + "collection_parent_idx": { + "name": "collection_parent_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "collection_root_idx": { + "name": "collection_root_idx", + "columns": [ + "root_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_owner_id_user_id_fk": { + "name": "collection_owner_id_user_id_fk", + "tableFrom": "collection", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "collection_parent_id_collection_id_fk": { + "name": "collection_parent_id_collection_id_fk", + "tableFrom": "collection", + "tableTo": "collection", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_access": { + "name": "collection_access", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collection_access_user_idx": { + "name": "collection_access_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_access_collection_id_collection_id_fk": { + "name": "collection_access_collection_id_collection_id_fk", + "tableFrom": "collection_access", + "tableTo": "collection", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_access_user_id_user_id_fk": { + "name": "collection_access_user_id_user_id_fk", + "tableFrom": "collection_access", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collection_access_collection_id_user_id_pk": { + "columns": [ + "collection_id", + "user_id" + ], + "name": "collection_access_collection_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_bookmark": { + "name": "collection_bookmark", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collection_bookmark_collection_idx": { + "name": "collection_bookmark_collection_idx", + "columns": [ + "collection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_bookmark_user_id_user_id_fk": { + "name": "collection_bookmark_user_id_user_id_fk", + "tableFrom": "collection_bookmark", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_bookmark_collection_id_collection_id_fk": { + "name": "collection_bookmark_collection_id_collection_id_fk", + "tableFrom": "collection_bookmark", + "tableTo": "collection", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collection_bookmark_user_id_collection_id_pk": { + "columns": [ + "user_id", + "collection_id" + ], + "name": "collection_bookmark_user_id_collection_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "data_entry": { + "name": "data_entry", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "artefact_id": { + "name": "artefact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob": { + "name": "blob", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authored_against_version": { + "name": "authored_against_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "data_entry_artefact_author_uq": { + "name": "data_entry_artefact_author_uq", + "columns": [ + "artefact_id", + "author_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "data_entry_artefact_id_artefact_id_fk": { + "name": "data_entry_artefact_id_artefact_id_fk", + "tableFrom": "data_entry", + "tableTo": "artefact", + "columnsFrom": [ + "artefact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_entry_author_id_user_id_fk": { + "name": "data_entry_author_id_user_id_fk", + "tableFrom": "data_entry", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_access_token": { + "name": "oauth_access_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "oauth_access_token_access_token_unique": { + "name": "oauth_access_token_access_token_unique", + "columns": [ + "access_token" + ], + "isUnique": true + }, + "oauth_access_token_refresh_token_unique": { + "name": "oauth_access_token_refresh_token_unique", + "columns": [ + "refresh_token" + ], + "isUnique": true + }, + "oauth_access_token_client_idx": { + "name": "oauth_access_token_client_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_access_token_user_idx": { + "name": "oauth_access_token_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_application_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_application", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_application": { + "name": "oauth_application", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "oauth_application_client_id_unique": { + "name": "oauth_application_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + }, + "oauth_application_user_idx": { + "name": "oauth_application_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_application_user_id_user_id_fk": { + "name": "oauth_application_user_id_user_id_fk", + "tableFrom": "oauth_application", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consent": { + "name": "oauth_consent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consent_given": { + "name": "consent_given", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "oauth_consent_client_idx": { + "name": "oauth_consent_client_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_consent_user_idx": { + "name": "oauth_consent_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_application_client_id_fk": { + "name": "oauth_consent_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_application", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "view_entry": { + "name": "view_entry", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "artefact_id": { + "name": "artefact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "viewer_id": { + "name": "viewer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "view_entry_artefact_viewer_uq": { + "name": "view_entry_artefact_viewer_uq", + "columns": [ + "artefact_id", + "viewer_id" + ], + "isUnique": true + }, + "view_entry_artefact_idx": { + "name": "view_entry_artefact_idx", + "columns": [ + "artefact_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "view_entry_artefact_id_artefact_id_fk": { + "name": "view_entry_artefact_id_artefact_id_fk", + "tableFrom": "view_entry", + "tableTo": "artefact", + "columnsFrom": [ + "artefact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "view_entry_viewer_id_user_id_fk": { + "name": "view_entry_viewer_id_user_id_fk", + "tableFrom": "view_entry", + "tableTo": "user", + "columnsFrom": [ + "viewer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/infra/db/migrations/meta/_journal.json b/src/infra/db/migrations/meta/_journal.json index 2391c79..11601c3 100644 --- a/src/infra/db/migrations/meta/_journal.json +++ b/src/infra/db/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1783020410130, "tag": "0007_boring_nextwave", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1789285109589, + "tag": "0008_loose_loki", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/infra/db/schema.ts b/src/infra/db/schema.ts index 1ab8bf1..8db4dd5 100644 --- a/src/infra/db/schema.ts +++ b/src/infra/db/schema.ts @@ -376,6 +376,10 @@ export const dataEntry = sqliteTable( .notNull() .references(() => user.id), blob: text("blob").notNull(), + // S19 (AD9) — payload hash at the last write. Nullable: pre-existing rows + // keep `null` (no backfill — we can't know which payload they were written + // against). + authoredAgainstVersion: text("authored_against_version"), createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }, diff --git a/src/server/artefacts/lifecycle.command.test.ts b/src/server/artefacts/lifecycle.command.test.ts index 31de269..c062138 100644 --- a/src/server/artefacts/lifecycle.command.test.ts +++ b/src/server/artefacts/lifecycle.command.test.ts @@ -106,11 +106,11 @@ describe("delete command (S15, AH11)", () => { await repo.save({ ...base(), status: "archived", archivedAt: new Date() }); const now = new Date(); await dataRepo.save({ - id: "d1", artefactId: "a1", authorId: OWNER, blob: "[1]", + id: "d1", artefactId: "a1", authorId: OWNER, blob: "[1]", authoredAgainstVersion: null, createdAt: now, updatedAt: now, }); await dataRepo.save({ - id: "d2", artefactId: "a1", authorId: "viewer-2", blob: "[2]", + id: "d2", artefactId: "a1", authorId: "viewer-2", blob: "[2]", authoredAgainstVersion: null, createdAt: now, updatedAt: now, }); // A view entry too — permanent delete must remove these as well (VT5). diff --git a/src/server/collections/collections.command.test.ts b/src/server/collections/collections.command.test.ts index f8d4c86..0d3a5e4 100644 --- a/src/server/collections/collections.command.test.ts +++ b/src/server/collections/collections.command.test.ts @@ -470,7 +470,7 @@ describe("collection lifecycle cascades (S26, CL7/CL8)", () => { await bookmarkRepo.addArtefact(OWNER, "a2"); const now = new Date(); await dataRepo.save({ - id: "d1", artefactId: "a2", authorId: OWNER, blob: "[1]", + id: "d1", artefactId: "a2", authorId: OWNER, blob: "[1]", authoredAgainstVersion: null, createdAt: now, updatedAt: now, }); diff --git a/src/server/data.test.ts b/src/server/data.test.ts index f5078a9..f095a22 100644 --- a/src/server/data.test.ts +++ b/src/server/data.test.ts @@ -134,6 +134,66 @@ describe("artefact data store — /data/me (S11)", () => { expect(((await (await dataMe(slug, { method: "GET", cookie: owner })).json()) as DataEntryResponse).blob).toBeNull(); }); + // S19 (AD9) — against the real Drizzle adapter, because the upsert is an + // INSERT … ON CONFLICT DO UPDATE whose SET clause must carry the pin too: + // otherwise it is stamped on the first write and frozen on every later one. + describe("payload version pin (S19/AD9)", () => { + // The owner's pin, read back through the Drizzle adapter. + async function pinOf(artefactId: string) { + const { dataRepository } = await import("./adapters"); + const { db } = await import("../infra/db/client"); + const { user } = await import("../infra/db/schema"); + const { eq } = await import("drizzle-orm"); + const [u] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, "data-owner@example.com")); + return (await dataRepository.findByArtefactAndAuthor(artefactId, u!.id)) + ?.authoredAgainstVersion; + } + + async function payloadHashOf(artefactId: string) { + const { artefactRepository } = await import("./adapters"); + const { SINGLETON_SCOPE } = await import("../domain/artefact/tenant-scope"); + return (await artefactRepository.findById(artefactId, SINGLETON_SCOPE))!.payloadHash; + } + + it("stamps the payload hash on write and re-stamps it after a payload edit", async () => { + const { id, slug } = await makeShared(owner); + await dataMe(slug, { method: "PUT", body: '{"v":1}', cookie: owner }); + const first = await payloadHashOf(id); + expect(await pinOf(id)).toBe(first); + + const form = new FormData(); + form.set("payload", new File(["

f, reshaped

"], "f.html")); + expect( + (await app.request(`/api/artefacts/${id}`, { method: "PATCH", body: form, headers: { cookie: owner } })) + .status, + ).toBe(200); + const second = await payloadHashOf(id); + expect(second).not.toBe(first); + // Written before the edit → the staleness signal. + expect(await pinOf(id)).toBe(first); + + await dataMe(slug, { method: "PUT", body: '{"v":2}', cookie: owner }); + expect(await pinOf(id)).toBe(second); + }); + + it("an entry predating the column reads null and is served normally", async () => { + const { id, slug } = await makeShared(owner); + await dataMe(slug, { method: "PUT", body: '{"v":1}', cookie: owner }); + const { db } = await import("../infra/db/client"); + const { dataEntry } = await import("../infra/db/schema"); + const { eq } = await import("drizzle-orm"); + await db.update(dataEntry).set({ authoredAgainstVersion: null }).where(eq(dataEntry.artefactId, id)); + + expect(await pinOf(id)).toBeNull(); + const get = await dataMe(slug, { method: "GET", cookie: owner }); + expect(get.status).toBe(200); + expect(((await get.json()) as DataEntryResponse).blob).toBe('{"v":1}'); + }); + }); + // S31 — the served tab's shim pins its writes so a stale tab cannot silently // overwrite data replaced elsewhere (e.g. by an agent). Unpinned PUTs are // unchanged (every test above). diff --git a/src/server/data/own-data.command.test.ts b/src/server/data/own-data.command.test.ts index e782e77..7bc66f7 100644 --- a/src/server/data/own-data.command.test.ts +++ b/src/server/data/own-data.command.test.ts @@ -9,6 +9,7 @@ import { createArtefact, shareArtefact, archiveArtefact, + editArtefact, type Artefact, } from "../../domain/artefact/artefact"; import { InMemoryArtefactRepository } from "../../domain/artefact/in-memory-artefact-repository"; @@ -204,4 +205,83 @@ describe("own-data commands (S11)", () => { expect((await getOwnDataEntry(REF, deps))?.blob).toBe('{"v":2}'); }); }); + + // S19 (AD9) — every write stamps the payload hash it was written against. + // Advisory only: the pin never decides whether a read or write is allowed. + describe("payload version pin — authoredAgainstVersion (S19/AD9)", () => { + const REF = { ref: "slug1", authorId: OWNER, scope: SCOPE }; + + // Replace the artefact's payload in place (S3), as an edit would. + async function editPayload(hash: string) { + const current = (await artefactRepo.findById("a1", SCOPE))!; + await artefactRepo.save( + editArtefact(current, { payload: { ref: `r-${hash}`, bytes: 10, hash } }), + ); + } + + it("a fresh write records the artefact's current payload hash", async () => { + await seed(); + const saved = await putOwnDataEntry(REF, '{"v":1}', deps); + expect(saved.authoredAgainstVersion).toBe("h"); + expect((await getOwnDataEntry(REF, deps))?.authoredAgainstVersion).toBe("h"); + }); + + it("an entry written before a payload edit reads back a pin ≠ the new hash", async () => { + await seed(); + await putOwnDataEntry(REF, '{"v":1}', deps); + await editPayload("h2"); + const stale = await getOwnDataEntry(REF, deps); + expect(stale?.authoredAgainstVersion).toBe("h"); + expect(stale?.authoredAgainstVersion).not.toBe("h2"); + + // The next write re-stamps against the live payload. + await putOwnDataEntry(REF, '{"v":2}', deps); + expect((await getOwnDataEntry(REF, deps))?.authoredAgainstVersion).toBe("h2"); + }); + + it("an entry predating the pin reads null, and is still readable and writable", async () => { + await seed(); + await dataRepo.save({ + id: "legacy", + artefactId: "a1", + authorId: OWNER, + blob: '{"old":1}', + authoredAgainstVersion: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }); + const legacy = await getOwnDataEntry(REF, deps); + expect(legacy?.blob).toBe('{"old":1}'); + expect(legacy?.authoredAgainstVersion).toBeNull(); + + const saved = await putOwnDataEntry(REF, '{"new":1}', deps); + expect(saved.id).toBe("legacy"); + expect(saved.authoredAgainstVersion).toBe("h"); + }); + + it("never gates access: a stale pin doesn't block the owner, a matching one doesn't admit a non-viewer (AD3–AD5)", async () => { + const shared = await seed("public"); + await putOwnDataEntry(REF, '{"v":1}', deps); + await putOwnDataEntry({ ...REF, authorId: "user-2" }, '{"v":1}', deps); + await editPayload("h2"); + + // Stale pins: both authors still read and write their own entry. + await expect(getOwnDataEntry(REF, deps)).resolves.not.toBeNull(); + await expect(putOwnDataEntry(REF, '{"v":2}', deps)).resolves.toBeDefined(); + await expect( + putOwnDataEntry({ ...REF, authorId: "user-2" }, '{"v":2}', deps), + ).resolves.toBeDefined(); + + // Unshare → private. user-2's pin now matches the live payload, but that + // doesn't reach past the access matrix. + const current = (await artefactRepo.findById("a1", SCOPE))!; + await artefactRepo.save({ ...current, visibility: "private", publicSlug: shared.publicSlug }); + await expect( + getOwnDataEntry({ ...REF, authorId: "user-2" }, deps), + ).rejects.toBeInstanceOf(ArtefactNotFound); + await expect( + putOwnDataEntry({ ...REF, authorId: "user-2" }, "{}", deps), + ).rejects.toBeInstanceOf(ArtefactNotFound); + }); + }); }); diff --git a/src/server/data/own-data.command.ts b/src/server/data/own-data.command.ts index 572ad48..107eeff 100644 --- a/src/server/data/own-data.command.ts +++ b/src/server/data/own-data.command.ts @@ -104,7 +104,8 @@ export interface PutOwnDataOptions { ifUnmodifiedSince?: Date | null; } -// PUT own entry — validate + upsert the caller's blob (AD1, AD2, AD8). +// PUT own entry — validate + upsert the caller's blob (AD1, AD2, AD8), pinned +// to the payload it was written against (AD9). export async function putOwnDataEntry( ref: OwnDataRef, blob: string, @@ -136,6 +137,9 @@ export async function putOwnDataEntry( artefactId: artefact.id, authorId: ref.authorId, blob, + // AD9 — stamped here, so `PUT …/data/me` and `set_artefact_data` pin + // identically. Advisory: nothing above reads it. + authoredAgainstVersion: artefact.payloadHash, existing, now: (deps.now ?? (() => new Date()))(), }); diff --git a/src/server/mcp/tools.test.ts b/src/server/mcp/tools.test.ts index 5101390..1cee4b4 100644 --- a/src/server/mcp/tools.test.ts +++ b/src/server/mcp/tools.test.ts @@ -9,6 +9,7 @@ import { SINGLETON_SCOPE, type TenantScope } from "../../domain/artefact/tenant- import { InMemoryDataRepository } from "../../domain/data/in-memory-data-repository"; import { MAX_BLOB_BYTES, upsertDataEntry } from "../../domain/data/data-entry"; import { renderServedArtefact } from "../runtime/render"; +import { putOwnDataEntry } from "../data/own-data.command"; import type { PayloadStore, StoredPayload } from "../../domain/artefact/ports"; // S18 — the MCP tool surface, exercised through a real in-memory MCP @@ -209,10 +210,10 @@ describe("MCP artefact tools (S18)", () => { // Two users save data (the opaque blobs the running artefact persists). await deps.dataRepo.save( - upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob: "{}" }), + upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob: "{}", authoredAgainstVersion: null }), ); await deps.dataRepo.save( - upsertDataEntry({ id: "d2", artefactId: a.id, authorId: "u2", blob: "{}" }), + upsertDataEntry({ id: "d2", artefactId: a.id, authorId: "u2", blob: "{}", authoredAgainstVersion: null }), ); expect(json(await call(client, "get_artefact", { id: a.id })).dataAuthorCount).toBe(2); @@ -259,7 +260,7 @@ describe("MCP artefact tools (S18)", () => { await call(client, "create_artefact", { title: "Counter", kind: "form", html }), ); await deps.dataRepo.save( - upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u2", blob: "{}" }), + upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u2", blob: "{}", authoredAgainstVersion: null }), ); const r = json(await call(client, "get_artefact_html", { id: a.id })); @@ -301,6 +302,7 @@ describe("MCP artefact tools (S18)", () => { artefactId: a.id, authorId: "u1", blob, + authoredAgainstVersion: null, now: new Date("2026-09-12T10:00:00.000Z"), }), ); @@ -318,7 +320,7 @@ describe("MCP artefact tools (S18)", () => { await call(client, "create_artefact", { title: "Tracker", kind: "form", html: "t" }), ); await deps.dataRepo.save( - upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u2", blob: '{"theirs":1}' }), + upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u2", blob: '{"theirs":1}', authoredAgainstVersion: null }), ); const r = json(await call(client, "get_artefact_data", { id: a.id })); @@ -337,7 +339,7 @@ describe("MCP artefact tools (S18)", () => { ); const blob = JSON.stringify({ big: "a".repeat(MAX_MCP_BLOB_BYTES) }); await deps.dataRepo.save( - upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob }), + upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob, authoredAgainstVersion: null }), ); const r = await call(client, "get_artefact_data", { id: a.id }); @@ -404,7 +406,7 @@ describe("MCP artefact tools (S18)", () => { // A blob that contradicts the declaration entirely. const blob = '{"something-else":"[1,2,3]"}'; await deps.dataRepo.save( - upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob }), + upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob, authoredAgainstVersion: null }), ); const r = await call(client, "get_artefact_data", { id: a.id }); @@ -412,19 +414,65 @@ describe("MCP artefact tools (S18)", () => { expect(json(r).blob).toBe(blob); }); - it("get_artefact_data reports the payload version pin (AD9 reserved)", async () => { + it("get_artefact_data reports the payload version pin (AD9)", async () => { const client = await clientFor("u1"); const a = json( await call(client, "create_artefact", { title: "Pinned", kind: "form", html: "p" }), ); const stored = (await deps.repo.findById(a.id, SINGLETON_SCOPE))!; - const r = json(await call(client, "get_artefact_data", { id: a.id })); - expect(r.currentPayloadVersion).toBe(stored.payloadHash); - // Reserved, not yet populated: S19 (ALI-269) sets the pin on every write. - // The field's presence and shape are asserted now so S19 needs no tool change. - expect(r).toHaveProperty("authoredAgainstVersion"); - expect(r.authoredAgainstVersion).toBeNull(); + // No entry → no pin. The field's presence and shape are unchanged from S30. + const none = json(await call(client, "get_artefact_data", { id: a.id })); + expect(none.currentPayloadVersion).toBe(stored.payloadHash); + expect(none).toHaveProperty("authoredAgainstVersion"); + expect(none.authoredAgainstVersion).toBeNull(); + + // A write stamps the live payload hash → pin = current. + await call(client, "set_artefact_data", { id: a.id, blob: '{"v":"1"}' }); + const fresh = json(await call(client, "get_artefact_data", { id: a.id })); + expect(fresh.authoredAgainstVersion).toBe(stored.payloadHash); + expect(fresh.authoredAgainstVersion).toBe(fresh.currentPayloadVersion); + + // The HTML changes under the saved data → pin ≠ current (the staleness signal). + await call(client, "update_artefact", { id: a.id, html: "p, reshaped" }); + const stale = json(await call(client, "get_artefact_data", { id: a.id })); + expect(stale.currentPayloadVersion).not.toBe(stored.payloadHash); + expect(stale.authoredAgainstVersion).toBe(stored.payloadHash); + }); + + it("an entry predating the pin reads authoredAgainstVersion null, never an error (AD9)", async () => { + const client = await clientFor("u1"); + const a = json( + await call(client, "create_artefact", { title: "Legacy", kind: "form", html: "l" }), + ); + await deps.dataRepo.save( + upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob: "{}", authoredAgainstVersion: null }), + ); + const r = await call(client, "get_artefact_data", { id: a.id }); + expect(r.isError).toBeFalsy(); + expect(json(r).blob).toBe("{}"); + expect(json(r).authoredAgainstVersion).toBeNull(); + }); + + it("set_artefact_data stamps the pin exactly as PUT …/data/me does (AD9)", async () => { + const client = await clientFor("u1"); + const a = json( + await call(client, "create_artefact", { title: "Same path", kind: "form", html: "s" }), + ); + const stored = (await deps.repo.findById(a.id, SINGLETON_SCOPE))!; + await call(client, "set_artefact_data", { id: a.id, blob: '{"via":"agent"}' }); + const viaTool = (await deps.dataRepo.findByArtefactAndAuthor(a.id, "u1"))!; + + // The `PUT …/data/me` route's write, which is this command. + await putOwnDataEntry( + { ref: a.id, authorId: "u1", scope: SINGLETON_SCOPE }, + '{"via":"http"}', + { artefactRepo: deps.repo, collectionRepo: deps.collectionRepo, dataRepo: deps.dataRepo }, + ); + const viaCommand = (await deps.dataRepo.findByArtefactAndAuthor(a.id, "u1"))!; + + expect(viaTool.authoredAgainstVersion).toBe(stored.payloadHash); + expect(viaCommand.authoredAgainstVersion).toBe(viaTool.authoredAgainstVersion); }); it("both read-back tools are owner-scoped: unknown, another user's, and archived all -> not found", async () => { @@ -470,7 +518,7 @@ describe("MCP artefact tools (S18)", () => { const client = await clientFor("u1"); const a = await mine(client); await deps.dataRepo.save( - upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob: '{"k":"old"}', now: PAST }), + upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob: '{"k":"old"}', authoredAgainstVersion: null, now: PAST }), ); const read = json(await call(client, "get_artefact_data", { id: a.id })); @@ -513,7 +561,7 @@ describe("MCP artefact tools (S18)", () => { const client = await clientFor("u1"); const a = await mine(client); await deps.dataRepo.save( - upsertDataEntry({ id: "d2", artefactId: a.id, authorId: "u2", blob: '{"theirs":1}', now: PAST }), + upsertDataEntry({ id: "d2", artefactId: a.id, authorId: "u2", blob: '{"theirs":1}', authoredAgainstVersion: null, now: PAST }), ); await call(client, "set_artefact_data", { id: a.id, blob: '{"mine":1}' }); @@ -553,7 +601,7 @@ describe("MCP artefact tools (S18)", () => { const client = await clientFor("u1"); const a = await mine(client); await deps.dataRepo.save( - upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob: '{"v":"read"}', now: PAST }), + upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob: '{"v":"read"}', authoredAgainstVersion: null, now: PAST }), ); const read = json(await call(client, "get_artefact_data", { id: a.id })); // The user's open tab saves between the agent's read and its write. @@ -580,7 +628,7 @@ describe("MCP artefact tools (S18)", () => { const read = json(await call(client, "get_artefact_data", { id: a.id })); expect(read.updatedAt).toBeNull(); await deps.dataRepo.save( - upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob: '{"v":"tab"}' }), + upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob: '{"v":"tab"}', authoredAgainstVersion: null }), ); const r = await call(client, "set_artefact_data", { @@ -596,7 +644,7 @@ describe("MCP artefact tools (S18)", () => { const client = await clientFor("u1"); const a = await mine(client); await deps.dataRepo.save( - upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob: '{"v":"tab"}' }), + upsertDataEntry({ id: "d1", artefactId: a.id, authorId: "u1", blob: '{"v":"tab"}', authoredAgainstVersion: null }), ); const r = await call(client, "set_artefact_data", { id: a.id, blob: '{"v":"agent"}' }); expect(r.isError).toBeFalsy(); diff --git a/src/server/mcp/tools.ts b/src/server/mcp/tools.ts index 1eafa87..3fbceee 100644 --- a/src/server/mcp/tools.ts +++ b/src/server/mcp/tools.ts @@ -361,13 +361,11 @@ export function registerArtefactTools( // pin below is the payload hash the backend stamps on a write, while // the schema's own `version` is *semantic* and author-declared. currentPayloadVersion: a.payloadHash, - // Reserved by S30, populated by S19 (ALI-269) — which is an optional - // sharpener of this staleness signal, not a dependency: AD9 is advisory - // and never gates a read or write, so `null` is correct until then. - // The doctrine is written now and becomes exact then: null ⇒ unknown, - // treat as possibly stale; ≠ current ⇒ written against older HTML, - // migration owed; = current ⇒ matches what is deployed. - authoredAgainstVersion: null as string | null, + // Stamped by every `putOwnDataEntry` write (S19). null ⇒ no entry, or + // one written before the pin existed — unknown, treat as possibly + // stale; ≠ current ⇒ written against older HTML, migration owed; + // = current ⇒ matches what is deployed. + authoredAgainstVersion: entry?.authoredAgainstVersion ?? null, }; }), );