From e66906f752ef437ee9fd2239bdd023c3dd4e2a9b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 18 Sep 2026 00:33:44 -0700 Subject: [PATCH] feat(mount): accept metadata on artifact create and revise CreateArtifactRequest and ReviseArtifactRequest omitted metadata, so HTTP callers could not set the opaque JSONB field the service layer already stores per version. Export MetadataShape from artifacts.ts, thread metadata through mount.ts to createArtifact/writeArtifactVersion, and let revise accept metadata alone (no title/content required). --- src/artifacts.test.ts | 2 +- src/artifacts.ts | 11 ++++-- src/mount.test.ts | 88 +++++++++++++++++++++++++++++++++++++++++++ src/mount.ts | 17 +++++++-- 4 files changed, 111 insertions(+), 7 deletions(-) diff --git a/src/artifacts.test.ts b/src/artifacts.test.ts index f3bbf75..7a1f517 100644 --- a/src/artifacts.test.ts +++ b/src/artifacts.test.ts @@ -219,7 +219,7 @@ describe("versioning", () => { const row = await seedArtifact(db); await expect( writeArtifactVersion(db, { scope: SCOPE, artifactId: row.id }), - ).rejects.toThrow(/content and\/or title/); + ).rejects.toThrow(/content, title, and\/or metadata/); }); }); diff --git a/src/artifacts.ts b/src/artifacts.ts index 8b428a9..8f6ef0e 100644 --- a/src/artifacts.ts +++ b/src/artifacts.ts @@ -90,7 +90,8 @@ const JsonObject = type("object").narrow( // `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; +// Exported so the HTTP mount validates request bodies the same opaque way. +export const MetadataShape = JsonObject; const ParentVersionIdsShape = type("string[]"); export class ArtifactValidationError extends Error { @@ -401,8 +402,12 @@ export async function writeArtifactVersion( title: string; metadata: Record | null; }> { - if (args.title === undefined && args.content === undefined) { - throw new Error("Provide content and/or title to revise the artifact"); + if ( + args.title === undefined && + args.content === undefined && + args.metadata === undefined + ) { + throw new Error("Provide content, title, and/or metadata to revise the artifact"); } if (args.title !== undefined) { assertArtifactFieldSizes({ title: args.title }); diff --git a/src/mount.test.ts b/src/mount.test.ts index b255c10..8c1a30c 100644 --- a/src/mount.test.ts +++ b/src/mount.test.ts @@ -184,6 +184,38 @@ describe("POST /artifacts", () => { expect(malformed.status).toBe(400); }); + test("stores an opaque metadata object and returns it on the created artifact", async () => { + const db = await testDb(); + const res = await host(db).request( + "/artifacts", + json({ mode: "text", title: "Notes", content: "body", metadata: { kind: "run", stage: "draft" } }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { artifact: Record }; + expect(body.artifact.metadata).toEqual({ kind: "run", stage: "draft" }); + }); + + test("create with no metadata field stores null", async () => { + const db = await testDb(); + const res = await host(db).request( + "/artifacts", + json({ mode: "text", title: "Notes", content: "body" }), + ); + const body = (await res.json()) as { artifact: Record }; + expect(body.artifact.metadata).toBeNull(); + }); + + test("rejects a metadata value that is not a JSON object", async () => { + const db = await testDb(); + for (const metadata of ["a string", 42, ["array"]]) { + const res = await host(db).request( + "/artifacts", + json({ mode: "text", title: "t", content: "body", metadata }), + ); + expect(res.status).toBe(400); + } + }); + test("refuses a kind outside the importable allowlist", async () => { const db = await testDb(); const res = await host(db).request( @@ -550,6 +582,62 @@ describe("versions", () => { expect(page2.nextCursor).toBeNull(); }); + test("revise sets metadata and returns it in the response", async () => { + const db = await testDb(); + const app = host(db); + const row = await seedArtifact(db, { title: "Draft", content: "v1" }); + + const res = await app.request( + `/artifacts/${row.id}/versions`, + json({ content: "v2", metadata: { kind: "run", supersededByNodeId: null } }), + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + version: 2, + metadata: { kind: "run", supersededByNodeId: null }, + }); + }); + + test("revise omitting metadata carries the prior version's metadata forward", async () => { + const db = await testDb(); + const app = host(db); + const row = await seedArtifact(db, { title: "Draft", content: "v1" }); + await app.request(`/artifacts/${row.id}/versions`, json({ metadata: { kind: "run" } })); + + const res = await app.request(`/artifacts/${row.id}/versions`, json({ content: "v3" })); + expect(await res.json()).toMatchObject({ version: 3, metadata: { kind: "run" } }); + }); + + test("revise with an explicit null metadata clears it", async () => { + const db = await testDb(); + const app = host(db); + const row = await seedArtifact(db, { title: "Draft", content: "v1" }); + await app.request(`/artifacts/${row.id}/versions`, json({ metadata: { kind: "run" } })); + + const res = await app.request(`/artifacts/${row.id}/versions`, json({ metadata: null })); + expect(await res.json()).toMatchObject({ version: 3, metadata: null }); + }); + + test("revise with metadata alone (no title/content) is accepted", async () => { + const db = await testDb(); + const app = host(db); + const row = await seedArtifact(db, { title: "Draft", content: "v1" }); + + const res = await app.request(`/artifacts/${row.id}/versions`, json({ metadata: { stage: "final" } })); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ version: 2, metadata: { stage: "final" } }); + }); + + test("revise rejects a metadata value that is not a JSON object", async () => { + const db = await testDb(); + const row = await seedArtifact(db); + const res = await host(db).request( + `/artifacts/${row.id}/versions`, + json({ metadata: "not an object" }), + ); + expect(res.status).toBe(400); + }); + test("oversize create title is 400", async () => { const db = await testDb(); const res = await host(db).request( diff --git a/src/mount.ts b/src/mount.ts index a7c8199..531a9c5 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -16,6 +16,7 @@ import { listArtifactVersions, ListArtifactVersionsQuery, MAX_ARTIFACT_CONTENT_BYTES, + MetadataShape, serializeArtifact, serializeArtifactListItem, setArtifactArchived, @@ -121,6 +122,10 @@ const HttpUrl = TrimmedNonEmpty.narrow((content, ctx) => { return true; }); +// Opaque to this package, same shape the service layer already stores per +// version: any JSON object, or `null` to clear it explicitly. +const NullableMetadata = MetadataShape.or("null"); + /** * `kind` and `mode` must agree: only a URL may mint a `link` (whose content * downstream UIs treat as navigable), and a URL never mints a `document` whose @@ -132,11 +137,13 @@ const CreateArtifactRequest = type({ title: TrimmedNonEmpty, content: HttpUrl, "kind?": "'link'", + "metadata?": NullableMetadata, }).or({ mode: "'text'", title: TrimmedNonEmpty, content: TrimmedNonEmpty, "kind?": "'document'", + "metadata?": NullableMetadata, }); // A non-string (or missing/blank) field carries no provenance rather than @@ -151,11 +158,13 @@ const GeneratedByField = type("unknown") const ReviseArtifactRequest = type({ "title?": TrimmedNonEmpty, "content?": TrimmedNonEmpty, + "metadata?": NullableMetadata, }).narrow( (body, ctx) => body.title !== undefined || body.content !== undefined || - ctx.mustBe("a body with content and/or title"), + body.metadata !== undefined || + ctx.mustBe("a body with content, title, and/or metadata"), ); const idParam = { @@ -388,7 +397,7 @@ export function mountArtifacts( tags: ["Artifacts"], summary: "Import an artifact from an external source (link a URL or paste text)", description: - "`mode: url` links an external page (content is the URL, origin `imported`); `mode: text` stores a pasted body (origin `manual`). The artifact and its version 1 are written in one transaction.", + "`mode: url` links an external page (content is the URL, origin `imported`); `mode: text` stores a pasted body (origin `manual`). The artifact and its version 1 are written in one transaction. An optional `metadata` object is stored opaquely and returned as-is on every read.", responses: { 201: { description: "Artifact created" }, 400: { description: "Invalid request body" }, @@ -427,6 +436,7 @@ export function mountArtifacts( source: isUrl ? { origin: "imported", url: body.content } : { origin: "manual" }, + ...(body.metadata !== undefined ? { metadata: body.metadata } : {}), }); await onArtifactCreated(tx, created, scope); return created; @@ -664,7 +674,7 @@ export function mountArtifacts( tags: ["Artifacts"], summary: "Revise an artifact, creating a new version", description: - "Locks the row FOR UPDATE and bumps version by one; a unique (artifactId, version) index backstops a racing writer. Archived and skill-draft artifacts present as not found.", + "Locks the row FOR UPDATE and bumps version by one; a unique (artifactId, version) index backstops a racing writer. Archived and skill-draft artifacts present as not found. `metadata` is optional and opaque; when omitted, the prior version's metadata carries forward, and an explicit `null` clears it.", parameters: [idParam], responses: { 200: { description: "New version created" }, @@ -701,6 +711,7 @@ export function mountArtifacts( artifactId: loaded.row.id, ...(body.title !== undefined ? { title: body.title } : {}), ...(body.content !== undefined ? { content: body.content } : {}), + ...(body.metadata !== undefined ? { metadata: body.metadata } : {}), }), ); } catch (error) {