Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 152 additions & 1 deletion src/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm";
import {
ArtifactNotFoundError,
ArtifactSizeError,
ArtifactValidationError,
createArtifact,
findArtifactByTitle,
findOrVersionArtifact,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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<string, unknown>,
}),
),
).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);
});
});

Loading
Loading