diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md deleted file mode 100644 index 43c994c2..00000000 --- a/apps/web/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 164c296e..0d5f3dcf 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -18,7 +18,6 @@ import type * as billingModel from "../billingModel.js"; import type * as billingRetention from "../billingRetention.js"; import type * as billing_webhook_model from "../billing_webhook_model.js"; import type * as billing_webhooks from "../billing_webhooks.js"; -import type * as contentMigrations from "../contentMigrations.js"; import type * as crons from "../crons.js"; import type * as deploymentPreflight from "../deploymentPreflight.js"; import type * as draftRestore from "../draftRestore.js"; @@ -108,7 +107,6 @@ declare const fullApi: ApiFromModules<{ billingRetention: typeof billingRetention; billing_webhook_model: typeof billing_webhook_model; billing_webhooks: typeof billing_webhooks; - contentMigrations: typeof contentMigrations; crons: typeof crons; deploymentPreflight: typeof deploymentPreflight; draftRestore: typeof draftRestore; diff --git a/packages/backend/convex/contentMigrations.test.ts b/packages/backend/convex/contentMigrations.test.ts deleted file mode 100644 index 519ccdc9..00000000 --- a/packages/backend/convex/contentMigrations.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { expect, test } from "bun:test"; -import { convexTest } from "convex-test"; -import { makeFunctionReference } from "convex/server"; -import { getConvexSize } from "convex/values"; -import schema from "./schema"; -import { - hashOpenEditorContent, - parseOpenEditorDocument, -} from "./pageContentFormat"; - -const modules = { - "./_generated/api.ts": async () => ({}), - "./contentMigrations.ts": () => import("./contentMigrations"), -}; -const migration = makeFunctionReference<"mutation">( - "contentMigrations:quickLinksV2", -); - -test("content migration preserves revision identity, updates every hash, and is idempotent", async () => { - const t = convexTest(schema, modules); - const content = JSON.stringify({ - type: "doc", - version: 1, - content: [ - { - type: "customBlock", - attrs: { - "openeditor-id": "quick-links-1", - blockId: "baseblocks.quick-links", - version: 1, - data: { - links: [ - { - id: "link-1", - title: "Example", - url: "https://example.com", - linkType: "website", - }, - ], - }, - }, - }, - ], - }); - const contentHash = hashOpenEditorContent(content); - const ids = await t.run(async (ctx) => { - const siteId = await ctx.db.insert("sites", { - organizationId: "org", - name: "Site", - slug: "site", - createdBy: "user", - createdAt: 1, - updatedAt: 1, - visibility: "private", - settings: {}, - draftRevision: 1, - nextReleaseNumber: 2, - }); - const pageId = await ctx.db.insert("pages", { - siteId, - title: "Page", - slug: "page", - order: 0, - createdBy: "user", - createdAt: 1, - updatedAt: 1, - }); - const payloadId = await ctx.db.insert("contentPayloads", { - siteId, - content, - contentHash, - contentSize: getConvexSize(content), - createdAt: 1, - }); - const revisionId = await ctx.db.insert("contentRevisions", { - siteId, - payloadId, - contentHash, - contentSize: getConvexSize(content), - searchText: "Example", - libraryIds: [], - fileIds: [], - pageIds: [], - createdAt: 1, - }); - const draftId = await ctx.db.insert("pageDocuments", { - siteId, - pageId, - revisionId, - contentHash, - contentSize: getConvexSize(content), - updatedAt: 1, - }); - const releaseId = await ctx.db.insert("siteReleases", { - siteId, - number: 1, - name: "Release", - settings: {}, - sourceDraftRevision: 1, - createdBy: "user", - createdAt: 1, - pageCount: 1, - changeCount: 1, - }); - const releasePageId = await ctx.db.insert("releasePages", { - siteId, - releaseId, - pageId, - title: "Page", - slug: "page", - order: 0, - contentRevisionId: revisionId, - contentHash, - description: "Example", - updatedAt: 1, - }); - return { payloadId, revisionId, draftId, releasePageId }; - }); - const args = { - payloadId: ids.payloadId, - expectedHash: contentHash, - apply: false, - }; - expect((await t.mutation(migration, args)).changed).toBe(true); - expect((await t.run((ctx) => ctx.db.get(ids.payloadId)))?.content).toBe( - content, - ); - await expect( - t.mutation(migration, { ...args, expectedHash: "stale", apply: true }), - ).rejects.toThrow("changed after"); - expect((await t.mutation(migration, { ...args, apply: true })).changed).toBe( - true, - ); - await t.run(async (ctx) => { - const payload = (await ctx.db.get(ids.payloadId))!; - expect(payload.contentHash).toBe(hashOpenEditorContent(payload.content)); - expect(payload.contentSize).toBe(getConvexSize(payload.content)); - expect( - parseOpenEditorDocument(payload.content).content[0]?.attrs?.version, - ).toBe(2); - expect((await ctx.db.get(ids.revisionId))?.contentHash).toBe( - payload.contentHash, - ); - expect((await ctx.db.get(ids.draftId))?.revisionId).toBe(ids.revisionId); - expect((await ctx.db.get(ids.draftId))?.contentHash).toBe( - payload.contentHash, - ); - expect((await ctx.db.get(ids.releasePageId))?.contentRevisionId).toBe( - ids.revisionId, - ); - expect((await ctx.db.get(ids.releasePageId))?.contentHash).toBe( - payload.contentHash, - ); - }); - expect((await t.mutation(migration, { ...args, apply: true })).changed).toBe( - false, - ); -}); diff --git a/packages/backend/convex/contentMigrations.ts b/packages/backend/convex/contentMigrations.ts deleted file mode 100644 index 517bb50c..00000000 --- a/packages/backend/convex/contentMigrations.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { migrateQuickLinksDocuments } from "@baseblocks/custom-blocks/migrations"; -import { getConvexSize, v } from "convex/values"; -import { internalMutation } from "./_generated/server"; -import { - extractOpenEditorReferences, - extractOpenEditorText, - hashOpenEditorContent, - parseOpenEditorDocument, -} from "./pageContentFormat"; - -/** Explicit, idempotent storage migration; never called by document reads. */ -export const quickLinksV2 = internalMutation({ - args: { - payloadId: v.id("contentPayloads"), - expectedHash: v.string(), - apply: v.boolean(), - }, - handler: async (ctx, { payloadId, expectedHash, apply }) => { - const payload = await ctx.db.get(payloadId); - if (!payload) throw new Error("Content payload does not exist."); - const migrated = migrateQuickLinksDocuments(JSON.parse(payload.content)); - if (!migrated.changed) return { changed: false }; - if (payload.contentHash !== expectedHash) - throw new Error("Content changed after the migration audit."); - const document = parseOpenEditorDocument(migrated.document); - const content = JSON.stringify(document); - const contentHash = hashOpenEditorContent(content); - const contentSize = getConvexSize(content); - const revisions = await ctx.db - .query("contentRevisions") - .withIndex("by_payload", (q) => q.eq("payloadId", payloadId)) - .collect(); - if (!apply) - return { - changed: true, - revisions: revisions.length, - contentHash, - contentSize, - }; - const references = extractOpenEditorReferences(document); - const libraryIds = [...references.libraryIds] - .flatMap((id) => ctx.db.normalizeId("documentLibraries", id) ?? []) - .sort(); - const fileIds = [...references.fileIds] - .flatMap((id) => ctx.db.normalizeId("files", id) ?? []) - .sort(); - const pageIds = [...references.pageIds] - .flatMap((id) => ctx.db.normalizeId("pages", id) ?? []) - .sort(); - const searchText = extractOpenEditorText(document); - await ctx.db.patch(payloadId, { content, contentHash, contentSize }); - // Preserve revision identities, including released history. Update every denormalized hash. - for (const revision of revisions) { - await ctx.db.patch(revision._id, { - contentHash, - contentSize, - searchText, - libraryIds, - fileIds, - pageIds, - }); - const drafts = await ctx.db - .query("pageDocuments") - .withIndex("by_revision", (q) => q.eq("revisionId", revision._id)) - .collect(); - for (const draft of drafts) - await ctx.db.patch(draft._id, { contentHash, contentSize }); - const releases = await ctx.db - .query("releasePages") - .withIndex("by_content_revision", (q) => - q.eq("contentRevisionId", revision._id), - ) - .collect(); - for (const release of releases) - await ctx.db.patch(release._id, { contentHash }); - } - const site = await ctx.db.get(payload.siteId); - // The operator runs the existing storage reconciliation once per returned organization. - return { - changed: true, - revisions: revisions.length, - organizationId: site?.organizationId, - }; - }, -}); diff --git a/packages/backend/convex/model/aiWorkspaceFingerprint.test.ts b/packages/backend/convex/model/aiWorkspaceFingerprint.test.ts deleted file mode 100644 index 443dc18c..00000000 --- a/packages/backend/convex/model/aiWorkspaceFingerprint.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - fingerprintSnapshot, - fingerprintSnapshotManifest, - fingerprintSnapshotPage, - type Snapshot, -} from "@baseblocks/openeditor-contracts/snapshots"; -import { assertAiWorkspaceFingerprints } from "./aiWorkspaceFingerprint"; - -const document = { type: "doc" as const, version: 1 as const, content: [] }; -const current: Snapshot = { - id: "site-1", - revision: "4", - title: "Site", - metadata: { defaultPageId: "home" }, - pages: [ - { - id: "home", - title: "Home", - slug: "home", - parentId: null, - order: 0, - metadata: { icon: null }, - document, - }, - ], -}; - -describe("OpenEditor atomic fingerprint checks", () => { - test("accepts the complete unchanged-to-updated trust chain", async () => { - const next = structuredClone(current); - next.pages[0]!.title = "Welcome"; - await expect( - assertAiWorkspaceFingerprints({ - currentProject: current, - nextProject: next, - expectedProjectFingerprint: await fingerprintSnapshot(current), - expectedSiteFingerprint: await fingerprintSnapshotManifest(current), - nextSiteFingerprint: await fingerprintSnapshotManifest(next), - pageFingerprints: [ - { - pageId: "home", - expectedFingerprint: await fingerprintSnapshotPage( - current.pages[0]!, - ), - nextFingerprint: await fingerprintSnapshotPage(next.pages[0]!), - }, - ], - }), - ).resolves.toMatchObject({ - expectedProjectFingerprint: await fingerprintSnapshot(current), - resultProjectFingerprint: await fingerprintSnapshot(next), - }); - }); - - test("rejects stale current and forged next fingerprints", async () => { - await expect( - assertAiWorkspaceFingerprints({ - currentProject: current, - nextProject: current, - expectedProjectFingerprint: "stale", - expectedSiteFingerprint: await fingerprintSnapshotManifest(current), - nextSiteFingerprint: await fingerprintSnapshotManifest(current), - pageFingerprints: [], - }), - ).rejects.toThrow("project fingerprint no longer matches"); - }); -}); diff --git a/packages/backend/convex/model/aiWorkspaceFingerprint.ts b/packages/backend/convex/model/aiWorkspaceFingerprint.ts index d377ef4a..c85e1fb2 100644 --- a/packages/backend/convex/model/aiWorkspaceFingerprint.ts +++ b/packages/backend/convex/model/aiWorkspaceFingerprint.ts @@ -1,16 +1,8 @@ import { fingerprintSnapshot, fingerprintSnapshotManifest, - fingerprintSnapshotPage, type Snapshot, } from "@baseblocks/openeditor-contracts/snapshots"; -import { AiWorkspaceValidationError } from "./aiWorkspaceBounds"; - -export type AiPageFingerprintPrecondition = { - pageId: string; - expectedFingerprint: string | null; - nextFingerprint?: string; -}; export async function fingerprintAiProjectTrustRoot(project: Snapshot) { return { @@ -18,74 +10,3 @@ export async function fingerprintAiProjectTrustRoot(project: Snapshot) { siteFingerprint: await fingerprintSnapshotManifest(project), }; } - -function fail(message: string): never { - throw new AiWorkspaceValidationError(message); -} - -/** Verify the complete OpenEditor trust root inside the write transaction. */ -export async function assertAiWorkspaceFingerprints(input: { - currentProject: Snapshot; - nextProject: Snapshot; - expectedProjectFingerprint: string; - expectedSiteFingerprint: string; - nextSiteFingerprint: string; - pageFingerprints: AiPageFingerprintPrecondition[]; -}): Promise<{ - expectedProjectFingerprint: string; - resultProjectFingerprint: string; - expectedSiteFingerprint: string; - resultSiteFingerprint: string; -}> { - const currentTrust = await fingerprintAiProjectTrustRoot( - input.currentProject, - ); - const currentProjectFingerprint = currentTrust.projectFingerprint; - if (currentProjectFingerprint !== input.expectedProjectFingerprint) { - fail("The OpenEditor project fingerprint no longer matches"); - } - if (currentTrust.siteFingerprint !== input.expectedSiteFingerprint) { - fail("The OpenEditor site fingerprint no longer matches"); - } - const resultTrust = await fingerprintAiProjectTrustRoot(input.nextProject); - if (resultTrust.siteFingerprint !== input.nextSiteFingerprint) { - fail("The OpenEditor next-site fingerprint is invalid"); - } - - const currentPages = new Map( - input.currentProject.pages.map((page) => [page.id, page]), - ); - const nextPages = new Map( - input.nextProject.pages.map((page) => [page.id, page]), - ); - for (const precondition of input.pageFingerprints) { - const current = currentPages.get(precondition.pageId); - if (precondition.expectedFingerprint === null) { - if (current) fail(`Created page ${precondition.pageId} already exists`); - } else if ( - !current || - (await fingerprintSnapshotPage(current)) !== - precondition.expectedFingerprint - ) { - fail(`Page ${precondition.pageId} fingerprint no longer matches`); - } - - if (precondition.nextFingerprint !== undefined) { - const next = nextPages.get(precondition.pageId); - if ( - !next || - (await fingerprintSnapshotPage(next)) !== precondition.nextFingerprint - ) { - fail(`Page ${precondition.pageId} next fingerprint is invalid`); - } - } else if (nextPages.has(precondition.pageId)) { - fail(`Deleted page ${precondition.pageId} remains in the next project`); - } - } - return { - expectedProjectFingerprint: currentProjectFingerprint, - resultProjectFingerprint: resultTrust.projectFingerprint, - expectedSiteFingerprint: input.expectedSiteFingerprint, - resultSiteFingerprint: input.nextSiteFingerprint, - }; -} diff --git a/packages/custom-blocks/package.json b/packages/custom-blocks/package.json index ab96df86..14c552f9 100644 --- a/packages/custom-blocks/package.json +++ b/packages/custom-blocks/package.json @@ -8,8 +8,7 @@ "exports": { ".": "./src/index.ts", "./editor": "./src/editor.tsx", - "./viewer": "./src/viewer.tsx", - "./migrations": "./src/migrations.ts" + "./viewer": "./src/viewer.tsx" }, "scripts": { "check-types": "tsc --noEmit", diff --git a/packages/custom-blocks/src/migrations.ts b/packages/custom-blocks/src/migrations.ts deleted file mode 100644 index cd6b7c6a..00000000 --- a/packages/custom-blocks/src/migrations.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { parseQuickLinksData } from "./quick-links"; - -/** Explicit application migration. Never runs implicitly while rendering or editing. */ -export const migrateQuickLinksV1 = ({ - version, - data, -}: { - version: number; - data: unknown; -}) => { - const rawLinks = - data && typeof data === "object" - ? (data as { links?: unknown }).links - : undefined; - if (version !== 1 || !Array.isArray(rawLinks)) - throw new Error("Unsupported Quick Links version."); - const links = rawLinks.flatMap((item: unknown) => { - if ( - !item || - typeof item !== "object" || - Array.isArray(item) || - (item as Record).linkType !== "website" - ) - return []; - const link = item as Record; - const artwork = - link.artwork && - typeof link.artwork === "object" && - !Array.isArray(link.artwork) - ? (link.artwork as Record) - : null; - return [ - { - id: link.id, - title: link.title, - url: link.url, - ...(artwork?.kind === "asset" && typeof artwork.assetId === "string" - ? { imageAssetId: artwork.assetId } - : {}), - }, - ]; - }); - return { - blockId: "baseblocks.quick-links", - version: 2, - data: parseQuickLinksData({ links }), - }; -}; - -/** Converts persisted Quick Links envelopes, including those in nested documents. */ -export function migrateQuickLinksDocuments(source: T): { - document: T; - changed: boolean; -} { - const document = structuredClone(source); - let changed = false; - const visit = (value: unknown): void => { - if (!value || typeof value !== "object") return; - if (Array.isArray(value)) { - value.forEach(visit); - return; - } - const node = value as Record; - const attrs = node.attrs as Record | undefined; - if ( - node.type === "customBlock" && - attrs?.blockId === "baseblocks.quick-links" && - attrs.version === 1 - ) { - node.attrs = { - ...attrs, - ...migrateQuickLinksV1({ version: 1, data: attrs.data }), - }; - changed = true; - } - Object.values(node).forEach(visit); - }; - visit(document); - return { document, changed }; -} diff --git a/packages/custom-blocks/src/quick-links.test.ts b/packages/custom-blocks/src/quick-links.test.ts index d62f56ad..c133508d 100644 --- a/packages/custom-blocks/src/quick-links.test.ts +++ b/packages/custom-blocks/src/quick-links.test.ts @@ -6,55 +6,8 @@ import { updateQuickLink, safeQuickLinkHref, } from "./quick-links"; -import { migrateQuickLinksV1 } from "./migrations"; describe("quick links data", () => { - test("migrates website links and removes unsupported app links and icons", () => { - expect( - migrateQuickLinksV1({ - version: 1, - data: { - links: [ - { - id: "website", - title: "Website", - url: "https://example.com", - linkType: "website", - artwork: { kind: "asset", assetId: "website-image" }, - }, - { - id: "icon", - title: "Icon", - url: "/icon", - linkType: "website", - artwork: { kind: "icon", id: "book" }, - }, - { - id: "app", - title: "App", - url: "baseblocks://open", - linkType: "app", - }, - ], - }, - }), - ).toEqual({ - blockId: "baseblocks.quick-links", - version: 2, - data: { - links: [ - { - id: "website", - title: "Website", - url: "https://example.com", - imageAssetId: "website-image", - }, - { id: "icon", title: "Icon", url: "/icon" }, - ], - }, - }); - }); - test("accepts site-relative and HTTP links but rejects executable URLs", () => { expect( safeQuickLinkHref({ diff --git a/packages/openeditor-contracts/src/snapshots/fingerprints.test.ts b/packages/openeditor-contracts/src/snapshots/fingerprints.test.ts new file mode 100644 index 00000000..8315c2b4 --- /dev/null +++ b/packages/openeditor-contracts/src/snapshots/fingerprints.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createSnapshotManifest, + fingerprintSnapshot, + fingerprintSnapshotManifest, + fingerprintSnapshotPage, + pagePath, +} from "./index"; + +const makePage = (title = "Home") => ({ + id: "home", + title, + route: "/", + order: 0, + document: { + type: "doc" as const, + version: 1 as const, + meta: { id: "home", title }, + content: [ + { + type: "heading", + attrs: { level: 1, "openeditor-id": "oe_heading_home" }, + content: [{ type: "text", text: title }], + }, + { + type: "paragraph", + attrs: { "openeditor-id": "oe_paragraph_home" }, + content: [{ type: "text", text: "Welcome" }], + }, + ], + }, +}); + +const makeSnapshot = () => ({ + id: "site_alpha", + revision: "revision_7", + title: "Documentation", + metadata: { locale: "en" }, + pages: [makePage()], +}); + +test("fingerprints preserve the established manifest and page wire values", async () => { + const snapshot = makeSnapshot(); + + assert.equal(pagePath("home"), "pages/home.json"); + assert.deepEqual(createSnapshotManifest(snapshot), { + format: "openeditor.site", + version: 1, + project: { + id: "site_alpha", + revision: "revision_7", + title: "Documentation", + metadata: { locale: "en" }, + }, + pages: [ + { + id: "home", + file: "pages/home.json", + title: "Home", + route: "/", + order: 0, + }, + ], + }); + assert.equal( + await fingerprintSnapshotPage(snapshot.pages[0]), + "oep1-oe1-fnv1a64-497bb41406b875c1-7f26c0d6cd9efd2b28be9ddb31b7903a3c6e8f853544586a2556f17822bafe3a", + ); + assert.equal( + await fingerprintSnapshot(snapshot), + "775a5051f1db9114c70a494e7d0a585f548ad830d04b933a6e31a7f2fcfa15eb", + ); + assert.equal( + await fingerprintSnapshotManifest(snapshot), + "59e0d22546cbb233fab3b536449f13176a87cb9f3758ee51a62baa6ca8043b7b", + ); +}); diff --git a/packages/openeditor-contracts/src/snapshots/fingerprints.ts b/packages/openeditor-contracts/src/snapshots/fingerprints.ts index 06af90bd..22647730 100644 --- a/packages/openeditor-contracts/src/snapshots/fingerprints.ts +++ b/packages/openeditor-contracts/src/snapshots/fingerprints.ts @@ -5,7 +5,7 @@ import { } from "@openeditor/document"; import { OPENEDITOR_MANIFEST_FORMAT, - OPENEDITOR_MUTATION_VERSION, + OPENEDITOR_MANIFEST_VERSION, type Snapshot, type SnapshotPage, } from "./types"; @@ -47,7 +47,7 @@ const manifestPageValue = (page: SnapshotPage): JsonValue => ({ */ export const createSnapshotManifest = (snapshot: Snapshot): JsonValue => ({ format: OPENEDITOR_MANIFEST_FORMAT, - version: OPENEDITOR_MUTATION_VERSION, + version: OPENEDITOR_MANIFEST_VERSION, project: { id: snapshot.id, revision: snapshot.revision, diff --git a/packages/openeditor-contracts/src/snapshots/index.ts b/packages/openeditor-contracts/src/snapshots/index.ts index ede0e3ee..4757c3c0 100644 --- a/packages/openeditor-contracts/src/snapshots/index.ts +++ b/packages/openeditor-contracts/src/snapshots/index.ts @@ -1,3 +1,2 @@ export * from "./fingerprints"; -export * from "./mutation"; export * from "./types"; diff --git a/packages/openeditor-contracts/src/snapshots/mutation.test.ts b/packages/openeditor-contracts/src/snapshots/mutation.test.ts deleted file mode 100644 index 735d51ba..00000000 --- a/packages/openeditor-contracts/src/snapshots/mutation.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - OPENEDITOR_MUTATION_FORMAT, - OPENEDITOR_MUTATION_VERSION, - applyMutation, - checkMutationPreconditions, - createSnapshotManifest, - fingerprintSnapshot, - fingerprintSnapshotManifest, - fingerprintSnapshotPage, - pagePath, - runTransactionalMutationStoreConformance, -} from "./index"; - -const makePage = (title = "Home") => ({ - id: "home", - title, - route: "/", - order: 0, - document: { - type: "doc", - version: 1, - meta: { id: "home", title }, - content: [ - { - type: "heading", - attrs: { level: 1, "openeditor-id": "oe_heading_home" }, - content: [{ type: "text", text: title }], - }, - { - type: "paragraph", - attrs: { "openeditor-id": "oe_paragraph_home" }, - content: [{ type: "text", text: "Welcome" }], - }, - ], - }, -}); - -const makeSnapshot = () => ({ - id: "site_alpha", - revision: "revision_7", - title: "Documentation", - metadata: { locale: "en" }, - pages: [makePage()], -}); - -const makeMutation = async (current, next) => ({ - format: OPENEDITOR_MUTATION_FORMAT, - version: OPENEDITOR_MUTATION_VERSION, - snapshotId: current.id, - expectedRevision: current.revision, - expectedSnapshotFingerprint: await fingerprintSnapshot(current), - expectedManifestFingerprint: await fingerprintSnapshotManifest(current), - nextManifestFingerprint: await fingerprintSnapshotManifest(next), - nextSnapshot: next, - pageChanges: [ - { - kind: "update", - pageId: "home", - expectedFingerprint: await fingerprintSnapshotPage(current.pages[0]), - nextFingerprint: await fingerprintSnapshotPage(next.pages[0]), - next: next.pages[0], - }, - ], -}); - -test("fingerprints preserve the established manifest and page wire values", async () => { - const snapshot = makeSnapshot(); - - assert.equal(pagePath("home"), "pages/home.json"); - assert.deepEqual(createSnapshotManifest(snapshot), { - format: "openeditor.site", - version: 1, - project: { - id: "site_alpha", - revision: "revision_7", - title: "Documentation", - metadata: { locale: "en" }, - }, - pages: [ - { - id: "home", - file: "pages/home.json", - title: "Home", - route: "/", - order: 0, - }, - ], - }); - assert.equal( - await fingerprintSnapshotPage(snapshot.pages[0]), - "oep1-oe1-fnv1a64-497bb41406b875c1-7f26c0d6cd9efd2b28be9ddb31b7903a3c6e8f853544586a2556f17822bafe3a", - ); - assert.equal( - await fingerprintSnapshot(snapshot), - "775a5051f1db9114c70a494e7d0a585f548ad830d04b933a6e31a7f2fcfa15eb", - ); - assert.equal( - await fingerprintSnapshotManifest(snapshot), - "59e0d22546cbb233fab3b536449f13176a87cb9f3758ee51a62baa6ca8043b7b", - ); -}); - -test("preconditions accept one complete mutation and reject its replay", async () => { - const current = makeSnapshot(); - const next = structuredClone(current); - next.pages[0] = makePage("Welcome"); - const mutation = await makeMutation(current, next); - - const accepted = await checkMutationPreconditions(current, mutation); - assert.equal(accepted.applied, true); - assert.deepEqual(accepted.snapshot, next); - assert.notEqual(accepted.snapshot, next); - - const replay = await checkMutationPreconditions(next, mutation); - assert.deepEqual(replay, { - applied: false, - reason: "snapshot_fingerprint_mismatch", - }); -}); - -test("atomic apply delegates to the store and the conformance test detects replay", async () => { - let current = makeSnapshot(); - const next = structuredClone(current); - next.pages[0] = makePage("Welcome"); - const mutation = await makeMutation(current, next); - let calls = 0; - const store = { - async applyMutationAtomically(value) { - calls += 1; - const result = await checkMutationPreconditions(current, value); - if (result.applied) current = result.snapshot; - return result; - }, - }; - - assert.equal((await applyMutation(store, mutation)).applied, true); - assert.equal(calls, 1); - - current = makeSnapshot(); - calls = 0; - const result = await runTransactionalMutationStoreConformance( - store, - mutation, - ); - assert.equal(result.conformant, true); - assert.equal(result.first.applied, true); - assert.deepEqual(result.replay, { - applied: false, - reason: "snapshot_fingerprint_mismatch", - }); - assert.equal(calls, 2); -}); diff --git a/packages/openeditor-contracts/src/snapshots/mutation.ts b/packages/openeditor-contracts/src/snapshots/mutation.ts deleted file mode 100644 index b17308a4..00000000 --- a/packages/openeditor-contracts/src/snapshots/mutation.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - fingerprintSnapshot, - fingerprintSnapshotManifest, - fingerprintSnapshotPage, -} from "./fingerprints"; -import type { - Mutation, - MutationApplyResult, - Snapshot, - TransactionalMutationConformanceResult, - TransactionalMutationStore, -} from "./types"; - -/** Delegates only to a provider that explicitly guarantees atomic CAS. */ -export const applyMutation = ( - store: TransactionalMutationStore, - mutation: Mutation, -): Promise => store.applyMutationAtomically(mutation); - -/** - * Pure precondition helper for use inside a provider transaction. Calling it - * before a later write does not make that write atomic. - */ -export const checkMutationPreconditions = async ( - current: Snapshot, - mutation: Mutation, -): Promise => { - if (current.id !== mutation.snapshotId) { - return { applied: false, reason: "snapshot_id_mismatch" }; - } - if (current.revision !== mutation.expectedRevision) { - return { applied: false, reason: "snapshot_revision_mismatch" }; - } - if ( - (await fingerprintSnapshot(current)) !== - mutation.expectedSnapshotFingerprint - ) { - return { applied: false, reason: "snapshot_fingerprint_mismatch" }; - } - if ( - (await fingerprintSnapshotManifest(current)) !== - mutation.expectedManifestFingerprint - ) { - return { applied: false, reason: "manifest_fingerprint_mismatch" }; - } - - const pages = new Map(current.pages.map((page) => [page.id, page])); - for (const change of mutation.pageChanges) { - const page = pages.get(change.pageId); - if (change.kind === "create") { - if (page) { - return { - applied: false, - reason: "page_create_collision", - pageId: change.pageId, - }; - } - continue; - } - if ( - !page || - (await fingerprintSnapshotPage(page)) !== change.expectedFingerprint - ) { - return { - applied: false, - reason: "page_fingerprint_mismatch", - pageId: change.pageId, - }; - } - } - - return { applied: true, snapshot: structuredClone(mutation.nextSnapshot) }; -}; - -/** - * Minimal destructive conformance exercise for a disposable provider fixture: - * the first CAS must commit and an identical replay must be rejected. - */ -export const runTransactionalMutationStoreConformance = async ( - store: TransactionalMutationStore, - mutation: Mutation, -): Promise => { - const first = await store.applyMutationAtomically(mutation); - const replay = await store.applyMutationAtomically(mutation); - const failures = [ - ...(!first.applied ? ["A valid first mutation was rejected."] : []), - ...(replay.applied - ? ["An already-applied mutation was accepted again."] - : []), - ]; - return { conformant: failures.length === 0, first, replay, failures }; -}; diff --git a/packages/openeditor-contracts/src/snapshots/types.ts b/packages/openeditor-contracts/src/snapshots/types.ts index 57b4f8f1..42f90d77 100644 --- a/packages/openeditor-contracts/src/snapshots/types.ts +++ b/packages/openeditor-contracts/src/snapshots/types.ts @@ -1,13 +1,8 @@ import type { JsonObject, OpenEditorDocument } from "@openeditor/document"; -export const OPENEDITOR_MUTATION_VERSION = 1 as const; - -/** Retained for serialized mutation compatibility. */ -export const OPENEDITOR_MUTATION_FORMAT = - "openeditor.project-changeset" as const; - -/** Retained for existing snapshot-manifest fingerprints. */ +/** Wire format used by snapshot-manifest fingerprints. */ export const OPENEDITOR_MANIFEST_FORMAT = "openeditor.site" as const; +export const OPENEDITOR_MANIFEST_VERSION = 1 as const; export type SnapshotPage = { id: string; @@ -27,66 +22,3 @@ export type Snapshot = { pages: readonly SnapshotPage[]; metadata?: JsonObject; }; - -export type CreatePageChange = { - kind: "create"; - pageId: string; - expectedFingerprint: null; - nextFingerprint: string; - next: SnapshotPage; -}; - -export type UpdatePageChange = { - kind: "update"; - pageId: string; - expectedFingerprint: string; - nextFingerprint: string; - next: SnapshotPage; -}; - -export type DeletePageChange = { - kind: "delete"; - pageId: string; - expectedFingerprint: string; -}; - -export type PageChange = CreatePageChange | UpdatePageChange | DeletePageChange; - -export type Mutation = { - format: typeof OPENEDITOR_MUTATION_FORMAT; - version: typeof OPENEDITOR_MUTATION_VERSION; - snapshotId: string; - expectedRevision: string; - expectedSnapshotFingerprint: string; - expectedManifestFingerprint: string; - nextManifestFingerprint: string; - nextSnapshot: Snapshot; - pageChanges: readonly PageChange[]; -}; - -export type MutationConflictReason = - | "snapshot_id_mismatch" - | "snapshot_revision_mismatch" - | "snapshot_fingerprint_mismatch" - | "manifest_fingerprint_mismatch" - | "page_fingerprint_mismatch" - | "page_create_collision"; - -export type MutationApplyResult = - | { applied: true; snapshot: Snapshot } - | { applied: false; reason: MutationConflictReason; pageId?: string }; - -/** - * The comparison and commit must occur in one database or storage - * transaction. OpenEditor provides no non-atomic fallback. - */ -export interface TransactionalMutationStore { - applyMutationAtomically(mutation: Mutation): Promise; -} - -export type TransactionalMutationConformanceResult = { - conformant: boolean; - first: MutationApplyResult; - replay: MutationApplyResult; - failures: readonly string[]; -};