From 03ba135e405f991ec07b4bf658c3b0ae8abed1ac Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 10 Sep 2026 06:20:59 -0700 Subject: [PATCH] Let a version carry metadata and explicit parent versions Hosts that build on artifacts keep side tables for what a version cannot hold: a stage, a content hash, provenance, and which exact versions a version was generated from. A nullable opaque metadata object on each version, mirrored onto the artifact for the current version the way title and content already are, and an explicit parent_version_ids array that is never inferred from order, let those side tables go. Migration 0004 adds the columns; existing databases adopt with no backfill. --- CHANGELOG.md | 11 +++ README.md | 10 +++ src/artifacts.test.ts | 153 ++++++++++++++++++++++++++++++++++++++++- src/artifacts.ts | 116 +++++++++++++++++++++++++++++-- src/migrations.test.ts | 89 ++++++++++++++++++++++++ src/migrations.ts | 28 ++++++++ src/schema.ts | 11 +++ 7 files changed, 412 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a24a363..ff9d7e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ always called out under their own heading. ### Added +- `artifact_version.metadata` (jsonb, nullable) and + `artifact_version.parent_version_ids` (text[], nullable), added by the new + `0004_version_metadata` migration. `metadata` is opaque to the package — + stored and returned as-is on every version read — and `artifact.metadata` + mirrors the current version's value the same way `title`/`content`/ + `version` already do. `parentVersionIds` is explicit lineage set by the + writer and is never inferred from version order or carried forward between + versions. `createArtifact`, `writeArtifactVersion`, and + `findOrVersionArtifact` all accept optional `metadata` and + `parentVersionIds`; `getArtifactVersion` and `listArtifactVersions` return + both fields alongside each version. - `findOrVersionArtifact(db, args)` — the atomic primitive behind "find an artifact by title, create it if absent, add a version if present." The schema's only uniqueness is `(artifactId, version)`; nothing constrains diff --git a/README.md b/README.md index 15f7c08..e9d74b9 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,16 @@ content server-side; only the response projection changes. last version number returned (newest-first). Version rows omit content; use `GET /api/artifacts/:id?version=N` (or `getArtifactVersion`) for a pinned body. +**Version metadata and lineage:** each version optionally carries `metadata` (any JSON +object, opaque to the package — stored and returned as-is) and `parentVersionIds` (an +explicit array of ids the writer supplies; never inferred from version order). +`createArtifact`, `writeArtifactVersion`, and `findOrVersionArtifact` all accept both as +optional arguments; `getArtifactVersion` and `listArtifactVersions` return both on every +version. Omitting `metadata` on a revision carries the previous version's value forward, +the same way an omitted `title`/`content` does; `parentVersionIds` is never carried +forward — a version with no explicit parents simply has none. `artifact.metadata` mirrors +the current version's value, same as `title`/`content`/`version` already do. + **Write size limits:** create and revise reject titles longer than 512 characters and content larger than 15 MiB UTF-8 (`ArtifactSizeError` / HTTP 400). JSON mutators also refuse a declared `Content-Length` over that same 15 MiB ceiling with HTTP 413 before diff --git a/src/artifacts.test.ts b/src/artifacts.test.ts index 4d92b69..f3bbf75 100644 --- a/src/artifacts.test.ts +++ b/src/artifacts.test.ts @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"; import { ArtifactNotFoundError, ArtifactSizeError, + ArtifactValidationError, createArtifact, findArtifactByTitle, findOrVersionArtifact, @@ -25,7 +26,13 @@ describe("create", () => { expect(row.version).toBe(1); const pinned = await getArtifactVersion(db, row.id, 1); - expect(pinned).toEqual({ title: "Brief", content: "first", version: 1 }); + expect(pinned).toEqual({ + title: "Brief", + content: "first", + version: 1, + metadata: null, + parentVersionIds: null, + }); }); test("refuses to mint a skill-draft", async () => { @@ -523,3 +530,147 @@ describe("version history isolation", () => { }); }); +describe("version metadata and lineage", () => { + test("round-trips metadata and parentVersionIds on create, mirrored onto the artifact row", async () => { + const db = await testDb(); + const row = await db.transaction((tx) => + createArtifact(tx, { + scope: SCOPE, + ownerPrincipalId: null, + kind: "document", + title: "Brief", + content: "v1", + source: { origin: "manual" }, + metadata: { tag: "draft" }, + parentVersionIds: ["ancestor-1"], + }), + ); + + expect(row.metadata).toEqual({ tag: "draft" }); + + const pinned = await getArtifactVersion(db, row.id, 1); + expect(pinned?.metadata).toEqual({ tag: "draft" }); + expect(pinned?.parentVersionIds).toEqual(["ancestor-1"]); + + const detail = serializeArtifact(row); + expect(detail.metadata).toEqual({ tag: "draft" }); + }); + + test("writeArtifactVersion carries metadata forward when omitted, but never carries parentVersionIds forward", async () => { + const db = await testDb(); + const row = await db.transaction((tx) => + createArtifact(tx, { + scope: SCOPE, + ownerPrincipalId: null, + kind: "document", + title: "Brief", + content: "v1", + source: { origin: "manual" }, + metadata: { tag: "draft" }, + parentVersionIds: ["ancestor-1"], + }), + ); + + const second = await writeArtifactVersion(db, { + scope: SCOPE, + artifactId: row.id, + content: "v2", + }); + expect(second.metadata).toEqual({ tag: "draft" }); + + const v2 = await getArtifactVersion(db, row.id, 2); + expect(v2?.metadata).toEqual({ tag: "draft" }); + expect(v2?.parentVersionIds).toBeNull(); + + const third = await writeArtifactVersion(db, { + scope: SCOPE, + artifactId: row.id, + content: "v3", + metadata: { tag: "final" }, + parentVersionIds: ["v1-id", "v2-id"], + }); + expect(third.metadata).toEqual({ tag: "final" }); + const v3 = await getArtifactVersion(db, row.id, 3); + expect(v3?.parentVersionIds).toEqual(["v1-id", "v2-id"]); + }); + + test("findOrVersionArtifact passes metadata and parentVersionIds through both outcomes", async () => { + const db = await testDb(); + const created = await findOrVersionArtifact(db, { + scope: SCOPE, + ownerPrincipalId: null, + kind: "document", + title: "Report", + content: "v1", + source: { origin: "workflow" }, + metadata: { origin: "pipeline" }, + }); + expect(created.outcome).toBe("created"); + expect(created.artifact.metadata).toEqual({ origin: "pipeline" }); + + const revised = await findOrVersionArtifact(db, { + scope: SCOPE, + ownerPrincipalId: null, + kind: "document", + title: "Report", + content: "v2", + source: { origin: "workflow" }, + parentVersionIds: [created.artifact.id], + }); + expect(revised.outcome).toBe("revised"); + expect(revised.artifact.metadata).toEqual({ origin: "pipeline" }); + const revisedVersion = await getArtifactVersion( + db, + revised.artifact.id, + revised.artifact.version, + ); + expect(revisedVersion?.parentVersionIds).toEqual([created.artifact.id]); + }); + + test("listArtifactVersions returns metadata and parentVersionIds per version", async () => { + const db = await testDb(); + const row = await seedArtifact(db, { title: "Draft", content: "v1" }); + await writeArtifactVersion(db, { + scope: SCOPE, + artifactId: row.id, + content: "v2", + metadata: { step: 2 }, + parentVersionIds: [row.id], + }); + + const history = await listArtifactVersions(db, row.id); + const v2 = history.versions.find((v) => v.version === 2); + expect(v2?.metadata).toEqual({ step: 2 }); + expect(v2?.parentVersionIds).toEqual([row.id]); + const v1 = history.versions.find((v) => v.version === 1); + expect(v1?.metadata).toBeNull(); + expect(v1?.parentVersionIds).toBeNull(); + }); + + test("rejects a non-object metadata and a non-string-array parentVersionIds", async () => { + const db = await testDb(); + await expect( + db.transaction((tx) => + createArtifact(tx, { + scope: SCOPE, + ownerPrincipalId: null, + kind: "document", + title: "x", + content: "y", + source: { origin: "manual" }, + metadata: ["not", "an", "object"] as unknown as Record, + }), + ), + ).rejects.toBeInstanceOf(ArtifactValidationError); + + await expect( + writeArtifactVersion(db, { + scope: SCOPE, + artifactId: (await seedArtifact(db)).id, + content: "z", + parentVersionIds: [1, 2] as unknown as string[], + }), + ).rejects.toBeInstanceOf(ArtifactValidationError); + }); +}); + diff --git a/src/artifacts.ts b/src/artifacts.ts index fe9cf20..b134cf4 100644 --- a/src/artifacts.ts +++ b/src/artifacts.ts @@ -87,6 +87,40 @@ const JsonObject = type("object").narrow( (value): value is Record => !Array.isArray(value), ); +// `metadata` is opaque to the package: any JSON object is accepted and +// returned as-is, never interpreted. `parentVersionIds` is explicit lineage — +// a plain array of ids, never inferred from version order. +const MetadataShape = JsonObject; +const ParentVersionIdsShape = type("string[]"); + +export class ArtifactValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ArtifactValidationError"; + } +} + +/** Reject a malformed `metadata` or `parentVersionIds` before they hit the database. */ +export function assertVersionMetadataShape(fields: { + metadata?: unknown; + parentVersionIds?: unknown; +}): void { + if (fields.metadata !== undefined && fields.metadata !== null) { + const result = MetadataShape(fields.metadata); + if (result instanceof type.errors) { + throw new ArtifactValidationError(`Invalid metadata: ${result.summary}`); + } + } + if (fields.parentVersionIds !== undefined && fields.parentVersionIds !== null) { + const result = ParentVersionIdsShape(fields.parentVersionIds); + if (result instanceof type.errors) { + throw new ArtifactValidationError( + `Invalid parentVersionIds: ${result.summary}`, + ); + } + } +} + /** * A null source, one that is not a JSON object at all, or one with an * unrecognized origin, all read as `unknown`. @@ -116,6 +150,8 @@ export type SerializedArtifactBase = { source: Record & { origin: string }; version: number; ownerPrincipalId: string | null; + /** Mirrors the current version's `artifact_version.metadata`, opaque to the package. */ + metadata: Record | null; archivedAt: string | null; createdAt: string; updatedAt: string; @@ -145,6 +181,7 @@ function serializeArtifactBase( source: normalizeSource(row.source), version: row.version, ownerPrincipalId: row.ownerPrincipalId, + metadata: (row.metadata as Record | null) ?? null, archivedAt: row.archivedAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), @@ -181,6 +218,10 @@ export type CreateArtifactArgs = { title: string; content: string; source: Record; + /** Opaque to the package; stored and returned as-is on every version read. */ + metadata?: Record | null; + /** Explicit lineage for version 1 — never inferred from order. */ + parentVersionIds?: string[] | null; }; /** @@ -206,6 +247,12 @@ export async function createArtifact( } const content = normalizeContentForKind(args.kind, args.content); assertArtifactFieldSizes({ title: args.title, content }); + assertVersionMetadataShape({ + metadata: args.metadata, + parentVersionIds: args.parentVersionIds, + }); + const metadata = args.metadata ?? null; + const parentVersionIds = args.parentVersionIds ?? null; const now = new Date(); const [row] = await tx @@ -219,6 +266,7 @@ export async function createArtifact( content, source: args.source, version: 1, + metadata, createdAt: now, updatedAt: now, }) @@ -231,6 +279,8 @@ export async function createArtifact( title: args.title, content, authorId: args.scope.principalId, + metadata, + parentVersionIds, createdAt: now, }); @@ -262,9 +312,17 @@ async function reviseArtifactVersion( artifactId: string; title?: string; content?: string; + /** Opaque to the package; undefined carries the prior version's metadata forward. */ + metadata?: Record | null; + /** Explicit lineage for this version — never inferred, never carried forward. */ + parentVersionIds?: string[] | null; }, now: Date, ): Promise { + assertVersionMetadataShape({ + metadata: args.metadata, + parentVersionIds: args.parentVersionIds, + }); const [existing] = await tx .select() .from(artifact) @@ -294,10 +352,15 @@ async function reviseArtifactVersion( if (args.content !== undefined) { assertArtifactFieldSizes({ content }); } + const metadata = + args.metadata === undefined + ? (existing.metadata as Record | null) + : args.metadata; + const parentVersionIds = args.parentVersionIds ?? null; const [updated] = await tx .update(artifact) - .set({ title, content, version, updatedAt: now }) + .set({ title, content, version, metadata, updatedAt: now }) .where(eq(artifact.id, args.artifactId)) .returning(); if (!updated) throw new ArtifactNotFoundError(args.artifactId); @@ -308,6 +371,8 @@ async function reviseArtifactVersion( title, content, authorId: args.scope.principalId, + metadata, + parentVersionIds, createdAt: now, }); @@ -325,8 +390,17 @@ export async function writeArtifactVersion( artifactId: string; title?: string; content?: string; + /** Opaque to the package; omit to carry the prior version's metadata forward. */ + metadata?: Record | null; + /** Explicit lineage for this version — never inferred from order. */ + parentVersionIds?: string[] | null; }, -): Promise<{ artifactId: string; version: number; title: string }> { +): Promise<{ + artifactId: string; + version: number; + title: string; + metadata: Record | null; +}> { if (args.title === undefined && args.content === undefined) { throw new Error("Provide content and/or title to revise the artifact"); } @@ -337,7 +411,12 @@ export async function writeArtifactVersion( return await db.transaction(async (tx) => { const row = await reviseArtifactVersion(tx, args, now); - return { artifactId: row.id, version: row.version, title: row.title }; + return { + artifactId: row.id, + version: row.version, + title: row.title, + metadata: (row.metadata as Record | null) ?? null, + }; }); } @@ -358,12 +437,20 @@ export async function getArtifactVersion( db: ArtifactDb, artifactId: string, version: number, -): Promise<{ title: string; content: string; version: number } | null> { +): Promise<{ + title: string; + content: string; + version: number; + metadata: Record | null; + parentVersionIds: string[] | null; +} | null> { const [row] = await db .select({ title: artifactVersion.title, content: artifactVersion.content, version: artifactVersion.version, + metadata: artifactVersion.metadata, + parentVersionIds: artifactVersion.parentVersionIds, }) .from(artifactVersion) .where( @@ -373,7 +460,12 @@ export async function getArtifactVersion( ), ) .limit(1); - return row ?? null; + if (!row) return null; + return { + ...row, + metadata: (row.metadata as Record | null) ?? null, + parentVersionIds: row.parentVersionIds ?? null, + }; } export type ArtifactVersionListItem = { @@ -381,6 +473,8 @@ export type ArtifactVersionListItem = { title: string; authorId: string; createdAt: string; + metadata: Record | null; + parentVersionIds: string[] | null; }; export type ListArtifactVersionsFilters = { @@ -409,6 +503,8 @@ export async function listArtifactVersions( title: artifactVersion.title, authorId: artifactVersion.authorId, createdAt: artifactVersion.createdAt, + metadata: artifactVersion.metadata, + parentVersionIds: artifactVersion.parentVersionIds, }) .from(artifactVersion) .where(and(...conditions)) @@ -419,6 +515,8 @@ export async function listArtifactVersions( const versions = page.map((r) => ({ ...r, createdAt: r.createdAt.toISOString(), + metadata: (r.metadata as Record | null) ?? null, + parentVersionIds: r.parentVersionIds ?? null, })); if (fetched.length <= limit) return { versions, nextCursor: null }; const last = page[page.length - 1]!; @@ -695,6 +793,10 @@ export type FindOrVersionArtifactArgs = { content: string; /** Ignored on the revise path — only a fresh artifact's provenance. */ source: Record; + /** Opaque to the package; omit on revise to carry the prior version's metadata forward. */ + metadata?: Record | null; + /** Explicit lineage for this version — never inferred from order. */ + parentVersionIds?: string[] | null; }; export type FindOrVersionArtifactResult = { @@ -761,6 +863,8 @@ export async function findOrVersionArtifact( scope: args.scope, artifactId: existing.artifactId, content: args.content, + metadata: args.metadata, + parentVersionIds: args.parentVersionIds, }, new Date(), ); @@ -774,6 +878,8 @@ export async function findOrVersionArtifact( title: args.title, content: args.content, source: args.source, + metadata: args.metadata, + parentVersionIds: args.parentVersionIds, }); return { artifact: row, outcome: "created" }; }); diff --git a/src/migrations.test.ts b/src/migrations.test.ts index 076599d..ec50ce7 100644 --- a/src/migrations.test.ts +++ b/src/migrations.test.ts @@ -635,4 +635,93 @@ describe("migrations", () => { await db.execute(sql`DROP SCHEMA IF EXISTS ${sql.identifier(SCHEMA)} CASCADE`); await runArtifactMigrations(db); }); + + test("0004_version_metadata adds metadata and parent_version_ids columns", async () => { + assertDestructiveArtifactTestsAllowed(DATABASE_URL); + await db.execute(sql`DROP SCHEMA IF EXISTS ${sql.identifier(SCHEMA)} CASCADE`); + await runArtifactMigrations(db); + + const columns = await db.execute<{ + table_name: string; + column_name: string; + udt_name: string; + }>(sql` + SELECT table_name, column_name, udt_name + FROM information_schema.columns + WHERE table_schema = ${SCHEMA} + AND table_name IN ('artifact', 'artifact_version') + AND column_name IN ('metadata', 'parent_version_ids') + ORDER BY table_name, column_name + `); + expect([...columns]).toEqual([ + { table_name: "artifact", column_name: "metadata", udt_name: "jsonb" }, + { + table_name: "artifact_version", + column_name: "metadata", + udt_name: "jsonb", + }, + { + table_name: "artifact_version", + column_name: "parent_version_ids", + udt_name: "_text", + }, + ]); + + const ledger = await db.execute<{ id: string }>( + sql`SELECT "id" FROM ${sql.identifier(SCHEMA)}.${sql.identifier(LEDGER)} ORDER BY "id"`, + ); + expect(ledger.map((r) => r.id)).toContain("0004_version_metadata"); + }); + + test("adopting a 0003-only database applies 0004's new columns forward", async () => { + assertDestructiveArtifactTestsAllowed(DATABASE_URL); + await db.execute(sql`DROP SCHEMA IF EXISTS ${sql.identifier(SCHEMA)} CASCADE`); + + // Build a database that only ever saw migrations through 0003 — the shape + // an existing production database has before this change ships. + const upTo0003 = MIGRATIONS.filter((m) => m.id !== "0004_version_metadata"); + await db.transaction(async (tx) => { + await tx.execute(sql`CREATE SCHEMA IF NOT EXISTS ${sql.identifier(SCHEMA)}`); + await tx.execute(sql` + CREATE TABLE IF NOT EXISTS ${sql.identifier(SCHEMA)}.${sql.identifier(LEDGER)} ( + "id" text PRIMARY KEY, + "checksum" text NOT NULL, + "applied_at" timestamptz NOT NULL DEFAULT now() + ) + `); + for (const migration of upTo0003) { + for (const statement of migration.statements) { + await tx.execute(statement); + } + await tx.execute(sql` + INSERT INTO ${sql.identifier(SCHEMA)}.${sql.identifier(LEDGER)} + ("id", "checksum") + VALUES (${migration.id}, ${migrationChecksum(migration)}) + `); + } + }); + + // Running the full migration set adopts the new migration cleanly: no + // adopt flag needed, since the ledger already has real rows for 0001-0003 + // and only 0004 is missing — the ordinary "apply what's new" path. + await runArtifactMigrations(db); + + const ledger = await db.execute<{ id: string }>( + sql`SELECT "id" FROM ${sql.identifier(SCHEMA)}.${sql.identifier(LEDGER)} ORDER BY "id"`, + ); + expect(ledger.map((r) => r.id)).toEqual(MIGRATIONS.map((m) => m.id)); + + const columns = await db.execute<{ column_name: string }>(sql` + SELECT column_name FROM information_schema.columns + WHERE table_schema = ${SCHEMA} AND table_name = 'artifact_version' + AND column_name IN ('metadata', 'parent_version_ids') + `); + expect(columns.map((c) => c.column_name).sort()).toEqual([ + "metadata", + "parent_version_ids", + ]); + + await db.execute(sql`DROP SCHEMA IF EXISTS ${sql.identifier(SCHEMA)} CASCADE`); + await runArtifactMigrations(db); + }); }); diff --git a/src/migrations.ts b/src/migrations.ts index cec7630..f39eb10 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -194,6 +194,31 @@ export const MIGRATIONS: Migration[] = [ `, ], }, + { + // `metadata` is opaque-to-the-package jsonb: `artifact_version` carries it + // per version, and `artifact` mirrors the current version's value the same + // way it already mirrors `title`/`content`/`version`. `parent_version_ids` + // is explicit lineage set by the writer, never inferred from version + // order, so it lives only on `artifact_version` — there is no "current + // parents" concept to mirror onto `artifact`. Both columns are nullable + // and additive: `ADD COLUMN IF NOT EXISTS` is safe to re-run and an + // existing database adopts cleanly with no backfill. + id: "0004_version_metadata", + statements: [ + sql` + ALTER TABLE "artifacts"."artifact" + ADD COLUMN IF NOT EXISTS "metadata" jsonb + `, + sql` + ALTER TABLE "artifacts"."artifact_version" + ADD COLUMN IF NOT EXISTS "metadata" jsonb + `, + sql` + ALTER TABLE "artifacts"."artifact_version" + ADD COLUMN IF NOT EXISTS "parent_version_ids" text[] + `, + ], + }, ]; // Advisory locks are namespaced by this integer alone; deliberately arbitrary @@ -281,6 +306,7 @@ const EXPECTED_OWNED_SHAPE: Readonly> { name: "archived_at", udt: "timestamptz" }, { name: "created_at", udt: "timestamptz" }, { name: "updated_at", udt: "timestamptz" }, + { name: "metadata", udt: "jsonb" }, ], artifact_version: [ { name: "id", udt: "text" }, @@ -290,6 +316,8 @@ const EXPECTED_OWNED_SHAPE: Readonly> { name: "content", udt: "text" }, { name: "author_id", udt: "text" }, { name: "created_at", udt: "timestamptz" }, + { name: "metadata", udt: "jsonb" }, + { name: "parent_version_ids", udt: "_text" }, ], upload: [ { name: "id", udt: "text" }, diff --git a/src/schema.ts b/src/schema.ts index e0f842e..183270e 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -73,6 +73,13 @@ export const artifact = artifactsSchema.table( content: text("content").notNull(), source: jsonb("source"), version: integer("version").notNull().default(1), + /** + * Mirrors the current version's `artifact_version.metadata` — the same + * opaque-to-the-package jsonb, kept in lockstep the way `title`/`content`/ + * `version` already mirror the current version row. Never validated or + * interpreted here. + */ + metadata: jsonb("metadata"), /** Soft-archive: null = visible, a timestamp = hidden from discovery. */ archivedAt: timestamp("archived_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), @@ -108,6 +115,10 @@ export const artifactVersion = artifactsSchema.table( title: text("title").notNull(), content: text("content").notNull(), authorId: text("author_id").notNull(), + /** Opaque to the package: stored and returned as-is, never interpreted. */ + metadata: jsonb("metadata"), + /** Explicit lineage set by the writer — never inferred from version order. */ + parentVersionIds: text("parent_version_ids").array(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => [