From 8f58d5a61805bb7e180e2bea66a3d3e5d370bd1b Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:15:21 -0400 Subject: [PATCH 01/36] test(content): gated live e2e for Builder CMS write orchestration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves the full execute orchestration (prepare→execute→reconcile) performs a safe Builder autosave end-to-end using the REAL write client — the one seam previously only unit-tested with a mocked client. Runs `executeBuilder SourceExecutionWithDeps` with real `executeBuilderCmsWrite` against the safe model, asserts the execution succeeds + reconciles and the live/published artifact is unchanged (autosaved value never goes live). Gated behind BUILDER_LIVE_E2E=1 + BUILDER_PRIVATE_KEY + BUILDER_PUBLIC_KEY; skips offline so normal CI stays green. Verified: typecheck, prettier, full guard suite, skip-mode, and a real live run all pass; cleans up its throwaway entry. Authored via Codex (GPT-5.5), reviewed + live-verified by Claude. Co-Authored-By: Claude Opus 4.8 --- ...cute-builder-source-execution.live.test.ts | 348 ++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 templates/content/actions/execute-builder-source-execution.live.test.ts diff --git a/templates/content/actions/execute-builder-source-execution.live.test.ts b/templates/content/actions/execute-builder-source-execution.live.test.ts new file mode 100644 index 0000000000..dd1f64e4df --- /dev/null +++ b/templates/content/actions/execute-builder-source-execution.live.test.ts @@ -0,0 +1,348 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + BUILDER_CMS_SAFE_WRITE_MODEL, + type ContentDatabaseResponse, + type ContentDatabaseSource, + type ContentDatabaseSourceChangeSet, +} from "../shared/api"; +import { builderCmsQualifiedId } from "./_builder-cms-source-adapter"; +import { buildBuilderCmsExecutionPlan } from "./_builder-cms-write-adapter"; +import { executeBuilderCmsWrite } from "./_builder-cms-write-client"; +import { + executeBuilderSourceExecutionWithDeps, + type BuilderSourceExecutionRecord, + type ExecuteBuilderSourceExecutionDeps, +} from "./execute-builder-source-execution"; + +// Gated live integration: when explicitly enabled, this makes real Builder +// writes against BUILDER_CMS_SAFE_WRITE_MODEL. Normal CI skips it offline. +const LIVE_BUILDER_ENABLED = + process.env.BUILDER_LIVE_E2E === "1" && + !!process.env.BUILDER_PRIVATE_KEY && + !!process.env.BUILDER_PUBLIC_KEY; + +const NOW = "2026-06-15T12:00:00.000Z"; +const DATABASE_ID = "database-live"; +const SOURCE_ID = "source-live"; +const CHANGE_SET_ID = "change-live"; +const DOCUMENT_ID = "doc-live"; +const DATABASE_ITEM_ID = "item-live"; +const AUTOSAVED_MARKER = "v2-autosaved-should-NOT-go-live"; + +const RESPONSE: ContentDatabaseResponse = { + database: { + id: DATABASE_ID, + documentId: "database-page-live", + title: "Builder live execution test", + viewConfig: { + activeViewId: "default", + views: [], + sorts: [], + filters: [], + columnWidths: {}, + }, + createdAt: NOW, + updatedAt: NOW, + }, + properties: [], + items: [], + source: null, +}; + +type DatabaseRecord = NonNullable< + Awaited> +>; +type ReconcileWriteArgs = Parameters< + ExecuteBuilderSourceExecutionDeps["reconcileWrite"] +>[0]; +type MarkExecutionSucceededArgs = Parameters< + ExecuteBuilderSourceExecutionDeps["markExecutionSucceeded"] +>[0]; +type MarkExecutionFailedArgs = Parameters< + ExecuteBuilderSourceExecutionDeps["markExecutionFailed"] +>[0]; + +const DATABASE: DatabaseRecord = { + id: DATABASE_ID, + ownerEmail: "local@localhost", + orgId: null, + documentId: "database-page-live", + title: "Builder live execution test", + viewConfigJson: "{}", + createdAt: NOW, + updatedAt: NOW, +}; + +function randomSuffix() { + return crypto.randomUUID().slice(0, 8); +} + +function requireEnv(name: "BUILDER_PRIVATE_KEY" | "BUILDER_PUBLIC_KEY") { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required for live Builder tests.`); + return value; +} + +function recordFromJson(value: unknown): Record { + expect(value).toEqual(expect.any(Object)); + expect(Array.isArray(value)).toBe(false); + return value as Record; +} + +function buildSource(args: { + entryId: string; + changeSet: ContentDatabaseSourceChangeSet; +}): ContentDatabaseSource { + return { + id: SOURCE_ID, + databaseId: DATABASE_ID, + sourceType: "builder-cms", + sourceName: "Builder CMS", + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + syncState: "idle", + freshness: "fresh", + lastRefreshedAt: null, + lastSourceUpdatedAt: null, + lastError: null, + capabilities: { + canRefresh: true, + canCreateChangeSets: true, + canWriteFields: true, + canWriteBody: true, + canPush: true, + canPull: true, + canPublish: true, + canDelete: false, + canStageLocalRevision: true, + liveWritesEnabled: true, + readOnlyRefresh: true, + }, + metadata: { + primaryKey: "id", + titleField: "data.title", + naturalKeyField: "/blog/[slug]", + pushMode: "autosave", + allowedWriteModes: ["autosave"], + }, + fields: [], + rows: [ + { + id: "row-live", + databaseItemId: DATABASE_ITEM_ID, + documentId: DOCUMENT_ID, + sourceRowId: args.entryId, + sourceQualifiedId: builderCmsQualifiedId({ + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + entryId: args.entryId, + }), + sourceDisplayKey: "Builder live execution fixture", + provenance: "Builder CMS write adapter", + syncState: "idle", + freshness: "fresh", + lastSyncedAt: NOW, + lastSourceUpdatedAt: NOW, + }, + ], + changeSets: [args.changeSet], + }; +} + +function buildChangeSet(): ContentDatabaseSourceChangeSet { + return { + id: CHANGE_SET_ID, + databaseItemId: DATABASE_ITEM_ID, + documentId: DOCUMENT_ID, + kind: "field_update", + direction: "outbound", + state: "approved", + pushMode: "autosave", + localOnly: true, + summary: "Approved local Builder marker autosave.", + fieldChanges: [ + { + propertyId: null, + propertyName: "Marker", + localFieldKey: "marker", + sourceFieldKey: "data.marker", + currentValue: "v1-live", + proposedValue: AUTOSAVED_MARKER, + }, + ], + bodyChange: null, + riskLevel: "low", + riskReasons: ["single safe-model autosave field diff"], + conflictState: "none", + reviewEvents: [], + executions: [], + createdAt: NOW, + updatedAt: NOW, + }; +} + +function buildDeps(args: { + source: ContentDatabaseSource; + execution: BuilderSourceExecutionRecord; + onSucceeded: (call: MarkExecutionSucceededArgs) => void; + onFailed: (call: MarkExecutionFailedArgs) => void; + onReconcile: (call: ReconcileWriteArgs) => void; +}): ExecuteBuilderSourceExecutionDeps { + return { + now: vi.fn(() => NOW), + resolveDatabase: vi.fn(async () => DATABASE), + assertEditor: vi.fn(async () => {}), + getSourceSnapshot: vi.fn(async () => args.source), + getExecution: vi.fn(async () => args.execution), + updateExecutionState: vi.fn(async () => {}), + claimExecution: vi.fn(async () => true), + markExecutionSucceeded: vi.fn(async (call) => { + args.onSucceeded(call); + }), + markExecutionFailed: vi.fn(async (call) => { + args.onFailed(call); + }), + executeWrite: vi.fn((call) => executeBuilderCmsWrite(call)), + reconcileWrite: vi.fn(async (call) => { + args.onReconcile(call); + }), + getResponse: vi.fn(async () => RESPONSE), + }; +} + +async function fetchLiveBuilderEntry(entryId: string) { + const url = new URL( + `https://cdn.builder.io/api/v3/content/${encodeURIComponent( + BUILDER_CMS_SAFE_WRITE_MODEL, + )}/${encodeURIComponent(entryId)}`, + ); + url.searchParams.set("apiKey", requireEnv("BUILDER_PUBLIC_KEY")); + url.searchParams.set("includeUnpublished", "true"); + url.searchParams.set("cachebust", randomSuffix()); + + const response = await fetch(url); + expect(response.ok).toBe(true); + return recordFromJson(await response.json()); +} + +describe.skipIf(!LIVE_BUILDER_ENABLED)( + "execute Builder source execution against live Builder", + () => { + let entryId: string | null = null; + + beforeAll(async () => { + const result = await executeBuilderCmsWrite({ + request: { + method: "POST", + path: `/api/v1/write/${BUILDER_CMS_SAFE_WRITE_MODEL}`, + body: { + name: `zz-pr3-e2e-${randomSuffix()}`, + published: "published", + data: { + marker: "v1-live", + }, + }, + }, + }); + + expect(result.ok).toBe(true); + expect(result.entryId).toEqual(expect.any(String)); + entryId = result.entryId ?? null; + if (!entryId) { + throw new Error("Builder did not return an entry ID for live setup."); + } + }); + + afterAll(async () => { + if (!entryId) return; + try { + await fetch( + `https://builder.io/api/v1/write/${encodeURIComponent( + BUILDER_CMS_SAFE_WRITE_MODEL, + )}/${encodeURIComponent(entryId)}`, + { + method: "DELETE", + headers: { + authorization: `Bearer ${requireEnv("BUILDER_PRIVATE_KEY")}`, + }, + }, + ); + } catch { + // Best-effort cleanup only; the assertion path above owns test failure. + } + }); + + it("executes a prepared autosave plan and leaves published content unchanged", async () => { + if (!entryId) throw new Error("Live Builder entry was not created."); + + const changeSet = buildChangeSet(); + const source = buildSource({ entryId, changeSet }); + const plan = buildBuilderCmsExecutionPlan({ + source, + changeSet, + pushModeConfirmation: "autosave", + }); + expect(plan.state).toBe("ready"); + + const execution: BuilderSourceExecutionRecord = { + id: "execution-live", + state: "ready", + idempotencyKey: plan.idempotencyKey, + payloadJson: JSON.stringify(plan.payload), + updatedAt: NOW, + }; + + let succeededCall: MarkExecutionSucceededArgs | null = null; + let failedCall: MarkExecutionFailedArgs | null = null; + let reconcileCall: ReconcileWriteArgs | null = null; + const deps = buildDeps({ + source, + execution, + onSucceeded: (call) => { + succeededCall = call; + }, + onFailed: (call) => { + failedCall = call; + }, + onReconcile: (call) => { + reconcileCall = call; + }, + }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: DATABASE_ID, + changeSetId: CHANGE_SET_ID, + pushModeConfirmation: "autosave", + }, + deps, + ), + ).resolves.toBe(RESPONSE); + + expect(succeededCall).toEqual( + expect.objectContaining({ + executionId: execution.id, + changeSetId: changeSet.id, + summary: "Builder autosave execution succeeded.", + }), + ); + expect(failedCall).toBeNull(); + expect(reconcileCall).toEqual( + expect.objectContaining({ + database: DATABASE, + source, + changeSet, + plan, + writeResult: expect.objectContaining({ + ok: true, + }), + now: NOW, + }), + ); + + const liveEntry = await fetchLiveBuilderEntry(entryId); + const liveData = recordFromJson(liveEntry.data); + expect(liveEntry.published).toBe("published"); + expect(liveData.marker).toBe("v1-live"); + }); + }, +); From 881802a2f127cdae2ebaa329a6fbc55bfc6b7f8f Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:25:49 -0400 Subject: [PATCH 02/36] feat(content): discoverable enable-live-writes toggle on Builder source Adds a guarded autosave-only toggle next to the Builder source status badge so an editor can turn on live writes without the agent/CLI. Reuses the existing `builderSourceLiveWriteControlState` helper and the file's existing toggle styling; calls the already-wired `onSetBuilderLiveWrites` callback. Guard rails: shown only for the safe write model (a muted hint explains why it's absent on other Builder models), disabled on `!canEdit || sourceActionPending`, autosave-only (enforced by the callback + server gates). role="switch" + aria-checked for a11y. UI-only; no server/action changes. Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier, 81-test component suite, full guard suite). Browser visual pass owed. Co-Authored-By: Claude Opus 4.8 --- .../components/editor/DocumentDatabase.tsx | 77 ++++++++++++++++--- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/templates/content/app/components/editor/DocumentDatabase.tsx b/templates/content/app/components/editor/DocumentDatabase.tsx index cc649a0b1b..ecd9c6c147 100644 --- a/templates/content/app/components/editor/DocumentDatabase.tsx +++ b/templates/content/app/components/editor/DocumentDatabase.tsx @@ -4091,6 +4091,11 @@ function DatabaseSettingsSourcePanel({ } // Attached model → the minimal read-only leaf panel. + const liveWriteControl = builderSourceLiveWriteControlState(source); + const builderLiveWritesEnabled = + liveWriteControl.safeTarget && liveWriteControl.enabled; + const builderLiveWritesDisabled = !canEdit || sourceActionPending; + return (
<> @@ -4100,23 +4105,73 @@ function DatabaseSettingsSourcePanel({ {source.sourceName} {isBuilderSource ? ( - source.capabilities.liveWritesEnabled ? ( - - - Live writes on - - ) : ( - - - Read-only - - ) +
+ {builderLiveWritesEnabled ? ( + + + Live writes on + + ) : ( + + + Read-only + + )} + {liveWriteControl.safeTarget ? ( + + ) : null} +
) : ( {source.syncState} )}
+ {isBuilderSource && !liveWriteControl.safeTarget ? ( +
+ Live writes are only available for the Agent Native test + collection. +
+ ) : null}
{builderSyncFailed ? ( - ) : null} -
+ !liveWriteControl.safeTarget ? ( + + + Read-only + + ) : null ) : ( {source.syncState} @@ -4199,6 +4150,43 @@ function DatabaseSettingsSourcePanel({ `Local snapshot · ${source.freshness}` )} + {isBuilderSource && liveWriteControl.safeTarget ? ( +
+ Enable live writes (autosave) + +
+ ) : null} {reviewableBuilderChangeSets.length > 0 || From b856993d9509d49bab6941c232c5ecf754e10191 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:08:01 -0400 Subject: [PATCH 05/36] feat(content): publication-state effect model for Builder writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the state-blind autosave/draft/publish intents with a typed write effect that makes state-preservation structural: - autosave / update_in_place send only changed data.* and NO `published` field (Builder PATCH merges data.* and preserves omitted publication state — proven against real Builder), so they cannot change publication state by construction. - publish / unpublish are the ONLY effects that set `published`, gated behind an explicit per-item transition intent (publish) / confirmation (unpublish). - create_draft writes new entries as draft. update_in_place/publish/unpublish trigger webhooks (live-affecting); autosave stays quiet. Dry-run staleness now compares `effect`. Legacy non-autosave push modes reinterpret as update_in_place (no more blind publish/draft). Also updates the source-panel layout test for the decluttered live-writes control (drops the removed "Live writes on" badge copy). Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier, guards, full suite 782 pass). Live-read classification + transitions wired next. Co-Authored-By: Claude Opus 4.8 --- .../_builder-cms-write-adapter.test.ts | 338 +++++++++++++----- .../actions/_builder-cms-write-adapter.ts | 143 ++++++-- .../editor/DocumentDatabase.layout.test.ts | 5 +- templates/content/shared/api.ts | 7 + 4 files changed, 362 insertions(+), 131 deletions(-) diff --git a/templates/content/actions/_builder-cms-write-adapter.test.ts b/templates/content/actions/_builder-cms-write-adapter.test.ts index b77bfd29b0..1cd999c319 100644 --- a/templates/content/actions/_builder-cms-write-adapter.test.ts +++ b/templates/content/actions/_builder-cms-write-adapter.test.ts @@ -108,20 +108,20 @@ describe("Builder CMS write adapter plan", () => { }); it("prepares a write-disabled execution plan by default", () => { - expect( - buildBuilderCmsExecutionPlan({ - source: source(false), - changeSet: approvedChangeSet(), - pushModeConfirmation: "autosave", - }), - ).toMatchObject({ + const plan = buildBuilderCmsExecutionPlan({ + source: source(false), + changeSet: approvedChangeSet(), + pushModeConfirmation: "autosave", + }); + + expect(plan).toMatchObject({ adapter: "builder-cms", pushMode: "autosave", state: "write_disabled", idempotencyKey: "builder-cms:source-1:change-1:autosave", payload: { sourceTable: "blog_article", - intent: "autosave_revision", + effect: "autosave", target: { entryId: "builder-entry-1", }, @@ -153,23 +153,34 @@ describe("Builder CMS write adapter plan", () => { }, lastError: "Live Builder writes are disabled for this source.", }); + expect(plan.payload.request.body).not.toHaveProperty("published"); }); it("returns ready when live writes are configured for the safe Builder test model", () => { - expect( - buildBuilderCmsExecutionPlan({ - source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), - changeSet: approvedChangeSet(), - pushModeConfirmation: "autosave", - }), - ).toMatchObject({ + const plan = buildBuilderCmsExecutionPlan({ + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), + changeSet: approvedChangeSet(), + pushModeConfirmation: "autosave", + }); + + expect(plan).toMatchObject({ state: "ready", summary: "Prepared Builder autosave execution. Ready to send to Builder.", payload: { + effect: "autosave", sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, request: { method: "PATCH", path: `/api/v1/write/${BUILDER_CMS_SAFE_WRITE_MODEL}/builder-entry-1`, + query: { + autoSaveOnly: "true", + triggerWebhooks: "false", + }, + body: { + data: { + title: "New title", + }, + }, }, safety: { liveWritesEnabled: true, @@ -179,6 +190,186 @@ describe("Builder CMS write adapter plan", () => { }, lastError: null, }); + expect(plan.payload.request.body).not.toHaveProperty("published"); + }); + + it("prepares update-in-place for existing live-write edits without a transition", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), + changeSet: { + ...approvedChangeSet(), + pushMode: "draft", + }, + pushModeConfirmation: "draft", + }); + + expect(plan).toMatchObject({ + state: "ready", + payload: { + effect: "update_in_place", + request: { + method: "PATCH", + path: `/api/v1/write/${BUILDER_CMS_SAFE_WRITE_MODEL}/builder-entry-1`, + query: { + triggerWebhooks: "true", + }, + body: { + data: { + title: "New title", + }, + }, + }, + safety: { + blockers: [], + checks: expect.arrayContaining([ + "Update in place preserves publication state — no published field is sent.", + ]), + }, + }, + }); + expect(plan.payload.request.body).not.toHaveProperty("published"); + }); + + it("prepares create-draft for new Builder entries", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: { + ...source(true, BUILDER_CMS_SAFE_WRITE_MODEL), + rows: [], + }, + changeSet: { + ...approvedChangeSet(), + pushMode: "draft", + }, + pushModeConfirmation: "draft", + }); + + expect(plan).toMatchObject({ + state: "ready", + payload: { + effect: "create_draft", + target: { + entryId: null, + }, + request: { + method: "POST", + path: `/api/v1/write/${BUILDER_CMS_SAFE_WRITE_MODEL}`, + query: { + triggerWebhooks: "false", + }, + body: { + data: { + title: "New title", + }, + published: "draft", + }, + }, + safety: { + blockers: [], + }, + }, + }); + }); + + it("prepares explicit publish transitions", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), + changeSet: { + ...approvedChangeSet(), + pushMode: "draft", + }, + pushModeConfirmation: "draft", + publicationTransition: "publish", + }); + + expect(plan).toMatchObject({ + state: "ready", + payload: { + effect: "publish", + request: { + method: "PATCH", + path: `/api/v1/write/${BUILDER_CMS_SAFE_WRITE_MODEL}/builder-entry-1`, + query: { + triggerWebhooks: "true", + }, + body: { + data: { + title: "New title", + }, + published: "published", + }, + }, + safety: { + blockers: [], + }, + }, + }); + }); + + it("blocks unpublish transitions without explicit confirmation", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), + changeSet: { + ...approvedChangeSet(), + pushMode: "draft", + }, + pushModeConfirmation: "draft", + publicationTransition: "unpublish", + }); + + expect(plan).toMatchObject({ + state: "blocked", + lastError: "Unpublish requires explicit confirmation.", + payload: { + effect: "unpublish", + request: { + method: "PATCH", + query: { + triggerWebhooks: "true", + }, + body: { + data: { + title: "New title", + }, + published: "draft", + }, + }, + safety: { + blockers: ["Unpublish requires explicit confirmation."], + }, + }, + }); + }); + + it("prepares confirmed unpublish transitions", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), + changeSet: { + ...approvedChangeSet(), + pushMode: "draft", + }, + pushModeConfirmation: "draft", + publicationTransition: "unpublish", + confirmUnpublish: true, + }); + + expect(plan).toMatchObject({ + state: "ready", + payload: { + effect: "unpublish", + request: { + method: "PATCH", + query: { + triggerWebhooks: "true", + }, + body: { + published: "draft", + }, + }, + safety: { + blockers: [], + }, + }, + }); }); it("encodes Builder write path segments", () => { @@ -203,7 +394,7 @@ describe("Builder CMS write adapter plan", () => { ); }); - it("blocks live autosave for unmatched legacy fixture-wrapped Builder rows", () => { + it("blocks live writes for unmatched legacy fixture-wrapped Builder rows", () => { const plan = buildBuilderCmsExecutionPlan({ source: { ...source(true, BUILDER_CMS_SAFE_WRITE_MODEL), @@ -234,16 +425,16 @@ describe("Builder CMS write adapter plan", () => { sourceQualifiedId: null, }, request: { - method: "PATCH", + method: "POST", path: `/api/v1/write/${BUILDER_CMS_SAFE_WRITE_MODEL}`, query: { - autoSaveOnly: "true", triggerWebhooks: "false", }, body: { data: { title: "New title", }, + published: "draft", }, }, safety: { @@ -278,116 +469,81 @@ describe("Builder CMS write adapter plan", () => { }); }); - it("blocks autosave execution when the Builder entry ID is missing", () => { + it("blocks publication transitions for Builder models outside the safe test collection", () => { expect( buildBuilderCmsExecutionPlan({ - source: { - ...source(true, BUILDER_CMS_SAFE_WRITE_MODEL), - rows: [], + source: source(true, "blog_article"), + changeSet: { + ...approvedChangeSet(), + pushMode: "draft", }, - changeSet: approvedChangeSet(), - pushModeConfirmation: "autosave", + pushModeConfirmation: "draft", + publicationTransition: "publish", }), ).toMatchObject({ state: "blocked", - lastError: "Autosave requires an existing Builder entry ID.", payload: { + effect: "publish", safety: { - blockers: ["Autosave requires an existing Builder entry ID."], + blockers: [ + `Live Builder writes are only allowed for ${BUILDER_CMS_SAFE_WRITE_MODEL}.`, + ], }, }, }); }); - it("keeps publish blocked without explicit adapter opt-in", () => { - expect( - buildBuilderCmsExecutionPlan({ - source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), - changeSet: { - ...approvedChangeSet(), - pushMode: "publish", - }, - pushModeConfirmation: "publish", - }), - ).toMatchObject({ - state: "blocked", - lastError: "Publish writes require explicit adapter opt-in.", - payload: { - intent: "publish", - request: { - body: { - data: { - title: "New title", - }, - published: "published", - }, - }, - safety: { - blockers: ["Publish writes require explicit adapter opt-in."], - }, + it("keeps legacy publish push mode state-preserving without a transition", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), + changeSet: { + ...approvedChangeSet(), + pushMode: "publish", }, + pushModeConfirmation: "publish", }); - }); - it("keeps draft blocked for existing entries without explicit adapter opt-in", () => { - expect( - buildBuilderCmsExecutionPlan({ - source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), - changeSet: { - ...approvedChangeSet(), - pushMode: "draft", - }, - pushModeConfirmation: "draft", - }), - ).toMatchObject({ - state: "blocked", - lastError: - "Draft writes require explicit adapter opt-in because draft can affect already-live content.", + expect(plan).toMatchObject({ + state: "ready", payload: { - intent: "save_draft", + effect: "update_in_place", request: { + query: { + triggerWebhooks: "true", + }, body: { data: { title: "New title", }, - published: "draft", }, }, }, }); + expect(plan.payload.request.body).not.toHaveProperty("published"); }); - it("keeps draft blocked for new entries without explicit adapter opt-in", () => { + it("keeps body diffs and empty field operations blocked", () => { const plan = buildBuilderCmsExecutionPlan({ - source: { - ...source(true, BUILDER_CMS_SAFE_WRITE_MODEL), - rows: [], - }, + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), changeSet: { ...approvedChangeSet(), - pushMode: "draft", + fieldChanges: [], + bodyChange: { + summary: "Body changed", + currentExcerpt: "Old", + proposedExcerpt: "New", + }, }, - pushModeConfirmation: "draft", + pushModeConfirmation: "autosave", }); expect(plan).toMatchObject({ state: "blocked", - lastError: - "Draft writes require explicit adapter opt-in because draft can affect already-live content.", payload: { - intent: "save_draft", - target: { - entryId: null, - }, - request: { - method: "POST", - body: { - published: "draft", - }, - }, safety: { blockers: [ - "Draft writes require explicit adapter opt-in because draft can affect already-live content.", + "No field operations are available for this Builder change.", + "Builder body diffs are not executable in this slice.", ], }, }, @@ -469,7 +625,7 @@ describe("Builder CMS write adapter plan", () => { const payload = validateBuilderCmsExecutionDryRun({ storedPayload: { - intent: plan.payload.intent, + effect: plan.payload.effect, target: plan.payload.target, operations: plan.payload.operations, }, @@ -498,7 +654,7 @@ describe("Builder CMS write adapter plan", () => { expect( validateBuilderCmsExecutionDryRun({ storedPayload: { - intent: plan.payload.intent, + effect: plan.payload.effect, target: plan.payload.target, operations: plan.payload.operations, }, diff --git a/templates/content/actions/_builder-cms-write-adapter.ts b/templates/content/actions/_builder-cms-write-adapter.ts index bc5b966b24..69bcbed28e 100644 --- a/templates/content/actions/_builder-cms-write-adapter.ts +++ b/templates/content/actions/_builder-cms-write-adapter.ts @@ -1,4 +1,5 @@ import type { + BuilderCmsPublicationTransitionIntent, ContentDatabaseSource, ContentDatabaseSourceChangeSet, ContentDatabaseSourceExecutionState, @@ -7,10 +8,12 @@ import type { import { BUILDER_CMS_SAFE_WRITE_MODEL as SAFE_WRITE_MODEL } from "../shared/api.js"; import { builderCmsSourceRowIdentityState } from "./_builder-cms-source-adapter.js"; -export type BuilderCmsWriteIntent = - | "autosave_revision" - | "save_draft" - | "publish"; +export type BuilderCmsWriteEffect = + | "autosave" + | "update_in_place" + | "create_draft" + | "publish" + | "unpublish"; export interface BuilderCmsExecutionOperation { sourceFieldKey: string; @@ -24,7 +27,7 @@ export interface BuilderCmsExecutionPayload { sourceTable: string; changeSetId: string; pushMode: ContentDatabaseSourcePushMode; - intent: BuilderCmsWriteIntent; + effect: BuilderCmsWriteEffect; target: { model: string; entryId: string | null; @@ -71,12 +74,16 @@ export function builderCmsExecutionIdempotencyKey(args: { return `builder-cms:${args.sourceId}:${args.changeSetId}:${args.pushMode}`; } -function builderIntentForPushMode( - pushMode: ContentDatabaseSourcePushMode, -): BuilderCmsWriteIntent { - if (pushMode === "draft") return "save_draft"; - if (pushMode === "publish") return "publish"; - return "autosave_revision"; +function builderEffectForWrite(args: { + pushMode: ContentDatabaseSourcePushMode; + entryId: string | null; + publicationTransition?: BuilderCmsPublicationTransitionIntent | null; +}): BuilderCmsWriteEffect { + if (!args.entryId) return "create_draft"; + if (args.publicationTransition === "publish") return "publish"; + if (args.publicationTransition === "unpublish") return "unpublish"; + if (args.pushMode === "autosave") return "autosave"; + return "update_in_place"; } function nestedBuilderPatch( @@ -98,15 +105,15 @@ function nestedBuilderPatch( return body; } -function builderRequestForIntent(args: { - intent: BuilderCmsWriteIntent; +function builderRequestForEffect(args: { + effect: BuilderCmsWriteEffect; model: string; entryId: string | null; bodyPatch: Record; }): BuilderCmsExecutionPayload["request"] { const entryPath = args.entryId ? `/${encodeURIComponent(args.entryId)}` : ""; const basePath = `/api/v1/write/${encodeURIComponent(args.model)}${entryPath}`; - if (args.intent === "autosave_revision") { + if (args.effect === "autosave") { return { method: "PATCH", path: basePath, @@ -117,12 +124,22 @@ function builderRequestForIntent(args: { body: args.bodyPatch, }; } - if (args.intent === "publish") { + if (args.effect === "update_in_place") { + return { + method: "PATCH", + path: basePath, + query: { + triggerWebhooks: "true", + }, + body: args.bodyPatch, + }; + } + if (args.effect === "publish") { return { method: args.entryId ? "PATCH" : "POST", path: basePath, query: { - triggerWebhooks: "false", + triggerWebhooks: "true", }, body: { ...args.bodyPatch, @@ -130,8 +147,21 @@ function builderRequestForIntent(args: { }, }; } + if (args.effect === "unpublish") { + return { + method: "PATCH", + path: basePath, + query: { + triggerWebhooks: "true", + }, + body: { + ...args.bodyPatch, + published: "draft", + }, + }; + } return { - method: args.entryId ? "PATCH" : "POST", + method: "POST", path: basePath, query: { triggerWebhooks: "false", @@ -147,7 +177,9 @@ function builderSafetyChecks(args: { source: ContentDatabaseSource; changeSet: ContentDatabaseSourceChangeSet; pushMode: ContentDatabaseSourcePushMode; - intent: BuilderCmsWriteIntent; + effect: BuilderCmsWriteEffect; + publicationTransition?: BuilderCmsPublicationTransitionIntent | null; + confirmUnpublish?: boolean; entryId: string | null; syntheticFixtureTarget: boolean; operations: BuilderCmsExecutionOperation[]; @@ -155,7 +187,6 @@ function builderSafetyChecks(args: { const checks = [ "Requires explicit approval before execution.", "Uses the stored execution idempotency key.", - "Does not run while live Builder writes are disabled.", ]; const blockers: string[] = []; @@ -165,28 +196,41 @@ function builderSafetyChecks(args: { if (args.changeSet.bodyChange) { blockers.push("Builder body diffs are not executable in this slice."); } - if (args.intent === "autosave_revision") { - checks.push("Autosave keeps published state unchanged."); + if (args.effect === "autosave" || args.effect === "update_in_place") { + const label = args.effect === "autosave" ? "Autosave" : "Update in place"; + checks.push( + `${label} preserves publication state — no published field is sent.`, + ); if (args.syntheticFixtureTarget) { blockers.push( "This row is not matched to a Builder entry yet. Refresh or match a Builder row before pushing.", ); } else if (!args.entryId) { - blockers.push("Autosave requires an existing Builder entry ID."); + blockers.push(`${label} requires an existing Builder entry ID.`); } } - if (args.intent === "save_draft") { - checks.push("Draft writes set Builder published state to draft."); - if (args.source.metadata.allowDraftWrites !== true) { + if (args.effect === "create_draft") { + checks.push( + "Create draft writes a new Builder entry with published state set to draft.", + ); + if (args.syntheticFixtureTarget) { blockers.push( - "Draft writes require explicit adapter opt-in because draft can affect already-live content.", + "This row is not matched to a Builder entry yet. Refresh or match a Builder row before pushing.", ); } } - if (args.intent === "publish") { - checks.push("Publish writes set Builder published state to published."); - if (args.source.metadata.allowPublishWrites !== true) { - blockers.push("Publish writes require explicit adapter opt-in."); + if (args.effect === "publish") { + checks.push( + "Publish transition sets Builder published state to published.", + ); + if (args.publicationTransition !== "publish") { + blockers.push("Publish requires an explicit publication transition."); + } + } + if (args.effect === "unpublish") { + checks.push("Unpublish transition sets Builder published state to draft."); + if (args.confirmUnpublish !== true) { + blockers.push("Unpublish requires explicit confirmation."); } } @@ -202,6 +246,18 @@ function builderSafetyChecks(args: { `Live Builder writes are only allowed for ${SAFE_WRITE_MODEL}.`, ); } + if (args.source.capabilities.liveWritesEnabled !== true) { + checks.push("Does not run while live Builder writes are disabled."); + if ( + args.effect === "update_in_place" || + args.effect === "publish" || + args.effect === "unpublish" + ) { + blockers.push( + `${args.effect} requires live Builder writes to be enabled.`, + ); + } + } return { checks, blockers }; } @@ -210,6 +266,8 @@ export function buildBuilderCmsExecutionPlan(args: { source: ContentDatabaseSource; changeSet: ContentDatabaseSourceChangeSet; pushModeConfirmation?: ContentDatabaseSourcePushMode | null; + publicationTransition?: BuilderCmsPublicationTransitionIntent | null; + confirmUnpublish?: boolean; }): BuilderCmsExecutionPlan { if (args.source.sourceType !== "builder-cms") { throw new Error("Builder execution plans require a Builder CMS source."); @@ -236,7 +294,6 @@ export function buildBuilderCmsExecutionPlan(args: { ); } - const intent = builderIntentForPushMode(pushMode); const targetRow = args.source.rows.find( (row) => @@ -254,14 +311,22 @@ export function buildBuilderCmsExecutionPlan(args: { const targetSourceQualifiedId = target?.isSyntheticFixture ? null : (target?.sourceQualifiedId ?? null); + const effect = builderEffectForWrite({ + pushMode, + entryId: targetEntryId, + publicationTransition: args.publicationTransition, + }); const operations = args.changeSet.fieldChanges.map((field) => ({ sourceFieldKey: field.sourceFieldKey, localFieldKey: field.localFieldKey, value: field.proposedValue, })); const bodyPatch = nestedBuilderPatch(operations); - const request = builderRequestForIntent({ - intent, + // State-preserving effects must not include `published` in the body. Builder + // PATCH preserves omitted publication state, so only transition/create effects + // are allowed to set it. + const request = builderRequestForEffect({ + effect, model: args.source.sourceTable, entryId: targetEntryId, bodyPatch, @@ -270,7 +335,9 @@ export function buildBuilderCmsExecutionPlan(args: { source: args.source, changeSet: args.changeSet, pushMode, - intent, + effect, + publicationTransition: args.publicationTransition, + confirmUnpublish: args.confirmUnpublish, entryId: targetEntryId, syntheticFixtureTarget: args.source.capabilities.liveWritesEnabled === true && @@ -313,7 +380,7 @@ export function buildBuilderCmsExecutionPlan(args: { databaseId: args.source.databaseId, sourceTable: args.source.sourceTable, changeSetId: args.changeSet.id, - intent, + effect, target: { model: args.source.sourceTable, entryId: targetEntryId, @@ -384,9 +451,9 @@ export function validateBuilderCmsExecutionDryRun(args: { "Stored Builder operations no longer match the approved change.", ); } - if (storedComparable.intent !== planComparable.intent) { + if (storedComparable.effect !== planComparable.effect) { mismatches.push( - "Stored Builder intent no longer matches the approved push mode.", + "Stored Builder effect no longer matches the approved write mode.", ); } if ( @@ -414,7 +481,7 @@ export function validateBuilderCmsExecutionDryRun(args: { validatedAt: args.now, checks: [ "Rebuilt execution plan from current source state.", - "Compared request, operations, intent, and target against stored gate.", + "Compared request, operations, effect, and target against stored gate.", "No Builder API call was made.", ], mismatches, diff --git a/templates/content/app/components/editor/DocumentDatabase.layout.test.ts b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts index b3a7cbe594..37a586fb76 100644 --- a/templates/content/app/components/editor/DocumentDatabase.layout.test.ts +++ b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts @@ -162,9 +162,10 @@ describe("document database layout", () => { it("reduces the connected source panel to read-only status plus a diff slot", () => { const source = readDatabaseSource(); - // Read-only is the headline signal; live writes flip the same badge. + // Read-only is the headline signal for non-safe models; the safe write + // model exposes an explicit "Enable live writes" control instead of a badge. expect(source).toContain("Read-only"); - expect(source).toContain("Live writes on"); + expect(source).toContain("Enable live writes (autosave)"); // The dormant diff slot is the single push-review entry point. expect(source).toContain("Review diff"); // A failed sync surfaces inline instead of silently going stale. diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index a2c3fc5e3a..6f8c960290 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -345,6 +345,7 @@ export type ContentDatabaseSourcePushMode = | "autosave" | "draft" | "publish"; +export type BuilderCmsPublicationTransitionIntent = "publish" | "unpublish"; export const BUILDER_CMS_SAFE_WRITE_MODEL = "agent-native-blog-article-test"; export type ContentDatabaseSourceChangeDirection = "outbound"; export type ContentDatabaseSourceChangeState = @@ -739,6 +740,8 @@ export interface PrepareBuilderSourceExecutionRequest { documentId?: string; changeSetId: string; pushModeConfirmation?: ContentDatabaseSourcePushMode; + publicationTransition?: BuilderCmsPublicationTransitionIntent; + confirmUnpublish?: boolean; } export interface ValidateBuilderSourceExecutionRequest { @@ -754,6 +757,8 @@ export interface ExecuteBuilderSourceExecutionRequest { changeSetId: string; idempotencyKey?: string; pushModeConfirmation?: ContentDatabaseSourcePushMode; + publicationTransition?: BuilderCmsPublicationTransitionIntent; + confirmUnpublish?: boolean; } export interface SetContentDatabaseSourceWriteModeRequest { @@ -769,6 +774,8 @@ export interface PrepareBuilderSourceReviewRequest { databaseId?: string; documentId?: string; pushModeConfirmation?: ContentDatabaseSourcePushMode; + publicationTransition?: BuilderCmsPublicationTransitionIntent; + confirmUnpublish?: boolean; } export interface ContentDatabaseSourceReviewRowSummary { From 59b682462965f26637f4a589a2e5ab1b0e5dff0e Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:16:40 -0400 Subject: [PATCH 06/36] feat(content): live preflight read + stale/transition guard before Builder writes Adds the "re-check at write time" capability that didn't exist (the dry-run only compared a local snapshot). Before any live-affecting write (update_in_place / publish / unpublish), reads the target entry's current Builder state via a new readBuilderCmsEntryLiveState and blocks before claim/write on: - missing entry (deleted in Builder), - stale: live lastUpdated != the row's lastSourceUpdatedAt baseline (someone edited it since the diff was approved), - transition mismatch: publish on a non-draft, or unpublish on a non-published. autosave/create_draft skip the preflight. Threads publicationTransition / confirmUnpublish through the action schema into the plan. Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier, guards, full suite 789 pass). NOTE for live-test phase: must verify the lastUpdated format matches across the sync baseline and the live read (epoch vs ISO) so update_in_place doesn't falsely block. Co-Authored-By: Claude Opus 4.8 --- .../actions/_builder-cms-read-client.ts | 125 ++++++++ .../execute-builder-source-execution.test.ts | 268 ++++++++++++++++++ .../execute-builder-source-execution.ts | 114 ++++++++ 3 files changed, 507 insertions(+) diff --git a/templates/content/actions/_builder-cms-read-client.ts b/templates/content/actions/_builder-cms-read-client.ts index bb1f12a3b9..b397fed314 100644 --- a/templates/content/actions/_builder-cms-read-client.ts +++ b/templates/content/actions/_builder-cms-read-client.ts @@ -18,6 +18,13 @@ export interface BuilderCmsReadResult { message: string | null; } +export interface BuilderCmsEntryLiveState { + exists: boolean; + published: "published" | "draft" | string | null; + lastUpdated: number | string | null; + id: string | null; +} + type FetchLike = typeof fetch; type BuilderMcpContentPart = { @@ -51,6 +58,80 @@ function entryArrayFromResponse(value: unknown) { return Array.isArray(record.results) ? record.results : []; } +function stringFromUnknown(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function stringOrNumberFromUnknown(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) return value; + return stringFromUnknown(value); +} + +function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState { + if (Array.isArray(value)) { + return value.length > 0 + ? liveStateFromBuilderEntry(value[0]) + : { + exists: false, + published: null, + lastUpdated: null, + id: null, + }; + } + if (!value || typeof value !== "object") { + return { + exists: false, + published: null, + lastUpdated: null, + id: null, + }; + } + + const record = value as Record; + if (Array.isArray(record.results)) { + return liveStateFromBuilderEntry(record.results); + } + if (Object.keys(record).length === 0) { + return { + exists: false, + published: null, + lastUpdated: null, + id: null, + }; + } + + const data = + record.data && + typeof record.data === "object" && + !Array.isArray(record.data) + ? (record.data as Record) + : {}; + const id = + stringFromUnknown(record.id) ?? + stringFromUnknown(record["@id"]) ?? + stringFromUnknown(record.uuid); + if (!id) { + return { + exists: false, + published: null, + lastUpdated: null, + id: null, + }; + } + + return { + exists: true, + published: + stringFromUnknown(record.published) ?? stringFromUnknown(data.published), + lastUpdated: + stringOrNumberFromUnknown(record.lastUpdated) ?? + stringOrNumberFromUnknown(record.updatedDate) ?? + stringOrNumberFromUnknown(record.updatedAt) ?? + stringOrNumberFromUnknown(data.updatedAt), + id, + }; +} + function readLimit(limit: number | undefined) { if (typeof limit === "number" && Number.isFinite(limit) && limit > 0) { return Math.min(Math.floor(limit), BUILDER_CMS_MAX_READ_LIMIT); @@ -505,6 +586,50 @@ async function readBuilderCmsContentEntriesViaContentApi(args: { }; } +export async function readBuilderCmsEntryLiveState(args: { + model: string; + entryId: string; + fetchImpl?: FetchLike; +}): Promise { + const publicKey = await resolveBuilderCredential("BUILDER_PUBLIC_KEY"); + if (!publicKey) { + throw new Error( + "Builder CMS live entry read skipped because BUILDER_PUBLIC_KEY is not configured.", + ); + } + + const url = new URL( + `/api/v3/content/${encodeURIComponent(args.model)}/${encodeURIComponent( + args.entryId, + )}`, + builderContentApiHost(), + ); + url.searchParams.set("apiKey", publicKey); + url.searchParams.set("includeUnpublished", "true"); + url.searchParams.set("cachebust", String(Date.now())); + + const response = await fetchBuilderContentPage({ + fetchImpl: args.fetchImpl ?? fetch, + url, + }); + if (response.status === 404) { + return { + exists: false, + published: null, + lastUpdated: null, + id: null, + }; + } + if (!response.ok) { + throw new Error( + `Builder CMS live entry read failed with HTTP ${response.status}.`, + ); + } + + const json = (await response.json()) as unknown; + return liveStateFromBuilderEntry(json); +} + export async function listBuilderCmsModels( args: { fetchImpl?: FetchLike; diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 98859fa8ea..7abe4073b4 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -9,6 +9,7 @@ import { buildBuilderCmsExecutionPlan, builderCmsExecutionIdempotencyKey, } from "./_builder-cms-write-adapter"; +import type { BuilderCmsEntryLiveState } from "./_builder-cms-read-client"; import type { BuilderCmsWriteResult } from "./_builder-cms-write-client"; import { builderCmsReconciledSourceRowPatch, @@ -164,11 +165,15 @@ function executionFor(args: { payloadJson?: string; state?: BuilderSourceExecutionRecord["state"]; updatedAt?: string; + publicationTransition?: "publish" | "unpublish"; + confirmUnpublish?: boolean; }): BuilderSourceExecutionRecord { const plan = buildBuilderCmsExecutionPlan({ source: args.source, changeSet: args.changeSet, pushModeConfirmation: args.changeSet.pushMode ?? undefined, + publicationTransition: args.publicationTransition, + confirmUnpublish: args.confirmUnpublish, }); return { id: "execution-1", @@ -184,6 +189,7 @@ function depsFor(args: { execution: BuilderSourceExecutionRecord | null; writeResult?: BuilderCmsWriteResult; claimExecution?: boolean; + readLiveEntry?: BuilderCmsEntryLiveState; }): ExecuteBuilderSourceExecutionDeps { return { now: vi.fn(() => NOW), @@ -205,6 +211,16 @@ function depsFor(args: { responseBody: { id: "builder-entry-1" }, }, ), + readLiveEntry: vi.fn(async () => + args.readLiveEntry + ? args.readLiveEntry + : { + exists: true, + published: "draft", + lastUpdated: "2026-06-08T00:00:00.000Z", + id: "builder-entry-1", + }, + ), reconcileWrite: vi.fn(async () => {}), getResponse: vi.fn(async () => RESPONSE), }; @@ -444,6 +460,258 @@ describe("execute Builder source execution", () => { expect(reconcileCallOrder).toBeLessThan(successCallOrder); }); + it("preflights update-in-place writes before claiming and then writes", async () => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ changeSets: [approvedChangeSet] }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + }); + const deps = depsFor({ source: builderSource, execution }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "draft", + }, + deps, + ), + ).resolves.toBe(RESPONSE); + + expect(deps.readLiveEntry).toHaveBeenCalledWith({ + model: BUILDER_CMS_SAFE_WRITE_MODEL, + entryId: "builder-entry-1", + }); + expect(deps.executeWrite).toHaveBeenCalledTimes(1); + const readCallOrder = vi.mocked(deps.readLiveEntry).mock + .invocationCallOrder[0]; + const claimCallOrder = vi.mocked(deps.claimExecution).mock + .invocationCallOrder[0]; + expect(readCallOrder).toBeLessThan(claimCallOrder); + }); + + it("blocks stale live entries before claiming or writing", async () => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ changeSets: [approvedChangeSet] }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: true, + published: "draft", + lastUpdated: "2026-06-09T00:00:00.000Z", + id: "builder-entry-1", + }, + }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "draft", + }, + deps, + ), + ).rejects.toThrow( + "Builder entry changed since this diff was approved; refresh and re-review.", + ); + + expect(deps.updateExecutionState).toHaveBeenCalledWith( + expect.objectContaining({ + executionId: execution.id, + state: "blocked", + lastError: + "Builder entry changed since this diff was approved; refresh and re-review.", + }), + ); + expect(deps.claimExecution).not.toHaveBeenCalled(); + expect(deps.executeWrite).not.toHaveBeenCalled(); + }); + + it("blocks missing live entries before claiming or writing", async () => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ changeSets: [approvedChangeSet] }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: false, + published: null, + lastUpdated: null, + id: null, + }, + }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "draft", + }, + deps, + ), + ).rejects.toThrow("Builder entry no longer exists; refresh the source."); + + expect(deps.updateExecutionState).toHaveBeenCalledWith( + expect.objectContaining({ + executionId: execution.id, + state: "blocked", + lastError: "Builder entry no longer exists; refresh the source.", + }), + ); + expect(deps.claimExecution).not.toHaveBeenCalled(); + expect(deps.executeWrite).not.toHaveBeenCalled(); + }); + + it("publishes draft entries after live transition preflight", async () => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ changeSets: [approvedChangeSet] }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + publicationTransition: "publish", + }); + const deps = depsFor({ source: builderSource, execution }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "draft", + publicationTransition: "publish", + }, + deps, + ), + ).resolves.toBe(RESPONSE); + + expect(deps.readLiveEntry).toHaveBeenCalledTimes(1); + expect(deps.executeWrite).toHaveBeenCalledWith({ + request: expect.objectContaining({ + body: expect.objectContaining({ published: "published" }), + }), + }); + }); + + it("blocks publish transitions when the entry is already published", async () => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ changeSets: [approvedChangeSet] }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + publicationTransition: "publish", + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: "2026-06-08T00:00:00.000Z", + id: "builder-entry-1", + }, + }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "draft", + publicationTransition: "publish", + }, + deps, + ), + ).rejects.toThrow("Entry is already published."); + + expect(deps.updateExecutionState).toHaveBeenCalledWith( + expect.objectContaining({ + executionId: execution.id, + state: "blocked", + lastError: "Entry is already published.", + }), + ); + expect(deps.claimExecution).not.toHaveBeenCalled(); + expect(deps.executeWrite).not.toHaveBeenCalled(); + }); + + it("unpublishes published entries when explicitly confirmed", async () => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ changeSets: [approvedChangeSet] }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + publicationTransition: "unpublish", + confirmUnpublish: true, + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: "2026-06-08T00:00:00.000Z", + id: "builder-entry-1", + }, + }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "draft", + publicationTransition: "unpublish", + confirmUnpublish: true, + }, + deps, + ), + ).resolves.toBe(RESPONSE); + + expect(deps.readLiveEntry).toHaveBeenCalledTimes(1); + expect(deps.executeWrite).toHaveBeenCalledWith({ + request: expect.objectContaining({ + body: expect.objectContaining({ published: "draft" }), + }), + }); + }); + + it("keeps autosave writes on the no-preflight path", async () => { + const approvedChangeSet = changeSet(); + const builderSource = source({ changeSets: [approvedChangeSet] }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + }); + const deps = depsFor({ source: builderSource, execution }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "autosave", + }, + deps, + ), + ).resolves.toBe(RESPONSE); + + expect(deps.readLiveEntry).not.toHaveBeenCalled(); + expect(deps.executeWrite).toHaveBeenCalledTimes(1); + }); + it("records and throws write failures without applying the change set", async () => { const approvedChangeSet = changeSet(); const builderSource = source({ changeSets: [approvedChangeSet] }); diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index 6f904293a0..d2fcde4c6c 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -17,6 +17,10 @@ import { type BuilderCmsWriteResult, executeBuilderCmsWrite, } from "./_builder-cms-write-client.js"; +import { + type BuilderCmsEntryLiveState, + readBuilderCmsEntryLiveState, +} from "./_builder-cms-read-client.js"; import type { BuilderCmsExecutionPayload, BuilderCmsExecutionPlan, @@ -93,6 +97,10 @@ export interface ExecuteBuilderSourceExecutionDeps { executeWrite: (args: { request: BuilderCmsExecutionPayload["request"]; }) => ReturnType; + readLiveEntry: (args: { + model: string; + entryId: string; + }) => Promise; reconcileWrite: (args: { database: DatabaseRecord; source: ContentDatabaseSource; @@ -205,6 +213,54 @@ function proposedSourceDisplayKey( : fallback; } +function sourceRowForChangeSet( + source: ContentDatabaseSource, + changeSet: ContentDatabaseSourceChangeSet, +) { + return ( + source.rows.find( + (row) => + row.documentId === changeSet.documentId || + row.databaseItemId === changeSet.databaseItemId, + ) ?? null + ); +} + +function requiresLivePreflight(effect: BuilderCmsExecutionPayload["effect"]) { + return ( + effect === "update_in_place" || + effect === "publish" || + effect === "unpublish" + ); +} + +function normalizedLiveTimestamp(value: number | string | null | undefined) { + return value === null || value === undefined ? null : String(value); +} + +function livePreflightBlockMessage(args: { + liveState: BuilderCmsEntryLiveState; + baselineLastUpdated: string | null; + effect: BuilderCmsExecutionPayload["effect"]; +}) { + if (!args.liveState.exists) { + return "Builder entry no longer exists; refresh the source."; + } + if ( + normalizedLiveTimestamp(args.liveState.lastUpdated) !== + normalizedLiveTimestamp(args.baselineLastUpdated) + ) { + return "Builder entry changed since this diff was approved; refresh and re-review."; + } + if (args.effect === "publish" && args.liveState.published !== "draft") { + return "Entry is already published."; + } + if (args.effect === "unpublish" && args.liveState.published !== "published") { + return "Entry is not currently published."; + } + return null; +} + export function builderCmsReconciledSourceRowPatch(args: { source: ContentDatabaseSource; changeSet: ContentDatabaseSourceChangeSet; @@ -425,6 +481,7 @@ function realExecutionDeps(): ExecuteBuilderSourceExecutionDeps { .where(eq(schema.contentDatabaseSourceExecutions.id, args.executionId)); }, executeWrite: (args) => executeBuilderCmsWrite(args), + readLiveEntry: (args) => readBuilderCmsEntryLiveState(args), reconcileWrite: reconcileBuilderCmsWrite, getResponse: (databaseId) => getContentDatabaseResponse(databaseId), }; @@ -494,6 +551,8 @@ export async function executeBuilderSourceExecutionWithDeps( source, changeSet, pushModeConfirmation: pushMode, + publicationTransition: args.publicationTransition, + confirmUnpublish: args.confirmUnpublish, }); const storedPayload = parsePayload(execution.payloadJson); const validatedPayload = validateBuilderCmsExecutionDryRun({ @@ -589,6 +648,53 @@ export async function executeBuilderSourceExecutionWithDeps( return deps.getResponse(database.id); } + if (requiresLivePreflight(plan.payload.effect)) { + const entryId = plan.payload.target.entryId; + if (!entryId) { + const message = "Builder entry no longer exists; refresh the source."; + await deps.updateExecutionState({ + executionId: execution.id, + state: "blocked", + summary: `${plan.summary} Execution blocked before write.`, + payload: validatedPayload, + lastError: message, + now, + }); + throw new Error(message); + } + + const liveState = await deps.readLiveEntry({ + model: plan.payload.target.model, + entryId, + }); + const targetRow = sourceRowForChangeSet(source, changeSet); + const message = livePreflightBlockMessage({ + liveState, + baselineLastUpdated: targetRow?.lastSourceUpdatedAt ?? null, + effect: plan.payload.effect, + }); + if (message) { + await deps.updateExecutionState({ + executionId: execution.id, + state: "blocked", + summary: `${plan.summary} Execution blocked before write.`, + payload: { + ...validatedPayload, + livePreflight: { + checkedAt: now, + exists: liveState.exists, + published: liveState.published, + lastUpdated: liveState.lastUpdated, + id: liveState.id, + }, + }, + lastError: message, + now, + }); + throw new Error(message); + } + } + const claimed = await deps.claimExecution({ executionId: execution.id, summary: `Running Builder ${plan.pushMode} execution.`, @@ -669,6 +775,14 @@ export default defineAction({ .enum(["autosave", "draft", "publish"]) .optional() .describe("Explicit push mode confirmation for the live write"), + publicationTransition: z + .enum(["publish", "unpublish"]) + .optional() + .describe("Explicit publication transition to validate at write time"), + confirmUnpublish: z + .boolean() + .optional() + .describe("Required explicit confirmation for unpublish transitions"), }), run: async ( args: ExecuteBuilderSourceExecutionRequest, From a58e731f4116ed995f40bf01d82dd6a762811c2a Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:34:13 -0400 Subject: [PATCH 07/36] fix(content): capture numeric Builder lastUpdated so the stale guard works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live verification caught a feature-breaking bug: Builder delivery returns `lastUpdated` as a NUMBER and stringFromRecord only accepted strings, so the synced `lastSourceUpdatedAt` baseline never captured the entry version — the Task-2 preflight then flagged EVERY update_in_place/publish/unpublish as stale. - Adapter: timestampStringFromRecord captures a numeric lastUpdated as its stringified epoch (string/ISO fallbacks kept). - Execute: toEpochMs + liveTimestampsDiffer normalize both sides to epoch-ms (number / numeric-string / ISO) before comparing; unknown values fall back to strict string compare (never silently skip the guard). - Gated live test extended with real readLiveEntry: update_in_place is NOT falsely stale-blocked + takes content live, a wrong baseline DOES block, and publish/unpublish transitions work — all against real Builder (5/5 live pass). Authored via Codex (GPT-5.5), reviewed + live-verified by Claude (typecheck, prettier, guards, offline suite, 5/5 live). Co-Authored-By: Claude Opus 4.8 --- .../_builder-cms-source-adapter.test.ts | 30 +++ .../actions/_builder-cms-source-adapter.ts | 24 +- ...cute-builder-source-execution.live.test.ts | 212 ++++++++++++++++++ .../execute-builder-source-execution.test.ts | 23 +- .../execute-builder-source-execution.ts | 37 ++- 5 files changed, 315 insertions(+), 11 deletions(-) diff --git a/templates/content/actions/_builder-cms-source-adapter.test.ts b/templates/content/actions/_builder-cms-source-adapter.test.ts index e14635ad99..9512774e07 100644 --- a/templates/content/actions/_builder-cms-source-adapter.test.ts +++ b/templates/content/actions/_builder-cms-source-adapter.test.ts @@ -205,6 +205,36 @@ describe("Builder CMS source adapter", () => { }); }); + it("uses numeric Builder lastUpdated as the row source baseline", () => { + const lastUpdated = 1782328870774; + const entry = normalizeBuilderCmsApiEntry( + { + id: "entry-numeric-last-updated", + lastUpdated, + data: { + title: "Numeric timestamp entry", + url: "/blog/numeric-timestamp-entry", + }, + }, + "blog_article", + ); + + if (!entry) throw new Error("Expected Builder entry to normalize."); + expect(entry.updatedAt).toBe(String(lastUpdated)); + expect(entry.sourceValues.lastUpdated).toBe(String(lastUpdated)); + expect( + builderCmsSourceRowIdentity({ + item: item("Local title"), + sourceTable: "blog_article", + now: "2026-06-08T12:30:00.000Z", + entry, + }), + ).toMatchObject({ + sourceRowId: "entry-numeric-last-updated", + lastSourceUpdatedAt: String(lastUpdated), + }); + }); + it("renders Builder reference fields as readable labels, not raw JSON", () => { const result = normalizeBuilderCmsApiEntry( { diff --git a/templates/content/actions/_builder-cms-source-adapter.ts b/templates/content/actions/_builder-cms-source-adapter.ts index ff76e75b72..bde6f0a412 100644 --- a/templates/content/actions/_builder-cms-source-adapter.ts +++ b/templates/content/actions/_builder-cms-source-adapter.ts @@ -196,6 +196,22 @@ function stringFromRecord( return null; } +function timestampStringFromRecord( + value: Record, + keys: string[], +): string | null { + for (const key of keys) { + const candidate = value[key]; + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return String(candidate); + } + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } + return null; +} + function pickStringField( obj: Record, keys: string[], @@ -317,8 +333,12 @@ export function normalizeBuilderCmsApiEntry( stringFromRecord(data, ["url", "urlPath", "path"]) ?? (slug ? `/blog/${slug.replace(/^\/+/, "")}` : `/blog/${id}`); const updatedAt = - stringFromRecord(record, ["lastUpdated", "updatedDate", "updatedAt"]) ?? - stringFromRecord(data, ["updatedAt"]) ?? + timestampStringFromRecord(record, [ + "lastUpdated", + "updatedDate", + "updatedAt", + ]) ?? + timestampStringFromRecord(data, ["updatedAt"]) ?? new Date().toISOString(); return { diff --git a/templates/content/actions/execute-builder-source-execution.live.test.ts b/templates/content/actions/execute-builder-source-execution.live.test.ts index dd1f64e4df..d2bb583556 100644 --- a/templates/content/actions/execute-builder-source-execution.live.test.ts +++ b/templates/content/actions/execute-builder-source-execution.live.test.ts @@ -8,6 +8,7 @@ import { import { builderCmsQualifiedId } from "./_builder-cms-source-adapter"; import { buildBuilderCmsExecutionPlan } from "./_builder-cms-write-adapter"; import { executeBuilderCmsWrite } from "./_builder-cms-write-client"; +import { readBuilderCmsEntryLiveState } from "./_builder-cms-read-client"; import { executeBuilderSourceExecutionWithDeps, type BuilderSourceExecutionRecord, @@ -201,6 +202,12 @@ function buildDeps(args: { args.onFailed(call); }), executeWrite: vi.fn((call) => executeBuilderCmsWrite(call)), + readLiveEntry: vi.fn(async () => ({ + exists: true, + published: "draft", + lastUpdated: NOW, + id: args.source.rows[0]?.sourceRowId ?? "builder-entry-1", + })), reconcileWrite: vi.fn(async (call) => { args.onReconcile(call); }), @@ -346,3 +353,208 @@ describe.skipIf(!LIVE_BUILDER_ENABLED)( }); }, ); + +// Publication-state effects against live Builder, using the REAL readLiveEntry +// preflight. The baseline is derived from the seeded entry's real `lastUpdated` +// (a NUMBER from delivery), which locks down the stale-guard format fix: +// update_in_place must NOT be falsely blocked, and a wrong baseline MUST block. +describe.skipIf(!LIVE_BUILDER_ENABLED)( + "Builder publication-state effects against live Builder", + () => { + const seededIds: string[] = []; + + async function seedEntry( + published: "published" | "draft", + marker: string, + ): Promise<{ entryId: string; baselineLastUpdated: string | null }> { + const created = await executeBuilderCmsWrite({ + request: { + method: "POST", + path: `/api/v1/write/${BUILDER_CMS_SAFE_WRITE_MODEL}`, + query: { triggerWebhooks: "false" }, + body: { + name: `zz-pr3-eff-${randomSuffix()}`, + published, + data: { marker }, + }, + }, + }); + if (!created.entryId) throw new Error("Failed to seed live entry."); + seededIds.push(created.entryId); + // Derive the staleness baseline exactly as a real sync would observe it: + // the live numeric lastUpdated, stringified. + const live = await readBuilderCmsEntryLiveState({ + model: BUILDER_CMS_SAFE_WRITE_MODEL, + entryId: created.entryId, + }); + const baselineLastUpdated = + live.lastUpdated === null || live.lastUpdated === undefined + ? null + : String(live.lastUpdated); + return { entryId: created.entryId, baselineLastUpdated }; + } + + afterAll(async () => { + for (const id of seededIds) { + await fetch( + `https://builder.io/api/v1/write/${encodeURIComponent( + BUILDER_CMS_SAFE_WRITE_MODEL, + )}/${encodeURIComponent(id)}`, + { + method: "DELETE", + headers: { + authorization: `Bearer ${requireEnv("BUILDER_PRIVATE_KEY")}`, + }, + }, + ).catch(() => {}); + } + }); + + async function runEffect(opts: { + entryId: string; + pushMode: "autosave" | "draft" | "publish"; + allowedWriteModes: ("autosave" | "draft" | "publish")[]; + baselineLastUpdated: string | null; + marker: string; + publicationTransition?: "publish" | "unpublish"; + confirmUnpublish?: boolean; + }) { + const changeSet: ContentDatabaseSourceChangeSet = { + ...buildChangeSet(), + pushMode: opts.pushMode, + fieldChanges: [ + { + propertyId: null, + propertyName: "Marker", + localFieldKey: "marker", + sourceFieldKey: "data.marker", + currentValue: null, + proposedValue: opts.marker, + }, + ], + }; + const source = buildSource({ entryId: opts.entryId, changeSet }); + source.metadata = { + ...source.metadata, + pushMode: opts.pushMode, + allowedWriteModes: opts.allowedWriteModes, + }; + source.rows[0]!.lastSourceUpdatedAt = opts.baselineLastUpdated; + + const plan = buildBuilderCmsExecutionPlan({ + source, + changeSet, + pushModeConfirmation: opts.pushMode, + publicationTransition: opts.publicationTransition, + confirmUnpublish: opts.confirmUnpublish, + }); + const execution: BuilderSourceExecutionRecord = { + id: "execution-eff", + state: "ready", + idempotencyKey: plan.idempotencyKey, + payloadJson: JSON.stringify(plan.payload), + updatedAt: NOW, + }; + let succeededCall: MarkExecutionSucceededArgs | null = null; + let failedCall: MarkExecutionFailedArgs | null = null; + let wrote = false; + const deps = buildDeps({ + source, + execution, + onSucceeded: (c) => { + succeededCall = c; + }, + onFailed: (c) => { + failedCall = c; + }, + onReconcile: () => {}, + }); + // Use the REAL preflight read against live Builder. + deps.readLiveEntry = (args) => readBuilderCmsEntryLiveState(args); + const realWrite = deps.executeWrite; + deps.executeWrite = (args) => { + wrote = true; + return realWrite(args); + }; + const result = await executeBuilderSourceExecutionWithDeps( + { + databaseId: DATABASE_ID, + changeSetId: CHANGE_SET_ID, + pushModeConfirmation: opts.pushMode, + publicationTransition: opts.publicationTransition, + confirmUnpublish: opts.confirmUnpublish, + }, + deps, + ); + return { result, succeededCall, failedCall, wrote, plan }; + } + + it("update_in_place takes content live, stays published, and is NOT falsely stale-blocked", async () => { + const { entryId, baselineLastUpdated } = await seedEntry( + "published", + "before", + ); + const { wrote, failedCall } = await runEffect({ + entryId, + pushMode: "publish", // non-autosave + no transition → update_in_place + allowedWriteModes: ["publish"], + baselineLastUpdated, + marker: "after-LIVE", + }); + expect(wrote).toBe(true); + expect(failedCall).toBeNull(); + const after = await fetchLiveBuilderEntry(entryId); + expect(after.published).toBe("published"); + expect(recordFromJson(after.data).marker).toBe("after-LIVE"); + }); + + it("blocks before write when the baseline is stale (entry changed since the diff)", async () => { + const { entryId } = await seedEntry("published", "orig"); + await expect( + runEffect({ + entryId, + pushMode: "publish", + allowedWriteModes: ["publish"], + baselineLastUpdated: "1700000000000", // wrong/old → stale + marker: "should-not-write", + }), + ).rejects.toThrow(/changed since this diff/i); + const after = await fetchLiveBuilderEntry(entryId); + expect(recordFromJson(after.data).marker).toBe("orig"); + }); + + it("publish transition takes a draft to published", async () => { + const { entryId, baselineLastUpdated } = await seedEntry("draft", "d1"); + const { wrote } = await runEffect({ + entryId, + pushMode: "autosave", + allowedWriteModes: ["autosave"], + baselineLastUpdated, + marker: "d2", + publicationTransition: "publish", + }); + expect(wrote).toBe(true); + const after = await fetchLiveBuilderEntry(entryId); + expect(after.published).toBe("published"); + }); + + it("unpublish transition takes a published entry to draft (with confirmation)", async () => { + const { entryId, baselineLastUpdated } = await seedEntry( + "published", + "u1", + ); + const { wrote } = await runEffect({ + entryId, + pushMode: "autosave", + allowedWriteModes: ["autosave"], + baselineLastUpdated, + marker: "u1", + publicationTransition: "unpublish", + confirmUnpublish: true, + }); + expect(wrote).toBe(true); + const after = await fetchLiveBuilderEntry(entryId); + expect(after.published).toBe("draft"); + }); + }, +); diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 7abe4073b4..95393982fb 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -19,6 +19,8 @@ import { } from "./execute-builder-source-execution"; const NOW = "2026-06-15T12:00:00.000Z"; +const BUILDER_LAST_UPDATED_MS = 1782328870774; +const STALE_BUILDER_LAST_UPDATED_MS = 1700000000000; const RESPONSE: ContentDatabaseResponse = { database: { id: "database-1", @@ -72,7 +74,7 @@ function row( syncState: "idle", freshness: "fresh", lastSyncedAt: "2026-06-08T00:00:00.000Z", - lastSourceUpdatedAt: "2026-06-08T00:00:00.000Z", + lastSourceUpdatedAt: String(BUILDER_LAST_UPDATED_MS), ...overrides, }; } @@ -217,7 +219,7 @@ function depsFor(args: { : { exists: true, published: "draft", - lastUpdated: "2026-06-08T00:00:00.000Z", + lastUpdated: BUILDER_LAST_UPDATED_MS, id: "builder-entry-1", }, ), @@ -460,7 +462,7 @@ describe("execute Builder source execution", () => { expect(reconcileCallOrder).toBeLessThan(successCallOrder); }); - it("preflights update-in-place writes before claiming and then writes", async () => { + it("preflights update-in-place writes when string baseline matches numeric live timestamp", async () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); const builderSource = source({ changeSets: [approvedChangeSet] }); const execution = executionFor({ @@ -494,7 +496,14 @@ describe("execute Builder source execution", () => { it("blocks stale live entries before claiming or writing", async () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); - const builderSource = source({ changeSets: [approvedChangeSet] }); + const builderSource = source({ + rows: [ + row({ + lastSourceUpdatedAt: String(STALE_BUILDER_LAST_UPDATED_MS), + }), + ], + changeSets: [approvedChangeSet], + }); const execution = executionFor({ source: builderSource, changeSet: approvedChangeSet, @@ -505,7 +514,7 @@ describe("execute Builder source execution", () => { readLiveEntry: { exists: true, published: "draft", - lastUpdated: "2026-06-09T00:00:00.000Z", + lastUpdated: BUILDER_LAST_UPDATED_MS, id: "builder-entry-1", }, }); @@ -619,7 +628,7 @@ describe("execute Builder source execution", () => { readLiveEntry: { exists: true, published: "published", - lastUpdated: "2026-06-08T00:00:00.000Z", + lastUpdated: BUILDER_LAST_UPDATED_MS, id: "builder-entry-1", }, }); @@ -662,7 +671,7 @@ describe("execute Builder source execution", () => { readLiveEntry: { exists: true, published: "published", - lastUpdated: "2026-06-08T00:00:00.000Z", + lastUpdated: BUILDER_LAST_UPDATED_MS, id: "builder-entry-1", }, }); diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index d2fcde4c6c..854ad929a0 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -238,6 +238,37 @@ function normalizedLiveTimestamp(value: number | string | null | undefined) { return value === null || value === undefined ? null : String(value); } +function toEpochMs(value: number | string | null | undefined) { + if (value === null || value === undefined) return null; + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + + const trimmed = value.trim(); + if (!trimmed) return null; + + const numeric = Number(trimmed); + if (Number.isFinite(numeric)) return numeric; + + const parsed = Date.parse(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +function liveTimestampsDiffer(args: { + liveLastUpdated: number | string | null | undefined; + baselineLastUpdated: string | null | undefined; +}) { + const liveEpoch = toEpochMs(args.liveLastUpdated); + const baselineEpoch = toEpochMs(args.baselineLastUpdated); + if (liveEpoch !== null && baselineEpoch !== null) { + return liveEpoch !== baselineEpoch; + } + return ( + normalizedLiveTimestamp(args.liveLastUpdated) !== + normalizedLiveTimestamp(args.baselineLastUpdated) + ); +} + function livePreflightBlockMessage(args: { liveState: BuilderCmsEntryLiveState; baselineLastUpdated: string | null; @@ -247,8 +278,10 @@ function livePreflightBlockMessage(args: { return "Builder entry no longer exists; refresh the source."; } if ( - normalizedLiveTimestamp(args.liveState.lastUpdated) !== - normalizedLiveTimestamp(args.baselineLastUpdated) + liveTimestampsDiffer({ + liveLastUpdated: args.liveState.lastUpdated, + baselineLastUpdated: args.baselineLastUpdated, + }) ) { return "Builder entry changed since this diff was approved; refresh and re-review."; } From 2eaba918cf347452dd46ff5d9132578a63e344f1 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:54:45 -0400 Subject: [PATCH 08/36] feat(content): tiered Builder write enablement (read-only / stage / publish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the binary autosave toggle with an intentional write tier: - read_only (default; new sources start here), - stage_only → autosave revisions (human publishes in Builder), - publish_updates → state-preserving live writes (update_in_place). Publication transitions (publish a draft / unpublish a published) are an extra per-source allowance (allowPublicationTransitions) that requires publish_updates and stays per-item + confirmed. capabilities.liveWritesEnabled is now derived from writeMode; legacy pushMode/flags still work for older sources. UI: the source panel's control becomes a three-tier selector (safe model only), revealing "Allow publish/unpublish per item" at publish_updates. Effect derivation maps tier→default effect. Settings/action validate tier combinations and safe-model-only. Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier, guards, offline suite 795 pass, 5/5 live cases against real Builder incl. the new transition gate). Browser visual pass of the tier selector owed. Co-Authored-By: Claude Opus 4.8 --- .../_builder-cms-source-adapter.test.ts | 7 +- .../actions/_builder-cms-source-adapter.ts | 10 +- .../_builder-cms-write-adapter.test.ts | 136 +++++++++++-- .../actions/_builder-cms-write-adapter.ts | 62 ++++-- .../_builder-cms-write-settings.test.ts | 122 +++++++---- .../actions/_builder-cms-write-settings.ts | 168 +++++++++++---- .../content/actions/_database-source-utils.ts | 17 +- .../content-database-source-actions.test.ts | 14 ++ ...cute-builder-source-execution.live.test.ts | 1 + .../execute-builder-source-execution.test.ts | 21 +- .../set-content-database-source-write-mode.ts | 26 ++- .../editor/DocumentDatabase.layout.test.ts | 6 +- .../editor/DocumentDatabase.test.ts | 6 +- .../components/editor/DocumentDatabase.tsx | 192 +++++++++++++----- templates/content/shared/api.ts | 10 +- 15 files changed, 618 insertions(+), 180 deletions(-) diff --git a/templates/content/actions/_builder-cms-source-adapter.test.ts b/templates/content/actions/_builder-cms-source-adapter.test.ts index 9512774e07..2e1146d6f0 100644 --- a/templates/content/actions/_builder-cms-source-adapter.test.ts +++ b/templates/content/actions/_builder-cms-source-adapter.test.ts @@ -65,12 +65,15 @@ describe("Builder CMS source adapter", () => { ); }); - it("records Builder metadata with natural key and autosave push mode", () => { + it("records Builder metadata with natural key and read-only write mode", () => { expect(builderCmsSourceMetadata("blog_article")).toMatchObject({ primaryKey: "id", titleField: "data.title", naturalKeyField: "/blog/[slug]", - pushMode: "autosave", + pushMode: "none", + writeMode: "read_only", + allowPublicationTransitions: false, + allowedWriteModes: [], label: "builder.cms.blog_article", }); }); diff --git a/templates/content/actions/_builder-cms-source-adapter.ts b/templates/content/actions/_builder-cms-source-adapter.ts index bde6f0a412..18899afb1a 100644 --- a/templates/content/actions/_builder-cms-source-adapter.ts +++ b/templates/content/actions/_builder-cms-source-adapter.ts @@ -164,11 +164,13 @@ export function builderCmsSourceMetadata(sourceTable: string) { primaryKey: "id", titleField: "data.title", naturalKeyField: "/blog/[slug]", - pushMode: "autosave" as const, - pushModeLabel: "Save revision / autosave", + pushMode: "none" as const, + pushModeLabel: "No writes", pushModeDescription: - "Local-only Builder revision staging. No Builder API write runs in this slice.", - allowedWriteModes: ["autosave"], + "Read-only Builder source. Choose a write tier before any Builder API write can run.", + writeMode: "read_only" as const, + allowPublicationTransitions: false, + allowedWriteModes: [], allowDraftWrites: false, allowPublishWrites: false, notes: diff --git a/templates/content/actions/_builder-cms-write-adapter.test.ts b/templates/content/actions/_builder-cms-write-adapter.test.ts index 1cd999c319..d15a063ae2 100644 --- a/templates/content/actions/_builder-cms-write-adapter.test.ts +++ b/templates/content/actions/_builder-cms-write-adapter.test.ts @@ -13,6 +13,7 @@ import { function source( liveWritesEnabled = false, sourceTable = "blog_article", + metadata: Partial = {}, ): ContentDatabaseSource { return { id: "source-1", @@ -43,6 +44,7 @@ function source( titleField: "data.title", naturalKeyField: "/blog/[slug]", pushMode: "autosave", + ...metadata, }, fields: [], rows: [ @@ -193,6 +195,65 @@ describe("Builder CMS write adapter plan", () => { expect(plan.payload.request.body).not.toHaveProperty("published"); }); + it("derives autosave effect from stage-only write mode", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL, { + writeMode: "stage_only", + pushMode: "autosave", + allowedWriteModes: ["autosave"], + }), + changeSet: { + ...approvedChangeSet(), + pushMode: "publish", + }, + pushModeConfirmation: "autosave", + }); + + expect(plan).toMatchObject({ + state: "ready", + pushMode: "autosave", + payload: { + effect: "autosave", + request: { + query: { + autoSaveOnly: "true", + triggerWebhooks: "false", + }, + }, + }, + }); + }); + + it("derives update-in-place effect from publish-updates write mode", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL, { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + }), + changeSet: approvedChangeSet(), + pushModeConfirmation: "publish", + }); + + expect(plan).toMatchObject({ + state: "ready", + pushMode: "publish", + payload: { + effect: "update_in_place", + request: { + method: "PATCH", + query: { + triggerWebhooks: "true", + }, + }, + safety: { + blockers: [], + }, + }, + }); + expect(plan.payload.request.body).not.toHaveProperty("published"); + }); + it("prepares update-in-place for existing live-write edits without a transition", () => { const plan = buildBuilderCmsExecutionPlan({ source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), @@ -270,14 +331,48 @@ describe("Builder CMS write adapter plan", () => { }); }); - it("prepares explicit publish transitions", () => { + it("blocks publication transitions when the source has not enabled them", () => { const plan = buildBuilderCmsExecutionPlan({ - source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL, { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + allowPublicationTransitions: false, + }), changeSet: { ...approvedChangeSet(), - pushMode: "draft", + pushMode: "publish", }, - pushModeConfirmation: "draft", + pushModeConfirmation: "publish", + publicationTransition: "publish", + }); + + expect(plan).toMatchObject({ + state: "blocked", + payload: { + effect: "publish", + safety: { + blockers: [ + "Publication transitions are not enabled for this source.", + ], + }, + }, + }); + }); + + it("prepares explicit publish transitions when the source allows them", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL, { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + allowPublicationTransitions: true, + }), + changeSet: { + ...approvedChangeSet(), + pushMode: "publish", + }, + pushModeConfirmation: "publish", publicationTransition: "publish", }); @@ -307,12 +402,17 @@ describe("Builder CMS write adapter plan", () => { it("blocks unpublish transitions without explicit confirmation", () => { const plan = buildBuilderCmsExecutionPlan({ - source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL, { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + allowPublicationTransitions: true, + }), changeSet: { ...approvedChangeSet(), - pushMode: "draft", + pushMode: "publish", }, - pushModeConfirmation: "draft", + pushModeConfirmation: "publish", publicationTransition: "unpublish", }); @@ -342,12 +442,17 @@ describe("Builder CMS write adapter plan", () => { it("prepares confirmed unpublish transitions", () => { const plan = buildBuilderCmsExecutionPlan({ - source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL, { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + allowPublicationTransitions: true, + }), changeSet: { ...approvedChangeSet(), - pushMode: "draft", + pushMode: "publish", }, - pushModeConfirmation: "draft", + pushModeConfirmation: "publish", publicationTransition: "unpublish", confirmUnpublish: true, }); @@ -472,12 +577,17 @@ describe("Builder CMS write adapter plan", () => { it("blocks publication transitions for Builder models outside the safe test collection", () => { expect( buildBuilderCmsExecutionPlan({ - source: source(true, "blog_article"), + source: source(true, "blog_article", { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + allowPublicationTransitions: true, + }), changeSet: { ...approvedChangeSet(), - pushMode: "draft", + pushMode: "publish", }, - pushModeConfirmation: "draft", + pushModeConfirmation: "publish", publicationTransition: "publish", }), ).toMatchObject({ diff --git a/templates/content/actions/_builder-cms-write-adapter.ts b/templates/content/actions/_builder-cms-write-adapter.ts index 69bcbed28e..02b2f3bfed 100644 --- a/templates/content/actions/_builder-cms-write-adapter.ts +++ b/templates/content/actions/_builder-cms-write-adapter.ts @@ -4,9 +4,11 @@ import type { ContentDatabaseSourceChangeSet, ContentDatabaseSourceExecutionState, ContentDatabaseSourcePushMode, + ContentDatabaseSourceWriteMode, } from "../shared/api.js"; import { BUILDER_CMS_SAFE_WRITE_MODEL as SAFE_WRITE_MODEL } from "../shared/api.js"; import { builderCmsSourceRowIdentityState } from "./_builder-cms-source-adapter.js"; +import { builderCmsPushModeForTier } from "./_builder-cms-write-settings.js"; export type BuilderCmsWriteEffect = | "autosave" @@ -76,16 +78,29 @@ export function builderCmsExecutionIdempotencyKey(args: { function builderEffectForWrite(args: { pushMode: ContentDatabaseSourcePushMode; + writeMode?: ContentDatabaseSourceWriteMode | null; entryId: string | null; publicationTransition?: BuilderCmsPublicationTransitionIntent | null; }): BuilderCmsWriteEffect { if (!args.entryId) return "create_draft"; if (args.publicationTransition === "publish") return "publish"; if (args.publicationTransition === "unpublish") return "unpublish"; + if (args.writeMode === "stage_only") return "autosave"; + if (args.writeMode === "publish_updates") return "update_in_place"; if (args.pushMode === "autosave") return "autosave"; return "update_in_place"; } +function normalizeSourceWriteMode( + value: unknown, +): ContentDatabaseSourceWriteMode | null { + return value === "read_only" || + value === "stage_only" || + value === "publish_updates" + ? value + : null; +} + function nestedBuilderPatch( operations: BuilderCmsExecutionOperation[], ): Record { @@ -226,9 +241,15 @@ function builderSafetyChecks(args: { if (args.publicationTransition !== "publish") { blockers.push("Publish requires an explicit publication transition."); } + if (args.source.metadata.allowPublicationTransitions !== true) { + blockers.push("Publication transitions are not enabled for this source."); + } } if (args.effect === "unpublish") { checks.push("Unpublish transition sets Builder published state to draft."); + if (args.source.metadata.allowPublicationTransitions !== true) { + blockers.push("Publication transitions are not enabled for this source."); + } if (args.confirmUnpublish !== true) { blockers.push("Unpublish requires explicit confirmation."); } @@ -281,14 +302,25 @@ export function buildBuilderCmsExecutionPlan(args: { ); } - const pushMode = - args.changeSet.pushMode ?? args.source.metadata.pushMode ?? "autosave"; + const sourceWriteMode = normalizeSourceWriteMode( + args.source.metadata.writeMode, + ); + const pushMode = sourceWriteMode + ? builderCmsPushModeForTier(sourceWriteMode) + : (args.changeSet.pushMode ?? args.source.metadata.pushMode ?? "autosave"); + const effectivePushMode = pushMode === "none" ? "autosave" : pushMode; if (pushMode === "none") { - throw new Error( - "Builder execution requires Autosave, Draft, or Publish push mode.", - ); + if (args.source.capabilities.liveWritesEnabled === true) { + throw new Error( + "Builder execution requires Autosave, Draft, or Publish push mode.", + ); + } } - if (args.pushModeConfirmation && args.pushModeConfirmation !== pushMode) { + if ( + pushMode !== "none" && + args.pushModeConfirmation && + args.pushModeConfirmation !== pushMode + ) { throw new Error( `Push mode confirmation did not match approved change set: ${pushMode}.`, ); @@ -312,7 +344,8 @@ export function buildBuilderCmsExecutionPlan(args: { ? null : (target?.sourceQualifiedId ?? null); const effect = builderEffectForWrite({ - pushMode, + pushMode: effectivePushMode, + writeMode: sourceWriteMode, entryId: targetEntryId, publicationTransition: args.publicationTransition, }); @@ -334,7 +367,7 @@ export function buildBuilderCmsExecutionPlan(args: { const safety = builderSafetyChecks({ source: args.source, changeSet: args.changeSet, - pushMode, + pushMode: effectivePushMode, effect, publicationTransition: args.publicationTransition, confirmUnpublish: args.confirmUnpublish, @@ -354,14 +387,15 @@ export function buildBuilderCmsExecutionPlan(args: { const idempotencyKey = builderCmsExecutionIdempotencyKey({ sourceId: args.source.id, changeSetId: args.changeSet.id, - pushMode, + pushMode: effectivePushMode, }); + const summaryMode = pushMode === "none" ? "read-only" : pushMode; const summary = state === "ready" - ? `Prepared Builder ${pushMode} execution. Ready to send to Builder.` + ? `Prepared Builder ${summaryMode} execution. Ready to send to Builder.` : state === "blocked" - ? `Prepared Builder ${pushMode} execution, but it is blocked: ${safety.blockers.join(" ")}` - : `Prepared Builder ${pushMode} execution, but live writes are disabled.`; + ? `Prepared Builder ${summaryMode} execution, but it is blocked: ${safety.blockers.join(" ")}` + : `Prepared Builder ${summaryMode} execution, but live writes are disabled.`; const lastError = state === "ready" ? null @@ -371,7 +405,7 @@ export function buildBuilderCmsExecutionPlan(args: { return { adapter: "builder-cms", - pushMode, + pushMode: effectivePushMode, state, idempotencyKey, summary, @@ -388,7 +422,7 @@ export function buildBuilderCmsExecutionPlan(args: { documentId: args.changeSet.documentId, databaseItemId: args.changeSet.databaseItemId, }, - pushMode, + pushMode: effectivePushMode, request, operations, safety: { diff --git a/templates/content/actions/_builder-cms-write-settings.test.ts b/templates/content/actions/_builder-cms-write-settings.test.ts index 92dfb854c6..17dbeed8ea 100644 --- a/templates/content/actions/_builder-cms-write-settings.test.ts +++ b/templates/content/actions/_builder-cms-write-settings.test.ts @@ -10,10 +10,12 @@ import { sourceCapabilitiesForType } from "./_database-source-utils"; const baseMetadata = JSON.stringify({ primaryKey: "id", titleField: "data.title", - pushMode: "autosave", + pushMode: "none", + writeMode: "read_only", readMode: "builder-api", liveReadConfigured: true, - allowedWriteModes: ["autosave"], + allowedWriteModes: [], + allowPublicationTransitions: false, allowDraftWrites: false, allowPublishWrites: false, }); @@ -25,8 +27,7 @@ describe("Builder CMS write settings", () => { sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, capabilitiesJson: sourceCapabilitiesForType("builder-cms"), metadataJson: baseMetadata, - liveWritesEnabled: true, - allowedWriteModes: ["autosave"], + writeMode: "stage_only", }); expect( @@ -35,81 +36,87 @@ describe("Builder CMS write settings", () => { metadataJson: next.metadataJson, }), ).toEqual({ + writeMode: "stage_only", liveWritesEnabled: true, allowedWriteModes: ["autosave"], + allowPublicationTransitions: false, allowDraftWrites: false, allowPublishWrites: false, }); }); - it("refuses enablement when allowed modes are missing", () => { + it("derives live write capability and allowed modes from publish updates", () => { + const next = buildBuilderCmsWriteModeJson({ + sourceType: "builder-cms", + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + capabilitiesJson: sourceCapabilitiesForType("builder-cms"), + metadataJson: baseMetadata, + writeMode: "publish_updates", + allowPublicationTransitions: true, + }); + + expect( + builderCmsWriteSettingsFromJson({ + capabilitiesJson: next.capabilitiesJson, + metadataJson: next.metadataJson, + }), + ).toEqual({ + writeMode: "publish_updates", + liveWritesEnabled: true, + allowedWriteModes: ["autosave", "publish"], + allowPublicationTransitions: true, + allowDraftWrites: false, + allowPublishWrites: true, + }); + expect(JSON.parse(next.metadataJson)).toMatchObject({ + pushMode: "publish", + writeMode: "publish_updates", + allowedWriteModes: ["autosave", "publish"], + }); + }); + + it("requires publish updates before enabling publication transitions", () => { expect(() => buildBuilderCmsWriteModeJson({ sourceType: "builder-cms", sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, capabilitiesJson: sourceCapabilitiesForType("builder-cms"), metadataJson: baseMetadata, - liveWritesEnabled: true, - allowedWriteModes: [], + writeMode: "stage_only", + allowPublicationTransitions: true, }), - ).toThrow(/at least one allowed Builder write mode/); + ).toThrow("Publication transitions require publish updates mode."); }); - it("blocks live writes for non-test Builder models", () => { + it("blocks publish updates for non-test Builder models", () => { expect(() => buildBuilderCmsWriteModeJson({ sourceType: "builder-cms", sourceTable: "blog_article", capabilitiesJson: sourceCapabilitiesForType("builder-cms"), metadataJson: baseMetadata, - liveWritesEnabled: true, - allowedWriteModes: ["autosave"], + writeMode: "publish_updates", }), ).toThrow( `Live Builder writes are only allowed for ${BUILDER_CMS_SAFE_WRITE_MODEL}.`, ); }); - it("requires explicit draft and publish opt-ins", () => { - expect(() => - buildBuilderCmsWriteModeJson({ - sourceType: "builder-cms", - sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, - capabilitiesJson: sourceCapabilitiesForType("builder-cms"), - metadataJson: baseMetadata, - liveWritesEnabled: true, - allowedWriteModes: ["draft"], - }), - ).toThrow("Draft writes require explicit draft opt-in."); - - expect(() => - buildBuilderCmsWriteModeJson({ - sourceType: "builder-cms", - sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, - capabilitiesJson: sourceCapabilitiesForType("builder-cms"), - metadataJson: baseMetadata, - liveWritesEnabled: true, - allowedWriteModes: ["publish"], - }), - ).toThrow("Publish writes require explicit publish opt-in."); - }); - it("disabling clears live write eligibility and mode opt-ins", () => { const enabled = buildBuilderCmsWriteModeJson({ sourceType: "builder-cms", sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, capabilitiesJson: sourceCapabilitiesForType("builder-cms"), metadataJson: baseMetadata, - liveWritesEnabled: true, - allowedWriteModes: ["autosave"], + writeMode: "publish_updates", + allowPublicationTransitions: true, }); const disabled = buildBuilderCmsWriteModeJson({ sourceType: "builder-cms", sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, capabilitiesJson: enabled.capabilitiesJson, metadataJson: enabled.metadataJson, - liveWritesEnabled: false, - allowedWriteModes: ["draft", "publish"], + writeMode: "read_only", }); expect( @@ -118,11 +125,18 @@ describe("Builder CMS write settings", () => { metadataJson: disabled.metadataJson, }), ).toEqual({ + writeMode: "read_only", liveWritesEnabled: false, allowedWriteModes: [], + allowPublicationTransitions: false, allowDraftWrites: false, allowPublishWrites: false, }); + expect(JSON.parse(disabled.metadataJson)).toMatchObject({ + pushMode: "none", + writeMode: "read_only", + allowPublicationTransitions: false, + }); }); it("preserves explicit safe-model enablement across Builder refresh metadata", () => { @@ -131,8 +145,7 @@ describe("Builder CMS write settings", () => { sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, capabilitiesJson: sourceCapabilitiesForType("builder-cms"), metadataJson: baseMetadata, - liveWritesEnabled: true, - allowedWriteModes: ["autosave"], + writeMode: "stage_only", }); const refreshed = mergeBuilderCmsWriteSettingsIntoJson({ @@ -143,7 +156,8 @@ describe("Builder CMS write settings", () => { nextMetadataJson: JSON.stringify({ primaryKey: "id", titleField: "data.title", - pushMode: "autosave", + pushMode: "none", + writeMode: "read_only", readMode: "builder-api", liveReadConfigured: true, lastReadEntryCount: 20, @@ -157,6 +171,7 @@ describe("Builder CMS write settings", () => { metadataJson: refreshed.metadataJson, }), ).toMatchObject({ + writeMode: "stage_only", liveWritesEnabled: true, allowedWriteModes: ["autosave"], }); @@ -172,6 +187,7 @@ describe("Builder CMS write settings", () => { sourceTable: "blog_article", currentCapabilitiesJson: JSON.stringify({ liveWritesEnabled: true }), currentMetadataJson: JSON.stringify({ + writeMode: "stage_only", allowedWriteModes: ["autosave"], }), nextCapabilitiesJson: sourceCapabilitiesForType("builder-cms"), @@ -185,4 +201,26 @@ describe("Builder CMS write settings", () => { }).liveWritesEnabled, ).toBe(false); }); + + it("keeps legacy live-write requests working as stage-only", () => { + const next = buildBuilderCmsWriteModeJson({ + sourceType: "builder-cms", + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + capabilitiesJson: sourceCapabilitiesForType("builder-cms"), + metadataJson: baseMetadata, + liveWritesEnabled: true, + allowedWriteModes: ["autosave"], + }); + + expect( + builderCmsWriteSettingsFromJson({ + capabilitiesJson: next.capabilitiesJson, + metadataJson: next.metadataJson, + }), + ).toMatchObject({ + writeMode: "stage_only", + liveWritesEnabled: true, + allowedWriteModes: ["autosave"], + }); + }); }); diff --git a/templates/content/actions/_builder-cms-write-settings.ts b/templates/content/actions/_builder-cms-write-settings.ts index 0ae5328755..0d2669c5b1 100644 --- a/templates/content/actions/_builder-cms-write-settings.ts +++ b/templates/content/actions/_builder-cms-write-settings.ts @@ -2,6 +2,7 @@ import { BUILDER_CMS_SAFE_WRITE_MODEL, type ContentDatabaseSourceCapabilities, type ContentDatabaseSourcePushMode, + type ContentDatabaseSourceWriteMode, } from "../shared/api.js"; export type BuilderCmsLiveWriteMode = Exclude< @@ -14,7 +15,9 @@ export interface BuilderCmsWriteSettingsPatch { sourceTable: string; capabilitiesJson: string; metadataJson: string; - liveWritesEnabled: boolean; + liveWritesEnabled?: boolean; + writeMode?: ContentDatabaseSourceWriteMode; + allowPublicationTransitions?: boolean; allowedWriteModes?: BuilderCmsLiveWriteMode[]; allowDraftWrites?: boolean; allowPublishWrites?: boolean; @@ -40,6 +43,16 @@ function normalizeMode(value: unknown): BuilderCmsLiveWriteMode | null { : null; } +function normalizeWriteMode( + value: unknown, +): ContentDatabaseSourceWriteMode | null { + return value === "read_only" || + value === "stage_only" || + value === "publish_updates" + ? value + : null; +} + function uniqueModes( modes: readonly BuilderCmsLiveWriteMode[] | undefined, ): BuilderCmsLiveWriteMode[] { @@ -50,25 +63,100 @@ function uniqueModes( return unique; } +function legacyWriteModeFromStored(args: { + liveWritesEnabled: boolean; + allowedWriteModes: readonly BuilderCmsLiveWriteMode[]; + pushMode?: unknown; +}): ContentDatabaseSourceWriteMode { + if (!args.liveWritesEnabled) return "read_only"; + const pushMode = normalizeMode(args.pushMode); + return args.allowedWriteModes.some((mode) => mode !== "autosave") || + (pushMode && pushMode !== "autosave") + ? "publish_updates" + : "stage_only"; +} + +function writeModeFromPatch( + args: BuilderCmsWriteSettingsPatch, +): ContentDatabaseSourceWriteMode { + const explicit = normalizeWriteMode(args.writeMode); + if (args.writeMode !== undefined && !explicit) { + throw new Error("Choose a valid Builder write mode."); + } + if (explicit) return explicit; + if (args.liveWritesEnabled === false) return "read_only"; + if (args.liveWritesEnabled === true) { + const allowed = uniqueModes(args.allowedWriteModes); + return allowed.some((mode) => mode !== "autosave") + ? "publish_updates" + : "stage_only"; + } + const metadata = parseRecord(args.metadataJson); + const capabilities = parseRecord(args.capabilitiesJson); + const allowedWriteModes = Array.isArray(metadata.allowedWriteModes) + ? uniqueModes( + metadata.allowedWriteModes + .map(normalizeMode) + .filter((mode): mode is BuilderCmsLiveWriteMode => !!mode), + ) + : []; + return ( + normalizeWriteMode(metadata.writeMode) ?? + legacyWriteModeFromStored({ + liveWritesEnabled: capabilities.liveWritesEnabled === true, + allowedWriteModes, + pushMode: metadata.pushMode, + }) + ); +} + +function allowedWriteModesForTier( + writeMode: ContentDatabaseSourceWriteMode, +): BuilderCmsLiveWriteMode[] { + if (writeMode === "stage_only") return ["autosave"]; + if (writeMode === "publish_updates") return ["autosave", "publish"]; + return []; +} + +function pushModeForTier( + writeMode: ContentDatabaseSourceWriteMode, +): ContentDatabaseSourcePushMode { + if (writeMode === "stage_only") return "autosave"; + if (writeMode === "publish_updates") return "publish"; + return "none"; +} + export function builderCmsWriteSettingsFromJson(args: { capabilitiesJson: string | null | undefined; metadataJson: string | null | undefined; }) { const capabilities = parseRecord(args.capabilitiesJson); const metadata = parseRecord(args.metadataJson); - const allowedWriteModes = Array.isArray(metadata.allowedWriteModes) + const legacyAllowedWriteModes = Array.isArray(metadata.allowedWriteModes) ? uniqueModes( metadata.allowedWriteModes .map(normalizeMode) .filter((mode): mode is BuilderCmsLiveWriteMode => !!mode), ) : []; + const writeMode = + normalizeWriteMode(metadata.writeMode) ?? + legacyWriteModeFromStored({ + liveWritesEnabled: capabilities.liveWritesEnabled === true, + allowedWriteModes: legacyAllowedWriteModes, + pushMode: metadata.pushMode, + }); + const allowedWriteModes = allowedWriteModesForTier(writeMode); return { - liveWritesEnabled: capabilities.liveWritesEnabled === true, + writeMode, + liveWritesEnabled: writeMode !== "read_only", allowedWriteModes, - allowDraftWrites: metadata.allowDraftWrites === true, - allowPublishWrites: metadata.allowPublishWrites === true, + allowPublicationTransitions: + writeMode === "publish_updates" && + metadata.allowPublicationTransitions === true, + allowDraftWrites: false, + allowPublishWrites: writeMode === "publish_updates", }; } @@ -77,8 +165,14 @@ export function buildBuilderCmsWriteModeJson( ) { const capabilities = parseRecord(args.capabilitiesJson); const metadata = parseRecord(args.metadataJson); + const writeMode = writeModeFromPatch(args); + const enabled = writeMode !== "read_only"; + const allowPublicationTransitions = + enabled && + writeMode === "publish_updates" && + args.allowPublicationTransitions === true; - if (args.liveWritesEnabled) { + if (enabled) { if (args.sourceType !== "builder-cms") { throw new Error( "Live writes can only be enabled for Builder CMS sources.", @@ -91,50 +185,28 @@ export function buildBuilderCmsWriteModeJson( } } - const enabled = args.liveWritesEnabled === true; - const allowedWriteModes = enabled ? uniqueModes(args.allowedWriteModes) : []; - if (args.liveWritesEnabled && allowedWriteModes.length === 0) { - throw new Error( - "Choose at least one allowed Builder write mode before enabling live writes.", - ); - } if ( - enabled && - allowedWriteModes.includes("draft") && - args.allowDraftWrites !== true - ) { - throw new Error("Draft writes require explicit draft opt-in."); - } - if ( - enabled && - allowedWriteModes.includes("publish") && - args.allowPublishWrites !== true + args.allowPublicationTransitions === true && + writeMode !== "publish_updates" ) { - throw new Error("Publish writes require explicit publish opt-in."); + throw new Error("Publication transitions require publish updates mode."); } + const allowedWriteModes = allowedWriteModesForTier(writeMode); const nextCapabilities: Partial = { ...capabilities, liveWritesEnabled: enabled, }; const nextMetadata: Record = { ...metadata, - allowedWriteModes: enabled ? allowedWriteModes : [], - allowDraftWrites: enabled && args.allowDraftWrites === true, - allowPublishWrites: enabled && args.allowPublishWrites === true, + writeMode, + allowPublicationTransitions, + allowedWriteModes, + allowDraftWrites: false, + allowPublishWrites: writeMode === "publish_updates", + pushMode: pushModeForTier(writeMode), }; - if ( - enabled && - (!nextMetadata.pushMode || - nextMetadata.pushMode === "none" || - !allowedWriteModes.includes( - normalizeMode(nextMetadata.pushMode) ?? "autosave", - )) - ) { - nextMetadata.pushMode = allowedWriteModes[0]; - } - return { capabilitiesJson: JSON.stringify(nextCapabilities), metadataJson: JSON.stringify(nextMetadata), @@ -155,7 +227,7 @@ export function mergeBuilderCmsWriteSettingsIntoJson(args: { if ( currentSettings.liveWritesEnabled !== true || args.sourceTable !== BUILDER_CMS_SAFE_WRITE_MODEL || - currentSettings.allowedWriteModes.length === 0 + currentSettings.writeMode === "read_only" ) { return { capabilitiesJson: args.nextCapabilitiesJson, @@ -168,9 +240,19 @@ export function mergeBuilderCmsWriteSettingsIntoJson(args: { sourceTable: args.sourceTable, capabilitiesJson: args.nextCapabilitiesJson, metadataJson: args.nextMetadataJson, - liveWritesEnabled: true, - allowedWriteModes: currentSettings.allowedWriteModes, - allowDraftWrites: currentSettings.allowDraftWrites, - allowPublishWrites: currentSettings.allowPublishWrites, + writeMode: currentSettings.writeMode, + allowPublicationTransitions: currentSettings.allowPublicationTransitions, }); } + +export function builderCmsAllowedWriteModesForTier( + writeMode: ContentDatabaseSourceWriteMode, +) { + return allowedWriteModesForTier(writeMode); +} + +export function builderCmsPushModeForTier( + writeMode: ContentDatabaseSourceWriteMode, +) { + return pushModeForTier(writeMode); +} diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 757aee443d..b9d6232be3 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -84,6 +84,8 @@ type SourceMetadataRecord = { pushMode?: ContentDatabaseSourcePushMode; pushModeLabel?: string | null; pushModeDescription?: string | null; + writeMode?: ContentDatabaseSource["metadata"]["writeMode"]; + allowPublicationTransitions?: boolean; notes?: string | null; readMode?: string | null; liveReadConfigured?: boolean; @@ -669,6 +671,16 @@ async function loadSourceSnapshot( }), ); const metadata = parseObject(source.metadataJson) ?? {}; + const normalizedWriteMode = + metadata.writeMode === "read_only" || + metadata.writeMode === "stage_only" || + metadata.writeMode === "publish_updates" + ? metadata.writeMode + : undefined; + const capabilities = normalizeCapabilities(source.capabilitiesJson); + if (normalizedWriteMode) { + capabilities.liveWritesEnabled = normalizedWriteMode !== "read_only"; + } // A local-table source shows the target database's *live* title, so renaming // the underlying table is reflected here instead of the name frozen at attach. @@ -692,7 +704,7 @@ async function loadSourceSnapshot( lastRefreshedAt: source.lastRefreshedAt, lastSourceUpdatedAt: source.lastSourceUpdatedAt, lastError: source.lastError, - capabilities: normalizeCapabilities(source.capabilitiesJson), + capabilities, metadata: { primaryKey: metadata.primaryKey ?? "id", titleField: metadata.titleField ?? "title", @@ -700,6 +712,9 @@ async function loadSourceSnapshot( pushMode: metadata.pushMode ?? "none", pushModeLabel: metadata.pushModeLabel ?? null, pushModeDescription: metadata.pushModeDescription ?? null, + writeMode: normalizedWriteMode, + allowPublicationTransitions: + metadata.allowPublicationTransitions === true, notes: metadata.notes ?? null, readMode: metadata.readMode ?? null, liveReadConfigured: metadata.liveReadConfigured === true, diff --git a/templates/content/actions/content-database-source-actions.test.ts b/templates/content/actions/content-database-source-actions.test.ts index 18d8fae1a9..602a96a3ca 100644 --- a/templates/content/actions/content-database-source-actions.test.ts +++ b/templates/content/actions/content-database-source-actions.test.ts @@ -291,6 +291,20 @@ describe("content database source actions", () => { }); }); + it("accepts tiered Builder write mode requests", () => { + expect( + setWriteMode.schema.parse({ + documentId: "database-page", + writeMode: "publish_updates", + allowPublicationTransitions: true, + }), + ).toEqual({ + documentId: "database-page", + writeMode: "publish_updates", + allowPublicationTransitions: true, + }); + }); + it("marks successful Builder reads as live source metadata", () => { expect( JSON.parse( diff --git a/templates/content/actions/execute-builder-source-execution.live.test.ts b/templates/content/actions/execute-builder-source-execution.live.test.ts index d2bb583556..e03021b7bc 100644 --- a/templates/content/actions/execute-builder-source-execution.live.test.ts +++ b/templates/content/actions/execute-builder-source-execution.live.test.ts @@ -438,6 +438,7 @@ describe.skipIf(!LIVE_BUILDER_ENABLED)( ...source.metadata, pushMode: opts.pushMode, allowedWriteModes: opts.allowedWriteModes, + allowPublicationTransitions: Boolean(opts.publicationTransition), }; source.rows[0]!.lastSourceUpdatedAt = opts.baselineLastUpdated; diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 95393982fb..29f9c17fbd 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -586,7 +586,12 @@ describe("execute Builder source execution", () => { it("publishes draft entries after live transition preflight", async () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); - const builderSource = source({ changeSets: [approvedChangeSet] }); + const builderSource = source({ + changeSets: [approvedChangeSet], + metadata: { + allowPublicationTransitions: true, + }, + }); const execution = executionFor({ source: builderSource, changeSet: approvedChangeSet, @@ -616,7 +621,12 @@ describe("execute Builder source execution", () => { it("blocks publish transitions when the entry is already published", async () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); - const builderSource = source({ changeSets: [approvedChangeSet] }); + const builderSource = source({ + changeSets: [approvedChangeSet], + metadata: { + allowPublicationTransitions: true, + }, + }); const execution = executionFor({ source: builderSource, changeSet: approvedChangeSet, @@ -658,7 +668,12 @@ describe("execute Builder source execution", () => { it("unpublishes published entries when explicitly confirmed", async () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); - const builderSource = source({ changeSets: [approvedChangeSet] }); + const builderSource = source({ + changeSets: [approvedChangeSet], + metadata: { + allowPublicationTransitions: true, + }, + }); const execution = executionFor({ source: builderSource, changeSet: approvedChangeSet, diff --git a/templates/content/actions/set-content-database-source-write-mode.ts b/templates/content/actions/set-content-database-source-write-mode.ts index 1fa66c0066..7364163e4a 100644 --- a/templates/content/actions/set-content-database-source-write-mode.ts +++ b/templates/content/actions/set-content-database-source-write-mode.ts @@ -6,6 +6,7 @@ import { getDb, schema } from "../server/db/index.js"; import { type ContentDatabaseResponse, type ContentDatabaseSourcePushMode, + type ContentDatabaseSourceWriteMode, type SetContentDatabaseSourceWriteModeRequest, } from "../shared/api.js"; import { @@ -15,7 +16,12 @@ import { import { resolveDatabaseForSourceMutation } from "./_database-source-utils.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; -const writeModeSchema = z.enum(["autosave", "draft", "publish"]); +const legacyWriteModeSchema = z.enum(["autosave", "draft", "publish"]); +const sourceWriteModeSchema = z.enum([ + "read_only", + "stage_only", + "publish_updates", +]); function executableWriteModes( modes: readonly ContentDatabaseSourcePushMode[] | undefined, @@ -28,17 +34,27 @@ function executableWriteModes( export default defineAction({ description: - "Enable or disable live Builder CMS writes for one source. Live writes stay off by default and can only be enabled for the safe Builder test collection with explicit allowed write modes.", + "Set the tiered Builder CMS write mode for one source. Writes stay off by default and can only be enabled for the safe Builder test collection.", schema: z.object({ databaseId: z.string().optional().describe("Database ID"), documentId: z.string().optional().describe("Database document/page ID"), liveWritesEnabled: z .boolean() + .optional() .describe("Whether this source may execute guarded live Builder writes"), + writeMode: sourceWriteModeSchema + .optional() + .describe("Tiered Builder write mode for this source"), + allowPublicationTransitions: z + .boolean() + .optional() + .describe( + "Allow explicit per-item publish/unpublish transitions in publish updates mode", + ), allowedWriteModes: z - .array(writeModeSchema) + .array(legacyWriteModeSchema) .optional() - .describe("Explicit Builder write modes allowed for this source"), + .describe("Legacy Builder write modes allowed for this source"), allowDraftWrites: z .boolean() .optional() @@ -81,6 +97,8 @@ export default defineAction({ capabilitiesJson: source.capabilitiesJson, metadataJson: source.metadataJson, liveWritesEnabled: args.liveWritesEnabled, + writeMode: args.writeMode as ContentDatabaseSourceWriteMode | undefined, + allowPublicationTransitions: args.allowPublicationTransitions, allowedWriteModes: executableWriteModes(args.allowedWriteModes), allowDraftWrites: args.allowDraftWrites, allowPublishWrites: args.allowPublishWrites, diff --git a/templates/content/app/components/editor/DocumentDatabase.layout.test.ts b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts index 37a586fb76..e1505063e0 100644 --- a/templates/content/app/components/editor/DocumentDatabase.layout.test.ts +++ b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts @@ -163,9 +163,11 @@ describe("document database layout", () => { const source = readDatabaseSource(); // Read-only is the headline signal for non-safe models; the safe write - // model exposes an explicit "Enable live writes" control instead of a badge. + // model exposes an explicit tier selector instead of a badge. expect(source).toContain("Read-only"); - expect(source).toContain("Enable live writes (autosave)"); + expect(source).toContain("Builder write mode"); + expect(source).toContain("Publish updates"); + expect(source).toContain("Allow publish/unpublish per item"); // The dormant diff slot is the single push-review entry point. expect(source).toContain("Review diff"); // A failed sync surfaces inline instead of silently going stale. diff --git a/templates/content/app/components/editor/DocumentDatabase.test.ts b/templates/content/app/components/editor/DocumentDatabase.test.ts index 066920864b..432e92d28e 100644 --- a/templates/content/app/components/editor/DocumentDatabase.test.ts +++ b/templates/content/app/components/editor/DocumentDatabase.test.ts @@ -252,8 +252,10 @@ function builderSource( primaryKey: "id", titleField: "data.title", naturalKeyField: "/blog/[slug]", - pushMode: "autosave", - allowedWriteModes: ["autosave"], + pushMode: "none", + writeMode: "read_only", + allowedWriteModes: [], + allowPublicationTransitions: false, allowDraftWrites: false, allowPublishWrites: false, }, diff --git a/templates/content/app/components/editor/DocumentDatabase.tsx b/templates/content/app/components/editor/DocumentDatabase.tsx index 64d9b4d98f..4e8079e720 100644 --- a/templates/content/app/components/editor/DocumentDatabase.tsx +++ b/templates/content/app/components/editor/DocumentDatabase.tsx @@ -155,6 +155,7 @@ import { type ContentDatabaseSourceChangeSet, type ContentDatabaseSourceJoinRequest, type ContentDatabaseSourceReviewPayload, + type ContentDatabaseSourceWriteMode, type SourceJoinSuggestion, type ContentDatabaseView, type ContentDatabaseViewConfig, @@ -185,6 +186,11 @@ import { isEmptyPropertyValue, } from "@shared/properties"; +type BuilderSourceWriteSettingsInput = { + writeMode: ContentDatabaseSourceWriteMode; + allowPublicationTransitions?: boolean; +}; + interface DocumentDatabaseProps { document: Document; canEdit: boolean; @@ -1472,25 +1478,26 @@ function DatabaseTable({ setBuilderReviewCheckedAt(null); setBuilderReviewOpen(true); }} - onSetBuilderLiveWrites={(enabled) => + onSetBuilderLiveWrites={(settings) => setSourceWriteMode.mutate( { documentId: document.id, - liveWritesEnabled: enabled, - allowedWriteModes: enabled ? ["autosave"] : [], + writeMode: settings.writeMode, + allowPublicationTransitions: + settings.writeMode === "publish_updates" && + settings.allowPublicationTransitions === true, }, { onSuccess: () => { - toast.success( - enabled - ? "Builder live writes enabled" - : "Builder live writes disabled", - { - description: enabled - ? "Only autosave writes to the Agent Native test collection can run." - : "Push will return to local validation only.", - }, - ); + const enabled = settings.writeMode !== "read_only"; + toast.success("Builder write mode updated", { + description: + settings.writeMode === "publish_updates" + ? "Approved updates can write through to Builder while preserving publication state." + : enabled + ? "Approved updates will stage Builder autosave revisions." + : "Builder writes are disabled for this source.", + }); }, onError: (error) => { toast.error("Builder write mode was not changed", { @@ -3423,7 +3430,7 @@ function DatabaseSettingsPanelSheet({ onRefreshSource: () => void; onDisconnectSource: () => void; onReviewBuilderUpdate: () => void; - onSetBuilderLiveWrites: (enabled: boolean) => void; + onSetBuilderLiveWrites: (settings: BuilderSourceWriteSettingsInput) => void; sourceActionPending: boolean; onViewTypeChange: (type: ContentDatabaseViewType) => void; onWrapCellsChange: (wrapCells: boolean) => void; @@ -3675,22 +3682,65 @@ export function builderSourceLiveWriteControlState( const isBuilderSource = source?.sourceType === "builder-cms"; const safeTarget = isBuilderSource && source?.sourceTable === BUILDER_CMS_SAFE_WRITE_MODEL; - const enabled = source?.capabilities.liveWritesEnabled === true; + const legacyAllowedWriteModes = source?.metadata.allowedWriteModes ?? []; + const writeMode = + source?.metadata.writeMode ?? + (source?.capabilities.liveWritesEnabled === true + ? legacyAllowedWriteModes.some((mode) => mode !== "autosave") + ? "publish_updates" + : "stage_only" + : "read_only"); + const enabled = writeMode !== "read_only"; return { safeTarget, enabled, + writeMode, + allowPublicationTransitions: + writeMode === "publish_updates" && + source?.metadata.allowPublicationTransitions === true, showAction: safeTarget, actionLabel: enabled ? "Disable" : "Enable", description: enabled - ? "Enabled for autosave writes to the Agent Native test collection." + ? writeMode === "publish_updates" + ? "Approved updates can write through to Builder while preserving publication state." + : "Enabled for autosave writes to the Agent Native test collection." : safeTarget - ? "Off by default. Enable only when you are ready to send autosave writes to the Agent Native test collection." + ? "Off by default. Choose a write tier only when this source should send guarded Builder writes." : isBuilderSource ? "Unavailable here; live writes are locked to the Agent Native test collection." : "Live writes are not available for this source.", }; } +const BUILDER_WRITE_MODE_OPTIONS: Array<{ + mode: ContentDatabaseSourceWriteMode; + label: string; + description: string; +}> = [ + { + mode: "read_only", + label: "Read-only", + description: "No Builder writes.", + }, + { + mode: "stage_only", + label: "Stage only", + description: "Autosave revisions only.", + }, + { + mode: "publish_updates", + label: "Publish updates", + description: "Update live content in place.", + }, +]; + +function builderWriteModeSummary(mode: ContentDatabaseSourceWriteMode) { + return ( + BUILDER_WRITE_MODE_OPTIONS.find((option) => option.mode === mode) + ?.description ?? "No Builder writes." + ); +} + export function buildClientBuilderReviewPayload( source: ContentDatabaseSource, changeSets: ContentDatabaseSourceChangeSet[], @@ -3836,7 +3886,7 @@ function DatabaseSettingsSourcePanel({ onRefreshSource: () => void; onDisconnectSource: () => void; onReviewBuilderUpdate: () => void; - onSetBuilderLiveWrites: (enabled: boolean) => void; + onSetBuilderLiveWrites: (settings: BuilderSourceWriteSettingsInput) => void; sourceActionPending: boolean; }) { const outboundChangeSets = @@ -4092,9 +4142,8 @@ function DatabaseSettingsSourcePanel({ // Attached model → the minimal read-only leaf panel. const liveWriteControl = builderSourceLiveWriteControlState(source); - const builderLiveWritesEnabled = - liveWriteControl.safeTarget && liveWriteControl.enabled; const builderLiveWritesDisabled = !canEdit || sourceActionPending; + const builderWriteMode = liveWriteControl.writeMode; return (
@@ -4151,40 +4200,85 @@ function DatabaseSettingsSourcePanel({ )}
{isBuilderSource && liveWriteControl.safeTarget ? ( -
- Enable live writes (autosave) - + ); + })} +
+ {builderWriteMode === "publish_updates" ? ( + + > + {liveWriteControl.allowPublicationTransitions ? ( + + ) : null} +
+ + + Allow publish/unpublish per item + + + Requires explicit item intent; unpublish still needs + confirmation. + + + + ) : null} ) : null} diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 6f8c960290..6ad66898a4 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -345,6 +345,10 @@ export type ContentDatabaseSourcePushMode = | "autosave" | "draft" | "publish"; +export type ContentDatabaseSourceWriteMode = + | "read_only" + | "stage_only" + | "publish_updates"; export type BuilderCmsPublicationTransitionIntent = "publish" | "unpublish"; export const BUILDER_CMS_SAFE_WRITE_MODEL = "agent-native-blog-article-test"; export type ContentDatabaseSourceChangeDirection = "outbound"; @@ -540,6 +544,8 @@ export interface ContentDatabaseSource { pushMode?: ContentDatabaseSourcePushMode; pushModeLabel?: string | null; pushModeDescription?: string | null; + writeMode?: ContentDatabaseSourceWriteMode; + allowPublicationTransitions?: boolean; notes?: string | null; readMode?: "fixture" | "builder-api" | string | null; liveReadConfigured?: boolean; @@ -764,7 +770,9 @@ export interface ExecuteBuilderSourceExecutionRequest { export interface SetContentDatabaseSourceWriteModeRequest { databaseId?: string; documentId?: string; - liveWritesEnabled: boolean; + liveWritesEnabled?: boolean; + writeMode?: ContentDatabaseSourceWriteMode; + allowPublicationTransitions?: boolean; allowedWriteModes?: Exclude[]; allowDraftWrites?: boolean; allowPublishWrites?: boolean; From 4780c7e06a1cf6a4b879ee3ec5af3cb858cdfbe3 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:14:00 -0400 Subject: [PATCH 09/36] feat(content): bulk Builder write runner (concurrency, continue-on-error) Adds executeBuilderSourceBatchWithDeps + an execute-builder-source-batch action to push many approved outbound change-sets in one pass over the proven per-item pipeline: - bounded concurrency (default 3, cap 8), - continue-on-error with per-item {changeSetId, status, message} results, - a batch summary (total/succeeded/blocked/failed), - resumable: already-succeeded executions are skipped (per-item idempotency), - transitions applied ONLY when explicitly mapped per change-set (bulk default stays update-in-place; never auto-publishes/unpublishes). Extends prepare so an explicit publish/unpublish prepares the matching gate. UI: a "Push all approved (N)" affordance (safe-model-only) that shows the returned summary + non-succeeded per-item messages. Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier, all guards incl. no-unscoped-queries, offline suite 801 pass). Batch orchestration is unit-tested over the already-live-verified per-item path; UI visual pass owed. Co-Authored-By: Claude Opus 4.8 --- .../content-database-source-actions.test.ts | 21 ++ .../execute-builder-source-batch.test.ts | 299 ++++++++++++++++++ .../actions/execute-builder-source-batch.ts | 278 ++++++++++++++++ .../execute-builder-source-execution.ts | 2 +- .../prepare-builder-source-execution.ts | 10 + .../components/editor/DocumentDatabase.tsx | 74 +++-- .../BuilderSourceReviewDialog.tsx | 106 ++++++- .../content/app/hooks/use-content-database.ts | 19 ++ templates/content/shared/api.ts | 31 ++ 9 files changed, 788 insertions(+), 52 deletions(-) create mode 100644 templates/content/actions/execute-builder-source-batch.test.ts create mode 100644 templates/content/actions/execute-builder-source-batch.ts diff --git a/templates/content/actions/content-database-source-actions.test.ts b/templates/content/actions/content-database-source-actions.test.ts index 602a96a3ca..9bab3b1acc 100644 --- a/templates/content/actions/content-database-source-actions.test.ts +++ b/templates/content/actions/content-database-source-actions.test.ts @@ -5,6 +5,7 @@ import addSourceFieldProperty, { sourceFieldPropertyValuesFromRows, } from "./add-content-database-source-field-property"; import disconnectSource from "./disconnect-content-database-source"; +import executeBatch from "./execute-builder-source-batch"; import executeExecution from "./execute-builder-source-execution"; import getSource from "./get-content-database-source"; import listBuilderModels from "./list-builder-cms-models"; @@ -31,6 +32,26 @@ describe("content database source actions", () => { }); }); + it("accepts Builder source batch execution args", () => { + expect( + executeBatch.schema.parse({ + documentId: "database-page", + changeSetIds: ["change-1", "change-2"], + maxConcurrency: 2, + transitions: { + "change-2": { publicationTransition: "publish" }, + }, + }), + ).toEqual({ + documentId: "database-page", + changeSetIds: ["change-1", "change-2"], + maxConcurrency: 2, + transitions: { + "change-2": { publicationTransition: "publish" }, + }, + }); + }); + it("defaults source attachment to the safe mock-local source type", () => { expect(attachSource.schema.parse({ documentId: "database-page" })).toEqual({ documentId: "database-page", diff --git a/templates/content/actions/execute-builder-source-batch.test.ts b/templates/content/actions/execute-builder-source-batch.test.ts new file mode 100644 index 0000000000..8657f5ed80 --- /dev/null +++ b/templates/content/actions/execute-builder-source-batch.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type ContentDatabaseSource, + type ContentDatabaseSourceChangeSet, + type ContentDatabaseSourceExecution, +} from "../shared/api"; +import { + executeBuilderSourceBatchWithDeps, + type ExecuteBuilderSourceBatchDeps, +} from "./execute-builder-source-batch"; + +const NOW = "2026-06-24T12:00:00.000Z"; + +type DatabaseRecord = NonNullable< + Awaited> +>; + +const DATABASE: DatabaseRecord = { + id: "database-1", + ownerEmail: "local@localhost", + orgId: null, + documentId: "database-page", + title: "Editorial calendar", + viewConfigJson: "{}", + createdAt: NOW, + updatedAt: NOW, +}; + +function execution( + overrides: Partial = {}, +): ContentDatabaseSourceExecution { + return { + id: "execution-1", + changeSetId: "change-1", + adapter: "builder-cms", + pushMode: "autosave", + state: "ready", + idempotencyKey: "builder-cms:source-1:change-1:autosave", + summary: "Prepared Builder autosave execution.", + payload: {}, + lastError: null, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function changeSet( + id: string, + overrides: Partial = {}, +): ContentDatabaseSourceChangeSet { + return { + id, + databaseItemId: `item-${id}`, + documentId: `doc-${id}`, + kind: "field_update", + direction: "outbound", + state: "approved", + pushMode: "autosave", + localOnly: true, + summary: "Approved local Builder title change.", + fieldChanges: [ + { + propertyId: null, + propertyName: "Title", + localFieldKey: "title", + sourceFieldKey: "data.title", + currentValue: "Old title", + proposedValue: "New title", + }, + ], + bodyChange: null, + riskLevel: "low", + riskReasons: ["single field diff"], + conflictState: "none", + reviewEvents: [], + executions: [], + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function source( + changeSets: ContentDatabaseSourceChangeSet[], +): ContentDatabaseSource { + return { + id: "source-1", + databaseId: "database-1", + sourceType: "builder-cms", + sourceName: "Builder CMS", + sourceTable: "agent-native-blog-article-test", + syncState: "idle", + freshness: "fresh", + lastRefreshedAt: null, + lastSourceUpdatedAt: null, + lastError: null, + capabilities: { + canRefresh: true, + canCreateChangeSets: true, + canWriteFields: true, + canWriteBody: true, + canPush: true, + canPull: true, + canPublish: true, + canDelete: false, + canStageLocalRevision: true, + liveWritesEnabled: true, + readOnlyRefresh: true, + }, + metadata: { + primaryKey: "id", + titleField: "data.title", + pushMode: "autosave", + }, + fields: [], + rows: [], + changeSets, + }; +} + +function depsFor(args: { + source: ContentDatabaseSource; + runOne: ExecuteBuilderSourceBatchDeps["runOne"]; +}): ExecuteBuilderSourceBatchDeps { + return { + resolveDatabase: vi.fn(async () => DATABASE), + assertEditor: vi.fn(async () => {}), + getSourceSnapshot: vi.fn(async () => args.source), + runOne: args.runOne, + }; +} + +describe("execute Builder source batch", () => { + it("returns an all-succeeded summary", async () => { + const runOne = vi.fn(async (changeSetId: string) => ({ + changeSetId, + status: "succeeded" as const, + })); + const result = await executeBuilderSourceBatchWithDeps( + { databaseId: "database-1" }, + depsFor({ + source: source([ + changeSet("change-1"), + changeSet("change-2"), + changeSet("change-3"), + ]), + runOne, + }), + ); + + expect(result.summary).toEqual({ + total: 3, + succeeded: 3, + blocked: 0, + failed: 0, + }); + expect(result.results.map((item) => item.status)).toEqual([ + "succeeded", + "succeeded", + "succeeded", + ]); + expect(runOne).toHaveBeenCalledTimes(3); + }); + + it("continues after blocked and failed items", async () => { + const runOne = vi.fn(async (changeSetId: string) => { + if (changeSetId === "change-2") { + throw new Error("Builder execution is blocked before write."); + } + if (changeSetId === "change-3") { + throw new Error("Network write failed."); + } + return { changeSetId, status: "succeeded" as const }; + }); + const result = await executeBuilderSourceBatchWithDeps( + { databaseId: "database-1" }, + depsFor({ + source: source([ + changeSet("change-1"), + changeSet("change-2"), + changeSet("change-3"), + ]), + runOne, + }), + ); + + expect(result.summary).toEqual({ + total: 3, + succeeded: 1, + blocked: 1, + failed: 1, + }); + expect(result.results).toEqual([ + { changeSetId: "change-1", status: "succeeded" }, + { + changeSetId: "change-2", + status: "blocked", + message: "Builder execution is blocked before write.", + }, + { + changeSetId: "change-3", + status: "failed", + message: "Network write failed.", + }, + ]); + }); + + it("respects the concurrency cap", async () => { + let inFlight = 0; + let maxInFlight = 0; + const runOne = vi.fn(async (changeSetId: string) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + return { changeSetId, status: "succeeded" as const }; + }); + + await executeBuilderSourceBatchWithDeps( + { databaseId: "database-1", maxConcurrency: 2 }, + depsFor({ + source: source([ + changeSet("change-1"), + changeSet("change-2"), + changeSet("change-3"), + changeSet("change-4"), + changeSet("change-5"), + ]), + runOne, + }), + ); + + expect(maxInFlight).toBe(2); + expect(runOne).toHaveBeenCalledTimes(5); + }); + + it("skips already-succeeded executions", async () => { + const runOne = vi.fn(async (changeSetId: string) => ({ + changeSetId, + status: "succeeded" as const, + })); + const result = await executeBuilderSourceBatchWithDeps( + { + databaseId: "database-1", + changeSetIds: ["change-1", "change-2"], + }, + depsFor({ + source: source([ + changeSet("change-1", { + state: "applied", + executions: [execution({ state: "succeeded" })], + }), + changeSet("change-2"), + ]), + runOne, + }), + ); + + expect(result.summary).toEqual({ + total: 2, + succeeded: 2, + blocked: 0, + failed: 0, + }); + expect(result.results[0]).toEqual({ + changeSetId: "change-1", + status: "succeeded", + message: "Builder execution already succeeded; skipped.", + }); + expect(runOne).toHaveBeenCalledTimes(1); + expect(runOne).toHaveBeenCalledWith("change-2", undefined); + }); + + it("passes transitions only when explicitly mapped", async () => { + const runOne = vi.fn(async (changeSetId: string) => ({ + changeSetId, + status: "succeeded" as const, + })); + await executeBuilderSourceBatchWithDeps( + { + databaseId: "database-1", + maxConcurrency: 1, + transitions: { + "change-2": { publicationTransition: "publish" }, + }, + }, + depsFor({ + source: source([changeSet("change-1"), changeSet("change-2")]), + runOne, + }), + ); + + expect(runOne).toHaveBeenNthCalledWith(1, "change-1", undefined); + expect(runOne).toHaveBeenNthCalledWith(2, "change-2", { + publicationTransition: "publish", + }); + }); +}); diff --git a/templates/content/actions/execute-builder-source-batch.ts b/templates/content/actions/execute-builder-source-batch.ts new file mode 100644 index 0000000000..aee0cd395e --- /dev/null +++ b/templates/content/actions/execute-builder-source-batch.ts @@ -0,0 +1,278 @@ +import { defineAction } from "@agent-native/core"; +import { assertAccess } from "@agent-native/core/sharing"; +import { z } from "zod"; +import { + type BuilderSourceBatchItemResult, + type ContentDatabaseSource, + type ContentDatabaseSourceChangeSet, + type ExecuteBuilderSourceBatchRequest, + type ExecuteBuilderSourceBatchResponse, + type ExecuteBuilderSourceBatchTransition, +} from "../shared/api.js"; +import { + getContentDatabaseSourceSnapshot, + resolveDatabaseForSourceMutation, +} from "./_database-source-utils.js"; +import { + executeBuilderSourceExecutionWithDeps, + realExecutionDeps, +} from "./execute-builder-source-execution.js"; +import prepareBuilderSourceExecution from "./prepare-builder-source-execution.js"; + +type DatabaseRecord = NonNullable< + Awaited> +>; + +export const DEFAULT_BUILDER_SOURCE_BATCH_CONCURRENCY = 3; +export const MAX_BUILDER_SOURCE_BATCH_CONCURRENCY = 8; + +export interface ExecuteBuilderSourceBatchDeps { + resolveDatabase: ( + args: Pick, + ) => Promise; + assertEditor: (database: DatabaseRecord) => Promise; + getSourceSnapshot: ( + database: DatabaseRecord, + ) => Promise; + runOne: ( + changeSetId: string, + transition?: ExecuteBuilderSourceBatchTransition, + ) => Promise; +} + +function realBatchDeps( + args: Pick, +): ExecuteBuilderSourceBatchDeps { + return { + resolveDatabase: (request) => resolveDatabaseForSourceMutation(request), + assertEditor: async (database) => { + await assertAccess("document", database.documentId, "editor"); + }, + getSourceSnapshot: (database) => getContentDatabaseSourceSnapshot(database), + runOne: async (changeSetId, transition) => { + const executionArgs = { + databaseId: args.databaseId, + documentId: args.documentId, + changeSetId, + publicationTransition: transition?.publicationTransition, + confirmUnpublish: transition?.confirmUnpublish, + }; + if (transition?.publicationTransition) { + await prepareBuilderSourceExecution.run(executionArgs); + } + try { + await executeBuilderSourceExecutionWithDeps( + executionArgs, + realExecutionDeps(), + ); + } catch (error) { + if (!isMissingGateMessage(errorMessage(error))) { + throw error; + } + await prepareBuilderSourceExecution.run({ + databaseId: args.databaseId, + documentId: args.documentId, + changeSetId, + publicationTransition: transition?.publicationTransition, + confirmUnpublish: transition?.confirmUnpublish, + }); + await executeBuilderSourceExecutionWithDeps( + executionArgs, + realExecutionDeps(), + ); + } + return { changeSetId, status: "succeeded" }; + }, + }; +} + +function errorMessage(error: unknown) { + return error instanceof Error && error.message + ? error.message + : "Unknown Builder batch error."; +} + +function isMissingGateMessage(message: string) { + return /prepare the builder execution gate/i.test(message); +} + +function isBlockedMessage(message: string) { + return [ + /blocked/i, + /approve/i, + /disabled/i, + /not allowed/i, + /no longer exists/i, + /changed since/i, + /already running/i, + /requires/i, + /not ready/i, + /refresh/i, + /not executable/i, + ].some((pattern) => pattern.test(message)); +} + +function clampConcurrency(value: number | undefined) { + if (!value || !Number.isFinite(value)) { + return DEFAULT_BUILDER_SOURCE_BATCH_CONCURRENCY; + } + return Math.max(1, Math.min(MAX_BUILDER_SOURCE_BATCH_CONCURRENCY, value)); +} + +function uniqueIds(ids: string[]) { + return Array.from(new Set(ids.filter((id) => id.trim()))); +} + +function hasSucceededExecution(changeSet: ContentDatabaseSourceChangeSet) { + return changeSet.executions.some( + (execution) => execution.state === "succeeded", + ); +} + +function defaultBatchChangeSetIds(source: ContentDatabaseSource) { + return source.changeSets + .filter( + (changeSet) => + changeSet.direction === "outbound" && changeSet.state === "approved", + ) + .map((changeSet) => changeSet.id); +} + +function explicitBatchBlocker( + changeSet: ContentDatabaseSourceChangeSet | undefined, +) { + if (!changeSet) return "Source change-set not found."; + if (changeSet.direction !== "outbound") { + return "Only outbound Builder change sets can be executed."; + } + if ( + changeSet.state !== "pending_push" && + changeSet.state !== "staged_revision" && + changeSet.state !== "approved" + ) { + return "Approve the Builder change set before executing it."; + } + return null; +} + +async function runBoundedPool( + items: T[], + maxConcurrency: number, + worker: (item: T, index: number) => Promise, +) { + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.min(maxConcurrency, items.length); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await worker(items[index], index); + } + }), + ); + return results; +} + +function summarize(results: BuilderSourceBatchItemResult[]) { + return { + total: results.length, + succeeded: results.filter((result) => result.status === "succeeded").length, + blocked: results.filter((result) => result.status === "blocked").length, + failed: results.filter((result) => result.status === "failed").length, + }; +} + +export async function executeBuilderSourceBatchWithDeps( + args: ExecuteBuilderSourceBatchRequest, + deps: ExecuteBuilderSourceBatchDeps, +): Promise { + const database = await deps.resolveDatabase(args); + if (!database) throw new Error("Database not found."); + await deps.assertEditor(database); + + const source = await deps.getSourceSnapshot(database); + if (!source || source.sourceType !== "builder-cms") { + throw new Error("Attach a Builder CMS source before executing writes."); + } + + const ids = uniqueIds(args.changeSetIds ?? defaultBatchChangeSetIds(source)); + const changeSetsById = new Map( + source.changeSets.map((changeSet) => [changeSet.id, changeSet]), + ); + const maxConcurrency = clampConcurrency(args.maxConcurrency); + const results = await runBoundedPool( + ids, + maxConcurrency, + async (changeSetId) => { + const changeSet = changeSetsById.get(changeSetId); + if (changeSet && hasSucceededExecution(changeSet)) { + return { + changeSetId, + status: "succeeded", + message: "Builder execution already succeeded; skipped.", + }; + } + + const blocker = explicitBatchBlocker(changeSet); + if (blocker) { + return { changeSetId, status: "blocked", message: blocker }; + } + + try { + return await deps.runOne(changeSetId, args.transitions?.[changeSetId]); + } catch (error) { + const message = errorMessage(error); + return { + changeSetId, + status: isBlockedMessage(message) ? "blocked" : "failed", + message, + }; + } + }, + ); + + return { + summary: summarize(results), + results, + }; +} + +const transitionSchema = z.object({ + publicationTransition: z.enum(["publish", "unpublish"]).optional(), + confirmUnpublish: z.boolean().optional(), +}); + +export default defineAction({ + description: + "Execute a bounded batch of approved outbound Builder CMS change-sets. Each item uses the existing prepare and execute gates, continues on item errors, and does not publish or unpublish unless an explicit per-change-set transition is provided.", + schema: z.object({ + databaseId: z.string().optional().describe("Database ID"), + documentId: z.string().optional().describe("Database document/page ID"), + changeSetIds: z + .array(z.string()) + .optional() + .describe( + "Approved source change-set IDs. Defaults to all approved outbound Builder change-sets.", + ), + maxConcurrency: z + .number() + .int() + .min(1) + .max(MAX_BUILDER_SOURCE_BATCH_CONCURRENCY) + .optional() + .describe("Maximum Builder change-sets to execute at once."), + transitions: z + .record(z.string(), transitionSchema) + .optional() + .describe( + "Optional per-change-set publication transitions. Omit by default to preserve publication state.", + ), + }), + run: async ( + args: ExecuteBuilderSourceBatchRequest, + ): Promise => { + return executeBuilderSourceBatchWithDeps(args, realBatchDeps(args)); + }, +}); diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index 854ad929a0..9e84b9959b 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -411,7 +411,7 @@ async function reconcileBuilderCmsWrite(args: { .where(eq(schema.contentDatabaseSources.id, args.source.id)); } -function realExecutionDeps(): ExecuteBuilderSourceExecutionDeps { +export function realExecutionDeps(): ExecuteBuilderSourceExecutionDeps { return { now: () => new Date().toISOString(), resolveDatabase: (args) => resolveDatabaseForSourceMutation(args), diff --git a/templates/content/actions/prepare-builder-source-execution.ts b/templates/content/actions/prepare-builder-source-execution.ts index edc7577e2d..6e6c019b29 100644 --- a/templates/content/actions/prepare-builder-source-execution.ts +++ b/templates/content/actions/prepare-builder-source-execution.ts @@ -25,6 +25,14 @@ export default defineAction({ .enum(["autosave", "draft", "publish"]) .optional() .describe("Explicit push mode confirmation for the planned write"), + publicationTransition: z + .enum(["publish", "unpublish"]) + .optional() + .describe("Explicit publication transition to validate at write time"), + confirmUnpublish: z + .boolean() + .optional() + .describe("Required explicit confirmation for unpublish transitions"), }), run: async ( args: PrepareBuilderSourceExecutionRequest, @@ -49,6 +57,8 @@ export default defineAction({ source, changeSet, pushModeConfirmation: args.pushModeConfirmation, + publicationTransition: args.publicationTransition, + confirmUnpublish: args.confirmUnpublish, }); const now = new Date().toISOString(); const db = getDb(); diff --git a/templates/content/app/components/editor/DocumentDatabase.tsx b/templates/content/app/components/editor/DocumentDatabase.tsx index 4e8079e720..a38733c52b 100644 --- a/templates/content/app/components/editor/DocumentDatabase.tsx +++ b/templates/content/app/components/editor/DocumentDatabase.tsx @@ -99,7 +99,7 @@ import { useContentDatabases, useDisconnectContentDatabaseSource, useDuplicateDatabaseItem, - useExecuteBuilderSourceExecution, + useExecuteBuilderSourceBatch, useMoveDatabaseItem, usePrepareBuilderSourceReview, useRefreshContentDatabaseSource, @@ -156,6 +156,7 @@ import { type ContentDatabaseSourceJoinRequest, type ContentDatabaseSourceReviewPayload, type ContentDatabaseSourceWriteMode, + type ExecuteBuilderSourceBatchResponse, type SourceJoinSuggestion, type ContentDatabaseView, type ContentDatabaseViewConfig, @@ -415,7 +416,7 @@ function DatabaseTable({ const refreshSource = useRefreshContentDatabaseSource(document.id); const disconnectSource = useDisconnectContentDatabaseSource(document.id); const prepareBuilderReview = usePrepareBuilderSourceReview(document.id); - const executeBuilderExecution = useExecuteBuilderSourceExecution(document.id); + const executeBuilderBatch = useExecuteBuilderSourceBatch(document.id); const setSourceWriteMode = useSetContentDatabaseSourceWriteMode(document.id); const setProperty = useSetDocumentProperty(document.id); const updateView = useUpdateContentDatabaseView(document.id); @@ -442,6 +443,8 @@ function DatabaseTable({ const [builderReviewOpen, setBuilderReviewOpen] = useState(false); const [builderReviewResult, setBuilderReviewResult] = useState(null); + const [builderBatchResult, setBuilderBatchResult] = + useState(null); const [builderReviewCheckedAt, setBuilderReviewCheckedAt] = useState< string | null >(null); @@ -882,54 +885,34 @@ function DatabaseTable({ async function handleBuilderReviewPush() { setBuilderReviewResult(null); + setBuilderBatchResult(null); setBuilderReviewCheckedAt(null); try { const prepared = await prepareBuilderReview.mutateAsync({ documentId: document.id, - pushModeConfirmation: "autosave", }); let nextReview = prepared.review; + let batchResult: ExecuteBuilderSourceBatchResponse | null = null; if ( nextReview.liveWritesEnabled && nextReview.result.status === "validated" ) { - const executableRows = builderReviewExecutableRows(nextReview); - let executedResponse: ContentDatabaseResponse | null = null; - for (const row of executableRows) { - if (!row.execution?.idempotencyKey) continue; - executedResponse = await executeBuilderExecution.mutateAsync({ - documentId: document.id, - changeSetId: row.changeSetId, - idempotencyKey: row.execution.idempotencyKey, - pushModeConfirmation: nextReview.pushMode, - }); - } - const executedSource = executedResponse?.source ?? null; - if (executedSource) { - const reviewedIds = new Set( - nextReview.rows.map((row) => row.changeSetId), - ); - const reviewedChangeSets = executedSource.changeSets.filter( - (changeSet) => reviewedIds.has(changeSet.id), - ); - if (reviewedChangeSets.length > 0) { - nextReview = buildClientBuilderReviewPayload( - executedSource, - reviewedChangeSets, - ); - } - } + batchResult = await executeBuilderBatch.mutateAsync({ + documentId: document.id, + changeSetIds: nextReview.rows.map((row) => row.changeSetId), + }); + setBuilderBatchResult(batchResult); } setBuilderReviewResult(nextReview); setBuilderReviewCheckedAt(new Date().toISOString()); toast.success( - nextReview.result.status === "succeeded" - ? "Builder update pushed" + builderBatchSucceeded(batchResult) + ? "Builder updates pushed" : "Builder update checked", { - description: nextReview.result.message, + description: builderBatchToastMessage(batchResult, nextReview), }, ); } catch (error) { @@ -1458,6 +1441,7 @@ function DatabaseTable({ setSettingsPanel("source"); setBuilderReviewOpen(false); setBuilderReviewResult(null); + setBuilderBatchResult(null); setBuilderReviewCheckedAt(null); toast.success("Source disconnected", { description: @@ -1475,6 +1459,7 @@ function DatabaseTable({ } onReviewBuilderUpdate={() => { setBuilderReviewResult(null); + setBuilderBatchResult(null); setBuilderReviewCheckedAt(null); setBuilderReviewOpen(true); }} @@ -1513,7 +1498,7 @@ function DatabaseTable({ refreshSource.isPending || disconnectSource.isPending || prepareBuilderReview.isPending || - executeBuilderExecution.isPending || + executeBuilderBatch.isPending || setSourceWriteMode.isPending } onViewTypeChange={(type) => @@ -1538,8 +1523,9 @@ function DatabaseTable({ source={source} canEdit={canEdit} pending={ - prepareBuilderReview.isPending || executeBuilderExecution.isPending + prepareBuilderReview.isPending || executeBuilderBatch.isPending } + batchResult={builderBatchResult} checkedAt={builderReviewCheckedAt} onClose={() => setBuilderReviewOpen(false)} onValidate={() => void handleBuilderReviewPush()} @@ -3676,6 +3662,26 @@ export function builderReviewExecutableRows( ); } +function builderBatchSucceeded( + result: ExecuteBuilderSourceBatchResponse | null, +) { + return ( + !!result && + result.summary.total > 0 && + result.summary.blocked === 0 && + result.summary.failed === 0 + ); +} + +function builderBatchToastMessage( + result: ExecuteBuilderSourceBatchResponse | null, + review: ContentDatabaseSourceReviewPayload, +) { + if (!result) return review.result.message; + const { succeeded, blocked, failed } = result.summary; + return `${succeeded} succeeded, ${blocked} blocked, ${failed} failed.`; +} + export function builderSourceLiveWriteControlState( source: ContentDatabaseSource | null, ) { diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx index 16e641a163..fbc5f38b94 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx @@ -7,11 +7,13 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; +import { BUILDER_CMS_SAFE_WRITE_MODEL } from "@shared/api"; import type { ContentDatabaseSource, ContentDatabaseSourceChangeSet, ContentDatabaseSourceReviewPayload, DocumentPropertyValue, + ExecuteBuilderSourceBatchResponse, } from "@shared/api"; function sourceRiskClass(risk: ContentDatabaseSourceChangeSet["riskLevel"]) { @@ -64,6 +66,7 @@ export function BuilderSourceReviewDialog({ source, canEdit, pending, + batchResult, checkedAt, onClose, onValidate, @@ -73,43 +76,78 @@ export function BuilderSourceReviewDialog({ source: ContentDatabaseSource | null; canEdit: boolean; pending: boolean; + batchResult: ExecuteBuilderSourceBatchResponse | null; checkedAt: string | null; onClose: () => void; onValidate: () => void; }) { const checked = !!checkedAt; + const safeModel = + source?.sourceType === "builder-cms" && + source.sourceTable === BUILDER_CMS_SAFE_WRITE_MODEL; + const batchHasIssues = + !!batchResult && + (batchResult.summary.blocked > 0 || batchResult.summary.failed > 0); const retryable = review?.result.status === "failed" || review?.result.status === "blocked" || - review?.result.status === "stale"; + review?.result.status === "stale" || + batchHasIssues; + const unsafeLiveTarget = review?.liveWritesEnabled === true && !safeModel; const disabled = !canEdit || pending || (!retryable && checked) || !review || - review.rows.length === 0; + review.rows.length === 0 || + unsafeLiveTarget; + const rowTitleById = new Map( + review?.rows.map((row) => [row.changeSetId, row.title]) ?? [], + ); + const batchIssueResults = + batchResult?.results.filter((result) => result.status !== "succeeded") ?? + []; + const batchSummaryText = batchResult + ? `${batchResult.summary.succeeded} succeeded, ${batchResult.summary.blocked} blocked, ${batchResult.summary.failed} failed.` + : null; + const resultMessage = batchSummaryText ?? review?.result.message; + const resultStatus = batchResult + ? batchHasIssues + ? "partial" + : "succeeded" + : review?.result.status; const footerText = pending ? review?.liveWritesEnabled ? "Preparing the Builder gate and sending through the guarded write path." : "Checking the Builder gate locally." - : checked - ? review?.result.status === "succeeded" - ? "Pushed to Builder and reconciled locally." + : unsafeLiveTarget + ? `Live batch pushes are limited to ${BUILDER_CMS_SAFE_WRITE_MODEL}.` + : checked + ? review?.result.status === "succeeded" + ? "Pushed to Builder and reconciled locally." + : batchResult + ? batchSummaryText + : review?.liveWritesEnabled + ? (review?.result.message ?? "Builder push finished.") + : "Checked just now. Nothing was sent to Builder." : review?.liveWritesEnabled - ? (review?.result.message ?? "Builder push finished.") - : "Checked just now. Nothing was sent to Builder." - : review?.liveWritesEnabled - ? "Push will send autosave writes through the guarded Builder path." - : "Builder writes are disabled. Push will check the update only."; + ? "Push will send approved writes through the guarded Builder path." + : "Builder writes are disabled. Push will check the update only."; const buttonLabel = pending ? review?.liveWritesEnabled ? "Pushing..." : "Checking..." - : checked && review?.result.status === "succeeded" - ? "Pushed" - : checked && !retryable - ? "Checked" - : "Push"; + : checked && batchResult + ? batchHasIssues + ? "Retry" + : "Pushed" + : checked && review?.result.status === "succeeded" + ? "Pushed" + : checked && !retryable + ? "Checked" + : review?.liveWritesEnabled && (review?.rows.length ?? 0) > 1 + ? `Push all approved (${review?.rows.length ?? 0})` + : "Push"; return (
Result
- {review.result.message} + {resultMessage}
- {review.result.status.replace(/_/g, " ")} + {resultStatus?.replace(/_/g, " ")} + {batchResult ? ( +
+
+ + {batchResult.summary.succeeded} succeeded + + + {batchResult.summary.blocked} blocked + + + {batchResult.summary.failed} failed + +
+ {batchIssueResults.length > 0 ? ( +
+ {batchIssueResults.map((result) => ( +
+
+ {rowTitleById.get(result.changeSetId) ?? + result.changeSetId} +
+
+ {result.status}:{" "} + {result.message ?? "No details returned."} +
+
+ ))} +
+ ) : null} +
+ ) : null} ) : ( diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index 65978a20f2..d50bb8367b 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -10,6 +10,8 @@ import type { ContentDatabaseSourceStatusResponse, CreateDatabaseRequest, DisconnectContentDatabaseSourceRequest, + ExecuteBuilderSourceBatchRequest, + ExecuteBuilderSourceBatchResponse, DuplicateDatabaseItemRequest, ExecuteBuilderSourceExecutionRequest, MoveDatabaseItemRequest, @@ -376,6 +378,23 @@ export function useExecuteBuilderSourceExecution(documentId: string) { }); } +export function useExecuteBuilderSourceBatch(documentId: string) { + const queryClient = useQueryClient(); + return useActionMutation< + ExecuteBuilderSourceBatchResponse, + ExecuteBuilderSourceBatchRequest + >("execute-builder-source-batch", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: contentDatabaseQueryKey(documentId), + }); + queryClient.invalidateQueries({ + queryKey: ["action", "get-content-database-source", { documentId }], + }); + }, + }); +} + export function useSetContentDatabaseSourceWriteMode(documentId: string) { const queryClient = useQueryClient(); return useActionMutation< diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 6ad66898a4..b42a522696 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -767,6 +767,37 @@ export interface ExecuteBuilderSourceExecutionRequest { confirmUnpublish?: boolean; } +export interface ExecuteBuilderSourceBatchTransition { + publicationTransition?: BuilderCmsPublicationTransitionIntent; + confirmUnpublish?: boolean; +} + +export interface ExecuteBuilderSourceBatchRequest { + databaseId?: string; + documentId?: string; + changeSetIds?: string[]; + maxConcurrency?: number; + transitions?: Record; +} + +export type BuilderSourceBatchItemStatus = "succeeded" | "blocked" | "failed"; + +export interface BuilderSourceBatchItemResult { + changeSetId: string; + status: BuilderSourceBatchItemStatus; + message?: string; +} + +export interface ExecuteBuilderSourceBatchResponse { + summary: { + total: number; + succeeded: number; + blocked: number; + failed: number; + }; + results: BuilderSourceBatchItemResult[]; +} + export interface SetContentDatabaseSourceWriteModeRequest { databaseId?: string; documentId?: string; From 23725c133638dc016e3f5535e4f568e0cd70594f Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:25:12 -0400 Subject: [PATCH 10/36] feat(content): per-item publication effect + transition controls in the diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review/diff now makes publication intent explicit per row (the philosophy: transitions are deliberate, item-level acts): - per-row default effect label (Stage autosave / Update in place — keeps current published/draft state), - per-row Publish / Unpublish controls, shown ONLY when the source allows transitions (publish_updates + allowPublicationTransitions); mutually exclusive; Unpublish is destructive-styled and requires explicit confirm, - a footer intent summary ("N update in place · M publish · K unpublish"), - selections flow into the batch `transitions` map (unselected rows push with no transition — update-in-place/autosave per tier; nothing auto-transitions). Authored via Codex (GPT-5.5), reviewed + verified by Claude (typecheck, prettier, guards, offline suite 805 pass). UI visual pass owed (spacing/wrap in real review payloads). Co-Authored-By: Claude Opus 4.8 --- .../editor/DocumentDatabase.layout.test.ts | 48 +++ .../components/editor/DocumentDatabase.tsx | 25 +- .../BuilderSourceReviewDialog.test.ts | 55 ++++ .../BuilderSourceReviewDialog.tsx | 281 ++++++++++++++++-- 4 files changed, 387 insertions(+), 22 deletions(-) create mode 100644 templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts diff --git a/templates/content/app/components/editor/DocumentDatabase.layout.test.ts b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts index e1505063e0..c7cf877715 100644 --- a/templates/content/app/components/editor/DocumentDatabase.layout.test.ts +++ b/templates/content/app/components/editor/DocumentDatabase.layout.test.ts @@ -7,6 +7,18 @@ function readDatabaseSource() { }); } +function readBuilderReviewDialogSource() { + return readFileSync( + new URL( + "./database-sources/BuilderSourceReviewDialog.tsx", + import.meta.url, + ), + { + encoding: "utf8", + }, + ); +} + describe("document database layout", () => { it("wraps database toolbar controls instead of clipping them", () => { const source = readDatabaseSource(); @@ -178,6 +190,42 @@ describe("document database layout", () => { expect(source).not.toContain(">Field mappings<"); }); + it("wires Builder review publication intent controls into the push path", () => { + const source = readDatabaseSource(); + const dialog = readBuilderReviewDialogSource(); + + expect(dialog).toContain("Stage autosave"); + expect(dialog).toContain( + "Update in place (keeps current published/draft state)", + ); + expect(dialog).toContain('aria-pressed={'); + expect(dialog).toContain("Publish"); + expect(dialog).toContain("Unpublish"); + expect(dialog).toContain("Confirm unpublish"); + expect(dialog).toContain('writeMode === "publish_updates"'); + expect(dialog).toContain( + "source?.metadata.allowPublicationTransitions === true", + ); + expect(dialog).toContain( + 'return `${defaultAction} ${defaultLabel} · ${publish} publish · ${unpublish} unpublish`;', + ); + expect(dialog).toContain( + "builderReviewPublicationTransitionsMap(transitionSelections)", + ); + expect(source).toContain( + "type BuilderReviewPublicationTransitions", + ); + expect(source).toContain( + "transitions: BuilderReviewPublicationTransitions = {}", + ); + expect(source).toContain( + "Object.keys(scopedTransitions).length > 0", + ); + expect(source).toContain( + "onValidate={(transitions) => void handleBuilderReviewPush(transitions)}", + ); + }); + it("keeps the Layout settings panel limited to implemented controls", () => { const source = readDatabaseSource(); diff --git a/templates/content/app/components/editor/DocumentDatabase.tsx b/templates/content/app/components/editor/DocumentDatabase.tsx index a38733c52b..6c4f556b77 100644 --- a/templates/content/app/components/editor/DocumentDatabase.tsx +++ b/templates/content/app/components/editor/DocumentDatabase.tsx @@ -145,7 +145,10 @@ import { peekPreviewDocumentSaveController, releasePreviewDocumentSaveController, } from "./previewDocumentSaveRegistry"; -import { BuilderSourceReviewDialog } from "./database-sources/BuilderSourceReviewDialog"; +import { + BuilderSourceReviewDialog, + type BuilderReviewPublicationTransitions, +} from "./database-sources/BuilderSourceReviewDialog"; import { BUILDER_CMS_SAFE_WRITE_MODEL, type BuilderCmsModelSummary, @@ -883,7 +886,9 @@ function DatabaseTable({ updateActiveView((view) => ({ ...view, hideEmptyGroups })); } - async function handleBuilderReviewPush() { + async function handleBuilderReviewPush( + transitions: BuilderReviewPublicationTransitions = {}, + ) { setBuilderReviewResult(null); setBuilderBatchResult(null); setBuilderReviewCheckedAt(null); @@ -898,9 +903,21 @@ function DatabaseTable({ nextReview.liveWritesEnabled && nextReview.result.status === "validated" ) { + const changeSetIds = nextReview.rows.map((row) => row.changeSetId); + const scopedTransitions = Object.fromEntries( + changeSetIds.flatMap((changeSetId) => + transitions[changeSetId] + ? [[changeSetId, transitions[changeSetId]]] + : [], + ), + ); batchResult = await executeBuilderBatch.mutateAsync({ documentId: document.id, - changeSetIds: nextReview.rows.map((row) => row.changeSetId), + changeSetIds, + transitions: + Object.keys(scopedTransitions).length > 0 + ? scopedTransitions + : undefined, }); setBuilderBatchResult(batchResult); } @@ -1528,7 +1545,7 @@ function DatabaseTable({ batchResult={builderBatchResult} checkedAt={builderReviewCheckedAt} onClose={() => setBuilderReviewOpen(false)} - onValidate={() => void handleBuilderReviewPush()} + onValidate={(transitions) => void handleBuilderReviewPush(transitions)} /> {!database.isLoading ? ( diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts new file mode 100644 index 0000000000..d1a422d3f6 --- /dev/null +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + builderReviewDefaultPublicationEffectLabel, + builderReviewPublicationIntentSummary, + builderReviewPublicationTransitionsMap, +} from "./BuilderSourceReviewDialog"; + +describe("BuilderSourceReviewDialog publication intent helpers", () => { + it("labels the default Builder publication effect from the source tier", () => { + expect(builderReviewDefaultPublicationEffectLabel("stage_only")).toBe( + "Stage autosave", + ); + expect(builderReviewDefaultPublicationEffectLabel("publish_updates")).toBe( + "Update in place (keeps current published/draft state)", + ); + }); + + it("summarizes per-row publication intent selections", () => { + expect( + builderReviewPublicationIntentSummary( + ["change-1", "change-2", "change-3"], + { + "change-2": { publicationTransition: "publish" }, + "change-3": { + publicationTransition: "unpublish", + confirmUnpublish: true, + }, + }, + "publish_updates", + ), + ).toBe("1 update in place · 1 publish · 1 unpublish"); + }); + + it("builds a batch transition map without defaulting unselected rows", () => { + expect( + builderReviewPublicationTransitionsMap({ + "change-2": { publicationTransition: "publish" }, + "change-3": { + publicationTransition: "unpublish", + confirmUnpublish: true, + }, + "change-4": { + publicationTransition: "unpublish", + confirmUnpublish: false, + }, + }), + ).toEqual({ + "change-2": { publicationTransition: "publish" }, + "change-3": { + publicationTransition: "unpublish", + confirmUnpublish: true, + }, + }); + }); +}); diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx index fbc5f38b94..54734e346c 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx @@ -1,3 +1,4 @@ +import { useEffect, useMemo, useState } from "react"; import { IconCheck, IconX } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { @@ -9,13 +10,86 @@ import { import { Spinner } from "@/components/ui/spinner"; import { BUILDER_CMS_SAFE_WRITE_MODEL } from "@shared/api"; import type { + BuilderCmsPublicationTransitionIntent, ContentDatabaseSource, ContentDatabaseSourceChangeSet, ContentDatabaseSourceReviewPayload, + ContentDatabaseSourceWriteMode, DocumentPropertyValue, + ExecuteBuilderSourceBatchTransition, ExecuteBuilderSourceBatchResponse, } from "@shared/api"; +export type BuilderReviewPublicationTransitionSelection = { + publicationTransition: BuilderCmsPublicationTransitionIntent; + confirmUnpublish?: boolean; +}; + +export type BuilderReviewPublicationTransitionSelections = Record< + string, + BuilderReviewPublicationTransitionSelection +>; + +export type BuilderReviewPublicationTransitions = Record< + string, + ExecuteBuilderSourceBatchTransition +>; + +export function builderReviewDefaultPublicationEffectLabel( + writeMode?: ContentDatabaseSourceWriteMode, +) { + if (writeMode === "stage_only") return "Stage autosave"; + if (writeMode === "publish_updates") { + return "Update in place (keeps current published/draft state)"; + } + return "Check only"; +} + +export function builderReviewPublicationIntentSummary( + changeSetIds: string[], + selections: BuilderReviewPublicationTransitionSelections, + writeMode?: ContentDatabaseSourceWriteMode, +) { + const publish = changeSetIds.filter( + (changeSetId) => + selections[changeSetId]?.publicationTransition === "publish", + ).length; + const unpublish = changeSetIds.filter( + (changeSetId) => + selections[changeSetId]?.publicationTransition === "unpublish", + ).length; + const defaultAction = Math.max(changeSetIds.length - publish - unpublish, 0); + const defaultLabel = + writeMode === "stage_only" ? "stage autosave" : "update in place"; + + return `${defaultAction} ${defaultLabel} · ${publish} publish · ${unpublish} unpublish`; +} + +export function builderReviewPublicationTransitionsMap( + selections: BuilderReviewPublicationTransitionSelections, +) { + const transitions: BuilderReviewPublicationTransitions = {}; + + for (const [changeSetId, selection] of Object.entries(selections)) { + if (selection.publicationTransition === "publish") { + transitions[changeSetId] = { publicationTransition: "publish" }; + continue; + } + + if ( + selection.publicationTransition === "unpublish" && + selection.confirmUnpublish === true + ) { + transitions[changeSetId] = { + publicationTransition: "unpublish", + confirmUnpublish: true, + }; + } + } + + return transitions; +} + function sourceRiskClass(risk: ContentDatabaseSourceChangeSet["riskLevel"]) { if (risk === "high") { return "rounded border border-destructive/40 bg-destructive/10 px-1.5 py-0.5 text-destructive"; @@ -79,12 +153,59 @@ export function BuilderSourceReviewDialog({ batchResult: ExecuteBuilderSourceBatchResponse | null; checkedAt: string | null; onClose: () => void; - onValidate: () => void; + onValidate: (transitions: BuilderReviewPublicationTransitions) => void; }) { const checked = !!checkedAt; const safeModel = source?.sourceType === "builder-cms" && source.sourceTable === BUILDER_CMS_SAFE_WRITE_MODEL; + const writeMode = source?.metadata.writeMode; + const defaultEffectLabel = + builderReviewDefaultPublicationEffectLabel(writeMode); + const allowPublicationTransitionControls = + safeModel && + writeMode === "publish_updates" && + source?.metadata.allowPublicationTransitions === true; + const reviewRowIds = useMemo( + () => review?.rows.map((row) => row.changeSetId) ?? [], + [review], + ); + const reviewRowIdsKey = reviewRowIds.join("\u0000"); + const [transitionSelections, setTransitionSelections] = + useState({}); + useEffect(() => { + if (!open || !allowPublicationTransitionControls) { + setTransitionSelections({}); + return; + } + + const reviewRowIdSet = new Set( + reviewRowIdsKey ? reviewRowIdsKey.split("\u0000") : [], + ); + setTransitionSelections((current) => { + const next: BuilderReviewPublicationTransitionSelections = {}; + for (const [changeSetId, selection] of Object.entries(current)) { + if (reviewRowIdSet.has(changeSetId)) next[changeSetId] = selection; + } + return Object.keys(next).length === Object.keys(current).length + ? current + : next; + }); + }, [allowPublicationTransitionControls, open, reviewRowIdsKey]); + const transitionMap = useMemo( + () => builderReviewPublicationTransitionsMap(transitionSelections), + [transitionSelections], + ); + const intentSummary = builderReviewPublicationIntentSummary( + reviewRowIds, + transitionSelections, + writeMode, + ); + const hasUnconfirmedUnpublish = Object.values(transitionSelections).some( + (selection) => + selection.publicationTransition === "unpublish" && + selection.confirmUnpublish !== true, + ); const batchHasIssues = !!batchResult && (batchResult.summary.blocked > 0 || batchResult.summary.failed > 0); @@ -100,7 +221,8 @@ export function BuilderSourceReviewDialog({ (!retryable && checked) || !review || review.rows.length === 0 || - unsafeLiveTarget; + unsafeLiveTarget || + hasUnconfirmedUnpublish; const rowTitleById = new Map( review?.rows.map((row) => [row.changeSetId, row.title]) ?? [], ); @@ -120,19 +242,21 @@ export function BuilderSourceReviewDialog({ ? review?.liveWritesEnabled ? "Preparing the Builder gate and sending through the guarded write path." : "Checking the Builder gate locally." - : unsafeLiveTarget - ? `Live batch pushes are limited to ${BUILDER_CMS_SAFE_WRITE_MODEL}.` - : checked - ? review?.result.status === "succeeded" - ? "Pushed to Builder and reconciled locally." - : batchResult - ? batchSummaryText - : review?.liveWritesEnabled - ? (review?.result.message ?? "Builder push finished.") - : "Checked just now. Nothing was sent to Builder." - : review?.liveWritesEnabled - ? "Push will send approved writes through the guarded Builder path." - : "Builder writes are disabled. Push will check the update only."; + : hasUnconfirmedUnpublish + ? "Confirm unpublish on selected rows before pushing." + : unsafeLiveTarget + ? `Live batch pushes are limited to ${BUILDER_CMS_SAFE_WRITE_MODEL}.` + : checked + ? review?.result.status === "succeeded" + ? "Pushed to Builder and reconciled locally." + : batchResult + ? batchSummaryText + : review?.liveWritesEnabled + ? (review?.result.message ?? "Builder push finished.") + : "Checked just now. Nothing was sent to Builder." + : review?.liveWritesEnabled + ? "Push will send approved writes through the guarded Builder path." + : "Builder writes are disabled. Push will check the update only."; const buttonLabel = pending ? review?.liveWritesEnabled ? "Pushing..." @@ -149,6 +273,44 @@ export function BuilderSourceReviewDialog({ ? `Push all approved (${review?.rows.length ?? 0})` : "Push"; + function setRowPublicationTransition( + changeSetId: string, + publicationTransition: BuilderCmsPublicationTransitionIntent, + ) { + setTransitionSelections((current) => { + const currentSelection = current[changeSetId]; + const next = { ...current }; + + if (currentSelection?.publicationTransition === publicationTransition) { + delete next[changeSetId]; + return next; + } + + next[changeSetId] = { + publicationTransition, + confirmUnpublish: + publicationTransition === "unpublish" ? false : undefined, + }; + return next; + }); + } + + function setRowConfirmUnpublish(changeSetId: string, confirmed: boolean) { + setTransitionSelections((current) => { + const currentSelection = current[changeSetId]; + if (currentSelection?.publicationTransition !== "unpublish") { + return current; + } + return { + ...current, + [changeSetId]: { + publicationTransition: "unpublish", + confirmUnpublish: confirmed, + }, + }; + }); + } + return (
+ {safeModel ? ( +
+ + {defaultEffectLabel} + + {allowPublicationTransitionControls ? ( +
+ + + {transitionSelections[row.changeSetId] + ?.publicationTransition === "unpublish" ? ( + + ) : null} +
+ ) : null} +
+ ) : null} {row.fieldChanges.map((field) => (
-
- {footerText} +
+ {safeModel && review ? ( +
{intentSummary}
+ ) : null} +
{footerText}
@@ -5258,15 +5260,6 @@ function sourceBuilderReadModeSummary(source: ContentDatabaseSource) { return "Local fixture"; } -function sourcePushModeLabel( - mode: ContentDatabaseSource["metadata"]["pushMode"] | null | undefined, -) { - if (mode === "autosave") return "Save revision / autosave"; - if (mode === "draft") return "Draft"; - if (mode === "publish") return "Publish"; - return "No push"; -} - function sourceFieldMappingForColumn( source: ContentDatabaseSource | null, columnKey: ColumnKey, diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts index d1a422d3f6..b271b5d749 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts @@ -1,24 +1,48 @@ import { describe, expect, it } from "vitest"; import { - builderReviewDefaultPublicationEffectLabel, - builderReviewPublicationIntentSummary, + builderReviewDestinationLine, + builderReviewEffectiveRowEffect, + builderReviewIntentSummary, builderReviewPublicationTransitionsMap, + builderReviewResultStatus, + builderReviewRowEffectLabel, } from "./BuilderSourceReviewDialog"; describe("BuilderSourceReviewDialog publication intent helpers", () => { - it("labels the default Builder publication effect from the source tier", () => { - expect(builderReviewDefaultPublicationEffectLabel("stage_only")).toBe( - "Stage autosave", - ); - expect(builderReviewDefaultPublicationEffectLabel("publish_updates")).toBe( - "Update in place (keeps current published/draft state)", + it("labels each write effect in plain language", () => { + expect(builderReviewRowEffectLabel("create_draft")).toEqual({ + tag: "New", + sentence: "Creates a new draft entry", + }); + expect(builderReviewRowEffectLabel("update_in_place").tag).toBe("Edit"); + expect(builderReviewRowEffectLabel("unpublish").tag).toBe("Unpublish"); + }); + + it("lets a chosen transition override the base effect", () => { + expect(builderReviewEffectiveRowEffect("create_draft", undefined)).toBe( + "create_draft", ); + expect( + builderReviewEffectiveRowEffect("update_in_place", { + publicationTransition: "publish", + }), + ).toBe("publish"); + expect( + builderReviewEffectiveRowEffect("update_in_place", { + publicationTransition: "unpublish", + confirmUnpublish: true, + }), + ).toBe("unpublish"); }); - it("summarizes per-row publication intent selections", () => { + it("summarizes per-row intent in plain language, honoring transitions", () => { expect( - builderReviewPublicationIntentSummary( - ["change-1", "change-2", "change-3"], + builderReviewIntentSummary( + [ + { changeSetId: "change-1", effect: "create_draft" }, + { changeSetId: "change-2", effect: "update_in_place" }, + { changeSetId: "change-3", effect: "update_in_place" }, + ], { "change-2": { publicationTransition: "publish" }, "change-3": { @@ -26,9 +50,49 @@ describe("BuilderSourceReviewDialog publication intent helpers", () => { confirmUnpublish: true, }, }, - "publish_updates", ), - ).toBe("1 update in place · 1 publish · 1 unpublish"); + ).toBe("1 draft to create · 1 to publish · 1 to unpublish"); + }); + + it("describes the destination from the dominant effect", () => { + expect( + builderReviewDestinationLine({ + rows: [{ changeSetId: "change-1", effect: "create_draft" }], + selections: {}, + liveWritesEnabled: true, + }), + ).toBe("Writes a new draft to Builder — won't publish."); + + expect( + builderReviewDestinationLine({ + rows: [{ changeSetId: "change-1", effect: "update_in_place" }], + selections: {}, + liveWritesEnabled: true, + }), + ).toBe("Updates content in Builder — publication state is preserved."); + + expect( + builderReviewDestinationLine({ + rows: [{ changeSetId: "change-1", effect: "create_draft" }], + selections: {}, + liveWritesEnabled: false, + }), + ).toBe("Checks the update only — nothing is sent to Builder."); + }); + + it("maps execution status to plain-language result labels", () => { + expect(builderReviewResultStatus("succeeded")).toEqual({ + label: "Pushed", + tone: "ok", + }); + expect(builderReviewResultStatus("validated").label).toBe("Ready"); + expect(builderReviewResultStatus("blocked")).toEqual({ + label: "Needs attention", + tone: "warn", + }); + expect(builderReviewResultStatus("write_disabled").label).toBe( + "Checks only", + ); }); it("builds a batch transition map without defaulting unselected rows", () => { diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx index 54734e346c..6b42820cb0 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx @@ -1,5 +1,10 @@ import { useEffect, useMemo, useState } from "react"; -import { IconCheck, IconX } from "@tabler/icons-react"; +import { + IconAlertTriangle, + IconCheck, + IconCloudUpload, + IconX, +} from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -11,10 +16,9 @@ import { Spinner } from "@/components/ui/spinner"; import { BUILDER_CMS_SAFE_WRITE_MODEL } from "@shared/api"; import type { BuilderCmsPublicationTransitionIntent, + BuilderCmsWriteEffect, ContentDatabaseSource, - ContentDatabaseSourceChangeSet, ContentDatabaseSourceReviewPayload, - ContentDatabaseSourceWriteMode, DocumentPropertyValue, ExecuteBuilderSourceBatchTransition, ExecuteBuilderSourceBatchResponse, @@ -35,34 +39,128 @@ export type BuilderReviewPublicationTransitions = Record< ExecuteBuilderSourceBatchTransition >; -export function builderReviewDefaultPublicationEffectLabel( - writeMode?: ContentDatabaseSourceWriteMode, -) { - if (writeMode === "stage_only") return "Stage autosave"; - if (writeMode === "publish_updates") { - return "Update in place (keeps current published/draft state)"; - } - return "Check only"; +const EFFECT_LABELS: Record< + BuilderCmsWriteEffect, + { tag: string; sentence: string } +> = { + create_draft: { tag: "New", sentence: "Creates a new draft entry" }, + update_in_place: { tag: "Edit", sentence: "Updates the live entry" }, + autosave: { tag: "Draft", sentence: "Saves a draft revision" }, + publish: { tag: "Publish", sentence: "Publishes this entry" }, + unpublish: { tag: "Unpublish", sentence: "Unpublishes the live entry" }, +}; + +export function builderReviewRowEffectLabel(effect: BuilderCmsWriteEffect) { + return EFFECT_LABELS[effect] ?? EFFECT_LABELS.update_in_place; } -export function builderReviewPublicationIntentSummary( - changeSetIds: string[], +/** The effect a row will actually run, accounting for a chosen transition. */ +export function builderReviewEffectiveRowEffect( + baseEffect: BuilderCmsWriteEffect, + selection?: BuilderReviewPublicationTransitionSelection, +): BuilderCmsWriteEffect { + if (selection?.publicationTransition === "publish") return "publish"; + if (selection?.publicationTransition === "unpublish") return "unpublish"; + return baseEffect; +} + +export function builderReviewIntentSummary( + rows: { changeSetId: string; effect: BuilderCmsWriteEffect }[], selections: BuilderReviewPublicationTransitionSelections, - writeMode?: ContentDatabaseSourceWriteMode, ) { - const publish = changeSetIds.filter( - (changeSetId) => - selections[changeSetId]?.publicationTransition === "publish", - ).length; - const unpublish = changeSetIds.filter( - (changeSetId) => - selections[changeSetId]?.publicationTransition === "unpublish", - ).length; - const defaultAction = Math.max(changeSetIds.length - publish - unpublish, 0); - const defaultLabel = - writeMode === "stage_only" ? "stage autosave" : "update in place"; + const counts: Record = { + create_draft: 0, + update_in_place: 0, + autosave: 0, + publish: 0, + unpublish: 0, + }; + for (const row of rows) { + counts[ + builderReviewEffectiveRowEffect(row.effect, selections[row.changeSetId]) + ] += 1; + } + const parts: string[] = []; + if (counts.create_draft) { + parts.push( + `${counts.create_draft} draft${counts.create_draft === 1 ? "" : "s"} to create`, + ); + } + if (counts.update_in_place) { + parts.push( + `${counts.update_in_place} update${counts.update_in_place === 1 ? "" : "s"}`, + ); + } + if (counts.autosave) { + parts.push( + `${counts.autosave} draft save${counts.autosave === 1 ? "" : "s"}`, + ); + } + if (counts.publish) parts.push(`${counts.publish} to publish`); + if (counts.unpublish) parts.push(`${counts.unpublish} to unpublish`); + return parts.join(" · ") || "No changes"; +} - return `${defaultAction} ${defaultLabel} · ${publish} publish · ${unpublish} unpublish`; +export function builderReviewDestinationLine(args: { + rows: { changeSetId: string; effect: BuilderCmsWriteEffect }[]; + selections: BuilderReviewPublicationTransitionSelections; + liveWritesEnabled: boolean; +}) { + if (!args.liveWritesEnabled) { + return "Checks the update only — nothing is sent to Builder."; + } + const effects = new Set( + args.rows.map((row) => + builderReviewEffectiveRowEffect( + row.effect, + args.selections[row.changeSetId], + ), + ), + ); + if (effects.has("unpublish")) { + return "Unpublishes selected entries in Builder."; + } + if (effects.has("publish")) { + return "Publishes selected entries in Builder."; + } + if (effects.size === 1 && effects.has("create_draft")) { + return args.rows.length === 1 + ? "Writes a new draft to Builder — won't publish." + : "Writes new drafts to Builder — nothing is published."; + } + return "Updates content in Builder — publication state is preserved."; +} + +export function builderReviewResultStatus(status?: string): { + label: string; + tone: "ok" | "warn" | "danger" | "muted"; +} { + switch (status) { + case "succeeded": + return { label: "Pushed", tone: "ok" }; + case "validated": + return { label: "Ready", tone: "ok" }; + case "partial": + case "blocked": + return { label: "Needs attention", tone: "warn" }; + case "failed": + return { label: "Failed — you can retry", tone: "danger" }; + case "stale": + return { label: "Needs a fresh review", tone: "warn" }; + case "running": + return { label: "Working…", tone: "muted" }; + case "write_disabled": + return { label: "Checks only", tone: "muted" }; + default: + return { label: "Ready", tone: "muted" }; + } +} + +function resultToneClass(tone: "ok" | "warn" | "danger" | "muted") { + if (tone === "ok") return "text-emerald-600 dark:text-emerald-400"; + if (tone === "warn") return "text-amber-700 dark:text-amber-300"; + if (tone === "danger") return "text-destructive"; + return "text-muted-foreground"; } export function builderReviewPublicationTransitionsMap( @@ -90,14 +188,14 @@ export function builderReviewPublicationTransitionsMap( return transitions; } -function sourceRiskClass(risk: ContentDatabaseSourceChangeSet["riskLevel"]) { - if (risk === "high") { - return "rounded border border-destructive/40 bg-destructive/10 px-1.5 py-0.5 text-destructive"; - } - if (risk === "medium") { - return "rounded border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-amber-700"; - } - return "rounded border border-emerald-300 bg-emerald-50 px-1.5 py-0.5 text-emerald-700"; +const ATTENTION_TAG_EFFECTS: ReadonlySet = new Set([ + "unpublish", +]); + +function rowEffectTagClass(effect: BuilderCmsWriteEffect) { + return ATTENTION_TAG_EFFECTS.has(effect) + ? "rounded border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300" + : "rounded border border-border bg-muted/40 px-1.5 py-0.5 text-muted-foreground"; } function sourceValueText(value: DocumentPropertyValue) { @@ -107,33 +205,6 @@ function sourceValueText(value: DocumentPropertyValue) { return String(value); } -function sourceBuilderReadModeSummary(source: ContentDatabaseSource) { - if (source.metadata.readMode === "builder-api") - return "Builder API read-only"; - if (source.metadata.readMode === "local-fixture") return "Local fixture"; - if (source.metadata.readMode === "unconfigured") return "Not configured"; - if (source.metadata.readMode === "error") return "Read error"; - return "Local review"; -} - -function sourcePushModeLabel( - mode: ContentDatabaseSource["metadata"]["pushMode"], -) { - if (mode === "autosave") return "Save revision / autosave"; - if (mode === "draft") return "Draft"; - if (mode === "publish") return "Publish"; - return "None"; -} - -function SourceMetadataRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - export function BuilderSourceReviewDialog({ open, review, @@ -160,15 +231,14 @@ export function BuilderSourceReviewDialog({ source?.sourceType === "builder-cms" && source.sourceTable === BUILDER_CMS_SAFE_WRITE_MODEL; const writeMode = source?.metadata.writeMode; - const defaultEffectLabel = - builderReviewDefaultPublicationEffectLabel(writeMode); const allowPublicationTransitionControls = safeModel && writeMode === "publish_updates" && source?.metadata.allowPublicationTransitions === true; + const reviewRows = useMemo(() => review?.rows ?? [], [review]); const reviewRowIds = useMemo( - () => review?.rows.map((row) => row.changeSetId) ?? [], - [review], + () => reviewRows.map((row) => row.changeSetId), + [reviewRows], ); const reviewRowIdsKey = reviewRowIds.join("\u0000"); const [transitionSelections, setTransitionSelections] = @@ -196,11 +266,15 @@ export function BuilderSourceReviewDialog({ () => builderReviewPublicationTransitionsMap(transitionSelections), [transitionSelections], ); - const intentSummary = builderReviewPublicationIntentSummary( - reviewRowIds, + const intentSummary = builderReviewIntentSummary( + reviewRows, transitionSelections, - writeMode, ); + const destinationLine = builderReviewDestinationLine({ + rows: reviewRows, + selections: transitionSelections, + liveWritesEnabled: review?.liveWritesEnabled === true, + }); const hasUnconfirmedUnpublish = Object.values(transitionSelections).some( (selection) => selection.publicationTransition === "unpublish" && @@ -224,43 +298,52 @@ export function BuilderSourceReviewDialog({ unsafeLiveTarget || hasUnconfirmedUnpublish; const rowTitleById = new Map( - review?.rows.map((row) => [row.changeSetId, row.title]) ?? [], + reviewRows.map((row) => [row.changeSetId, row.title]), ); const batchIssueResults = batchResult?.results.filter((result) => result.status !== "succeeded") ?? []; - const batchSummaryText = batchResult - ? `${batchResult.summary.succeeded} succeeded, ${batchResult.summary.blocked} blocked, ${batchResult.summary.failed} failed.` - : null; - const resultMessage = batchSummaryText ?? review?.result.message; - const resultStatus = batchResult - ? batchHasIssues - ? "partial" - : "succeeded" - : review?.result.status; - const footerText = pending + const resultStatus = builderReviewResultStatus( + batchResult + ? batchHasIssues + ? "partial" + : "succeeded" + : review?.result.status, + ); + const footerHint = pending ? review?.liveWritesEnabled - ? "Preparing the Builder gate and sending through the guarded write path." - : "Checking the Builder gate locally." + ? "Sending to Builder…" + : "Checking…" : hasUnconfirmedUnpublish - ? "Confirm unpublish on selected rows before pushing." + ? "Confirm unpublish on the selected rows first." : unsafeLiveTarget - ? `Live batch pushes are limited to ${BUILDER_CMS_SAFE_WRITE_MODEL}.` - : checked - ? review?.result.status === "succeeded" - ? "Pushed to Builder and reconciled locally." - : batchResult - ? batchSummaryText - : review?.liveWritesEnabled - ? (review?.result.message ?? "Builder push finished.") - : "Checked just now. Nothing was sent to Builder." - : review?.liveWritesEnabled - ? "Push will send approved writes through the guarded Builder path." - : "Builder writes are disabled. Push will check the update only."; + ? `Live pushes are limited to the ${BUILDER_CMS_SAFE_WRITE_MODEL} test model.` + : null; + const effectiveEffects = new Set( + reviewRows.map((row) => + builderReviewEffectiveRowEffect( + row.effect, + transitionSelections[row.changeSetId], + ), + ), + ); + const pushVerb = !review?.liveWritesEnabled + ? "Check" + : effectiveEffects.has("unpublish") + ? "Unpublish" + : effectiveEffects.has("publish") + ? "Publish" + : effectiveEffects.size === 1 && effectiveEffects.has("create_draft") + ? reviewRows.length > 1 + ? `Create ${reviewRows.length} drafts` + : "Create draft" + : reviewRows.length > 1 + ? `Push ${reviewRows.length} updates` + : "Push update"; const buttonLabel = pending ? review?.liveWritesEnabled - ? "Pushing..." - : "Checking..." + ? "Working…" + : "Checking…" : checked && batchResult ? batchHasIssues ? "Retry" @@ -269,9 +352,7 @@ export function BuilderSourceReviewDialog({ ? "Pushed" : checked && !retryable ? "Checked" - : review?.liveWritesEnabled && (review?.rows.length ?? 0) > 1 - ? `Push all approved (${review?.rows.length ?? 0})` - : "Push"; + : pushVerb; function setRowPublicationTransition( changeSetId: string, @@ -350,242 +431,194 @@ export function BuilderSourceReviewDialog({
What changed
- {review.rows.map((row) => ( -
-
-
-
- {row.title} -
-
- {row.fieldChanges.length} field change - {row.fieldChanges.length === 1 ? "" : "s"} - {row.bodyChange ? " plus body diff" : ""} + {reviewRows.map((row) => { + const selection = transitionSelections[row.changeSetId]; + const effect = builderReviewEffectiveRowEffect( + row.effect, + selection, + ); + const { tag, sentence } = + builderReviewRowEffectLabel(effect); + const showConflict = + effect !== "create_draft" && + row.conflictState === "source_changed"; + return ( +
+
+
+
+ {row.title} +
+
+ {sentence} +
+ + {tag} +
- - {row.riskLevel} risk - -
-
- {safeModel ? ( -
- - {defaultEffectLabel} - - {allowPublicationTransitionControls ? ( -
- - - {transitionSelections[row.changeSetId] - ?.publicationTransition === "unpublish" ? ( - - ) : null} +
+ {allowPublicationTransitionControls ? ( +
+ + + {transitionSelections[row.changeSetId] + ?.publicationTransition === "unpublish" ? ( + + ) : null} +
+ ) : null} + {row.fieldChanges.map((field) => ( +
+
+ {field.propertyName ?? field.sourceFieldKey} +
+
+
+ From: {sourceValueText(field.currentValue)} +
+
+ To: {sourceValueText(field.proposedValue)} +
- ) : null} -
- ) : null} - {row.fieldChanges.map((field) => ( -
-
- {field.propertyName ?? field.sourceFieldKey}
-
-
- From: {sourceValueText(field.currentValue)} + ))} + {row.bodyChange ? ( +
+
+ {row.bodyChange.summary}
-
- To: {sourceValueText(field.proposedValue)} +
+ Builder body edits need a safer push path before + they can be sent.
-
- ))} - {row.bodyChange ? ( -
-
- {row.bodyChange.summary} + ) : null} + {showConflict ? ( +
+ + + Changed in Builder since you synced — review + before pushing. +
-
- Builder body edits need a safer push path before - they can be sent. + ) : null} + {effect === "unpublish" ? ( +
+ + + This unpublishes the live entry in Builder. +
-
- ) : null} - {row.execution?.lastError ? ( -
- {row.execution.lastError} -
- ) : null} + ) : null} + {row.execution?.lastError ? ( +
+ {row.execution.lastError} +
+ ) : null} +
-
- ))} + ); + })}
-
-
Where it will go
-
- - - - - -
-
+
+ + {destinationLine} +
-
-
Risk check
-
- - {review.riskLevel} risk - - {(review.riskReasons.length - ? review.riskReasons - : ["single field diff"] - ).map((reason) => ( - - {reason} - - ))} - - {review.dryRunOnly ? "checks only" : "can send to Builder"} - -
-
- -
-
-
-
Result
-
- {resultMessage} -
+ {batchResult && batchIssueResults.length > 0 ? ( +
+
+ Needs attention before this can finish
- - {resultStatus?.replace(/_/g, " ")} - -
- {batchResult ? ( -
-
- - {batchResult.summary.succeeded} succeeded - - - {batchResult.summary.blocked} blocked - - - {batchResult.summary.failed} failed + {batchIssueResults.map((result) => ( +
+ + {rowTitleById.get(result.changeSetId) ?? + result.changeSetId} + {" — "} + {result.message ?? "No details returned."}
- {batchIssueResults.length > 0 ? ( -
- {batchIssueResults.map((result) => ( -
-
- {rowTitleById.get(result.changeSetId) ?? - result.changeSetId} -
-
- {result.status}:{" "} - {result.message ?? "No details returned."} -
-
- ))} -
- ) : null} -
- ) : null} -
+ ))} + + ) : null}
) : (
@@ -596,10 +629,28 @@ export function BuilderSourceReviewDialog({
- {safeModel && review ? ( -
{intentSummary}
+ {review ? ( +
+ {checked ? ( + resultStatus.tone === "ok" ? ( + + ) : ( + + ) + ) : null} + + {checked + ? `${resultStatus.label} · ${intentSummary}` + : intentSummary} + +
) : null} -
{footerText}
+ {footerHint ?
{footerHint}
: null}