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(["