diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f7516ff03..a6948ecfde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1232,30 +1232,6 @@ importers: specifier: 'catalog:' version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0)(canvas@3.2.3))(vite@8.1.0(@types/node@25.9.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)) - templates/.retired/calls: {} - - templates/.retired/code: {} - - templates/.retired/contracts: {} - - templates/.retired/images: {} - - templates/.retired/issues: {} - - templates/.retired/meeting-notes: {} - - templates/.retired/migration: {} - - templates/.retired/recruiting: {} - - templates/.retired/scheduling: {} - - templates/.retired/visual-plans: {} - - templates/.retired/voice: {} - - templates/.retired/workbench: {} - templates/analytics: dependencies: '@agent-native/core': diff --git a/templates/content/README.md b/templates/content/README.md index 6403225cdc..a99530a949 100644 --- a/templates/content/README.md +++ b/templates/content/README.md @@ -28,3 +28,19 @@ Open http://localhost:8080 and create your first page. ## Data Documents are stored in the app's SQL database. Local development defaults to SQLite at `data/app.db`; deployed apps should set `DATABASE_URL` to a persistent SQL database. The agent should use content actions for normal document operations and reserve `db-query` / `db-exec` for inspection or maintenance. + +## Enable Builder live writes + +Connect Builder through the existing Builder Connect flow, the same connection used by the AI assistant. Once connected, Content resolves the key automatically for the user, or for org owners/admins through the org connection. There is no separate key entry. In local development, `BUILDER_PRIVATE_KEY` and `BUILDER_PUBLIC_KEY` in `.env.local` also work; see `DEVELOPING.md` for local env opt-in details. + +Live writes are only allowed for the safe write model `agent-native-blog-article-test` (`BUILDER_CMS_SAFE_WRITE_MODEL`). Other Builder models stay read-only by design. + +Write access is an intentional per-source choice from the source panel selector. Sources start at `read_only`, can move to `stage_only` to push approved changes as Builder autosave revisions, and can move to `publish_updates` for state-preserving live writes. Autosave staging stays quiet; update-in-place and publish writes trigger Builder webhooks so downstream rebuilds can run. + +In `publish_updates`, Content PATCHes the Builder entry in place and preserves its current publication state: published entries stay published and drafts stay draft. Content never sends a `published` field, so a normal content push cannot publish or unpublish. Builder merges the field changes and preserves the entry envelope, including scheduling and targeting. + +Publication transitions are explicit per-row choices in the review/diff, only when the source enables **Allow publish/unpublish per item**. Publishing a draft or unpublishing a published entry is never a bulk default, and unpublish requires confirmation because it takes live content down. New Builder entries are created as drafts. + +**Push all approved (N)** runs approved rows in a concurrency-capped batch. It continues after individual errors, reports per-item `succeeded`, `blocked`, or `failed` status, and can be resumed because already-succeeded items are skipped. + +Before each write, Content reads the target's current state from Builder. A write blocks if the entry changed or was deleted since the diff, or if a requested publish/unpublish transition no longer matches the real Builder state. Body diffs remain non-executable for now; they are planned for a later slice. diff --git a/templates/content/actions/_builder-cms-read-client.ts b/templates/content/actions/_builder-cms-read-client.ts index fb9b4d6900..ebd2b074ec 100644 --- a/templates/content/actions/_builder-cms-read-client.ts +++ b/templates/content/actions/_builder-cms-read-client.ts @@ -19,6 +19,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 = { @@ -52,6 +59,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); @@ -506,6 +587,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/_builder-cms-source-adapter.test.ts b/templates/content/actions/_builder-cms-source-adapter.test.ts index 7c50ffa4de..33c91d6305 100644 --- a/templates/content/actions/_builder-cms-source-adapter.test.ts +++ b/templates/content/actions/_builder-cms-source-adapter.test.ts @@ -66,12 +66,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", }); }); @@ -206,6 +209,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..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: @@ -196,6 +198,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 +335,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/_builder-cms-write-adapter.test.ts b/templates/content/actions/_builder-cms-write-adapter.test.ts index 95fd8890c2..da022464f2 100644 --- a/templates/content/actions/_builder-cms-write-adapter.test.ts +++ b/templates/content/actions/_builder-cms-write-adapter.test.ts @@ -8,12 +8,14 @@ import { BUILDER_CMS_SAFE_WRITE_MODEL } from "../shared/api"; import { buildBuilderCmsExecutionPlan, builderCmsExecutionIdempotencyKey, + resolveBuilderCmsExecutionPushMode, validateBuilderCmsExecutionDryRun, } from "./_builder-cms-write-adapter"; function source( liveWritesEnabled = false, sourceTable = "blog_article", + metadata: Partial = {}, ): ContentDatabaseSource { return { id: "source-1", @@ -44,6 +46,7 @@ function source( titleField: "data.title", naturalKeyField: "/blog/[slug]", pushMode: "autosave", + ...metadata, }, fields: [], rows: [ @@ -109,20 +112,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", }, @@ -154,23 +157,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, @@ -180,6 +194,335 @@ describe("Builder CMS write adapter plan", () => { }, lastError: null, }); + 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), + 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("resolves the gate push mode from the tier, ignoring a change-set's own pushMode", () => { + // Local create change-sets hardcode pushMode "autosave". Under the + // publish_updates tier the gate must still key on the tier mode ("publish"), + // so prepare and execute compute the same idempotency key. + const publishUpdatesSource = source(true, BUILDER_CMS_SAFE_WRITE_MODEL, { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + }); + const autosaveChangeSet: ContentDatabaseSourceChangeSet = { + ...approvedChangeSet(), + pushMode: "autosave", + }; + + expect( + resolveBuilderCmsExecutionPushMode({ + source: publishUpdatesSource, + changeSet: autosaveChangeSet, + }), + ).toBe("publish"); + }); + + it("keys a tier create-draft gate on the tier push mode, not autosave", () => { + // Reproduces the create-push regression: a new-row create change-set + // (no matched Builder entry, pushMode "autosave") pushed with no explicit + // confirmation under publish_updates. Prepare's gate key must be :publish so + // execute — which resolves the same way — finds the gate. + const plan = buildBuilderCmsExecutionPlan({ + source: { + ...source(true, BUILDER_CMS_SAFE_WRITE_MODEL, { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + }), + rows: [], + }, + changeSet: { + ...approvedChangeSet(), + pushMode: "autosave", + }, + }); + + expect(plan.payload.effect).toBe("create_draft"); + expect(plan.idempotencyKey).toBe("builder-cms:source-1:change-1:publish"); + }); + + it("blocks publication transitions when the source has not enabled them", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL, { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + allowPublicationTransitions: false, + }), + changeSet: { + ...approvedChangeSet(), + pushMode: "publish", + }, + 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", + }); + + 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, { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + allowPublicationTransitions: true, + }), + changeSet: { + ...approvedChangeSet(), + pushMode: "publish", + }, + pushModeConfirmation: "publish", + 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, { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + allowPublicationTransitions: true, + }), + changeSet: { + ...approvedChangeSet(), + pushMode: "publish", + }, + pushModeConfirmation: "publish", + 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", () => { @@ -204,7 +547,11 @@ describe("Builder CMS write adapter plan", () => { ); }); - it("blocks live autosave for unmatched legacy fixture-wrapped Builder rows", () => { + it("creates a draft for an unmatched (synthetic-fixture) Builder row", () => { + // A row synthesized as `builder-` has no real Builder entry, so + // its effect is create_draft. Creating a new entry from such a row is the + // intended behavior — the unmatched-row blocker only applies to effects that + // write to an existing entry (autosave / update_in_place). const plan = buildBuilderCmsExecutionPlan({ source: { ...source(true, BUILDER_CMS_SAFE_WRITE_MODEL), @@ -226,32 +573,30 @@ describe("Builder CMS write adapter plan", () => { }); expect(plan).toMatchObject({ - state: "blocked", - lastError: - "This row is not matched to a Builder entry yet. Refresh or match a Builder row before pushing.", + state: "ready", + lastError: null, payload: { + effect: "create_draft", target: { entryId: null, 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: { - dryRunOnly: true, - blockers: [ - "This row is not matched to a Builder entry yet. Refresh or match a Builder row before pushing.", - ], + dryRunOnly: false, + blockers: [], }, }, }); @@ -279,116 +624,86 @@ 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: [], - }, - changeSet: approvedChangeSet(), - pushModeConfirmation: "autosave", - }), - ).toMatchObject({ - state: "blocked", - lastError: "Autosave requires an existing Builder entry ID.", - payload: { - safety: { - blockers: ["Autosave requires an existing Builder entry ID."], - }, - }, - }); - }); - - it("keeps publish blocked without explicit adapter opt-in", () => { - expect( - buildBuilderCmsExecutionPlan({ - source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL), + source: source(true, "blog_article", { + writeMode: "publish_updates", + pushMode: "publish", + allowedWriteModes: ["autosave", "publish"], + allowPublicationTransitions: true, + }), changeSet: { ...approvedChangeSet(), pushMode: "publish", }, pushModeConfirmation: "publish", + publicationTransition: "publish", }), ).toMatchObject({ state: "blocked", - lastError: "Publish writes require explicit adapter opt-in.", payload: { - intent: "publish", - request: { - body: { - data: { - title: "New title", - }, - published: "published", - }, - }, + effect: "publish", safety: { - blockers: ["Publish writes require explicit adapter opt-in."], + blockers: [ + `Live Builder writes are only allowed for ${BUILDER_CMS_SAFE_WRITE_MODEL}.`, + ], }, }, }); }); - 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.", + 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", + }); + + 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.", ], }, }, @@ -470,7 +785,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, }, @@ -499,7 +814,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..f64bc8e3ce 100644 --- a/templates/content/actions/_builder-cms-write-adapter.ts +++ b/templates/content/actions/_builder-cms-write-adapter.ts @@ -1,16 +1,17 @@ import type { + BuilderCmsPublicationTransitionIntent, + BuilderCmsWriteEffect, ContentDatabaseSource, 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 BuilderCmsWriteIntent = - | "autosave_revision" - | "save_draft" - | "publish"; +export type { BuilderCmsWriteEffect }; export interface BuilderCmsExecutionOperation { sourceFieldKey: string; @@ -24,7 +25,7 @@ export interface BuilderCmsExecutionPayload { sourceTable: string; changeSetId: string; pushMode: ContentDatabaseSourcePushMode; - intent: BuilderCmsWriteIntent; + effect: BuilderCmsWriteEffect; target: { model: string; entryId: string | null; @@ -71,12 +72,107 @@ 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; + 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; +} + +/** + * Single source of truth for the push mode that gates an execution. Prepare and + * execute MUST resolve this identically, or their idempotency keys diverge and + * the gate lookup fails ("Prepare the Builder execution gate before executing + * it"). The write tier wins when set, so a change-set's own `pushMode` (e.g. a + * local create hardcoded to "autosave") cannot drift from the tier. + */ +export function resolveBuilderCmsExecutionPushMode(args: { + source: ContentDatabaseSource; + changeSet: ContentDatabaseSourceChangeSet; +}): ContentDatabaseSourcePushMode { + const sourceWriteMode = normalizeSourceWriteMode( + args.source.metadata.writeMode, + ); + if (sourceWriteMode) { + return builderCmsPushModeForTier(sourceWriteMode); + } + return args.changeSet.pushMode ?? args.source.metadata.pushMode ?? "autosave"; +} + +/** + * Resolve the Builder entry this change-set targets. A synthetic-fixture row + * (sourceRowId `builder-`, never matched to a real entry) resolves + * to a null entry id, which is what makes the effect a create. + */ +export function resolveBuilderCmsWriteTarget(args: { + source: ContentDatabaseSource; + changeSet: ContentDatabaseSourceChangeSet; +}) { + const targetRow = + args.source.rows.find( + (row) => + row.documentId === args.changeSet.documentId || + row.databaseItemId === args.changeSet.databaseItemId, + ) ?? null; + const target = targetRow + ? builderCmsSourceRowIdentityState({ row: targetRow }) + : null; + const entryId = target?.isSyntheticFixture + ? null + : (target?.sourceRowId ?? null); + const sourceQualifiedId = target?.isSyntheticFixture + ? null + : (target?.sourceQualifiedId ?? null); + return { targetRow, target, entryId, sourceQualifiedId }; +} + +/** + * The resolved write effect (create_draft / update_in_place / autosave / + * publish / unpublish) for a change-set. Unlike buildBuilderCmsExecutionPlan + * this does not require the change-set to be approved, so it is safe to call + * while building review payloads for plain-language labels. + */ +export function resolveBuilderCmsWriteEffect(args: { + source: ContentDatabaseSource; + changeSet: ContentDatabaseSourceChangeSet; + publicationTransition?: BuilderCmsPublicationTransitionIntent | null; +}): BuilderCmsWriteEffect { + const sourceWriteMode = normalizeSourceWriteMode( + args.source.metadata.writeMode, + ); + const pushMode = resolveBuilderCmsExecutionPushMode({ + source: args.source, + changeSet: args.changeSet, + }); + const effectivePushMode = pushMode === "none" ? "autosave" : pushMode; + const { entryId } = resolveBuilderCmsWriteTarget({ + source: args.source, + changeSet: args.changeSet, + }); + return builderEffectForWrite({ + pushMode: effectivePushMode, + writeMode: sourceWriteMode, + entryId, + publicationTransition: args.publicationTransition, + }); } function nestedBuilderPatch( @@ -98,15 +194,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 +213,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 +236,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 +266,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 +276,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 +285,45 @@ 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) { - blockers.push( - "Draft writes require explicit adapter opt-in because draft can affect already-live content.", - ); + if (args.effect === "create_draft") { + checks.push( + "Create draft writes a new Builder entry with published state set to draft.", + ); + // A create_draft target has no Builder entry by definition — that is the + // whole point of a create. The unmatched-row blocker only applies to + // effects that write to an existing entry (autosave / update_in_place). + } + 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.source.metadata.allowPublicationTransitions !== true) { + blockers.push("Publication transitions are not enabled for this source."); } } - 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 === "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."); } } @@ -202,6 +339,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 +359,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."); @@ -223,45 +374,56 @@ export function buildBuilderCmsExecutionPlan(args: { ); } - const pushMode = - args.changeSet.pushMode ?? args.source.metadata.pushMode ?? "autosave"; + const sourceWriteMode = normalizeSourceWriteMode( + args.source.metadata.writeMode, + ); + const pushMode = resolveBuilderCmsExecutionPushMode({ + source: args.source, + changeSet: args.changeSet, + }); + 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}.`, ); } - const intent = builderIntentForPushMode(pushMode); - const targetRow = - args.source.rows.find( - (row) => - row.documentId === args.changeSet.documentId || - row.databaseItemId === args.changeSet.databaseItemId, - ) ?? null; - const target = targetRow - ? builderCmsSourceRowIdentityState({ - row: targetRow, - }) - : null; - const targetEntryId = target?.isSyntheticFixture - ? null - : (target?.sourceRowId ?? null); - const targetSourceQualifiedId = target?.isSyntheticFixture - ? null - : (target?.sourceQualifiedId ?? null); + const { + target, + entryId: targetEntryId, + sourceQualifiedId: targetSourceQualifiedId, + } = resolveBuilderCmsWriteTarget({ + source: args.source, + changeSet: args.changeSet, + }); + const effect = builderEffectForWrite({ + pushMode: effectivePushMode, + writeMode: sourceWriteMode, + 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, @@ -269,8 +431,10 @@ export function buildBuilderCmsExecutionPlan(args: { const safety = builderSafetyChecks({ source: args.source, changeSet: args.changeSet, - pushMode, - intent, + pushMode: effectivePushMode, + effect, + publicationTransition: args.publicationTransition, + confirmUnpublish: args.confirmUnpublish, entryId: targetEntryId, syntheticFixtureTarget: args.source.capabilities.liveWritesEnabled === true && @@ -284,17 +448,22 @@ export function buildBuilderCmsExecutionPlan(args: { : args.source.capabilities.liveWritesEnabled === true ? "ready" : "write_disabled"; + // Key on the RAW resolved push mode (which may be "none" for a read-only + // tier), not the effective one. Collapsing "none" → "autosave" would let a + // read-only gate share a key with a stage-only gate for the same change-set, + // so enabling live writes could reuse a gate prepared under read-only. const idempotencyKey = builderCmsExecutionIdempotencyKey({ sourceId: args.source.id, changeSetId: args.changeSet.id, pushMode, }); + 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 @@ -304,7 +473,7 @@ export function buildBuilderCmsExecutionPlan(args: { return { adapter: "builder-cms", - pushMode, + pushMode: effectivePushMode, state, idempotencyKey, summary, @@ -313,7 +482,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, @@ -321,7 +490,7 @@ export function buildBuilderCmsExecutionPlan(args: { documentId: args.changeSet.documentId, databaseItemId: args.changeSet.databaseItemId, }, - pushMode, + pushMode: effectivePushMode, request, operations, safety: { @@ -384,9 +553,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 +583,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/actions/_builder-cms-write-settings.test.ts b/templates/content/actions/_builder-cms-write-settings.test.ts index 7dce09ebbe..737f474026 100644 --- a/templates/content/actions/_builder-cms-write-settings.test.ts +++ b/templates/content/actions/_builder-cms-write-settings.test.ts @@ -11,10 +11,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, }); @@ -26,8 +28,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( @@ -36,81 +37,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( @@ -119,11 +126,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", () => { @@ -132,8 +146,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({ @@ -144,7 +157,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, @@ -158,6 +172,7 @@ describe("Builder CMS write settings", () => { metadataJson: refreshed.metadataJson, }), ).toMatchObject({ + writeMode: "stage_only", liveWritesEnabled: true, allowedWriteModes: ["autosave"], }); @@ -173,6 +188,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"), @@ -186,4 +202,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.test.ts b/templates/content/actions/_database-source-utils.test.ts index 99ea50ca17..0e119c10bd 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -201,6 +201,232 @@ describe("database source helpers", () => { }); }); + it("detects a changed mapped property field on an existing row (not just title)", () => { + const [changeSet] = buildBuilderLocalOutboundChangeSets({ + source: { sourceType: "builder-cms" }, + rowRows: [ + { + id: "row-source", + databaseItemId: "item-1", + documentId: "doc-1", + sourceDisplayKey: "Same title", + sourceValuesJson: JSON.stringify({ "data.body": "old body" }), + }, + ], + documentTitleById: new Map([["doc-1", "Same title"]]), + storedChangeSets: [], + localValuesByDocument: new Map([ + ["doc-1", new Map([["prop-body", "new body"]])], + ]), + writableFields: [ + { + propertyId: "prop-body", + localFieldKey: "prop-body", + sourceFieldKey: "data.body", + sourceFieldLabel: "Body", + }, + ], + } as Parameters[0]); + + expect(changeSet).toMatchObject({ + direction: "outbound", + fieldChanges: [ + { + localFieldKey: "prop-body", + sourceFieldKey: "data.body", + currentValue: "old body", + proposedValue: "new body", + }, + ], + }); + }); + + it("does NOT diff a mapped field whose local value matches the source baseline", () => { + const pending = buildBuilderLocalOutboundChangeSets({ + source: { sourceType: "builder-cms" }, + rowRows: [ + { + id: "row-source", + databaseItemId: "item-1", + documentId: "doc-1", + sourceDisplayKey: "Same title", + sourceValuesJson: JSON.stringify({ "data.body": "same body" }), + }, + ], + documentTitleById: new Map([["doc-1", "Same title"]]), + storedChangeSets: [], + localValuesByDocument: new Map([ + ["doc-1", new Map([["prop-body", "same body"]])], + ]), + writableFields: [ + { + propertyId: "prop-body", + localFieldKey: "prop-body", + sourceFieldKey: "data.body", + sourceFieldLabel: "Body", + }, + ], + } as Parameters[0]); + expect(pending).toHaveLength(0); + }); + + it("creates a create_draft change-set for a new local row not linked to Builder", () => { + const pending = buildBuilderLocalOutboundChangeSets({ + source: { sourceType: "builder-cms" }, + rowRows: [ + { + id: "row-source", + databaseItemId: "item-linked", + documentId: "doc-linked", + sourceDisplayKey: "Linked entry", + }, + ], + documentTitleById: new Map([ + ["doc-linked", "Linked entry"], + ["doc-new", "Brand New Article"], + ]), + storedChangeSets: [], + databaseItems: [ + { databaseItemId: "item-linked", documentId: "doc-linked" }, + { databaseItemId: "item-new", documentId: "doc-new" }, + ], + localValuesByDocument: new Map([ + ["doc-new", new Map([["prop-body", "Hello body"]])], + ]), + writableFields: [ + { + propertyId: "prop-body", + localFieldKey: "prop-body", + sourceFieldKey: "data.body", + sourceFieldLabel: "Body", + }, + ], + } as Parameters[0]); + + const create = pending.find((cs) => cs.documentId === "doc-new"); + expect(create).toMatchObject({ + direction: "outbound", + state: "pending_push", + databaseItemId: "item-new", + summary: 'Pending new Builder entry "Brand New Article".', + fieldChanges: [ + { + localFieldKey: "title", + sourceFieldKey: "data.title", + currentValue: null, + proposedValue: "Brand New Article", + }, + { + localFieldKey: "prop-body", + sourceFieldKey: "data.body", + currentValue: null, + proposedValue: "Hello body", + }, + ], + }); + // The already-linked row with no title change yields nothing. + expect( + pending.find((cs) => cs.documentId === "doc-linked"), + ).toBeUndefined(); + }); + + it("does not create rows owned by another source (row-union scoping)", () => { + const pending = buildBuilderLocalOutboundChangeSets({ + source: { sourceType: "builder-cms" }, + rowRows: [], + documentTitleById: new Map([ + ["doc-mine", "My new row"], + ["doc-other", "Belongs to another collection"], + ]), + storedChangeSets: [], + databaseItems: [ + { databaseItemId: "item-mine", documentId: "doc-mine" }, + { databaseItemId: "item-other", documentId: "doc-other" }, + ], + // doc-other is owned by a different source — it must not become a create + // candidate for this one, even though it isn't in this source's rowRows. + otherSourceDocumentIds: new Set(["doc-other"]), + } as Parameters[0]); + + expect(pending.find((cs) => cs.documentId === "doc-mine")).toBeDefined(); + expect( + pending.find((cs) => cs.documentId === "doc-other"), + ).toBeUndefined(); + }); + + it("a non-primary source adopts a row tagged for it via the Source property", () => { + // A new, unlinked row tagged for "source-zz" must create against zz even + // though zz is not the primary (allowUnsourcedCreates: false). + const pending = buildBuilderLocalOutboundChangeSets({ + source: { sourceType: "builder-cms", id: "source-zz" }, + rowRows: [], + documentTitleById: new Map([ + ["doc-zz", "New resource"], + ["doc-blog", "New blog row"], + ]), + storedChangeSets: [], + databaseItems: [ + { databaseItemId: "item-zz", documentId: "doc-zz" }, + { databaseItemId: "item-blog", documentId: "doc-blog" }, + ], + allowUnsourcedCreates: false, + taggedSourceByDocumentId: new Map([ + ["doc-zz", "source-zz"], + ["doc-blog", "source-blog"], + ]), + } as Parameters[0]); + + // zz adopts its own tagged row; the row tagged for another collection is + // left alone even though this is the non-primary source. + expect(pending.find((cs) => cs.documentId === "doc-zz")).toBeDefined(); + expect(pending.find((cs) => cs.documentId === "doc-blog")).toBeUndefined(); + }); + + it("only the primary adopts unsourced rows as creates (allowUnsourcedCreates)", () => { + const args = { + source: { sourceType: "builder-cms" }, + rowRows: [], + documentTitleById: new Map([["doc-local", "Unsourced local row"]]), + storedChangeSets: [], + databaseItems: [{ databaseItemId: "item-local", documentId: "doc-local" }], + } as Parameters[0]; + + // A non-primary source leaves an unsourced "Local" row alone. + expect( + buildBuilderLocalOutboundChangeSets({ + ...args, + allowUnsourcedCreates: false, + }), + ).toHaveLength(0); + // The primary (default) adopts it as a create_draft. + expect( + buildBuilderLocalOutboundChangeSets({ + ...args, + allowUnsourcedCreates: true, + }).find((cs) => cs.documentId === "doc-local"), + ).toBeDefined(); + }); + + it("skips creates for titleless rows or rows that already have a stored change", () => { + const pending = buildBuilderLocalOutboundChangeSets({ + source: { sourceType: "builder-cms" }, + rowRows: [], + documentTitleById: new Map([["doc-titled", "Has Title"]]), + storedChangeSets: [ + { + direction: "outbound", + state: "pending_push", + documentId: "doc-titled", + }, + ], + databaseItems: [ + { databaseItemId: "item-empty", documentId: "doc-empty" }, + { databaseItemId: "item-titled", documentId: "doc-titled" }, + ], + } as Parameters[0]); + expect(pending).toHaveLength(0); + }); + it("does not synthesize live Builder push diffs for legacy fixture rows", () => { const pending = buildBuilderLocalOutboundChangeSets({ source: { diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 9e46220a83..0baf8153db 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -1,6 +1,12 @@ import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; import { getDb, schema } from "../server/db/index.js"; +import { + parsePropertyOptions, + serializePropertyOptions, + serializePropertyValue, + type DocumentPropertyOptionColor, +} from "../shared/properties.js"; import type { ContentDatabase, ContentDatabaseItem, @@ -85,6 +91,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; @@ -381,17 +389,10 @@ function reviewedChangeSet(args: { riskLevel = maxRisk(riskLevel, "high"); riskReasons.push("external write"); } - if (args.changeSet.pushMode === "publish") { + if (!args.changeSet.localOnly && args.changeSet.pushMode === "publish") { riskLevel = maxRisk(riskLevel, "high"); riskReasons.push("publish mode"); } - if ( - args.changeSet.direction === "outbound" && - normalizeCapabilities(args.source.capabilitiesJson).liveWritesEnabled - ) { - riskLevel = maxRisk(riskLevel, "high"); - riskReasons.push("live writes enabled"); - } const sourceRow = args.changeSet.documentId ? args.rowByDocumentId.get(args.changeSet.documentId) @@ -417,11 +418,61 @@ function reviewedChangeSet(args: { }; } +// Stable, key-order-insensitive serialization so two same-shape property values +// (source baseline vs local) don't false-diff purely on key order. +function stableValueString(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (Array.isArray(value)) { + return `[${value.map(stableValueString).join(",")}]`; + } + if (typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableValueString(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +// Equal when both normalize the same. null/undefined/"" are all "empty"; strings +// are trimmed; objects compared by stable serialization. +function sameSourceFieldValue(a: unknown, b: unknown): boolean { + const normalize = (value: unknown) => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value.trim(); + return stableValueString(value); + }; + return normalize(a) === normalize(b); +} + export function buildBuilderLocalOutboundChangeSets(args: { source: ContentDatabaseSourceRowDb; rowRows: ContentDatabaseSourceRecordRowDb[]; documentTitleById: Map; storedChangeSets: ContentDatabaseSourceChangeSet[]; + // Optional inputs that enable new-row creates. When omitted (e.g. legacy + // callers/tests) the function behaves exactly as before (title diffs only). + databaseItems?: Array<{ databaseItemId: string; documentId: string }>; + localValuesByDocument?: Map>; + writableFields?: Array<{ + propertyId: string | null; + localFieldKey: string; + sourceFieldKey: string; + sourceFieldLabel: string; + }>; + // Row-union scoping (multi-source). Documents owned by ANOTHER source must + // never be create candidates for this one — each row belongs to exactly one + // collection. And a truly unsourced ("Local") row creates only against the + // primary, not every attached collection. Both default to the single-source + // behavior when omitted (no other owners; creates allowed). + otherSourceDocumentIds?: Set; + allowUnsourcedCreates?: boolean; + // Per-document ownership from the visible "Source" select tag (documentId → + // owning sourceId). A new, still-unlinked row tagged for a specific + // collection is adopted as a create_draft by THAT collection only; an + // untagged / "Local" row falls back to the primary (allowUnsourcedCreates). + taggedSourceByDocumentId?: Map; }): ContentDatabaseSourceChangeSet[] { if (normalizeSourceType(args.source.sourceType) !== "builder-cms") return []; @@ -442,19 +493,45 @@ export function buildBuilderLocalOutboundChangeSets(args: { const sourceTitle = row.sourceDisplayKey.trim(); const localTitle = args.documentTitleById.get(row.documentId)?.trim() ?? ""; - if (!localTitle || localTitle === sourceTitle) continue; - - const fieldChanges: ContentDatabaseSourceFieldChange[] = [ - { + const fieldChanges: ContentDatabaseSourceFieldChange[] = []; + if (localTitle && localTitle !== sourceTitle) { + fieldChanges.push({ propertyId: null, propertyName: "Title", localFieldKey: "title", sourceFieldKey: "data.title", currentValue: sourceTitle, proposedValue: localTitle, - }, - ]; - const matchingStoredChange = args.storedChangeSets.some((changeSet) => { + }); + } + // Diff every mapped property field: local value vs the synced source + // baseline (same-shape DocumentPropertyValue, stable compare). An absent + // local value means "not loaded", not "cleared" — skip it. + const rowLocalValues = args.localValuesByDocument?.get(row.documentId); + if (rowLocalValues) { + const rowSourceValues = + parseObject>( + row.sourceValuesJson, + ) ?? {}; + for (const field of args.writableFields ?? []) { + if (!rowLocalValues.has(field.localFieldKey)) continue; + const localValue = rowLocalValues.get(field.localFieldKey); + const baseValue = rowSourceValues[field.sourceFieldKey]; + if (sameSourceFieldValue(localValue, baseValue)) continue; + fieldChanges.push({ + propertyId: field.propertyId, + propertyName: field.sourceFieldLabel, + localFieldKey: field.localFieldKey, + sourceFieldKey: field.sourceFieldKey, + currentValue: (baseValue ?? null) as DocumentPropertyValue, + proposedValue: localValue as DocumentPropertyValue, + }); + } + } + if (fieldChanges.length === 0) continue; + // Skip if this row already has a live (non-rejected/applied) stored outbound + // autosave change-set — the stored one is what's being reviewed/pushed. + const matchesStoredChange = args.storedChangeSets.some((changeSet) => { if ( changeSet.direction !== "outbound" || changeSet.documentId !== row.documentId || @@ -464,14 +541,16 @@ export function buildBuilderLocalOutboundChangeSets(args: { ) { return false; } - return changeSet.fieldChanges.some( - (fieldChange) => - fieldChange.localFieldKey === "title" && - fieldChange.currentValue === sourceTitle && - fieldChange.proposedValue === localTitle, + return changeSet.fieldChanges.some((stored) => + fieldChanges.some( + (change) => + change.localFieldKey === stored.localFieldKey && + sameSourceFieldValue(change.currentValue, stored.currentValue) && + sameSourceFieldValue(change.proposedValue, stored.proposedValue), + ), ); }); - if (matchingStoredChange) continue; + if (matchesStoredChange) continue; const now = new Date().toISOString(); pending.push({ @@ -483,7 +562,10 @@ export function buildBuilderLocalOutboundChangeSets(args: { state: "pending_push", pushMode: "autosave", localOnly: true, - summary: `Pending local Builder CMS title change for "${localTitle}".`, + summary: + fieldChanges.length === 1 && fieldChanges[0]?.localFieldKey === "title" + ? `Pending local Builder CMS title change for "${localTitle}".` + : `Pending local Builder CMS changes for "${localTitle || sourceTitle}".`, fieldChanges, bodyChange: null, riskLevel: "low", @@ -496,6 +578,91 @@ export function buildBuilderLocalOutboundChangeSets(args: { }); } + // New-row creates: a local database item NOT linked to a Builder entry (no + // source row) and with a non-empty title becomes a create_draft change-set. + // No baseline comparison here — we send the local values; the create_draft + // effect (derived from a null target entryId) writes the entry as a draft. + if (args.databaseItems && args.databaseItems.length > 0) { + const linkedDocumentIds = new Set( + args.rowRows.map((row) => row.documentId), + ); + const documentIdsWithStoredChange = new Set( + args.storedChangeSets + .filter( + (changeSet) => + changeSet.direction === "outbound" && + changeSet.state !== "applied" && + changeSet.state !== "rejected", + ) + .map((changeSet) => changeSet.documentId), + ); + const allowUnsourcedCreates = args.allowUnsourcedCreates ?? true; + for (const item of args.databaseItems) { + if (linkedDocumentIds.has(item.documentId)) continue; + // Owned by another collection's row identity — not this source's to create. + if (args.otherSourceDocumentIds?.has(item.documentId)) continue; + const taggedSourceId = args.taggedSourceByDocumentId?.get( + item.documentId, + ); + if (taggedSourceId) { + // Explicitly tagged for a collection via the "Source" property: only + // that collection adopts it (regardless of primary/non-primary). + if (taggedSourceId !== args.source.id) continue; + } else if (!allowUnsourcedCreates) { + // Untagged / "Local": only the primary adopts it as a create; other + // collections leave it alone until it's explicitly assigned to them. + continue; + } + if (documentIdsWithStoredChange.has(item.documentId)) continue; + const title = args.documentTitleById.get(item.documentId)?.trim() ?? ""; + if (!title) continue; + const localValues = args.localValuesByDocument?.get(item.documentId); + const fieldChanges: ContentDatabaseSourceFieldChange[] = [ + { + propertyId: null, + propertyName: "Title", + localFieldKey: "title", + sourceFieldKey: "data.title", + currentValue: null, + proposedValue: title, + }, + ]; + for (const field of args.writableFields ?? []) { + if (!localValues?.has(field.localFieldKey)) continue; + fieldChanges.push({ + propertyId: field.propertyId, + propertyName: field.sourceFieldLabel, + localFieldKey: field.localFieldKey, + sourceFieldKey: field.sourceFieldKey, + currentValue: null, + proposedValue: (localValues.get(field.localFieldKey) ?? + null) as DocumentPropertyValue, + }); + } + const now = new Date().toISOString(); + pending.push({ + id: `local-pending-create-${item.databaseItemId}`, + databaseItemId: item.databaseItemId, + documentId: item.documentId, + kind: "field_update", + direction: "outbound", + state: "pending_push", + pushMode: "autosave", + localOnly: true, + summary: `Pending new Builder entry "${title}".`, + fieldChanges, + bodyChange: null, + riskLevel: "low", + riskReasons: ["new Builder entry (create as draft)"], + conflictState: "none", + reviewEvents: [], + executions: [], + createdAt: now, + updatedAt: now, + }); + } + } + return pending; } @@ -542,11 +709,52 @@ export async function getContentDatabaseSourceSnapshot( .select() .from(schema.contentDatabaseSources) .where(eq(schema.contentDatabaseSources.databaseId, database.id)) - .orderBy(asc(schema.contentDatabaseSources.createdAt)); + .orderBy( + asc(schema.contentDatabaseSources.createdAt), + asc(schema.contentDatabaseSources.id), + ); + if (!source) return null; + return loadSourceSnapshot(source, database); +} + +/** + * Load one specific attached source by id (scoped to the database). Multi-source + * write paths use this so an action can target a non-primary source; single-source + * callers keep using {@link getContentDatabaseSourceSnapshot} (the primary). + */ +export async function getContentDatabaseSourceSnapshotById( + database: ContentDatabaseRow | ContentDatabase, + sourceId: string, +): Promise { + const db = getDb(); + const [source] = await db + .select() + .from(schema.contentDatabaseSources) + .where( + and( + eq(schema.contentDatabaseSources.databaseId, database.id), + eq(schema.contentDatabaseSources.id, sourceId), + ), + ); if (!source) return null; return loadSourceSnapshot(source, database); } +/** + * Resolve the source an action should operate on: the explicit `sourceId` when + * given (multi-source), otherwise the primary (back-compat single-source). The + * default path is byte-for-byte the old behavior, so existing callers that omit + * `sourceId` are unaffected. + */ +export async function getContentDatabaseSourceSnapshotForWrite( + database: ContentDatabaseRow | ContentDatabase, + sourceId?: string | null, +): Promise { + return sourceId + ? getContentDatabaseSourceSnapshotById(database, sourceId) + : getContentDatabaseSourceSnapshot(database); +} + /** * Load every source attached to a database (oldest first → `[0]` is the * primary). Federation joins read this; single-source callers keep using @@ -563,7 +771,10 @@ export async function getAllContentDatabaseSourceSnapshots( .select() .from(schema.contentDatabaseSources) .where(eq(schema.contentDatabaseSources.databaseId, database.id)) - .orderBy(asc(schema.contentDatabaseSources.createdAt)); + .orderBy( + asc(schema.contentDatabaseSources.createdAt), + asc(schema.contentDatabaseSources.id), + ); const snapshots: ContentDatabaseSource[] = []; for (const source of sources) { snapshots.push(await loadSourceSnapshot(source, database)); @@ -647,8 +858,32 @@ async function loadSourceSnapshot( executions.push(serializeExecution(row)); executionsByChangeSetId.set(row.changeSetId, executions); } + const isBuilderSource = + normalizeSourceType(source.sourceType) === "builder-cms"; + // For Builder sources, load ALL database items (not just synced source rows) + // so brand-new local rows (no source link) can become create_draft change-sets. + const databaseItemRows = isBuilderSource + ? await db + .select({ + id: schema.contentDatabaseItems.id, + documentId: schema.contentDatabaseItems.documentId, + }) + .from(schema.contentDatabaseItems) + .where( + and( + eq(schema.contentDatabaseItems.databaseId, database.id), + eq(schema.contentDatabaseItems.ownerEmail, source.ownerEmail), + ), + ) + : []; + const allDocumentIds = Array.from( + new Set([ + ...rowRows.map((row) => row.documentId), + ...databaseItemRows.map((item) => item.documentId), + ]), + ); const rowDocuments = - rowRows.length > 0 + allDocumentIds.length > 0 ? await db .select({ id: schema.documents.id, @@ -657,10 +892,7 @@ async function loadSourceSnapshot( .from(schema.documents) .where( and( - inArray( - schema.documents.id, - rowRows.map((row) => row.documentId), - ), + inArray(schema.documents.id, allDocumentIds), eq(schema.documents.ownerEmail, source.ownerEmail), ), ) @@ -668,11 +900,123 @@ async function loadSourceSnapshot( const documentTitleById = new Map( rowDocuments.map((document) => [document.id, document.title]), ); + const propertyValueRows = + isBuilderSource && allDocumentIds.length > 0 + ? await db + .select({ + documentId: schema.documentPropertyValues.documentId, + propertyId: schema.documentPropertyValues.propertyId, + valueJson: schema.documentPropertyValues.valueJson, + }) + .from(schema.documentPropertyValues) + .where( + and( + inArray(schema.documentPropertyValues.documentId, allDocumentIds), + eq(schema.documentPropertyValues.ownerEmail, source.ownerEmail), + ), + ) + : []; + const localValuesByDocument = new Map>(); + for (const valueRow of propertyValueRows) { + let byField = localValuesByDocument.get(valueRow.documentId); + if (!byField) { + byField = new Map(); + localValuesByDocument.set(valueRow.documentId, byField); + } + let parsed: unknown = null; + try { + parsed = JSON.parse(valueRow.valueJson); + } catch { + parsed = null; + } + byField.set(valueRow.propertyId, parsed); + } + const writableFields = fieldRows + .filter((row) => row.mappingType === "property") + .map((row) => ({ + propertyId: row.propertyId ?? null, + localFieldKey: row.localFieldKey, + sourceFieldKey: row.sourceFieldKey, + sourceFieldLabel: row.sourceFieldLabel, + })); + // Row-union ownership scoping (Builder only). Determine which documents belong + // to OTHER sources and whether this source is the primary (oldest), so the + // create-candidate logic never claims another collection's rows and unsourced + // "Local" rows only create against the primary. Single-source: no other + // sources ⇒ empty set, isPrimary ⇒ identical to the old behavior. + let otherSourceDocumentIds = new Set(); + let isPrimarySource = true; + let taggedSourceByDocumentId = new Map(); + if (isBuilderSource) { + const dbSources = await db + .select({ + id: schema.contentDatabaseSources.id, + sourceName: schema.contentDatabaseSources.sourceName, + }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.databaseId, database.id)) + // Same (createdAt, id) ordering as getExistingSource / + // getContentDatabaseSourceSnapshot, so "primary" here is definitionally + // the same source the write path treats as primary — never a different + // pick on a createdAt tie. + .orderBy( + asc(schema.contentDatabaseSources.createdAt), + asc(schema.contentDatabaseSources.id), + ); + isPrimarySource = dbSources[0]?.id === source.id; + const otherSourceIds = dbSources + .map((row) => row.id) + .filter((id) => id !== source.id); + if (otherSourceIds.length > 0) { + const ownedRows = await db + .select({ documentId: schema.contentDatabaseSourceRows.documentId }) + .from(schema.contentDatabaseSourceRows) + .where( + inArray(schema.contentDatabaseSourceRows.sourceId, otherSourceIds), + ); + otherSourceDocumentIds = new Set(ownedRows.map((row) => row.documentId)); + } + // Multi-source: a row's visible "Source" tag value IS its owning source id + // (the Source option id equals the source id), so adoption is pure id + // matching — no source-name hop, immune to duplicate names or a "Local" + // collision. The "Local" sentinel isn't a real source id, so untagged rows + // fall through to the primary-only path. + if (dbSources.length > 1) { + const [sourceProp] = await db + .select({ id: schema.documentPropertyDefinitions.id }) + .from(schema.documentPropertyDefinitions) + .where( + and( + eq(schema.documentPropertyDefinitions.databaseId, database.id), + eq(schema.documentPropertyDefinitions.name, SOURCE_PROPERTY_NAME), + eq(schema.documentPropertyDefinitions.type, "select"), + ), + ); + if (sourceProp) { + const validSourceIds = new Set(dbSources.map((row) => row.id)); + for (const [documentId, byProperty] of localValuesByDocument) { + const optionId = byProperty.get(sourceProp.id); + if (typeof optionId === "string" && validSourceIds.has(optionId)) { + taggedSourceByDocumentId.set(documentId, optionId); + } + } + } + } + } const localOutboundChangeSets = buildBuilderLocalOutboundChangeSets({ source, rowRows, documentTitleById, storedChangeSets, + databaseItems: databaseItemRows.map((item) => ({ + databaseItemId: item.id, + documentId: item.documentId, + })), + localValuesByDocument, + writableFields, + otherSourceDocumentIds, + allowUnsourcedCreates: isPrimarySource, + taggedSourceByDocumentId, }); const rowByDocumentId = new Map(rowRows.map((row) => [row.documentId, row])); const changeSets = [...storedChangeSets, ...localOutboundChangeSets].map( @@ -686,6 +1030,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. @@ -709,7 +1063,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", @@ -717,6 +1071,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, @@ -977,47 +1334,65 @@ export async function seedMockSourceFields(args: { createdAt: args.now, updatedAt: args.now, }, - ...args.properties.map((property) => ({ - id: crypto.randomUUID(), - ownerEmail: args.ownerEmail, - sourceId: args.sourceId, - propertyId: property.definition.id, - localFieldKey: property.definition.id, - sourceFieldKey: isBuilder - ? builderCmsSourceFieldKey( - property.definition.id, - property.definition.name, - ) - : `fields.${slugifySourceField(property.definition.name)}`, - sourceFieldLabel: property.definition.name, - sourceFieldType: property.definition.type, - mappingType: "property", - writeOwner: - property.definition.type === "created_time" || - property.definition.type === "created_by" || - property.definition.type === "last_edited_time" || - property.definition.type === "last_edited_by" - ? "derived" - : isBuilder - ? "source" - : "local", - readOnly: - property.definition.type === "created_time" || - property.definition.type === "created_by" || - property.definition.type === "last_edited_time" || - property.definition.type === "last_edited_by" - ? 1 - : 0, - provenance: - property.definition.type === "formula" || - property.definition.type === "rollup" - ? "derived" - : "source field", - freshness: "fresh", - lastSyncedAt: args.now, - createdAt: args.now, - updatedAt: args.now, - })), + // The auto-created "Source" property is internal row-tagging (which + // collection a row belongs to). It must NEVER become a writable Builder + // source field — otherwise its local option-id value diffs against an + // absent baseline and every row shows a phantom pending change, and a push + // would try to write the internal tag to Builder. Match the SAME shape + // ensureDatabaseSourceProperty uses to identify it (a `select` named + // "Source") and only for Builder sources, so a user's own field happening + // to be named "Source" — or any non-Builder/local-table source — is left + // untouched. + ...args.properties + .filter( + (property) => + !( + isBuilder && + property.definition.name === SOURCE_PROPERTY_NAME && + property.definition.type === "select" + ), + ) + .map((property) => ({ + id: crypto.randomUUID(), + ownerEmail: args.ownerEmail, + sourceId: args.sourceId, + propertyId: property.definition.id, + localFieldKey: property.definition.id, + sourceFieldKey: isBuilder + ? builderCmsSourceFieldKey( + property.definition.id, + property.definition.name, + ) + : `fields.${slugifySourceField(property.definition.name)}`, + sourceFieldLabel: property.definition.name, + sourceFieldType: property.definition.type, + mappingType: "property", + writeOwner: + property.definition.type === "created_time" || + property.definition.type === "created_by" || + property.definition.type === "last_edited_time" || + property.definition.type === "last_edited_by" + ? "derived" + : isBuilder + ? "source" + : "local", + readOnly: + property.definition.type === "created_time" || + property.definition.type === "created_by" || + property.definition.type === "last_edited_time" || + property.definition.type === "last_edited_by" + ? 1 + : 0, + provenance: + property.definition.type === "formula" || + property.definition.type === "rollup" + ? "derived" + : "source field", + freshness: "fresh", + lastSyncedAt: args.now, + createdAt: args.now, + updatedAt: args.now, + })), ]; if (isBuilder) { const existingSourceFieldKeys = new Set( @@ -1528,6 +1903,11 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { now: string; sourceTable: string; existingSourceRows?: ContentDatabaseSourceRecordRowDb[]; + // When importing an ADDITIONAL source (row-union), two collections may share + // a title legitimately, so the cross-database title dedup must be skipped — + // per-source re-import idempotency is still handled by + // builderCmsEntryAlreadyRepresented (existingSourceRows). + skipTitleDedup?: boolean; }) { if (args.entries.length === 0) return 0; const db = getDb(); @@ -1580,7 +1960,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: { const title = entry.title.trim() || entry.id; const titleKey = title.toLowerCase(); - if (existingTitles.has(titleKey)) continue; + if (!args.skipTitleDedup && existingTitles.has(titleKey)) continue; existingTitles.add(titleKey); const documentId = nanoid(); @@ -1690,12 +2070,34 @@ export async function resyncBuilderCmsSourceSnapshot(args: { builderRead.state === "live" ? builderRead.entries : [], now: args.now, }); + // Row-union: a resync must only (re)link items that BELONG to this source — + // never claim every database item. With a single source, all items belong to + // it (back-compat). With multiple sources, link only this source's + // remote-backed rows when the read is live (this self-heals any prior + // over-claim, since rows are deleted then reseeded); when offline, preserve + // just the rows already owned so nothing is orphaned. New / "Local" / + // other-collection rows stay unlinked, so the Source-tag create path can + // adopt them into the right collection. + const databaseSourceCount = ( + await db + .select({ id: schema.contentDatabaseSources.id }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.databaseId, args.database.id)) + ).length; + const itemsToLink = + databaseSourceCount > 1 + ? response.items.filter((item) => + builderRead.state === "live" + ? builderEntriesByDocumentId.has(item.document.id) + : existingBuilderRows.has(item.document.id), + ) + : response.items; await seedMockSourceRows({ sourceId: args.source.id, ownerEmail: args.database.ownerEmail, sourceType: "builder-cms", sourceTable: args.source.sourceTable, - items: response.items, + items: itemsToLink, now: args.now, existingBuilderRows, builderEntriesByDocumentId, @@ -2059,10 +2461,224 @@ export async function getExistingSource(databaseId: string) { const [source] = await db .select() .from(schema.contentDatabaseSources) - .where(eq(schema.contentDatabaseSources.databaseId, databaseId)); + .where(eq(schema.contentDatabaseSources.databaseId, databaseId)) + // Oldest-first so "the source" is deterministically the primary, matching + // getContentDatabaseSourceSnapshot. Without this, a multi-source database + // could resolve a non-primary source when a caller omits sourceId. The `id` + // tie-break keeps the choice stable when two sources share a createdAt + // timestamp (no uniqueness guarantee on created_at). + .orderBy( + asc(schema.contentDatabaseSources.createdAt), + asc(schema.contentDatabaseSources.id), + ); + return source ?? null; +} + +/** The source DB row for one attached source by id (scoped to the database). */ +export async function getExistingSourceById( + databaseId: string, + sourceId: string, +) { + const db = getDb(); + const [source] = await db + .select() + .from(schema.contentDatabaseSources) + .where( + and( + eq(schema.contentDatabaseSources.databaseId, databaseId), + eq(schema.contentDatabaseSources.id, sourceId), + ), + ); return source ?? null; } +/** The source DB row for an action: explicit `sourceId` when given, else primary. */ +export async function getExistingSourceForWrite( + databaseId: string, + sourceId?: string | null, +) { + return sourceId + ? getExistingSourceById(databaseId, sourceId) + : getExistingSource(databaseId); +} + +/** Whether a source for this model (sourceTable) is already attached. */ +export async function databaseSourceExistsForTable( + databaseId: string, + sourceTable: string, +): Promise { + const db = getDb(); + const [row] = await db + .select({ id: schema.contentDatabaseSources.id }) + .from(schema.contentDatabaseSources) + .where( + and( + eq(schema.contentDatabaseSources.databaseId, databaseId), + eq(schema.contentDatabaseSources.sourceTable, sourceTable), + ), + ); + return !!row; +} + +export const SOURCE_PROPERTY_NAME = "Source"; +// The "Local" (no collection) option id. A fixed non-UUID sentinel so it never +// collides with a source id (which is what every collection option's id is). +export const SOURCE_LOCAL_OPTION_ID = "local"; + +const SOURCE_OPTION_PALETTE: DocumentPropertyOptionColor[] = [ + "blue", + "green", + "orange", + "purple", + "pink", + "yellow", + "brown", + "red", +]; + +/** + * Ensure a "Source" select property exists tagging each row with the collection + * it belongs to, and (re)set every item's value. Rows with no source binding are + * "Local" — the same first-class state a brand-new local row has. Only runs once + * a database has 2+ sources (row-union); a single-source database doesn't need + * the tag. Option ids are preserved across re-runs so colors/filters stay stable. + */ +export async function ensureDatabaseSourceProperty(args: { + database: ContentDatabaseRow; + now: string; +}) { + const db = getDb(); + const sources = await db + .select({ + id: schema.contentDatabaseSources.id, + sourceName: schema.contentDatabaseSources.sourceName, + }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.databaseId, args.database.id)) + .orderBy(asc(schema.contentDatabaseSources.createdAt)); + if (sources.length < 2) return; + + const [existing] = await db + .select() + .from(schema.documentPropertyDefinitions) + .where( + and( + eq(schema.documentPropertyDefinitions.databaseId, args.database.id), + eq(schema.documentPropertyDefinitions.name, SOURCE_PROPERTY_NAME), + eq(schema.documentPropertyDefinitions.type, "select"), + ), + ); + + const priorOptions = existing + ? (parsePropertyOptions(existing.optionsJson).options ?? []) + : []; + // Each source option's id IS the sourceId (and "Local" uses a fixed sentinel + // that can't collide with a UUID source id). Resolving a row's tag back to a + // source is then pure id matching — no source-name hop — so duplicate display + // names or a collection literally named "Local" can never misroute a row. + const priorById = new Map(priorOptions.map((option) => [option.id, option])); + const options = [ + ...sources.map((source, index) => ({ + id: source.id, + name: source.sourceName, + color: + priorById.get(source.id)?.color ?? + SOURCE_OPTION_PALETTE[index % SOURCE_OPTION_PALETTE.length], + })), + { + id: SOURCE_LOCAL_OPTION_ID, + name: "Local", + color: (priorById.get(SOURCE_LOCAL_OPTION_ID)?.color ?? + "gray") as DocumentPropertyOptionColor, + }, + ]; + const optionsJson = serializePropertyOptions({ options }); + + let propertyId: string; + if (existing) { + propertyId = existing.id; + await db + .update(schema.documentPropertyDefinitions) + .set({ optionsJson, updatedAt: args.now }) + .where(eq(schema.documentPropertyDefinitions.id, existing.id)); + } else { + const [maxPos] = await db + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.documentPropertyDefinitions) + .where( + eq(schema.documentPropertyDefinitions.databaseId, args.database.id), + ); + propertyId = crypto.randomUUID(); + await db.insert(schema.documentPropertyDefinitions).values({ + id: propertyId, + ownerEmail: args.database.ownerEmail, + orgId: args.database.orgId, + databaseId: args.database.id, + name: SOURCE_PROPERTY_NAME, + type: "select", + visibility: "always_show", + optionsJson, + position: (maxPos?.max ?? -1) + 1, + createdAt: args.now, + updatedAt: args.now, + }); + } + + // A row's Source value IS its owning source id (= the option id); unsourced + // rows get the "Local" sentinel. Pure id mapping, no source-name hop. + const rows = await db + .select({ + documentId: schema.contentDatabaseSourceRows.documentId, + sourceId: schema.contentDatabaseSourceRows.sourceId, + }) + .from(schema.contentDatabaseSourceRows) + .where( + inArray( + schema.contentDatabaseSourceRows.sourceId, + sources.map((source) => source.id), + ), + ); + const ownerSourceIdByDocumentId = new Map(); + for (const row of rows) { + if (row.documentId) ownerSourceIdByDocumentId.set(row.documentId, row.sourceId); + } + + const items = await db + .select({ documentId: schema.contentDatabaseItems.documentId }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, args.database.id)); + for (const item of items) { + const optionId = + ownerSourceIdByDocumentId.get(item.documentId) ?? SOURCE_LOCAL_OPTION_ID; + const valueJson = serializePropertyValue(optionId); + const [existingValue] = await db + .select({ id: schema.documentPropertyValues.id }) + .from(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.documentId, item.documentId), + eq(schema.documentPropertyValues.propertyId, propertyId), + ), + ); + if (existingValue) { + await db + .update(schema.documentPropertyValues) + .set({ valueJson, updatedAt: args.now }) + .where(eq(schema.documentPropertyValues.id, existingValue.id)); + } else { + await db.insert(schema.documentPropertyValues).values({ + id: crypto.randomUUID(), + ownerEmail: args.database.ownerEmail, + documentId: item.documentId, + propertyId, + valueJson, + createdAt: args.now, + updatedAt: args.now, + }); + } + } +} + export async function getSourceRows(sourceId: string) { const db = getDb(); return db diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 4d5219d292..7a31f6310c 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -181,21 +181,19 @@ export async function getContentDatabaseResponse( const serializedDocumentIds = new Set( serializedItems.map((item) => item.document.id), ); - // When paginating, the *primary* source's rows are document-backed, so we can - // scope them to the visible page. Secondary rows join by canonical key (no - // document), so they're left intact — only matched ones overlay anyway. + // When paginating, scope every DOCUMENT-BACKED source's rows to the visible + // page — that's the primary AND any row-union secondary (each row maps to a + // real document). Federated join rows carry no document (empty documentId), + // so they're kept intact — only matched ones overlay anyway. const pagedSources = limit !== null - ? sources.map((source, index) => - index === 0 - ? { - ...source, - rows: source.rows.filter((row) => - serializedDocumentIds.has(row.documentId), - ), - } - : source, - ) + ? sources.map((source) => ({ + ...source, + rows: source.rows.filter( + (row) => + !row.documentId || serializedDocumentIds.has(row.documentId), + ), + })) : sources; const pagedPrimary = pagedSources[0] ?? null; diff --git a/templates/content/actions/attach-content-database-source.ts b/templates/content/actions/attach-content-database-source.ts index 6e8d3efaec..ecc1fe8f8e 100644 --- a/templates/content/actions/attach-content-database-source.ts +++ b/templates/content/actions/attach-content-database-source.ts @@ -15,6 +15,8 @@ import { } from "./_builder-cms-read-client.js"; import type { BuilderCmsSourceEntry } from "./_builder-cms-source-adapter.js"; import { + databaseSourceExistsForTable, + ensureDatabaseSourceProperty, getExistingSource, getSourceRows, importBuilderCmsEntriesAsDatabaseItems, @@ -134,6 +136,12 @@ export default defineAction({ .describe( "When adding a SECOND source, the canonical-key join that federates it onto the primary (read-only overlay).", ), + mode: z + .enum(["replace", "add"]) + .optional() + .describe( + "replace (default) re-links the primary source; add attaches an ADDITIONAL Builder source whose entries become their own rows (row-union, no join).", + ), limit: z.coerce.number().int().min(1).max(500).default(100), offset: z.coerce.number().int().min(0).default(0), }), @@ -229,6 +237,97 @@ export default defineAction({ }); } + // Adding an ADDITIONAL writable Builder source (row-union): insert a new + // source and import its entries as their OWN rows, instead of replacing the + // primary. No canonical-key join — each row belongs to exactly one source. + if (args.mode === "add" && existingSource && sourceType === "builder-cms") { + // Don't add the same collection twice — each "add" starts a fresh source + // with no prior rows, so a duplicate attach would re-import duplicate rows. + if (await databaseSourceExistsForTable(database.id, sourceTable)) { + throw new Error(`"${sourceTable}" is already attached as a source.`); + } + const additionalRead = await readBuilderCmsContentEntries({ + model: sourceTable, + }); + const additionalModelFields = await readBuilderCmsModelFields({ + model: sourceTable, + }); + const additionalSourceId = await insertSecondarySource({ + database, + sourceType, + sourceName, + sourceTable, + now, + }); + // Snapshot existing items BEFORE importing so we can bind the new source + // to ONLY the rows it imports — never the primary's existing rows. + const beforeSetup = await sourceSetupPayload(database.id); + const priorDocumentIds = new Set( + beforeSetup.response.items.map((item) => item.document.id), + ); + if (additionalRead.state === "live") { + await importBuilderCmsEntriesAsDatabaseItems({ + database, + entries: additionalRead.entries, + now, + sourceTable, + existingSourceRows: [], + skipTitleDedup: true, + }); + } + const additionalSetup = await sourceSetupPayload(database.id); + // Only the items this collection just created — exclude the primary's. + const importedItems = additionalSetup.response.items.filter( + (item) => !priorDocumentIds.has(item.document.id), + ); + const additionalEntriesByDocumentId = + additionalRead.state === "live" + ? mapBuilderCmsEntriesToLocalItems({ + entries: additionalRead.entries, + items: importedItems, + sourceTable, + now, + existingRows: [], + }) + : undefined; + await seedMockSourceFields({ + sourceId: additionalSourceId, + ownerEmail: database.ownerEmail, + sourceType, + properties: additionalSetup.properties, + builderModelFields: additionalModelFields, + builderSampleEntries: + additionalRead.state === "live" ? additionalRead.entries : [], + now, + }); + await seedMockSourceRows({ + sourceId: additionalSourceId, + ownerEmail: database.ownerEmail, + sourceType, + sourceTable, + items: importedItems, + now, + builderEntriesByDocumentId: additionalEntriesByDocumentId, + }); + await updateBuilderCmsSourceReadMetadata({ + sourceId: additionalSourceId, + sourceTable, + readState: additionalRead.state, + entryCount: additionalRead.entries.length, + matchedRowCount: additionalEntriesByDocumentId?.size ?? 0, + fetchedAt: additionalRead.fetchedAt, + now, + message: additionalRead.message, + syncState: "linked", + }); + await ensureDatabaseSourceProperty({ database, now }); + + return getContentDatabaseResponse(database.id, { + limit: args.limit, + offset: args.offset, + }); + } + const existingSourceRows = existingSource ? await getSourceRows(existingSource.id) : []; diff --git a/templates/content/actions/bind-content-database-source-field.db.test.ts b/templates/content/actions/bind-content-database-source-field.db.test.ts new file mode 100644 index 0000000000..18db870aba --- /dev/null +++ b/templates/content/actions/bind-content-database-source-field.db.test.ts @@ -0,0 +1,342 @@ +// Integration tests for the row-union per-source column field-binding action +// (slice 6c + its Codex review fixes). Boots a real in-memory libsql DB, runs +// the actual migrations, seeds a 2-source row-union, and drives the bind action +// through `run` (with an owner request context so assertAccess passes). + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { runWithRequestContext } from "@agent-native/core/server"; +import { and, eq } from "drizzle-orm"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rmSync } from "node:fs"; + +const TEST_DB_PATH = join( + tmpdir(), + `bind-source-field-test-${process.pid}-${Date.now()}.sqlite`, +); +process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; + +let getDb: () => any; +let schema: typeof import("../server/db/schema.js"); +let bindAction: typeof import("./bind-content-database-source-field.js").default; + +const OWNER = "owner@example.com"; + +beforeAll(async () => { + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + const plugin = (await import("../server/plugins/db.js")).default; + await plugin(undefined as any); + bindAction = (await import("./bind-content-database-source-field.js")).default; +}, 60000); + +afterAll(() => { + for (const suffix of ["", "-shm", "-wal"]) { + rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); + } +}); + +let counter = 0; +async function asOwner(fn: () => Promise): Promise { + return runWithRequestContext({ userEmail: OWNER }, fn); +} + +/** + * Seed a row-union database with two Builder sources. Source A has two rows + * carrying a `data.cat` value (one of which is empty), plus a multi-value + * `data.labels` field; source B has one row. A text column "Tag" is the bind + * target. Returns the ids needed to drive and assert against the action. + */ +async function seedRowUnion() { + const db = getDb(); + const now = new Date().toISOString(); + const suffix = `${++counter}_${Math.random().toString(36).slice(2, 7)}`; + const databaseId = `db_${suffix}`; + const databaseDocId = `doc_${databaseId}`; + + await db.insert(schema.documents).values({ + id: databaseDocId, + ownerEmail: OWNER, + title: "Row union DB", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabases).values({ + id: databaseId, + ownerEmail: OWNER, + documentId: databaseDocId, + title: "Row union DB", + createdAt: now, + updatedAt: now, + }); + + async function addSource(name: string, createdAt: string) { + const id = `src_${name}_${suffix}`; + await db.insert(schema.contentDatabaseSources).values({ + id, + ownerEmail: OWNER, + databaseId, + sourceType: "builder-cms", + sourceName: name, + sourceTable: name, + createdAt, + updatedAt: createdAt, + }); + return id; + } + // A is the primary (older); B is the secondary. + const sourceA = await addSource("collection-a", "2026-01-01T00:00:00.000Z"); + const sourceB = await addSource("collection-b", "2026-01-02T00:00:00.000Z"); + + async function addRow( + sourceId: string, + label: string, + sourceValues: Record, + ) { + const docId = `doc_${label}_${suffix}`; + const itemId = `item_${label}_${suffix}`; + await db.insert(schema.documents).values({ + id: docId, + ownerEmail: OWNER, + title: label, + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseItems).values({ + id: itemId, + ownerEmail: OWNER, + databaseId, + documentId: docId, + position: counter, + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseSourceRows).values({ + id: `row_${label}_${suffix}`, + ownerEmail: OWNER, + sourceId, + databaseItemId: itemId, + documentId: docId, + sourceRowId: `srid_${label}`, + sourceQualifiedId: `qid_${label}`, + sourceDisplayKey: label, + sourceValuesJson: JSON.stringify(sourceValues), + createdAt: now, + updatedAt: now, + }); + return docId; + } + const a1 = await addRow(sourceA, "a1", { "data.cat": "Alpha" }); + const a2 = await addRow(sourceA, "a2", {}); // no cat value (sparse) + const b1 = await addRow(sourceB, "b1", { "data.cat": "Beta" }); + + async function addField( + sourceId: string, + sourceFieldKey: string, + sourceFieldType: string, + ) { + const id = `field_${sourceFieldKey}_${sourceId}`; + await db.insert(schema.contentDatabaseSourceFields).values({ + id, + ownerEmail: OWNER, + sourceId, + propertyId: null, + localFieldKey: sourceFieldKey, + sourceFieldKey, + sourceFieldLabel: sourceFieldKey, + sourceFieldType, + mappingType: "property", + writeOwner: "source", + createdAt: now, + updatedAt: now, + }); + return id; + } + const fieldACat = await addField(sourceA, "data.cat", "text"); + const fieldAOther = await addField(sourceA, "data.other", "text"); + const fieldALabels = await addField(sourceA, "data.labels", "list"); + const fieldBCat = await addField(sourceB, "data.cat", "text"); + + // Target text column "Tag". + const tagPropertyId = `prop_tag_${suffix}`; + await db.insert(schema.documentPropertyDefinitions).values({ + id: tagPropertyId, + ownerEmail: OWNER, + databaseId, + name: "Tag", + type: "text", + visibility: "always_show", + optionsJson: "{}", + position: 0, + createdAt: now, + updatedAt: now, + }); + + return { + databaseId, + docs: { a1, a2, b1 }, + sourceA, + sourceB, + fields: { fieldACat, fieldAOther, fieldALabels, fieldBCat }, + tagPropertyId, + }; +} + +async function tagValue(documentId: string, propertyId: string) { + const db = getDb(); + const [row] = await db + .select({ valueJson: schema.documentPropertyValues.valueJson }) + .from(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.documentId, documentId), + eq(schema.documentPropertyValues.propertyId, propertyId), + ), + ); + return row ? (JSON.parse(row.valueJson) as unknown) : undefined; +} + +describe("bind-content-database-source-field (row-union)", () => { + it("backfills only the bound source's rows into the column", async () => { + const f = await seedRowUnion(); + await asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ); + // Source A's row with a value gets it; source B's row is untouched. + expect(await tagValue(f.docs.a1, f.tagPropertyId)).toBe("Alpha"); + expect(await tagValue(f.docs.b1, f.tagPropertyId)).toBeUndefined(); + }); + + it("clears a stale column value when the newly bound field is empty", async () => { + const f = await seedRowUnion(); + const db = getDb(); + // Pre-seed a stale value on a2 (whose data.cat is empty). + await db.insert(schema.documentPropertyValues).values({ + id: `pv_stale_${f.docs.a2}`, + ownerEmail: OWNER, + documentId: f.docs.a2, + propertyId: f.tagPropertyId, + valueJson: JSON.stringify("STALE"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + await asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ); + // The empty-valued row no longer shows the stale value. + expect(await tagValue(f.docs.a2, f.tagPropertyId)).toBeUndefined(); + expect(await tagValue(f.docs.a1, f.tagPropertyId)).toBe("Alpha"); + }); + + it("rejects rebinding a field already bound to another column", async () => { + const f = await seedRowUnion(); + const db = getDb(); + const otherProp = `prop_other_${f.databaseId}`; + await db.insert(schema.documentPropertyDefinitions).values({ + id: otherProp, + ownerEmail: OWNER, + databaseId: f.databaseId, + name: "Other", + type: "text", + visibility: "always_show", + optionsJson: "{}", + position: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + await asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ); + await expect( + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: otherProp, + }), + ), + ).rejects.toThrow(/already bound to another column/i); + }); + + it("rejects a second field from the same source on the same column", async () => { + const f = await seedRowUnion(); + await asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ); + await expect( + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldAOther, + propertyId: f.tagPropertyId, + }), + ), + ).rejects.toThrow(/already feeds this column/i); + }); + + it("rejects a multi-value field into a text column", async () => { + const f = await seedRowUnion(); + await expect( + asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldALabels, + propertyId: f.tagPropertyId, + }), + ), + ).rejects.toThrow(/multi-value/i); + }); + + it("allows two different sources to feed one column, then unbinds", async () => { + const f = await seedRowUnion(); + await asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: f.tagPropertyId, + }), + ); + await asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldBCat, + propertyId: f.tagPropertyId, + }), + ); + // Both sources now feed "Tag": A's a1 and B's b1 both populated. + expect(await tagValue(f.docs.a1, f.tagPropertyId)).toBe("Alpha"); + expect(await tagValue(f.docs.b1, f.tagPropertyId)).toBe("Beta"); + + // Unbind source A's field; its mapping reverts to unmapped. + await asOwner(() => + bindAction.run({ + databaseId: f.databaseId, + sourceFieldId: f.fields.fieldACat, + propertyId: null, + }), + ); + const db = getDb(); + const [field] = await db + .select({ propertyId: schema.contentDatabaseSourceFields.propertyId }) + .from(schema.contentDatabaseSourceFields) + .where(eq(schema.contentDatabaseSourceFields.id, f.fields.fieldACat)); + expect(field.propertyId).toBeNull(); + }); +}); diff --git a/templates/content/actions/bind-content-database-source-field.ts b/templates/content/actions/bind-content-database-source-field.ts new file mode 100644 index 0000000000..2a5dc72c3c --- /dev/null +++ b/templates/content/actions/bind-content-database-source-field.ts @@ -0,0 +1,228 @@ +import { defineAction } from "@agent-native/core"; +import { assertAccess } from "@agent-native/core/sharing"; +import { and, eq, inArray, ne } from "drizzle-orm"; +import { z } from "zod"; +import type { + BindContentDatabaseSourceFieldRequest, + ContentDatabaseResponse, +} from "../shared/api.js"; +import { serializePropertyValue } from "../shared/properties.js"; +import { getDb, schema } from "../server/db/index.js"; +import { nanoid } from "./_property-utils.js"; +import { + propertyTypeForSourceField, + sourceFieldPropertyValuesFromRows, +} from "./add-content-database-source-field-property.js"; +import { resolveDatabaseForSourceMutation } from "./_database-source-utils.js"; +import { getContentDatabaseResponse } from "./_database-utils.js"; + +const SOURCE_TAG_PROPERTY_NAME = "Source"; + +export default defineAction({ + description: + "Bind a source field to an existing database column (row-union per-source field binding), or unbind it. Binding routes the source's per-row values into the shared column; types must be compatible. Pass propertyId: null to unbind.", + schema: z.object({ + databaseId: z.string().optional().describe("Database ID"), + documentId: z.string().optional().describe("Database document/page ID"), + sourceFieldId: z.string().describe("Source field mapping ID"), + propertyId: z + .string() + .nullable() + .describe( + "Target column property to bind the field to, or null to unbind.", + ), + }), + run: async ( + args: BindContentDatabaseSourceFieldRequest, + ): Promise => { + const database = await resolveDatabaseForSourceMutation(args); + if (!database) throw new Error("Database not found."); + await assertAccess("document", database.documentId, "editor"); + + const db = getDb(); + const [field] = await db + .select() + .from(schema.contentDatabaseSourceFields) + .where(eq(schema.contentDatabaseSourceFields.id, args.sourceFieldId)); + if (!field) throw new Error("Source field not found."); + + const [source] = await db + .select() + .from(schema.contentDatabaseSources) + .where( + and( + eq(schema.contentDatabaseSources.id, field.sourceId), + eq(schema.contentDatabaseSources.databaseId, database.id), + ), + ); + if (!source) { + throw new Error("Source field does not belong to this database."); + } + if (field.mappingType === "title") { + throw new Error("The title field is bound to Name and can't be rebound."); + } + if (field.mappingType === "system" || field.writeOwner === "derived") { + throw new Error("Integration-managed fields can't be bound to a column."); + } + + const now = new Date().toISOString(); + + // ── Unbind ──────────────────────────────────────────────────────────── + if (args.propertyId === null) { + await db + .update(schema.contentDatabaseSourceFields) + .set({ + propertyId: null, + localFieldKey: field.sourceFieldKey, + updatedAt: now, + }) + .where(eq(schema.contentDatabaseSourceFields.id, field.id)); + await db + .update(schema.contentDatabaseSources) + .set({ updatedAt: now }) + .where(eq(schema.contentDatabaseSources.id, source.id)); + return getContentDatabaseResponse(database.id); + } + + // ── Bind to an existing column ───────────────────────────────────────── + const [property] = await db + .select() + .from(schema.documentPropertyDefinitions) + .where( + and( + eq(schema.documentPropertyDefinitions.id, args.propertyId), + eq(schema.documentPropertyDefinitions.databaseId, database.id), + ), + ); + if (!property) { + throw new Error("Target column does not belong to this database."); + } + // The auto-created "Source" tag is internal row-tagging, never a writable + // bind target. + if ( + property.name === SOURCE_TAG_PROPERTY_NAME && + property.type === "select" + ) { + throw new Error("The Source tag column can't be bound to a source field."); + } + // Don't silently repoint a field that's already feeding another column — + // that would orphan the old column's materialized values. Require an + // explicit unbind first. (Re-binding to the SAME column is an idempotent + // refresh and is allowed.) + if (field.propertyId && field.propertyId !== property.id) { + throw new Error( + "This source field is already bound to another column. Unbind it first.", + ); + } + // At most one field per source per column: a column reads one value per row, + // and a row belongs to one source. Enforce server-side, not just in the UI. + const [conflictingField] = await db + .select({ id: schema.contentDatabaseSourceFields.id }) + .from(schema.contentDatabaseSourceFields) + .where( + and( + eq(schema.contentDatabaseSourceFields.sourceId, source.id), + eq(schema.contentDatabaseSourceFields.propertyId, property.id), + ne(schema.contentDatabaseSourceFields.id, field.id), + ), + ); + if (conflictingField) { + throw new Error( + "This source already feeds this column from another field. Unbind it first.", + ); + } + // Only type-compatible fields can share a column. A `text` column is a + // permissive target for SCALAR fields; a multi-value (list) field would be + // lossily stringified, so it needs a matching list/multi-select column. + // Otherwise the field's derived type must equal the column's type. + const fieldType = propertyTypeForSourceField(field.sourceFieldType); + const fieldIsMultiValue = ["list", "array", "tags", "multi_select"].includes( + field.sourceFieldType.trim().toLowerCase(), + ); + if (property.type === "text") { + if (fieldIsMultiValue) { + throw new Error( + "A multi-value source field can't be bound to a text column.", + ); + } + } else if (property.type !== fieldType) { + throw new Error( + `Field type "${fieldType}" is not compatible with the "${property.type}" column.`, + ); + } + + await db + .update(schema.contentDatabaseSourceFields) + .set({ + propertyId: property.id, + localFieldKey: property.id, + mappingType: "property", + updatedAt: now, + }) + .where(eq(schema.contentDatabaseSourceFields.id, field.id)); + await db + .update(schema.contentDatabaseSources) + .set({ updatedAt: now }) + .where(eq(schema.contentDatabaseSources.id, source.id)); + + // Backfill the column with this source's per-row values. A federated + // secondary's rows carry no local document (the read path overlays them), + // so only materialize for document-backed sources. + let federationRole: string | null = null; + try { + const parsed = JSON.parse(source.metadataJson ?? "{}") as { + federation?: { role?: string }; + }; + federationRole = parsed.federation?.role ?? null; + } catch { + federationRole = null; + } + if (federationRole !== "secondary") { + const sourceRows = await db + .select() + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); + const itemValues = sourceFieldPropertyValuesFromRows( + sourceRows, + field.sourceFieldKey, + property.type, + ); + // Clear this column's values for ALL of this source's rows first — not + // just the rows that now have a value — so a row whose new bound field is + // empty doesn't keep showing a stale/previous value. Then write the + // non-empty ones. (This source owns these documents' values for the row- + // union, so clearing them is safe.) + const sourceDocumentIds = sourceRows + .map((row) => row.documentId) + .filter((id): id is string => Boolean(id)); + if (sourceDocumentIds.length > 0) { + await db + .delete(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.propertyId, property.id), + inArray( + schema.documentPropertyValues.documentId, + sourceDocumentIds, + ), + ), + ); + } + if (itemValues.length > 0) { + await db.insert(schema.documentPropertyValues).values( + itemValues.map((row) => ({ + id: nanoid(), + ownerEmail: database.ownerEmail, + documentId: row.documentId, + propertyId: property.id, + valueJson: serializePropertyValue(row.value), + createdAt: now, + updatedAt: now, + })), + ); + } + } + + return getContentDatabaseResponse(database.id); + }, +}); diff --git a/templates/content/actions/content-database-source-actions.test.ts b/templates/content/actions/content-database-source-actions.test.ts index d9216706ad..f004984890 100644 --- a/templates/content/actions/content-database-source-actions.test.ts +++ b/templates/content/actions/content-database-source-actions.test.ts @@ -9,6 +9,7 @@ import addSourceFieldProperty, { } from "./add-content-database-source-field-property"; import attachSource from "./attach-content-database-source"; 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"; @@ -32,6 +33,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", @@ -292,6 +313,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-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..9392eac005 --- /dev/null +++ b/templates/content/actions/execute-builder-source-batch.ts @@ -0,0 +1,281 @@ +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 { + getContentDatabaseSourceSnapshotForWrite, + 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< + ExecuteBuilderSourceBatchRequest, + "databaseId" | "documentId" | "sourceId" + >, +): ExecuteBuilderSourceBatchDeps { + return { + resolveDatabase: (request) => resolveDatabaseForSourceMutation(request), + assertEditor: async (database) => { + await assertAccess("document", database.documentId, "editor"); + }, + getSourceSnapshot: (database) => + getContentDatabaseSourceSnapshotForWrite(database, args.sourceId), + runOne: async (changeSetId, transition) => { + const executionArgs = { + databaseId: args.databaseId, + documentId: args.documentId, + sourceId: args.sourceId, + changeSetId, + publicationTransition: transition?.publicationTransition, + confirmUnpublish: transition?.confirmUnpublish, + }; + if (transition?.publicationTransition) { + await prepareBuilderSourceExecution.run(executionArgs); + } + try { + await executeBuilderSourceExecutionWithDeps( + executionArgs, + realExecutionDeps(args.sourceId), + ); + } catch (error) { + if (!isMissingGateMessage(errorMessage(error))) { + throw error; + } + await prepareBuilderSourceExecution.run(executionArgs); + await executeBuilderSourceExecutionWithDeps( + executionArgs, + realExecutionDeps(args.sourceId), + ); + } + 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"), + sourceId: z + .string() + .optional() + .describe("Target source ID (defaults to the primary source)"), + 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.live.test.ts b/templates/content/actions/execute-builder-source-execution.live.test.ts new file mode 100644 index 0000000000..e03021b7bc --- /dev/null +++ b/templates/content/actions/execute-builder-source-execution.live.test.ts @@ -0,0 +1,561 @@ +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 { readBuilderCmsEntryLiveState } from "./_builder-cms-read-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)), + 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); + }), + 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"); + }); + }, +); + +// 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, + allowPublicationTransitions: Boolean(opts.publicationTransition), + }; + 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 d89f02a409..0b5163324c 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -10,6 +10,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, @@ -19,6 +20,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 +75,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, }; } @@ -165,11 +168,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", @@ -185,6 +192,7 @@ function depsFor(args: { execution: BuilderSourceExecutionRecord | null; writeResult?: BuilderCmsWriteResult; claimExecution?: boolean; + readLiveEntry?: BuilderCmsEntryLiveState; }): ExecuteBuilderSourceExecutionDeps { return { now: vi.fn(() => NOW), @@ -206,6 +214,16 @@ function depsFor(args: { responseBody: { id: "builder-entry-1" }, }, ), + readLiveEntry: vi.fn(async () => + args.readLiveEntry + ? args.readLiveEntry + : { + exists: true, + published: "draft", + lastUpdated: BUILDER_LAST_UPDATED_MS, + id: "builder-entry-1", + }, + ), reconcileWrite: vi.fn(async () => {}), getResponse: vi.fn(async () => RESPONSE), }; @@ -245,7 +263,7 @@ describe("execute Builder source execution", () => { expect(deps.executeWrite).not.toHaveBeenCalled(); }); - it("blocks synthetic fixture rows before any live write", async () => { + it("creates a new Builder entry for an unmatched (synthetic-fixture) row", async () => { const approvedChangeSet = changeSet(); const builderSource = source({ liveWritesEnabled: true, @@ -263,36 +281,40 @@ describe("execute Builder source execution", () => { source: builderSource, changeSet: approvedChangeSet, }); - const deps = depsFor({ source: builderSource, execution }); + const deps = depsFor({ + source: builderSource, + execution, + writeResult: { + ok: true, + status: 200, + entryId: "new-builder-entry", + responseBody: { id: "new-builder-entry" }, + }, + }); - await expect( - executeBuilderSourceExecutionWithDeps( - { - databaseId: "database-1", - changeSetId: approvedChangeSet.id, - pushModeConfirmation: "autosave", - }, - deps, - ), - ).rejects.toThrow( - "This row is not matched to a Builder entry yet. Refresh or match a Builder row before pushing.", + await executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "autosave", + }, + deps, ); - expect(deps.updateExecutionState).toHaveBeenCalledWith( + // create_draft skips the live preflight (no entry to read yet) and POSTs a + // new draft entry. + expect(deps.readLiveEntry).not.toHaveBeenCalled(); + expect(deps.executeWrite).toHaveBeenCalledWith( expect.objectContaining({ - executionId: execution.id, - state: "blocked", - lastError: - "This row is not matched to a Builder entry yet. Refresh or match a Builder row before pushing.", - payload: expect.objectContaining({ - target: expect.objectContaining({ - entryId: null, - sourceQualifiedId: null, - }), + request: expect.objectContaining({ + method: "POST", + body: expect.objectContaining({ published: "draft" }), }), }), ); - expect(deps.executeWrite).not.toHaveBeenCalled(); + expect(deps.markExecutionSucceeded).toHaveBeenCalledWith( + expect.objectContaining({ executionId: execution.id }), + ); }); it("blocks non-test Builder models before any write", async () => { @@ -445,6 +467,280 @@ describe("execute Builder source execution", () => { expect(reconcileCallOrder).toBeLessThan(successCallOrder); }); + 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({ + 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({ + rows: [ + row({ + lastSourceUpdatedAt: String(STALE_BUILDER_LAST_UPDATED_MS), + }), + ], + changeSets: [approvedChangeSet], + }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: true, + published: "draft", + lastUpdated: BUILDER_LAST_UPDATED_MS, + 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], + metadata: { + allowPublicationTransitions: true, + }, + }); + 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], + metadata: { + allowPublicationTransitions: true, + }, + }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + publicationTransition: "publish", + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + 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], + metadata: { + allowPublicationTransitions: true, + }, + }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + publicationTransition: "unpublish", + confirmUnpublish: true, + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + 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 7461649932..3e706e4f9e 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -14,6 +14,10 @@ import { type ExecuteBuilderSourceExecutionRequest, } from "../shared/api.js"; import { builderCmsQualifiedId } from "./_builder-cms-source-adapter.js"; +import { + type BuilderCmsEntryLiveState, + readBuilderCmsEntryLiveState, +} from "./_builder-cms-read-client.js"; import type { BuilderCmsExecutionPayload, BuilderCmsExecutionPlan, @@ -21,6 +25,7 @@ import type { import { buildBuilderCmsExecutionPlan, builderCmsExecutionIdempotencyKey, + resolveBuilderCmsExecutionPushMode, validateBuilderCmsExecutionDryRun, } from "./_builder-cms-write-adapter.js"; import { @@ -28,7 +33,7 @@ import { executeBuilderCmsWrite, } from "./_builder-cms-write-client.js"; import { - getContentDatabaseSourceSnapshot, + getContentDatabaseSourceSnapshotForWrite, resolveDatabaseForSourceMutation, } from "./_database-source-utils.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; @@ -94,6 +99,10 @@ export interface ExecuteBuilderSourceExecutionDeps { executeWrite: (args: { request: BuilderCmsExecutionPayload["request"]; }) => ReturnType; + readLiveEntry: (args: { + model: string; + entryId: string; + }) => Promise; reconcileWrite: (args: { database: DatabaseRecord; source: ContentDatabaseSource; @@ -206,6 +215,87 @@ 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 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; + effect: BuilderCmsExecutionPayload["effect"]; +}) { + if (!args.liveState.exists) { + return "Builder entry no longer exists; refresh the source."; + } + if ( + liveTimestampsDiffer({ + liveLastUpdated: args.liveState.lastUpdated, + baselineLastUpdated: 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; @@ -323,14 +413,17 @@ async function reconcileBuilderCmsWrite(args: { .where(eq(schema.contentDatabaseSources.id, args.source.id)); } -function realExecutionDeps(): ExecuteBuilderSourceExecutionDeps { +export function realExecutionDeps( + sourceId?: string, +): ExecuteBuilderSourceExecutionDeps { return { now: () => new Date().toISOString(), resolveDatabase: (args) => resolveDatabaseForSourceMutation(args), assertEditor: async (database) => { await assertAccess("document", database.documentId, "editor"); }, - getSourceSnapshot: (database) => getContentDatabaseSourceSnapshot(database), + getSourceSnapshot: (database) => + getContentDatabaseSourceSnapshotForWrite(database, sourceId), getExecution: async (args) => { const [execution] = await getDb() .select() @@ -426,6 +519,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), }; @@ -452,18 +546,28 @@ export async function executeBuilderSourceExecutionWithDeps( throw new Error("Only outbound Builder change sets can be executed."); } + const resolvedPushMode = resolveBuilderCmsExecutionPushMode({ + source, + changeSet, + }); + const effectivePushMode = + resolvedPushMode === "none" ? "autosave" : resolvedPushMode; const pushMode = executablePushMode( - args.pushModeConfirmation ?? changeSet.pushMode ?? source.metadata.pushMode, + args.pushModeConfirmation ?? effectivePushMode, ); if (!pushMode) { throw new Error( "Builder execution requires Autosave, Draft, or Publish push mode.", ); } + // The gate key is keyed on the RAW resolved push mode (matching the plan in + // buildBuilderCmsExecutionPlan) — NOT on pushModeConfirmation. Keying on the + // confirmation would let a caller's confirmation diverge the key from the + // prepared gate; the confirmation is still validated inside the plan below. const expectedKey = builderCmsExecutionIdempotencyKey({ sourceId: source.id, changeSetId: changeSet.id, - pushMode, + pushMode: resolvedPushMode, }); if (args.idempotencyKey && args.idempotencyKey !== expectedKey) { throw new Error( @@ -495,6 +599,8 @@ export async function executeBuilderSourceExecutionWithDeps( source, changeSet, pushModeConfirmation: pushMode, + publicationTransition: args.publicationTransition, + confirmUnpublish: args.confirmUnpublish, }); const storedPayload = parsePayload(execution.payloadJson); const validatedPayload = validateBuilderCmsExecutionDryRun({ @@ -590,6 +696,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.`, @@ -661,6 +814,10 @@ export default defineAction({ schema: z.object({ databaseId: z.string().optional().describe("Database ID"), documentId: z.string().optional().describe("Database document/page ID"), + sourceId: z + .string() + .optional() + .describe("Target source ID (defaults to the primary source)"), changeSetId: z.string().describe("Approved source change-set ID"), idempotencyKey: z .string() @@ -670,10 +827,21 @@ 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, ): Promise => { - return executeBuilderSourceExecutionWithDeps(args, realExecutionDeps()); + return executeBuilderSourceExecutionWithDeps( + args, + realExecutionDeps(args.sourceId), + ); }, }); diff --git a/templates/content/actions/prepare-builder-source-execution.ts b/templates/content/actions/prepare-builder-source-execution.ts index db2f67dd53..fd06d3a58b 100644 --- a/templates/content/actions/prepare-builder-source-execution.ts +++ b/templates/content/actions/prepare-builder-source-execution.ts @@ -10,7 +10,7 @@ import type { } from "../shared/api.js"; import { buildBuilderCmsExecutionPlan } from "./_builder-cms-write-adapter.js"; import { - getContentDatabaseSourceSnapshot, + getContentDatabaseSourceSnapshotForWrite, resolveDatabaseForSourceMutation, } from "./_database-source-utils.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; @@ -21,11 +21,23 @@ export default defineAction({ schema: z.object({ databaseId: z.string().optional().describe("Database ID"), documentId: z.string().optional().describe("Database document/page ID"), + sourceId: z + .string() + .optional() + .describe("Target source ID (defaults to the primary source)"), changeSetId: z.string().describe("Approved source change-set ID"), pushModeConfirmation: z .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, @@ -34,7 +46,10 @@ export default defineAction({ if (!database) throw new Error("Database not found."); await assertAccess("document", database.documentId, "editor"); - const source = await getContentDatabaseSourceSnapshot(database); + const source = await getContentDatabaseSourceSnapshotForWrite( + database, + args.sourceId, + ); if (!source || source.sourceType !== "builder-cms") { throw new Error( "Attach a Builder CMS source before preparing execution.", @@ -50,6 +65,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/actions/prepare-builder-source-review.ts b/templates/content/actions/prepare-builder-source-review.ts index 56b52ffb93..e621e333f3 100644 --- a/templates/content/actions/prepare-builder-source-review.ts +++ b/templates/content/actions/prepare-builder-source-review.ts @@ -17,12 +17,12 @@ import type { } from "../shared/api.js"; import { buildBuilderCmsExecutionPlan, + resolveBuilderCmsWriteEffect, validateBuilderCmsExecutionDryRun, } from "./_builder-cms-write-adapter.js"; import { findOpenSourceChangeSet, - getContentDatabaseSourceSnapshot, - getExistingSource, + getContentDatabaseSourceSnapshotForWrite, resolveDatabaseForSourceMutation, sourceChangeSetKey, } from "./_database-source-utils.js"; @@ -103,6 +103,10 @@ export function buildBuilderSourceReviewPayload(args: { riskLevel: changeSet.riskLevel, riskReasons: changeSet.riskReasons, conflictState: changeSet.conflictState, + effect: resolveBuilderCmsWriteEffect({ + source: args.source, + changeSet, + }), execution: latestExecution, }; }); @@ -360,6 +364,10 @@ export default defineAction({ schema: z.object({ databaseId: z.string().optional().describe("Database ID"), documentId: z.string().optional().describe("Database document/page ID"), + sourceId: z + .string() + .optional() + .describe("Target source ID (defaults to the primary source)"), pushModeConfirmation: z .enum(["autosave", "draft", "publish"]) .optional() @@ -372,13 +380,13 @@ export default defineAction({ if (!database) throw new Error("Database not found."); await assertAccess("document", database.documentId, "editor"); - const sourceRecord = await getExistingSource(database.id); - if (!sourceRecord || sourceRecord.sourceType !== "builder-cms") { + const snapshot = await getContentDatabaseSourceSnapshotForWrite( + database, + args.sourceId, + ); + if (!snapshot || snapshot.sourceType !== "builder-cms") { throw new Error("Attach a Builder CMS source before reviewing updates."); } - - const snapshot = await getContentDatabaseSourceSnapshot(database); - if (!snapshot) throw new Error("Attach a source before reviewing updates."); const reviewableChanges = snapshot.changeSets.filter( (changeSet) => changeSet.direction === "outbound" && @@ -397,7 +405,7 @@ export default defineAction({ for (const changeSet of reviewableChanges) { approvedIds.push( await approveChangeSetForReview({ - sourceId: sourceRecord.id, + sourceId: snapshot.id, ownerEmail: database.ownerEmail, changeSet, reviewerEmail, @@ -406,7 +414,10 @@ export default defineAction({ ); } - const approvedSnapshot = await getContentDatabaseSourceSnapshot(database); + const approvedSnapshot = await getContentDatabaseSourceSnapshotForWrite( + database, + args.sourceId, + ); if (!approvedSnapshot) throw new Error("Builder source disappeared."); const approvedChangeSets = approvedSnapshot.changeSets.filter( (changeSet) => @@ -425,19 +436,21 @@ export default defineAction({ await getDb() .update(schema.contentDatabaseSources) .set({ updatedAt: now }) - .where(eq(schema.contentDatabaseSources.id, sourceRecord.id)); + .where(eq(schema.contentDatabaseSources.id, snapshot.id)); - const response = await getContentDatabaseResponse(database.id); - const source = response.source; - if (!source) throw new Error("Builder source disappeared."); - const reviewedChangeSets = source.changeSets.filter((changeSet) => + // Build the review payload from the TARGET source snapshot, not + // response.source (which is always the primary). For a non-primary + // sourceId the review rows/effects/live-write flags must reflect the + // source actually being reviewed. + const reviewedChangeSets = approvedSnapshot.changeSets.filter((changeSet) => approvedIds.includes(changeSet.id), ); + const response = await getContentDatabaseResponse(database.id); return { ...response, review: buildBuilderSourceReviewPayload({ - source, + source: approvedSnapshot, changeSets: reviewedChangeSets, }), }; diff --git a/templates/content/actions/refresh-content-database-source.ts b/templates/content/actions/refresh-content-database-source.ts index 9dbf30b7a4..50a2f05d41 100644 --- a/templates/content/actions/refresh-content-database-source.ts +++ b/templates/content/actions/refresh-content-database-source.ts @@ -4,8 +4,8 @@ import { z } from "zod"; import type { ContentDatabaseSourceStatusResponse } from "../shared/api.js"; import { - getContentDatabaseSourceSnapshot, - getExistingSource, + getContentDatabaseSourceSnapshotForWrite, + getExistingSourceForWrite, resyncBuilderCmsSourceSnapshot, resyncMockSourceSnapshot, resolveDatabaseForSourceMutation, @@ -18,13 +18,17 @@ export default defineAction({ schema: z.object({ databaseId: z.string().optional().describe("Database ID"), documentId: z.string().optional().describe("Database document/page ID"), + sourceId: z + .string() + .optional() + .describe("Target source ID (defaults to the primary source)"), }), run: async (args): Promise => { const database = await resolveDatabaseForSourceMutation(args); if (!database) throw new Error("Database not found."); await assertAccess("document", database.documentId, "editor"); - const source = await getExistingSource(database.id); + const source = await getExistingSourceForWrite(database.id, args.sourceId); if (!source) { return { database: serializeDatabase(database), @@ -45,7 +49,10 @@ export default defineAction({ } else { throw new Error(`Unsupported source type "${source.sourceType}".`); } - const snapshot = await getContentDatabaseSourceSnapshot(database); + const snapshot = await getContentDatabaseSourceSnapshotForWrite( + database, + args.sourceId, + ); return { database: serializeDatabase(database), diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts new file mode 100644 index 0000000000..e58e20ade4 --- /dev/null +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -0,0 +1,206 @@ +// Integration test for the row-union resync over-claim fix (slice 6b). Boots a +// real in-memory libsql DB, simulates the PRE-FIX corrupted state where source +// A over-claimed every database item (including source B's row), then resyncs +// A against a mocked live Builder read and asserts the self-heal: A keeps only +// its own remote-backed rows and never re-claims B's row. + +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rmSync } from "node:fs"; + +// Mock the Builder read client so resync runs "live" with deterministic entries +// (no network). Real exports are preserved; only the two reads are overridden. +vi.mock("./_builder-cms-read-client.js", async () => { + const actual = await vi.importActual< + typeof import("./_builder-cms-read-client.js") + >("./_builder-cms-read-client.js"); + return { + ...actual, + readBuilderCmsModelFields: vi.fn(async () => []), + readBuilderCmsContentEntries: vi.fn(async ({ model }: { model: string }) => ({ + state: model === "collection-a" ? "live" : "unconfigured", + entries: + model === "collection-a" + ? [ + { + id: "entry-a1", + model: "collection-a", + title: "A One", + urlPath: "/a-one", + updatedAt: "2026-01-01T00:00:00.000Z", + sourceValues: { "data.title": "A One" }, + }, + { + id: "entry-a2", + model: "collection-a", + title: "A Two", + urlPath: "/a-two", + updatedAt: "2026-01-01T00:00:00.000Z", + sourceValues: { "data.title": "A Two" }, + }, + ] + : [], + fetchedAt: "2026-01-01T00:00:00.000Z", + message: null, + })), + }; +}); + +const TEST_DB_PATH = join( + tmpdir(), + `resync-source-test-${process.pid}-${Date.now()}.sqlite`, +); +process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; + +let getDb: () => any; +let schema: typeof import("../server/db/schema.js"); +let resync: typeof import("./_database-source-utils.js").resyncBuilderCmsSourceSnapshot; + +const OWNER = "owner@example.com"; + +beforeAll(async () => { + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + const plugin = (await import("../server/plugins/db.js")).default; + await plugin(undefined as any); + resync = (await import("./_database-source-utils.js")) + .resyncBuilderCmsSourceSnapshot; +}, 60000); + +afterAll(() => { + for (const suffix of ["", "-shm", "-wal"]) { + rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); + } +}); + +it("resync re-links only the source's own rows, never another collection's (self-heal)", async () => { + const db = getDb(); + const now = new Date().toISOString(); + const databaseId = "db_resync"; + const databaseDocId = "doc_db_resync"; + await db.insert(schema.documents).values({ + id: databaseDocId, + ownerEmail: OWNER, + title: "DB", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabases).values({ + id: databaseId, + ownerEmail: OWNER, + documentId: databaseDocId, + title: "DB", + createdAt: now, + updatedAt: now, + }); + // Two sources so the multi-source restriction applies. + await db.insert(schema.contentDatabaseSources).values([ + { + id: "src-a", + ownerEmail: OWNER, + databaseId, + sourceType: "builder-cms", + sourceName: "collection-a", + sourceTable: "collection-a", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: now, + }, + { + id: "src-b", + ownerEmail: OWNER, + databaseId, + sourceType: "builder-cms", + sourceName: "collection-b", + sourceTable: "collection-b", + createdAt: "2026-01-02T00:00:00.000Z", + updatedAt: now, + }, + ]); + + async function addDoc(id: string, title: string, position: number) { + await db.insert(schema.documents).values({ + id, + ownerEmail: OWNER, + title, + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseItems).values({ + id: `item_${id}`, + ownerEmail: OWNER, + databaseId, + documentId: id, + position, + createdAt: now, + updatedAt: now, + }); + } + await addDoc("doc-a1", "A One", 0); + await addDoc("doc-a2", "A Two", 1); + await addDoc("doc-b1", "B Item", 2); + + function srcRow( + id: string, + sourceId: string, + documentId: string, + sourceRowId: string, + ) { + return { + id, + ownerEmail: OWNER, + sourceId, + databaseItemId: `item_${documentId}`, + documentId, + sourceRowId, + sourceQualifiedId: `q_${sourceRowId}`, + sourceDisplayKey: documentId, + sourceValuesJson: "{}", + provenance: "Builder CMS read adapter", + createdAt: now, + updatedAt: now, + }; + } + // Source B legitimately owns doc-b1. + await db + .insert(schema.contentDatabaseSourceRows) + .values(srcRow("row-b1", "src-b", "doc-b1", "entry-b1")); + // PRE-FIX over-claim: source A claims ALL THREE docs, including B's row. + await db.insert(schema.contentDatabaseSourceRows).values([ + srcRow("row-a1", "src-a", "doc-a1", "entry-a1"), + srcRow("row-a2", "src-a", "doc-a2", "entry-a2"), + srcRow("row-a-bogus", "src-a", "doc-b1", "bogus-claim"), + ]); + + const [database] = await db + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, databaseId)); + const [sourceA] = await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, "src-a")); + + await resync({ database, source: sourceA, now }); + + const aRows = await db + .select({ documentId: schema.contentDatabaseSourceRows.documentId }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, "src-a")); + const aDocIds = aRows.map((r: { documentId: string }) => r.documentId).sort(); + + // A keeps only its own two remote-backed rows; the over-claimed B row is gone. + expect(aDocIds).toEqual(["doc-a1", "doc-a2"]); + expect(aDocIds).not.toContain("doc-b1"); + + // Source B's own row is untouched. + const bRows = await db + .select({ documentId: schema.contentDatabaseSourceRows.documentId }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, "src-b")); + expect(bRows.map((r: { documentId: string }) => r.documentId)).toEqual([ + "doc-b1", + ]); +}); diff --git a/templates/content/actions/review-content-database-source-change-set.ts b/templates/content/actions/review-content-database-source-change-set.ts index 7b4ee9b3ab..6d78c01e2a 100644 --- a/templates/content/actions/review-content-database-source-change-set.ts +++ b/templates/content/actions/review-content-database-source-change-set.ts @@ -10,7 +10,7 @@ import type { ReviewContentDatabaseSourceChangeSetRequest, } from "../shared/api.js"; import { - getExistingSource, + getExistingSourceForWrite, resolveDatabaseForSourceMutation, } from "./_database-source-utils.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; @@ -28,6 +28,10 @@ export default defineAction({ schema: z.object({ databaseId: z.string().optional().describe("Database ID"), documentId: z.string().optional().describe("Database document/page ID"), + sourceId: z + .string() + .optional() + .describe("Target source ID (defaults to the primary source)"), changeSetId: z.string().describe("Source change-set ID"), decision: z .enum(["approve", "reject"]) @@ -41,7 +45,7 @@ export default defineAction({ if (!database) throw new Error("Database not found."); await assertAccess("document", database.documentId, "editor"); - const source = await getExistingSource(database.id); + const source = await getExistingSourceForWrite(database.id, args.sourceId); if (!source) throw new Error("Attach a source before reviewing changes."); const [changeSet] = await getDb() 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 657b34eeba..6945803eb1 100644 --- a/templates/content/actions/set-content-database-source-write-mode.ts +++ b/templates/content/actions/set-content-database-source-write-mode.ts @@ -1,22 +1,31 @@ import { defineAction } from "@agent-native/core"; import { assertAccess } from "@agent-native/core/sharing"; -import { asc, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { type ContentDatabaseResponse, type ContentDatabaseSourcePushMode, + type ContentDatabaseSourceWriteMode, type SetContentDatabaseSourceWriteModeRequest, } from "../shared/api.js"; import { buildBuilderCmsWriteModeJson, type BuilderCmsLiveWriteMode, } from "./_builder-cms-write-settings.js"; -import { resolveDatabaseForSourceMutation } from "./_database-source-utils.js"; +import { + getExistingSourceForWrite, + 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, @@ -29,17 +38,31 @@ 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"), + sourceId: z + .string() + .optional() + .describe("Target source ID (defaults to the primary source)"), 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() @@ -59,12 +82,7 @@ export default defineAction({ await assertAccess("document", database.documentId, "editor"); const db = getDb(); - const [source] = await db - .select() - .from(schema.contentDatabaseSources) - .where(eq(schema.contentDatabaseSources.databaseId, database.id)) - .orderBy(asc(schema.contentDatabaseSources.createdAt)) - .limit(1); + const source = await getExistingSourceForWrite(database.id, args.sourceId); if (!source) { throw new Error( "Attach a Builder CMS source before changing write mode.", @@ -82,6 +100,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/actions/stage-builder-revision.ts b/templates/content/actions/stage-builder-revision.ts index f8536a9a54..ab4a07f113 100644 --- a/templates/content/actions/stage-builder-revision.ts +++ b/templates/content/actions/stage-builder-revision.ts @@ -7,8 +7,7 @@ import { getDb, schema } from "../server/db/index.js"; import type { ContentDatabaseResponse } from "../shared/api.js"; import { findOpenSourceChangeSet, - getContentDatabaseSourceSnapshot, - getExistingSource, + getContentDatabaseSourceSnapshotForWrite, resolveDatabaseForSourceMutation, sourceChangeSetKey, } from "./_database-source-utils.js"; @@ -20,24 +19,29 @@ export default defineAction({ schema: z.object({ databaseId: z.string().optional().describe("Database ID"), documentId: z.string().optional().describe("Database document/page ID"), + sourceId: z + .string() + .optional() + .describe("Target source ID (defaults to the primary source)"), }), run: async (args): Promise => { const database = await resolveDatabaseForSourceMutation(args); if (!database) throw new Error("Database not found."); await assertAccess("document", database.documentId, "editor"); - const source = await getExistingSource(database.id); + const source = await getContentDatabaseSourceSnapshotForWrite( + database, + args.sourceId, + ); if (!source || source.sourceType !== "builder-cms") { throw new Error("Attach a Builder CMS source before staging a revision."); } - const snapshot = await getContentDatabaseSourceSnapshot(database); - const pendingOutboundChanges = - snapshot?.changeSets.filter( - (changeSet) => - changeSet.direction === "outbound" && - changeSet.state === "pending_push", - ) ?? []; + const pendingOutboundChanges = source.changeSets.filter( + (changeSet) => + changeSet.direction === "outbound" && + changeSet.state === "pending_push", + ); if (pendingOutboundChanges.length === 0) { throw new Error("No pending local Builder changes to stage."); diff --git a/templates/content/actions/validate-builder-source-execution.ts b/templates/content/actions/validate-builder-source-execution.ts index 65c8b53bf1..f99a20220c 100644 --- a/templates/content/actions/validate-builder-source-execution.ts +++ b/templates/content/actions/validate-builder-source-execution.ts @@ -14,7 +14,7 @@ import { validateBuilderCmsExecutionDryRun, } from "./_builder-cms-write-adapter.js"; import { - getContentDatabaseSourceSnapshot, + getContentDatabaseSourceSnapshotForWrite, resolveDatabaseForSourceMutation, } from "./_database-source-utils.js"; import { getContentDatabaseResponse } from "./_database-utils.js"; @@ -36,6 +36,10 @@ export default defineAction({ schema: z.object({ databaseId: z.string().optional().describe("Database ID"), documentId: z.string().optional().describe("Database document/page ID"), + sourceId: z + .string() + .optional() + .describe("Target source ID (defaults to the primary source)"), changeSetId: z.string().describe("Approved source change-set ID"), idempotencyKey: z .string() @@ -49,7 +53,10 @@ export default defineAction({ if (!database) throw new Error("Database not found."); await assertAccess("document", database.documentId, "editor"); - const source = await getContentDatabaseSourceSnapshot(database); + const source = await getContentDatabaseSourceSnapshotForWrite( + database, + args.sourceId, + ); if (!source || source.sourceType !== "builder-cms") { throw new Error( "Attach a Builder CMS source before validating execution.", diff --git a/templates/content/app/components/editor/DocumentDatabase.test.ts b/templates/content/app/components/editor/DocumentDatabase.test.ts index e90cbbbe3d..1c4009a5f5 100644 --- a/templates/content/app/components/editor/DocumentDatabase.test.ts +++ b/templates/content/app/components/editor/DocumentDatabase.test.ts @@ -253,8 +253,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 3c3ff4e3b5..54bdcc2a8a 100644 --- a/templates/content/app/components/editor/DocumentDatabase.tsx +++ b/templates/content/app/components/editor/DocumentDatabase.tsx @@ -9,12 +9,15 @@ import { import { BUILDER_CMS_SAFE_WRITE_MODEL, type BuilderCmsModelSummary, + type BuilderCmsWriteEffect, type ContentDatabaseItem, type ContentDatabaseResponse, type ContentDatabaseSource, type ContentDatabaseSourceChangeSet, type ContentDatabaseSourceJoinRequest, type ContentDatabaseSourceReviewPayload, + type ContentDatabaseSourceWriteMode, + type ExecuteBuilderSourceBatchResponse, type SourceJoinSuggestion, type ContentDatabaseView, type ContentDatabaseViewConfig, @@ -140,7 +143,7 @@ import { useContentDatabases, useDisconnectContentDatabaseSource, useDuplicateDatabaseItem, - useExecuteBuilderSourceExecution, + useExecuteBuilderSourceBatch, useMoveDatabaseItem, usePrepareBuilderSourceReview, useRefreshContentDatabaseSource, @@ -186,7 +189,14 @@ import { peekPreviewDocumentSaveController, releasePreviewDocumentSaveController, } from "./previewDocumentSaveRegistry"; +import { type BuilderReviewPublicationTransitions } from "./database-sources/BuilderSourceReviewDialog"; import { VisualEditor } from "./VisualEditor"; +import { resolveBuilderCmsWriteEffect } from "../../../actions/_builder-cms-write-adapter.js"; + +type BuilderSourceWriteSettingsInput = { + writeMode: ContentDatabaseSourceWriteMode; + allowPublicationTransitions?: boolean; +}; interface DocumentDatabaseProps { document: Document; @@ -472,7 +482,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); @@ -484,6 +494,35 @@ function DatabaseTable({ const databaseId = data?.database.id ?? null; const source = data?.source ?? null; const sources = data?.sources ?? (source ? [source] : []); + // New-row source picker (row-union): when a database has 2+ sources, the auto + // -created "Source" select tags which collection a row belongs to. Surface a + // picker on "New" so a row is created already tagged for its collection (the + // backend then routes its create_draft to that source). Mirrors the server's + // SOURCE_PROPERTY_NAME ("Source", a select). + const sourceTagPicker = useMemo(() => { + if (sources.length < 2) return null; + const property = properties.find( + (item) => + item.definition.name === "Source" && item.definition.type === "select", + ); + if (!property) return null; + // Each Source option's id IS the source id (and "local" is the Local + // sentinel), so a collection always resolves to a valid tag — never a + // missing option that would silently create an untagged row. + const optionIds = new Set( + (property.definition.options.options ?? []).map((option) => option.id), + ); + const collections = sources + .filter( + (item) => item.sourceType === "builder-cms" && optionIds.has(item.id), + ) + .map((item) => ({ label: item.sourceName, optionId: item.id })); + return { + propertyId: property.definition.id, + collections, + localOptionId: optionIds.has("local") ? "local" : null, + }; + }, [properties, sources]); const [previewDocumentId, setPreviewDocumentId] = useState( null, ); @@ -499,9 +538,17 @@ function DatabaseTable({ const [builderReviewOpen, setBuilderReviewOpen] = useState(false); const [builderReviewResult, setBuilderReviewResult] = useState(null); + const [builderBatchResult, setBuilderBatchResult] = + useState(null); const [builderReviewCheckedAt, setBuilderReviewCheckedAt] = useState< string | null >(null); + // Which Builder source the review dialog / push targets. null ⇒ the primary + // source (single-source behavior). Set when opening a non-primary source's + // writable leaf so review/push/write-mode scope to that collection. + const [builderReviewSourceId, setBuilderReviewSourceId] = useState< + string | null + >(null); const [settingsPanel, setSettingsPanel] = useState("main"); const [viewConfig, setViewConfig] = useState( @@ -619,16 +666,30 @@ function DatabaseTable({ () => databaseSelectedItems(visibleItems, selectedItemIds), [visibleItems, selectedItemIds], ); + // The review dialog operates on the source the user opened, which may be a + // non-primary row-union collection. Fall back to the primary when no + // explicit target is set (single-source behavior). + const activeReviewSource = useMemo( + () => + builderReviewSourceId + ? (sources.find((item) => item.id === builderReviewSourceId) ?? source) + : source, + [builderReviewSourceId, sources, source], + ); const builderReviewChangeSets = useMemo( - () => builderReviewableChangeSets(source), - [source], + () => builderReviewableChangeSets(activeReviewSource), + [activeReviewSource], ); const builderReviewPreview = useMemo( () => - source?.sourceType === "builder-cms" && builderReviewChangeSets.length > 0 - ? buildClientBuilderReviewPayload(source, builderReviewChangeSets) + activeReviewSource?.sourceType === "builder-cms" && + builderReviewChangeSets.length > 0 + ? buildClientBuilderReviewPayload( + activeReviewSource, + builderReviewChangeSets, + ) : null, - [builderReviewChangeSets, source], + [builderReviewChangeSets, activeReviewSource], ); const activeBuilderReview = builderReviewResult ?? builderReviewPreview; @@ -937,56 +998,59 @@ function DatabaseTable({ updateActiveView((view) => ({ ...view, hideEmptyGroups })); } - async function handleBuilderReviewPush() { + async function handleBuilderReviewPush( + transitions: BuilderReviewPublicationTransitions = {}, + ) { setBuilderReviewResult(null); + setBuilderBatchResult(null); setBuilderReviewCheckedAt(null); + // Target the SAME source the dialog renders. activeReviewSource falls back + // to the primary if the selected source was disconnected/refetched out, so + // the backend never receives a stale id that the UI no longer reflects. + const targetSourceId = + activeReviewSource && activeReviewSource.id !== source?.id + ? activeReviewSource.id + : undefined; try { const prepared = await prepareBuilderReview.mutateAsync({ documentId: document.id, - pushModeConfirmation: "autosave", + sourceId: targetSourceId, }); 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, - ); - } - } + 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, + sourceId: targetSourceId, + changeSetIds, + transitions: + Object.keys(scopedTransitions).length > 0 + ? scopedTransitions + : undefined, + }); + 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) { @@ -1194,18 +1258,76 @@ function DatabaseTable({ ) : null} {canEdit ? ( - + sourceTagPicker ? ( + + + + + + {db("addARowTo")} + {sourceTagPicker.collections.map((collection) => ( + + void createRow( + "", + collection.optionId + ? { + [sourceTagPicker.propertyId]: + collection.optionId, + } + : {}, + ) + } + > + + {collection.label} + + ))} + + + void createRow( + "", + sourceTagPicker.localOptionId + ? { + [sourceTagPicker.propertyId]: + sourceTagPicker.localOptionId, + } + : {}, + ) + } + > + + {db("localNoCollection")} + + + + ) : ( + + ) ) : null} @@ -1488,6 +1610,10 @@ function DatabaseTable({ sourceType: "builder-cms", sourceName: model.displayName, sourceTable: model.name, + // A database that already has a source gets the new collection + // added as its own writable source (row-union); the first source + // replaces the empty binding. + mode: sources.length > 0 || source ? "add" : "replace", }) } onFederateSource={(candidate, join) => @@ -1500,24 +1626,37 @@ function DatabaseTable({ }) } onDisconnectSecondary={(sourceId) => - disconnectSource.mutate({ documentId: document.id, sourceId }) + disconnectSource.mutate( + { documentId: document.id, sourceId }, + { + onSuccess: () => { + setBuilderReviewSourceId((current) => + current === sourceId ? null : current, + ); + }, + }, + ) } - onRefreshSource={() => + onRefreshSource={(sourceId) => refreshSource.mutate({ documentId: document.id, + sourceId, }) } - onDisconnectSource={() => + onDisconnectSource={(sourceId) => disconnectSource.mutate( { documentId: document.id, + sourceId, }, { onSuccess: () => { setSettingsPanel("source"); setBuilderReviewOpen(false); setBuilderReviewResult(null); + setBuilderBatchResult(null); setBuilderReviewCheckedAt(null); + setBuilderReviewSourceId(null); toast.success(db("sourceDisconnected"), { description: db( "databaseRowsAndLocalPropertiesWereKeptIntact", @@ -1533,30 +1672,34 @@ function DatabaseTable({ }, ) } - onReviewBuilderUpdate={() => { + onReviewBuilderUpdate={(sourceId) => { setBuilderReviewResult(null); + setBuilderBatchResult(null); setBuilderReviewCheckedAt(null); + setBuilderReviewSourceId(sourceId ?? null); setBuilderReviewOpen(true); }} - onSetBuilderLiveWrites={(enabled) => + onSetBuilderLiveWrites={(settings, sourceId) => setSourceWriteMode.mutate( { documentId: document.id, - liveWritesEnabled: enabled, - allowedWriteModes: enabled ? ["autosave"] : [], + sourceId, + 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(db("builderWriteModeUpdated"), { + 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(db("builderWriteModeWasNotChanged"), { @@ -1572,7 +1715,7 @@ function DatabaseTable({ refreshSource.isPending || disconnectSource.isPending || prepareBuilderReview.isPending || - executeBuilderExecution.isPending || + executeBuilderBatch.isPending || setSourceWriteMode.isPending } onViewTypeChange={(type) => @@ -1594,14 +1737,15 @@ function DatabaseTable({ setBuilderReviewOpen(false)} - onValidate={() => void handleBuilderReviewPush()} + onValidate={(transitions) => void handleBuilderReviewPush(transitions)} /> {!database.isLoading ? ( @@ -3109,6 +3253,7 @@ function DatabaseTableView({ property={property} documentId={databaseDocumentId} source={source} + sources={sources} canEdit={canEdit} isDragging={draggedPropertyId === property.definition.id} dropSide={ @@ -3507,10 +3652,13 @@ function DatabaseSettingsPanelSheet({ join: ContentDatabaseSourceJoinRequest, ) => void; onDisconnectSecondary: (sourceId: string) => void; - onRefreshSource: () => void; - onDisconnectSource: () => void; - onReviewBuilderUpdate: () => void; - onSetBuilderLiveWrites: (enabled: boolean) => void; + onRefreshSource: (sourceId?: string) => void; + onDisconnectSource: (sourceId?: string) => void; + onReviewBuilderUpdate: (sourceId?: string) => void; + onSetBuilderLiveWrites: ( + settings: BuilderSourceWriteSettingsInput, + sourceId?: string, + ) => void; sourceActionPending: boolean; onViewTypeChange: (type: ContentDatabaseViewType) => void; onWrapCellsChange: (wrapCells: boolean) => void; @@ -3583,6 +3731,7 @@ function DatabaseSettingsPanelSheet({ 0 ? sources : source ? [source] : [] + ).reduce( + (total, item) => total + builderReviewableChangeSets(item).length, + 0, + ); return (
@@ -3758,28 +3916,94 @@ 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, ) { 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; + labelKey: string; + descriptionKey: string; +}> = [ + { + mode: "read_only", + labelKey: "readOnly", + descriptionKey: "noBuilderWrites", + }, + { + mode: "stage_only", + labelKey: "stageOnly", + descriptionKey: "savesDraftsNeverPublishes", + }, + { + mode: "publish_updates", + labelKey: "publishUpdates", + descriptionKey: "writesUpdatesToLiveEntries", + }, +]; + +function builderWriteModeSummary( + mode: ContentDatabaseSourceWriteMode, + db: DatabaseT, +) { + const option = BUILDER_WRITE_MODE_OPTIONS.find( + (candidate) => candidate.mode === mode, + ); + return db(option?.descriptionKey ?? "noBuilderWrites"); +} + export function buildClientBuilderReviewPayload( source: ContentDatabaseSource, changeSets: ContentDatabaseSourceChangeSet[], @@ -3819,6 +4043,7 @@ export function buildClientBuilderReviewPayload( riskLevel: changeSet.riskLevel, riskReasons: changeSet.riskReasons, conflictState: changeSet.conflictState, + effect: resolveBuilderCmsWriteEffect({ source, changeSet }), execution: latestExecution, }; }); @@ -3922,27 +4147,16 @@ function DatabaseSettingsSourcePanel({ join: ContentDatabaseSourceJoinRequest, ) => void; onDisconnectSecondary: (sourceId: string) => void; - onRefreshSource: () => void; - onDisconnectSource: () => void; - onReviewBuilderUpdate: () => void; - onSetBuilderLiveWrites: (enabled: boolean) => void; + onRefreshSource: (sourceId?: string) => void; + onDisconnectSource: (sourceId?: string) => void; + onReviewBuilderUpdate: (sourceId?: string) => void; + onSetBuilderLiveWrites: ( + settings: BuilderSourceWriteSettingsInput, + sourceId?: string, + ) => void; sourceActionPending: boolean; }) { const db = useDatabaseT(); - const outboundChangeSets = - source?.changeSets.filter( - (changeSet) => changeSet.direction === "outbound", - ) ?? []; - const reviewableBuilderChangeSets = outboundChangeSets.filter( - (changeSet) => - changeSet.state === "pending_push" || - changeSet.state === "staged_revision" || - changeSet.state === "approved", - ); - const conflictChangeSets = - source?.changeSets.filter( - (changeSet) => changeSet.conflictState === "source_changed", - ) ?? []; const { isCodeMode } = useCodeMode(); const isBuilderSource = source?.sourceType === "builder-cms"; const builderStatus = useBuilderStatus(); @@ -3963,33 +4177,42 @@ function DatabaseSettingsSourcePanel({ void builderStatus.refetch(); }, }); - const builderSyncFailed = - isBuilderSource && - (source?.syncState === "error" || Boolean(source?.lastError)); - // Auto-sync: the manual Refresh button is gone, so pull the read-only // snapshot when the panel opens and whenever the window regains focus. // Throttled so rapid focus changes don't hammer Builder; the refresh // mutation is silent (no toast), so this stays quiet in the background. const refreshSourceRef = useRef(onRefreshSource); refreshSourceRef.current = onRefreshSource; - const lastAutoSyncRef = useRef(0); + const lastAutoSyncRef = useRef<{ at: number; sourceId?: string }>({ at: 0 }); const autoSyncEnabled = Boolean(source) && isBuilderSource && canEdit; + const top = nav[nav.length - 1]; + // When a non-primary Builder source's leaf is open, auto-sync should refresh + // THAT source, not the primary. At the root/list (no model leaf) we fall back + // to the primary (undefined ⇒ primary in the refresh action). + const autoSyncSourceId = + top?.kind === "model" + ? sources.find( + (item) => + item.sourceType === "builder-cms" && + item.sourceTable === top.model.name, + )?.id + : undefined; useEffect(() => { if (!autoSyncEnabled) return; const maybeSync = () => { const now = Date.now(); - if (now - lastAutoSyncRef.current < 15_000) return; - lastAutoSyncRef.current = now; - refreshSourceRef.current(); + const last = lastAutoSyncRef.current; + // Throttle repeated syncs of the SAME source, but always sync when the + // viewed source changes (so opening a secondary leaf refreshes it). + if (last.sourceId === autoSyncSourceId && now - last.at < 15_000) return; + lastAutoSyncRef.current = { at: now, sourceId: autoSyncSourceId }; + refreshSourceRef.current(autoSyncSourceId); }; maybeSync(); const onFocus = () => maybeSync(); window.addEventListener("focus", onFocus); return () => window.removeEventListener("focus", onFocus); - }, [autoSyncEnabled]); - - const top = nav[nav.length - 1]; + }, [autoSyncEnabled, autoSyncSourceId]); // ── Sources list (root) ─────────────────────────────────────────────── if (!top) { @@ -3997,11 +4220,11 @@ function DatabaseSettingsSourcePanel({ - onNavPush({ kind: "provider", providerId: "builder" }) + onOpenConnectedBuilder={(connected) => + onNavPush({ + kind: "model", + model: builderModelSummaryFromSource(connected), + }) } onOpenSecondary={(secondary) => onNavPush({ @@ -4026,6 +4249,11 @@ function DatabaseSettingsSourcePanel({ .map((item) => item.sourceTable), ]} canEdit={canEdit} + builderConfigured={builderConfigured} + builderSpaceLabel={builderSpaceLabel} + onOpenBuilder={() => + onNavPush({ kind: "provider", providerId: "builder" }) + } onPickLocalTable={(table) => onNavPush({ kind: "keyConfirm", @@ -4127,9 +4355,9 @@ function DatabaseSettingsSourcePanel({ if (top.kind === "space") { return ( item.sourceType === "builder-cms") + .map((item) => item.sourceTable)} onOpenModel={(model) => onNavPush({ kind: "model", model })} /> ); @@ -4137,12 +4365,21 @@ function DatabaseSettingsSourcePanel({ // ── Model leaf ──────────────────────────────────────────────────────── const model = top.model; - const isAttachedModel = - Boolean(source) && isBuilderSource && source?.sourceTable === model.name; + // Resolve the source this leaf operates on by the model being viewed, NOT by + // assuming the primary — a row-union database has multiple writable Builder + // sources, and opening any of them must land on its own writable leaf. + // Collection names are unique per database (duplicate-attach is guarded), so + // matching on sourceTable is unambiguous. + const leafSource = + sources.find( + (item) => + item.sourceType === "builder-cms" && item.sourceTable === model.name, + ) ?? null; + const isAttachedModel = Boolean(leafSource); // Unattached model → the attach affordance (the model is already chosen by // drilling in, so there's no model picker here). - if (!isAttachedModel || !source) { + if (!isAttachedModel || !leafSource) { return (
@@ -4180,127 +4417,233 @@ function DatabaseSettingsSourcePanel({ ); } - // Attached model → the minimal read-only leaf panel. + // Attached model → the writable leaf panel for THIS source (primary or a + // row-union secondary). All derived state below is scoped to `leafSource`, + // never the component-level primary `source`. + const liveWriteControl = builderSourceLiveWriteControlState(leafSource); + const builderLiveWritesDisabled = !canEdit || sourceActionPending; + const builderWriteMode = liveWriteControl.writeMode; + const leafOutboundChangeSets = leafSource.changeSets.filter( + (changeSet) => changeSet.direction === "outbound", + ); + const leafReviewableChangeSets = leafOutboundChangeSets.filter( + (changeSet) => + changeSet.state === "pending_push" || + changeSet.state === "staged_revision" || + changeSet.state === "approved", + ); + const leafPendingChangeSets = leafOutboundChangeSets.filter( + (changeSet) => changeSet.state !== "applied", + ); + const leafAppliedChangeSets = leafOutboundChangeSets.filter( + (changeSet) => changeSet.state === "applied", + ); + const leafSyncFailed = + leafSource.syncState === "error" || Boolean(leafSource.lastError); + return (
<>
- - {source.sourceName} + + {leafSource.sourceName} - {isBuilderSource ? ( - source.capabilities.liveWritesEnabled ? ( - - - - - ) : ( - - - - - ) - ) : ( - - {source.syncState} + {!liveWriteControl.safeTarget ? ( + + + - )} + ) : null}
+ {!liveWriteControl.safeTarget ? ( +
+ {db("liveWritesTestCollectionOnly")} +
+ ) : null}
- {builderSyncFailed ? ( + {leafSyncFailed ? ( - ) : isBuilderSource ? ( + ) : ( [ builderConfigured ? (builderSpaceLabel ?? "Connected") : null, - source.lastRefreshedAt + leafSource.lastRefreshedAt ? `synced ${ - formatRelativeSyncTime(source.lastRefreshedAt) ?? - source.freshness + formatRelativeSyncTime(leafSource.lastRefreshedAt) ?? + leafSource.freshness }` - : source.freshness, + : leafSource.freshness, ] .filter(Boolean) .join(" · ") - ) : ( - `Local snapshot · ${source.freshness}` )}
-
- - {reviewableBuilderChangeSets.length > 0 || - conflictChangeSets.length > 0 ? ( -
-
-
-
- {conflictChangeSets.length > 0 - ? `${conflictChangeSets.length} change${ - conflictChangeSets.length === 1 ? "" : "s" - } need review` - : `${reviewableBuilderChangeSets.length} change${ - reviewableBuilderChangeSets.length === 1 ? "" : "s" - } ready to push`} -
-
- -
+ {liveWriteControl.safeTarget ? ( +
+
+ + {db("builderWriteMode")} + + {builderWriteModeSummary(builderWriteMode, db)}
- + {BUILDER_WRITE_MODE_OPTIONS.map((option) => { + const selected = builderWriteMode === option.mode; + return ( + + ); + })} +
+ {builderWriteMode === "publish_updates" ? ( + + ) : null}
-
- ) : null} + ) : null} +
- {isCodeMode ? ( - <> -
-
- {source.sourceType === "builder-cms" - ? "Local Builder changes" - : "Local outbound changes"} + {leafPendingChangeSets.length > 0 || + leafAppliedChangeSets.length > 0 || + isCodeMode ? ( +
+
{db("builderChanges")}
+
+ {leafSource.capabilities.liveWritesEnabled + ? db("reviewLocalEditsBeforeBuilder") + : db("liveWritesOffStagedForReview")} +
+ + {leafPendingChangeSets.length > 0 ? ( +
+ {leafPendingChangeSets.slice(0, 6).map((changeSet) => ( + + ))}
+ ) : isCodeMode ? (
- {source.sourceType === "builder-cms" - ? source.capabilities.liveWritesEnabled - ? "Local edits can be reviewed and sent through the guarded Builder autosave path." - : "Local edits can be staged as a Builder save revision/autosave record. Live Builder writes are disabled." - : "No local outbound push lane is active for this mock source."} + {db("noPendingChangesEditASourceBackedRow")}
-
- {outboundChangeSets.slice(0, 6).map((changeSet) => ( + ) : null} + + {leafReviewableChangeSets.length > 0 ? ( +
+ +
+ ) : null} + + {leafAppliedChangeSets.length > 0 ? ( +
+
+ {db("recentlyPushed")} +
+ {leafAppliedChangeSets.slice(0, 3).map((changeSet) => ( ))} - {outboundChangeSets.length === 0 ? ( -
- {source.sourceType === "builder-cms" - ? "No pending local Builder changes yet. Rename a source-backed row to see a local outbound diff." - : "No local outbound changes yet."} + {leafAppliedChangeSets.length > 3 ? ( +
+ +{leafAppliedChangeSets.length - 3} more
) : null}
-
- + ) : null} +
) : null}
@@ -4316,7 +4659,7 @@ function DatabaseSettingsSourcePanel({ variant="outline" className="mt-2 h-8 text-xs text-destructive hover:text-destructive" disabled={!canEdit || sourceActionPending} - onClick={onDisconnectSource} + onClick={() => onDisconnectSource(leafSource.id)} > {sourceActionPending ? ( @@ -4333,113 +4676,109 @@ function DatabaseSettingsSourcePanel({ // Root of the Sources drill-down: third-party integrations + Agent-Native apps, // each provider a row. Builder is live; the rest are disabled "coming soon". +function builderModelSummaryFromSource( + source: ContentDatabaseSource, +): BuilderCmsModelSummary { + return { + id: source.sourceTable, + name: source.sourceTable, + displayName: source.sourceName, + kind: "data", + fields: [], + }; +} + +function reviewableCountForSource(source: ContentDatabaseSource): number { + return source.changeSets.filter( + (changeSet) => + changeSet.direction === "outbound" && + (changeSet.state === "pending_push" || + changeSet.state === "staged_revision" || + changeSet.state === "approved"), + ).length; +} + function SourcesListView({ source, sources, - builderConfigured, - builderSpaceLabel, - reviewableCount, - onOpenBuilder, + onOpenConnectedBuilder, onOpenSecondary, onAddSource, }: { source: ContentDatabaseSource | null; sources: ContentDatabaseSource[]; - builderConfigured: boolean; - builderSpaceLabel: string | null; - reviewableCount: number; - onOpenBuilder: () => void; + onOpenConnectedBuilder: (source: ContentDatabaseSource) => void; onOpenSecondary: (source: ContentDatabaseSource) => void; onAddSource: () => void; }) { const db = useDatabaseT(); - const isBuilderSource = source?.sourceType === "builder-cms"; const connectedSources = sources.length > 0 ? sources : source ? [source] : []; - return ( -
- {connectedSources.length === 0 ? ( -
- -
- ) : ( -
-
- -
- {connectedSources.map((connected, index) => ( - - ) : ( - - ) - } - label={connected.sourceName} - value={ - connected.metadata.federation?.role === "secondary" - ? "Federated" - : index === 0 - ? "Primary" - : undefined - } - onClick={ - connected.metadata.federation?.role === "secondary" - ? () => onOpenSecondary(connected) - : connected.sourceType === "builder-cms" - ? onOpenBuilder - : undefined - } - disabled={ - connected.metadata.federation?.role !== "secondary" && - connected.sourceType !== "builder-cms" - } - /> - ))} - } - label={db("addAnotherSource")} - onClick={onAddSource} - /> -
- )} -
-
- + + // A brand-new database has no sources — the only action is to add one. + // Integrations (Builder, Notion, …) live inside the "Add a source" flow. + if (connectedSources.length === 0) { + return ( +
+
+ {db("connectABuilderCollectionToMapRows")}
} - label="Builder" - value={ - isBuilderSource - ? (builderSpaceLabel ?? "Connected") - : builderConfigured - ? "Connected" - : undefined - } - badgeCount={reviewableCount} - onClick={onOpenBuilder} - /> - } - label="Notion" - value="Coming soon" - disabled + icon={} + label={db("addASource")} + onClick={onAddSource} />
-
-
- -
+ ); + } + + return ( +
+
+ {db("connectedSources")} +
+ {connectedSources.map((connected, index) => ( } - label="Analytics" - value="Coming soon" - disabled + key={connected.id} + icon={ + connected.sourceType === "builder-cms" ? ( + + ) : ( + + ) + } + label={connected.sourceName} + value={ + connected.metadata.federation?.role === "secondary" + ? "Federated" + : index === 0 + ? "Primary" + : undefined + } + badgeCount={ + connected.metadata.federation?.role !== "secondary" && + connected.sourceType === "builder-cms" + ? reviewableCountForSource(connected) + : 0 + } + onClick={ + connected.metadata.federation?.role === "secondary" + ? () => onOpenSecondary(connected) + : connected.sourceType === "builder-cms" + ? () => onOpenConnectedBuilder(connected) + : undefined + } + disabled={ + connected.metadata.federation?.role !== "secondary" && + connected.sourceType !== "builder-cms" + } /> -
+ ))} + } + label={db("addAnotherSource")} + onClick={onAddSource} + />
); } @@ -4641,10 +4980,16 @@ function CanonicalKeyConfirmView({ function AddSourceView({ excludeDatabaseIds, canEdit, + builderConfigured, + builderSpaceLabel, + onOpenBuilder, onPickLocalTable, }: { excludeDatabaseIds: string[]; canEdit: boolean; + builderConfigured: boolean; + builderSpaceLabel: string | null; + onOpenBuilder: () => void; onPickLocalTable: (table: { databaseId: string; documentId: string; @@ -4690,6 +5035,15 @@ function AddSourceView({
+ } + label="Builder" + value={ + builderSpaceLabel ?? (builderConfigured ? "Connected" : undefined) + } + onClick={canEdit ? onOpenBuilder : undefined} + disabled={!canEdit} + /> } label="Notion" @@ -4697,6 +5051,17 @@ function AddSourceView({ disabled />
+
+
+ +
+ } + label="Analytics" + value="Coming soon" + disabled + /> +
); } @@ -4783,12 +5148,13 @@ function SecondarySourceLeaf({ // A Builder space's data models, as drill-in rows. The attached model (if any) // is marked; selecting a row opens that model's leaf. function BuilderSpaceModelsView({ - attachedModelName, + attachedModelNames, onOpenModel, }: { - attachedModelName: string | null; + attachedModelNames: string[]; onOpenModel: (model: BuilderCmsModelSummary) => void; }) { + const attachedModelNameSet = new Set(attachedModelNames); const db = useDatabaseT(); const modelsQuery = useBuilderCmsModels(true); const models = modelsQuery.data?.models ?? []; @@ -4860,15 +5226,15 @@ function BuilderSpaceModelsView({ model.displayName.toLowerCase().includes(normalizedQuery) || model.name.toLowerCase().includes(normalizedQuery); const filtered = models.filter(matchesQuery); - const attachedModels = filtered.filter( - (model) => attachedModelName === model.name, + const attachedModels = filtered.filter((model) => + attachedModelNameSet.has(model.name), ); const otherModels = filtered.filter( - (model) => attachedModelName !== model.name, + (model) => !attachedModelNameSet.has(model.name), ); const renderRow = (model: BuilderCmsModelSummary) => { - const isAttached = attachedModelName === model.name; + const isAttached = attachedModelNameSet.has(model.name); return ( +
+ ))} +
+ ) : ( +
+ {t("database.noSourceFieldsBoundYet")} +
+ )} + {bindableSourceFields.length > 0 ? ( + + + + {t("database.bindAFieldFromASource")} + + + {bindableSourceFields.map(({ source: src, field }) => ( + { + event.preventDefault(); + void bindSourceField.mutateAsync({ + documentId, + sourceFieldId: field.id, + propertyId: property.definition.id, + }); + }} + > + + + {field.sourceFieldLabel} + + + {src.sourceName} + + + ))} + + + ) : null} +
+ + ) : sourceAttached ? ( <>
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..ee38430005 --- /dev/null +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + builderReviewDestinationLine, + builderReviewEffectiveRowEffect, + builderReviewIntentSummary, + builderReviewPublicationTransitionsMap, + builderReviewResultStatus, + builderReviewRowEffectLabel, +} from "./BuilderSourceReviewDialog"; + +describe("BuilderSourceReviewDialog publication intent helpers", () => { + 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 intent in plain language, honoring transitions", () => { + expect( + 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": { + publicationTransition: "unpublish", + confirmUnpublish: true, + }, + }, + ), + ).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({ + labelKey: "pushed", + tone: "ok", + }); + expect(builderReviewResultStatus("validated").labelKey).toBe("ready"); + expect(builderReviewResultStatus("blocked")).toEqual({ + labelKey: "needsAttention", + tone: "warn", + }); + expect(builderReviewResultStatus("write_disabled").labelKey).toBe( + "checksOnly", + ); + }); + + 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 8d198565f3..91d48e43ca 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx @@ -1,12 +1,11 @@ +import { useEffect, useMemo, useState } from "react"; import { useT } from "@agent-native/core/client"; -import type { - ContentDatabaseSource, - ContentDatabaseSourceChangeSet, - ContentDatabaseSourceReviewPayload, - DocumentPropertyValue, -} from "@shared/api"; -import { IconCheck, IconX } from "@tabler/icons-react"; - +import { + IconAlertTriangle, + IconCheck, + IconCloudUpload, + IconX, +} from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -15,55 +14,201 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; +import { BUILDER_CMS_SAFE_WRITE_MODEL } from "@shared/api"; +import type { + BuilderCmsPublicationTransitionIntent, + BuilderCmsWriteEffect, + ContentDatabaseSource, + ContentDatabaseSourceReviewPayload, + DocumentPropertyValue, + ExecuteBuilderSourceBatchTransition, + ExecuteBuilderSourceBatchResponse, +} from "@shared/api"; -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"; +export type BuilderReviewPublicationTransitionSelection = { + publicationTransition: BuilderCmsPublicationTransitionIntent; + confirmUnpublish?: boolean; +}; + +export type BuilderReviewPublicationTransitionSelections = Record< + string, + BuilderReviewPublicationTransitionSelection +>; + +export type BuilderReviewPublicationTransitions = Record< + string, + ExecuteBuilderSourceBatchTransition +>; + +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; +} + +/** The effect a row will actually run, accounting for a chosen transition. */ +export function builderReviewEffectiveRowEffect( + baseEffect: BuilderCmsWriteEffect, + selection?: BuilderReviewPublicationTransitionSelection, +): BuilderCmsWriteEffect { + // A create has no Builder entry yet, so a publish/unpublish transition can't + // apply — the adapter always writes a draft (create_draft) when there's no + // entry id. Never let a transition relabel a create. + if (baseEffect === "create_draft") return "create_draft"; + if (selection?.publicationTransition === "publish") return "publish"; + if (selection?.publicationTransition === "unpublish") return "unpublish"; + return baseEffect; +} + +export function builderReviewIntentSummary( + rows: { changeSetId: string; effect: BuilderCmsWriteEffect }[], + selections: BuilderReviewPublicationTransitionSelections, +) { + 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; } - if (risk === "medium") { - return "rounded border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-amber-700"; + const parts: string[] = []; + if (counts.create_draft) { + parts.push( + `${counts.create_draft} draft${counts.create_draft === 1 ? "" : "s"} to create`, + ); } - return "rounded border border-emerald-300 bg-emerald-50 px-1.5 py-0.5 text-emerald-700"; + 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"; } -function sourceValueText(value: DocumentPropertyValue, emptyLabel: string) { - if (value === null || value === undefined || value === "") return emptyLabel; - if (Array.isArray(value)) return value.join(", ") || emptyLabel; - if (typeof value === "object") return JSON.stringify(value); - return String(value); +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."; } -function sourceBuilderReadModeSummary( - source: ContentDatabaseSource, - t: ReturnType, -) { - if (source.metadata.readMode === "builder-api") - return t("database.builderApiReadOnly"); - if (source.metadata.readMode === "local-fixture") - return t("database.localFixture"); - if (source.metadata.readMode === "unconfigured") - return t("database.notConfigured"); - if (source.metadata.readMode === "error") return t("database.readError"); - return t("database.localReview"); +export function builderReviewResultStatus(status?: string): { + // i18n key under the `database.` namespace; resolved by the caller via t(). + labelKey: string; + tone: "ok" | "warn" | "danger" | "muted"; +} { + switch (status) { + case "succeeded": + return { labelKey: "pushed", tone: "ok" }; + case "validated": + return { labelKey: "ready", tone: "ok" }; + case "partial": + case "blocked": + return { labelKey: "needsAttention", tone: "warn" }; + case "failed": + return { labelKey: "failedYouCanRetry", tone: "danger" }; + case "stale": + return { labelKey: "needsAFreshReview", tone: "warn" }; + case "running": + return { labelKey: "working", tone: "muted" }; + case "write_disabled": + return { labelKey: "checksOnly", tone: "muted" }; + default: + return { labelKey: "ready", tone: "muted" }; + } } -function sourcePushModeLabel( - mode: ContentDatabaseSource["metadata"]["pushMode"], - t: ReturnType, +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( + selections: BuilderReviewPublicationTransitionSelections, ) { - if (mode === "autosave") return t("database.saveRevisionAutosave"); - if (mode === "draft") return t("database.draft"); - if (mode === "publish") return t("database.publish"); - return t("database.none"); + 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 SourceMetadataRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); +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) { + if (value === null || value === undefined || value === "") return "empty"; + if (Array.isArray(value)) return value.join(", ") || "empty"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); } export function BuilderSourceReviewDialog({ @@ -72,6 +217,7 @@ export function BuilderSourceReviewDialog({ source, canEdit, pending, + batchResult = null, checkedAt, onClose, onValidate, @@ -81,44 +227,179 @@ export function BuilderSourceReviewDialog({ source: ContentDatabaseSource | null; canEdit: boolean; pending: boolean; + // Optional so the inline-database caller (DatabaseView) that doesn't surface + // batch results can still mount the dialog. + batchResult?: ExecuteBuilderSourceBatchResponse | null; checkedAt: string | null; onClose: () => void; - onValidate: () => void; + onValidate: (transitions: BuilderReviewPublicationTransitions) => void; }) { const t = useT(); const checked = !!checkedAt; + const safeModel = + source?.sourceType === "builder-cms" && + source.sourceTable === BUILDER_CMS_SAFE_WRITE_MODEL; + const writeMode = source?.metadata.writeMode; + const allowPublicationTransitionControls = + safeModel && + writeMode === "publish_updates" && + source?.metadata.allowPublicationTransitions === true; + const reviewRows = useMemo(() => review?.rows ?? [], [review]); + const reviewRowIds = useMemo( + () => reviewRows.map((row) => row.changeSetId), + [reviewRows], + ); + 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 = builderReviewIntentSummary( + reviewRows, + transitionSelections, + ); + const destinationLine = builderReviewDestinationLine({ + rows: reviewRows, + selections: transitionSelections, + liveWritesEnabled: review?.liveWritesEnabled === true, + }); + const hasUnconfirmedUnpublish = Object.values(transitionSelections).some( + (selection) => + selection.publicationTransition === "unpublish" && + selection.confirmUnpublish !== true, + ); + 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; - const footerText = pending + review.rows.length === 0 || + unsafeLiveTarget || + hasUnconfirmedUnpublish; + const rowTitleById = new Map( + reviewRows.map((row) => [row.changeSetId, row.title]), + ); + const batchIssueResults = + batchResult?.results.filter((result) => result.status !== "succeeded") ?? + []; + const resultStatus = builderReviewResultStatus( + batchResult + ? batchHasIssues + ? "partial" + : "succeeded" + : review?.result.status, + ); + const footerHint = pending ? review?.liveWritesEnabled - ? t("database.builderPreparingGate") - : t("database.builderCheckingGate") - : checked - ? review?.result.status === "succeeded" - ? t("database.builderPushedReconciled") - : review?.liveWritesEnabled - ? (review?.result.message ?? t("database.builderPushFinished")) - : t("database.builderCheckedNothingSent") - : review?.liveWritesEnabled - ? t("database.builderPushWillSend") - : t("database.builderWritesDisabledCheckOnly"); + ? "Sending to Builder…" + : "Checking…" + : hasUnconfirmedUnpublish + ? "Confirm unpublish on the selected rows first." + : unsafeLiveTarget + ? `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 - ? t("database.pushing") - : t("database.checking") - : checked && review?.result.status === "succeeded" - ? t("database.pushed") - : checked && !retryable - ? t("database.checked") - : t("database.push"); + ? "Working…" + : "Checking…" + : checked && batchResult + ? batchHasIssues + ? "Retry" + : "Pushed" + : checked && review?.result.status === "succeeded" + ? "Pushed" + : checked && !retryable + ? "Checked" + : pushVerb; + + 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 ( - {review?.summary ?? t("database.noPendingBuilderChanges")} + {review?.summary ?? "No pending Builder changes."}
+ + {transitionSelections[row.changeSetId] + ?.publicationTransition === "unpublish" ? ( + + ) : null}
-
-
- {t("database.fromValue", { - value: sourceValueText( - field.currentValue, - t("database.empty"), - ), - })} + ) : null} + {row.fieldChanges.map((field) => ( +
+
+ {field.propertyName ?? field.sourceFieldKey}
-
- {t("database.toValue", { - value: sourceValueText( - field.proposedValue, - t("database.empty"), - ), - })} +
+
+ From: {sourceValueText(field.currentValue)} +
+
+ To: {sourceValueText(field.proposedValue)} +
-
- ))} - {row.bodyChange ? ( -
-
- {row.bodyChange.summary} + ))} + {row.bodyChange ? ( +
+
+ {row.bodyChange.summary} +
+
+ {t("database.builderBodyEditsNeedSaferPath")} +
-
- {t("database.builderBodyEditsNeedSaferPath")} + ) : null} + {showConflict ? ( +
+ + + {t("database.changedInBuilderSinceSync")} +
-
- ) : null} - {row.execution?.lastError ? ( -
- {row.execution.lastError} -
- ) : null} + ) : null} + {effect === "unpublish" ? ( +
+ + + {t("database.thisUnpublishesTheLiveEntry")} + +
+ ) : null} + {row.execution?.lastError ? ( +
+ {row.execution.lastError} +
+ ) : null} +
-
- ))} + ); + })}
-
-
- {t("database.whereItWillGo")} -
-
- - - - - -
-
+
+ + {destinationLine} +
-
-
- {t("database.riskCheck")} -
-
- - {t("database.riskLabel", { level: review.riskLevel })} - - {(review.riskReasons.length - ? review.riskReasons - : [t("database.singleFieldDiff")] - ).map((reason) => ( - - {reason} - - ))} - - {review.dryRunOnly - ? t("database.checksOnly") - : t("database.canSendToBuilder")} - -
-
- -
-
-
-
- {t("database.result")} -
-
- {review.result.message} -
+ {batchResult && batchIssueResults.length > 0 ? ( +
+
+ {t("database.needsAttentionBeforeFinish")}
- - {review.result.status.replace(/_/g, " ")} - -
-
+ {batchIssueResults.map((result) => ( +
+ + {rowTitleById.get(result.changeSetId) ?? + result.changeSetId} + + {" — "} + {result.message ?? "No details returned."} +
+ ))} + + ) : null}
) : (
- {t("database.noPendingLocalBuilderChangesYet")} + {t("database.noPendingLocalBuilderChanges")}
)}
-
- {footerText} +
+ {review ? ( +
+ {checked ? ( + resultStatus.tone === "ok" ? ( + + ) : ( + + ) + ) : null} + + {checked + ? `${t(`database.${resultStatus.labelKey}`)} · ${intentSummary}` + : intentSummary} + +
+ ) : null} + {footerHint ?
{footerHint}
: null}