From b8ab85b8ad81591f346ed269f7e1ce2d3f7291f2 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 14:58:16 -0300 Subject: [PATCH] feat(hosted): release objects and publishDraft Adds release objects and `publishDraft`: commits the draft to main, writes the release to the CDN bucket, updates latest.json and purges it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- apps/api/src/hosted/publish.test.ts | 336 ++++++++++++++++++ apps/api/src/hosted/publish.ts | 373 ++++++++++++++++++++ apps/api/src/hosted/release-objects.test.ts | 69 ++++ apps/api/src/hosted/release-objects.ts | 143 ++++++++ 4 files changed, 921 insertions(+) create mode 100644 apps/api/src/hosted/publish.test.ts create mode 100644 apps/api/src/hosted/publish.ts create mode 100644 apps/api/src/hosted/release-objects.test.ts create mode 100644 apps/api/src/hosted/release-objects.ts diff --git a/apps/api/src/hosted/publish.test.ts b/apps/api/src/hosted/publish.test.ts new file mode 100644 index 0000000000..42f938c461 --- /dev/null +++ b/apps/api/src/hosted/publish.test.ts @@ -0,0 +1,336 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { serializeBlock } from "@decocms/blocks/protocol"; +import { createDeliveryPurge } from "./delivery-purge"; +import { deliveryKeys } from "./delivery-store"; +import { createDraftStore, type HostedDraftRef } from "./draft-store"; +import { + fakeRepo, + memoryDeliveryStore, + memoryKv, + SCHEMA_HASH, + SCHEMA_TEXT, +} from "./hosted-test-helpers"; +import { + type HostedRepo, + MainMovedError, + publishDraft, + readLatest, +} from "./publish"; + +beforeAll(() => { + process.env.FAST_PREVIEW_CACHE_DIR = ""; +}); + +const REF: HostedDraftRef = { + organizationId: "org", + virtualMcpId: "vmcp", + branch: "thread-1", + site: "acme", +}; + +function setup() { + const git = fakeRepo({ + ".deco/schema.gen.json": SCHEMA_TEXT, + ".deco/blocks/Home.json": serializeBlock({ path: "/", title: "v1" }), + ".deco/blocks/Old.json": serializeBlock({ x: 1 }), + }); + const delivery = memoryDeliveryStore(); + const drafts = createDraftStore({ kv: memoryKv(), store: delivery.store }); + const repo: HostedRepo = { + client: git.client, + packagePath: null, + mainBranch: "main", + store: delivery.store, + purge: delivery.purge, + site: "acme", + }; + return { git, delivery, drafts, repo }; +} + +describe("publishDraft", () => { + it("keeps a draft saved during the publish (only the published save goes)", async () => { + const { drafts, repo } = setup(); + const { slug } = await drafts.update(REF, () => ({ + set: { Home: { path: "/", title: "v2" } }, + delete: [], + })); + const commitFiles = repo.client.commitFiles; + repo.client.commitFiles = async (params) => { + await drafts.update(REF, (body) => ({ + ...body, + set: { ...body.set, Late: { x: 2 } }, + })); + return commitFiles(params); + }; + const result = await publishDraft(repo, drafts, REF, { + message: "Update home", + coAuthor: null, + }); + expect(result).toMatchObject({ result: "merged", release: "current" }); + const kept = await drafts.load(REF); + expect(kept?.slug).toBe(slug); + expect(Object.keys(kept!.body.set).sort()).toEqual(["Home", "Late"]); + }); + + it("commits to main, deletes the draft, then writes the revision and latest.json (Merged · Current on the CDN)", async () => { + const { git, delivery, drafts, repo } = setup(); + const { slug } = await drafts.update(REF, () => ({ + set: { Home: { path: "/", title: "v2" } }, + delete: ["Old"], + })); + const before = git.head(); + delivery.log.length = 0; + + const result = await publishDraft(repo, drafts, REF, { + message: "Update home", + coAuthor: null, + }); + + const sha = git.head(); + expect(sha).not.toBe(before); + expect(result).toEqual({ result: "merged", sha, release: "current" }); + expect(git.history[0]!.message).toBe("Update home"); + expect(git.filesAt(sha)).toEqual({ + ".deco/schema.gen.json": SCHEMA_TEXT, + ".deco/blocks/Home.json": serializeBlock({ path: "/", title: "v2" }), + }); + expect(delivery.log).toEqual([ + `delete ${deliveryKeys.draft("acme", slug)}`, + `put ${deliveryKeys.revision("acme", sha)}`, + `put ${deliveryKeys.latest("acme")}`, + `purge ${deliveryKeys.latest("acme")}`, + ]); + const revision = delivery.objects.get(deliveryKeys.revision("acme", sha))!; + expect(JSON.parse(revision.text)).toEqual({ + revision: sha, + schemaHash: SCHEMA_HASH, + blocks: { Home: { path: "/", title: "v2" } }, + }); + expect(revision.cacheControl).toBe("public, max-age=31536000, immutable"); + const latest = delivery.objects.get(deliveryKeys.latest("acme"))!; + expect(Object.keys(JSON.parse(latest.text)).sort()).toEqual([ + "publishedAt", + "revision", + "schemaHash", + ]); + expect(latest.cacheControl).toBe( + "public, max-age=0, s-maxage=3600, must-revalidate", + ); + expect(await drafts.load(REF)).toBeNull(); + }); + + it("is Merged · release created, not current, when main moved after the commit: no latest.json, draft deleted", async () => { + const { git, delivery, drafts, repo } = setup(); + await drafts.update(REF, () => ({ set: { New: { a: 1 } }, delete: [] })); + const result = await publishDraft( + repo, + drafts, + REF, + { message: "", coAuthor: null }, + { + beforePointerWrite: async () => { + git.pushDirect({ ".deco/blocks/Other.json": "{}\n" }); + }, + }, + ); + expect(result).toMatchObject({ result: "merged", release: "created" }); + expect(await readLatest(delivery.store, "acme")).toBeNull(); + // Publish is done once the commit is on main. + expect(await drafts.load(REF)).toBeNull(); + }); + + it("fails and keeps the draft when main moved under the commit", async () => { + const { git, delivery, drafts, repo } = setup(); + await drafts.update(REF, () => ({ set: { New: { a: 1 } }, delete: [] })); + const commitFiles = git.client.commitFiles; + git.client.commitFiles = async (params) => { + git.pushDirect({ ".deco/blocks/Race.json": "{}\n" }); + return commitFiles(params); + }; + await expect( + publishDraft(repo, drafts, REF, { message: "", coAuthor: null }), + ).rejects.toThrow(MainMovedError); + expect(await drafts.load(REF)).not.toBeNull(); + expect(await readLatest(delivery.store, "acme")).toBeNull(); + }); + + it("is Merged · no release created when the companion write fails: latest.json untouched, draft deleted", async () => { + const { drafts, repo, delivery } = setup(); + await drafts.update(REF, () => ({ set: { New: { a: 1 } }, delete: [] })); + const put = delivery.store.putJson; + delivery.store.putJson = async (key, value, cache) => { + if (key.includes("/revisions/")) throw new Error("R2 down"); + return put(key, value, cache); + }; + const result = await publishDraft(repo, drafts, REF, { + message: "", + coAuthor: null, + }); + expect(result).toMatchObject({ result: "merged", release: "none" }); + expect(delivery.log.some((l) => l.includes("latest.json"))).toBe(false); + expect(await drafts.load(REF)).toBeNull(); + }); + + it("fails fast when the purge fails: one purge call, no restore, Merged · release created, draft deleted", async () => { + const { git, delivery, drafts, repo } = setup(); + await drafts.update(REF, () => ({ set: { New: { a: 1 } }, delete: [] })); + delivery.log.length = 0; + delivery.failPurge(true); + const result = await publishDraft(repo, drafts, REF, { + message: "", + coAuthor: null, + }); + expect(result).toEqual({ + result: "merged", + sha: git.head(), + release: "created", + }); + // One purge call (its single retry is inside it) and one pointer write: + // no restore. + expect(delivery.log.slice(1)).toEqual([ + `put ${deliveryKeys.revision("acme", git.head())}`, + `put ${deliveryKeys.latest("acme")}`, + `purge ${deliveryKeys.latest("acme")}`, + ]); + expect((await readLatest(delivery.store, "acme"))?.revision).toBe( + git.head(), + ); + expect(await drafts.load(REF)).toBeNull(); + }); + + it("is Merged · release created when both purge attempts fail (exactly two calls)", async () => { + const { git, delivery, drafts, repo } = setup(); + await drafts.update(REF, () => ({ set: { New: { a: 1 } }, delete: [] })); + let calls = 0; + repo.purge = createDeliveryPurge({ + zoneId: "z", + apiToken: "t", + fetch: (async () => { + calls++; + return new Response(JSON.stringify({ success: false }), { + status: 503, + }); + }) as unknown as typeof fetch, + }); + delivery.log.length = 0; + const result = await publishDraft(repo, drafts, REF, { + message: "", + coAuthor: null, + }); + expect(result).toEqual({ + result: "merged", + sha: git.head(), + release: "created", + }); + expect(calls).toBe(2); + // No restore: the pointer written once stays. + expect(delivery.log.slice(1)).toEqual([ + `put ${deliveryKeys.revision("acme", git.head())}`, + `put ${deliveryKeys.latest("acme")}`, + ]); + expect((await readLatest(delivery.store, "acme"))?.revision).toBe( + git.head(), + ); + expect(await drafts.load(REF)).toBeNull(); + }); + + it("publishes and deletes the draft when the purge retry succeeds", async () => { + const { git, drafts, repo } = setup(); + await drafts.update(REF, () => ({ set: { New: { a: 1 } }, delete: [] })); + let calls = 0; + repo.purge = createDeliveryPurge({ + zoneId: "z", + apiToken: "t", + fetch: (async () => { + calls++; + return new Response(JSON.stringify({ success: calls > 1 }), { + status: calls > 1 ? 200 : 503, + }); + }) as unknown as typeof fetch, + }); + const result = await publishDraft(repo, drafts, REF, { + message: "", + coAuthor: null, + }); + expect(result).toEqual({ + result: "merged", + sha: git.head(), + release: "current", + }); + expect(calls).toBe(2); + expect(await drafts.load(REF)).toBeNull(); + }); + + it("does not purge when the latest.json write fails (Merged · release created)", async () => { + const { git, delivery, drafts, repo } = setup(); + await drafts.update(REF, () => ({ set: { New: { a: 1 } }, delete: [] })); + delivery.log.length = 0; + const put = delivery.store.putJson; + delivery.store.putJson = async (key, value, cache) => { + if (key === deliveryKeys.latest("acme")) throw new Error("R2 down"); + return put(key, value, cache); + }; + const result = await publishDraft(repo, drafts, REF, { + message: "", + coAuthor: null, + }); + expect(result).toEqual({ + result: "merged", + sha: git.head(), + release: "created", + }); + expect(delivery.log.slice(1)).toEqual([ + `put ${deliveryKeys.revision("acme", git.head())}`, + ]); + expect(await drafts.load(REF)).toBeNull(); + }); + + it("keeps the draft and fails when the commit fails", async () => { + const { delivery, drafts, repo } = setup(); + await drafts.update(REF, () => ({ set: { New: { a: 1 } }, delete: [] })); + delivery.log.length = 0; + repo.client.commitFiles = async () => { + throw new Error("GitHub down"); + }; + await expect( + publishDraft(repo, drafts, REF, { message: "", coAuthor: null }), + ).rejects.toThrow("GitHub down"); + expect(await drafts.load(REF)).not.toBeNull(); + expect(delivery.log).toEqual([]); + }); + + it("leaves latest.json alone and deletes the draft when the draft commits nothing", async () => { + const { git, delivery, drafts, repo } = setup(); + // Same content as main: nothing to commit. + await drafts.update(REF, () => ({ + set: { Home: { path: "/", title: "v1" } }, + delete: [], + })); + const before = git.head(); + delivery.log.length = 0; + expect( + await publishDraft(repo, drafts, REF, { message: "", coAuthor: null }), + ).toEqual({ result: "up-to-date" }); + expect(git.head()).toBe(before); + expect(delivery.log.some((l) => !l.startsWith("delete "))).toBe(false); + expect(await drafts.load(REF)).toBeNull(); + }); + + it("answers up-to-date for an empty draft and deletes it", async () => { + const { delivery, drafts, repo } = setup(); + await drafts.update(REF, () => ({ set: {}, delete: [] })); + delivery.log.length = 0; + expect( + await publishDraft(repo, drafts, REF, { message: "", coAuthor: null }), + ).toEqual({ result: "up-to-date" }); + expect(delivery.log.some((l) => l.includes("latest.json"))).toBe(false); + expect(await drafts.load(REF)).toBeNull(); + }); + + it("answers up-to-date without a draft", async () => { + const { drafts, repo } = setup(); + expect( + await publishDraft(repo, drafts, REF, { message: "", coAuthor: null }), + ).toEqual({ result: "up-to-date" }); + }); +}); diff --git a/apps/api/src/hosted/publish.ts b/apps/api/src/hosted/publish.ts new file mode 100644 index 0000000000..f3a2bf0606 --- /dev/null +++ b/apps/api/src/hosted/publish.ts @@ -0,0 +1,373 @@ +/** + * Publish for a hosted v8 site: + * + * 1. commit the draft directly to main (fast-forward only, no PR); + * 2. delete the draft: the merge is done; + * 3. write the commit's companion release, the immutable + * sites//revisions/.json; + * 4. make it current: write latest.json and purge its URL from Cloudflare's + * edge (5 s per attempt, one retry), only while the commit is still + * main's head, so an older Publish never replaces a newer one. + * + * Git is the source of truth for content; latest.json alone says what the CDN + * serves ("Current"). A failed commit fails Publish and keeps the draft. A + * failure in step 3 or 4 doesn't undo the merge: the result says which step + * failed, nothing is restored, and Releases → Make current retries step 4. A + * draft that commits nothing leaves latest.json alone. + * Every latest.json write stamps `publishedAt` now: the SDK prefers the CDN + * only when it is later than its bundle's build time. + */ + +import { + appendCoAuthorTrailer, + type CoAuthorIdentity, +} from "@decocms/sandbox/shared"; +import { + blockFileName, + blockNameFromFile, + fullyDecodeFileName, + serializeBlock, + spellingKey, +} from "@decocms/blocks/protocol"; +import { + type FileChange, + type RepoContentClient, + RepoWriteConflict, + requireBranchHead, + type TreeEntry, +} from "@/git-providers"; +import { + decofileCommitMessage, + regenerateGenArtifact, +} from "@/decofile/gen-artifact"; +import { + type BlockSource, + blockEntriesInTree, + blocksDirPath, + gitBlobSha, + primeBlobCache, +} from "@/decofile/read-decofile"; +import { + CACHE_LATEST, + CACHE_REVISION, + type DeliveryStore, + deliveryKeys, + getJson, +} from "./delivery-store"; +import type { DeliveryPurge } from "./delivery-purge"; +import { + type DraftBody, + type DraftStore, + type HostedDraftRef, + isEmptyDraft, +} from "./draft-store"; +import { buildRevision, schemaHashAt } from "./release-objects"; + +/** Main moved while the draft was being committed: nothing was published. */ +export class MainMovedError extends Error { + constructor() { + super("main moved; publish again"); + this.name = "MainMovedError"; + } +} + +/** + * Writing latest.json or purging it from the edge (after its one retry) + * failed. Nothing is restored: the user tries Make current again. + */ +export class LatestUpdateError extends Error { + constructor(cause: unknown) { + super( + `Making it current on the CDN failed (${ + cause instanceof Error ? cause.message : String(cause) + }). Try again.`, + ); + this.name = "LatestUpdateError"; + } +} + +export interface LatestPointer { + revision: string; + schemaHash: string; + publishedAt: string; +} + +function parseLatest(value: unknown): LatestPointer | null { + const v = value as Partial | null; + return typeof v === "object" && + v !== null && + typeof v.revision === "string" && + typeof v.schemaHash === "string" && + typeof v.publishedAt === "string" + ? { + revision: v.revision, + schemaHash: v.schemaHash, + publishedAt: v.publishedAt, + } + : null; +} + +export async function readLatest( + store: DeliveryStore, + site: string, +): Promise { + const object = await getJson(store, deliveryKeys.latest(site)); + return object ? parseLatest(object.value) : null; +} + +/** Whether `sites//revisions/.json` exists (a listing, not a read). */ +async function hasRevision( + store: DeliveryStore, + site: string, + sha: string, +): Promise { + const key = deliveryKeys.revision(site, sha); + return (await store.listKeys(key)).includes(key); +} + +export interface HostedRepo { + client: RepoContentClient; + packagePath: string | null; + mainBranch: string; + store: DeliveryStore; + /** Purges latest.json from the edge after each write of it. */ + purge: DeliveryPurge; + site: string; +} + +/** + * What a merged Publish did on the CDN: `current` (companion written and made + * current), `created` (companion written, making it current failed) or + * `none` (no companion: the next Publish includes these changes). + */ +export type ReleaseOutcome = "current" | "created" | "none"; + +/** "merged": the draft is on main (Publish is done); `release` says the rest. */ +export type PublishResult = + | { result: "merged"; sha: string; release: ReleaseOutcome } + | { result: "up-to-date" }; + +/** + * The file changes that apply a draft to the tree at `headSha`: a set name + * writes its existing spelling's file (else the protocol's file name), unless + * that file already holds exactly this content, and drops its other + * spellings; a delete name removes every spelling. + */ +export async function draftFileChanges( + repo: Pick, + headSha: string, + draft: DraftBody, +): Promise<{ + changes: FileChange[]; + tree: TreeEntry[]; + next: Map; +}> { + const prefix = `${blocksDirPath(repo.packagePath)}/`; + const tree = await repo.client.listDecofileEntries(headSha, repo.packagePath); + const entries = blockEntriesInTree(tree, repo.packagePath); + const next = new Map( + entries.map((e) => [e.path, { stem: e.stem, sha: e.sha }]), + ); + const blobShaAt = new Map(entries.map((e) => [e.path, e.sha])); + const groups = new Map(); + for (const entry of entries) { + const group = fullyDecodeFileName(entry.path.slice(prefix.length)).name; + groups.set(group, [...(groups.get(group) ?? []), entry.path]); + } + const changes: FileChange[] = []; + const remove = (path: string) => { + changes.push({ path, deleted: true }); + next.delete(path); + }; + for (const [name, entry] of Object.entries(draft.set)) { + const spellings = groups.get(spellingKey(name)) ?? []; + // The existing spelling's file when there is one, else the protocol's. + const path = + spellings.find( + (p) => blockNameFromFile(p.slice(prefix.length)) === name, + ) ?? `${prefix}${blockFileName(name)}`; + const content = serializeBlock(entry); + for (const other of spellings) if (other !== path) remove(other); + // Already on main byte for byte: nothing to commit for this name. + if (blobShaAt.get(path) === gitBlobSha(content)) continue; + changes.push({ path, content }); + next.set(path, { + stem: path.slice(prefix.length, -".json".length), + content, + }); + await primeBlobCache(repo.client.repo, content); + } + for (const name of draft.delete) { + for (const path of groups.get(spellingKey(name)) ?? []) remove(path); + } + return { changes, tree, next }; +} + +/** + * Step 1: one fast-forward commit on main. Returns the commit sha, or null + * when the draft changes nothing on main. + */ +async function commitDraftToMain( + repo: HostedRepo, + draft: DraftBody, + message: string, +): Promise { + const headSha = await requireBranchHead(repo.client, repo.mainBranch); + const { changes, tree, next } = await draftFileChanges(repo, headSha, draft); + if (changes.length === 0) return null; + const gen = await regenerateGenArtifact({ + client: repo.client, + tree, + packagePath: repo.packagePath, + branch: repo.mainBranch, + nextBlocks: next.values(), + memo: new Map(), + }); + if (gen) changes.push(gen); + try { + // No `rewriteFrom`: the ref only moves by fast-forward from `headSha`. + const { sha } = await repo.client.commitFiles({ + branch: repo.mainBranch, + message, + expectedHead: headSha, + changes, + }); + return sha; + } catch (error) { + // OPEN: O-9 — a non-fast-forward stops with "main moved", no retry. + if (error instanceof RepoWriteConflict) throw new MainMovedError(); + throw error; + } +} + +/** + * Step 3 for commit `sha`: its companion release, written only when missing + * (it is immutable). Returns its schemaHash. + */ +async function ensureRevision(repo: HostedRepo, sha: string): Promise { + if (await hasRevision(repo.store, repo.site, sha)) { + return schemaHashAt(repo.client, repo.packagePath, sha); + } + const revision = await buildRevision(repo.client, repo.packagePath, sha); + await repo.store.putJson( + deliveryKeys.revision(repo.site, sha), + revision, + CACHE_REVISION, + ); + return revision.schemaHash; +} + +/** + * Writes latest.json, then purges its URL from the edge (5 s per attempt, + * exactly one retry). Either failing throws {@link LatestUpdateError}: no + * restore of the previous pointer, no other recovery (a failed write skips + * the purge). + */ +export async function writeLatest( + repo: Pick, + pointer: LatestPointer, +): Promise { + const key = deliveryKeys.latest(repo.site); + try { + await repo.store.putJson(key, pointer, CACHE_LATEST); + await repo.purge.purge(key); + } catch (error) { + throw new LatestUpdateError(error); + } +} + +/** + * Step 4 for commit `sha`, whose companion has `schemaHash`: make it current, + * only while `sha` is still main's head. Returns whether latest.json was + * written; a failed write or purge throws {@link LatestUpdateError}. + */ +async function makeCommitCurrent( + repo: HostedRepo, + sha: string, + schemaHash: string, + hooks: PublishHooks, +): Promise { + await hooks.beforePointerWrite?.(); + // OPEN: O-10 — a plain PUT after the head check; a race lost in between is + // fixed with Make current. + const head = await requireBranchHead(repo.client, repo.mainBranch); + if (head !== sha) return false; + await writeLatest(repo, { + revision: sha, + schemaHash, + publishedAt: new Date().toISOString(), + }); + return true; +} + +export interface PublishHooks { + beforePointerWrite?: () => Promise; +} + +function logCdnFailure( + repo: HostedRepo, + sha: string, + step: string, + error: unknown, +) { + console.error(`hosted publish: ${step} failed`, { + site: repo.site, + sha, + error: error instanceof Error ? error.message : String(error), + }); +} + +/** Publish: steps 1–4 for the draft of `ref`. */ +export async function publishDraft( + repo: HostedRepo, + drafts: DraftStore, + ref: HostedDraftRef, + note: { message: string; coAuthor?: CoAuthorIdentity | null }, + hooks: PublishHooks = {}, +): Promise { + const draft = await drafts.load(ref); + // OPEN: O-26 — nothing to publish answers the popover's existing no-changes state. + if (!draft) return { result: "up-to-date" }; + const removeDraft = () => + // Only the save that was published goes: a save made during the publish + // keeps the draft (its ETag changed). + drafts + .remove(ref, draft.etag) + .catch((error) => + console.error("hosted publish: draft delete failed", { + site: repo.site, + error: error instanceof Error ? error.message : String(error), + }), + ); + const sha = isEmptyDraft(draft.body) + ? null + : await commitDraftToMain( + repo, + draft.body, + note.message.trim() + ? appendCoAuthorTrailer(note.message.trim(), note.coAuthor) + : decofileCommitMessage( + Object.keys(draft.body.set), + draft.body.delete, + note.coAuthor, + ), + ); + // The draft goes once it is merged, or when it commits nothing. + await removeDraft(); + // Nothing committed: latest.json is left alone. + if (sha === null) return { result: "up-to-date" }; + let schemaHash: string; + try { + schemaHash = await ensureRevision(repo, sha); + } catch (error) { + logCdnFailure(repo, sha, "release write", error); + return { result: "merged", sha, release: "none" }; + } + try { + const current = await makeCommitCurrent(repo, sha, schemaHash, hooks); + return { result: "merged", sha, release: current ? "current" : "created" }; + } catch (error) { + logCdnFailure(repo, sha, "make current", error); + return { result: "merged", sha, release: "created" }; + } +} diff --git a/apps/api/src/hosted/release-objects.test.ts b/apps/api/src/hosted/release-objects.test.ts new file mode 100644 index 0000000000..8d3e01096d --- /dev/null +++ b/apps/api/src/hosted/release-objects.test.ts @@ -0,0 +1,69 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { serializeBlock } from "@decocms/blocks/protocol"; +import { fakeRepo, SCHEMA_HASH, SCHEMA_TEXT } from "./hosted-test-helpers"; +import { + buildRevision, + InvalidSavedBlock, + NotV8Site, + schemaHashOfText, +} from "./release-objects"; + +beforeAll(() => { + process.env.FAST_PREVIEW_CACHE_DIR = ""; +}); + +describe("schemaHashOfText", () => { + it("matches the vector deco content writes into the site", async () => { + expect(await schemaHashOfText(SCHEMA_TEXT)).toBe(SCHEMA_HASH); + }); + + it("is null for a v7 schema or bad JSON", async () => { + expect(await schemaHashOfText('{"definitions":{}}')).toBeNull(); + expect(await schemaHashOfText("{")).toBeNull(); + }); +}); + +describe("buildRevision", () => { + it("reads the saved blocks the way deco content does", async () => { + const repo = fakeRepo({ + ".deco/schema.gen.json": SCHEMA_TEXT, + ".deco/blocks/Home.json": serializeBlock({ path: "/", a: 1 }), + // Two spellings of one name: the page-like entry wins. + ".deco/blocks/pages%20A.json": serializeBlock({ path: "/a" }), + ".deco/blocks/pages%2520A.json": serializeBlock({ b: 2 }), + ".deco/blocks/nested/Skip.json": serializeBlock({ c: 3 }), + }); + const revision = await buildRevision(repo.client, null, repo.head()); + expect(revision).toEqual({ + revision: repo.head(), + schemaHash: SCHEMA_HASH, + blocks: { Home: { path: "/", a: 1 }, "pages A": { path: "/a" } }, + }); + }); + + it("refuses a commit without a v8 schema", async () => { + const repo = fakeRepo({ ".deco/blocks/Home.json": "{}" }); + await expect(buildRevision(repo.client, null, repo.head())).rejects.toThrow( + NotV8Site, + ); + }); + + it("refuses a block the site's own build would refuse", async () => { + const repo = fakeRepo({ + ".deco/schema.gen.json": SCHEMA_TEXT, + ".deco/blocks/Home.json": "[1]", + }); + await expect(buildRevision(repo.client, null, repo.head())).rejects.toThrow( + InvalidSavedBlock, + ); + }); + + it("reads under the app root", async () => { + const repo = fakeRepo({ + "apps/site/.deco/schema.gen.json": SCHEMA_TEXT, + "apps/site/.deco/blocks/Home.json": serializeBlock({ a: 1 }), + }); + const revision = await buildRevision(repo.client, "apps/site", repo.head()); + expect(revision.blocks).toEqual({ Home: { a: 1 } }); + }); +}); diff --git a/apps/api/src/hosted/release-objects.ts b/apps/api/src/hosted/release-objects.ts new file mode 100644 index 0000000000..a3dbd7bd47 --- /dev/null +++ b/apps/api/src/hosted/release-objects.ts @@ -0,0 +1,143 @@ +/** + * What a hosted release is made of, read from one commit of the site's repo. + * + * `blocks` is built exactly the way `deco content` builds the site's bundled + * content (`readSavedBlocks` in @decocms/blocks): every `.deco/blocks/*.json` + * file whose name is valid, resolved across spellings, parsed. `schemaHash` + * is `sha256Hex(canonicalJson(JSON.parse(.deco/schema.gen.json)))`, the value + * `deco content` writes into the site's content module: the SDK swaps to a + * release only when the two match. + */ + +import { + blockNameFromFile, + canonicalJson, + checkBlockName, + entryHasPath, + isBlockFileName, + resolveSpellings, + sha256Hex, +} from "@decocms/blocks/protocol"; +import type { RepoContentClient } from "@/git-providers"; +import { + blockEntriesInTree, + blocksDirPath, + resolveBlockContents, +} from "@/decofile/read-decofile"; + +/** The commit has no Blocks v8 schema; nothing is published for it. */ +export class NotV8Site extends Error { + constructor() { + super("not a Blocks v8 site (.deco/schema.gen.json without blocksMajor 8)"); + this.name = "NotV8Site"; + } +} + +/** A saved block the site's own build would refuse. */ +export class InvalidSavedBlock extends Error { + constructor(file: string, reason: string) { + super(`.deco/blocks/${file}: ${reason}`); + this.name = "InvalidSavedBlock"; + } +} + +export interface RevisionObject { + revision: string; + schemaHash: string; + blocks: Record>; +} + +function schemaPath(packagePath: string | null): string { + return packagePath + ? `${packagePath}/.deco/schema.gen.json` + : ".deco/schema.gen.json"; +} + +/** The schemaHash of schema text, or null when it isn't a v8 schema. */ +export async function schemaHashOfText(text: string): Promise { + let schema: unknown; + try { + schema = JSON.parse(text); + } catch { + return null; + } + if ( + typeof schema !== "object" || + schema === null || + Array.isArray(schema) || + (schema as { blocksMajor?: unknown }).blocksMajor !== 8 + ) { + return null; + } + return sha256Hex(canonicalJson(schema)); +} + +/** The schemaHash at `sha`; throws {@link NotV8Site} when there is none. */ +export async function schemaHashAt( + client: RepoContentClient, + packagePath: string | null, + sha: string, +): Promise { + const text = await client.readFileAtRef(sha, schemaPath(packagePath)); + const hash = text === null ? null : await schemaHashOfText(text); + if (!hash) throw new NotV8Site(); + return hash; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** The saved blocks at `sha`, keyed by entry name. */ +async function savedBlocksAt( + client: RepoContentClient, + packagePath: string | null, + sha: string, +): Promise>> { + const prefix = `${blocksDirPath(packagePath)}/`; + const tree = await client.listDecofileEntries(sha, packagePath); + const files = blockEntriesInTree(tree, packagePath) + .map((entry) => ({ file: entry.path.slice(prefix.length), sha: entry.sha })) + .filter((f) => isBlockFileName(f.file)); + const contents = await resolveBlockContents( + client, + files.map((f) => ({ stem: f.file, sha: f.sha })), + ); + const candidates = files.map((f, i) => { + const [invalid] = checkBlockName(blockNameFromFile(f.file)); + if (invalid) { + throw new InvalidSavedBlock(f.file, "not a valid entry name"); + } + let entry: unknown; + try { + entry = JSON.parse(contents[i]!.content); + } catch { + throw new InvalidSavedBlock(f.file, "invalid JSON"); + } + if (!isPlainObject(entry)) { + throw new InvalidSavedBlock( + f.file, + "a saved block must be a JSON object", + ); + } + return { file: f.file, hasPath: entryHasPath(entry), entry }; + }); + const blocks: Record> = Object.create(null); + for (const { name, winner } of resolveSpellings(candidates).values()) { + blocks[name] = winner.entry; + } + return blocks; +} + +/** The revision object for commit `sha`. */ +export async function buildRevision( + client: RepoContentClient, + packagePath: string | null, + sha: string, +): Promise { + const [schemaHash, blocks] = await Promise.all([ + schemaHashAt(client, packagePath, sha), + savedBlocksAt(client, packagePath, sha), + ]); + return { revision: sha, schemaHash, blocks }; +}