diff --git a/apps/api/src/hosted/draft-publish-status.test.ts b/apps/api/src/hosted/draft-publish-status.test.ts new file mode 100644 index 0000000000..c3c002bec8 --- /dev/null +++ b/apps/api/src/hosted/draft-publish-status.test.ts @@ -0,0 +1,81 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { serializeBlock } from "@decocms/blocks/protocol"; +import { + hostedDraftPublishDiff, + draftGitDiscard, + hostedDraftPublishStatus, +} from "./draft-publish-status"; +import { createDraftStore, type HostedDraftRef } from "./draft-store"; +import { + fakeRepo, + memoryDeliveryStore, + memoryKv, + SCHEMA_TEXT, +} from "./hosted-test-helpers"; + +beforeAll(() => { + process.env.FAST_PREVIEW_CACHE_DIR = ""; +}); + +const REF: HostedDraftRef = { + organizationId: "org", + virtualMcpId: "vmcp", + branch: "thread-1", + site: "acme", +}; + +describe("hosted draft publish status", () => { + it("shows, diffs and discards the draft's changes against main", async () => { + const git = fakeRepo({ + ".deco/schema.gen.json": SCHEMA_TEXT, + ".deco/blocks/Home.json": serializeBlock({ v: 1 }), + ".deco/blocks/Old.json": serializeBlock({ o: 1 }), + }); + const drafts = createDraftStore({ + kv: memoryKv(), + store: memoryDeliveryStore().store, + }); + const repo = { client: git.client, packagePath: null, mainBranch: "main" }; + await drafts.update(REF, () => ({ + set: { Home: { v: 2 }, New: { n: 1 } }, + delete: ["Old"], + })); + const draft = await drafts.load(REF); + + const status = await hostedDraftPublishStatus(repo, REF.branch, draft); + expect(status.changedFiles).toEqual([ + { path: ".deco/blocks/Home.json", status: "modified" }, + { path: ".deco/blocks/New.json", status: "added" }, + { path: ".deco/blocks/Old.json", status: "removed" }, + ]); + expect(status.aheadOfBase).toBe(3); + expect(status.headSha.startsWith(`${git.head()}~`)).toBe(true); + + const diff = await hostedDraftPublishDiff(repo, draft); + expect(diff.diffs[".deco/blocks/Home.json"]).toEqual({ + from: serializeBlock({ v: 1 }), + to: serializeBlock({ v: 2 }), + }); + expect(diff.diffs[".deco/blocks/Old.json"]?.to).toBeNull(); + + await draftGitDiscard(drafts, REF, [ + ".deco/blocks/Home.json", + ".deco/blocks/Old.json", + ]); + expect((await drafts.load(REF))?.body).toEqual({ + set: { New: { n: 1 } }, + delete: [], + }); + }); + + it("has nothing to publish without a draft", async () => { + const git = fakeRepo({ ".deco/schema.gen.json": SCHEMA_TEXT }); + const status = await hostedDraftPublishStatus( + { client: git.client, packagePath: null, mainBranch: "main" }, + "thread-1", + null, + ); + expect(status.changedFiles).toEqual([]); + expect(status.aheadOfBase).toBe(0); + }); +}); diff --git a/apps/api/src/hosted/draft-publish-status.ts b/apps/api/src/hosted/draft-publish-status.ts new file mode 100644 index 0000000000..c385f16e27 --- /dev/null +++ b/apps/api/src/hosted/draft-publish-status.ts @@ -0,0 +1,183 @@ +/** + * The publish popover's status for a hosted v8 project, in the git shapes its + * routes already speak (`/git/status`, `/git/diff`, `/git/discard`, see + * `decofile/git-compat.ts`), served from the project's CDN draft instead of a + * branch: the changed paths are the files Publish would commit to main, and + * discarding a path drops that block from the draft. The popover works + * unchanged on top. + */ + +import { fullyDecodeFileName, spellingKey } from "@decocms/blocks/protocol"; +import { + buildPublishStatus, + type CompatGitDiff, + type CompatGitStatus, +} from "@/decofile/git-compat"; +import { resolveBlockContents } from "@/decofile/read-decofile"; +import { orgFlagEnabled } from "@decocms/shared/organization/schema"; +import { type RepoContentClient, requireBranchHead } from "@/git-providers"; +import type { KVStorage } from "@/storage/kv"; +import { bareEtag } from "./delivery-store"; +import type { + DraftBody, + DraftStore, + HostedDraftRef, + LoadedDraft, +} from "./draft-store"; +import { draftFileChanges } from "./publish"; +import { hostedDrafts, mainIsV8, ownedProjectSite } from "./scope"; + +interface DraftRepo { + client: RepoContentClient; + packagePath: string | null; + mainBranch: string; +} + +export interface HostedDraftRequest { + storage: { + kv: KVStorage; + orgSites: Parameters[0]; + organizationSettings: { + get( + organizationId: string, + ): Promise<{ flags?: Record | null } | null>; + }; + }; + organizationId: string; + virtualMcpId: string; + branch: string; + metadata: Record | null | undefined; + /** The project repo's content client (only called for a hosted project). */ + gitClient: () => Promise; +} + +/** + * A hosted v8 project's draft, for the sandbox-less git routes: its changes + * live on the CDN draft, not on the branch. Null for every other project, + * which keeps the branch-backed answers. + */ +export async function loadHostedDraft(req: HostedDraftRequest) { + const site = await ownedProjectSite( + req.storage.orgSites, + req.virtualMcpId, + req.organizationId, + ); + const drafts = site ? hostedDrafts(req.storage.kv) : null; + if (!site || !drafts) return null; + const settings = await req.storage.organizationSettings.get( + req.organizationId, + ); + if (!orgFlagEnabled(settings?.flags, "site_editor_content_protocol")) { + return null; + } + const ref: HostedDraftRef = { + organizationId: req.organizationId, + virtualMcpId: req.virtualMcpId, + branch: req.branch, + site, + }; + const client = await req.gitClient(); + const runtime = req.metadata?.runtime as { path?: string | null } | undefined; + const repo: DraftRepo = { + client, + packagePath: runtime?.path?.replace(/^\/+|\/+$/g, "") || null, + mainBranch: await client.getDefaultBranch(), + }; + const draft = await drafts.load(ref); + if (!draft && !(await mainIsV8(client, repo.packagePath, repo.mainBranch))) { + return null; + } + return { drafts, ref, repo, draft }; +} + +const EMPTY: DraftBody = { set: {}, delete: [] }; + +async function changesAgainstMain(repo: DraftRepo, draft: LoadedDraft | null) { + const mainSha = await requireBranchHead(repo.client, repo.mainBranch); + const { changes, tree } = await draftFileChanges( + repo, + mainSha, + draft?.body ?? EMPTY, + ); + const onMain = new Map( + tree.filter((e) => e.type === "blob").map((e) => [e.path, e.sha]), + ); + return { mainSha, changes, onMain }; +} + +export async function hostedDraftPublishStatus( + repo: DraftRepo, + branch: string, + draft: LoadedDraft | null, +): Promise { + const { mainSha, changes, onMain } = await changesAgainstMain(repo, draft); + return buildPublishStatus({ + base: repo.mainBranch, + branch, + // The draft's revision, as the content protocol reports it. + headSha: `${mainSha}~${bareEtag(draft?.etag) ?? "none"}`, + compared: { + aheadBy: changes.length, + behindBy: 0, + files: changes.map((change) => ({ + filename: change.path, + status: + "deleted" in change + ? "removed" + : onMain.has(change.path) + ? "modified" + : "added", + })), + }, + }); +} + +export async function hostedDraftPublishDiff( + repo: DraftRepo, + draft: LoadedDraft | null, +): Promise { + const { mainSha, changes, onMain } = await changesAgainstMain(repo, draft); + const before = changes.flatMap((change) => { + const sha = onMain.get(change.path); + return sha ? [{ stem: change.path, sha }] : []; + }); + const contents = new Map( + (await resolveBlockContents(repo.client, before)).map((b) => [ + b.stem, + b.content, + ]), + ); + return { + diffs: Object.fromEntries( + changes.map((change) => [ + change.path, + { + from: contents.get(change.path) ?? null, + to: "content" in change ? change.content : null, + }, + ]), + ), + mergeBaseSha: mainSha, + }; +} + +/** Drops the blocks behind `filepaths` from the draft. */ +export async function draftGitDiscard( + drafts: DraftStore, + ref: HostedDraftRef, + filepaths: string[], +): Promise { + const groups = new Set( + filepaths.map( + (path) => fullyDecodeFileName(path.split("/").pop() ?? "").name, + ), + ); + await drafts.update(ref, (body) => ({ + set: Object.fromEntries( + Object.entries(body.set).filter( + ([name]) => !groups.has(spellingKey(name)), + ), + ), + delete: body.delete.filter((name) => !groups.has(spellingKey(name))), + })); +} diff --git a/apps/api/src/hosted/releases.test.ts b/apps/api/src/hosted/releases.test.ts new file mode 100644 index 0000000000..f58c722c47 --- /dev/null +++ b/apps/api/src/hosted/releases.test.ts @@ -0,0 +1,193 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import { CACHE_REVISION, deliveryKeys } from "./delivery-store"; +import { + fakeInsights, + fakeRepo, + memoryDeliveryStore, + SCHEMA_HASH, + SCHEMA_TEXT, +} from "./hosted-test-helpers"; +import { type HostedRepo, LatestUpdateError, readLatest } from "./publish"; +import { buildRevision } from "./release-objects"; +import { + listReleases, + makeCurrent, + NotPublishedError, + SchemaMismatchError, +} from "./releases"; + +beforeAll(() => { + process.env.FAST_PREVIEW_CACHE_DIR = ""; +}); + +function setup() { + const git = fakeRepo({ + ".deco/schema.gen.json": SCHEMA_TEXT, + ".deco/blocks/Home.json": "{}\n", + }); + const delivery = memoryDeliveryStore(); + const repo: HostedRepo = { + client: git.client, + packagePath: null, + mainBranch: "main", + store: delivery.store, + purge: delivery.purge, + site: "acme", + }; + const insights = fakeInsights(git.history); + /** Writes main head's companion release, as a Publish does. */ + const release = async () => { + const sha = git.head(); + await delivery.store.putJson( + deliveryKeys.revision("acme", sha), + await buildRevision(git.client, null, sha), + CACHE_REVISION, + ); + return sha; + }; + return { git, delivery, repo, insights, release }; +} + +const LATEST = deliveryKeys.latest("acme"); + +describe("listReleases", () => { + it("lists main's commits newest first, marking the ones with a companion release", async () => { + const { git, repo, insights, release } = setup(); + await release(); + git.pushDirect({ ".deco/blocks/Home.json": '{"a":1}\n' }, "published"); + await release(); + git.pushDirect({ ".deco/blocks/Home.json": '{"a":2}\n' }, "developer push"); + + const page = await listReleases(repo, insights.client, null); + expect(page.commits.map((c) => [c.message, c.hasRelease])).toEqual([ + ["developer push", false], + ["published", true], + ["initial", true], + ]); + }); + + it("finds companions with one listing of the revisions/ prefix, not one request per commit", async () => { + const { git, delivery, repo, insights, release } = setup(); + await release(); + for (let i = 0; i < 5; i++) { + git.pushDirect({ ".deco/blocks/Home.json": `{"i":${i}}\n` }); + } + const listed: string[] = []; + const reads: string[] = []; + const { listKeys, get } = delivery.store; + delivery.store.listKeys = async (prefix) => { + listed.push(prefix); + return listKeys(prefix); + }; + delivery.store.get = async (key) => { + reads.push(key); + return get(key); + }; + await listReleases(repo, insights.client, null); + expect(listed).toEqual([deliveryKeys.revisions("acme")]); + expect(reads).toEqual([LATEST]); + }); + + it("says Current is exactly the commit latest.json names", async () => { + const { git, repo, insights, release } = setup(); + const first = await release(); + git.pushDirect({ ".deco/blocks/Home.json": '{"a":1}\n' }); + await release(); + await makeCurrent(repo, first, { confirm: false }); + // Not main's head, and not inferred from it: what latest.json says. + const page = await listReleases(repo, insights.client, null); + expect(page.current?.revision).toBe(first); + }); + + it("has no Current when latest.json is missing", async () => { + const { repo, insights, release } = setup(); + await release(); + const page = await listReleases(repo, insights.client, null); + expect(page.current).toBeNull(); + expect(page.commits[0]?.hasRelease).toBe(true); + }); + + it("reads one page of 50 commits", async () => { + const { git, repo, insights } = setup(); + for (let i = 0; i < 60; i++) { + git.pushDirect({ ".deco/blocks/Home.json": `{"i":${i}}\n` }, `dev ${i}`); + } + insights.calls.length = 0; + const page = await listReleases(repo, insights.client, null); + expect(insights.calls).toEqual([{ cursor: null, limit: 50 }]); + expect(page.commits).toHaveLength(50); + expect(page.nextCursor).toBe("50"); + }); +}); + +describe("makeCurrent", () => { + it("writes latest.json for the companion, then purges it", async () => { + const { git, delivery, repo, release } = setup(); + const first = await release(); + git.pushDirect({ ".deco/blocks/Home.json": '{"a":1}\n' }); + delivery.log.length = 0; + const pointer = await makeCurrent(repo, first, { confirm: false }); + expect(pointer).toMatchObject({ revision: first, schemaHash: SCHEMA_HASH }); + expect(Date.now() - Date.parse(pointer.publishedAt)).toBeLessThan(5_000); + expect(delivery.log).toEqual([`put ${LATEST}`, `purge ${LATEST}`]); + expect((await readLatest(delivery.store, "acme"))?.revision).toBe(first); + }); + + it("refuses a commit without a companion release", async () => { + const { git, delivery, repo } = setup(); + await expect( + makeCurrent(repo, git.head(), { confirm: false }), + ).rejects.toThrow(NotPublishedError); + expect(delivery.log).toEqual([]); + }); + + it("fails fast on a failed purge: one purge call, no restore", async () => { + const { git, delivery, repo, release } = setup(); + const first = await release(); + git.pushDirect({ ".deco/blocks/Home.json": '{"b":1}\n' }); + const second = await release(); + await makeCurrent(repo, second, { confirm: false }); + delivery.failPurge(true); + delivery.log.length = 0; + const error = await makeCurrent(repo, first, { confirm: false }).catch( + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(LatestUpdateError); + expect((error as Error).message).toContain("Try again"); + expect(delivery.log).toEqual([`put ${LATEST}`, `purge ${LATEST}`]); + // No restore: the written pointer stays, and the screen reads it back. + expect((await readLatest(delivery.store, "acme"))?.revision).toBe(first); + }); + + it("fails without purging when the latest.json write fails; Current stays put", async () => { + const { git, delivery, repo, release } = setup(); + const first = await release(); + await makeCurrent(repo, first, { confirm: false }); + git.pushDirect({ ".deco/blocks/Home.json": '{"b":1}\n' }); + const second = await release(); + const put = delivery.store.putJson; + delivery.store.putJson = async (key, value, cache) => { + if (key === LATEST) throw new Error("R2 down"); + return put(key, value, cache); + }; + delivery.log.length = 0; + await expect(makeCurrent(repo, second, { confirm: false })).rejects.toThrow( + LatestUpdateError, + ); + expect(delivery.log).toEqual([]); + expect((await readLatest(delivery.store, "acme"))?.revision).toBe(first); + }); + + it("warns when the target's schema differs from main's, unless confirmed", async () => { + const { git, delivery, repo, release } = setup(); + const old = await release(); + git.pushDirect({ + ".deco/schema.gen.json": SCHEMA_TEXT.replace("next.7", "next.8"), + }); + await expect(makeCurrent(repo, old, { confirm: false })).rejects.toThrow( + SchemaMismatchError, + ); + await makeCurrent(repo, old, { confirm: true }); + expect((await readLatest(delivery.store, "acme"))?.revision).toBe(old); + }); +}); diff --git a/apps/api/src/hosted/releases.ts b/apps/api/src/hosted/releases.ts new file mode 100644 index 0000000000..e2af2a3ca9 --- /dev/null +++ b/apps/api/src/hosted/releases.ts @@ -0,0 +1,137 @@ +/** + * The Releases screen: a timeline of main's commits, newest first. A commit + * may have a companion release (sites//revisions/.json, found with + * one listing of the site's revisions/ prefix per page, never per commit): + * only those can be made current. "Current" is exactly the commit latest.json + * names, read, never inferred. A commit without a companion (a developer + * push, or a Publish whose release write failed) is just a merge. + */ + +import { type RepoInsightsClient, requireBranchHead } from "@/git-providers"; +import { + deliveryKeys, + getJson, + isCommitSha, + listRevisionShas, +} from "./delivery-store"; +import { + type HostedRepo, + type LatestPointer, + readLatest, + writeLatest, +} from "./publish"; +import { NotV8Site, schemaHashAt } from "./release-objects"; + +export interface ReleaseCommit { + sha: string; + date: string; + message: string; + author: string | null; + /** Has a companion release: it can be made current. */ + hasRelease: boolean; +} + +export interface ReleasesPage { + /** What latest.json names; null when there is no latest.json. */ + current: LatestPointer | null; + commits: ReleaseCommit[]; + nextCursor: string | null; +} + +/** The commit has no companion release: there is nothing to make current. */ +export class NotPublishedError extends Error { + constructor() { + super("This commit has no release"); + this.name = "NotPublishedError"; + } +} + +/** The target's schema differs from main's and the user didn't confirm. */ +export class SchemaMismatchError extends Error { + constructor( + readonly target: string, + readonly head: string | null, + ) { + super("schema-mismatch"); + this.name = "SchemaMismatchError"; + } +} + +const PAGE_SIZE = 50; +// OPEN: O-S5 — listCommits needs `since`; the whole history, paged by cursor. +const SINCE_EPOCH = "1970-01-01T00:00:00Z"; + +async function headSchemaHashOf(repo: HostedRepo, head: string) { + try { + return await schemaHashAt(repo.client, repo.packagePath, head); + } catch (error) { + if (error instanceof NotV8Site) return null; + throw error; + } +} + +export async function listReleases( + repo: HostedRepo, + insights: RepoInsightsClient, + cursor: string | null, +): Promise { + const [current, withRelease, page] = await Promise.all([ + readLatest(repo.store, repo.site), + listRevisionShas(repo.store, repo.site), + insights.listCommits({ + ref: repo.mainBranch, + since: SINCE_EPOCH, + cursor, + limit: PAGE_SIZE, + }), + ]); + return { + current, + commits: page.items.map((commit) => ({ + sha: commit.sha, + date: commit.date, + message: commit.message, + author: commit.author.name ?? commit.author.login, + hasRelease: withRelease.has(commit.sha), + })), + nextCursor: page.nextCursor, + }; +} + +/** + * "Make current": point latest.json at a commit's companion release and purge + * it. It succeeds or throws (`LatestUpdateError` on a failed write or purge, + * nothing restored); the screen then shows what latest.json says. + */ +export async function makeCurrent( + repo: HostedRepo, + sha: string, + options: { confirm: boolean }, +): Promise { + if (!isCommitSha(sha)) throw new NotPublishedError(); + // OPEN: O-S7 — the companion is read for its schemaHash (no R2 metadata + // field); a missing one means there is nothing to make current. + const object = await getJson( + repo.store, + deliveryKeys.revision(repo.site, sha), + ); + const schemaHash = (object?.value as { schemaHash?: unknown } | undefined) + ?.schemaHash; + if (typeof schemaHash !== "string") throw new NotPublishedError(); + if (!options.confirm) { + const head = await requireBranchHead(repo.client, repo.mainBranch); + const headSchemaHash = await headSchemaHashOf(repo, head); + if (schemaHash !== headSchemaHash) { + throw new SchemaMismatchError(schemaHash, headSchemaHash); + } + } + const pointer: LatestPointer = { + revision: sha, + schemaHash, + // Every latest.json write is stamped now: the SDK prefers the CDN only + // when publishedAt is later than its bundle's build time. + publishedAt: new Date().toISOString(), + }; + await writeLatest(repo, pointer); + return pointer; +} diff --git a/apps/api/src/hosted/scope.ts b/apps/api/src/hosted/scope.ts new file mode 100644 index 0000000000..3393ae8a8e --- /dev/null +++ b/apps/api/src/hosted/scope.ts @@ -0,0 +1,61 @@ +/** + * Which projects get the hosted Deco CMS, and the pieces a hosted request + * needs. Hosted is for Blocks v8 sites on GitHub only (main's + * `.deco/schema.gen.json` has `"blocksMajor": 8`), behind the + * `site_editor_content_protocol` org flag; v7 sites never reach it. + */ + +import { isValidSiteSlug } from "@decocms/shared/site-slug"; +import type { RepoContentClient } from "@/git-providers"; +import type { KVStorage } from "@/storage/kv"; +import type { OrgSiteStoragePort } from "@/storage/ports"; +import { deliveryStore } from "./delivery-store"; +import { createDraftStore, type DraftStore } from "./draft-store"; +import { schemaHashOfText } from "./release-objects"; + +/** + * The site id a project names in `metadata.siteSlug` (what it was created + * with). Ownership is the `org_sites` link: see {@link ownedProjectSite}. + */ +export function projectSite( + metadata: Record | null | undefined, +): string | null { + const slug = metadata?.siteSlug; + return typeof slug === "string" && isValidSiteSlug(slug) ? slug : null; +} + +/** + * The project's site id: the `org_sites` row linked to it, only when its own + * organization owns that row. The link is set once, when the project is + * created or imported (hosted/claim-site.ts, the deco import, the admin + * backfill), and never changes, so every hosted write (delivery objects, + * drafts, site tokens) keys on it rather than on `metadata.siteSlug`. A + * project that isn't linked gets no hosted features. + */ +export async function ownedProjectSite( + orgSites: Pick, + projectId: string, + organizationId: string, +): Promise { + const site = await orgSites.getByProject(projectId); + return site && site.organizationId === organizationId ? site.slug : null; +} + +/** The draft store over the delivery bucket, or null when none is configured. */ +export function hostedDrafts(kv: KVStorage): DraftStore | null { + const store = deliveryStore(); + return store ? createDraftStore({ kv, store }) : null; +} + +/** Whether main's committed schema is a Blocks v8 one. */ +export async function mainIsV8( + client: RepoContentClient, + packagePath: string | null, + mainBranch: string, +): Promise { + const path = packagePath + ? `${packagePath}/.deco/schema.gen.json` + : ".deco/schema.gen.json"; + const text = await client.readFileAtRef(mainBranch, path); + return text !== null && (await schemaHashOfText(text)) !== null; +} diff --git a/packages/shared/src/organization/schema.ts b/packages/shared/src/organization/schema.ts index 1403d0f3dc..6deb5b8c75 100644 --- a/packages/shared/src/organization/schema.ts +++ b/packages/shared/src/organization/schema.ts @@ -277,6 +277,12 @@ export const OrgFlagsSchema = z.object({ .describe( "Use the redesigned blocks editor for every member of the organization. Off by default — the classic editor stays until an admin opts the org in.", ), + site_editor_content_protocol: z + .boolean() + .optional() + .describe( + "Edit next-major Blocks sites through the content protocol: a project with a committed schema (`.deco/schema.gen.json` or `meta.gen.json`) is edited without running its code, on GitHub or through a connected `deco serve`. Off by default — with it off, every project stays on the legacy editing path.", + ), hide_default_blog_blocks: z .boolean() .optional() diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index 846ea08606..089219a7fa 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -133,6 +133,7 @@ export interface StudioToolIO { site_create_enabled?: boolean | undefined; delivery_lanes_enabled?: boolean | undefined; new_blocks_editor?: boolean | undefined; + site_editor_content_protocol?: boolean | undefined; hide_default_blog_blocks?: boolean | undefined; } | null @@ -212,6 +213,7 @@ export interface StudioToolIO { site_create_enabled?: boolean | undefined; delivery_lanes_enabled?: boolean | undefined; new_blocks_editor?: boolean | undefined; + site_editor_content_protocol?: boolean | undefined; hide_default_blog_blocks?: boolean | undefined; } | undefined; @@ -287,6 +289,7 @@ export interface StudioToolIO { site_create_enabled?: boolean | undefined; delivery_lanes_enabled?: boolean | undefined; new_blocks_editor?: boolean | undefined; + site_editor_content_protocol?: boolean | undefined; hide_default_blog_blocks?: boolean | undefined; } | null