From 23eb1ce8beb30aa3559a8dbd6d5e4ac1f2e6f992 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:03:18 -0400 Subject: [PATCH 01/20] Keep Content's Builder saves from snapping back A save that should leave the live page alone now picks its request from the publication state Builder reports right before the write: a draft entry gets a plain save that Builder's reads return, and a published entry gets an autosave. Builder keeps autosaves out of its reads, so Content records the live value each saved field replaced and keeps its saved value through refreshes and body reads while Builder still reports that value. The pre-write check is now field-level for saves: when Builder's lastUpdated moved, a save blocks only if a field it writes changed, or the body changed or cannot be compared. Publish and unpublish stay strict. --- .../actions/_builder-cms-read-client.test.ts | 2 + .../actions/_builder-cms-read-client.ts | 22 +- .../_builder-cms-source-adapter.test.ts | 26 ++ .../actions/_builder-cms-source-adapter.ts | 16 + .../_builder-cms-unpublished-changes.test.ts | 344 ++++++++++++++++ .../_builder-cms-unpublished-changes.ts | 254 ++++++++++++ .../_builder-cms-write-adapter.test.ts | 110 ++++++ .../actions/_builder-cms-write-adapter.ts | 149 ++++++- .../actions/_database-source-utils.test.ts | 31 ++ .../content/actions/_database-source-utils.ts | 41 +- .../execute-builder-source-execution.test.ts | 374 +++++++++++++++++- .../execute-builder-source-execution.ts | 220 +++++++++-- .../actions/prepare-builder-source-review.ts | 18 +- .../BuilderSourceReviewDialog.tsx | 9 +- ...y-from-content-no-longer-snaps-back-on-.md | 6 + .../capabilities/content.source.adapters.md | 32 +- templates/content/shared/api.ts | 26 +- 17 files changed, 1595 insertions(+), 85 deletions(-) create mode 100644 templates/content/actions/_builder-cms-unpublished-changes.test.ts create mode 100644 templates/content/actions/_builder-cms-unpublished-changes.ts create mode 100644 templates/content/changelog/2026-10-22-saving-a-builder-entry-from-content-no-longer-snaps-back-on-.md diff --git a/templates/content/actions/_builder-cms-read-client.test.ts b/templates/content/actions/_builder-cms-read-client.test.ts index 9d140c74e4d..53a056729b9 100644 --- a/templates/content/actions/_builder-cms-read-client.test.ts +++ b/templates/content/actions/_builder-cms-read-client.test.ts @@ -2598,6 +2598,8 @@ describe("Builder CMS read client", () => { lastUpdated: 1_786_000_000_000, blocksHash: baselineHash, id: "builder-entry-1", + values: expect.objectContaining({ "data.title": "Builder title" }), + hasAutosaves: null, }); expect(fetchImpl).toHaveBeenCalledTimes(2); for (const [input] of fetchImpl.mock.calls) { diff --git a/templates/content/actions/_builder-cms-read-client.ts b/templates/content/actions/_builder-cms-read-client.ts index 53eb4000912..63ea6c115da 100644 --- a/templates/content/actions/_builder-cms-read-client.ts +++ b/templates/content/actions/_builder-cms-read-client.ts @@ -11,6 +11,7 @@ import type { BuilderCmsModelFieldSummary, BuilderCmsModelSummary, BuilderCmsModelsResponse, + DocumentPropertyValue, } from "../shared/api.js"; import { builderBlocksHash, @@ -18,6 +19,7 @@ import { type BuilderContentEntry, } from "../shared/builder-mdx.js"; import { + builderEntryHasAutosaves, normalizeBuilderCmsApiEntry, type BuilderCmsSourceEntry, } from "./_builder-cms-source-adapter.js"; @@ -53,6 +55,9 @@ export interface BuilderCmsEntryLiveState { lastUpdated: number | string | null; blocksHash: string | null; id: string | null; + /** The entry's source values, normalized as a refresh stores them. */ + values?: Record | null; + hasAutosaves?: boolean | null; } export interface BuilderCmsEntryFidelitySummary { @@ -388,10 +393,13 @@ function stringOrNumberFromUnknown(value: unknown) { return stringFromUnknown(value); } -function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState { +function liveStateFromBuilderEntry( + value: unknown, + model: string, +): BuilderCmsEntryLiveState { if (Array.isArray(value)) { return value.length > 0 - ? liveStateFromBuilderEntry(value[0]) + ? liveStateFromBuilderEntry(value[0], model) : { exists: false, published: null, @@ -412,7 +420,7 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState { const record = value as Record; if (Array.isArray(record.results)) { - return liveStateFromBuilderEntry(record.results); + return liveStateFromBuilderEntry(record.results, model); } if (Object.keys(record).length === 0) { return { @@ -456,6 +464,8 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState { stringOrNumberFromUnknown(data.updatedAt), blocksHash: blocks.length > 0 ? builderBlocksHash(blocks) : null, id, + values: normalizeBuilderCmsApiEntry(record, model)?.sourceValues ?? null, + hasAutosaves: builderEntryHasAutosaves(record), }; } @@ -1793,7 +1803,7 @@ export async function readBuilderCmsEntryLiveState(args: { } const json = (await response.json()) as unknown; const { results } = parseBuilderGeneralQueryDataResponse(json); - if (results.length === 0) return liveStateFromBuilderEntry([]); + if (results.length === 0) return liveStateFromBuilderEntry([], args.model); const entry = normalizeBuilderCmsApiEntry(results[0], args.model); if (!entry || entry.id !== args.entryId) { fail( @@ -1801,7 +1811,7 @@ export async function readBuilderCmsEntryLiveState(args: { { errorCode: "builder_response_invalid", statusCode: 424 }, ); } - return liveStateFromBuilderEntry(entry.rawEntry); + return liveStateFromBuilderEntry(entry.rawEntry, args.model); } const publicKey = await resolveBuilderCredential("BUILDER_PUBLIC_KEY"); if (!publicKey) { @@ -1839,7 +1849,7 @@ export async function readBuilderCmsEntryLiveState(args: { } const json = (await response.json()) as unknown; - return liveStateFromBuilderEntry(json); + return liveStateFromBuilderEntry(json, args.model); } export async function readBuilderCmsContentEntry(args: { diff --git a/templates/content/actions/_builder-cms-source-adapter.test.ts b/templates/content/actions/_builder-cms-source-adapter.test.ts index 73153dec01c..af89dbece77 100644 --- a/templates/content/actions/_builder-cms-source-adapter.test.ts +++ b/templates/content/actions/_builder-cms-source-adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { ContentDatabaseItem } from "../shared/api"; import { + BUILDER_CMS_HAS_AUTOSAVES_KEY, buildBuilderCmsFixtureEntry, builderCmsQualifiedId, builderCmsSourceFieldKey, @@ -222,6 +223,31 @@ describe("Builder CMS source adapter", () => { }); }); + it("records whether Builder reports unpublished changes on an entry", () => { + const entry = (meta: Record) => + normalizeBuilderCmsApiEntry( + { + id: "entry-1", + published: "published", + lastUpdated: 1782328870774, + meta, + data: { title: "Builder API title" }, + }, + "blog_article", + )?.sourceValues; + + expect(entry({ hasAutosaves: true })).toMatchObject({ + [BUILDER_CMS_HAS_AUTOSAVES_KEY]: true, + }); + expect(entry({ hasAutosaves: false })).toMatchObject({ + [BUILDER_CMS_HAS_AUTOSAVES_KEY]: false, + }); + expect(entry({})).not.toHaveProperty(BUILDER_CMS_HAS_AUTOSAVES_KEY); + expect(entry({ hasAutosaves: "yes" })).not.toHaveProperty( + BUILDER_CMS_HAS_AUTOSAVES_KEY, + ); + }); + it("uses numeric Builder lastUpdated as the row source baseline", () => { const lastUpdated = 1782328870774; const entry = normalizeBuilderCmsApiEntry( diff --git a/templates/content/actions/_builder-cms-source-adapter.ts b/templates/content/actions/_builder-cms-source-adapter.ts index 44a531edc5e..14a7f9f04aa 100644 --- a/templates/content/actions/_builder-cms-source-adapter.ts +++ b/templates/content/actions/_builder-cms-source-adapter.ts @@ -19,6 +19,8 @@ export const BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY = export const BUILDER_CMS_BODY_READABLE_MAP_KEY = "__builder.body.readableMapJson"; export const BUILDER_CMS_BODY_BLOCK_COUNT_KEY = "__builder.body.blockCount"; +/** Builder's `meta.hasAutosaves`: a published entry has unpublished changes. */ +export const BUILDER_CMS_HAS_AUTOSAVES_KEY = "__builder.meta.hasAutosaves"; export interface BuilderCmsSourceEntry { id: string; @@ -297,6 +299,16 @@ function sourceValueFromUnknown(value: unknown): DocumentPropertyValue { return null; } +/** Null when Builder's read did not say. */ +export function builderEntryHasAutosaves( + record: Record, +): boolean | null { + const meta = record.meta; + if (!meta || typeof meta !== "object" || Array.isArray(meta)) return null; + const value = (meta as Record).hasAutosaves; + return typeof value === "boolean" ? value : null; +} + function builderSourceValuesFromRecord(args: { record: Record; data: Record; @@ -319,6 +331,10 @@ function builderSourceValuesFromRecord(args: { values[key] = sourceValueFromUnknown(args.record[key]); } } + const hasAutosaves = builderEntryHasAutosaves(args.record); + if (hasAutosaves !== null) { + values[BUILDER_CMS_HAS_AUTOSAVES_KEY] = hasAutosaves; + } const blocks = builderEntryBlocks(args.rawEntry); if (blocks.length > 0) { values[BUILDER_CMS_BODY_BLOCKS_HASH_KEY] = builderBlocksHash(blocks); diff --git a/templates/content/actions/_builder-cms-unpublished-changes.test.ts b/templates/content/actions/_builder-cms-unpublished-changes.test.ts new file mode 100644 index 00000000000..109257ff42a --- /dev/null +++ b/templates/content/actions/_builder-cms-unpublished-changes.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + BUILDER_CMS_BODY_CONTENT_KEY, + BUILDER_CMS_HAS_AUTOSAVES_KEY, +} from "./_builder-cms-source-adapter"; +import { + BUILDER_CMS_UNPUBLISHED_CHANGES_KEY, + builderCmsEntryStatus, + builderCmsUnpublishedChangesAfterWrite, + builderSourceValuesKeepingUnpublishedChanges, + readBuilderCmsUnpublishedChanges, + withBuilderCmsUnpublishedChanges, +} from "./_builder-cms-unpublished-changes"; + +function saved( + values: Record, + changes: { + liveValues: Record; + liveBlocksHash?: string; + }, +) { + return withBuilderCmsUnpublishedChanges(values, changes); +} + +describe("Builder unpublished changes", () => { + it("keeps a saved value while Builder still reports the value it was saved over", () => { + const existing = saved( + { published: "published", "data.title": "Saved title" }, + { liveValues: { "data.title": "Live title" } }, + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { published: "published", "data.title": "Live title" }, + existing, + }); + + expect(next["data.title"]).toBe("Saved title"); + expect(readBuilderCmsUnpublishedChanges(next)).toEqual({ + state: "pending", + changes: { liveValues: { "data.title": "Live title" } }, + }); + }); + + it("lets Builder's value win once that field changes in Builder", () => { + const existing = saved( + { + published: "published", + "data.title": "Saved title", + "data.blurb": "Saved blurb", + }, + { + liveValues: { "data.title": "Live title", "data.blurb": "Live blurb" }, + }, + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + "data.title": "Published in Builder", + "data.blurb": "Live blurb", + }, + existing, + }); + + expect(next["data.title"]).toBe("Published in Builder"); + expect(next["data.blurb"]).toBe("Saved blurb"); + expect(readBuilderCmsUnpublishedChanges(next)).toEqual({ + state: "pending", + changes: { liveValues: { "data.blurb": "Live blurb" } }, + }); + }); + + it("keeps a saved value Builder did not return", () => { + const existing = saved( + { published: "published", "data.title": "Saved title" }, + { liveValues: { "data.title": "Live title" } }, + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { published: "published" }, + existing, + }); + + expect(next["data.title"]).toBe("Saved title"); + }); + + it("keeps saved fields through a body read that fills missing fields from the row", () => { + const existing = saved( + { published: "published", "data.blurb": "Saved blurb" }, + { liveValues: { "data.blurb": null } }, + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { ...existing, published: "published" }, + existing, + keepFields: true, + }); + + expect(next["data.blurb"]).toBe("Saved blurb"); + expect(readBuilderCmsUnpublishedChanges(next)).toEqual({ + state: "pending", + changes: { liveValues: { "data.blurb": null } }, + }); + }); + + it("keeps the reference id that goes with a kept reference value", () => { + const existing = saved( + { + published: "published", + "data.author": "Saved author", + "__agent_native_builder_reference_id:data.author": "author-2", + }, + { liveValues: { "data.author": "Live author" } }, + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + "data.author": "Live author", + "__agent_native_builder_reference_id:data.author": "author-1", + }, + existing, + }); + + expect(next["data.author"]).toBe("Saved author"); + expect(next["__agent_native_builder_reference_id:data.author"]).toBe( + "author-2", + ); + }); + + it("drops the record when the entry is no longer published", () => { + const existing = saved( + { published: "published", "data.title": "Saved title" }, + { liveValues: { "data.title": "Live title" } }, + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { published: "draft", "data.title": "Live title" }, + existing, + }); + + expect(next["data.title"]).toBe("Live title"); + expect(next).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + + it("drops the record when Builder says the entry has no unpublished changes", () => { + const existing = saved( + { published: "published", "data.title": "Saved title" }, + { liveValues: { "data.title": "Live title" } }, + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + "data.title": "Live title", + [BUILDER_CMS_HAS_AUTOSAVES_KEY]: false, + }, + existing, + }); + + expect(next["data.title"]).toBe("Live title"); + expect(next).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + + it("keeps the record when Builder does not report whether changes are pending", () => { + const existing = saved( + { published: "published", "data.title": "Saved title" }, + { liveValues: { "data.title": "Live title" } }, + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + "data.title": "Live title", + [BUILDER_CMS_HAS_AUTOSAVES_KEY]: true, + }, + existing, + }); + + expect(next["data.title"]).toBe("Saved title"); + }); + + it("warns and lets Builder win when the record cannot be read", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { published: "published", "data.title": "Live title" }, + existing: { + published: "published", + "data.title": "Saved title", + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: "{not json", + }, + }); + + expect(next["data.title"]).toBe("Live title"); + expect(next).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + + it("tells an unreadable record apart from no record", () => { + expect(readBuilderCmsUnpublishedChanges({})).toEqual({ state: "none" }); + expect( + readBuilderCmsUnpublishedChanges({ + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: JSON.stringify({ + liveValues: [], + }), + }), + ).toEqual({ state: "invalid" }); + }); + + it("keeps a saved body while Builder still reports the body it was saved over", () => { + const existing = saved( + { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-hash", + }, + { liveValues: {}, liveBlocksHash: "live-hash" }, + ); + + const kept = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Live body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "live-hash", + }, + existing, + }); + expect(kept[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Saved body"); + expect(kept[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]).toBe("saved-hash"); + + const replaced = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Published body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "published-hash", + }, + existing, + }); + expect(replaced[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Published body"); + expect(replaced).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + + it("records the live value from before Content's first save over a field", () => { + const first = builderCmsUnpublishedChangesAfterWrite({ + existing: { published: "published" }, + effect: "autosave", + writtenFieldKeys: ["data.title", "data.blurb"], + wroteBody: true, + live: { + values: { "data.title": "Live title" }, + blocksHash: "live-hash", + }, + }); + expect(first).toEqual({ + liveValues: { "data.title": "Live title", "data.blurb": null }, + liveBlocksHash: "live-hash", + }); + + const second = builderCmsUnpublishedChangesAfterWrite({ + existing: withBuilderCmsUnpublishedChanges( + { published: "published" }, + first, + ), + effect: "autosave", + writtenFieldKeys: ["data.title"], + wroteBody: true, + live: { + values: { "data.title": "Live title" }, + blocksHash: "live-hash", + }, + }); + expect(second).toEqual(first); + }); + + it("refuses to record an autosave without the live values", () => { + expect(() => + builderCmsUnpublishedChangesAfterWrite({ + existing: { published: "published" }, + effect: "autosave", + writtenFieldKeys: ["data.title"], + wroteBody: false, + live: { values: null, blocksHash: null }, + }), + ).toThrow("live values were not recorded"); + }); + + it("removes fields a live write put live, and clears the record on publication changes", () => { + const existing = saved( + { published: "published" }, + { + liveValues: { "data.title": "Live title", "data.blurb": "Live blurb" }, + liveBlocksHash: "live-hash", + }, + ); + + expect( + builderCmsUnpublishedChangesAfterWrite({ + existing, + effect: "update_in_place", + writtenFieldKeys: ["data.title"], + wroteBody: true, + live: { values: {}, blocksHash: null }, + }), + ).toEqual({ liveValues: { "data.blurb": "Live blurb" } }); + for (const effect of ["publish", "unpublish"] as const) { + expect( + builderCmsUnpublishedChangesAfterWrite({ + existing, + effect, + writtenFieldKeys: [], + wroteBody: false, + live: { values: {}, blocksHash: null }, + }), + ).toBeNull(); + } + }); + + it("reports Builder's status words for an entry", () => { + expect(builderCmsEntryStatus({ published: "draft" })).toBe("draft"); + expect(builderCmsEntryStatus({ published: "published" })).toBe("published"); + expect( + builderCmsEntryStatus({ + published: "published", + [BUILDER_CMS_HAS_AUTOSAVES_KEY]: true, + }), + ).toBe("unpublished_changes"); + expect( + builderCmsEntryStatus( + saved( + { published: "published" }, + { liveValues: { "data.title": "Live title" } }, + ), + ), + ).toBe("unpublished_changes"); + expect(builderCmsEntryStatus({ published: "archived" })).toBeNull(); + expect(builderCmsEntryStatus(undefined)).toBeNull(); + }); +}); diff --git a/templates/content/actions/_builder-cms-unpublished-changes.ts b/templates/content/actions/_builder-cms-unpublished-changes.ts new file mode 100644 index 00000000000..41fff1423b3 --- /dev/null +++ b/templates/content/actions/_builder-cms-unpublished-changes.ts @@ -0,0 +1,254 @@ +import type { + BuilderCmsWriteEffect, + DocumentPropertyValue, +} from "../shared/api.js"; +import { + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + BUILDER_CMS_BODY_CONTENT_KEY, + BUILDER_CMS_BODY_LAST_UPDATED_KEY, + BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY, + BUILDER_CMS_BODY_READABLE_MAP_KEY, + BUILDER_CMS_BODY_SIDECARS_KEY, + BUILDER_CMS_HAS_AUTOSAVES_KEY, +} from "./_builder-cms-source-adapter.js"; + +/** + * Builder keeps the unpublished changes on a published entry out of its read + * API, so every refresh reads the live values back. Content records the live + * value each field had when it saved unpublished changes over it; while + * Builder still reports that live value, the saved value stays the row's + * baseline instead of snapping back. + */ +export const BUILDER_CMS_UNPUBLISHED_CHANGES_KEY = + "__agent_native_builder_unpublished_changes"; + +export interface BuilderCmsUnpublishedChanges { + /** The live value of each source field when Content saved over it. */ + liveValues: Record; + /** The live body's blocks hash when Content saved a body over it. */ + liveBlocksHash?: string; +} + +export type BuilderCmsUnpublishedChangesRead = + | { state: "none" } + | { state: "pending"; changes: BuilderCmsUnpublishedChanges } + | { state: "invalid" }; + +export type BuilderCmsEntryStatus = + | "published" + | "draft" + | "unpublished_changes"; + +const REFERENCE_ID_KEY_PREFIX = "__agent_native_builder_reference_id:"; + +const BODY_BASELINE_KEYS = [ + BUILDER_CMS_BODY_CONTENT_KEY, + BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY, + BUILDER_CMS_BODY_READABLE_MAP_KEY, + BUILDER_CMS_BODY_SIDECARS_KEY, + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + BUILDER_CMS_BODY_LAST_UPDATED_KEY, +]; + +export function builderCmsStableValue(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (Array.isArray(value)) { + return `[${value.map(builderCmsStableValue).join(",")}]`; + } + if (typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map( + (key) => `${JSON.stringify(key)}:${builderCmsStableValue(record[key])}`, + ) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function readBuilderCmsUnpublishedChanges( + sourceValues: Record | null | undefined, +): BuilderCmsUnpublishedChangesRead { + const raw = sourceValues?.[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]; + if (raw === undefined || raw === null) return { state: "none" }; + if (typeof raw !== "string") return { state: "invalid" }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { state: "invalid" }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { state: "invalid" }; + } + const record = parsed as Record; + const liveValues = record.liveValues; + if ( + !liveValues || + typeof liveValues !== "object" || + Array.isArray(liveValues) + ) { + return { state: "invalid" }; + } + const liveBlocksHash = record.liveBlocksHash; + if (liveBlocksHash !== undefined && typeof liveBlocksHash !== "string") { + return { state: "invalid" }; + } + return { + state: "pending", + changes: { + liveValues: liveValues as Record, + ...(liveBlocksHash ? { liveBlocksHash } : {}), + }, + }; +} + +export function withBuilderCmsUnpublishedChanges( + sourceValues: Record, + changes: BuilderCmsUnpublishedChanges | null, +): Record { + const next = { ...sourceValues }; + if ( + changes && + (Object.keys(changes.liveValues).length > 0 || changes.liveBlocksHash) + ) { + next[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY] = JSON.stringify(changes); + } else { + delete next[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]; + } + return next; +} + +/** + * Builder's own status words for an entry, as its content list shows them. + * Unpublished changes are Content's saves or Builder's own autosaves. + */ +export function builderCmsEntryStatus( + sourceValues: Record | null | undefined, +): BuilderCmsEntryStatus | null { + const published = sourceValues?.published; + if (published === "draft") return "draft"; + if (published !== "published") return null; + return readBuilderCmsUnpublishedChanges(sourceValues).state === "pending" || + sourceValues?.[BUILDER_CMS_HAS_AUTOSAVES_KEY] === true + ? "unpublished_changes" + : "published"; +} + +/** + * The baseline a refresh or body read should keep. A field keeps Content's + * saved value while Builder still reports the live value it was saved over; + * once Builder publishes, discards, or edits that field, Builder's value wins. + * A field Builder did not return says nothing about a change, so it keeps + * Content's value too. + */ +export function builderSourceValuesKeepingUnpublishedChanges(args: { + incoming: Record; + existing: Record | null | undefined; + /** + * A body read fills fields Builder did not return with the row's stored + * values, so those fields would look changed. The refresh that queued it + * already applied the field rule to the same Builder read. + */ + keepFields?: boolean; +}): Record { + const read = readBuilderCmsUnpublishedChanges(args.existing); + if (read.state === "invalid") { + console.warn( + "Dropping an unreadable Builder unpublished-changes record; Builder's values win.", + ); + } + if (read.state !== "pending" || !args.existing) { + return withBuilderCmsUnpublishedChanges(args.incoming, null); + } + if ( + args.incoming.published !== "published" || + args.incoming[BUILDER_CMS_HAS_AUTOSAVES_KEY] === false + ) { + return withBuilderCmsUnpublishedChanges(args.incoming, null); + } + const existing = args.existing; + const next = { ...args.incoming }; + const liveValues: Record = {}; + for (const [key, liveValue] of Object.entries(read.changes.liveValues)) { + const builderStillLive = + args.keepFields || + !Object.prototype.hasOwnProperty.call(args.incoming, key) || + builderCmsStableValue(args.incoming[key]) === + builderCmsStableValue(liveValue); + if (!builderStillLive) continue; + for (const keptKey of [key, `${REFERENCE_ID_KEY_PREFIX}${key}`]) { + if (Object.prototype.hasOwnProperty.call(existing, keptKey)) { + next[keptKey] = existing[keptKey]; + } + } + liveValues[key] = liveValue; + } + let liveBlocksHash: string | undefined; + if (read.changes.liveBlocksHash) { + const incomingHash = args.incoming[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]; + if ( + incomingHash === undefined || + incomingHash === read.changes.liveBlocksHash + ) { + for (const key of BODY_BASELINE_KEYS) { + if (existing[key] !== undefined) next[key] = existing[key]; + } + liveBlocksHash = read.changes.liveBlocksHash; + } + } + return withBuilderCmsUnpublishedChanges(next, { + liveValues, + ...(liveBlocksHash ? { liveBlocksHash } : {}), + }); +} + +/** + * What stays unpublished after a write. An autosave adds the fields it wrote, + * keeping the live value each one had before Content's first save over it; + * any other write puts its fields live, so they leave the record. + */ +export function builderCmsUnpublishedChangesAfterWrite(args: { + existing: Record | null | undefined; + effect: BuilderCmsWriteEffect; + writtenFieldKeys: string[]; + wroteBody: boolean; + live: { + values: Record | null; + blocksHash: string | null; + }; +}): BuilderCmsUnpublishedChanges | null { + if (args.effect === "publish" || args.effect === "unpublish") return null; + const read = readBuilderCmsUnpublishedChanges(args.existing); + const current: BuilderCmsUnpublishedChanges = + read.state === "pending" ? read.changes : { liveValues: {} }; + const liveValues = { ...current.liveValues }; + let liveBlocksHash = current.liveBlocksHash; + if (args.effect === "autosave") { + const live = args.live.values; + if (!live) { + throw new Error( + "Builder's live values were not recorded before this autosave, so Content cannot keep the saved changes.", + ); + } + for (const key of args.writtenFieldKeys) { + if (Object.prototype.hasOwnProperty.call(liveValues, key)) continue; + // A field the live entry lacks is recorded as null, which a refresh + // that also lacks it reads as unchanged. + liveValues[key] = Object.prototype.hasOwnProperty.call(live, key) + ? live[key] + : null; + } + if (args.wroteBody && !liveBlocksHash && args.live.blocksHash) { + liveBlocksHash = args.live.blocksHash; + } + } else { + for (const key of args.writtenFieldKeys) delete liveValues[key]; + if (args.wroteBody) liveBlocksHash = undefined; + } + return { + liveValues, + ...(liveBlocksHash ? { liveBlocksHash } : {}), + }; +} diff --git a/templates/content/actions/_builder-cms-write-adapter.test.ts b/templates/content/actions/_builder-cms-write-adapter.test.ts index 12b638871b8..06692e3bc43 100644 --- a/templates/content/actions/_builder-cms-write-adapter.test.ts +++ b/templates/content/actions/_builder-cms-write-adapter.test.ts @@ -10,6 +10,7 @@ import { buildBuilderCmsExecutionPlan, builderCmsExecutionIntentMarker, builderCmsExecutionIdempotencyKey, + builderCmsPayloadForLivePublication, resolveBuilderCmsExecutionPushMode, validateBuilderCmsExecutionDryRun, } from "./_builder-cms-write-adapter"; @@ -249,6 +250,85 @@ describe("Builder CMS write adapter plan", () => { }); }); + it("saves a draft entry without autosave so Builder's reads show the edit", () => { + const stageOnly = source(true, BUILDER_CMS_SAFE_WRITE_MODEL, { + writeMode: "stage_only", + pushMode: "autosave", + allowedWriteModes: ["autosave"], + }); + const plan = buildBuilderCmsExecutionPlan({ + source: { + ...stageOnly, + rows: stageOnly.rows.map((row) => ({ + ...row, + sourceValues: { published: "draft", "data.title": "Old title" }, + })), + }, + changeSet: approvedChangeSet(), + pushModeConfirmation: "autosave", + }); + + expect(plan).toMatchObject({ + state: "ready", + payload: { + effect: "save_draft", + request: { + method: "PATCH", + query: { triggerWebhooks: "false" }, + }, + }, + }); + expect(plan.payload.request.query).not.toHaveProperty("autoSaveOnly"); + expect(plan.payload.request.body).not.toHaveProperty("published"); + }); + + it("re-picks the unpublished save from Builder's live publication state", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: source(true, BUILDER_CMS_SAFE_WRITE_MODEL, { + writeMode: "stage_only", + pushMode: "autosave", + allowedWriteModes: ["autosave"], + }), + changeSet: approvedChangeSet(), + pushModeConfirmation: "autosave", + }); + expect(plan.payload.effect).toBe("autosave"); + + const asDraft = builderCmsPayloadForLivePublication(plan.payload, "draft"); + expect(asDraft).toMatchObject({ + effect: "save_draft", + request: { + method: plan.payload.request.method, + path: plan.payload.request.path, + body: plan.payload.request.body, + query: { triggerWebhooks: "false" }, + }, + }); + expect(asDraft.request.query).not.toHaveProperty("autoSaveOnly"); + expect(builderCmsPayloadForLivePublication(asDraft, "published")).toEqual( + plan.payload, + ); + expect(builderCmsPayloadForLivePublication(plan.payload, null)).toBe( + plan.payload, + ); + }); + + it("leaves non-unpublished saves alone when re-picking from live state", () => { + 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.payload.effect).toBe("update_in_place"); + expect(builderCmsPayloadForLivePublication(plan.payload, "draft")).toBe( + plan.payload, + ); + }); + it("keeps production-model title updates from changing Builder root names", () => { const plan = buildBuilderCmsExecutionPlan({ source: source(true, "blog_article", { @@ -1372,6 +1452,36 @@ describe("Builder CMS write adapter plan", () => { }); }); + it("validates a stored autosave gate against a rebuilt draft save", () => { + const stored = buildBuilderCmsExecutionPlan({ + source: source(false, BUILDER_CMS_SAFE_WRITE_MODEL), + changeSet: approvedChangeSet(), + pushModeConfirmation: "autosave", + }); + const draftSource = source(false, BUILDER_CMS_SAFE_WRITE_MODEL); + const rebuilt = buildBuilderCmsExecutionPlan({ + source: { + ...draftSource, + rows: draftSource.rows.map((row) => ({ + ...row, + sourceValues: { published: "draft" }, + })), + }, + changeSet: approvedChangeSet(), + pushModeConfirmation: "autosave", + }); + expect(stored.payload.effect).toBe("autosave"); + expect(rebuilt.payload.effect).toBe("save_draft"); + + expect( + validateBuilderCmsExecutionDryRun({ + storedPayload: stored.payload, + plan: rebuilt, + now: "2026-06-08T01:00:00.000Z", + }), + ).toMatchObject({ dryRun: { status: "validated", mismatches: [] } }); + }); + it("marks a stored dry-run payload stale when the request no longer matches", () => { const plan = buildBuilderCmsExecutionPlan({ source: source(false), diff --git a/templates/content/actions/_builder-cms-write-adapter.ts b/templates/content/actions/_builder-cms-write-adapter.ts index 3795f0dc8b2..09ec43fd7ad 100644 --- a/templates/content/actions/_builder-cms-write-adapter.ts +++ b/templates/content/actions/_builder-cms-write-adapter.ts @@ -7,6 +7,7 @@ import type { ContentDatabaseSourceExecutionState, ContentDatabaseSourcePushMode, ContentDatabaseSourceWriteMode, + DocumentPropertyValue, } from "../shared/api.js"; import { BUILDER_CMS_SAFE_WRITE_MODEL as SAFE_WRITE_MODEL } from "../shared/api.js"; import { @@ -58,6 +59,37 @@ export interface BuilderCmsExecutionPayload { checks: string[]; mismatches: string[]; }; + /** Builder's entry as read right before the write. */ + livePreflight?: BuilderCmsLivePreflight; +} + +export interface BuilderCmsLivePreflight { + checkedAt: string; + exists: boolean; + id: string | null; + published: string | null; + lastUpdated: number | string | null; + blocksHash: string | null; + hasAutosaves: boolean | null; + /** Live values of the fields this write sends; null when Builder did not return them. */ + values: Record | null; +} + +/** The source fields and body a Builder write request sends. */ +export function builderCmsWrittenSourceFields( + request: BuilderCmsExecutionPayload["request"], +) { + const data = request.body.data; + const record = + data && typeof data === "object" && !Array.isArray(data) + ? (data as Record) + : {}; + return { + fieldKeys: Object.keys(record) + .filter((name) => name !== "blocks") + .map((name) => `data.${name}`), + wroteBody: record.blocks !== undefined, + }; } export interface BuilderCmsExecutionPlan { @@ -310,18 +342,41 @@ export function builderCmsExecutionIntentMarker(idempotencyKey: string) { return `agent-native-execution:${portableIntentHash(idempotencyKey)}`; } +export type BuilderCmsUnpublishedSaveEffect = "autosave" | "save_draft"; + +export function isBuilderCmsUnpublishedSaveEffect( + effect: unknown, +): effect is BuilderCmsUnpublishedSaveEffect { + return effect === "autosave" || effect === "save_draft"; +} + +/** + * A save that must not change what is live. Builder keeps an autosave out of + * its read API, so a draft entry saved that way snaps back on the next + * refresh; a draft takes a plain save instead. Only "draft" picks that save: + * a stale or unknown state falls back to autosave, which never touches the + * live page, and the live check before the write settles the real choice. + */ +export function builderCmsUnpublishedSaveEffect( + published: unknown, +): BuilderCmsUnpublishedSaveEffect { + return published === "draft" ? "save_draft" : "autosave"; +} + function builderEffectForWrite(args: { pushMode: ContentDatabaseSourcePushMode; writeMode?: ContentDatabaseSourceWriteMode | null; entryId: string | null; + entryPublished?: unknown; 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"; + if (args.writeMode === "stage_only" || args.pushMode === "autosave") { + return builderCmsUnpublishedSaveEffect(args.entryPublished); + } return "update_in_place"; } @@ -383,7 +438,7 @@ export function resolveBuilderCmsWriteEffect(args: { changeSet: args.changeSet, }); const effectivePushMode = pushMode === "none" ? "autosave" : pushMode; - const { entryId } = resolveBuilderCmsWriteTarget({ + const { entryId, targetRow } = resolveBuilderCmsWriteTarget({ source: args.source, changeSet: args.changeSet, }); @@ -391,6 +446,7 @@ export function resolveBuilderCmsWriteEffect(args: { pushMode: effectivePushMode, writeMode: sourceWriteMode, entryId, + entryPublished: targetRow?.sourceValues?.published, publicationTransition: args.publicationTransition, }); } @@ -514,6 +570,24 @@ function builderBodyPatch(changeSet: ContentDatabaseSourceChangeSet) { } } +// Neither request sends a published field: `published: "draft"` on an entry +// that went live after the check would unpublish it. +function builderUnpublishedSaveRequest(args: { + effect: BuilderCmsUnpublishedSaveEffect; + path: string; + body: Record; +}): BuilderCmsExecutionPayload["request"] { + return { + method: "PATCH", + path: args.path, + query: + args.effect === "autosave" + ? { autoSaveOnly: "true", triggerWebhooks: "false" } + : { triggerWebhooks: "false" }, + body: args.body, + }; +} + function builderRequestForEffect(args: { effect: BuilderCmsWriteEffect; model: string; @@ -535,19 +609,15 @@ function builderRequestForEffect(args: { args.model === SAFE_WRITE_MODEL ? patchedTitle || args.currentTitle?.trim() || null : null; - if (args.effect === "autosave") { - return { - method: "PATCH", + if (isBuilderCmsUnpublishedSaveEffect(args.effect)) { + return builderUnpublishedSaveRequest({ + effect: args.effect, path: basePath, - query: { - autoSaveOnly: "true", - triggerWebhooks: "false", - }, body: { ...args.bodyPatch, ...(safeEntryName ? { name: safeEntryName } : {}), }, - }; + }); } if (args.effect === "update_in_place") { return { @@ -690,8 +760,15 @@ function builderSafetyChecks(args: { if (args.operations.length === 0 && !args.hasBodyOperation) { blockers.push("No Builder operations are available for this change."); } - if (args.effect === "autosave" || args.effect === "update_in_place") { - const label = args.effect === "autosave" ? "Autosave" : "Update in place"; + if ( + isBuilderCmsUnpublishedSaveEffect(args.effect) || + args.effect === "update_in_place" + ) { + // The live check can swap autosave and draft save, so the gate's text + // names neither. + const label = isBuilderCmsUnpublishedSaveEffect(args.effect) + ? "Unpublished save" + : "Update in place"; checks.push( `${label} preserves publication state — no published field is sent.`, ); @@ -809,6 +886,7 @@ export function buildBuilderCmsExecutionPlan(args: { pushMode: effectivePushMode, writeMode: sourceWriteMode, entryId: targetEntryId, + entryPublished: targetRow?.sourceValues?.published, publicationTransition: args.publicationTransition, }); const operations: BuilderCmsExecutionOperation[] = []; @@ -976,11 +1054,22 @@ function stableJson(value: unknown): string { return JSON.stringify(value); } -function stripDryRun( +// The reviewed intent of an unpublished save is "don't change what is live"; +// which of its two requests runs follows Builder's live state at write time, +// so a retry after that choice still matches its gate. +function gateComparablePayload( payload: Partial, ): Partial { const { dryRun: _dryRun, ...rest } = payload; - return rest; + if (!isBuilderCmsUnpublishedSaveEffect(rest.effect) || !rest.request) { + return rest; + } + const { autoSaveOnly: _autoSaveOnly, ...query } = rest.request.query ?? {}; + return { + ...rest, + effect: "autosave", + request: { ...rest.request, query }, + }; } export function validateBuilderCmsExecutionDryRun(args: { @@ -990,8 +1079,8 @@ export function validateBuilderCmsExecutionDryRun(args: { }): BuilderCmsExecutionPayload { const storedPayload = args.storedPayload as Partial; - const storedComparable = stripDryRun(storedPayload); - const planComparable = stripDryRun(args.plan.payload); + const storedComparable = gateComparablePayload(storedPayload); + const planComparable = gateComparablePayload(args.plan.payload); const mismatches: string[] = []; if ( @@ -1061,3 +1150,29 @@ export function validateBuilderCmsExecutionDryRun(args: { }, } as BuilderCmsExecutionPayload; } + +/** + * Re-picks an unpublished save from Builder's publication state read right + * before the write, so an entry published or unpublished since review still + * gets the save that leaves the live page as it is. + */ +export function builderCmsPayloadForLivePublication( + payload: BuilderCmsExecutionPayload, + livePublished: string | null, +): BuilderCmsExecutionPayload { + if (!isBuilderCmsUnpublishedSaveEffect(payload.effect)) return payload; + if (livePublished !== "draft" && livePublished !== "published") { + return payload; + } + const effect = builderCmsUnpublishedSaveEffect(livePublished); + if (effect === payload.effect) return payload; + return { + ...payload, + effect, + request: builderUnpublishedSaveRequest({ + effect, + path: payload.request.path, + body: payload.request.body, + }), + }; +} diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index 48c27a61b1d..527d8c83500 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -16,6 +16,7 @@ import { BUILDER_CMS_BODY_SIDECARS_KEY, BUILDER_CMS_FIXTURE_ROW_PROVENANCE, } from "./_builder-cms-source-adapter"; +import { BUILDER_CMS_UNPUBLISHED_CHANGES_KEY } from "./_builder-cms-unpublished-changes"; import { resolveBuilderCmsWriteEffect } from "./_builder-cms-write-adapter"; import { assertBuilderCmsContinuationIdentity, @@ -2995,6 +2996,36 @@ describe("database source helpers", () => { }); }); + it("keeps a saved unpublished change through a refresh that still reads the live value", () => { + const refreshed = (title: string) => + sourceValuesForSeededSourceRow({ + sourceType: "builder-cms", + item: item("doc-1", "Saved title"), + sourceTable: "blog_article", + now: "2026-06-08T13:00:00.000Z", + builderEntry: { + id: "entry-1", + model: "blog_article", + title, + urlPath: "/blog/live-title", + updatedAt: "2026-06-08T13:00:00.000Z", + sourceValues: { published: "published", "data.title": title }, + }, + existingSourceValuesJson: JSON.stringify({ + published: "published", + "data.title": "Saved title", + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: JSON.stringify({ + liveValues: { "data.title": "Live title" }, + }), + }), + }); + + expect(refreshed("Live title")["data.title"]).toBe("Saved title"); + const published = refreshed("Published in Builder"); + expect(published["data.title"]).toBe("Published in Builder"); + expect(published).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + it("preserves a hydrated Builder body baseline across metadata-only refreshes with the same hash", () => { expect( sourceValuesForSeededSourceRow({ diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 938c30b2a72..70849738d0a 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -105,6 +105,10 @@ import { type BuilderCmsSourceEntry, type ExistingBuilderSourceRowIdentity, } from "./_builder-cms-source-adapter.js"; +import { + builderSourceValuesKeepingUnpublishedChanges, + readBuilderCmsUnpublishedChanges, +} from "./_builder-cms-unpublished-changes.js"; import { mergeBuilderCmsWriteSettingsIntoJson } from "./_builder-cms-write-settings.js"; import { assertNoActiveBuilderSourceExecutions } from "./_builder-source-execution-guard.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; @@ -2102,8 +2106,14 @@ async function processBuilderBodyHydrationJob( const incomingLastUpdated = stringSourceValue(entryWithBody.sourceValues, "lastUpdated") ?? entryWithBody.updatedAt; + const unpublishedChanges = readBuilderCmsUnpublishedChanges(sourceValues); + const incomingIsBodySavedOver = + unpublishedChanges.state === "pending" && + !!incomingBlocksHash && + unpublishedChanges.changes.liveBlocksHash === incomingBlocksHash; if ( !isCodecMigration && + !incomingIsBodySavedOver && builderBodyBaselineHasSameVersionConflict({ persistedBlocksHash: stringSourceValue( sourceValues, @@ -2125,10 +2135,14 @@ async function processBuilderBodyHydrationJob( "Builder returned inconsistent body variants for the same lastUpdated value; the stored baseline and local document were preserved for retry.", ); } - let nextValues = { - ...sourceValues, - ...entryWithBody.sourceValues, - }; + let nextValues = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + ...sourceValues, + ...entryWithBody.sourceValues, + }, + existing: sourceValues, + keepFields: true, + }); let nextContent = stringSourceValue(nextValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; let emptyBodyRead: Extract< @@ -2225,7 +2239,11 @@ async function processBuilderBodyHydrationJob( if (!upgraded) return; activeSourceEntryJson = liveSourceEntryJson; entryWithBody = liveEntry; - nextValues = liveValues; + nextValues = builderSourceValuesKeepingUnpublishedChanges({ + incoming: liveValues, + existing: sourceValues, + keepFields: true, + }); nextContent = liveContent; } } else { @@ -2547,7 +2565,13 @@ async function processBuilderBodyHydrationJob( bodyHydrationStatus: "hydrated", bodyHydrationAttemptedAt: now, bodyHydrationError: null, - bodyHydrationVersion: builderBodyHydrationVersion(entryWithBody), + // A kept body is the version the next refresh expects; Builder's + // would queue this read again on every refresh. + bodyHydrationVersion: builderBodyHydrationVersion( + incomingIsBodySavedOver + ? { ...entryWithBody, sourceValues: nextValues } + : entryWithBody, + ), bodyHydrationReason: null, bodyHydrationProviderStatus: "http_200", bodyHydrationAttemptCount: row.attempts, @@ -6015,7 +6039,10 @@ export function sourceValuesForSeededSourceRow(args: { reference.id.trim(); } } - return values; + return builderSourceValuesKeepingUnpublishedChanges({ + incoming: values, + existing: existingSourceValues, + }); } if (existingSourceValues) return existingSourceValues; if (args.sourceType !== "builder-cms") return {}; diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 3de8ca91edd..96e765b6231 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -8,6 +8,10 @@ import { } from "../shared/api"; import type { BuilderCmsEntryLiveState } from "./_builder-cms-read-client"; import { BUILDER_CMS_BODY_BLOCKS_HASH_KEY } from "./_builder-cms-source-adapter"; +import { + BUILDER_CMS_UNPUBLISHED_CHANGES_KEY, + withBuilderCmsUnpublishedChanges, +} from "./_builder-cms-unpublished-changes"; import { buildBuilderCmsExecutionPlan, builderCmsExecutionIdempotencyKey, @@ -269,6 +273,7 @@ function depsFor(args: { lastUpdated: BUILDER_LAST_UPDATED_MS, blocksHash: null, id: "builder-entry-1", + values: { "data.title": "Old title" }, }, ), reconcileWrite: vi.fn(async () => {}), @@ -470,7 +475,18 @@ describe("execute Builder source execution", () => { source: builderSource, changeSet: approvedChangeSet, }); - const deps = depsFor({ source: builderSource, execution }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + values: { "data.title": "Old title", "data.slug": "old-title" }, + }, + }); const result = await executeBuilderSourceExecutionWithDeps( { @@ -529,7 +545,23 @@ describe("execute Builder source execution", () => { database: DATABASE, source: builderSource, changeSet: approvedChangeSet, - plan, + plan: expect.objectContaining({ + idempotencyKey: plan.idempotencyKey, + payload: expect.objectContaining({ + effect: "autosave", + request: plan.payload.request, + livePreflight: { + checkedAt: NOW, + exists: true, + id: "builder-entry-1", + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + hasAutosaves: null, + values: { "data.title": "Old title" }, + }, + }), + }), now: NOW, }), ); @@ -1107,6 +1139,7 @@ describe("execute Builder source execution", () => { lastUpdated: BUILDER_LAST_UPDATED_MS, blocksHash: "new-live-builder-hash", id: "builder-entry-1", + values: { "data.title": "Old title" }, }, }); @@ -2051,3 +2084,340 @@ describe("execute Builder source execution", () => { ); }); }); + +describe("Builder unpublished saves", () => { + const publishedRow = ( + overrides: Partial = {}, + ) => + row({ + sourceValues: { published: "published", "data.title": "Old title" }, + ...overrides, + }); + + function unpublishedSave(args: { + rows?: ContentDatabaseSource["rows"]; + changeSet?: ContentDatabaseSourceChangeSet; + readLiveEntry: BuilderCmsEntryLiveState; + writeResult?: BuilderCmsWriteResult; + }) { + const approvedChangeSet = args.changeSet ?? changeSet(); + const builderSource = source({ + rows: args.rows ?? [publishedRow()], + changeSets: [approvedChangeSet], + metadata: { + builderModelFields: [ + { name: "title", label: "Title", type: "string", required: true }, + ], + }, + }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: args.readLiveEntry, + writeResult: args.writeResult, + }); + const run = () => + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "autosave", + }, + deps, + ); + return { deps, run, execution }; + } + + it("saves a draft when Builder reports the entry unpublished right before the write", async () => { + const { deps, run } = unpublishedSave({ + readLiveEntry: { + exists: true, + published: "draft", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + values: { "data.title": "Old title" }, + }, + }); + + await expect(run()).resolves.toMatchObject(RESPONSE); + + const request = vi.mocked(deps.executeWrite).mock.calls[0]?.[0].request; + expect(request).toMatchObject({ + method: "PATCH", + query: { triggerWebhooks: "false" }, + }); + expect(request?.query).not.toHaveProperty("autoSaveOnly"); + expect(request?.body).not.toHaveProperty("published"); + expect(deps.reconcileWrite).toHaveBeenCalledWith( + expect.objectContaining({ + plan: expect.objectContaining({ + payload: expect.objectContaining({ effect: "save_draft" }), + }), + }), + ); + }); + + it("saves when Builder changed only a field this change does not write", async () => { + const { deps, run } = unpublishedSave({ + rows: [ + publishedRow({ + lastSourceUpdatedAt: String(STALE_BUILDER_LAST_UPDATED_MS), + }), + ], + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + values: { + "data.title": "Old title", + "data.blurb": "Edited in Builder", + }, + }, + }); + + await expect(run()).resolves.toMatchObject(RESPONSE); + expect(deps.executeWrite).toHaveBeenCalledTimes(1); + }); + + it("names the field Builder changed since the edit and blocks the save", async () => { + const { deps, run } = unpublishedSave({ + rows: [ + publishedRow({ + lastSourceUpdatedAt: String(STALE_BUILDER_LAST_UPDATED_MS), + }), + ], + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + values: { "data.title": "Edited in Builder" }, + }, + }); + + await expect(run()).rejects.toMatchObject({ + statusCode: 409, + message: + "Title changed in Builder since you edited it; refresh and re-review.", + }); + expect(deps.executeWrite).not.toHaveBeenCalled(); + }); + + it("treats the live value Content saved over as the value this edit was made against", async () => { + const { deps, run } = unpublishedSave({ + rows: [ + publishedRow({ + lastSourceUpdatedAt: String(STALE_BUILDER_LAST_UPDATED_MS), + sourceValues: withBuilderCmsUnpublishedChanges( + { published: "published", "data.title": "Saved title" }, + { liveValues: { "data.title": "Live title" } }, + ), + }), + ], + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + values: { "data.title": "Live title" }, + }, + }); + + await expect(run()).resolves.toMatchObject(RESPONSE); + expect(deps.executeWrite).toHaveBeenCalledTimes(1); + }); + + it("blocks a body save Builder changed around when the live body cannot be compared", async () => { + const bodyChangeSet = changeSet({ + fieldChanges: [], + bodyChange: { + summary: "Builder body changed.", + currentExcerpt: "Old body", + proposedExcerpt: "New body", + currentHash: "local-baseline-hash", + proposedHash: "local-next-hash", + proposedContent: "New body", + proposedBlocksJson: "[]", + sidecarsJson: "{}", + warnings: [], + }, + }); + const stalePublishedRow = publishedRow({ + lastSourceUpdatedAt: String(STALE_BUILDER_LAST_UPDATED_MS), + }); + const live = { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + id: "builder-entry-1", + values: { "data.title": "Old title" }, + } as const; + + const unverifiable = unpublishedSave({ + rows: [stalePublishedRow], + changeSet: bodyChangeSet, + readLiveEntry: { ...live, blocksHash: null }, + }); + await expect(unverifiable.run()).rejects.toThrow( + "Builder entry changed since this diff was approved; refresh and re-review.", + ); + expect(unverifiable.deps.executeWrite).not.toHaveBeenCalled(); + + const unchangedBody = unpublishedSave({ + rows: [stalePublishedRow], + changeSet: bodyChangeSet, + readLiveEntry: { ...live, blocksHash: "local-baseline-hash" }, + }); + await expect(unchangedBody.run()).resolves.toMatchObject(RESPONSE); + expect(unchangedBody.deps.executeWrite).toHaveBeenCalledTimes(1); + }); + + it("blocks an unpublished save when Builder does not return the entry's values", async () => { + const { deps, run } = unpublishedSave({ + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + }, + }); + + await expect(run()).rejects.toThrow( + "Builder did not return this entry's current values; refresh and re-review.", + ); + expect(deps.executeWrite).not.toHaveBeenCalled(); + }); + + it("blocks an unpublished save to an archived entry", async () => { + const { deps, run } = unpublishedSave({ + readLiveEntry: { + exists: true, + published: "archived", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + values: { "data.title": "Old title" }, + }, + }); + + await expect(run()).rejects.toThrow( + "This entry is archived in Builder; restore it there before saving.", + ); + expect(deps.executeWrite).not.toHaveBeenCalled(); + }); + + it("says so when a draft save lands on an entry Builder just published", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const { deps, run } = unpublishedSave({ + readLiveEntry: { + exists: true, + published: "draft", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + values: { "data.title": "Old title" }, + }, + writeResult: { + ok: true, + status: 200, + entryId: "builder-entry-1", + responseBody: { id: "builder-entry-1", published: "published" }, + }, + }); + + await expect(run()).resolves.toMatchObject(RESPONSE); + expect(deps.markExecutionSucceeded).toHaveBeenCalledWith( + expect.objectContaining({ + summary: expect.stringContaining("this change is live"), + }), + ); + } finally { + warn.mockRestore(); + } + }); + + it("records the live values an autosave saved over", () => { + const approvedChangeSet = changeSet(); + const builderSource = source({ + rows: [publishedRow()], + changeSets: [approvedChangeSet], + }); + const plan = buildBuilderCmsExecutionPlan({ + source: builderSource, + changeSet: approvedChangeSet, + pushModeConfirmation: "autosave", + }); + expect(plan.payload.effect).toBe("autosave"); + + const values = JSON.parse( + builderCmsReconciledSourceValuesJson({ + existingSourceValuesJson: JSON.stringify({ + published: "published", + "data.title": "Old title", + }), + snapshotSourceValues: undefined, + changeSet: approvedChangeSet, + plan: { + ...plan, + payload: { + ...plan.payload, + livePreflight: { + checkedAt: NOW, + exists: true, + id: "builder-entry-1", + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + hasAutosaves: null, + values: { "data.title": "Old title" }, + }, + }, + }, + }), + ); + + expect(values["data.title"]).toBe("New title"); + expect(JSON.parse(values[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY])).toEqual({ + liveValues: { "data.title": "Old title" }, + }); + }); + + it("leaves the record alone for a stored response from before live values were kept", () => { + const approvedChangeSet = changeSet(); + const builderSource = source({ + rows: [publishedRow()], + changeSets: [approvedChangeSet], + }); + const plan = buildBuilderCmsExecutionPlan({ + source: builderSource, + changeSet: approvedChangeSet, + pushModeConfirmation: "autosave", + }); + + const values = JSON.parse( + builderCmsReconciledSourceValuesJson({ + existingSourceValuesJson: JSON.stringify({ + published: "published", + "data.title": "Old title", + }), + snapshotSourceValues: undefined, + changeSet: approvedChangeSet, + plan, + }), + ); + + expect(values["data.title"]).toBe("New title"); + expect(values).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); +}); diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index d880d910258..332c0c3ef36 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -43,13 +43,23 @@ import { BUILDER_CMS_BODY_SIDECARS_KEY, builderCmsQualifiedId, } from "./_builder-cms-source-adapter.js"; +import { + builderCmsStableValue, + builderCmsUnpublishedChangesAfterWrite, + readBuilderCmsUnpublishedChanges, + withBuilderCmsUnpublishedChanges, +} from "./_builder-cms-unpublished-changes.js"; import type { BuilderCmsExecutionPayload, BuilderCmsExecutionPlan, + BuilderCmsLivePreflight, } from "./_builder-cms-write-adapter.js"; import { buildBuilderCmsExecutionPlan, builderCmsExecutionIdempotencyKey, + builderCmsPayloadForLivePublication, + builderCmsWrittenSourceFields, + isBuilderCmsUnpublishedSaveEffect, resolveBuilderCmsExecutionPushMode, validateBuilderCmsExecutionDryRun, BUILDER_CMS_EXECUTION_MARKER_FIELD, @@ -426,10 +436,11 @@ export function builderCmsReconciledSourceValuesJson(args: { changeSet: ContentDatabaseSourceChangeSet; plan: BuilderCmsExecutionPlan; }) { - const next = { + const existing = { ...(args.snapshotSourceValues ?? {}), ...parseSourceValues(args.existingSourceValuesJson), }; + const next = { ...existing }; for (const field of args.changeSet.fieldChanges) { next[field.sourceFieldKey] = field.proposedValue; } @@ -469,7 +480,26 @@ export function builderCmsReconciledSourceValuesJson(args: { } } } - return JSON.stringify(next); + const livePreflight = args.plan.payload.livePreflight; + // A response stored before live reads were recorded carries no live + // values, so it leaves the unpublished-changes record as it was. + if (!livePreflight) return JSON.stringify(next); + const written = builderCmsWrittenSourceFields(args.plan.payload.request); + return JSON.stringify( + withBuilderCmsUnpublishedChanges( + next, + builderCmsUnpublishedChangesAfterWrite({ + existing, + effect: args.plan.payload.effect, + writtenFieldKeys: written.fieldKeys, + wroteBody: written.wroteBody, + live: { + values: livePreflight.values, + blocksHash: livePreflight.blocksHash, + }, + }), + ), + ); } function sourceRowForChangeSet( @@ -487,7 +517,7 @@ function sourceRowForChangeSet( function requiresLivePreflight(effect: BuilderCmsExecutionPayload["effect"]) { return ( - effect === "autosave" || + isBuilderCmsUnpublishedSaveEffect(effect) || effect === "update_in_place" || effect === "publish" || effect === "unpublish" @@ -533,7 +563,10 @@ function livePreflightBlockMessage(args: { liveState: BuilderCmsEntryLiveState; baselineLastUpdated: string | null; baselineBlocksHash: string | null; + baselineValues: Record | undefined; effect: BuilderCmsExecutionPayload["effect"]; + request: BuilderCmsExecutionPayload["request"]; + fieldLabel: (sourceFieldKey: string) => string; }) { if (!args.liveState.exists) { return "Builder entry no longer exists; refresh the source."; @@ -546,23 +579,80 @@ function livePreflightBlockMessage(args: { if (toEpochMs(args.liveState.lastUpdated) === null) { return "Builder did not report when this entry last changed; refresh and re-review."; } + // An autosave records the live values it saves over, so it cannot run + // without them. + if ( + isBuilderCmsUnpublishedSaveEffect(args.effect) && + !args.liveState.values + ) { + return "Builder did not return this entry's current values; refresh and re-review."; + } + const unpublished = readBuilderCmsUnpublishedChanges(args.baselineValues); + const savedOver = + unpublished.state === "pending" ? unpublished.changes : null; + // A Builder value Content saved unpublished changes over is still the one + // this edit was made against. + const stillReviewedValue = ( + live: unknown, + baseline: unknown, + savedOverValue: unknown, + ) => + builderCmsStableValue(live) === builderCmsStableValue(baseline) || + (savedOverValue !== undefined && + builderCmsStableValue(live) === builderCmsStableValue(savedOverValue)); if ( liveTimestampsDiffer({ liveLastUpdated: args.liveState.lastUpdated, baselineLastUpdated: args.baselineLastUpdated, }) ) { - return "Builder entry changed since this diff was approved; refresh and re-review."; + // A save sends only the fields it changes, so an edit elsewhere in the + // entry is no conflict. Publication changes still need a fresh review. + const fieldLevel = + isBuilderCmsUnpublishedSaveEffect(args.effect) || + args.effect === "update_in_place"; + const liveValues = args.liveState.values; + const written = builderCmsWrittenSourceFields(args.request); + // The body check below skips a missing hash, which is only safe while + // the timestamps still vouch for the body. + const bodyUnverifiable = + written.wroteBody && + (!args.baselineBlocksHash || !args.liveState.blocksHash); + if (!fieldLevel || !liveValues || bodyUnverifiable) { + return "Builder entry changed since this diff was approved; refresh and re-review."; + } + const changedFields = written.fieldKeys.filter( + (key) => + !stillReviewedValue( + liveValues[key], + args.baselineValues?.[key], + savedOver?.liveValues[key], + ), + ); + if (changedFields.length > 0) { + return `${changedFields.map(args.fieldLabel).join(", ")} changed in Builder since you edited it; refresh and re-review.`; + } } if ( args.baselineBlocksHash && args.liveState.blocksHash && - args.baselineBlocksHash !== args.liveState.blocksHash + !stillReviewedValue( + args.liveState.blocksHash, + args.baselineBlocksHash, + savedOver?.liveBlocksHash, + ) ) { return "Builder body changed since this diff was approved; refresh and re-review."; } if ( - args.effect === "publish" && + isBuilderCmsUnpublishedSaveEffect(args.effect) && + args.liveState.published === "archived" + ) { + return "This entry is archived in Builder; restore it there before saving."; + } + if ( + (args.effect === "publish" || + isBuilderCmsUnpublishedSaveEffect(args.effect)) && args.liveState.published !== "draft" && args.liveState.published !== "published" ) { @@ -574,6 +664,48 @@ function livePreflightBlockMessage(args: { return null; } +function builderCmsLivePreflightRecord(args: { + liveState: BuilderCmsEntryLiveState; + request: BuilderCmsExecutionPayload["request"]; + checkedAt: string; +}): BuilderCmsLivePreflight { + const liveValues = args.liveState.values; + return { + checkedAt: args.checkedAt, + exists: args.liveState.exists, + id: args.liveState.id, + published: args.liveState.published, + lastUpdated: args.liveState.lastUpdated, + blocksHash: args.liveState.blocksHash, + hasAutosaves: args.liveState.hasAutosaves ?? null, + values: liveValues + ? Object.fromEntries( + builderCmsWrittenSourceFields(args.request).fieldKeys.map((key) => [ + key, + Object.prototype.hasOwnProperty.call(liveValues, key) + ? liveValues[key] + : null, + ]), + ) + : null, + }; +} + +function builderSourceFieldLabel(source: ContentDatabaseSource) { + return (sourceFieldKey: string) => { + const name = sourceFieldKey.replace(/^data\./, ""); + return ( + source.metadata.builderModelFields + ?.find((field) => field.name.trim() === name) + ?.label?.trim() || + source.fields + .find((field) => field.sourceFieldKey === sourceFieldKey) + ?.sourceFieldLabel?.trim() || + name + ); + }; +} + export function builderCmsReconciledSourceRowPatch(args: { source: ContentDatabaseSource; changeSet: ContentDatabaseSourceChangeSet; @@ -1731,8 +1863,26 @@ export async function executeBuilderSourceExecutionWithDeps( timing.record("approval_gate_and_dry_run_validation", gateStartedAt); timing.ensure("write_dispatch"); const reconciliationStartedAt = timing.start(); + // The live check may have picked the other unpublished save; the + // stored payload says which one Builder received. + const writtenPayload: BuilderCmsExecutionPayload = + isBuilderCmsUnpublishedSaveEffect(validatedPayload.effect) && + isBuilderCmsUnpublishedSaveEffect(storedPayload.effect) && + storedRequest + ? { + ...validatedPayload, + effect: storedPayload.effect, + request: storedRequest, + ...(recordValue(storedPayload.livePreflight) + ? { + livePreflight: + storedPayload.livePreflight as BuilderCmsLivePreflight, + } + : {}), + } + : validatedPayload; const payloadWithResponse = executionResponsePayload({ - payload: validatedPayload, + payload: writtenPayload, writeResult: storedWriteResult, }); const reconciledAt = deps.now(); @@ -1741,7 +1891,7 @@ export async function executeBuilderSourceExecutionWithDeps( database, source, changeSet, - plan, + plan: { ...plan, payload: writtenPayload }, writeResult: storedWriteResult, now: reconciledAt, }); @@ -1837,6 +1987,7 @@ export async function executeBuilderSourceExecutionWithDeps( now: deps.now(), }); + let writePayload: BuilderCmsExecutionPayload = validatedPayload; if (preflightEntryId) { // This check runs after the claim so it sits as close to the write as // possible. A Builder save that lands after this read and before the @@ -1891,27 +2042,25 @@ export async function executeBuilderSourceExecutionWithDeps( liveBlocksHash: liveState.blocksHash, livePublished: liveState.published, }); + const livePreflight = builderCmsLivePreflightRecord({ + liveState, + request: plan.payload.request, + checkedAt: now, + }); const message = livePreflightBlockMessage({ liveState, baselineLastUpdated, baselineBlocksHash: changeSet.bodyChange?.currentHash ?? null, + baselineValues: sourceRowForChangeSet(source, changeSet)?.sourceValues, effect: plan.payload.effect, + request: plan.payload.request, + fieldLabel: builderSourceFieldLabel(source), }); if (message) { await releaseClaim({ state: "blocked", summary: `${plan.summary} Execution blocked before write.`, - payload: { - ...validatedPayload, - livePreflight: { - checkedAt: now, - exists: liveState.exists, - published: liveState.published, - lastUpdated: liveState.lastUpdated, - blocksHash: liveState.blocksHash, - id: liveState.id, - }, - }, + payload: { ...validatedPayload, livePreflight }, lastError: message, }); fail(message, { @@ -1919,14 +2068,25 @@ export async function executeBuilderSourceExecutionWithDeps( statusCode: 409, }); } + writePayload = { + ...builderCmsPayloadForLivePublication( + validatedPayload, + liveState.published, + ), + livePreflight, + }; } + const writePlan: BuilderCmsExecutionPlan = { + ...plan, + payload: writePayload, + }; timing.record("approval_gate_and_dry_run_validation", gateStartedAt); let writeResult: BuilderCmsWriteResult; try { writeResult = await timing.measure("write_dispatch", () => deps.executeWrite({ - request: plan.payload.request, + request: writePayload.request, expectedSourceSpace: source.metadata.builderSpacePublicKey, expectedSourceConnectionId: source.metadata.connectionId, requireSourceBinding: true, @@ -1943,7 +2103,7 @@ export async function executeBuilderSourceExecutionWithDeps( throw error; } const payloadWithResponse = executionResponsePayload({ - payload: validatedPayload, + payload: writePayload, writeResult, }); @@ -2019,7 +2179,7 @@ export async function executeBuilderSourceExecutionWithDeps( database, source, changeSet, - plan, + plan: writePlan, writeResult, now: succeededAt, }); @@ -2037,10 +2197,24 @@ export async function executeBuilderSourceExecutionWithDeps( }); throw builderExecutionConflict(lastError); } + // A draft save sends no published field, so an entry published after + // the live check takes this change live; say so rather than "saved". + const draftSaveWentLive = + writePayload.effect === "save_draft" && + builderCmsResponseValue(writeResult.responseBody, ["published"]) === + "published"; + if (draftSaveWentLive) { + console.warn("builder_source_draft_save_went_live", { + executionId: execution.id, + changeSetId: changeSet.id, + }); + } await deps.markExecutionSucceeded({ executionId: execution.id, changeSetId: changeSet.id, - summary: `Builder ${plan.pushMode} execution succeeded.`, + summary: draftSaveWentLive + ? `Builder ${plan.pushMode} execution succeeded. Builder published this entry while Content was saving, so this change is live.` + : `Builder ${plan.pushMode} execution succeeded.`, payload: payloadWithResponse, now: succeededAt, attemptToken, diff --git a/templates/content/actions/prepare-builder-source-review.ts b/templates/content/actions/prepare-builder-source-review.ts index 492c67c57b1..a67f28db842 100644 --- a/templates/content/actions/prepare-builder-source-review.ts +++ b/templates/content/actions/prepare-builder-source-review.ts @@ -16,6 +16,7 @@ import type { PrepareBuilderSourceReviewRequest, PrepareBuilderSourceReviewResponse, } from "../shared/api.js"; +import { isBuilderCmsWriteEffect } from "../shared/api.js"; import { builderExecutionPayloadReference, cleanupBuilderPrivatePayload, @@ -184,17 +185,12 @@ export function buildBuilderSourceReviewPayload(args: { changeSet.fieldChanges.find((field) => field.localFieldKey === "title") ?.proposedValue ?? null; const plannedEffect = latestExecution?.payload.effect; - const effect = - plannedEffect === "autosave" || - plannedEffect === "update_in_place" || - plannedEffect === "create_draft" || - plannedEffect === "publish" || - plannedEffect === "unpublish" - ? plannedEffect - : resolveBuilderCmsWriteEffect({ - source: args.source, - changeSet, - }); + const effect = isBuilderCmsWriteEffect(plannedEffect) + ? plannedEffect + : resolveBuilderCmsWriteEffect({ + source: args.source, + changeSet, + }); return { changeSetId: changeSet.id, diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx index 3c36b8890b3..7ebe7c2f2a5 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx @@ -63,6 +63,7 @@ const EFFECT_LABELS: Record< 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" }, + save_draft: { tag: "Draft", sentence: "Saves the draft" }, publish: { tag: "Publish", sentence: "Publishes this entry" }, unpublish: { tag: "Unpublish", sentence: "Unpublishes the live entry" }, }; @@ -89,6 +90,7 @@ export function builderReviewIntentSummary( create_draft: 0, update_in_place: 0, autosave: 0, + save_draft: 0, publish: 0, unpublish: 0, }; @@ -108,10 +110,9 @@ export function builderReviewIntentSummary( `${counts.update_in_place} update${counts.update_in_place === 1 ? "" : "s"}`, ); } - if (counts.autosave) { - parts.push( - `${counts.autosave} draft save${counts.autosave === 1 ? "" : "s"}`, - ); + const draftSaves = counts.autosave + counts.save_draft; + if (draftSaves) { + parts.push(`${draftSaves} draft save${draftSaves === 1 ? "" : "s"}`); } if (counts.publish) parts.push(`${counts.publish} to publish`); if (counts.unpublish) parts.push(`${counts.unpublish} to unpublish`); diff --git a/templates/content/changelog/2026-10-22-saving-a-builder-entry-from-content-no-longer-snaps-back-on-.md b/templates/content/changelog/2026-10-22-saving-a-builder-entry-from-content-no-longer-snaps-back-on-.md new file mode 100644 index 00000000000..31f22c5e8d9 --- /dev/null +++ b/templates/content/changelog/2026-10-22-saving-a-builder-entry-from-content-no-longer-snaps-back-on-.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-10-22 +--- + +Saving a Builder entry from Content no longer snaps back on the next refresh, and draft entries get a plain save that Builder shows right away. diff --git a/templates/content/docs/product/capabilities/content.source.adapters.md b/templates/content/docs/product/capabilities/content.source.adapters.md index 3e2082dd5da..a980c968c37 100644 --- a/templates/content/docs/product/capabilities/content.source.adapters.md +++ b/templates/content/docs/product/capabilities/content.source.adapters.md @@ -147,15 +147,29 @@ conditional write. Existing-entry Builder writes are guarded on the Content side. Review records the entry's Builder `lastUpdated` from the last refresh, and a later refresh that moves it makes the review stale. Immediately before each existing-entry -write, Content reads the live `lastUpdated` through the bound OAuth content API -and blocks without writing when it differs from the reviewed value or when -either value is missing. This narrows the overwrite window but does not close -it: a Builder save that lands after that read and before Content's write -reaches Builder is still overwritten. Closing that window would need a -conditional write in Builder's API, which is out of scope. Unsaved Builder -editor state is not visible to the check. Focused execution tests cover it; it -has not been exercised against a live Builder space. New-entry creation keeps -its existing intent-reconciliation path. +write, Content reads the live entry through the bound OAuth content API and +blocks without writing when either `lastUpdated` is missing. When the live +`lastUpdated` differs from the reviewed value, publish and unpublish block; a +save blocks only when a field it writes no longer holds the value it was +reviewed against, or when the body changed. This narrows the overwrite window +but does not close it: a Builder save that lands after that read and before +Content's write reaches Builder is still overwritten. Closing that window would +need a conditional write in Builder's API, which is out of scope. Unsaved +Builder editor state is not visible to the check. Focused execution tests cover +it; it has not been exercised against a live Builder space. New-entry creation +keeps its existing intent-reconciliation path. + +A save that should not change what is live is chosen from the publication +state that live read reports. A draft entry gets a plain save, which Builder's +reads return. A published entry gets an autosave, which Builder keeps out of +its reads until someone publishes it there, so Content records the live value +each saved field replaced and keeps its saved value through refreshes while +Builder still reports that live value. Once Builder publishes, discards, or +changes that field, unpublishes the entry, or reports no unpublished changes, +Builder's value wins. A draft published between the live read and the write +takes the change live, and the execution summary says so. Unpublished changes +someone else made in Builder are not visible to the reads, so a conflict with +them is not detected. Prepared requests and receipts use identity-bound private blobs with verified readback; SQL stores compact opaque references. Exact replacement and deletion diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 34478462d5d..8a7bb6f0dae 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -1721,12 +1721,26 @@ export interface StageBuilderSourceBulkUpdateResponse { review: ContentDatabaseSourceReviewPayload | null; } -export type BuilderCmsWriteEffect = - | "autosave" - | "update_in_place" - | "create_draft" - | "publish" - | "unpublish"; +export const BUILDER_CMS_WRITE_EFFECTS = [ + "autosave", + "save_draft", + "update_in_place", + "create_draft", + "publish", + "unpublish", +] as const; + +/** + * `autosave` saves unpublished changes to a published entry; `save_draft` + * saves a draft entry. Neither changes what is live. + */ +export type BuilderCmsWriteEffect = (typeof BUILDER_CMS_WRITE_EFFECTS)[number]; + +export function isBuilderCmsWriteEffect( + value: unknown, +): value is BuilderCmsWriteEffect { + return (BUILDER_CMS_WRITE_EFFECTS as readonly unknown[]).includes(value); +} export interface ContentDatabaseSourceReviewRowSummary { changeSetId: string; From 107e59f5bd2c3f727be137e343f2e02c5fb90c78 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:32:34 -0400 Subject: [PATCH 02/20] Keep later Builder saves, saved bodies, and reference ids through refreshes and recovery --- .../actions/_builder-cms-read-client.ts | 10 +- .../actions/_builder-cms-source-adapter.ts | 29 +++ .../_builder-cms-unpublished-changes.test.ts | 185 ++++++++++++++++- .../_builder-cms-unpublished-changes.ts | 188 ++++++++++++++---- .../actions/_database-source-utils.test.ts | 39 ++++ .../content/actions/_database-source-utils.ts | 50 ++--- .../execute-builder-source-execution.test.ts | 168 ++++++++++++++++ .../execute-builder-source-execution.ts | 108 ++++++---- 8 files changed, 652 insertions(+), 125 deletions(-) diff --git a/templates/content/actions/_builder-cms-read-client.ts b/templates/content/actions/_builder-cms-read-client.ts index 63ea6c115da..23eaad508dc 100644 --- a/templates/content/actions/_builder-cms-read-client.ts +++ b/templates/content/actions/_builder-cms-read-client.ts @@ -19,6 +19,7 @@ import { type BuilderContentEntry, } from "../shared/builder-mdx.js"; import { + builderCmsReferenceIdSourceValues, builderEntryHasAutosaves, normalizeBuilderCmsApiEntry, type BuilderCmsSourceEntry, @@ -393,6 +394,13 @@ function stringOrNumberFromUnknown(value: unknown) { return stringFromUnknown(value); } +function liveSourceValues(record: Record, model: string) { + const sourceValues = normalizeBuilderCmsApiEntry(record, model)?.sourceValues; + return sourceValues + ? { ...sourceValues, ...builderCmsReferenceIdSourceValues(record.data) } + : null; +} + function liveStateFromBuilderEntry( value: unknown, model: string, @@ -464,7 +472,7 @@ function liveStateFromBuilderEntry( stringOrNumberFromUnknown(data.updatedAt), blocksHash: blocks.length > 0 ? builderBlocksHash(blocks) : null, id, - values: normalizeBuilderCmsApiEntry(record, model)?.sourceValues ?? null, + values: liveSourceValues(record, model), hasAutosaves: builderEntryHasAutosaves(record), }; } diff --git a/templates/content/actions/_builder-cms-source-adapter.ts b/templates/content/actions/_builder-cms-source-adapter.ts index 14a7f9f04aa..eeb87d2ca35 100644 --- a/templates/content/actions/_builder-cms-source-adapter.ts +++ b/templates/content/actions/_builder-cms-source-adapter.ts @@ -21,6 +21,35 @@ export const BUILDER_CMS_BODY_READABLE_MAP_KEY = export const BUILDER_CMS_BODY_BLOCK_COUNT_KEY = "__builder.body.blockCount"; /** Builder's `meta.hasAutosaves`: a published entry has unpublished changes. */ export const BUILDER_CMS_HAS_AUTOSAVES_KEY = "__builder.meta.hasAutosaves"; +export const BUILDER_CMS_REFERENCE_ID_KEY_PREFIX = + "__agent_native_builder_reference_id:"; + +export function builderCmsReferenceIdKey(sourceFieldKey: string) { + return `${BUILDER_CMS_REFERENCE_ID_KEY_PREFIX}${sourceFieldKey}`; +} + +/** The ids of an entry's reference fields, keyed as a refresh stores them. */ +export function builderCmsReferenceIdSourceValues( + data: unknown, +): Record { + if (!data || typeof data !== "object" || Array.isArray(data)) return {}; + const values: Record = {}; + for (const [name, rawValue] of Object.entries(data)) { + if (!rawValue || typeof rawValue !== "object" || Array.isArray(rawValue)) { + continue; + } + const reference = rawValue as Record; + if ( + reference["@type"] !== "@builder.io/core:Reference" || + typeof reference.id !== "string" || + !reference.id.trim() + ) { + continue; + } + values[builderCmsReferenceIdKey(`data.${name}`)] = reference.id.trim(); + } + return values; +} export interface BuilderCmsSourceEntry { id: string; diff --git a/templates/content/actions/_builder-cms-unpublished-changes.test.ts b/templates/content/actions/_builder-cms-unpublished-changes.test.ts index 109257ff42a..5d3e27a384d 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.test.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.test.ts @@ -19,6 +19,7 @@ function saved( changes: { liveValues: Record; liveBlocksHash?: string; + liveLastUpdated?: number; }, ) { return withBuilderCmsUnpublishedChanges(values, changes); @@ -95,7 +96,7 @@ describe("Builder unpublished changes", () => { const next = builderSourceValuesKeepingUnpublishedChanges({ incoming: { ...existing, published: "published" }, existing, - keepFields: true, + incomingFilledFromRow: true, }); expect(next["data.blurb"]).toBe("Saved blurb"); @@ -105,6 +106,22 @@ describe("Builder unpublished changes", () => { }); }); + it("lets a field Builder changed win through a body read that fills from the row", () => { + const existing = saved( + { published: "published", "data.title": "Saved title" }, + { liveValues: { "data.title": "Live title" } }, + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { published: "published", "data.title": "Published title" }, + existing, + incomingFilledFromRow: true, + }); + + expect(next["data.title"]).toBe("Published title"); + expect(next).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + it("keeps the reference id that goes with a kept reference value", () => { const existing = saved( { @@ -112,7 +129,12 @@ describe("Builder unpublished changes", () => { "data.author": "Saved author", "__agent_native_builder_reference_id:data.author": "author-2", }, - { liveValues: { "data.author": "Live author" } }, + { + liveValues: { + "data.author": "Live author", + "__agent_native_builder_reference_id:data.author": "author-1", + }, + }, ); const next = builderSourceValuesKeepingUnpublishedChanges({ @@ -130,6 +152,37 @@ describe("Builder unpublished changes", () => { ); }); + it("compares a reference by id, so a new reference with the same label wins", () => { + const existing = saved( + { + published: "published", + "data.author": "Saved author", + "__agent_native_builder_reference_id:data.author": "author-2", + }, + { + liveValues: { + "data.author": "Sam Lee", + "__agent_native_builder_reference_id:data.author": "author-1", + }, + }, + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + "data.author": "Sam Lee", + "__agent_native_builder_reference_id:data.author": "author-3", + }, + existing, + }); + + expect(next["data.author"]).toBe("Sam Lee"); + expect(next["__agent_native_builder_reference_id:data.author"]).toBe( + "author-3", + ); + expect(next).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + it("drops the record when the entry is no longer published", () => { const existing = saved( { published: "published", "data.title": "Saved title" }, @@ -246,7 +299,57 @@ describe("Builder unpublished changes", () => { expect(replaced).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); }); - it("records the live value from before Content's first save over a field", () => { + it("keeps a saved body when a read hashes it differently but Builder saved nothing since", () => { + const existing = saved( + { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-hash", + }, + { liveValues: {}, liveBlocksHash: "live-hash", liveLastUpdated: 1000 }, + ); + const read = (lastUpdated: number) => + builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + lastUpdated, + [BUILDER_CMS_BODY_CONTENT_KEY]: "Live body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "other-projection-hash", + }, + existing, + }); + + expect(read(1000)[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Saved body"); + expect(read(2000)[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Live body"); + }); + + it("checks the body against Builder's values when the stored body was put back", () => { + const existing = saved( + { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-hash", + }, + { liveValues: {}, liveBlocksHash: "live-hash" }, + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: existing, + existing, + builderBodyValues: { + published: "published", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "live-hash", + }, + }); + + expect(next[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Saved body"); + expect(readBuilderCmsUnpublishedChanges(next)).toEqual({ + state: "pending", + changes: { liveValues: {}, liveBlocksHash: "live-hash" }, + }); + }); + + it("records the live values each save read before it wrote", () => { const first = builderCmsUnpublishedChangesAfterWrite({ existing: { published: "published" }, effect: "autosave", @@ -255,11 +358,13 @@ describe("Builder unpublished changes", () => { live: { values: { "data.title": "Live title" }, blocksHash: "live-hash", + lastUpdated: 1000, }, }); expect(first).toEqual({ liveValues: { "data.title": "Live title", "data.blurb": null }, liveBlocksHash: "live-hash", + liveLastUpdated: 1000, }); const second = builderCmsUnpublishedChangesAfterWrite({ @@ -269,13 +374,72 @@ describe("Builder unpublished changes", () => { ), effect: "autosave", writtenFieldKeys: ["data.title"], - wroteBody: true, + wroteBody: false, live: { - values: { "data.title": "Live title" }, - blocksHash: "live-hash", + values: { "data.title": "First save, published in Builder" }, + blocksHash: "published-hash", + lastUpdated: 2000, + }, + }); + expect(second).toEqual({ + liveValues: { + "data.title": "First save, published in Builder", + "data.blurb": null, + }, + liveBlocksHash: "live-hash", + liveLastUpdated: 1000, + }); + }); + + it("keeps a second save after Builder published the first", () => { + const afterSecondSave = withBuilderCmsUnpublishedChanges( + { published: "published", "data.title": "Second save" }, + builderCmsUnpublishedChangesAfterWrite({ + existing: saved( + { published: "published", "data.title": "First save" }, + { liveValues: { "data.title": "Live title" } }, + ), + effect: "autosave", + writtenFieldKeys: ["data.title"], + wroteBody: false, + live: { + values: { "data.title": "First save" }, + blocksHash: null, + lastUpdated: 2000, + }, + }), + ); + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { published: "published", "data.title": "First save" }, + existing: afterSecondSave, + }); + + expect(next["data.title"]).toBe("Second save"); + }); + + it("records a reference's live id with its value", () => { + expect( + builderCmsUnpublishedChangesAfterWrite({ + existing: { published: "published" }, + effect: "autosave", + writtenFieldKeys: ["data.author"], + wroteBody: false, + live: { + values: { + "data.author": "Sam Lee", + "__agent_native_builder_reference_id:data.author": "author-1", + }, + blocksHash: null, + lastUpdated: 1000, + }, + }), + ).toEqual({ + liveValues: { + "data.author": "Sam Lee", + "__agent_native_builder_reference_id:data.author": "author-1", }, }); - expect(second).toEqual(first); }); it("refuses to record an autosave without the live values", () => { @@ -285,7 +449,7 @@ describe("Builder unpublished changes", () => { effect: "autosave", writtenFieldKeys: ["data.title"], wroteBody: false, - live: { values: null, blocksHash: null }, + live: { values: null, blocksHash: null, lastUpdated: null }, }), ).toThrow("live values were not recorded"); }); @@ -296,6 +460,7 @@ describe("Builder unpublished changes", () => { { liveValues: { "data.title": "Live title", "data.blurb": "Live blurb" }, liveBlocksHash: "live-hash", + liveLastUpdated: 1000, }, ); @@ -305,7 +470,7 @@ describe("Builder unpublished changes", () => { effect: "update_in_place", writtenFieldKeys: ["data.title"], wroteBody: true, - live: { values: {}, blocksHash: null }, + live: { values: null, blocksHash: null, lastUpdated: null }, }), ).toEqual({ liveValues: { "data.blurb": "Live blurb" } }); for (const effect of ["publish", "unpublish"] as const) { @@ -315,7 +480,7 @@ describe("Builder unpublished changes", () => { effect, writtenFieldKeys: [], wroteBody: false, - live: { values: {}, blocksHash: null }, + live: { values: null, blocksHash: null, lastUpdated: null }, }), ).toBeNull(); } diff --git a/templates/content/actions/_builder-cms-unpublished-changes.ts b/templates/content/actions/_builder-cms-unpublished-changes.ts index 41fff1423b3..7369a550aca 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.ts @@ -10,6 +10,8 @@ import { BUILDER_CMS_BODY_READABLE_MAP_KEY, BUILDER_CMS_BODY_SIDECARS_KEY, BUILDER_CMS_HAS_AUTOSAVES_KEY, + BUILDER_CMS_REFERENCE_ID_KEY_PREFIX, + builderCmsReferenceIdKey, } from "./_builder-cms-source-adapter.js"; /** @@ -23,10 +25,15 @@ export const BUILDER_CMS_UNPUBLISHED_CHANGES_KEY = "__agent_native_builder_unpublished_changes"; export interface BuilderCmsUnpublishedChanges { - /** The live value of each source field when Content saved over it. */ + /** + * The live value of each source field when Content last saved over it, + * with a reference field's id under its reference-id key. + */ liveValues: Record; - /** The live body's blocks hash when Content saved a body over it. */ + /** The live body's blocks hash when Content last saved a body over it. */ liveBlocksHash?: string; + /** Builder's lastUpdated when Content last saved a body over it. */ + liveLastUpdated?: number | string; } export type BuilderCmsUnpublishedChangesRead = @@ -39,8 +46,6 @@ export type BuilderCmsEntryStatus = | "draft" | "unpublished_changes"; -const REFERENCE_ID_KEY_PREFIX = "__agent_native_builder_reference_id:"; - const BODY_BASELINE_KEYS = [ BUILDER_CMS_BODY_CONTENT_KEY, BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY, @@ -50,6 +55,22 @@ const BODY_BASELINE_KEYS = [ BUILDER_CMS_BODY_LAST_UPDATED_KEY, ]; +function hasKey( + record: Record | null | undefined, + key: string, +): boolean { + return !!record && Object.prototype.hasOwnProperty.call(record, key); +} + +function epochMs(value: unknown): number | null { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value !== "string" || !value.trim()) return null; + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + export function builderCmsStableValue(value: unknown): string { if (value === null || value === undefined) return "null"; if (Array.isArray(value)) { @@ -67,6 +88,27 @@ export function builderCmsStableValue(value: unknown): string { return JSON.stringify(value); } +/** + * Whether `values` holds the same value for a source field as `expected`. A + * reference compares by id: its stored value is a display label that two + * references can share, and a saved row holds the reference object instead. + */ +export function builderCmsSourceFieldMatches(args: { + key: string; + values: Record | null | undefined; + expected: Record | null | undefined; +}): boolean { + const referenceKey = builderCmsReferenceIdKey(args.key); + const compared = + hasKey(args.values, referenceKey) || hasKey(args.expected, referenceKey) + ? referenceKey + : args.key; + return ( + builderCmsStableValue(args.values?.[compared]) === + builderCmsStableValue(args.expected?.[compared]) + ); +} + export function readBuilderCmsUnpublishedChanges( sourceValues: Record | null | undefined, ): BuilderCmsUnpublishedChangesRead { @@ -95,15 +137,28 @@ export function readBuilderCmsUnpublishedChanges( if (liveBlocksHash !== undefined && typeof liveBlocksHash !== "string") { return { state: "invalid" }; } + const liveLastUpdated = record.liveLastUpdated; + if ( + liveLastUpdated !== undefined && + typeof liveLastUpdated !== "string" && + typeof liveLastUpdated !== "number" + ) { + return { state: "invalid" }; + } return { state: "pending", changes: { liveValues: liveValues as Record, ...(liveBlocksHash ? { liveBlocksHash } : {}), + ...(liveLastUpdated !== undefined ? { liveLastUpdated } : {}), }, }; } +function hasBodyRecord(changes: BuilderCmsUnpublishedChanges) { + return !!changes.liveBlocksHash || changes.liveLastUpdated !== undefined; +} + export function withBuilderCmsUnpublishedChanges( sourceValues: Record, changes: BuilderCmsUnpublishedChanges | null, @@ -111,7 +166,7 @@ export function withBuilderCmsUnpublishedChanges( const next = { ...sourceValues }; if ( changes && - (Object.keys(changes.liveValues).length > 0 || changes.liveBlocksHash) + (Object.keys(changes.liveValues).length > 0 || hasBodyRecord(changes)) ) { next[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY] = JSON.stringify(changes); } else { @@ -136,6 +191,27 @@ export function builderCmsEntryStatus( : "published"; } +/** + * Whether a Builder read still reports the body Content saved over. Reads + * that project the body differently hash it differently, so an unchanged + * lastUpdated also counts: Builder has saved nothing since. + */ +export function builderCmsBodyStillSavedOver( + changes: BuilderCmsUnpublishedChanges, + incoming: Record, +): boolean { + if (!hasBodyRecord(changes)) return false; + const incomingHash = incoming[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]; + if (incomingHash === undefined || incomingHash === null) return true; + if (changes.liveBlocksHash && incomingHash === changes.liveBlocksHash) { + return true; + } + const recordedEpoch = epochMs(changes.liveLastUpdated); + return ( + recordedEpoch !== null && epochMs(incoming.lastUpdated) === recordedEpoch + ); +} + /** * The baseline a refresh or body read should keep. A field keeps Content's * saved value while Builder still reports the live value it was saved over; @@ -148,10 +224,14 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { existing: Record | null | undefined; /** * A body read fills fields Builder did not return with the row's stored - * values, so those fields would look changed. The refresh that queued it - * already applied the field rule to the same Builder read. + * values, so a field that equals Content's saved value says nothing. */ - keepFields?: boolean; + incomingFilledFromRow?: boolean; + /** + * Builder's own values for the body check, when `incoming` has had the + * stored body put back: that body's hash never matches the live one. + */ + builderBodyValues?: Record; }): Record { const read = readBuilderCmsUnpublishedChanges(args.existing); if (read.state === "invalid") { @@ -169,44 +249,59 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { return withBuilderCmsUnpublishedChanges(args.incoming, null); } const existing = args.existing; + const recorded = read.changes; const next = { ...args.incoming }; const liveValues: Record = {}; - for (const [key, liveValue] of Object.entries(read.changes.liveValues)) { + for (const key of Object.keys(recorded.liveValues)) { + if (key.startsWith(BUILDER_CMS_REFERENCE_ID_KEY_PREFIX)) continue; + const referenceKey = builderCmsReferenceIdKey(key); + const builderReturned = + hasKey(args.incoming, key) || hasKey(args.incoming, referenceKey); const builderStillLive = - args.keepFields || - !Object.prototype.hasOwnProperty.call(args.incoming, key) || - builderCmsStableValue(args.incoming[key]) === - builderCmsStableValue(liveValue); + !builderReturned || + builderCmsSourceFieldMatches({ + key, + values: args.incoming, + expected: recorded.liveValues, + }) || + (!!args.incomingFilledFromRow && + builderCmsSourceFieldMatches({ + key, + values: args.incoming, + expected: existing, + })); if (!builderStillLive) continue; - for (const keptKey of [key, `${REFERENCE_ID_KEY_PREFIX}${key}`]) { - if (Object.prototype.hasOwnProperty.call(existing, keptKey)) { - next[keptKey] = existing[keptKey]; + for (const keptKey of [key, referenceKey]) { + if (hasKey(existing, keptKey)) next[keptKey] = existing[keptKey]; + if (hasKey(recorded.liveValues, keptKey)) { + liveValues[keptKey] = recorded.liveValues[keptKey]; } } - liveValues[key] = liveValue; } - let liveBlocksHash: string | undefined; - if (read.changes.liveBlocksHash) { - const incomingHash = args.incoming[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]; - if ( - incomingHash === undefined || - incomingHash === read.changes.liveBlocksHash - ) { - for (const key of BODY_BASELINE_KEYS) { - if (existing[key] !== undefined) next[key] = existing[key]; - } - liveBlocksHash = read.changes.liveBlocksHash; + const bodyKept = builderCmsBodyStillSavedOver( + recorded, + args.builderBodyValues ?? args.incoming, + ); + if (bodyKept) { + for (const key of BODY_BASELINE_KEYS) { + if (existing[key] !== undefined) next[key] = existing[key]; } } return withBuilderCmsUnpublishedChanges(next, { liveValues, - ...(liveBlocksHash ? { liveBlocksHash } : {}), + ...(bodyKept && recorded.liveBlocksHash + ? { liveBlocksHash: recorded.liveBlocksHash } + : {}), + ...(bodyKept && recorded.liveLastUpdated !== undefined + ? { liveLastUpdated: recorded.liveLastUpdated } + : {}), }); } /** - * What stays unpublished after a write. An autosave adds the fields it wrote, - * keeping the live value each one had before Content's first save over it; + * What stays unpublished after a write. An autosave records the live values + * its own live check read for the fields it wrote, so a value Builder + * published since an earlier save is what the next refresh compares against; * any other write puts its fields live, so they leave the record. */ export function builderCmsUnpublishedChangesAfterWrite(args: { @@ -217,6 +312,7 @@ export function builderCmsUnpublishedChangesAfterWrite(args: { live: { values: Record | null; blocksHash: string | null; + lastUpdated: number | string | null; }; }): BuilderCmsUnpublishedChanges | null { if (args.effect === "publish" || args.effect === "unpublish") return null; @@ -225,30 +321,44 @@ export function builderCmsUnpublishedChangesAfterWrite(args: { read.state === "pending" ? read.changes : { liveValues: {} }; const liveValues = { ...current.liveValues }; let liveBlocksHash = current.liveBlocksHash; + let liveLastUpdated = current.liveLastUpdated; if (args.effect === "autosave") { const live = args.live.values; if (!live) { + // The live check blocks an autosave Builder returned no values for. + // guard:allow-bare-error — invariant: reaching this is a bug. throw new Error( "Builder's live values were not recorded before this autosave, so Content cannot keep the saved changes.", ); } for (const key of args.writtenFieldKeys) { - if (Object.prototype.hasOwnProperty.call(liveValues, key)) continue; // A field the live entry lacks is recorded as null, which a refresh // that also lacks it reads as unchanged. - liveValues[key] = Object.prototype.hasOwnProperty.call(live, key) - ? live[key] - : null; + liveValues[key] = hasKey(live, key) ? live[key] : null; + const referenceKey = builderCmsReferenceIdKey(key); + if (hasKey(live, referenceKey)) { + liveValues[referenceKey] = live[referenceKey]; + } else { + delete liveValues[referenceKey]; + } } - if (args.wroteBody && !liveBlocksHash && args.live.blocksHash) { - liveBlocksHash = args.live.blocksHash; + if (args.wroteBody) { + liveBlocksHash = args.live.blocksHash ?? undefined; + liveLastUpdated = args.live.lastUpdated ?? undefined; } } else { - for (const key of args.writtenFieldKeys) delete liveValues[key]; - if (args.wroteBody) liveBlocksHash = undefined; + for (const key of args.writtenFieldKeys) { + delete liveValues[key]; + delete liveValues[builderCmsReferenceIdKey(key)]; + } + if (args.wroteBody) { + liveBlocksHash = undefined; + liveLastUpdated = undefined; + } } return { liveValues, ...(liveBlocksHash ? { liveBlocksHash } : {}), + ...(liveLastUpdated !== undefined ? { liveLastUpdated } : {}), }; } diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index 527d8c83500..e938d806d91 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -3026,6 +3026,45 @@ describe("database source helpers", () => { expect(published).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); }); + it("keeps a saved unpublished body through a metadata refresh that puts the stored body back", () => { + const refreshed = sourceValuesForSeededSourceRow({ + sourceType: "builder-cms", + item: item("doc-1", "Same title"), + sourceTable: "blog_article", + now: "2026-06-08T13:00:00.000Z", + builderEntry: { + id: "entry-1", + model: "blog_article", + title: "Same title", + urlPath: "/blog/same-title", + updatedAt: "2026-06-08T13:00:00.000Z", + sourceValues: { + published: "published", + lastUpdated: "1000", + "data.title": "Same title", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "live-body-hash", + }, + }, + existingSourceValuesJson: JSON.stringify({ + published: "published", + lastUpdated: "1000", + "data.title": "Same title", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-body-hash", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved body", + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: JSON.stringify({ + liveValues: {}, + liveBlocksHash: "live-body-hash", + }), + }), + }); + + expect(refreshed[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Saved body"); + expect(refreshed[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]).toBe("saved-body-hash"); + expect( + JSON.parse(refreshed[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY] as string), + ).toEqual({ liveValues: {}, liveBlocksHash: "live-body-hash" }); + }); + it("preserves a hydrated Builder body baseline across metadata-only refreshes with the same hash", () => { expect( sourceValuesForSeededSourceRow({ diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 70849738d0a..8f17d33ff32 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -98,6 +98,8 @@ import { BUILDER_CMS_FIXTURE_ROW_PROVENANCE, buildBuilderCmsFixtureEntry, builderCmsQualifiedId, + builderCmsReferenceIdKey, + builderCmsReferenceIdSourceValues, builderCmsSourceFieldKey, builderCmsSourceMetadata, builderCmsSourceRowIdentity, @@ -106,6 +108,7 @@ import { type ExistingBuilderSourceRowIdentity, } from "./_builder-cms-source-adapter.js"; import { + builderCmsBodyStillSavedOver, builderSourceValuesKeepingUnpublishedChanges, readBuilderCmsUnpublishedChanges, } from "./_builder-cms-unpublished-changes.js"; @@ -826,7 +829,7 @@ export const CANCELLED_BUILDER_REVIEW_NOTE_PREFIX = "Cancelled prepared Builder update before dispatch"; export function builderReferenceIdSourceValueKey(sourceFieldKey: string) { - return `__agent_native_builder_reference_id:${sourceFieldKey}`; + return builderCmsReferenceIdKey(sourceFieldKey); } function riskRank(level: ContentDatabaseSourceRiskLevel) { @@ -2110,7 +2113,10 @@ async function processBuilderBodyHydrationJob( const incomingIsBodySavedOver = unpublishedChanges.state === "pending" && !!incomingBlocksHash && - unpublishedChanges.changes.liveBlocksHash === incomingBlocksHash; + builderCmsBodyStillSavedOver( + unpublishedChanges.changes, + entryWithBody.sourceValues, + ); if ( !isCodecMigration && !incomingIsBodySavedOver && @@ -2141,7 +2147,7 @@ async function processBuilderBodyHydrationJob( ...entryWithBody.sourceValues, }, existing: sourceValues, - keepFields: true, + incomingFilledFromRow: true, }); let nextContent = stringSourceValue(nextValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; @@ -2242,7 +2248,7 @@ async function processBuilderBodyHydrationJob( nextValues = builderSourceValuesKeepingUnpublishedChanges({ incoming: liveValues, existing: sourceValues, - keepFields: true, + incomingFilledFromRow: true, }); nextContent = liveContent; } @@ -6012,36 +6018,18 @@ export function sourceValuesForSeededSourceRow(args: { Record >(args.existingSourceValuesJson); if (args.builderEntry?.sourceValues) { - const values = builderSourceValuesWithPreservedBodyBaseline({ - incoming: args.builderEntry.sourceValues, - existing: existingSourceValues, - existingLastSourceUpdatedAt: args.existingLastSourceUpdatedAt, - }); - const rawData = args.builderEntry.rawEntry?.data; - if (rawData && typeof rawData === "object" && !Array.isArray(rawData)) { - for (const [name, rawValue] of Object.entries(rawData)) { - if ( - !rawValue || - typeof rawValue !== "object" || - Array.isArray(rawValue) - ) - continue; - const reference = rawValue as Record; - if ( - reference["@type"] !== "@builder.io/core:Reference" || - typeof reference.id !== "string" || - !reference.id.trim() - ) { - continue; - } - const sourceFieldKey = `data.${name}`; - values[builderReferenceIdSourceValueKey(sourceFieldKey)] = - reference.id.trim(); - } - } + const values = { + ...builderSourceValuesWithPreservedBodyBaseline({ + incoming: args.builderEntry.sourceValues, + existing: existingSourceValues, + existingLastSourceUpdatedAt: args.existingLastSourceUpdatedAt, + }), + ...builderCmsReferenceIdSourceValues(args.builderEntry.rawEntry?.data), + }; return builderSourceValuesKeepingUnpublishedChanges({ incoming: values, existing: existingSourceValues, + builderBodyValues: args.builderEntry.sourceValues, }); } if (existingSourceValues) return existingSourceValues; diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 96e765b6231..7ae80988a08 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -2107,6 +2107,12 @@ describe("Builder unpublished saves", () => { metadata: { builderModelFields: [ { name: "title", label: "Title", type: "string", required: true }, + { + name: "author", + label: "Author", + type: "reference", + model: "blog-author", + }, ], }, }); @@ -2236,6 +2242,78 @@ describe("Builder unpublished saves", () => { expect(deps.executeWrite).toHaveBeenCalledTimes(1); }); + it("compares a reference by id, so another author with the same name blocks the save", async () => { + const authorChange = changeSet({ + fieldChanges: [ + { + propertyId: "author-property", + propertyName: "Author", + localFieldKey: "author-property", + sourceFieldKey: "data.author", + currentValue: "Sam Lee", + proposedValue: "Alex Kim", + builderValueJson: JSON.stringify({ + "@type": "@builder.io/core:Reference", + id: "author-2", + model: "blog-author", + }), + }, + ], + }); + const reviewedRow = publishedRow({ + lastSourceUpdatedAt: String(STALE_BUILDER_LAST_UPDATED_MS), + sourceValues: { + published: "published", + "data.title": "Old title", + "data.author": "Sam Lee", + "__agent_native_builder_reference_id:data.author": "author-1", + }, + }); + const live = (authorId: string): BuilderCmsEntryLiveState => ({ + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + values: { + "data.title": "Old title", + "data.author": "Sam Lee", + "__agent_native_builder_reference_id:data.author": authorId, + }, + }); + + const otherAuthor = unpublishedSave({ + rows: [reviewedRow], + changeSet: authorChange, + readLiveEntry: live("author-3"), + }); + await expect(otherAuthor.run()).rejects.toThrow( + "Author changed in Builder since you edited it; refresh and re-review.", + ); + expect(otherAuthor.deps.executeWrite).not.toHaveBeenCalled(); + + const sameAuthor = unpublishedSave({ + rows: [reviewedRow], + changeSet: authorChange, + readLiveEntry: live("author-1"), + }); + await expect(sameAuthor.run()).resolves.toMatchObject(RESPONSE); + expect(sameAuthor.deps.reconcileWrite).toHaveBeenCalledWith( + expect.objectContaining({ + plan: expect.objectContaining({ + payload: expect.objectContaining({ + livePreflight: expect.objectContaining({ + values: { + "data.author": "Sam Lee", + "__agent_native_builder_reference_id:data.author": "author-1", + }, + }), + }), + }), + }), + ); + }); + it("blocks a body save Builder changed around when the live body cannot be compared", async () => { const bodyChangeSet = changeSet({ fieldChanges: [], @@ -2393,6 +2471,96 @@ describe("Builder unpublished saves", () => { }); }); + it("takes a live write's fields out of the record, even from a response stored without live values", () => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ + rows: [publishedRow()], + changeSets: [approvedChangeSet], + }); + const plan = buildBuilderCmsExecutionPlan({ + source: builderSource, + changeSet: approvedChangeSet, + pushModeConfirmation: "draft", + }); + expect(plan.payload.effect).toBe("update_in_place"); + + const values = JSON.parse( + builderCmsReconciledSourceValuesJson({ + existingSourceValuesJson: JSON.stringify( + withBuilderCmsUnpublishedChanges( + { published: "published", "data.title": "Saved title" }, + { liveValues: { "data.title": "Live title" } }, + ), + ), + snapshotSourceValues: undefined, + changeSet: approvedChangeSet, + plan, + }), + ); + + expect(values["data.title"]).toBe("New title"); + expect(values).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + + it("reconciles a stored live-write response with the live values it recorded", async () => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ changeSets: [approvedChangeSet] }); + const plan = buildBuilderCmsExecutionPlan({ + source: builderSource, + changeSet: approvedChangeSet, + pushModeConfirmation: "draft", + }); + const livePreflight = { + checkedAt: NOW, + exists: true, + id: "builder-entry-1", + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + hasAutosaves: null, + values: { "data.title": "Old title" }, + }; + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + state: "failed", + payloadJson: JSON.stringify({ + ...plan.payload, + livePreflight, + response: { + ok: true, + status: 200, + entryId: "builder-entry-1", + body: { id: "builder-entry-1" }, + }, + }), + }); + const deps = depsFor({ source: builderSource, execution }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "draft", + }, + deps, + ), + ).resolves.toMatchObject(RESPONSE); + + expect(deps.executeWrite).not.toHaveBeenCalled(); + expect(deps.reconcileWrite).toHaveBeenCalledWith( + expect.objectContaining({ + plan: expect.objectContaining({ + payload: expect.objectContaining({ + effect: "update_in_place", + livePreflight, + }), + }), + }), + ); + }); + it("leaves the record alone for a stored response from before live values were kept", () => { const approvedChangeSet = changeSet(); const builderSource = source({ diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index 332c0c3ef36..4b7e74f4bfb 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -42,9 +42,10 @@ import { BUILDER_CMS_BODY_CONTENT_KEY, BUILDER_CMS_BODY_SIDECARS_KEY, builderCmsQualifiedId, + builderCmsReferenceIdKey, } from "./_builder-cms-source-adapter.js"; import { - builderCmsStableValue, + builderCmsSourceFieldMatches, builderCmsUnpublishedChangesAfterWrite, readBuilderCmsUnpublishedChanges, withBuilderCmsUnpublishedChanges, @@ -474,16 +475,18 @@ export function builderCmsReconciledSourceValuesJson(args: { "@builder.io/core:Reference" && typeof (value as Record).id === "string" ) { - next[`__agent_native_builder_reference_id:${sourceFieldKey}`] = ( + next[builderCmsReferenceIdKey(sourceFieldKey)] = ( value as Record ).id as string; } } } const livePreflight = args.plan.payload.livePreflight; - // A response stored before live reads were recorded carries no live - // values, so it leaves the unpublished-changes record as it was. - if (!livePreflight) return JSON.stringify(next); + // An autosave response stored before live reads were recorded carries no + // live values, so it leaves the unpublished-changes record as it was. + if (!livePreflight && args.plan.payload.effect === "autosave") { + return JSON.stringify(next); + } const written = builderCmsWrittenSourceFields(args.plan.payload.request); return JSON.stringify( withBuilderCmsUnpublishedChanges( @@ -494,8 +497,9 @@ export function builderCmsReconciledSourceValuesJson(args: { writtenFieldKeys: written.fieldKeys, wroteBody: written.wroteBody, live: { - values: livePreflight.values, - blocksHash: livePreflight.blocksHash, + values: livePreflight?.values ?? null, + blocksHash: livePreflight?.blocksHash ?? null, + lastUpdated: livePreflight?.lastUpdated ?? null, }, }), ), @@ -592,14 +596,26 @@ function livePreflightBlockMessage(args: { unpublished.state === "pending" ? unpublished.changes : null; // A Builder value Content saved unpublished changes over is still the one // this edit was made against. - const stillReviewedValue = ( - live: unknown, - baseline: unknown, - savedOverValue: unknown, + const fieldStillReviewed = ( + key: string, + liveValues: Record, ) => - builderCmsStableValue(live) === builderCmsStableValue(baseline) || - (savedOverValue !== undefined && - builderCmsStableValue(live) === builderCmsStableValue(savedOverValue)); + builderCmsSourceFieldMatches({ + key, + values: liveValues, + expected: args.baselineValues, + }) || + (!!savedOver && + (Object.prototype.hasOwnProperty.call(savedOver.liveValues, key) || + Object.prototype.hasOwnProperty.call( + savedOver.liveValues, + builderCmsReferenceIdKey(key), + )) && + builderCmsSourceFieldMatches({ + key, + values: liveValues, + expected: savedOver.liveValues, + })); if ( liveTimestampsDiffer({ liveLastUpdated: args.liveState.lastUpdated, @@ -622,12 +638,7 @@ function livePreflightBlockMessage(args: { return "Builder entry changed since this diff was approved; refresh and re-review."; } const changedFields = written.fieldKeys.filter( - (key) => - !stillReviewedValue( - liveValues[key], - args.baselineValues?.[key], - savedOver?.liveValues[key], - ), + (key) => !fieldStillReviewed(key, liveValues), ); if (changedFields.length > 0) { return `${changedFields.map(args.fieldLabel).join(", ")} changed in Builder since you edited it; refresh and re-review.`; @@ -636,11 +647,8 @@ function livePreflightBlockMessage(args: { if ( args.baselineBlocksHash && args.liveState.blocksHash && - !stillReviewedValue( - args.liveState.blocksHash, - args.baselineBlocksHash, - savedOver?.liveBlocksHash, - ) + args.liveState.blocksHash !== args.baselineBlocksHash && + args.liveState.blocksHash !== savedOver?.liveBlocksHash ) { return "Builder body changed since this diff was approved; refresh and re-review."; } @@ -680,12 +688,25 @@ function builderCmsLivePreflightRecord(args: { hasAutosaves: args.liveState.hasAutosaves ?? null, values: liveValues ? Object.fromEntries( - builderCmsWrittenSourceFields(args.request).fieldKeys.map((key) => [ - key, - Object.prototype.hasOwnProperty.call(liveValues, key) - ? liveValues[key] - : null, - ]), + builderCmsWrittenSourceFields(args.request).fieldKeys.flatMap( + (key) => { + const referenceKey = builderCmsReferenceIdKey(key); + return [ + [ + key, + Object.prototype.hasOwnProperty.call(liveValues, key) + ? liveValues[key] + : null, + ], + ...(Object.prototype.hasOwnProperty.call( + liveValues, + referenceKey, + ) + ? [[referenceKey, liveValues[referenceKey]]] + : []), + ]; + }, + ), ) : null, }; @@ -1864,23 +1885,22 @@ export async function executeBuilderSourceExecutionWithDeps( timing.ensure("write_dispatch"); const reconciliationStartedAt = timing.start(); // The live check may have picked the other unpublished save; the - // stored payload says which one Builder received. - const writtenPayload: BuilderCmsExecutionPayload = - isBuilderCmsUnpublishedSaveEffect(validatedPayload.effect) && + // stored payload says which one Builder received, and what the live + // check read before the write. + const writtenPayload: BuilderCmsExecutionPayload = { + ...validatedPayload, + ...(isBuilderCmsUnpublishedSaveEffect(validatedPayload.effect) && isBuilderCmsUnpublishedSaveEffect(storedPayload.effect) && storedRequest + ? { effect: storedPayload.effect, request: storedRequest } + : {}), + ...(recordValue(storedPayload.livePreflight) ? { - ...validatedPayload, - effect: storedPayload.effect, - request: storedRequest, - ...(recordValue(storedPayload.livePreflight) - ? { - livePreflight: - storedPayload.livePreflight as BuilderCmsLivePreflight, - } - : {}), + livePreflight: + storedPayload.livePreflight as BuilderCmsLivePreflight, } - : validatedPayload; + : {}), + }; const payloadWithResponse = executionResponsePayload({ payload: writtenPayload, writeResult: storedWriteResult, From 9b16026f345a307b9282dcc903a2162a9e87f05a Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:35:30 -0400 Subject: [PATCH 03/20] Keep unpublished Builder saves through every body read --- .../_builder-cms-unpublished-changes.test.ts | 37 +++++++++++ .../actions/_database-source-utils.test.ts | 27 ++++++++ .../content/actions/_database-source-utils.ts | 61 +++++++++++-------- 3 files changed, 100 insertions(+), 25 deletions(-) diff --git a/templates/content/actions/_builder-cms-unpublished-changes.test.ts b/templates/content/actions/_builder-cms-unpublished-changes.test.ts index 5d3e27a384d..9b93c86abe7 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.test.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.test.ts @@ -442,6 +442,43 @@ describe("Builder unpublished changes", () => { }); }); + it("tracks a body saved over an empty live body by Builder's update time", () => { + const changes = builderCmsUnpublishedChangesAfterWrite({ + existing: { published: "published" }, + effect: "autosave", + writtenFieldKeys: [], + wroteBody: true, + live: { values: {}, blocksHash: null, lastUpdated: 1000 }, + }); + expect(changes).toEqual({ liveValues: {}, liveLastUpdated: 1000 }); + const existing = withBuilderCmsUnpublishedChanges( + { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-hash", + }, + changes, + ); + + const stillEmpty = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { published: "published", lastUpdated: 1000 }, + existing, + }); + expect(stillEmpty[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Saved body"); + + const published = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + lastUpdated: 2000, + [BUILDER_CMS_BODY_CONTENT_KEY]: "Published body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "published-hash", + }, + existing, + }); + expect(published[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Published body"); + expect(published).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + it("refuses to record an autosave without the live values", () => { expect(() => builderCmsUnpublishedChangesAfterWrite({ diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index e938d806d91..40c58d95afd 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -29,6 +29,7 @@ import { builderBodyHydrationNextAttemptAt, builderBodyHydrationFailureEvidence, builderBodyNeedsSourceComponentWrite, + builderBodyReadSourceValues, knownBuilderReviewDocumentIds, builderSourcePropertyAssignments, builderBodyHydrationVersion, @@ -3026,6 +3027,32 @@ describe("database source helpers", () => { expect(published).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); }); + it("keeps saved unpublished fields through every Builder body read", () => { + const rowValues = { + published: "published", + "data.title": "Saved title", + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: JSON.stringify({ + liveValues: { "data.title": "Live title" }, + }), + }; + + const kept = builderBodyReadSourceValues(rowValues, { + published: "published", + "data.title": "Live title", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Live body", + }); + expect(kept["data.title"]).toBe("Saved title"); + expect(kept[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Live body"); + expect(kept).toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + + const published = builderBodyReadSourceValues(rowValues, { + published: "published", + "data.title": "Published title", + }); + expect(published["data.title"]).toBe("Published title"); + expect(published).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + it("keeps a saved unpublished body through a metadata refresh that puts the stored body back", () => { const refreshed = sourceValuesForSeededSourceRow({ sourceType: "builder-cms", diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 8f17d33ff32..575c5531f50 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -1999,6 +1999,23 @@ async function enqueueEmptyHydratedBuilderBodiesFromStoredRows(args: { }); } +/** + * A Builder body read's values over the row's. Every body read goes through + * this, so none of them overwrites Content's unpublished saves with the live + * values Builder still returns. + */ +export function builderBodyReadSourceValues( + rowValues: Record, + builderValues: Record, +) { + return builderSourceValuesKeepingUnpublishedChanges({ + incoming: { ...rowValues, ...builderValues }, + existing: rowValues, + incomingFilledFromRow: true, + builderBodyValues: builderValues, + }); +} + function parseHydrationEntry( row: ContentDatabaseBodyHydrationQueueRowDb, ): BuilderCmsSourceEntry | null { @@ -2141,14 +2158,10 @@ async function processBuilderBodyHydrationJob( "Builder returned inconsistent body variants for the same lastUpdated value; the stored baseline and local document were preserved for retry.", ); } - let nextValues = builderSourceValuesKeepingUnpublishedChanges({ - incoming: { - ...sourceValues, - ...entryWithBody.sourceValues, - }, - existing: sourceValues, - incomingFilledFromRow: true, - }); + let nextValues = builderBodyReadSourceValues( + sourceValues, + entryWithBody.sourceValues, + ); let nextContent = stringSourceValue(nextValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; let emptyBodyRead: Extract< @@ -2213,10 +2226,10 @@ async function processBuilderBodyHydrationJob( }); if (liveRead.state === "body") { const liveEntry = liveRead.entry; - const liveValues = { - ...sourceValues, - ...liveEntry.sourceValues, - }; + const liveValues = builderBodyReadSourceValues( + sourceValues, + liveEntry.sourceValues, + ); const liveContent = stringSourceValue(liveValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; if (liveContent.trim()) { @@ -2245,11 +2258,7 @@ async function processBuilderBodyHydrationJob( if (!upgraded) return; activeSourceEntryJson = liveSourceEntryJson; entryWithBody = liveEntry; - nextValues = builderSourceValuesKeepingUnpublishedChanges({ - incoming: liveValues, - existing: sourceValues, - incomingFilledFromRow: true, - }); + nextValues = liveValues; nextContent = liveContent; } } else { @@ -2291,10 +2300,12 @@ async function processBuilderBodyHydrationJob( await tx .update(schema.contentDatabaseSourceRows) .set({ - sourceValuesJson: JSON.stringify({ - ...sourceValues, - ...emptyBodyRead.entry.sourceValues, - }), + sourceValuesJson: JSON.stringify( + builderBodyReadSourceValues( + sourceValues, + emptyBodyRead.entry.sourceValues, + ), + ), lastSyncedAt: now, lastSourceUpdatedAt: emptyBodyRead.entry.updatedAt ?? now, updatedAt: now, @@ -2682,10 +2693,10 @@ async function preparePristineBuilderBodyHydration(args: { }, }), ); - const nextValues = { - ...sourceValues, - ...entryWithBody.sourceValues, - }; + const nextValues = builderBodyReadSourceValues( + sourceValues, + entryWithBody.sourceValues, + ); const content = stringSourceValue(nextValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; if (!content.trim()) return null; From a7a850b44eaf936604a24a8b52a09b70d61270a5 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:06:36 -0400 Subject: [PATCH 04/20] Ignore Builder reads from before a save, check the entry name, and keep the went-live note on recovery --- .../actions/_builder-cms-read-client.ts | 3 + .../_builder-cms-source-adapter.test.ts | 20 +++ .../actions/_builder-cms-source-adapter.ts | 3 + .../_builder-cms-unpublished-changes.test.ts | 135 ++++++++++++++++-- .../_builder-cms-unpublished-changes.ts | 115 ++++++++++----- .../actions/_builder-cms-write-adapter.ts | 1 + .../actions/_database-source-utils.test.ts | 82 ++++++++++- .../content/actions/_database-source-utils.ts | 41 +++--- .../execute-builder-source-execution.test.ts | 94 +++++++++++- .../execute-builder-source-execution.ts | 75 +++++++--- 10 files changed, 472 insertions(+), 97 deletions(-) diff --git a/templates/content/actions/_builder-cms-read-client.ts b/templates/content/actions/_builder-cms-read-client.ts index 23eaad508dc..51b532de8fe 100644 --- a/templates/content/actions/_builder-cms-read-client.ts +++ b/templates/content/actions/_builder-cms-read-client.ts @@ -59,6 +59,8 @@ export interface BuilderCmsEntryLiveState { /** The entry's source values, normalized as a refresh stores them. */ values?: Record | null; hasAutosaves?: boolean | null; + /** The entry's own name, which safe-model writes also send. */ + name?: string | null; } export interface BuilderCmsEntryFidelitySummary { @@ -474,6 +476,7 @@ function liveStateFromBuilderEntry( id, values: liveSourceValues(record, model), hasAutosaves: builderEntryHasAutosaves(record), + name: stringFromUnknown(record.name), }; } diff --git a/templates/content/actions/_builder-cms-source-adapter.test.ts b/templates/content/actions/_builder-cms-source-adapter.test.ts index af89dbece77..c61b2fa4370 100644 --- a/templates/content/actions/_builder-cms-source-adapter.test.ts +++ b/templates/content/actions/_builder-cms-source-adapter.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import type { ContentDatabaseItem } from "../shared/api"; import { + BUILDER_CMS_BODY_BLOCK_COUNT_KEY, + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, BUILDER_CMS_HAS_AUTOSAVES_KEY, buildBuilderCmsFixtureEntry, builderCmsQualifiedId, @@ -223,6 +225,24 @@ describe("Builder CMS source adapter", () => { }); }); + it("tells an empty body Builder returned apart from a read without the body", () => { + const entry = (data: Record) => + normalizeBuilderCmsApiEntry( + { id: "entry-1", published: "published", lastUpdated: 1, data }, + "blog_article", + )?.sourceValues; + + expect(entry({ title: "Title", blocks: [] })).toMatchObject({ + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 0, + }); + expect(entry({ title: "Title", blocks: [] })).not.toHaveProperty( + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + ); + expect(entry({ title: "Title" })).not.toHaveProperty( + BUILDER_CMS_BODY_BLOCK_COUNT_KEY, + ); + }); + it("records whether Builder reports unpublished changes on an entry", () => { const entry = (meta: Record) => normalizeBuilderCmsApiEntry( diff --git a/templates/content/actions/_builder-cms-source-adapter.ts b/templates/content/actions/_builder-cms-source-adapter.ts index eeb87d2ca35..0a6cf505d51 100644 --- a/templates/content/actions/_builder-cms-source-adapter.ts +++ b/templates/content/actions/_builder-cms-source-adapter.ts @@ -368,6 +368,9 @@ function builderSourceValuesFromRecord(args: { if (blocks.length > 0) { values[BUILDER_CMS_BODY_BLOCKS_HASH_KEY] = builderBlocksHash(blocks); values[BUILDER_CMS_BODY_BLOCK_COUNT_KEY] = blocks.length; + } else if (Array.isArray(args.data.blocks)) { + // An empty body Builder returned, unlike a read that left the body out. + values[BUILDER_CMS_BODY_BLOCK_COUNT_KEY] = 0; } return values; } diff --git a/templates/content/actions/_builder-cms-unpublished-changes.test.ts b/templates/content/actions/_builder-cms-unpublished-changes.test.ts index 9b93c86abe7..60325ce5f88 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.test.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { + BUILDER_CMS_BODY_BLOCK_COUNT_KEY, BUILDER_CMS_BODY_BLOCKS_HASH_KEY, BUILDER_CMS_BODY_CONTENT_KEY, BUILDER_CMS_HAS_AUTOSAVES_KEY, @@ -18,8 +19,9 @@ function saved( values: Record, changes: { liveValues: Record; - liveBlocksHash?: string; liveLastUpdated?: number; + liveBlocksHash?: string; + liveBodyLastUpdated?: number; }, ) { return withBuilderCmsUnpublishedChanges(values, changes); @@ -198,23 +200,79 @@ describe("Builder unpublished changes", () => { expect(next).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); }); - it("drops the record when Builder says the entry has no unpublished changes", () => { + it("drops the record when a read made after the save says nothing is pending", () => { const existing = saved( { published: "published", "data.title": "Saved title" }, - { liveValues: { "data.title": "Live title" } }, + { liveValues: { "data.title": "Live title" }, liveLastUpdated: 1000 }, + ); + const read = (lastUpdated: number) => + builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + lastUpdated, + "data.title": "Live title", + [BUILDER_CMS_HAS_AUTOSAVES_KEY]: false, + }, + existing, + }); + + const afterSave = read(2000); + expect(afterSave["data.title"]).toBe("Live title"); + expect(afterSave).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + // An autosave leaves lastUpdated alone, so a read with the save's own + // time may be from just before it. + expect(read(1000)["data.title"]).toBe("Saved title"); + }); + + it("keeps the record through a read made before Content's latest save", () => { + const existing = saved( + { published: "published", "data.title": "Second save" }, + { + liveValues: { "data.title": "First save, published in Builder" }, + liveLastUpdated: 2000, + }, ); const next = builderSourceValuesKeepingUnpublishedChanges({ incoming: { + published: "draft", + lastUpdated: 1000, + "data.title": "Title before the first save", + [BUILDER_CMS_HAS_AUTOSAVES_KEY]: false, + }, + existing, + }); + + expect(next["data.title"]).toBe("Second save"); + expect(readBuilderCmsUnpublishedChanges(next)).toEqual( + readBuilderCmsUnpublishedChanges(existing), + ); + }); + + it("reads the entry's status from Builder's own values, not the row's", () => { + const existing = saved( + { published: "published", - "data.title": "Live title", + "data.title": "Saved title", [BUILDER_CMS_HAS_AUTOSAVES_KEY]: false, }, + { liveValues: { "data.title": "Live title" }, liveLastUpdated: 1000 }, + ); + const builderValues = { + published: "published", + lastUpdated: 2000, + "data.title": "Live title", + }; + + const next = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { ...existing, ...builderValues }, existing, + incomingFilledFromRow: true, + builderValues, }); - expect(next["data.title"]).toBe("Live title"); - expect(next).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + expect(next["data.title"]).toBe("Saved title"); + expect(next).toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); }); it("keeps the record when Builder does not report whether changes are pending", () => { @@ -306,7 +364,11 @@ describe("Builder unpublished changes", () => { [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved body", [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-hash", }, - { liveValues: {}, liveBlocksHash: "live-hash", liveLastUpdated: 1000 }, + { + liveValues: {}, + liveBlocksHash: "live-hash", + liveBodyLastUpdated: 1000, + }, ); const read = (lastUpdated: number) => builderSourceValuesKeepingUnpublishedChanges({ @@ -336,7 +398,7 @@ describe("Builder unpublished changes", () => { const next = builderSourceValuesKeepingUnpublishedChanges({ incoming: existing, existing, - builderBodyValues: { + builderValues: { published: "published", [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "live-hash", }, @@ -363,8 +425,9 @@ describe("Builder unpublished changes", () => { }); expect(first).toEqual({ liveValues: { "data.title": "Live title", "data.blurb": null }, - liveBlocksHash: "live-hash", liveLastUpdated: 1000, + liveBlocksHash: "live-hash", + liveBodyLastUpdated: 1000, }); const second = builderCmsUnpublishedChangesAfterWrite({ @@ -386,8 +449,9 @@ describe("Builder unpublished changes", () => { "data.title": "First save, published in Builder", "data.blurb": null, }, + liveLastUpdated: 2000, liveBlocksHash: "live-hash", - liveLastUpdated: 1000, + liveBodyLastUpdated: 1000, }); }); @@ -439,6 +503,7 @@ describe("Builder unpublished changes", () => { "data.author": "Sam Lee", "__agent_native_builder_reference_id:data.author": "author-1", }, + liveLastUpdated: 1000, }); }); @@ -450,7 +515,11 @@ describe("Builder unpublished changes", () => { wroteBody: true, live: { values: {}, blocksHash: null, lastUpdated: 1000 }, }); - expect(changes).toEqual({ liveValues: {}, liveLastUpdated: 1000 }); + expect(changes).toEqual({ + liveValues: {}, + liveLastUpdated: 1000, + liveBodyLastUpdated: 1000, + }); const existing = withBuilderCmsUnpublishedChanges( { published: "published", @@ -479,6 +548,42 @@ describe("Builder unpublished changes", () => { expect(published).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); }); + it("lets an empty body Builder published win over a saved body", () => { + const existing = saved( + { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-hash", + }, + { + liveValues: {}, + liveLastUpdated: 1000, + liveBlocksHash: "live-hash", + liveBodyLastUpdated: 1000, + }, + ); + const emptyRead = (lastUpdated: number) => + builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + lastUpdated, + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 0, + }, + existing, + }); + + const emptied = emptyRead(2000); + expect(emptied).not.toHaveProperty(BUILDER_CMS_BODY_CONTENT_KEY); + expect(emptied).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + // A read without the body at all still keeps it. + expect( + builderSourceValuesKeepingUnpublishedChanges({ + incoming: { published: "published", lastUpdated: 2000 }, + existing, + })[BUILDER_CMS_BODY_CONTENT_KEY], + ).toBe("Saved body"); + }); + it("refuses to record an autosave without the live values", () => { expect(() => builderCmsUnpublishedChangesAfterWrite({ @@ -496,8 +601,9 @@ describe("Builder unpublished changes", () => { { published: "published" }, { liveValues: { "data.title": "Live title", "data.blurb": "Live blurb" }, + liveLastUpdated: 900, liveBlocksHash: "live-hash", - liveLastUpdated: 1000, + liveBodyLastUpdated: 1000, }, ); @@ -509,7 +615,10 @@ describe("Builder unpublished changes", () => { wroteBody: true, live: { values: null, blocksHash: null, lastUpdated: null }, }), - ).toEqual({ liveValues: { "data.blurb": "Live blurb" } }); + ).toEqual({ + liveValues: { "data.blurb": "Live blurb" }, + liveLastUpdated: 900, + }); for (const effect of ["publish", "unpublish"] as const) { expect( builderCmsUnpublishedChangesAfterWrite({ diff --git a/templates/content/actions/_builder-cms-unpublished-changes.ts b/templates/content/actions/_builder-cms-unpublished-changes.ts index 7369a550aca..523485a1003 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.ts @@ -3,6 +3,7 @@ import type { DocumentPropertyValue, } from "../shared/api.js"; import { + BUILDER_CMS_BODY_BLOCK_COUNT_KEY, BUILDER_CMS_BODY_BLOCKS_HASH_KEY, BUILDER_CMS_BODY_CONTENT_KEY, BUILDER_CMS_BODY_LAST_UPDATED_KEY, @@ -30,10 +31,18 @@ export interface BuilderCmsUnpublishedChanges { * with a reference field's id under its reference-id key. */ liveValues: Record; - /** The live body's blocks hash when Content last saved a body over it. */ + /** + * Builder's lastUpdated at the live check before Content's latest save. A + * read older than this was made before that save. + */ + liveLastUpdated?: number | string; + /** + * The live body's blocks hash when Content last saved a body over it; + * absent when that live body was empty. + */ liveBlocksHash?: string; /** Builder's lastUpdated when Content last saved a body over it. */ - liveLastUpdated?: number | string; + liveBodyLastUpdated?: number | string; } export type BuilderCmsUnpublishedChangesRead = @@ -71,6 +80,10 @@ function epochMs(value: unknown): number | null { return Number.isFinite(parsed) ? parsed : null; } +function isTimestamp(value: unknown): value is number | string { + return typeof value === "string" || typeof value === "number"; +} + export function builderCmsStableValue(value: unknown): string { if (value === null || value === undefined) return "null"; if (Array.isArray(value)) { @@ -89,9 +102,10 @@ export function builderCmsStableValue(value: unknown): string { } /** - * Whether `values` holds the same value for a source field as `expected`. A - * reference compares by id: its stored value is a display label that two - * references can share, and a saved row holds the reference object instead. + * Whether `values` holds the same value for a source field as `expected`. + * When both carry a reference id, the ids are compared: a stored reference + * value is a display label two references can share, and a saved row holds + * the reference object instead. A read without an id compares the values. */ export function builderCmsSourceFieldMatches(args: { key: string; @@ -100,7 +114,7 @@ export function builderCmsSourceFieldMatches(args: { }): boolean { const referenceKey = builderCmsReferenceIdKey(args.key); const compared = - hasKey(args.values, referenceKey) || hasKey(args.expected, referenceKey) + hasKey(args.values, referenceKey) && hasKey(args.expected, referenceKey) ? referenceKey : args.key; return ( @@ -133,15 +147,13 @@ export function readBuilderCmsUnpublishedChanges( ) { return { state: "invalid" }; } - const liveBlocksHash = record.liveBlocksHash; + const { liveBlocksHash, liveLastUpdated, liveBodyLastUpdated } = record; if (liveBlocksHash !== undefined && typeof liveBlocksHash !== "string") { return { state: "invalid" }; } - const liveLastUpdated = record.liveLastUpdated; if ( - liveLastUpdated !== undefined && - typeof liveLastUpdated !== "string" && - typeof liveLastUpdated !== "number" + (liveLastUpdated !== undefined && !isTimestamp(liveLastUpdated)) || + (liveBodyLastUpdated !== undefined && !isTimestamp(liveBodyLastUpdated)) ) { return { state: "invalid" }; } @@ -149,14 +161,15 @@ export function readBuilderCmsUnpublishedChanges( state: "pending", changes: { liveValues: liveValues as Record, - ...(liveBlocksHash ? { liveBlocksHash } : {}), ...(liveLastUpdated !== undefined ? { liveLastUpdated } : {}), + ...(liveBlocksHash ? { liveBlocksHash } : {}), + ...(liveBodyLastUpdated !== undefined ? { liveBodyLastUpdated } : {}), }, }; } function hasBodyRecord(changes: BuilderCmsUnpublishedChanges) { - return !!changes.liveBlocksHash || changes.liveLastUpdated !== undefined; + return !!changes.liveBlocksHash || changes.liveBodyLastUpdated !== undefined; } export function withBuilderCmsUnpublishedChanges( @@ -198,17 +211,20 @@ export function builderCmsEntryStatus( */ export function builderCmsBodyStillSavedOver( changes: BuilderCmsUnpublishedChanges, - incoming: Record, + builderValues: Record, ): boolean { if (!hasBodyRecord(changes)) return false; - const incomingHash = incoming[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]; - if (incomingHash === undefined || incomingHash === null) return true; - if (changes.liveBlocksHash && incomingHash === changes.liveBlocksHash) { + const hash = builderValues[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]; + const readHash = typeof hash === "string" && hash ? hash : null; + // A read without the body says nothing about it; an empty one does. + if (!readHash && builderValues[BUILDER_CMS_BODY_BLOCK_COUNT_KEY] !== 0) { return true; } - const recordedEpoch = epochMs(changes.liveLastUpdated); + if (readHash === (changes.liveBlocksHash ?? null)) return true; + const recordedEpoch = epochMs(changes.liveBodyLastUpdated); return ( - recordedEpoch !== null && epochMs(incoming.lastUpdated) === recordedEpoch + recordedEpoch !== null && + epochMs(builderValues.lastUpdated) === recordedEpoch ); } @@ -216,8 +232,8 @@ export function builderCmsBodyStillSavedOver( * The baseline a refresh or body read should keep. A field keeps Content's * saved value while Builder still reports the live value it was saved over; * once Builder publishes, discards, or edits that field, Builder's value wins. - * A field Builder did not return says nothing about a change, so it keeps - * Content's value too. + * A field Builder did not return says nothing about a change, and neither + * does a read made before Content's latest save, so both keep Content's value. */ export function builderSourceValuesKeepingUnpublishedChanges(args: { incoming: Record; @@ -228,10 +244,11 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { */ incomingFilledFromRow?: boolean; /** - * Builder's own values for the body check, when `incoming` has had the - * stored body put back: that body's hash never matches the live one. + * Builder's own values, when `incoming` has the row's stored values mixed + * in. The entry's status, update time, and body are read from these: the + * row's copies are from before Content's save. */ - builderBodyValues?: Record; + builderValues?: Record; }): Record { const read = readBuilderCmsUnpublishedChanges(args.existing); if (read.state === "invalid") { @@ -242,14 +259,30 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { if (read.state !== "pending" || !args.existing) { return withBuilderCmsUnpublishedChanges(args.incoming, null); } + const existing = args.existing; + const recorded = read.changes; + const builder = args.builderValues ?? args.incoming; + const readEpoch = epochMs(builder.lastUpdated); + const checkedEpoch = epochMs(recorded.liveLastUpdated); + const readPredatesSave = + readEpoch !== null && checkedEpoch !== null && readEpoch < checkedEpoch; + // Builder's word that nothing is pending only counts from a read made after + // Content's save: an autosave leaves lastUpdated alone, so a read from just + // before the save carries the same time and an outdated answer. + const builderDiscardedChanges = + builder[BUILDER_CMS_HAS_AUTOSAVES_KEY] === false && + readEpoch !== null && + checkedEpoch !== null && + readEpoch > checkedEpoch; + const published = hasKey(builder, "published") + ? builder.published + : args.incoming.published; if ( - args.incoming.published !== "published" || - args.incoming[BUILDER_CMS_HAS_AUTOSAVES_KEY] === false + !readPredatesSave && + (published !== "published" || builderDiscardedChanges) ) { return withBuilderCmsUnpublishedChanges(args.incoming, null); } - const existing = args.existing; - const recorded = read.changes; const next = { ...args.incoming }; const liveValues: Record = {}; for (const key of Object.keys(recorded.liveValues)) { @@ -258,6 +291,7 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { const builderReturned = hasKey(args.incoming, key) || hasKey(args.incoming, referenceKey); const builderStillLive = + readPredatesSave || !builderReturned || builderCmsSourceFieldMatches({ key, @@ -278,10 +312,9 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { } } } - const bodyKept = builderCmsBodyStillSavedOver( - recorded, - args.builderBodyValues ?? args.incoming, - ); + const bodyKept = readPredatesSave + ? hasBodyRecord(recorded) + : builderCmsBodyStillSavedOver(recorded, builder); if (bodyKept) { for (const key of BODY_BASELINE_KEYS) { if (existing[key] !== undefined) next[key] = existing[key]; @@ -289,11 +322,14 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { } return withBuilderCmsUnpublishedChanges(next, { liveValues, + ...(recorded.liveLastUpdated !== undefined + ? { liveLastUpdated: recorded.liveLastUpdated } + : {}), ...(bodyKept && recorded.liveBlocksHash ? { liveBlocksHash: recorded.liveBlocksHash } : {}), - ...(bodyKept && recorded.liveLastUpdated !== undefined - ? { liveLastUpdated: recorded.liveLastUpdated } + ...(bodyKept && recorded.liveBodyLastUpdated !== undefined + ? { liveBodyLastUpdated: recorded.liveBodyLastUpdated } : {}), }); } @@ -320,8 +356,9 @@ export function builderCmsUnpublishedChangesAfterWrite(args: { const current: BuilderCmsUnpublishedChanges = read.state === "pending" ? read.changes : { liveValues: {} }; const liveValues = { ...current.liveValues }; - let liveBlocksHash = current.liveBlocksHash; let liveLastUpdated = current.liveLastUpdated; + let liveBlocksHash = current.liveBlocksHash; + let liveBodyLastUpdated = current.liveBodyLastUpdated; if (args.effect === "autosave") { const live = args.live.values; if (!live) { @@ -342,9 +379,10 @@ export function builderCmsUnpublishedChangesAfterWrite(args: { delete liveValues[referenceKey]; } } + liveLastUpdated = args.live.lastUpdated ?? undefined; if (args.wroteBody) { liveBlocksHash = args.live.blocksHash ?? undefined; - liveLastUpdated = args.live.lastUpdated ?? undefined; + liveBodyLastUpdated = args.live.lastUpdated ?? undefined; } } else { for (const key of args.writtenFieldKeys) { @@ -353,12 +391,13 @@ export function builderCmsUnpublishedChangesAfterWrite(args: { } if (args.wroteBody) { liveBlocksHash = undefined; - liveLastUpdated = undefined; + liveBodyLastUpdated = undefined; } } return { liveValues, - ...(liveBlocksHash ? { liveBlocksHash } : {}), ...(liveLastUpdated !== undefined ? { liveLastUpdated } : {}), + ...(liveBlocksHash ? { liveBlocksHash } : {}), + ...(liveBodyLastUpdated !== undefined ? { liveBodyLastUpdated } : {}), }; } diff --git a/templates/content/actions/_builder-cms-write-adapter.ts b/templates/content/actions/_builder-cms-write-adapter.ts index 09ec43fd7ad..c82b291f66a 100644 --- a/templates/content/actions/_builder-cms-write-adapter.ts +++ b/templates/content/actions/_builder-cms-write-adapter.ts @@ -89,6 +89,7 @@ export function builderCmsWrittenSourceFields( .filter((name) => name !== "blocks") .map((name) => `data.${name}`), wroteBody: record.blocks !== undefined, + wroteName: typeof request.body.name === "string", }; } diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index 40c58d95afd..b64eb37a147 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -3037,22 +3037,94 @@ describe("database source helpers", () => { }; const kept = builderBodyReadSourceValues(rowValues, { - published: "published", - "data.title": "Live title", - [BUILDER_CMS_BODY_CONTENT_KEY]: "Live body", + sourceValues: { + published: "published", + "data.title": "Live title", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Live body", + }, }); expect(kept["data.title"]).toBe("Saved title"); expect(kept[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Live body"); expect(kept).toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); const published = builderBodyReadSourceValues(rowValues, { - published: "published", - "data.title": "Published title", + sourceValues: { published: "published", "data.title": "Published title" }, }); expect(published["data.title"]).toBe("Published title"); expect(published).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); }); + it("keeps a newer save through a queued body read from before it", () => { + const rowValues = { + published: "published", + lastUpdated: "2000", + "data.title": "Second save", + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: JSON.stringify({ + liveValues: { "data.title": "First save, published in Builder" }, + liveLastUpdated: 2000, + }), + }; + + const next = builderBodyReadSourceValues(rowValues, { + sourceValues: { + published: "published", + lastUpdated: "1000", + "data.title": "Title before the first save", + }, + }); + + expect(next["data.title"]).toBe("Second save"); + expect(next).toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + + it("lets a reference Builder changed win through a body read", () => { + const authorId = "__agent_native_builder_reference_id:data.author"; + const rowValues = { + published: "published", + "data.author": { + "@type": "@builder.io/core:Reference", + id: "author-b", + model: "blog-author", + }, + [authorId]: "author-b", + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: JSON.stringify({ + liveValues: { "data.author": "Author A", [authorId]: "author-a" }, + }), + }; + + const labelOnly = builderBodyReadSourceValues(rowValues, { + sourceValues: { published: "published", "data.author": "Author C" }, + }); + expect(labelOnly["data.author"]).toBe("Author C"); + expect(labelOnly).not.toHaveProperty(authorId); + expect(labelOnly).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + + const withRawEntry = builderBodyReadSourceValues(rowValues, { + sourceValues: { published: "published", "data.author": "Author A" }, + rawEntry: { + id: "entry-1", + model: "blog_article", + data: { + author: { + "@type": "@builder.io/core:Reference", + id: "author-c", + model: "blog-author", + }, + }, + }, + }); + expect(withRawEntry[authorId]).toBe("author-c"); + expect(withRawEntry).not.toHaveProperty( + BUILDER_CMS_UNPUBLISHED_CHANGES_KEY, + ); + + const unchanged = builderBodyReadSourceValues(rowValues, { + sourceValues: { published: "published", "data.author": "Author A" }, + }); + expect(unchanged[authorId]).toBe("author-b"); + expect(unchanged).toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + it("keeps a saved unpublished body through a metadata refresh that puts the stored body back", () => { const refreshed = sourceValuesForSeededSourceRow({ sourceType: "builder-cms", diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 575c5531f50..a2fb901240d 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -2006,13 +2006,26 @@ async function enqueueEmptyHydratedBuilderBodiesFromStoredRows(args: { */ export function builderBodyReadSourceValues( rowValues: Record, - builderValues: Record, + entry: Pick, ) { + const builderValues = { + ...entry.sourceValues, + ...builderCmsReferenceIdSourceValues(entry.rawEntry?.data), + }; + const rowBase = { ...rowValues }; + // The row's reference id beside a value Builder returned would pair that + // value with an id Builder may have replaced. + for (const key of Object.keys(builderValues)) { + const referenceKey = builderCmsReferenceIdKey(key); + if (!Object.prototype.hasOwnProperty.call(builderValues, referenceKey)) { + delete rowBase[referenceKey]; + } + } return builderSourceValuesKeepingUnpublishedChanges({ - incoming: { ...rowValues, ...builderValues }, + incoming: { ...rowBase, ...builderValues }, existing: rowValues, incomingFilledFromRow: true, - builderBodyValues: builderValues, + builderValues, }); } @@ -2158,10 +2171,7 @@ async function processBuilderBodyHydrationJob( "Builder returned inconsistent body variants for the same lastUpdated value; the stored baseline and local document were preserved for retry.", ); } - let nextValues = builderBodyReadSourceValues( - sourceValues, - entryWithBody.sourceValues, - ); + let nextValues = builderBodyReadSourceValues(sourceValues, entryWithBody); let nextContent = stringSourceValue(nextValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; let emptyBodyRead: Extract< @@ -2226,10 +2236,7 @@ async function processBuilderBodyHydrationJob( }); if (liveRead.state === "body") { const liveEntry = liveRead.entry; - const liveValues = builderBodyReadSourceValues( - sourceValues, - liveEntry.sourceValues, - ); + const liveValues = builderBodyReadSourceValues(sourceValues, liveEntry); const liveContent = stringSourceValue(liveValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; if (liveContent.trim()) { @@ -2301,10 +2308,7 @@ async function processBuilderBodyHydrationJob( .update(schema.contentDatabaseSourceRows) .set({ sourceValuesJson: JSON.stringify( - builderBodyReadSourceValues( - sourceValues, - emptyBodyRead.entry.sourceValues, - ), + builderBodyReadSourceValues(sourceValues, emptyBodyRead.entry), ), lastSyncedAt: now, lastSourceUpdatedAt: emptyBodyRead.entry.updatedAt ?? now, @@ -2693,10 +2697,7 @@ async function preparePristineBuilderBodyHydration(args: { }, }), ); - const nextValues = builderBodyReadSourceValues( - sourceValues, - entryWithBody.sourceValues, - ); + const nextValues = builderBodyReadSourceValues(sourceValues, entryWithBody); const content = stringSourceValue(nextValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; if (!content.trim()) return null; @@ -6040,7 +6041,7 @@ export function sourceValuesForSeededSourceRow(args: { return builderSourceValuesKeepingUnpublishedChanges({ incoming: values, existing: existingSourceValues, - builderBodyValues: args.builderEntry.sourceValues, + builderValues: args.builderEntry.sourceValues, }); } if (existingSourceValues) return existingSourceValues; diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 7ae80988a08..f9104b32e79 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -2123,7 +2123,7 @@ describe("Builder unpublished saves", () => { const deps = depsFor({ source: builderSource, execution, - readLiveEntry: args.readLiveEntry, + readLiveEntry: { name: "Old title", ...args.readLiveEntry }, writeResult: args.writeResult, }); const run = () => @@ -2217,6 +2217,42 @@ describe("Builder unpublished saves", () => { expect(deps.executeWrite).not.toHaveBeenCalled(); }); + it("blocks a save that would overwrite an entry name Builder changed", async () => { + const { deps, run } = unpublishedSave({ + rows: [ + publishedRow({ + lastSourceUpdatedAt: String(STALE_BUILDER_LAST_UPDATED_MS), + }), + ], + changeSet: changeSet({ + fieldChanges: [ + { + propertyId: null, + propertyName: "Blurb", + localFieldKey: "blurb", + sourceFieldKey: "data.blurb", + currentValue: "Old blurb", + proposedValue: "New blurb", + }, + ], + }), + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + name: "Renamed in Builder", + values: { "data.title": "Renamed in Builder" }, + }, + }); + + await expect(run()).rejects.toThrow( + "Entry name changed in Builder since you edited it; refresh and re-review.", + ); + expect(deps.executeWrite).not.toHaveBeenCalled(); + }); + it("treats the live value Content saved over as the value this edit was made against", async () => { const { deps, run } = unpublishedSave({ rows: [ @@ -2468,6 +2504,7 @@ describe("Builder unpublished saves", () => { expect(values["data.title"]).toBe("New title"); expect(JSON.parse(values[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY])).toEqual({ liveValues: { "data.title": "Old title" }, + liveLastUpdated: BUILDER_LAST_UPDATED_MS, }); }); @@ -2561,6 +2598,61 @@ describe("Builder unpublished saves", () => { ); }); + it("says a recovered draft save went live when Builder's stored response says so", async () => { + const approvedChangeSet = changeSet(); + const builderSource = source({ + rows: [ + row({ + sourceValues: { published: "draft", "data.title": "Old title" }, + }), + ], + changeSets: [approvedChangeSet], + }); + const plan = buildBuilderCmsExecutionPlan({ + source: builderSource, + changeSet: approvedChangeSet, + pushModeConfirmation: "autosave", + }); + expect(plan.payload.effect).toBe("save_draft"); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + state: "failed", + payloadJson: JSON.stringify({ + ...plan.payload, + response: { + ok: true, + status: 200, + entryId: "builder-entry-1", + body: { id: "builder-entry-1", published: "published" }, + }, + }), + }); + const deps = depsFor({ source: builderSource, execution }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "autosave", + }, + deps, + ), + ).resolves.toMatchObject(RESPONSE); + } finally { + warn.mockRestore(); + } + + expect(deps.executeWrite).not.toHaveBeenCalled(); + expect(deps.markExecutionSucceeded).toHaveBeenCalledWith( + expect.objectContaining({ + summary: expect.stringContaining("this change is live"), + }), + ); + }); + it("leaves the record alone for a stored response from before live values were kept", () => { const approvedChangeSet = changeSet(); const builderSource = source({ diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index 4b7e74f4bfb..5645a008e23 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -568,6 +568,8 @@ function livePreflightBlockMessage(args: { baselineLastUpdated: string | null; baselineBlocksHash: string | null; baselineValues: Record | undefined; + /** The entry name Content had when the change was reviewed. */ + baselineName: string | null; effect: BuilderCmsExecutionPayload["effect"]; request: BuilderCmsExecutionPayload["request"]; fieldLabel: (sourceFieldKey: string) => string; @@ -637,11 +639,20 @@ function livePreflightBlockMessage(args: { if (!fieldLevel || !liveValues || bodyUnverifiable) { return "Builder entry changed since this diff was approved; refresh and re-review."; } - const changedFields = written.fieldKeys.filter( - (key) => !fieldStillReviewed(key, liveValues), - ); + const changedFields = written.fieldKeys + .filter((key) => !fieldStillReviewed(key, liveValues)) + .map(args.fieldLabel); + // A safe-model write also sends the entry name, taken from the title + // Content has, so it would overwrite a name Builder changed since. + const liveName = args.liveState.name?.trim() || null; + if ( + written.wroteName && + (!liveName || liveName !== args.baselineName?.trim()) + ) { + changedFields.push("Entry name"); + } if (changedFields.length > 0) { - return `${changedFields.map(args.fieldLabel).join(", ")} changed in Builder since you edited it; refresh and re-review.`; + return `${changedFields.join(", ")} changed in Builder since you edited it; refresh and re-review.`; } } if ( @@ -712,6 +723,30 @@ function builderCmsLivePreflightRecord(args: { }; } +/** + * A draft save sends no published field, so an entry published after the + * live check takes the change live; the summary says so rather than "saved". + */ +function builderExecutionSucceededSummary(args: { + pushMode: string; + effect: BuilderCmsExecutionPayload["effect"]; + writeResult: BuilderCmsWriteResult; + executionId: string; + changeSetId: string; +}) { + const draftSaveWentLive = + args.effect === "save_draft" && + builderCmsResponseValue(args.writeResult.responseBody, ["published"]) === + "published"; + if (!draftSaveWentLive) + return `Builder ${args.pushMode} execution succeeded.`; + console.warn("builder_source_draft_save_went_live", { + executionId: args.executionId, + changeSetId: args.changeSetId, + }); + return `Builder ${args.pushMode} execution succeeded. Builder published this entry while Content was saving, so this change is live.`; +} + function builderSourceFieldLabel(source: ContentDatabaseSource) { return (sourceFieldKey: string) => { const name = sourceFieldKey.replace(/^data\./, ""); @@ -1934,7 +1969,13 @@ export async function executeBuilderSourceExecutionWithDeps( await deps.markExecutionSucceeded({ executionId: execution.id, changeSetId: changeSet.id, - summary: `Builder ${plan.pushMode} execution succeeded.`, + summary: builderExecutionSucceededSummary({ + pushMode: plan.pushMode, + effect: writtenPayload.effect, + writeResult: storedWriteResult, + executionId: execution.id, + changeSetId: changeSet.id, + }), payload: payloadWithResponse, now: reconciledAt, }); @@ -2072,6 +2113,8 @@ export async function executeBuilderSourceExecutionWithDeps( baselineLastUpdated, baselineBlocksHash: changeSet.bodyChange?.currentHash ?? null, baselineValues: sourceRowForChangeSet(source, changeSet)?.sourceValues, + baselineName: + sourceRowForChangeSet(source, changeSet)?.sourceDisplayKey ?? null, effect: plan.payload.effect, request: plan.payload.request, fieldLabel: builderSourceFieldLabel(source), @@ -2217,24 +2260,16 @@ export async function executeBuilderSourceExecutionWithDeps( }); throw builderExecutionConflict(lastError); } - // A draft save sends no published field, so an entry published after - // the live check takes this change live; say so rather than "saved". - const draftSaveWentLive = - writePayload.effect === "save_draft" && - builderCmsResponseValue(writeResult.responseBody, ["published"]) === - "published"; - if (draftSaveWentLive) { - console.warn("builder_source_draft_save_went_live", { - executionId: execution.id, - changeSetId: changeSet.id, - }); - } await deps.markExecutionSucceeded({ executionId: execution.id, changeSetId: changeSet.id, - summary: draftSaveWentLive - ? `Builder ${plan.pushMode} execution succeeded. Builder published this entry while Content was saving, so this change is live.` - : `Builder ${plan.pushMode} execution succeeded.`, + summary: builderExecutionSucceededSummary({ + pushMode: plan.pushMode, + effect: writePayload.effect, + writeResult, + executionId: execution.id, + changeSetId: changeSet.id, + }), payload: payloadWithResponse, now: succeededAt, attemptToken, From 3b76eb30c9bf30e5d399db530813e11a133df9f9 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:53:43 -0400 Subject: [PATCH 05/20] Keep a saved empty body, let an empty Builder body win, and check Builder's own entry name --- .../actions/_builder-cms-read-client.ts | 3 - .../_builder-cms-source-adapter.test.ts | 2 + .../actions/_builder-cms-source-adapter.ts | 25 +++ .../_builder-cms-unpublished-changes.test.ts | 32 ++- .../_builder-cms-unpublished-changes.ts | 20 +- .../actions/_builder-cms-write-adapter.ts | 18 +- .../actions/_database-source-utils.test.ts | 52 +++++ .../content/actions/_database-source-utils.ts | 37 ++-- .../execute-builder-source-execution.test.ts | 168 ++++++++++++++- .../execute-builder-source-execution.ts | 34 ++- .../resync-content-database-source.db.test.ts | 204 +++++++++++++++++- 11 files changed, 524 insertions(+), 71 deletions(-) diff --git a/templates/content/actions/_builder-cms-read-client.ts b/templates/content/actions/_builder-cms-read-client.ts index 51b532de8fe..23eaad508dc 100644 --- a/templates/content/actions/_builder-cms-read-client.ts +++ b/templates/content/actions/_builder-cms-read-client.ts @@ -59,8 +59,6 @@ export interface BuilderCmsEntryLiveState { /** The entry's source values, normalized as a refresh stores them. */ values?: Record | null; hasAutosaves?: boolean | null; - /** The entry's own name, which safe-model writes also send. */ - name?: string | null; } export interface BuilderCmsEntryFidelitySummary { @@ -476,7 +474,6 @@ function liveStateFromBuilderEntry( id, values: liveSourceValues(record, model), hasAutosaves: builderEntryHasAutosaves(record), - name: stringFromUnknown(record.name), }; } diff --git a/templates/content/actions/_builder-cms-source-adapter.test.ts b/templates/content/actions/_builder-cms-source-adapter.test.ts index c61b2fa4370..51611431c95 100644 --- a/templates/content/actions/_builder-cms-source-adapter.test.ts +++ b/templates/content/actions/_builder-cms-source-adapter.test.ts @@ -4,6 +4,7 @@ import type { ContentDatabaseItem } from "../shared/api"; import { BUILDER_CMS_BODY_BLOCK_COUNT_KEY, BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + BUILDER_CMS_ENTRY_NAME_KEY, BUILDER_CMS_HAS_AUTOSAVES_KEY, buildBuilderCmsFixtureEntry, builderCmsQualifiedId, @@ -221,6 +222,7 @@ describe("Builder CMS source adapter", () => { "data.handle": "builder-api-title", "data.description": "A useful field", lastUpdated: "2026-06-08T12:00:00.000Z", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Fallback name", }, }); }); diff --git a/templates/content/actions/_builder-cms-source-adapter.ts b/templates/content/actions/_builder-cms-source-adapter.ts index 0a6cf505d51..7f021ad99cb 100644 --- a/templates/content/actions/_builder-cms-source-adapter.ts +++ b/templates/content/actions/_builder-cms-source-adapter.ts @@ -19,11 +19,33 @@ export const BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY = export const BUILDER_CMS_BODY_READABLE_MAP_KEY = "__builder.body.readableMapJson"; export const BUILDER_CMS_BODY_BLOCK_COUNT_KEY = "__builder.body.blockCount"; +/** The source values that together describe one entry body. */ +export const BUILDER_CMS_BODY_BASELINE_KEYS = [ + BUILDER_CMS_BODY_CONTENT_KEY, + BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY, + BUILDER_CMS_BODY_READABLE_MAP_KEY, + BUILDER_CMS_BODY_SIDECARS_KEY, + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + BUILDER_CMS_BODY_LAST_UPDATED_KEY, + BUILDER_CMS_BODY_BLOCK_COUNT_KEY, +] as const; /** Builder's `meta.hasAutosaves`: a published entry has unpublished changes. */ export const BUILDER_CMS_HAS_AUTOSAVES_KEY = "__builder.meta.hasAutosaves"; +/** The entry's own name, which Builder keeps apart from its title. */ +export const BUILDER_CMS_ENTRY_NAME_KEY = "__builder.entry.name"; export const BUILDER_CMS_REFERENCE_ID_KEY_PREFIX = "__agent_native_builder_reference_id:"; +/** + * Builder returned, or Content wrote, a body with no blocks. Hydration treats + * an empty document as a body still to fetch; this one is not. + */ +export function builderCmsBodyKnownEmpty( + values: Record | null | undefined, +) { + return values?.[BUILDER_CMS_BODY_BLOCK_COUNT_KEY] === 0; +} + export function builderCmsReferenceIdKey(sourceFieldKey: string) { return `${BUILDER_CMS_REFERENCE_ID_KEY_PREFIX}${sourceFieldKey}`; } @@ -360,6 +382,9 @@ function builderSourceValuesFromRecord(args: { values[key] = sourceValueFromUnknown(args.record[key]); } } + if (typeof args.record.name === "string") { + values[BUILDER_CMS_ENTRY_NAME_KEY] = args.record.name; + } const hasAutosaves = builderEntryHasAutosaves(args.record); if (hasAutosaves !== null) { values[BUILDER_CMS_HAS_AUTOSAVES_KEY] = hasAutosaves; diff --git a/templates/content/actions/_builder-cms-unpublished-changes.test.ts b/templates/content/actions/_builder-cms-unpublished-changes.test.ts index 60325ce5f88..28b17c4c8d4 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.test.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.test.ts @@ -4,6 +4,7 @@ import { BUILDER_CMS_BODY_BLOCK_COUNT_KEY, BUILDER_CMS_BODY_BLOCKS_HASH_KEY, BUILDER_CMS_BODY_CONTENT_KEY, + BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY, BUILDER_CMS_HAS_AUTOSAVES_KEY, } from "./_builder-cms-source-adapter"; import { @@ -16,7 +17,7 @@ import { } from "./_builder-cms-unpublished-changes"; function saved( - values: Record, + values: Record, changes: { liveValues: Record; liveLastUpdated?: number; @@ -357,6 +358,35 @@ describe("Builder unpublished changes", () => { expect(replaced).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); }); + it("keeps a saved body as one unit, including an empty one", () => { + const existing = saved( + { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-empty-hash", + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 0, + }, + { liveValues: {}, liveBlocksHash: "live-hash" }, + ); + + const kept = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Live body", + [BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY]: "Live body, lossless", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "live-hash", + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 3, + }, + existing, + }); + + expect(kept[BUILDER_CMS_BODY_CONTENT_KEY]).toBe(""); + expect(kept[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]).toBe("saved-empty-hash"); + expect(kept[BUILDER_CMS_BODY_BLOCK_COUNT_KEY]).toBe(0); + expect(kept).not.toHaveProperty(BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY); + expect(kept).toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + it("keeps a saved body when a read hashes it differently but Builder saved nothing since", () => { const existing = saved( { diff --git a/templates/content/actions/_builder-cms-unpublished-changes.ts b/templates/content/actions/_builder-cms-unpublished-changes.ts index 523485a1003..49083db6a00 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.ts @@ -3,13 +3,9 @@ import type { DocumentPropertyValue, } from "../shared/api.js"; import { + BUILDER_CMS_BODY_BASELINE_KEYS, BUILDER_CMS_BODY_BLOCK_COUNT_KEY, BUILDER_CMS_BODY_BLOCKS_HASH_KEY, - BUILDER_CMS_BODY_CONTENT_KEY, - BUILDER_CMS_BODY_LAST_UPDATED_KEY, - BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY, - BUILDER_CMS_BODY_READABLE_MAP_KEY, - BUILDER_CMS_BODY_SIDECARS_KEY, BUILDER_CMS_HAS_AUTOSAVES_KEY, BUILDER_CMS_REFERENCE_ID_KEY_PREFIX, builderCmsReferenceIdKey, @@ -55,15 +51,6 @@ export type BuilderCmsEntryStatus = | "draft" | "unpublished_changes"; -const BODY_BASELINE_KEYS = [ - BUILDER_CMS_BODY_CONTENT_KEY, - BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY, - BUILDER_CMS_BODY_READABLE_MAP_KEY, - BUILDER_CMS_BODY_SIDECARS_KEY, - BUILDER_CMS_BODY_BLOCKS_HASH_KEY, - BUILDER_CMS_BODY_LAST_UPDATED_KEY, -]; - function hasKey( record: Record | null | undefined, key: string, @@ -316,8 +303,9 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { ? hasBodyRecord(recorded) : builderCmsBodyStillSavedOver(recorded, builder); if (bodyKept) { - for (const key of BODY_BASELINE_KEYS) { - if (existing[key] !== undefined) next[key] = existing[key]; + for (const key of BUILDER_CMS_BODY_BASELINE_KEYS) { + if (hasKey(existing, key)) next[key] = existing[key]; + else delete next[key]; } } return withBuilderCmsUnpublishedChanges(next, { diff --git a/templates/content/actions/_builder-cms-write-adapter.ts b/templates/content/actions/_builder-cms-write-adapter.ts index c82b291f66a..462b72bb4f1 100644 --- a/templates/content/actions/_builder-cms-write-adapter.ts +++ b/templates/content/actions/_builder-cms-write-adapter.ts @@ -12,6 +12,7 @@ import type { import { BUILDER_CMS_SAFE_WRITE_MODEL as SAFE_WRITE_MODEL } from "../shared/api.js"; import { BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + BUILDER_CMS_ENTRY_NAME_KEY, builderCmsSourceRowIdentityState, } from "./_builder-cms-source-adapter.js"; import { builderCmsPushModeForTier } from "./_builder-cms-write-settings.js"; @@ -75,7 +76,10 @@ export interface BuilderCmsLivePreflight { values: Record | null; } -/** The source fields and body a Builder write request sends. */ +/** + * The source fields and body a Builder write request sends. A safe-model + * write also sends the entry name, so it counts as a written field. + */ export function builderCmsWrittenSourceFields( request: BuilderCmsExecutionPayload["request"], ) { @@ -85,11 +89,15 @@ export function builderCmsWrittenSourceFields( ? (data as Record) : {}; return { - fieldKeys: Object.keys(record) - .filter((name) => name !== "blocks") - .map((name) => `data.${name}`), + fieldKeys: [ + ...Object.keys(record) + .filter((name) => name !== "blocks") + .map((name) => `data.${name}`), + ...(typeof request.body.name === "string" + ? [BUILDER_CMS_ENTRY_NAME_KEY] + : []), + ], wroteBody: record.blocks !== undefined, - wroteName: typeof request.body.name === "string", }; } diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index b64eb37a147..07f0de58694 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -8,6 +8,7 @@ import type { } from "../shared/api"; import { builderBlocksHash } from "../shared/builder-mdx"; import { + BUILDER_CMS_BODY_BLOCK_COUNT_KEY, BUILDER_CMS_BODY_BLOCKS_HASH_KEY, BUILDER_CMS_BODY_CONTENT_KEY, BUILDER_CMS_BODY_LAST_UPDATED_KEY, @@ -3164,6 +3165,57 @@ describe("database source helpers", () => { ).toEqual({ liveValues: {}, liveBlocksHash: "live-body-hash" }); }); + it("lets an empty body Builder published replace a saved body on refresh", () => { + const existing = { + published: "published", + lastUpdated: "1000", + "data.title": "Same title", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-body-hash", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved body", + [BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY]: "Live body, lossless", + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: JSON.stringify({ + liveValues: {}, + liveLastUpdated: 1000, + liveBlocksHash: "live-body-hash", + liveBodyLastUpdated: 1000, + }), + }; + const emptied = { + published: "published", + lastUpdated: "2000", + "data.title": "Same title", + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 0, + }; + + const refreshed = sourceValuesForSeededSourceRow({ + sourceType: "builder-cms", + item: item("doc-1", "Same title"), + sourceTable: "blog_article", + now: "2026-06-08T13:00:00.000Z", + builderEntry: { + id: "entry-1", + model: "blog_article", + title: "Same title", + urlPath: "/blog/same-title", + updatedAt: "2026-06-08T13:00:00.000Z", + sourceValues: emptied, + }, + existingSourceValuesJson: JSON.stringify(existing), + }); + expect(refreshed).not.toHaveProperty(BUILDER_CMS_BODY_CONTENT_KEY); + expect(refreshed).not.toHaveProperty(BUILDER_CMS_BODY_BLOCKS_HASH_KEY); + expect(refreshed).not.toHaveProperty(BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY); + expect(refreshed[BUILDER_CMS_BODY_BLOCK_COUNT_KEY]).toBe(0); + expect(refreshed).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + + const bodyRead = builderBodyReadSourceValues(existing, { + sourceValues: emptied, + }); + expect(bodyRead).not.toHaveProperty(BUILDER_CMS_BODY_CONTENT_KEY); + expect(bodyRead).not.toHaveProperty(BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY); + expect(bodyRead).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + }); + it("preserves a hydrated Builder body baseline across metadata-only refreshes with the same hash", () => { expect( sourceValuesForSeededSourceRow({ diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index a2fb901240d..d6fdb59c5f5 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -89,6 +89,7 @@ import { type BuilderCmsReadState, } from "./_builder-cms-read-client.js"; import { + BUILDER_CMS_BODY_BASELINE_KEYS, BUILDER_CMS_BODY_BLOCKS_HASH_KEY, BUILDER_CMS_BODY_CONTENT_KEY, BUILDER_CMS_BODY_LAST_UPDATED_KEY, @@ -97,6 +98,7 @@ import { BUILDER_CMS_BODY_SIDECARS_KEY, BUILDER_CMS_FIXTURE_ROW_PROVENANCE, buildBuilderCmsFixtureEntry, + builderCmsBodyKnownEmpty, builderCmsQualifiedId, builderCmsReferenceIdKey, builderCmsReferenceIdSourceValues, @@ -1973,10 +1975,10 @@ async function enqueueEmptyHydratedBuilderBodiesFromStoredRows(args: { sourceTable: args.source.sourceTable, fallbackTitle: row.document.title, }); - const refreshedEntry = entry - ? await refreshBuilderBodySourceValuesFromStoredLossless(entry) - : null; - if (!refreshedEntry) continue; + // An empty body has nothing to fetch; its stored lossless copy is older. + if (!entry || builderCmsBodyKnownEmpty(entry.sourceValues)) continue; + const refreshedEntry = + await refreshBuilderBodySourceValuesFromStoredLossless(entry); requests.push({ sourceId: args.source.id, ownerEmail: row.item.ownerEmail, @@ -2021,6 +2023,11 @@ export function builderBodyReadSourceValues( delete rowBase[referenceKey]; } } + // Builder's empty body has no body keys, so the row's older body would + // show through. + if (builderCmsBodyKnownEmpty(builderValues)) { + for (const key of BUILDER_CMS_BODY_BASELINE_KEYS) delete rowBase[key]; + } return builderSourceValuesKeepingUnpublishedChanges({ incoming: { ...rowBase, ...builderValues }, existing: rowValues, @@ -2190,10 +2197,10 @@ async function processBuilderBodyHydrationJob( ? await refreshBuilderBodySourceValuesFromStoredLossless(rebuiltBaseEntry) : null; if (rebuiltEntry) { - const rebuiltValues = { - ...sourceValues, - ...rebuiltEntry.sourceValues, - }; + const rebuiltValues = builderBodyReadSourceValues( + sourceValues, + rebuiltEntry, + ); const rebuiltContent = stringSourceValue(rebuiltValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; if (rebuiltContent.trim()) { @@ -2267,6 +2274,9 @@ async function processBuilderBodyHydrationJob( entryWithBody = liveEntry; nextValues = liveValues; nextContent = liveContent; + } else if (builderCmsBodyKnownEmpty(liveValues)) { + // Content saved an empty body over the live one. + emptyBodyRead = { ...liveRead, state: "empty_body" }; } } else { emptyBodyRead = liveRead; @@ -6090,20 +6100,15 @@ function builderSourceValuesWithPreservedBodyBaseline(args: { existingEpoch !== null && incomingEpoch === existingEpoch; const next = { ...args.incoming }; + // A read without the body keeps the stored one; an empty body replaces it. const preserveExistingBody = + !builderCmsBodyKnownEmpty(args.incoming) && !incomingContent?.trim() && !!existingContent?.trim() && (sameRemoteVersion || !(incomingHash && existingHash && incomingHash !== existingHash)); if (preserveExistingBody) { - for (const key of [ - BUILDER_CMS_BODY_CONTENT_KEY, - BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY, - BUILDER_CMS_BODY_READABLE_MAP_KEY, - BUILDER_CMS_BODY_SIDECARS_KEY, - BUILDER_CMS_BODY_BLOCKS_HASH_KEY, - BUILDER_CMS_BODY_LAST_UPDATED_KEY, - ]) { + for (const key of BUILDER_CMS_BODY_BASELINE_KEYS) { if (existing[key] !== undefined) { next[key] = existing[key]; } diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index f9104b32e79..8e60896ed12 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -7,7 +7,12 @@ import { type ContentDatabaseSourceChangeSet, } from "../shared/api"; import type { BuilderCmsEntryLiveState } from "./_builder-cms-read-client"; -import { BUILDER_CMS_BODY_BLOCKS_HASH_KEY } from "./_builder-cms-source-adapter"; +import { + BUILDER_CMS_BODY_BLOCK_COUNT_KEY, + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + BUILDER_CMS_BODY_CONTENT_KEY, + BUILDER_CMS_ENTRY_NAME_KEY, +} from "./_builder-cms-source-adapter"; import { BUILDER_CMS_UNPUBLISHED_CHANGES_KEY, withBuilderCmsUnpublishedChanges, @@ -558,7 +563,10 @@ describe("execute Builder source execution", () => { lastUpdated: BUILDER_LAST_UPDATED_MS, blocksHash: null, hasAutosaves: null, - values: { "data.title": "Old title" }, + values: { + "data.title": "Old title", + [BUILDER_CMS_ENTRY_NAME_KEY]: null, + }, }, }), }), @@ -2090,7 +2098,11 @@ describe("Builder unpublished saves", () => { overrides: Partial = {}, ) => row({ - sourceValues: { published: "published", "data.title": "Old title" }, + sourceValues: { + published: "published", + "data.title": "Old title", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", + }, ...overrides, }); @@ -2123,7 +2135,13 @@ describe("Builder unpublished saves", () => { const deps = depsFor({ source: builderSource, execution, - readLiveEntry: { name: "Old title", ...args.readLiveEntry }, + readLiveEntry: { + ...args.readLiveEntry, + values: args.readLiveEntry.values && { + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", + ...args.readLiveEntry.values, + }, + }, writeResult: args.writeResult, }); const run = () => @@ -2242,8 +2260,7 @@ describe("Builder unpublished saves", () => { lastUpdated: BUILDER_LAST_UPDATED_MS, blocksHash: null, id: "builder-entry-1", - name: "Renamed in Builder", - values: { "data.title": "Renamed in Builder" }, + values: { [BUILDER_CMS_ENTRY_NAME_KEY]: "Renamed in Builder" }, }, }); @@ -2253,14 +2270,65 @@ describe("Builder unpublished saves", () => { expect(deps.executeWrite).not.toHaveBeenCalled(); }); + it("compares the entry name with Builder's name, not the article title", async () => { + const { deps, run } = unpublishedSave({ + rows: [ + publishedRow({ + lastSourceUpdatedAt: String(STALE_BUILDER_LAST_UPDATED_MS), + sourceValues: { + published: "published", + "data.title": "Old title", + "data.blurb": "Old blurb", + [BUILDER_CMS_ENTRY_NAME_KEY]: "CMS internal label", + }, + }), + ], + changeSet: changeSet({ + fieldChanges: [ + { + propertyId: null, + propertyName: "Blurb", + localFieldKey: "blurb", + sourceFieldKey: "data.blurb", + currentValue: "Old blurb", + proposedValue: "New blurb", + }, + ], + }), + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + values: { + "data.blurb": "Old blurb", + [BUILDER_CMS_ENTRY_NAME_KEY]: "CMS internal label", + }, + }, + }); + + await expect(run()).resolves.toMatchObject(RESPONSE); + expect(deps.executeWrite).toHaveBeenCalledTimes(1); + }); + it("treats the live value Content saved over as the value this edit was made against", async () => { const { deps, run } = unpublishedSave({ rows: [ publishedRow({ lastSourceUpdatedAt: String(STALE_BUILDER_LAST_UPDATED_MS), sourceValues: withBuilderCmsUnpublishedChanges( - { published: "published", "data.title": "Saved title" }, - { liveValues: { "data.title": "Live title" } }, + { + published: "published", + "data.title": "Saved title", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Saved title", + }, + { + liveValues: { + "data.title": "Live title", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Live title", + }, + }, ), }), ], @@ -2270,7 +2338,10 @@ describe("Builder unpublished saves", () => { lastUpdated: BUILDER_LAST_UPDATED_MS, blocksHash: null, id: "builder-entry-1", - values: { "data.title": "Live title" }, + values: { + "data.title": "Live title", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Live title", + }, }, }); @@ -2303,6 +2374,7 @@ describe("Builder unpublished saves", () => { "data.title": "Old title", "data.author": "Sam Lee", "__agent_native_builder_reference_id:data.author": "author-1", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", }, }); const live = (authorId: string): BuilderCmsEntryLiveState => ({ @@ -2342,6 +2414,7 @@ describe("Builder unpublished saves", () => { values: { "data.author": "Sam Lee", "__agent_native_builder_reference_id:data.author": "author-1", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", }, }), }), @@ -2479,6 +2552,7 @@ describe("Builder unpublished saves", () => { existingSourceValuesJson: JSON.stringify({ published: "published", "data.title": "Old title", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", }), snapshotSourceValues: undefined, changeSet: approvedChangeSet, @@ -2494,7 +2568,10 @@ describe("Builder unpublished saves", () => { lastUpdated: BUILDER_LAST_UPDATED_MS, blocksHash: null, hasAutosaves: null, - values: { "data.title": "Old title" }, + values: { + "data.title": "Old title", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", + }, }, }, }, @@ -2502,12 +2579,81 @@ describe("Builder unpublished saves", () => { ); expect(values["data.title"]).toBe("New title"); + expect(values[BUILDER_CMS_ENTRY_NAME_KEY]).toBe("New title"); expect(JSON.parse(values[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY])).toEqual({ - liveValues: { "data.title": "Old title" }, + liveValues: { + "data.title": "Old title", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", + }, liveLastUpdated: BUILDER_LAST_UPDATED_MS, }); }); + it("records an empty body save as a body with no blocks", () => { + const approvedChangeSet = changeSet({ + fieldChanges: [], + bodyChange: { + summary: "Builder body blocks changed.", + currentExcerpt: "Old body", + proposedExcerpt: "", + currentHash: "live-hash", + proposedHash: "empty-hash", + proposedContent: "", + proposedBlocksJson: "[]", + sidecarsJson: "{}", + warnings: [], + }, + }); + const builderSource = source({ + rows: [publishedRow()], + changeSets: [approvedChangeSet], + }); + const plan = buildBuilderCmsExecutionPlan({ + source: builderSource, + changeSet: approvedChangeSet, + pushModeConfirmation: "autosave", + }); + expect(plan.payload.effect).toBe("autosave"); + + const values = JSON.parse( + builderCmsReconciledSourceValuesJson({ + existingSourceValuesJson: JSON.stringify({ + published: "published", + "data.title": "Old title", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Old body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "live-hash", + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 2, + }), + snapshotSourceValues: undefined, + changeSet: approvedChangeSet, + plan: { + ...plan, + payload: { + ...plan.payload, + livePreflight: { + checkedAt: NOW, + exists: true, + id: "builder-entry-1", + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: "live-hash", + hasAutosaves: null, + values: { [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title" }, + }, + }, + }, + }), + ); + + expect(values[BUILDER_CMS_BODY_CONTENT_KEY]).toBe(""); + expect(values[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]).toBe("empty-hash"); + expect(values[BUILDER_CMS_BODY_BLOCK_COUNT_KEY]).toBe(0); + expect(JSON.parse(values[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY])).toEqual( + expect.objectContaining({ liveBlocksHash: "live-hash" }), + ); + }); + it("takes a live write's fields out of the record, even from a response stored without live values", () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); const builderSource = source({ diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index 5645a008e23..8aeb819555f 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -38,9 +38,11 @@ import { readBuilderCmsEntryLiveState, } from "./_builder-cms-read-client.js"; import { + BUILDER_CMS_BODY_BLOCK_COUNT_KEY, BUILDER_CMS_BODY_BLOCKS_HASH_KEY, BUILDER_CMS_BODY_CONTENT_KEY, BUILDER_CMS_BODY_SIDECARS_KEY, + BUILDER_CMS_ENTRY_NAME_KEY, builderCmsQualifiedId, builderCmsReferenceIdKey, } from "./_builder-cms-source-adapter.js"; @@ -457,12 +459,20 @@ export function builderCmsReconciledSourceValuesJson(args: { next[BUILDER_CMS_BODY_SIDECARS_KEY] = bodyChange.sidecarsJson; } } - const requestData = args.plan.payload.request.body.data; + const requestBody = args.plan.payload.request.body; + if (typeof requestBody.name === "string") { + next[BUILDER_CMS_ENTRY_NAME_KEY] = requestBody.name; + } + const requestData = requestBody.data; if ( requestData && typeof requestData === "object" && !Array.isArray(requestData) ) { + const requestBlocks = (requestData as Record).blocks; + if (Array.isArray(requestBlocks)) { + next[BUILDER_CMS_BODY_BLOCK_COUNT_KEY] = requestBlocks.length; + } for (const [fieldName, value] of Object.entries(requestData)) { if (fieldName === "blocks") continue; const sourceFieldKey = `data.${fieldName}`; @@ -568,8 +578,6 @@ function livePreflightBlockMessage(args: { baselineLastUpdated: string | null; baselineBlocksHash: string | null; baselineValues: Record | undefined; - /** The entry name Content had when the change was reviewed. */ - baselineName: string | null; effect: BuilderCmsExecutionPayload["effect"]; request: BuilderCmsExecutionPayload["request"]; fieldLabel: (sourceFieldKey: string) => string; @@ -639,20 +647,11 @@ function livePreflightBlockMessage(args: { if (!fieldLevel || !liveValues || bodyUnverifiable) { return "Builder entry changed since this diff was approved; refresh and re-review."; } - const changedFields = written.fieldKeys - .filter((key) => !fieldStillReviewed(key, liveValues)) - .map(args.fieldLabel); - // A safe-model write also sends the entry name, taken from the title - // Content has, so it would overwrite a name Builder changed since. - const liveName = args.liveState.name?.trim() || null; - if ( - written.wroteName && - (!liveName || liveName !== args.baselineName?.trim()) - ) { - changedFields.push("Entry name"); - } + const changedFields = written.fieldKeys.filter( + (key) => !fieldStillReviewed(key, liveValues), + ); if (changedFields.length > 0) { - return `${changedFields.join(", ")} changed in Builder since you edited it; refresh and re-review.`; + return `${changedFields.map(args.fieldLabel).join(", ")} changed in Builder since you edited it; refresh and re-review.`; } } if ( @@ -749,6 +748,7 @@ function builderExecutionSucceededSummary(args: { function builderSourceFieldLabel(source: ContentDatabaseSource) { return (sourceFieldKey: string) => { + if (sourceFieldKey === BUILDER_CMS_ENTRY_NAME_KEY) return "Entry name"; const name = sourceFieldKey.replace(/^data\./, ""); return ( source.metadata.builderModelFields @@ -2113,8 +2113,6 @@ export async function executeBuilderSourceExecutionWithDeps( baselineLastUpdated, baselineBlocksHash: changeSet.bodyChange?.currentHash ?? null, baselineValues: sourceRowForChangeSet(source, changeSet)?.sourceValues, - baselineName: - sourceRowForChangeSet(source, changeSet)?.sourceDisplayKey ?? null, effect: plan.payload.effect, request: plan.payload.request, fieldLabel: builderSourceFieldLabel(source), diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index d163e778c5b..de25e15e5d0 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -11,12 +11,17 @@ import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest"; import { BUILDER_CMS_SAFE_WRITE_MODEL } from "../shared/api"; import { builderBlocksHash } from "../shared/builder-mdx"; import { + BUILDER_CMS_BODY_BLOCK_COUNT_KEY, BUILDER_CMS_BODY_BLOCKS_HASH_KEY, BUILDER_CMS_BODY_CONTENT_KEY, BUILDER_CMS_BODY_LAST_UPDATED_KEY, BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY, BUILDER_CMS_BODY_SIDECARS_KEY, } from "./_builder-cms-source-adapter"; +import { + BUILDER_CMS_UNPUBLISHED_CHANGES_KEY, + readBuilderCmsUnpublishedChanges, +} from "./_builder-cms-unpublished-changes"; import { buildBuilderCmsExecutionPlan } from "./_builder-cms-write-adapter"; const builderReadMock = vi.hoisted(() => ({ @@ -78,7 +83,8 @@ vi.mock("./_builder-cms-read-client.js", async () => { model !== "collection-canonical-hash-repair" && model !== "collection-same-version-conflict" && model !== "collection-hydration-empty-terminal" && - model !== "collection-explicit-retry" + model !== "collection-explicit-retry" && + model !== "collection-saved-empty-body" ) { return null; } @@ -5130,6 +5136,202 @@ it("hydrates a provider-confirmed empty Builder body with terminal evidence", as expect(after.queued).toBeNull(); }); +it("keeps an empty body Content saved over a published entry through hydration", async () => { + builderReadMock.mode = "full"; + builderReadMock.calls = []; + builderReadMock.singleEntryCalls = []; + const db = getDb(); + const now = new Date().toISOString(); + const model = "collection-saved-empty-body"; + const databaseId = "db_saved_empty_body"; + const databaseDocId = "doc_db_saved_empty_body"; + const documentId = "doc_saved_empty_body"; + const itemId = "item_saved_empty_body"; + const sourceId = "src_saved_empty_body"; + const sourceRowId = "entry_saved_empty_body"; + const lastUpdated = "2026-01-01T00:00:00.000Z"; + // The live body the read mock returns for this model. + const live = await withBuilderBodyValues({ + id: sourceRowId, + model, + title: "Open live hydration", + urlPath: "/blog/open-live-hydration", + updatedAt: lastUpdated, + sourceValues: { + "data.title": "Open live hydration", + "data.url": "/blog/open-live-hydration", + lastUpdated, + }, + rawEntry: { + id: sourceRowId, + model, + lastUpdated, + data: { + blocks: [ + { + "@type": "@builder.io/sdk:Element", + "@version": 2, + id: "text-open-live", + component: { + name: "Text", + options: { text: "

Live opened row body from Builder.

" }, + }, + }, + ], + }, + }, + }); + const liveHash = live.sourceValues[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]; + expect(live.sourceValues[BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY]).toEqual( + expect.any(String), + ); + + await db.insert(schema.documents).values([ + { + id: databaseDocId, + ownerEmail: OWNER, + title: "DB saved empty body", + createdAt: now, + updatedAt: now, + }, + { + id: documentId, + ownerEmail: OWNER, + parentId: databaseDocId, + title: "Open live hydration", + content: "", + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(schema.contentDatabases).values({ + id: databaseId, + ownerEmail: OWNER, + documentId: databaseDocId, + title: "DB saved empty body", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseSources).values({ + id: sourceId, + ownerEmail: OWNER, + databaseId, + sourceType: "builder-cms", + sourceName: model, + sourceTable: model, + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseItems).values({ + id: itemId, + ownerEmail: OWNER, + databaseId, + documentId, + position: 0, + bodyHydrationStatus: "pending", + bodyHydrationError: null, + createdAt: now, + updatedAt: now, + }); + // Content autosaved an empty body; the stored lossless copy is still the + // live body it saved over. + await db.insert(schema.contentDatabaseSourceRows).values({ + id: "row_saved_empty_body", + ownerEmail: OWNER, + sourceId, + databaseItemId: itemId, + documentId, + sourceRowId, + sourceQualifiedId: `builder-cms://${model}/${sourceRowId}`, + sourceDisplayKey: "Open live hydration", + sourceValuesJson: JSON.stringify({ + ...live.sourceValues, + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: builderBlocksHash([]), + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 0, + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: JSON.stringify({ + liveValues: {}, + liveLastUpdated: Date.parse(lastUpdated), + liveBlocksHash: liveHash, + liveBodyLastUpdated: Date.parse(lastUpdated), + }), + }), + provenance: "Builder CMS read adapter", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseBodyHydrationQueue).values({ + id: "queue_saved_empty_body", + ownerEmail: OWNER, + sourceId, + databaseItemId: itemId, + documentId, + sourceRowId, + sourceTable: model, + sourceEntryJson: JSON.stringify({ + id: sourceRowId, + model, + title: "Open live hydration", + urlPath: "/blog/open-live-hydration", + updatedAt: lastUpdated, + sourceValues: { + "data.title": "Open live hydration", + "data.url": "/blog/open-live-hydration", + lastUpdated, + published: "published", + }, + }), + priority: 0, + attempts: 0, + createdAt: now, + updatedAt: now, + }); + + await hydrateQueuedBodies({ sourceId, limit: 1, preloadBodies: true }); + + const [after] = await db + .select({ + content: schema.documents.content, + status: schema.contentDatabaseItems.bodyHydrationStatus, + reason: schema.contentDatabaseItems.bodyHydrationReason, + sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson, + queued: schema.contentDatabaseBodyHydrationQueue.id, + }) + .from(schema.documents) + .innerJoin( + schema.contentDatabaseItems, + eq(schema.contentDatabaseItems.documentId, schema.documents.id), + ) + .innerJoin( + schema.contentDatabaseSourceRows, + eq( + schema.contentDatabaseSourceRows.databaseItemId, + schema.contentDatabaseItems.id, + ), + ) + .leftJoin( + schema.contentDatabaseBodyHydrationQueue, + eq( + schema.contentDatabaseBodyHydrationQueue.databaseItemId, + schema.contentDatabaseItems.id, + ), + ) + .where(eq(schema.documents.id, documentId)); + + expect(after.content).toBe(""); + expect(after.status).toBe("hydrated"); + expect(after.reason).toBe("empty_body"); + expect(after.queued).toBeNull(); + const values = JSON.parse(after.sourceValuesJson); + expect(values[BUILDER_CMS_BODY_CONTENT_KEY]).toBe(""); + expect(values[BUILDER_CMS_BODY_BLOCK_COUNT_KEY]).toBe(0); + expect(readBuilderCmsUnpublishedChanges(values)).toMatchObject({ + state: "pending", + changes: { liveBlocksHash: liveHash }, + }); +}); + it("explicitly retries a terminal retryable Builder hydration while preserving evidence until processing", async () => { const db = getDb(); const now = new Date().toISOString(); From b77ebfe509b762aaa44dd0ee32ceb1760be34bdd Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:27:44 -0400 Subject: [PATCH 06/20] Keep an empty Builder body through hydration and record the version the row was written with --- .../content/actions/_database-source-utils.ts | 309 ++++++++++-------- .../resync-content-database-source.db.test.ts | 244 +++++++++++++- 2 files changed, 412 insertions(+), 141 deletions(-) diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index d6fdb59c5f5..36174abfdba 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -1475,10 +1475,7 @@ async function readBuilderEntryWithLiveBodyFromSourceRow(args: { const entryWithStoredValues = { ...liveEntry, title: liveEntry.title || args.fallbackTitle, - sourceValues: { - ...sourceValues, - ...liveEntry.sourceValues, - }, + sourceValues: builderReadOverValues(sourceValues, liveEntry.sourceValues), }; const refreshedEntry = await withBuilderBodySourceValues( entryWithStoredValues, @@ -2006,6 +2003,23 @@ async function enqueueEmptyHydratedBuilderBodiesFromStoredRows(args: { * this, so none of them overwrites Content's unpublished saves with the live * values Builder still returns. */ +/** + * `base` with a Builder read laid over it. Builder's empty body has no body + * keys, so an older body in `base` would show through. + */ +function builderReadOverValues( + base: Record, + read: Record, +) { + const next = { ...base, ...read }; + if (builderCmsBodyKnownEmpty(read)) { + for (const key of BUILDER_CMS_BODY_BASELINE_KEYS) { + if (!Object.prototype.hasOwnProperty.call(read, key)) delete next[key]; + } + } + return next; +} + export function builderBodyReadSourceValues( rowValues: Record, entry: Pick, @@ -2023,13 +2037,8 @@ export function builderBodyReadSourceValues( delete rowBase[referenceKey]; } } - // Builder's empty body has no body keys, so the row's older body would - // show through. - if (builderCmsBodyKnownEmpty(builderValues)) { - for (const key of BUILDER_CMS_BODY_BASELINE_KEYS) delete rowBase[key]; - } return builderSourceValuesKeepingUnpublishedChanges({ - incoming: { ...rowBase, ...builderValues }, + incoming: builderReadOverValues(rowBase, builderValues), existing: rowValues, incomingFilledFromRow: true, builderValues, @@ -2066,10 +2075,10 @@ async function processBuilderBodyHydrationJob( preloaded?.bodyEntry?.id === entry.id ? { ...preloaded.bodyEntry, - sourceValues: { - ...entry.sourceValues, - ...preloaded.bodyEntry.sourceValues, - }, + sourceValues: builderReadOverValues( + entry.sourceValues, + preloaded.bodyEntry.sourceValues, + ), } : entry; let activeSourceEntryJson = row.sourceEntryJson; @@ -2185,7 +2194,14 @@ async function processBuilderBodyHydrationJob( BuilderLiveBodyReadResult, { state: "empty_body" | "not_found" } > | null = null; - if (!nextContent.trim()) { + if (builderCmsBodyKnownEmpty(nextValues)) { + // The stored lossless copy is an older body than this empty one. + emptyBodyRead = { + state: "empty_body", + entry: { ...entryWithBody, sourceValues: nextValues }, + providerStatus: "http_200", + }; + } else if (!nextContent.trim()) { const rebuiltBaseEntry = sourceRow ? builderEntryFromSourceRow({ row: sourceRow, @@ -2276,151 +2292,166 @@ async function processBuilderBodyHydrationJob( nextContent = liveContent; } else if (builderCmsBodyKnownEmpty(liveValues)) { // Content saved an empty body over the live one. - emptyBodyRead = { ...liveRead, state: "empty_body" }; + emptyBodyRead = { + ...liveRead, + state: "empty_body", + entry: { ...liveEntry, sourceValues: liveValues }, + }; } + } else if (liveRead.state === "empty_body") { + emptyBodyRead = { + ...liveRead, + entry: { + ...liveRead.entry, + sourceValues: builderBodyReadSourceValues( + sourceValues, + liveRead.entry, + ), + }, + }; } else { emptyBodyRead = liveRead; } } - if (!nextContent.trim()) { - const attempts = row.attempts; - await db.transaction(async (tx) => { - const queueRowCas = builderBodyHydrationQueueOwnershipFilter( - row, - activeSourceEntryJson, - ); - const markPendingIfReplaced = async () => { - const [replacedByNewerJob] = await tx - .select({ id: schema.contentDatabaseBodyHydrationQueue.id }) - .from(schema.contentDatabaseBodyHydrationQueue) - .where(eq(schema.contentDatabaseBodyHydrationQueue.id, row.id)); - if (!replacedByNewerJob) return; - await tx - .update(schema.contentDatabaseItems) - .set({ - bodyHydrationStatus: "pending", - bodyHydrationAttemptedAt: now, - bodyHydrationError: null, - updatedAt: now, - }) - .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); - }; - if (emptyBodyRead?.state === "empty_body") { - const [deleted] = await tx - .delete(schema.contentDatabaseBodyHydrationQueue) - .where(queueRowCas) - .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); - if (!deleted) { - await markPendingIfReplaced(); - return; - } - await tx - .update(schema.contentDatabaseSourceRows) - .set({ - sourceValuesJson: JSON.stringify( - builderBodyReadSourceValues(sourceValues, emptyBodyRead.entry), - ), - lastSyncedAt: now, - lastSourceUpdatedAt: emptyBodyRead.entry.updatedAt ?? now, - updatedAt: now, - }) - .where( - and( - eq(schema.contentDatabaseSourceRows.sourceId, row.sourceId), - eq( - schema.contentDatabaseSourceRows.databaseItemId, - row.databaseItemId, - ), - ), - ); - await tx - .update(schema.contentDatabaseItems) - .set({ - bodyHydrationStatus: "hydrated", - bodyHydrationAttemptedAt: now, - bodyHydrationError: null, - bodyHydrationVersion: builderBodyHydrationVersion( - emptyBodyRead.entry, - ), - bodyHydrationReason: "empty_body", - bodyHydrationProviderStatus: emptyBodyRead.providerStatus, - bodyHydrationAttemptCount: attempts, - bodyHydrationRetryable: 0, - updatedAt: now, - }) - .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); - return; - } - if (builderBodyHydrationAttemptIsTerminal(attempts)) { - const [deleted] = await tx - .delete(schema.contentDatabaseBodyHydrationQueue) - .where(queueRowCas) - .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); - if (!deleted) { - await markPendingIfReplaced(); - return; - } - const reason = - emptyBodyRead?.state === "not_found" - ? "not_found" - : "conversion_failed"; - await tx - .update(schema.contentDatabaseItems) - .set({ - bodyHydrationStatus: "error", - bodyHydrationAttemptedAt: now, - bodyHydrationError: - reason === "not_found" - ? "Builder no longer returns this source entry. Refresh the source or retry after restoring access." - : reason === "conversion_failed" - ? "Content could not construct a Builder body from the retained source record. Refresh the source to recover the authoritative body." - : null, - bodyHydrationVersion: - builderBodyUnavailableVersion(entryWithBody), - bodyHydrationReason: reason, - bodyHydrationProviderStatus: emptyBodyRead - ? emptyBodyRead.providerStatus - : "local_source_record", - bodyHydrationAttemptCount: attempts, - bodyHydrationRetryable: reason === "not_found" ? 1 : 0, - updatedAt: now, - }) - .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); + } + if (emptyBodyRead || !nextContent.trim()) { + const attempts = row.attempts; + await db.transaction(async (tx) => { + const queueRowCas = builderBodyHydrationQueueOwnershipFilter( + row, + activeSourceEntryJson, + ); + const markPendingIfReplaced = async () => { + const [replacedByNewerJob] = await tx + .select({ id: schema.contentDatabaseBodyHydrationQueue.id }) + .from(schema.contentDatabaseBodyHydrationQueue) + .where(eq(schema.contentDatabaseBodyHydrationQueue.id, row.id)); + if (!replacedByNewerJob) return; + await tx + .update(schema.contentDatabaseItems) + .set({ + bodyHydrationStatus: "pending", + bodyHydrationAttemptedAt: now, + bodyHydrationError: null, + updatedAt: now, + }) + .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); + }; + if (emptyBodyRead?.state === "empty_body") { + const [deleted] = await tx + .delete(schema.contentDatabaseBodyHydrationQueue) + .where(queueRowCas) + .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); + if (!deleted) { + await markPendingIfReplaced(); return; } - const [stillOwnsQueueRow] = await tx - .update(schema.contentDatabaseBodyHydrationQueue) + // The next refresh compares the version with the row as written. + const writtenEntry = { + ...emptyBodyRead.entry, + updatedAt: emptyBodyRead.entry.updatedAt ?? now, + }; + await tx + .update(schema.contentDatabaseSourceRows) .set({ - lastAttemptedAt: null, - lastError: BUILDER_BODY_NOT_AVAILABLE_ERROR, - nextAttemptAt: builderBodyHydrationNextAttemptAt(attempts, now), + sourceValuesJson: JSON.stringify(writtenEntry.sourceValues), + lastSyncedAt: now, + lastSourceUpdatedAt: writtenEntry.updatedAt, updatedAt: now, }) + .where( + and( + eq(schema.contentDatabaseSourceRows.sourceId, row.sourceId), + eq( + schema.contentDatabaseSourceRows.databaseItemId, + row.databaseItemId, + ), + ), + ); + await tx + .update(schema.contentDatabaseItems) + .set({ + bodyHydrationStatus: "hydrated", + bodyHydrationAttemptedAt: now, + bodyHydrationError: null, + bodyHydrationVersion: builderBodyHydrationVersion(writtenEntry), + bodyHydrationReason: "empty_body", + bodyHydrationProviderStatus: emptyBodyRead.providerStatus, + bodyHydrationAttemptCount: attempts, + bodyHydrationRetryable: 0, + updatedAt: now, + }) + .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); + return; + } + if (builderBodyHydrationAttemptIsTerminal(attempts)) { + const [deleted] = await tx + .delete(schema.contentDatabaseBodyHydrationQueue) .where(queueRowCas) .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); - if (!stillOwnsQueueRow) { + if (!deleted) { await markPendingIfReplaced(); return; } + const reason = + emptyBodyRead?.state === "not_found" + ? "not_found" + : "conversion_failed"; await tx .update(schema.contentDatabaseItems) .set({ - bodyHydrationStatus: "pending", + bodyHydrationStatus: "error", bodyHydrationAttemptedAt: now, - bodyHydrationError: BUILDER_BODY_NOT_AVAILABLE_ERROR, - bodyHydrationReason: - emptyBodyRead?.state === "not_found" ? "not_found" : null, + bodyHydrationError: + reason === "not_found" + ? "Builder no longer returns this source entry. Refresh the source or retry after restoring access." + : reason === "conversion_failed" + ? "Content could not construct a Builder body from the retained source record. Refresh the source to recover the authoritative body." + : null, + bodyHydrationVersion: builderBodyUnavailableVersion(entryWithBody), + bodyHydrationReason: reason, bodyHydrationProviderStatus: emptyBodyRead ? emptyBodyRead.providerStatus - : null, + : "local_source_record", bodyHydrationAttemptCount: attempts, - bodyHydrationRetryable: 1, + bodyHydrationRetryable: reason === "not_found" ? 1 : 0, updatedAt: now, }) .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); - }); - return; - } + return; + } + const [stillOwnsQueueRow] = await tx + .update(schema.contentDatabaseBodyHydrationQueue) + .set({ + lastAttemptedAt: null, + lastError: BUILDER_BODY_NOT_AVAILABLE_ERROR, + nextAttemptAt: builderBodyHydrationNextAttemptAt(attempts, now), + updatedAt: now, + }) + .where(queueRowCas) + .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); + if (!stillOwnsQueueRow) { + await markPendingIfReplaced(); + return; + } + await tx + .update(schema.contentDatabaseItems) + .set({ + bodyHydrationStatus: "pending", + bodyHydrationAttemptedAt: now, + bodyHydrationError: BUILDER_BODY_NOT_AVAILABLE_ERROR, + bodyHydrationReason: + emptyBodyRead?.state === "not_found" ? "not_found" : null, + bodyHydrationProviderStatus: emptyBodyRead + ? emptyBodyRead.providerStatus + : null, + bodyHydrationAttemptCount: attempts, + bodyHydrationRetryable: 1, + updatedAt: now, + }) + .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); + }); + return; } const documentContent = preloaded?.documentContent != null @@ -2701,10 +2732,10 @@ async function preparePristineBuilderBodyHydration(args: { const entryWithBody = await refreshBuilderBodySourceValuesFromStoredLossless( await withBuilderBodySourceValues({ ...bodyEntry, - sourceValues: { - ...queuedEntry.sourceValues, - ...bodyEntry.sourceValues, - }, + sourceValues: builderReadOverValues( + queuedEntry.sourceValues, + bodyEntry.sourceValues, + ), }), ); const nextValues = builderBodyReadSourceValues(sourceValues, entryWithBody); diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index de25e15e5d0..eb1889bfcd8 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -8,7 +8,10 @@ import { runWithRequestContext } from "@agent-native/core/server"; import { and, eq, ne, or } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest"; -import { BUILDER_CMS_SAFE_WRITE_MODEL } from "../shared/api"; +import { + BUILDER_CMS_SAFE_WRITE_MODEL, + type ContentDatabaseItem, +} from "../shared/api"; import { builderBlocksHash } from "../shared/builder-mdx"; import { BUILDER_CMS_BODY_BLOCK_COUNT_KEY, @@ -84,10 +87,35 @@ vi.mock("./_builder-cms-read-client.js", async () => { model !== "collection-same-version-conflict" && model !== "collection-hydration-empty-terminal" && model !== "collection-explicit-retry" && - model !== "collection-saved-empty-body" + model !== "collection-saved-empty-body" && + model !== "collection-published-empty-body" ) { return null; } + if (model === "collection-published-empty-body") { + const lastUpdated = "2026-01-02T00:00:00.000Z"; + return { + id: entryId, + model, + title: "Published empty body", + urlPath: "/blog/published-empty-body", + updatedAt: lastUpdated, + sourceValues: { + "data.title": "Published empty body", + "data.url": "/blog/published-empty-body", + lastUpdated, + published: "published", + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 0, + }, + rawEntry: { + id: entryId, + model, + lastUpdated, + published: "published", + data: { title: "Published empty body", blocks: [] }, + }, + }; + } if (model === "collection-large-597") { const index = Number(entryId.replace("entry-large-", "")); return { @@ -5330,6 +5358,218 @@ it("keeps an empty body Content saved over a published entry through hydration", state: "pending", changes: { liveBlocksHash: liveHash }, }); + + // The next refresh reads the live body again and must not queue it. + const { enqueueBuilderBodyHydrationForItems } = + await import("./_database-source-utils.js"); + await enqueueBuilderBodyHydrationForItems({ + sourceId, + ownerEmail: OWNER, + orgId: null, + sourceTable: model, + items: [ + { + id: itemId, + databaseId, + document: { id: documentId } as ContentDatabaseItem["document"], + position: 0, + properties: [], + }, + ], + builderEntriesByDocumentId: new Map([[documentId, live]]), + now: new Date().toISOString(), + processInBackground: false, + }); + const requeued = await db + .select({ id: schema.contentDatabaseBodyHydrationQueue.id }) + .from(schema.contentDatabaseBodyHydrationQueue) + .where(eq(schema.contentDatabaseBodyHydrationQueue.databaseItemId, itemId)); + expect(requeued).toEqual([]); +}); + +it("lets an empty body Builder published replace a saved body through hydration", async () => { + const db = getDb(); + const now = new Date().toISOString(); + const model = "collection-published-empty-body"; + const databaseId = "db_published_empty_body"; + const databaseDocId = "doc_db_published_empty_body"; + const documentId = "doc_published_empty_body"; + const itemId = "item_published_empty_body"; + const sourceId = "src_published_empty_body"; + const sourceRowId = "entry_published_empty_body"; + const savedAt = "2026-01-01T00:00:00.000Z"; + const publishedAt = "2026-01-02T00:00:00.000Z"; + const live = await withBuilderBodyValues({ + id: sourceRowId, + model, + title: "Published empty body", + urlPath: "/blog/published-empty-body", + updatedAt: savedAt, + sourceValues: { + "data.title": "Published empty body", + "data.url": "/blog/published-empty-body", + lastUpdated: savedAt, + }, + rawEntry: { + id: sourceRowId, + model, + lastUpdated: savedAt, + data: { + blocks: [ + { + "@type": "@builder.io/sdk:Element", + "@version": 2, + id: "text-published-empty", + component: { + name: "Text", + options: { text: "

Live body before the save.

" }, + }, + }, + ], + }, + }, + }); + + await db.insert(schema.documents).values([ + { + id: databaseDocId, + ownerEmail: OWNER, + title: "DB published empty body", + createdAt: now, + updatedAt: now, + }, + { + id: documentId, + ownerEmail: OWNER, + parentId: databaseDocId, + title: "Published empty body", + content: "", + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(schema.contentDatabases).values({ + id: databaseId, + ownerEmail: OWNER, + documentId: databaseDocId, + title: "DB published empty body", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseSources).values({ + id: sourceId, + ownerEmail: OWNER, + databaseId, + sourceType: "builder-cms", + sourceName: model, + sourceTable: model, + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseItems).values({ + id: itemId, + ownerEmail: OWNER, + databaseId, + documentId, + position: 0, + bodyHydrationStatus: "pending", + bodyHydrationError: null, + createdAt: now, + updatedAt: now, + }); + // Content autosaved a body over the live one; the stored lossless copy is + // still the live body. + await db.insert(schema.contentDatabaseSourceRows).values({ + id: "row_published_empty_body", + ownerEmail: OWNER, + sourceId, + databaseItemId: itemId, + documentId, + sourceRowId, + sourceQualifiedId: `builder-cms://${model}/${sourceRowId}`, + sourceDisplayKey: "Published empty body", + sourceValuesJson: JSON.stringify({ + ...live.sourceValues, + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved Content body.", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-body-hash", + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 1, + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: JSON.stringify({ + liveValues: {}, + liveLastUpdated: Date.parse(savedAt), + liveBlocksHash: live.sourceValues[BUILDER_CMS_BODY_BLOCKS_HASH_KEY], + liveBodyLastUpdated: Date.parse(savedAt), + }), + }), + provenance: "Builder CMS read adapter", + createdAt: now, + updatedAt: now, + }); + // Builder then published an empty body. + await db.insert(schema.contentDatabaseBodyHydrationQueue).values({ + id: "queue_published_empty_body", + ownerEmail: OWNER, + sourceId, + databaseItemId: itemId, + documentId, + sourceRowId, + sourceTable: model, + sourceEntryJson: JSON.stringify({ + id: sourceRowId, + model, + title: "Published empty body", + urlPath: "/blog/published-empty-body", + updatedAt: publishedAt, + sourceValues: { + "data.title": "Published empty body", + "data.url": "/blog/published-empty-body", + lastUpdated: publishedAt, + published: "published", + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 0, + }, + rawEntry: { + id: sourceRowId, + model, + lastUpdated: publishedAt, + data: { blocks: [] }, + }, + }), + priority: 0, + attempts: 0, + createdAt: now, + updatedAt: now, + }); + + await hydrateQueuedBodies({ sourceId, limit: 1 }); + + const [after] = await db + .select({ + content: schema.documents.content, + status: schema.contentDatabaseItems.bodyHydrationStatus, + reason: schema.contentDatabaseItems.bodyHydrationReason, + sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson, + }) + .from(schema.documents) + .innerJoin( + schema.contentDatabaseItems, + eq(schema.contentDatabaseItems.documentId, schema.documents.id), + ) + .innerJoin( + schema.contentDatabaseSourceRows, + eq( + schema.contentDatabaseSourceRows.databaseItemId, + schema.contentDatabaseItems.id, + ), + ) + .where(eq(schema.documents.id, documentId)); + + expect(after.content).toBe(""); + expect(after.status).toBe("hydrated"); + expect(after.reason).toBe("empty_body"); + const values = JSON.parse(after.sourceValuesJson); + expect(values[BUILDER_CMS_BODY_CONTENT_KEY] ?? "").toBe(""); + expect(values[BUILDER_CMS_BODY_BLOCK_COUNT_KEY]).toBe(0); + expect(values).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); }); it("explicitly retries a terminal retryable Builder hydration while preserving evidence until processing", async () => { From 5723c563adc361bba400e84be3e53c11aa6c6c45 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:22:31 -0400 Subject: [PATCH 07/20] Keep Builder's own entry name on saves that leave the title, and empty an unedited page when Builder's body is empty --- .../actions/_builder-cms-write-adapter.ts | 11 ++- .../content/actions/_database-source-utils.ts | 75 ++++++++----------- .../execute-builder-source-execution.test.ts | 9 ++- .../resync-content-database-source.db.test.ts | 2 +- 4 files changed, 51 insertions(+), 46 deletions(-) diff --git a/templates/content/actions/_builder-cms-write-adapter.ts b/templates/content/actions/_builder-cms-write-adapter.ts index 462b72bb4f1..18e743f74b4 100644 --- a/templates/content/actions/_builder-cms-write-adapter.ts +++ b/templates/content/actions/_builder-cms-write-adapter.ts @@ -602,6 +602,8 @@ function builderRequestForEffect(args: { model: string; entryId: string | null; bodyPatch: Record; + /** The entry's own name in Builder, kept by a write that leaves the title. */ + currentName?: string | null; currentTitle?: string | null; intentMarker?: string; }): BuilderCmsExecutionPayload["request"] { @@ -616,7 +618,10 @@ function builderRequestForEffect(args: { : null; const safeEntryName = args.model === SAFE_WRITE_MODEL - ? patchedTitle || args.currentTitle?.trim() || null + ? patchedTitle || + args.currentName?.trim() || + args.currentTitle?.trim() || + null : null; if (isBuilderCmsUnpublishedSaveEffect(args.effect)) { return builderUnpublishedSaveRequest({ @@ -943,6 +948,10 @@ export function buildBuilderCmsExecutionPlan(args: { model: args.source.sourceTable, entryId: targetEntryId, bodyPatch, + currentName: + typeof targetRow?.sourceValues?.[BUILDER_CMS_ENTRY_NAME_KEY] === "string" + ? targetRow.sourceValues[BUILDER_CMS_ENTRY_NAME_KEY] + : null, currentTitle: targetRow?.sourceDisplayKey ?? null, intentMarker: effect === "create_draft" && args.source.sourceTable === SAFE_WRITE_MODEL diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 36174abfdba..107e36565f1 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -2194,14 +2194,13 @@ async function processBuilderBodyHydrationJob( BuilderLiveBodyReadResult, { state: "empty_body" | "not_found" } > | null = null; - if (builderCmsBodyKnownEmpty(nextValues)) { - // The stored lossless copy is an older body than this empty one. - emptyBodyRead = { - state: "empty_body", - entry: { ...entryWithBody, sourceValues: nextValues }, - providerStatus: "http_200", - }; - } else if (!nextContent.trim()) { + // A known-empty body is written like any other body. The stored lossless + // copy is older than it, so it is never rebuilt over one. + const hasBody = ( + values: Record, + content: string, + ) => !!content.trim() || builderCmsBodyKnownEmpty(values); + if (!hasBody(nextValues, nextContent)) { const rebuiltBaseEntry = sourceRow ? builderEntryFromSourceRow({ row: sourceRow, @@ -2219,7 +2218,7 @@ async function processBuilderBodyHydrationJob( ); const rebuiltContent = stringSourceValue(rebuiltValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; - if (rebuiltContent.trim()) { + if (hasBody(rebuiltValues, rebuiltContent)) { const rebuiltSourceEntryJson = JSON.stringify(rebuiltEntry); const [upgraded] = await db .update(schema.contentDatabaseBodyHydrationQueue) @@ -2249,7 +2248,7 @@ async function processBuilderBodyHydrationJob( nextContent = rebuiltContent; } } - if (!nextContent.trim() && sourceRow) { + if (!hasBody(nextValues, nextContent) && sourceRow) { const liveRead = await readBuilderEntryWithLiveBodyFromSourceRow({ row: sourceRow, sourceTable: row.sourceTable, @@ -2257,12 +2256,14 @@ async function processBuilderBodyHydrationJob( expectedSourceSpace: preloaded?.expectedSourceSpace, expectedSourceConnectionId: preloaded?.expectedSourceConnectionId, }); - if (liveRead.state === "body") { + if (liveRead.state === "not_found") { + emptyBodyRead = liveRead; + } else { const liveEntry = liveRead.entry; const liveValues = builderBodyReadSourceValues(sourceValues, liveEntry); const liveContent = stringSourceValue(liveValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; - if (liveContent.trim()) { + if (hasBody(liveValues, liveContent)) { const liveSourceEntryJson = JSON.stringify(liveEntry); const [upgraded] = await db .update(schema.contentDatabaseBodyHydrationQueue) @@ -2290,31 +2291,13 @@ async function processBuilderBodyHydrationJob( entryWithBody = liveEntry; nextValues = liveValues; nextContent = liveContent; - } else if (builderCmsBodyKnownEmpty(liveValues)) { - // Content saved an empty body over the live one. - emptyBodyRead = { - ...liveRead, - state: "empty_body", - entry: { ...liveEntry, sourceValues: liveValues }, - }; + } else if (liveRead.state === "empty_body") { + emptyBodyRead = liveRead; } - } else if (liveRead.state === "empty_body") { - emptyBodyRead = { - ...liveRead, - entry: { - ...liveRead.entry, - sourceValues: builderBodyReadSourceValues( - sourceValues, - liveRead.entry, - ), - }, - }; - } else { - emptyBodyRead = liveRead; } } } - if (emptyBodyRead || !nextContent.trim()) { + if (!hasBody(nextValues, nextContent)) { const attempts = row.attempts; await db.transaction(async (tx) => { const queueRowCas = builderBodyHydrationQueueOwnershipFilter( @@ -2350,6 +2333,10 @@ async function processBuilderBodyHydrationJob( const writtenEntry = { ...emptyBodyRead.entry, updatedAt: emptyBodyRead.entry.updatedAt ?? now, + sourceValues: builderBodyReadSourceValues( + sourceValues, + emptyBodyRead.entry, + ), }; await tx .update(schema.contentDatabaseSourceRows) @@ -2565,12 +2552,18 @@ async function processBuilderBodyHydrationJob( ) : isNull(schema.contentDatabaseSourceRows.id), ); + // The next refresh compares the version with the row as written. + const writtenEntry = { + ...entryWithBody, + updatedAt: entryWithBody.updatedAt ?? now, + sourceValues: nextValues, + }; const [updatedSourceRow] = await tx .update(schema.contentDatabaseSourceRows) .set({ - sourceValuesJson: JSON.stringify(nextValues), + sourceValuesJson: JSON.stringify(writtenEntry.sourceValues), lastSyncedAt: now, - lastSourceUpdatedAt: entryWithBody.updatedAt ?? now, + lastSourceUpdatedAt: writtenEntry.updatedAt, updatedAt: now, }) .where(sourceRowWhere) @@ -2627,14 +2620,10 @@ async function processBuilderBodyHydrationJob( bodyHydrationStatus: "hydrated", bodyHydrationAttemptedAt: now, bodyHydrationError: null, - // A kept body is the version the next refresh expects; Builder's - // would queue this read again on every refresh. - bodyHydrationVersion: builderBodyHydrationVersion( - incomingIsBodySavedOver - ? { ...entryWithBody, sourceValues: nextValues } - : entryWithBody, - ), - bodyHydrationReason: null, + bodyHydrationVersion: builderBodyHydrationVersion(writtenEntry), + bodyHydrationReason: builderCmsBodyKnownEmpty(nextValues) + ? "empty_body" + : null, bodyHydrationProviderStatus: "http_200", bodyHydrationAttemptCount: row.attempts, bodyHydrationRetryable: 0, diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 8e60896ed12..a16e95feb6b 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -2270,7 +2270,7 @@ describe("Builder unpublished saves", () => { expect(deps.executeWrite).not.toHaveBeenCalled(); }); - it("compares the entry name with Builder's name, not the article title", async () => { + it("checks and keeps Builder's own entry name on a save that leaves the title", async () => { const { deps, run } = unpublishedSave({ rows: [ publishedRow({ @@ -2310,6 +2310,13 @@ describe("Builder unpublished saves", () => { await expect(run()).resolves.toMatchObject(RESPONSE); expect(deps.executeWrite).toHaveBeenCalledTimes(1); + expect(deps.executeWrite).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + body: expect.objectContaining({ name: "CMS internal label" }), + }), + }), + ); }); it("treats the live value Content saved over as the value this edit was made against", async () => { diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index eb1889bfcd8..cf54734ab57 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -5443,7 +5443,7 @@ it("lets an empty body Builder published replace a saved body through hydration" ownerEmail: OWNER, parentId: databaseDocId, title: "Published empty body", - content: "", + content: "Saved Content body.", createdAt: now, updatedAt: now, }, From 597193134c0ce466cddf1aaf2e183714d431d477 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:21:55 -0400 Subject: [PATCH 08/20] Keep a Builder row as saved when its unpublished-changes record can't be read --- .../_builder-cms-unpublished-changes.test.ts | 43 ++++++++++++++----- .../_builder-cms-unpublished-changes.ts | 9 ++-- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/templates/content/actions/_builder-cms-unpublished-changes.test.ts b/templates/content/actions/_builder-cms-unpublished-changes.test.ts index 28b17c4c8d4..d2516cfb90e 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.test.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.test.ts @@ -294,23 +294,46 @@ describe("Builder unpublished changes", () => { expect(next["data.title"]).toBe("Saved title"); }); - it("warns and lets Builder win when the record cannot be read", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + it("keeps the row as saved when the record cannot be read, until a save replaces it", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); try { + const existing = { + published: "published", + "data.title": "Saved title", + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: "{not json", + }; const next = builderSourceValuesKeepingUnpublishedChanges({ - incoming: { published: "published", "data.title": "Live title" }, - existing: { + incoming: { published: "published", - "data.title": "Saved title", - [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: "{not json", + "data.title": "Live title", + "data.blurb": "New Builder field", }, + existing, }); - expect(next["data.title"]).toBe("Live title"); - expect(next).not.toHaveProperty(BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); - expect(warn).toHaveBeenCalledTimes(1); + expect(next["data.title"]).toBe("Saved title"); + expect(next["data.blurb"]).toBe("New Builder field"); + expect(next[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]).toBe("{not json"); + expect(error).toHaveBeenCalledTimes(1); + + expect( + builderCmsUnpublishedChangesAfterWrite({ + existing, + effect: "autosave", + writtenFieldKeys: ["data.title"], + wroteBody: false, + live: { + values: { "data.title": "Live title" }, + blocksHash: null, + lastUpdated: 1000, + }, + }), + ).toEqual({ + liveValues: { "data.title": "Live title" }, + liveLastUpdated: 1000, + }); } finally { - warn.mockRestore(); + error.mockRestore(); } }); diff --git a/templates/content/actions/_builder-cms-unpublished-changes.ts b/templates/content/actions/_builder-cms-unpublished-changes.ts index 49083db6a00..b8d32d261eb 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.ts @@ -238,10 +238,13 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { builderValues?: Record; }): Record { const read = readBuilderCmsUnpublishedChanges(args.existing); - if (read.state === "invalid") { - console.warn( - "Dropping an unreadable Builder unpublished-changes record; Builder's values win.", + if (read.state === "invalid" && args.existing) { + // Without the record, Content can't tell its saved values from Builder's + // live ones, so neither wins until a save writes a readable record. + console.error( + "Keeping a Builder row whose unpublished-changes record is unreadable; a save from Content replaces the record.", ); + return { ...args.incoming, ...args.existing }; } if (read.state !== "pending" || !args.existing) { return withBuilderCmsUnpublishedChanges(args.incoming, null); From 5370e8a0186bc08ec4047b27af4ac9f522265c0e Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:23:38 -0400 Subject: [PATCH 09/20] Let Builder win over an unreadable record, read the fields a save writes, and block live updates whose publication state changed --- .../actions/_builder-cms-read-client.test.ts | 46 +++++++++++++++ .../actions/_builder-cms-read-client.ts | 21 +++++-- .../_builder-cms-unpublished-changes.test.ts | 7 ++- .../_builder-cms-unpublished-changes.ts | 14 +++-- .../execute-builder-source-execution.test.ts | 58 ++++++++++++++++++- .../execute-builder-source-execution.ts | 14 +++++ 6 files changed, 147 insertions(+), 13 deletions(-) diff --git a/templates/content/actions/_builder-cms-read-client.test.ts b/templates/content/actions/_builder-cms-read-client.test.ts index 53a056729b9..c48d37245ab 100644 --- a/templates/content/actions/_builder-cms-read-client.test.ts +++ b/templates/content/actions/_builder-cms-read-client.test.ts @@ -2611,6 +2611,52 @@ describe("Builder CMS read client", () => { } }); + it("asks the public-key live read for the fields a save writes", async () => { + resolveBuilderCredentialMock.mockImplementation(async (key) => + key === "BUILDER_PUBLIC_KEY" ? "public-key" : null, + ); + // Builder leaves out every data field a projected read doesn't name. + const fetchImpl = vi.fn(async (input: URL) => { + const fields = (input.searchParams.get("fields") ?? "").split(","); + const data = { title: "Builder title", blurb: "Builder blurb" }; + return new Response( + JSON.stringify({ + id: "builder-entry-1", + published: "published", + lastUpdated: 1_786_000_000_000, + data: Object.fromEntries( + Object.entries(data).filter(([name]) => + fields.includes(`data.${name}`), + ), + ), + }), + { status: 200 }, + ); + }); + + const live = await readBuilderCmsEntryLiveState({ + model: "blog_article", + entryId: "builder-entry-1", + fieldPaths: ["data.blurb", "__builder.entry.name"], + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(live.values).toMatchObject({ + "data.title": "Builder title", + "data.blurb": "Builder blurb", + }); + const fields = + fetchImpl.mock.calls[0]![0].searchParams.get("fields")!.split(","); + expect(fields).toEqual( + expect.arrayContaining([ + "data.blurb", + "data.blocks", + "data.blocksString", + ]), + ); + expect(fields).not.toContain("__builder.entry.name"); + }); + it("hydrates an exact entry through MCP with OAuth-only authorization", async () => { resolveBuilderRequestAuthorizationMock.mockResolvedValue({ token: "oauth-access-token", diff --git a/templates/content/actions/_builder-cms-read-client.ts b/templates/content/actions/_builder-cms-read-client.ts index 23eaad508dc..dc66fafe78a 100644 --- a/templates/content/actions/_builder-cms-read-client.ts +++ b/templates/content/actions/_builder-cms-read-client.ts @@ -325,16 +325,20 @@ export function builderCmsListEntryFields(fieldPaths: readonly string[] = []) { return Array.from(fields.values()).join(","); } -const BUILDER_CMS_METADATA_ENTRY_FIELDS = builderCmsListEntryFields(); -const BUILDER_CMS_BODY_ENTRY_FIELDS = `${BUILDER_CMS_METADATA_ENTRY_FIELDS},${BUILDER_CMS_HEAVY_BODY_FIELD_PATHS.join(",")}`; - -function applyBuilderCmsBodyEntryReadParams(url: URL, publicKey: string) { +function applyBuilderCmsBodyEntryReadParams( + url: URL, + publicKey: string, + fieldPaths: readonly string[] = [], +) { url.searchParams.set("apiKey", publicKey); url.searchParams.set("includeUnpublished", "true"); url.searchParams.set("enrich", "true"); url.searchParams.set("noCache", "true"); url.searchParams.set("cachebust", String(Date.now())); - url.searchParams.set("fields", BUILDER_CMS_BODY_ENTRY_FIELDS); + url.searchParams.set( + "fields", + `${builderCmsListEntryFields(fieldPaths)},${BUILDER_CMS_HEAVY_BODY_FIELD_PATHS.join(",")}`, + ); } function builderContentApiHost() { @@ -1762,6 +1766,11 @@ function builderGeneralEntryRequestFailure(error: unknown) { export async function readBuilderCmsEntryLiveState(args: { model: string; entryId: string; + /** + * Fields a save will write. The public-key read is projected, and a field + * it leaves out would read as absent from the live entry. + */ + fieldPaths?: readonly string[]; expectedSourceSpace?: string | null; expectedSourceConnectionId?: string | null; requireBoundOAuth?: boolean; @@ -1834,7 +1843,7 @@ export async function readBuilderCmsEntryLiveState(args: { )}`, builderContentApiHost(), ); - applyBuilderCmsBodyEntryReadParams(url, publicKey); + applyBuilderCmsBodyEntryReadParams(url, publicKey, args.fieldPaths); const response = await fetchBuilderContentPage({ fetchImpl: args.fetchImpl ?? fetch, diff --git a/templates/content/actions/_builder-cms-unpublished-changes.test.ts b/templates/content/actions/_builder-cms-unpublished-changes.test.ts index d2516cfb90e..66f4f59f433 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.test.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.test.ts @@ -294,7 +294,7 @@ describe("Builder unpublished changes", () => { expect(next["data.title"]).toBe("Saved title"); }); - it("keeps the row as saved when the record cannot be read, until a save replaces it", () => { + it("lets Builder win and keeps the unreadable record until a save replaces it", () => { const error = vi.spyOn(console, "error").mockImplementation(() => {}); try { const existing = { @@ -311,9 +311,12 @@ describe("Builder unpublished changes", () => { existing, }); - expect(next["data.title"]).toBe("Saved title"); + expect(next["data.title"]).toBe("Live title"); expect(next["data.blurb"]).toBe("New Builder field"); expect(next[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]).toBe("{not json"); + expect(readBuilderCmsUnpublishedChanges(next)).toEqual({ + state: "invalid", + }); expect(error).toHaveBeenCalledTimes(1); expect( diff --git a/templates/content/actions/_builder-cms-unpublished-changes.ts b/templates/content/actions/_builder-cms-unpublished-changes.ts index b8d32d261eb..9978c82a5f3 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.ts @@ -239,12 +239,18 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { }): Record { const read = readBuilderCmsUnpublishedChanges(args.existing); if (read.state === "invalid" && args.existing) { - // Without the record, Content can't tell its saved values from Builder's - // live ones, so neither wins until a save writes a readable record. + // Without the record, Content can't tell which values it saved, so + // Builder's win: keeping the row's would let a later save write them over + // Builder edits the refreshed row no longer flags. The record stays, so + // the row reads as unreadable rather than as having nothing pending. console.error( - "Keeping a Builder row whose unpublished-changes record is unreadable; a save from Content replaces the record.", + "A Builder row's unpublished-changes record is unreadable, so Builder's values win; the next save from Content replaces the record.", ); - return { ...args.incoming, ...args.existing }; + return { + ...args.incoming, + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: + args.existing[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY], + }; } if (read.state !== "pending" || !args.existing) { return withBuilderCmsUnpublishedChanges(args.incoming, null); diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index a16e95feb6b..c57219a1e90 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -582,7 +582,10 @@ describe("execute Builder source execution", () => { it("preflights update-in-place writes between the claim and the write when string baseline matches numeric live timestamp", async () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); - const builderSource = source({ changeSets: [approvedChangeSet] }); + const builderSource = source({ + rows: [row({ sourceValues: { published: "draft" } })], + changeSets: [approvedChangeSet], + }); const execution = executionFor({ source: builderSource, changeSet: approvedChangeSet, @@ -603,6 +606,7 @@ describe("execute Builder source execution", () => { expect(deps.readLiveEntry).toHaveBeenCalledWith({ model: BUILDER_CMS_SAFE_WRITE_MODEL, entryId: "builder-entry-1", + fieldPaths: ["data.title", BUILDER_CMS_ENTRY_NAME_KEY], expectedSourceSpace: "selected-space-key", expectedSourceConnectionId: "builder-oauth-connection-1", }); @@ -620,6 +624,57 @@ describe("execute Builder source execution", () => { expect(renewCallOrder).toBeLessThan(writeCallOrder); }); + it.each([ + { + name: "blocks an update in place when Builder unpublished the entry after review", + sourceValues: { published: "published", "data.title": "Old title" }, + message: + "Builder publication state changed since this diff was approved; refresh and re-review.", + }, + { + name: "blocks an update in place when the row has no reviewed publication state", + sourceValues: { "data.title": "Old title" }, + message: + "This change has no reviewed Builder publication state; refresh and re-review.", + }, + ])("$name", async ({ sourceValues, message }) => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ + rows: [row({ sourceValues })], + changeSets: [approvedChangeSet], + }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + }); + // The title Content writes is unchanged, so only the publication state + // tells this read apart from the reviewed one. + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: true, + published: "draft", + lastUpdated: BUILDER_LAST_UPDATED_MS + 1000, + blocksHash: null, + id: "builder-entry-1", + values: { "data.title": "Old title" }, + }, + }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "draft", + }, + deps, + ), + ).rejects.toThrow(message); + expect(deps.executeWrite).not.toHaveBeenCalled(); + }); + it("does not dispatch after preflight when another attempt reclaimed the lease", async () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); const builderSource = source({ changeSets: [approvedChangeSet] }); @@ -1113,6 +1168,7 @@ describe("execute Builder source execution", () => { expect(deps.readLiveEntry).toHaveBeenCalledWith({ model: BUILDER_CMS_SAFE_WRITE_MODEL, entryId: "builder-entry-1", + fieldPaths: ["data.title", BUILDER_CMS_ENTRY_NAME_KEY], expectedSourceSpace: "selected-space-key", expectedSourceConnectionId: "builder-oauth-connection-1", }); diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index 8aeb819555f..15c84bc1bdb 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -193,6 +193,7 @@ export interface ExecuteBuilderSourceExecutionDeps { readLiveEntry: (args: { model: string; entryId: string; + fieldPaths: readonly string[]; expectedSourceSpace?: string | null; expectedSourceConnectionId?: string | null; }) => Promise; @@ -662,6 +663,17 @@ function livePreflightBlockMessage(args: { ) { return "Builder body changed since this diff was approved; refresh and re-review."; } + // An update in place lands in whatever state the entry is in now, so a + // publish or unpublish in Builder would change what was approved. + if (args.effect === "update_in_place") { + const reviewedPublished = args.baselineValues?.published; + if (typeof reviewedPublished !== "string") { + return "This change has no reviewed Builder publication state; refresh and re-review."; + } + if (args.liveState.published !== reviewedPublished) { + return "Builder publication state changed since this diff was approved; refresh and re-review."; + } + } if ( isBuilderCmsUnpublishedSaveEffect(args.effect) && args.liveState.published === "archived" @@ -2059,6 +2071,8 @@ export async function executeBuilderSourceExecutionWithDeps( liveState = await deps.readLiveEntry({ model: plan.payload.target.model, entryId: preflightEntryId, + fieldPaths: builderCmsWrittenSourceFields(plan.payload.request) + .fieldKeys, expectedSourceSpace: source.metadata.builderSpacePublicKey, expectedSourceConnectionId: source.metadata.connectionId, }); From c7f926db9f67553e2a0ecbfe9d311eea669e6b5f Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:11:04 -0400 Subject: [PATCH 10/20] Record when the live body was empty so an empty read can't keep a saved body over one the live check never read --- .../actions/_builder-cms-read-client.test.ts | 8 +- .../_builder-cms-unpublished-changes.test.ts | 77 ++++++++++++++++++- .../_builder-cms-unpublished-changes.ts | 35 +++++++-- .../actions/_builder-cms-write-adapter.ts | 2 + .../execute-builder-source-execution.test.ts | 53 +++++++++++++ .../execute-builder-source-execution.ts | 2 + 6 files changed, 167 insertions(+), 10 deletions(-) diff --git a/templates/content/actions/_builder-cms-read-client.test.ts b/templates/content/actions/_builder-cms-read-client.test.ts index c48d37245ab..e5b2f9f3e6c 100644 --- a/templates/content/actions/_builder-cms-read-client.test.ts +++ b/templates/content/actions/_builder-cms-read-client.test.ts @@ -2618,7 +2618,11 @@ describe("Builder CMS read client", () => { // Builder leaves out every data field a projected read doesn't name. const fetchImpl = vi.fn(async (input: URL) => { const fields = (input.searchParams.get("fields") ?? "").split(","); - const data = { title: "Builder title", blurb: "Builder blurb" }; + const data = { + title: "Builder title", + blurb: "Builder blurb", + blocks: [], + }; return new Response( JSON.stringify({ id: "builder-entry-1", @@ -2644,6 +2648,8 @@ describe("Builder CMS read client", () => { expect(live.values).toMatchObject({ "data.title": "Builder title", "data.blurb": "Builder blurb", + // An empty body Builder returned, which the save records as such. + "__builder.body.blockCount": 0, }); const fields = fetchImpl.mock.calls[0]![0].searchParams.get("fields")!.split(","); diff --git a/templates/content/actions/_builder-cms-unpublished-changes.test.ts b/templates/content/actions/_builder-cms-unpublished-changes.test.ts index 66f4f59f433..1658a07a5be 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.test.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.test.ts @@ -563,17 +563,23 @@ describe("Builder unpublished changes", () => { }); }); - it("tracks a body saved over an empty live body by Builder's update time", () => { + it("keeps a body saved over an empty live body while Builder's body stays empty", () => { const changes = builderCmsUnpublishedChangesAfterWrite({ existing: { published: "published" }, effect: "autosave", writtenFieldKeys: [], wroteBody: true, - live: { values: {}, blocksHash: null, lastUpdated: 1000 }, + live: { + values: {}, + blocksHash: null, + bodyEmpty: true, + lastUpdated: 1000, + }, }); expect(changes).toEqual({ liveValues: {}, liveLastUpdated: 1000, + liveBodyEmpty: true, liveBodyLastUpdated: 1000, }); const existing = withBuilderCmsUnpublishedChanges( @@ -584,13 +590,78 @@ describe("Builder unpublished changes", () => { }, changes, ); + expect(readBuilderCmsUnpublishedChanges(existing)).toEqual({ + state: "pending", + changes, + }); + // Builder saved another field since, and its body is still empty. const stillEmpty = builderSourceValuesKeepingUnpublishedChanges({ - incoming: { published: "published", lastUpdated: 1000 }, + incoming: { + published: "published", + lastUpdated: 2000, + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 0, + }, existing, }); expect(stillEmpty[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Saved body"); + const published = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + lastUpdated: 2000, + [BUILDER_CMS_BODY_CONTENT_KEY]: "Published body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "published-hash", + }, + existing, + }); + expect(published[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Published body"); + }); + + it("tracks a body saved over one the live check didn't read by Builder's update time", () => { + const changes = builderCmsUnpublishedChangesAfterWrite({ + existing: { published: "published" }, + effect: "autosave", + writtenFieldKeys: [], + wroteBody: true, + live: { values: {}, blocksHash: null, lastUpdated: 1000 }, + }); + expect(changes).toEqual({ + liveValues: {}, + liveLastUpdated: 1000, + liveBodyLastUpdated: 1000, + }); + const existing = withBuilderCmsUnpublishedChanges( + { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-hash", + }, + changes, + ); + + const unchanged = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + lastUpdated: 1000, + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 0, + }, + existing, + }); + expect(unchanged[BUILDER_CMS_BODY_CONTENT_KEY]).toBe("Saved body"); + + // The body Content saved over is unknown, so an empty one Builder saved + // later replaces Content's. + const emptied = builderSourceValuesKeepingUnpublishedChanges({ + incoming: { + published: "published", + lastUpdated: 2000, + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 0, + }, + existing, + }); + expect(emptied).not.toHaveProperty(BUILDER_CMS_BODY_CONTENT_KEY); + const published = builderSourceValuesKeepingUnpublishedChanges({ incoming: { published: "published", diff --git a/templates/content/actions/_builder-cms-unpublished-changes.ts b/templates/content/actions/_builder-cms-unpublished-changes.ts index 9978c82a5f3..85273467abf 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.ts @@ -32,11 +32,13 @@ export interface BuilderCmsUnpublishedChanges { * read older than this was made before that save. */ liveLastUpdated?: number | string; + /** The live body's blocks hash when Content last saved a body over it. */ + liveBlocksHash?: string; /** - * The live body's blocks hash when Content last saved a body over it; - * absent when that live body was empty. + * Builder returned an empty body when Content last saved a body over it. + * With neither this nor a hash, the live check didn't read the body. */ - liveBlocksHash?: string; + liveBodyEmpty?: true; /** Builder's lastUpdated when Content last saved a body over it. */ liveBodyLastUpdated?: number | string; } @@ -134,10 +136,18 @@ export function readBuilderCmsUnpublishedChanges( ) { return { state: "invalid" }; } - const { liveBlocksHash, liveLastUpdated, liveBodyLastUpdated } = record; + const { + liveBlocksHash, + liveBodyEmpty, + liveLastUpdated, + liveBodyLastUpdated, + } = record; if (liveBlocksHash !== undefined && typeof liveBlocksHash !== "string") { return { state: "invalid" }; } + if (liveBodyEmpty !== undefined && liveBodyEmpty !== true) { + return { state: "invalid" }; + } if ( (liveLastUpdated !== undefined && !isTimestamp(liveLastUpdated)) || (liveBodyLastUpdated !== undefined && !isTimestamp(liveBodyLastUpdated)) @@ -150,13 +160,18 @@ export function readBuilderCmsUnpublishedChanges( liveValues: liveValues as Record, ...(liveLastUpdated !== undefined ? { liveLastUpdated } : {}), ...(liveBlocksHash ? { liveBlocksHash } : {}), + ...(liveBodyEmpty ? { liveBodyEmpty } : {}), ...(liveBodyLastUpdated !== undefined ? { liveBodyLastUpdated } : {}), }, }; } function hasBodyRecord(changes: BuilderCmsUnpublishedChanges) { - return !!changes.liveBlocksHash || changes.liveBodyLastUpdated !== undefined; + return ( + !!changes.liveBlocksHash || + !!changes.liveBodyEmpty || + changes.liveBodyLastUpdated !== undefined + ); } export function withBuilderCmsUnpublishedChanges( @@ -207,7 +222,9 @@ export function builderCmsBodyStillSavedOver( if (!readHash && builderValues[BUILDER_CMS_BODY_BLOCK_COUNT_KEY] !== 0) { return true; } - if (readHash === (changes.liveBlocksHash ?? null)) return true; + if (readHash ? readHash === changes.liveBlocksHash : changes.liveBodyEmpty) { + return true; + } const recordedEpoch = epochMs(changes.liveBodyLastUpdated); return ( recordedEpoch !== null && @@ -345,6 +362,8 @@ export function builderCmsUnpublishedChangesAfterWrite(args: { live: { values: Record | null; blocksHash: string | null; + /** Builder returned an empty body, unlike a read that left it out. */ + bodyEmpty?: boolean; lastUpdated: number | string | null; }; }): BuilderCmsUnpublishedChanges | null { @@ -355,6 +374,7 @@ export function builderCmsUnpublishedChangesAfterWrite(args: { const liveValues = { ...current.liveValues }; let liveLastUpdated = current.liveLastUpdated; let liveBlocksHash = current.liveBlocksHash; + let liveBodyEmpty = current.liveBodyEmpty; let liveBodyLastUpdated = current.liveBodyLastUpdated; if (args.effect === "autosave") { const live = args.live.values; @@ -379,6 +399,7 @@ export function builderCmsUnpublishedChangesAfterWrite(args: { liveLastUpdated = args.live.lastUpdated ?? undefined; if (args.wroteBody) { liveBlocksHash = args.live.blocksHash ?? undefined; + liveBodyEmpty = !liveBlocksHash && args.live.bodyEmpty ? true : undefined; liveBodyLastUpdated = args.live.lastUpdated ?? undefined; } } else { @@ -388,6 +409,7 @@ export function builderCmsUnpublishedChangesAfterWrite(args: { } if (args.wroteBody) { liveBlocksHash = undefined; + liveBodyEmpty = undefined; liveBodyLastUpdated = undefined; } } @@ -395,6 +417,7 @@ export function builderCmsUnpublishedChangesAfterWrite(args: { liveValues, ...(liveLastUpdated !== undefined ? { liveLastUpdated } : {}), ...(liveBlocksHash ? { liveBlocksHash } : {}), + ...(liveBodyEmpty ? { liveBodyEmpty } : {}), ...(liveBodyLastUpdated !== undefined ? { liveBodyLastUpdated } : {}), }; } diff --git a/templates/content/actions/_builder-cms-write-adapter.ts b/templates/content/actions/_builder-cms-write-adapter.ts index 18e743f74b4..a9a8ad386f6 100644 --- a/templates/content/actions/_builder-cms-write-adapter.ts +++ b/templates/content/actions/_builder-cms-write-adapter.ts @@ -71,6 +71,8 @@ export interface BuilderCmsLivePreflight { published: string | null; lastUpdated: number | string | null; blocksHash: string | null; + /** Builder returned an empty body, unlike a read that left the body out. */ + bodyEmpty?: boolean; hasAutosaves: boolean | null; /** Live values of the fields this write sends; null when Builder did not return them. */ values: Record | null; diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index c57219a1e90..59766df0d5e 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -562,6 +562,7 @@ describe("execute Builder source execution", () => { published: "published", lastUpdated: BUILDER_LAST_UPDATED_MS, blocksHash: null, + bodyEmpty: false, hasAutosaves: null, values: { "data.title": "Old title", @@ -675,6 +676,58 @@ describe("execute Builder source execution", () => { expect(deps.executeWrite).not.toHaveBeenCalled(); }); + it("records that the live body was empty, unlike a read that left it out", async () => { + const approvedChangeSet = changeSet({ pushMode: "autosave" }); + const builderSource = source({ + rows: [ + row({ + sourceValues: { published: "published", "data.title": "Old title" }, + }), + ], + changeSets: [approvedChangeSet], + }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + values: { + "data.title": "Old title", + [BUILDER_CMS_BODY_BLOCK_COUNT_KEY]: 0, + }, + }, + }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "autosave", + }, + deps, + ), + ).resolves.toMatchObject(RESPONSE); + + expect(deps.reconcileWrite).toHaveBeenCalledWith( + expect.objectContaining({ + plan: expect.objectContaining({ + payload: expect.objectContaining({ + livePreflight: expect.objectContaining({ bodyEmpty: true }), + }), + }), + }), + ); + }); + it("does not dispatch after preflight when another attempt reclaimed the lease", async () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); 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 15c84bc1bdb..aa23f913487 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -510,6 +510,7 @@ export function builderCmsReconciledSourceValuesJson(args: { live: { values: livePreflight?.values ?? null, blocksHash: livePreflight?.blocksHash ?? null, + bodyEmpty: livePreflight?.bodyEmpty === true, lastUpdated: livePreflight?.lastUpdated ?? null, }, }), @@ -707,6 +708,7 @@ function builderCmsLivePreflightRecord(args: { published: args.liveState.published, lastUpdated: args.liveState.lastUpdated, blocksHash: args.liveState.blocksHash, + bodyEmpty: liveValues?.[BUILDER_CMS_BODY_BLOCK_COUNT_KEY] === 0, hasAutosaves: args.liveState.hasAutosaves ?? null, values: liveValues ? Object.fromEntries( From 4f0a354fc9b14abc476466e402f7b813bdbddc6e Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:45:21 -0400 Subject: [PATCH 11/20] Show each Builder row's status and say what each save does A read-only "Builder status" select property (Published, Draft, Unpublished changes) now sits first in Builder collections, in the table, the board, and the page info sidebar. Attach, refresh, body reads, and saves keep it current, and rows leaving the source lose it. It's never mapped to a Builder field. After a save, the row's stored publication state follows the write. The save dialog's row labels use the mockup's wording and are translated, and the agent-facing Stage only description no longer says it never writes to Builder. --- .../_builder-cms-unpublished-changes.test.ts | 6 + .../_builder-cms-unpublished-changes.ts | 5 +- .../actions/_builder-cms-write-settings.ts | 4 +- .../_builder-status-property.db.test.ts | 303 ++++++++++++++++++ .../actions/_builder-status-property.ts | 260 +++++++++++++++ .../content/actions/_database-source-utils.ts | 97 +++++- .../disconnect-content-database-source.ts | 14 +- .../execute-builder-source-execution.test.ts | 78 +++++ .../execute-builder-source-execution.ts | 41 +++ .../resync-content-database-source.db.test.ts | 32 ++ .../BuilderSourceReviewDialog.test.ts | 24 +- .../BuilderSourceReviewDialog.tsx | 40 ++- templates/content/app/i18n-data.ts | 11 + templates/content/app/i18n/ar-SA.ts | 11 + templates/content/app/i18n/de-DE.ts | 12 + templates/content/app/i18n/es-ES.ts | 12 + templates/content/app/i18n/fr-FR.ts | 12 + templates/content/app/i18n/hi-IN.ts | 11 + templates/content/app/i18n/ja-JP.ts | 12 + templates/content/app/i18n/ko-KR.ts | 12 + templates/content/app/i18n/pt-BR.ts | 12 + templates/content/app/i18n/zh-CN.ts | 11 + templates/content/app/i18n/zh-TW.ts | 11 + ...how-each-entry-s-builder-status-publish.md | 6 + templates/content/shared/api.ts | 3 +- 25 files changed, 1017 insertions(+), 23 deletions(-) create mode 100644 templates/content/actions/_builder-status-property.db.test.ts create mode 100644 templates/content/actions/_builder-status-property.ts create mode 100644 templates/content/changelog/2026-10-23-builder-collections-show-each-entry-s-builder-status-publish.md diff --git a/templates/content/actions/_builder-cms-unpublished-changes.test.ts b/templates/content/actions/_builder-cms-unpublished-changes.test.ts index 1658a07a5be..4c4d44a26aa 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.test.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.test.ts @@ -776,6 +776,12 @@ describe("Builder unpublished changes", () => { ), ), ).toBe("unpublished_changes"); + expect( + builderCmsEntryStatus({ + published: "published", + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: "{not json", + }), + ).toBe("unpublished_changes"); expect(builderCmsEntryStatus({ published: "archived" })).toBeNull(); expect(builderCmsEntryStatus(undefined)).toBeNull(); }); diff --git a/templates/content/actions/_builder-cms-unpublished-changes.ts b/templates/content/actions/_builder-cms-unpublished-changes.ts index 85273467abf..67d6abfccec 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.ts @@ -192,7 +192,8 @@ export function withBuilderCmsUnpublishedChanges( /** * Builder's own status words for an entry, as its content list shows them. - * Unpublished changes are Content's saves or Builder's own autosaves. + * Unpublished changes are Content's saves or Builder's own autosaves. An + * unreadable record still means Content saved some, so it counts too. */ export function builderCmsEntryStatus( sourceValues: Record | null | undefined, @@ -200,7 +201,7 @@ export function builderCmsEntryStatus( const published = sourceValues?.published; if (published === "draft") return "draft"; if (published !== "published") return null; - return readBuilderCmsUnpublishedChanges(sourceValues).state === "pending" || + return readBuilderCmsUnpublishedChanges(sourceValues).state !== "none" || sourceValues?.[BUILDER_CMS_HAS_AUTOSAVES_KEY] === true ? "unpublished_changes" : "published"; diff --git a/templates/content/actions/_builder-cms-write-settings.ts b/templates/content/actions/_builder-cms-write-settings.ts index 4861480b9de..ec282f343c0 100644 --- a/templates/content/actions/_builder-cms-write-settings.ts +++ b/templates/content/actions/_builder-cms-write-settings.ts @@ -139,9 +139,9 @@ function writeModeCopy(writeMode: ContentDatabaseSourceWriteMode) { return { pushModeLabel: "Stage only", pushModeDescription: - "Prepare and review local revisions without writing to Builder.", + "Review local changes, then save them to Builder without making them live: a published entry keeps them as unpublished changes, a draft as its draft. Never publishes or unpublishes.", notes: - "Builder CMS source with local review staging enabled and remote writes disabled.", + "Builder CMS source that saves reviewed changes to Builder and never publishes.", }; } return { diff --git a/templates/content/actions/_builder-status-property.db.test.ts b/templates/content/actions/_builder-status-property.db.test.ts new file mode 100644 index 00000000000..683b182192e --- /dev/null +++ b/templates/content/actions/_builder-status-property.db.test.ts @@ -0,0 +1,303 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +// guard:allow-unscoped — isolated PGlite fixtures intentionally inspect rows directly. + +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +import type { + BuilderCmsSourceEntry, + ContentDatabaseItem, + DocumentProperty, +} from "../shared/api"; +import { BUILDER_CMS_UNPUBLISHED_CHANGES_KEY } from "./_builder-cms-unpublished-changes"; + +const TEST_DB_PATH = join( + tmpdir(), + `builder-status-property-test-${process.pid}-${Date.now()}.pglite`, +); +const OWNER = "owner@example.com"; +const DATABASE_ID = "db_builder_status"; +const SOURCE_ID = "src_builder_status"; +const MODEL = "builder-status-model"; +const NOW = "2026-10-23T00:00:00.000Z"; + +let getDb: () => any; +let schema: typeof import("../server/db/schema.js"); +let utils: typeof import("./_database-source-utils.js"); +let statusModule: typeof import("./_builder-status-property.js"); + +const rows = [ + { documentId: "doc_status_live", title: "Live entry" }, + { documentId: "doc_status_saved", title: "Saved entry" }, + { documentId: "doc_status_autosaved", title: "Autosaved entry" }, + { documentId: "doc_status_draft", title: "Draft entry" }, + { documentId: "doc_status_unknown", title: "Unknown entry" }, +]; + +function items(documentIds = rows.map((row) => row.documentId)) { + return rows + .filter((row) => documentIds.includes(row.documentId)) + .map( + (row) => + ({ + id: `item_${row.documentId}`, + databaseId: DATABASE_ID, + document: { id: row.documentId, title: row.title, content: "" }, + position: rows.indexOf(row), + properties: [], + }) as unknown as ContentDatabaseItem, + ); +} + +function entry( + documentId: string, + sourceValues: BuilderCmsSourceEntry["sourceValues"], +): [string, BuilderCmsSourceEntry] { + const title = rows.find((row) => row.documentId === documentId)!.title; + return [ + documentId, + { + id: `entry_${documentId}`, + model: MODEL, + title, + urlPath: `/blog/${documentId}`, + updatedAt: NOW, + sourceValues: { "data.title": title, ...sourceValues }, + }, + ]; +} + +async function statuses() { + const values = await getDb() + .select({ + documentId: schema.documentPropertyValues.documentId, + valueJson: schema.documentPropertyValues.valueJson, + }) + .from(schema.documentPropertyValues) + .where( + eq( + schema.documentPropertyValues.propertyId, + statusModule.builderStatusPropertyId(DATABASE_ID), + ), + ); + return Object.fromEntries( + values.map((value: { documentId: string; valueJson: string }) => [ + value.documentId, + JSON.parse(value.valueJson), + ]), + ); +} + +beforeAll(async () => { + process.env.DATABASE_URL = `pglite:${TEST_DB_PATH}`; + 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); + utils = await import("./_database-source-utils.js"); + statusModule = await import("./_builder-status-property.js"); + + const db = getDb(); + await db.insert(schema.documents).values([ + { + id: "doc_db_builder_status", + ownerEmail: OWNER, + title: "Builder status collection", + createdAt: NOW, + updatedAt: NOW, + }, + ...rows.map((row) => ({ + id: row.documentId, + ownerEmail: OWNER, + parentId: "doc_db_builder_status", + title: row.title, + content: "", + createdAt: NOW, + updatedAt: NOW, + })), + ]); + await db.insert(schema.contentDatabases).values({ + id: DATABASE_ID, + ownerEmail: OWNER, + documentId: "doc_db_builder_status", + title: "Builder status collection", + createdAt: NOW, + updatedAt: NOW, + }); + await db.insert(schema.contentDatabaseSources).values({ + id: SOURCE_ID, + ownerEmail: OWNER, + databaseId: DATABASE_ID, + sourceType: "builder-cms", + sourceName: MODEL, + sourceTable: MODEL, + createdAt: NOW, + updatedAt: NOW, + }); + await db.insert(schema.contentDatabaseItems).values( + rows.map((row, position) => ({ + id: `item_${row.documentId}`, + ownerEmail: OWNER, + databaseId: DATABASE_ID, + documentId: row.documentId, + position, + createdAt: NOW, + updatedAt: NOW, + })), + ); + await db.insert(schema.documentPropertyDefinitions).values({ + id: "prop_builder_status_author", + ownerEmail: OWNER, + databaseId: DATABASE_ID, + name: "Author", + type: "text", + position: 0, + createdAt: NOW, + updatedAt: NOW, + }); +}, 60000); + +afterAll(() => { + rmSync(TEST_DB_PATH, { force: true, recursive: true }); +}); + +it("shows each Builder row's status in a read-only property first in the collection", async () => { + const seed = { + sourceId: SOURCE_ID, + ownerEmail: OWNER, + sourceType: "builder-cms" as const, + sourceTable: MODEL, + items: items(), + now: NOW, + existingBuilderRows: new Map([ + [ + "doc_status_saved", + { + documentId: "doc_status_saved", + sourceRowId: "entry_doc_status_saved", + sourceQualifiedId: `builder-cms://${MODEL}/entry_doc_status_saved`, + sourceDisplayKey: "Saved entry", + provenance: "Builder CMS read adapter", + lastSourceUpdatedAt: NOW, + // Unreadable, but Content still saved changes over the live entry. + sourceValuesJson: JSON.stringify({ + published: "published", + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: "{not json", + }), + }, + ], + ]), + builderEntriesByDocumentId: new Map([ + entry("doc_status_live", { published: "published" }), + entry("doc_status_saved", { published: "published" }), + entry("doc_status_autosaved", { + published: "published", + "__builder.meta.hasAutosaves": true, + }), + entry("doc_status_draft", { published: "draft" }), + entry("doc_status_unknown", {}), + ]), + }; + await utils.seedMockSourceRows(seed); + await utils.seedMockSourceRows(seed); + + const definitions = await getDb() + .select() + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, DATABASE_ID)); + const status = definitions.filter( + (definition: { systemRole: string | null }) => + definition.systemRole === "builder_status", + ); + expect(status).toHaveLength(1); + expect(status[0]).toMatchObject({ + id: statusModule.builderStatusPropertyId(DATABASE_ID), + name: "Builder status", + type: "select", + position: -1, + }); + expect(JSON.parse(status[0].optionsJson).options).toEqual([ + { id: "published", name: "Published", color: "green" }, + { id: "draft", name: "Draft", color: "gray" }, + { id: "unpublished_changes", name: "Unpublished changes", color: "orange" }, + ]); + expect(await statuses()).toEqual({ + doc_status_live: "published", + doc_status_saved: "unpublished_changes", + doc_status_autosaved: "unpublished_changes", + doc_status_draft: "draft", + }); +}); + +it("updates statuses on refresh and clears rows that leave the source", async () => { + await utils.replaceMockSourceRows({ + sourceId: SOURCE_ID, + ownerEmail: OWNER, + sourceType: "builder-cms", + sourceTable: MODEL, + items: items([ + "doc_status_live", + "doc_status_autosaved", + "doc_status_unknown", + ]), + now: NOW, + builderEntriesByDocumentId: new Map([ + entry("doc_status_live", { published: "draft" }), + entry("doc_status_autosaved", { published: "published" }), + entry("doc_status_unknown", { published: "published" }), + ]), + }); + + expect(await statuses()).toEqual({ + doc_status_live: "draft", + doc_status_autosaved: "published", + doc_status_unknown: "published", + }); +}); + +it("never maps the Builder status to a Builder field", async () => { + const definitions = await getDb() + .select() + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, DATABASE_ID)); + await utils.seedMockSourceFields({ + sourceId: SOURCE_ID, + ownerEmail: OWNER, + sourceType: "builder-cms", + properties: definitions.map( + (definition: { id: string; name: string; type: string }) => + ({ + definition: { ...definition, options: {} }, + value: null, + }) as unknown as DocumentProperty, + ), + now: NOW, + }); + + const mapped = await getDb() + .select({ propertyId: schema.contentDatabaseSourceFields.propertyId }) + .from(schema.contentDatabaseSourceFields) + .where( + and( + eq(schema.contentDatabaseSourceFields.sourceId, SOURCE_ID), + eq( + schema.contentDatabaseSourceFields.propertyId, + statusModule.builderStatusPropertyId(DATABASE_ID), + ), + ), + ); + expect(mapped).toEqual([]); + const author = await getDb() + .select({ propertyId: schema.contentDatabaseSourceFields.propertyId }) + .from(schema.contentDatabaseSourceFields) + .where( + eq( + schema.contentDatabaseSourceFields.propertyId, + "prop_builder_status_author", + ), + ); + expect(author).toHaveLength(1); +}); diff --git a/templates/content/actions/_builder-status-property.ts b/templates/content/actions/_builder-status-property.ts new file mode 100644 index 00000000000..eaa1217522f --- /dev/null +++ b/templates/content/actions/_builder-status-property.ts @@ -0,0 +1,260 @@ +import { createHash } from "node:crypto"; + +import { and, eq, inArray, sql } from "drizzle-orm"; + +import { getDb, schema } from "../server/db/index.js"; +import type { + DocumentPropertyOption, + DocumentPropertyValue, +} from "../shared/api.js"; +import { + serializePropertyOptions, + serializePropertyValue, +} from "../shared/properties.js"; +import { chunks, bulkChunkSizeForColumnCount } from "./_batch-utils.js"; +import { builderCmsEntryStatus } from "./_builder-cms-unpublished-changes.js"; +import { + propertyDefinitionsPositionScope, + withPositionLock, +} from "./_position-utils.js"; + +type Db = Pick< + ReturnType, + "select" | "insert" | "update" | "delete" +>; + +export const BUILDER_STATUS_PROPERTY_NAME = "Builder status"; + +/** Option ids are `builderCmsEntryStatus` results; Builder's own words. */ +const BUILDER_STATUS_OPTIONS: DocumentPropertyOption[] = [ + { id: "published", name: "Published", color: "green" }, + { id: "draft", name: "Draft", color: "gray" }, + { id: "unpublished_changes", name: "Unpublished changes", color: "orange" }, +]; + +function hashId(prefix: string, key: string) { + return `${prefix}_${createHash("sha256").update(key).digest("hex").slice(0, 32)}`; +} + +export function builderStatusPropertyId(databaseId: string) { + return hashId("content_builder_status_property", databaseId); +} + +// Two writers racing for a row's first status share this id, so the second +// updates the first's value instead of adding a duplicate. +function builderStatusValueId(propertyId: string, documentId: string) { + return hashId("content_builder_status_value", `${propertyId}:${documentId}`); +} + +async function builderStatusTarget(db: Db, sourceId: string) { + const [target] = await db + .select({ + databaseId: schema.contentDatabases.id, + ownerEmail: schema.contentDatabases.ownerEmail, + orgId: schema.contentDatabases.orgId, + }) + .from(schema.contentDatabaseSources) + .innerJoin( + schema.contentDatabases, + eq(schema.contentDatabases.id, schema.contentDatabaseSources.databaseId), + ) + .where( + and( + eq(schema.contentDatabaseSources.id, sourceId), + eq(schema.contentDatabaseSources.sourceType, "builder-cms"), + ), + ) + .limit(1); + return target ?? null; +} + +async function existingBuilderStatusPropertyId(db: Db, databaseId: string) { + const [definition] = await db + .select({ id: schema.documentPropertyDefinitions.id }) + .from(schema.documentPropertyDefinitions) + .where( + and( + eq(schema.documentPropertyDefinitions.databaseId, databaseId), + eq(schema.documentPropertyDefinitions.systemRole, "builder_status"), + ), + ) + .limit(1); + return definition?.id ?? null; +} + +async function createBuilderStatusProperty(args: { + target: NonNullable>>; + now: string; +}) { + const db = getDb(); + return withPositionLock( + propertyDefinitionsPositionScope(args.target.databaseId), + async () => { + const existing = await existingBuilderStatusPropertyId( + db, + args.target.databaseId, + ); + if (existing) return existing; + const [minPosition] = await db + .select({ min: sql`MIN(position)` }) + .from(schema.documentPropertyDefinitions) + .where( + eq( + schema.documentPropertyDefinitions.databaseId, + args.target.databaseId, + ), + ); + const min = Number(minPosition?.min ?? 0); + await db + .insert(schema.documentPropertyDefinitions) + .values({ + id: builderStatusPropertyId(args.target.databaseId), + ownerEmail: args.target.ownerEmail, + orgId: args.target.orgId, + databaseId: args.target.databaseId, + systemRole: "builder_status", + name: BUILDER_STATUS_PROPERTY_NAME, + type: "select", + visibility: "always_show", + optionsJson: serializePropertyOptions({ + options: BUILDER_STATUS_OPTIONS, + }), + // First, next to the title, where the status is easy to scan. + position: (Number.isSafeInteger(min) ? min : 0) - 1, + createdAt: args.now, + updatedAt: args.now, + }) + .onConflictDoNothing(); + return existingBuilderStatusPropertyId(db, args.target.databaseId); + }, + ); +} + +/** + * Writes each row's Builder status from the source values just stored for it. + * Every path that stores a Builder row's `published`, autosave flag, or + * unpublished-changes record calls this, or the status goes stale until the + * next refresh. `createProperty` is for attach and refresh, which run outside + * a transaction; inside one, rows are only updated once the property exists. + */ +export async function writeBuilderStatusValues( + db: Db, + args: { + sourceId: string; + rows: ReadonlyArray<{ + documentId: string | null | undefined; + sourceValues: Record | null | undefined; + }>; + now: string; + createProperty?: boolean; + }, +) { + const rows = args.rows.filter( + ( + row, + ): row is { documentId: string; sourceValues: typeof row.sourceValues } => + Boolean(row.documentId), + ); + if (rows.length === 0) return; + const target = await builderStatusTarget(db, args.sourceId); + if (!target) return; + const propertyId = args.createProperty + ? await createBuilderStatusProperty({ target, now: args.now }) + : await existingBuilderStatusPropertyId(db, target.databaseId); + if (!propertyId) return; + + const statusByDocumentId = new Map( + rows.map((row) => [ + row.documentId, + builderCmsEntryStatus(row.sourceValues), + ]), + ); + const documentIds = [...statusByDocumentId.keys()]; + const existing: Array<{ id: string; documentId: string; valueJson: string }> = + []; + for (const documentIdChunk of chunks( + documentIds, + bulkChunkSizeForColumnCount(1), + )) { + existing.push( + ...(await db + .select({ + id: schema.documentPropertyValues.id, + documentId: schema.documentPropertyValues.documentId, + valueJson: schema.documentPropertyValues.valueJson, + }) + .from(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.propertyId, propertyId), + inArray(schema.documentPropertyValues.documentId, documentIdChunk), + ), + )), + ); + } + const existingByDocumentId = new Map( + existing.map((value) => [value.documentId, value]), + ); + + const upserts: Array = []; + const deletedIds: string[] = []; + for (const [documentId, status] of statusByDocumentId) { + const current = existingByDocumentId.get(documentId); + if (!status) { + // An entry in no state Builder names has no status, not a stale one. + if (current) deletedIds.push(current.id); + continue; + } + const valueJson = serializePropertyValue(status); + if (current?.valueJson === valueJson) continue; + upserts.push({ + id: current?.id ?? builderStatusValueId(propertyId, documentId), + ownerEmail: target.ownerEmail, + documentId, + propertyId, + valueJson, + createdAt: args.now, + updatedAt: args.now, + }); + } + for (const chunk of chunks(upserts, bulkChunkSizeForColumnCount(7))) { + await db + .insert(schema.documentPropertyValues) + .values(chunk) + .onConflictDoUpdate({ + target: schema.documentPropertyValues.id, + set: { + valueJson: sql`excluded.value_json`, + updatedAt: sql`excluded.updated_at`, + }, + }); + } + for (const chunk of chunks(deletedIds, bulkChunkSizeForColumnCount(1))) { + await db + .delete(schema.documentPropertyValues) + .where(inArray(schema.documentPropertyValues.id, chunk)); + } +} + +/** Clears the status of rows leaving a Builder source, which no longer has one. */ +export async function clearBuilderStatusValues( + db: Db, + args: { databaseId: string; documentIds: readonly string[] }, +) { + if (args.documentIds.length === 0) return; + const propertyId = await existingBuilderStatusPropertyId(db, args.databaseId); + if (!propertyId) return; + for (const chunk of chunks( + [...args.documentIds], + bulkChunkSizeForColumnCount(1), + )) { + await db + .delete(schema.documentPropertyValues) + .where( + and( + eq(schema.documentPropertyValues.propertyId, propertyId), + inArray(schema.documentPropertyValues.documentId, chunk), + ), + ); + } +} diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 107e36565f1..ca2e7e925d2 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -116,6 +116,10 @@ import { } from "./_builder-cms-unpublished-changes.js"; import { mergeBuilderCmsWriteSettingsIntoJson } from "./_builder-cms-write-settings.js"; import { assertNoActiveBuilderSourceExecutions } from "./_builder-source-execution-guard.js"; +import { + clearBuilderStatusValues, + writeBuilderStatusValues, +} from "./_builder-status-property.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { ensureDocumentsFilesMembership } from "./_content-files.js"; import { @@ -2355,6 +2359,16 @@ async function processBuilderBodyHydrationJob( ), ), ); + await writeBuilderStatusValues(tx, { + sourceId: row.sourceId, + rows: [ + { + documentId: row.documentId, + sourceValues: writtenEntry.sourceValues, + }, + ], + now, + }); await tx .update(schema.contentDatabaseItems) .set({ @@ -2614,6 +2628,13 @@ async function processBuilderBodyHydrationJob( } return; } + await writeBuilderStatusValues(tx, { + sourceId: row.sourceId, + rows: [ + { documentId: row.documentId, sourceValues: writtenEntry.sourceValues }, + ], + now, + }); await tx .update(schema.contentDatabaseItems) .set({ @@ -2643,6 +2664,7 @@ type PreparedPristineBuilderBodyHydration = { sourceRow: ContentDatabaseSourceRecordRowDb; documentContent: string; content: string; + sourceValues: Record; sourceValuesJson: string; lastSourceUpdatedAt: string; bodyHydrationVersion: string; @@ -2736,6 +2758,7 @@ async function preparePristineBuilderBodyHydration(args: { sourceRow, documentContent: documentContent ?? "", content, + sourceValues: nextValues, sourceValuesJson: JSON.stringify(nextValues), lastSourceUpdatedAt: entryWithBody.updatedAt ?? new Date().toISOString(), bodyHydrationVersion: builderBodyHydrationVersion(entryWithBody), @@ -2902,6 +2925,18 @@ async function persistPristineBuilderBodyHydrationsInBulk( "Builder body hydration item changed.", ); } + for (const sourceId of new Set(batch.map((row) => row.job.sourceId))) { + await writeBuilderStatusValues(tx, { + sourceId, + rows: batch + .filter((row) => row.job.sourceId === sourceId) + .map((row) => ({ + documentId: row.job.documentId, + sourceValues: row.sourceValues, + })), + now, + }); + } }); for (const row of batch) persistedJobIds.add(row.job.id); } catch (error) { @@ -5628,6 +5663,11 @@ export async function seedMockSourceFields(args: { }) { const db = getDb(); const isBuilder = args.sourceType === "builder-cms"; + // Content keeps the Builder status itself; mapped to a field, a refresh + // would overwrite it and a save would send it to Builder. + const mappableProperties = args.properties.filter( + (property) => property.definition.systemRole !== "builder_status", + ); const builderModelFieldBySourceKey = new Map( (args.builderModelFields ?? []).map((field) => [ `data.${field.name.trim()}`, @@ -5713,10 +5753,10 @@ export async function seedMockSourceFields(args: { }, ...(isBuilder ? builderSourcePropertyAssignments({ - properties: args.properties, + properties: mappableProperties, existingFields: args.existingFields, }) - : args.properties.map((property) => ({ + : mappableProperties.map((property) => ({ property, sourceFieldKey: `fields.${slugifySourceField(property.definition.name)}`, })) @@ -5902,6 +5942,25 @@ export async function seedMockSourceRows(args: { ); await insertMockSourceRows(tx, rows, args.now); }); + if (args.sourceType === "builder-cms") { + await writeBuilderStatusValues(db, { + sourceId: args.sourceId, + rows: builderStatusRows(rows), + now: args.now, + createProperty: true, + }); + } +} + +function builderStatusRows( + rows: Array<{ documentId: string; sourceValuesJson: string }>, +) { + return rows.map((row) => ({ + documentId: row.documentId, + sourceValues: parseObject>( + row.sourceValuesJson, + ), + })); } function mockSourceRowsForSeed(args: { @@ -6035,6 +6094,7 @@ export async function replaceMockSourceRows(args: { const oldRows = await tx .select({ databaseItemId: schema.contentDatabaseSourceRows.databaseItemId, + documentId: schema.contentDatabaseSourceRows.documentId, }) .from(schema.contentDatabaseSourceRows) .where(scope); @@ -6044,7 +6104,33 @@ export async function replaceMockSourceRows(args: { ]); await tx.delete(schema.contentDatabaseSourceRows).where(scope); await insertMockSourceRows(tx, rows, args.now); + if (args.sourceType === "builder-cms") { + const keptDocumentIds = new Set(rows.map((row) => row.documentId)); + const [source] = await tx + .select({ databaseId: schema.contentDatabaseSources.databaseId }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, args.sourceId)); + if (source) { + await clearBuilderStatusValues(tx, { + databaseId: source.databaseId, + documentIds: oldRows + .map((row) => row.documentId) + .filter( + (documentId): documentId is string => + !!documentId && !keptDocumentIds.has(documentId), + ), + }); + } + } }); + if (args.sourceType === "builder-cms") { + await writeBuilderStatusValues(db, { + sourceId: args.sourceId, + rows: builderStatusRows(rows), + now: args.now, + createProperty: true, + }); + } } export function sourceValuesForSeededSourceRow(args: { @@ -7402,6 +7488,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { id: schema.contentDatabaseSourceRows.id, sourceRowId: schema.contentDatabaseSourceRows.sourceRowId, databaseItemId: schema.contentDatabaseSourceRows.databaseItemId, + documentId: schema.contentDatabaseSourceRows.documentId, }) .from(schema.contentDatabaseSourceRows) .where(eq(schema.contentDatabaseSourceRows.sourceId, args.source.id)) @@ -7419,6 +7506,12 @@ export async function resyncBuilderCmsSourceSnapshot(args: { .delete(schema.contentDatabaseSourceRows) .where(inArray(schema.contentDatabaseSourceRows.id, idChunk)); } + await clearBuilderStatusValues(tx, { + databaseId: args.database.id, + documentIds: staleRows + .map((row) => row.documentId) + .filter((documentId): documentId is string => !!documentId), + }); }); } diff --git a/templates/content/actions/disconnect-content-database-source.ts b/templates/content/actions/disconnect-content-database-source.ts index 1ac3d55c2a7..96d8ae9f337 100644 --- a/templates/content/actions/disconnect-content-database-source.ts +++ b/templates/content/actions/disconnect-content-database-source.ts @@ -16,6 +16,7 @@ import { isBuilderPrivatePayloadBoundToSource, } from "./_builder-cms-blob-custody.js"; import { isBuilderExecutionClaimStale } from "./_builder-cms-execution-lease.js"; +import { clearBuilderStatusValues } from "./_builder-status-property.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { getExistingSource, @@ -138,9 +139,18 @@ async function deleteSourceRecords(databaseId: string, sourceId: string) { await tx .delete(schema.contentDatabaseSourceChangeSets) .where(eq(schema.contentDatabaseSourceChangeSets.sourceId, sourceId)); - await tx + const deletedRows = await tx .delete(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)); + .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)) + .returning({ documentId: schema.contentDatabaseSourceRows.documentId }); + if (source.sourceType === "builder-cms") { + await clearBuilderStatusValues(tx, { + databaseId, + documentIds: deletedRows + .map((row) => row.documentId) + .filter((documentId): documentId is string => !!documentId), + }); + } await tx .delete(schema.contentDatabaseSourceFields) .where(eq(schema.contentDatabaseSourceFields.sourceId, sourceId)); diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 59766df0d5e..2218ad7f7c7 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -2770,6 +2770,84 @@ describe("Builder unpublished saves", () => { ); }); + it("stores the entry's publication state once the write lands", () => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ + rows: [publishedRow()], + changeSets: [approvedChangeSet], + }); + const plan = buildBuilderCmsExecutionPlan({ + source: builderSource, + changeSet: approvedChangeSet, + pushModeConfirmation: "draft", + }); + const publishedAfter = (args: { + effect: (typeof plan)["payload"]["effect"]; + existing: string; + livePublished?: string; + responseBody?: unknown; + }) => + JSON.parse( + builderCmsReconciledSourceValuesJson({ + existingSourceValuesJson: JSON.stringify({ + published: args.existing, + }), + snapshotSourceValues: undefined, + changeSet: approvedChangeSet, + plan: { + ...plan, + payload: { + ...plan.payload, + effect: args.effect, + ...(args.livePublished + ? { + livePreflight: { + checkedAt: NOW, + exists: true, + id: "builder-entry-1", + published: args.livePublished, + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + hasAutosaves: null, + values: {}, + }, + } + : {}), + }, + }, + responseBody: args.responseBody, + }), + ).published; + + expect(publishedAfter({ effect: "publish", existing: "draft" })).toBe( + "published", + ); + expect(publishedAfter({ effect: "unpublish", existing: "published" })).toBe( + "draft", + ); + // A draft save can land on an entry someone published since the refresh. + expect( + publishedAfter({ + effect: "save_draft", + existing: "draft", + livePublished: "draft", + responseBody: { published: "published" }, + }), + ).toBe("published"); + // Builder's reply to an autosave describes the autosave, not the entry. + expect( + publishedAfter({ + effect: "autosave", + existing: "draft", + livePublished: "published", + responseBody: { published: "draft" }, + }), + ).toBe("published"); + expect(publishedAfter({ effect: "save_draft", existing: "draft" })).toBe( + "draft", + ); + }); + it("takes a live write's fields out of the record, even from a response stored without live values", () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); const builderSource = source({ diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index 892054d8473..6a1a3bd0043 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -73,6 +73,7 @@ import { executeBuilderCmsWrite, } from "./_builder-cms-write-client.js"; import { createBuilderSourceTiming } from "./_builder-source-timings.js"; +import { writeBuilderStatusValues } from "./_builder-status-property.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { lockDatabaseMemberships } from "./_database-membership-lock.js"; import { @@ -438,17 +439,46 @@ function parseSourceValues( } } +/** + * The entry's publication state once the write landed. An autosave leaves the + * entry as the live check found it; Builder's reply describes the autosave. + * Otherwise Builder's reply wins: a draft save can land on an entry published + * since the check. + */ +function builderCmsPublishedAfterWrite(args: { + effect: BuilderCmsExecutionPayload["effect"]; + livePublished: string | null | undefined; + responseBody: unknown; +}) { + if (args.effect === "publish") return "published"; + if (args.effect === "unpublish" || args.effect === "create_draft") { + return "draft"; + } + if (args.effect !== "autosave") { + const replied = builderCmsResponseValue(args.responseBody, ["published"]); + if (typeof replied === "string") return replied; + } + return args.livePublished ?? null; +} + export function builderCmsReconciledSourceValuesJson(args: { existingSourceValuesJson: string | null | undefined; snapshotSourceValues: Record | undefined; changeSet: ContentDatabaseSourceChangeSet; plan: BuilderCmsExecutionPlan; + responseBody?: unknown; }) { const existing = { ...(args.snapshotSourceValues ?? {}), ...parseSourceValues(args.existingSourceValuesJson), }; const next = { ...existing }; + const published = builderCmsPublishedAfterWrite({ + effect: args.plan.payload.effect, + livePublished: args.plan.payload.livePreflight?.published, + responseBody: args.responseBody, + }); + if (published) next.published = published; for (const field of args.changeSet.fieldChanges) { next[field.sourceFieldKey] = field.proposedValue; } @@ -869,6 +899,7 @@ async function reconcileBuilderCmsWrite(args: { snapshotSourceValues: snapshotRow?.sourceValues, changeSet: args.changeSet, plan: args.plan, + responseBody: args.writeResult.responseBody, }); const patchWithValues = { ...patch, @@ -894,6 +925,16 @@ async function reconcileBuilderCmsWrite(args: { "Builder write succeeded, but the local source row was missing.", ); } + await writeBuilderStatusValues(tx, { + sourceId: args.source.id, + rows: [ + { + documentId: row?.documentId ?? args.changeSet.documentId, + sourceValues: parseSourceValues(sourceValuesJson), + }, + ], + now: args.now, + }); await tx .update(schema.contentDatabaseSourceFields) diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index cf54734ab57..4c46e81d9e6 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -5539,8 +5539,40 @@ it("lets an empty body Builder published replace a saved body through hydration" createdAt: now, updatedAt: now, }); + const { builderStatusPropertyId, writeBuilderStatusValues } = + await import("./_builder-status-property.js"); + const builderStatus = async () => { + const [value] = await db + .select({ valueJson: schema.documentPropertyValues.valueJson }) + .from(schema.documentPropertyValues) + .where( + and( + eq( + schema.documentPropertyValues.propertyId, + builderStatusPropertyId(databaseId), + ), + eq(schema.documentPropertyValues.documentId, documentId), + ), + ); + return value ? JSON.parse(value.valueJson) : null; + }; + const [savedRow] = await db + .select({ + sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson, + }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.id, "row_published_empty_body")); + await writeBuilderStatusValues(db, { + sourceId, + rows: [{ documentId, sourceValues: JSON.parse(savedRow.sourceValuesJson) }], + now, + createProperty: true, + }); + expect(await builderStatus()).toBe("unpublished_changes"); await hydrateQueuedBodies({ sourceId, limit: 1 }); + // Builder's publish replaced Content's saved changes. + expect(await builderStatus()).toBe("published"); const [after] = await db .select({ diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts index 981b07c87ba..d7a44159eb4 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { messagesByLocale } from "../../../i18n-data"; import { builderReviewDestinationLine, builderReviewEffectiveRowEffect, @@ -14,11 +15,26 @@ import { 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", + tagKey: "builderEffectNewTag", + sentenceKey: "builderEffectNewSentence", }); - expect(builderReviewRowEffectLabel("update_in_place").tag).toBe("Edit"); - expect(builderReviewRowEffectLabel("unpublish").tag).toBe("Unpublish"); + expect(builderReviewRowEffectLabel("update_in_place").tagKey).toBe( + "builderEffectEditTag", + ); + expect(builderReviewRowEffectLabel("unpublish").tagKey).toBe( + "builderEffectUnpublishTag", + ); + // Saving to a published entry doesn't make it a draft in Builder. + expect(builderReviewRowEffectLabel("autosave")).toEqual({ + tagKey: "builderEffectSaveTag", + sentenceKey: "builderEffectAutosaveSentence", + }); + expect( + messagesByLocale["en-US"].database.builderEffectAutosaveSentence, + ).toBe("Saved, not live until you publish in Builder"); + expect( + messagesByLocale["en-US"].database.builderEffectSaveDraftSentence, + ).toBe("Saves the draft"); }); it("lets a chosen transition override the base effect", () => { diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx index 7ebe7c2f2a5..b3da8a5c3f4 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx @@ -56,16 +56,36 @@ export type BuilderReviewSelection = { transitions: BuilderReviewPublicationTransitions; }; +// Keys under `database.`. const EFFECT_LABELS: Record< BuilderCmsWriteEffect, - { tag: string; sentence: string } + { tagKey: string; sentenceKey: 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" }, - save_draft: { tag: "Draft", sentence: "Saves the draft" }, - publish: { tag: "Publish", sentence: "Publishes this entry" }, - unpublish: { tag: "Unpublish", sentence: "Unpublishes the live entry" }, + create_draft: { + tagKey: "builderEffectNewTag", + sentenceKey: "builderEffectNewSentence", + }, + update_in_place: { + tagKey: "builderEffectEditTag", + sentenceKey: "builderEffectEditSentence", + }, + // An autosave lands on a published entry; it stays as Builder shows it. + autosave: { + tagKey: "builderEffectSaveTag", + sentenceKey: "builderEffectAutosaveSentence", + }, + save_draft: { + tagKey: "builderEffectSaveTag", + sentenceKey: "builderEffectSaveDraftSentence", + }, + publish: { + tagKey: "builderEffectPublishTag", + sentenceKey: "builderEffectPublishSentence", + }, + unpublish: { + tagKey: "builderEffectUnpublishTag", + sentenceKey: "builderEffectUnpublishSentence", + }, }; export function builderReviewRowEffectLabel(effect: BuilderCmsWriteEffect) { @@ -656,7 +676,7 @@ export function BuilderSourceReviewDialog({ row.effect, selection, ); - const { tag, sentence } = + const { tagKey, sentenceKey } = builderReviewRowEffectLabel(effect); const showConflict = effect !== "create_draft" && @@ -686,7 +706,7 @@ export function BuilderSourceReviewDialog({ ) : null}
- {sentence} + {t(`database.${sentenceKey}`)}
- {tag} + {t(`database.${tagKey}`)}
diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index c3b135bfab5..7be7b073c9b 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -656,6 +656,17 @@ const databaseMessages = { noBuilderWrites: "No Builder writes.", stageOnly: "Stage only", savesDraftsNeverPublishes: "Saves drafts to Builder — never publishes.", + builderEffectNewTag: "New", + builderEffectNewSentence: "Creates a new draft entry", + builderEffectEditTag: "Edit", + builderEffectEditSentence: "Updates the live entry", + builderEffectSaveTag: "Save", + builderEffectAutosaveSentence: "Saved, not live until you publish in Builder", + builderEffectSaveDraftSentence: "Saves the draft", + builderEffectPublishTag: "Publish", + builderEffectPublishSentence: "Publishes this entry", + builderEffectUnpublishTag: "Unpublish", + builderEffectUnpublishSentence: "Unpublishes the live entry", publishUpdates: "Publish updates", writesUpdatesToLiveEntries: "Writes updates to live entries.", sourcesFeedingThisColumn: "Sources feeding this column", diff --git a/templates/content/app/i18n/ar-SA.ts b/templates/content/app/i18n/ar-SA.ts index 8e76cd73e63..65e7ece2a06 100644 --- a/templates/content/app/i18n/ar-SA.ts +++ b/templates/content/app/i18n/ar-SA.ts @@ -260,6 +260,17 @@ const database = { noBuilderWrites: "لا توجد عمليات كتابة في Builder.", stageOnly: "التجهيز فقط", savesDraftsNeverPublishes: "يحفظ المسودات في Builder — لا ينشر أبدًا.", + builderEffectNewTag: "جديد", + builderEffectNewSentence: "ينشئ إدخال مسودة جديدًا", + builderEffectEditTag: "تعديل", + builderEffectEditSentence: "يحدّث الإدخال المنشور", + builderEffectSaveTag: "حفظ", + builderEffectAutosaveSentence: "محفوظ، ولن يظهر للعامة حتى تنشره في Builder", + builderEffectSaveDraftSentence: "يحفظ المسودة", + builderEffectPublishTag: "نشر", + builderEffectPublishSentence: "ينشر هذا الإدخال", + builderEffectUnpublishTag: "إلغاء النشر", + builderEffectUnpublishSentence: "يلغي نشر الإدخال المنشور", publishUpdates: "نشر التحديثات", writesUpdatesToLiveEntries: "يكتب التحديثات في الإدخالات المباشرة.", sourcesFeedingThisColumn: "المصادر التي تغذّي هذا العمود", diff --git a/templates/content/app/i18n/de-DE.ts b/templates/content/app/i18n/de-DE.ts index f743a38ebbc..9ed7ccfabe4 100644 --- a/templates/content/app/i18n/de-DE.ts +++ b/templates/content/app/i18n/de-DE.ts @@ -280,6 +280,18 @@ const database = { stageOnly: "Nur bereitstellen", savesDraftsNeverPublishes: "Speichert Entwürfe in Builder — veröffentlicht nie.", + builderEffectNewTag: "Neu", + builderEffectNewSentence: "Erstellt einen neuen Entwurfseintrag", + builderEffectEditTag: "Bearbeiten", + builderEffectEditSentence: "Aktualisiert den Live-Eintrag", + builderEffectSaveTag: "Speichern", + builderEffectAutosaveSentence: + "Gespeichert, aber erst live, wenn Sie in Builder veröffentlichen", + builderEffectSaveDraftSentence: "Speichert den Entwurf", + builderEffectPublishTag: "Veröffentlichen", + builderEffectPublishSentence: "Veröffentlicht diesen Eintrag", + builderEffectUnpublishTag: "Zurückziehen", + builderEffectUnpublishSentence: "Zieht den Live-Eintrag zurück", publishUpdates: "Updates veröffentlichen", writesUpdatesToLiveEntries: "Schreibt Updates in Live-Einträge.", sourcesFeedingThisColumn: "Quellen, die diese Spalte speisen", diff --git a/templates/content/app/i18n/es-ES.ts b/templates/content/app/i18n/es-ES.ts index 32710018bf0..6a45c3c2f95 100644 --- a/templates/content/app/i18n/es-ES.ts +++ b/templates/content/app/i18n/es-ES.ts @@ -277,6 +277,18 @@ const database = { noBuilderWrites: "Sin escrituras en Builder.", stageOnly: "Solo preparar", savesDraftsNeverPublishes: "Guarda borradores en Builder — nunca publica.", + builderEffectNewTag: "Nuevo", + builderEffectNewSentence: "Crea una nueva entrada en borrador", + builderEffectEditTag: "Editar", + builderEffectEditSentence: "Actualiza la entrada publicada", + builderEffectSaveTag: "Guardar", + builderEffectAutosaveSentence: + "Guardado, no se publica hasta que publiques en Builder", + builderEffectSaveDraftSentence: "Guarda el borrador", + builderEffectPublishTag: "Publicar", + builderEffectPublishSentence: "Publica esta entrada", + builderEffectUnpublishTag: "Despublicar", + builderEffectUnpublishSentence: "Despublica la entrada publicada", publishUpdates: "Publicar actualizaciones", writesUpdatesToLiveEntries: "Escribe actualizaciones en las entradas en vivo.", diff --git a/templates/content/app/i18n/fr-FR.ts b/templates/content/app/i18n/fr-FR.ts index f5402106fb3..341bbdc0daf 100644 --- a/templates/content/app/i18n/fr-FR.ts +++ b/templates/content/app/i18n/fr-FR.ts @@ -281,6 +281,18 @@ const database = { stageOnly: "Préparer seulement", savesDraftsNeverPublishes: "Enregistre des brouillons dans Builder — ne publie jamais.", + builderEffectNewTag: "Nouveau", + builderEffectNewSentence: "Crée une nouvelle entrée en brouillon", + builderEffectEditTag: "Modifier", + builderEffectEditSentence: "Met à jour l'entrée en direct", + builderEffectSaveTag: "Enregistrer", + builderEffectAutosaveSentence: + "Enregistré, pas en ligne tant que vous ne publiez pas dans Builder", + builderEffectSaveDraftSentence: "Enregistre le brouillon", + builderEffectPublishTag: "Publier", + builderEffectPublishSentence: "Publie cette entrée", + builderEffectUnpublishTag: "Dépublier", + builderEffectUnpublishSentence: "Dépublie l'entrée en direct", publishUpdates: "Publier les mises à jour", writesUpdatesToLiveEntries: "Écrit les mises à jour dans les entrées en direct.", diff --git a/templates/content/app/i18n/hi-IN.ts b/templates/content/app/i18n/hi-IN.ts index 3f9ba139eb9..ba4221a9c3e 100644 --- a/templates/content/app/i18n/hi-IN.ts +++ b/templates/content/app/i18n/hi-IN.ts @@ -258,6 +258,17 @@ const database = { noBuilderWrites: "कोई Builder राइट नहीं।", stageOnly: "केवल स्टेज करें", savesDraftsNeverPublishes: "Builder में ड्राफ़्ट सहेजता है — कभी प्रकाशित नहीं करता।", + builderEffectNewTag: "नया", + builderEffectNewSentence: "नई ड्राफ़्ट एंट्री बनाता है", + builderEffectEditTag: "संपादित करें", + builderEffectEditSentence: "लाइव एंट्री अपडेट करता है", + builderEffectSaveTag: "सहेजें", + builderEffectAutosaveSentence: "सहेजा गया, Builder में प्रकाशित करने तक लाइव नहीं", + builderEffectSaveDraftSentence: "ड्राफ़्ट सहेजता है", + builderEffectPublishTag: "प्रकाशित करें", + builderEffectPublishSentence: "यह एंट्री प्रकाशित करता है", + builderEffectUnpublishTag: "अप्रकाशित करें", + builderEffectUnpublishSentence: "लाइव एंट्री को अप्रकाशित करता है", publishUpdates: "अपडेट प्रकाशित करें", writesUpdatesToLiveEntries: "लाइव प्रविष्टियों में अपडेट लिखता है।", sourcesFeedingThisColumn: "इस कॉलम को आपूर्ति करने वाले स्रोत", diff --git a/templates/content/app/i18n/ja-JP.ts b/templates/content/app/i18n/ja-JP.ts index 472227ef8bf..5b9bb382900 100644 --- a/templates/content/app/i18n/ja-JP.ts +++ b/templates/content/app/i18n/ja-JP.ts @@ -275,6 +275,18 @@ const database = { noBuilderWrites: "Builder への書き込みなし。", stageOnly: "ステージングのみ", savesDraftsNeverPublishes: "Builder に下書きを保存します — 公開はしません。", + builderEffectNewTag: "新規", + builderEffectNewSentence: "新しい下書きエントリを作成します", + builderEffectEditTag: "編集", + builderEffectEditSentence: "公開中のエントリを更新します", + builderEffectSaveTag: "保存", + builderEffectAutosaveSentence: + "保存済み。Builder で公開するまで公開されません", + builderEffectSaveDraftSentence: "下書きを保存します", + builderEffectPublishTag: "公開", + builderEffectPublishSentence: "このエントリを公開します", + builderEffectUnpublishTag: "非公開にする", + builderEffectUnpublishSentence: "公開中のエントリを非公開にします", publishUpdates: "更新を公開", writesUpdatesToLiveEntries: "ライブエントリに更新を書き込みます。", sourcesFeedingThisColumn: "この列に供給するソース", diff --git a/templates/content/app/i18n/ko-KR.ts b/templates/content/app/i18n/ko-KR.ts index 5b776b09ff6..aeddad5f681 100644 --- a/templates/content/app/i18n/ko-KR.ts +++ b/templates/content/app/i18n/ko-KR.ts @@ -270,6 +270,18 @@ const database = { noBuilderWrites: "Builder 쓰기 없음.", stageOnly: "준비만", savesDraftsNeverPublishes: "Builder에 초안을 저장합니다 — 게시하지 않습니다.", + builderEffectNewTag: "새 항목", + builderEffectNewSentence: "새 초안 항목을 만듭니다", + builderEffectEditTag: "편집", + builderEffectEditSentence: "게시된 항목을 업데이트합니다", + builderEffectSaveTag: "저장", + builderEffectAutosaveSentence: + "저장됨. Builder에서 게시하기 전까지 공개되지 않습니다", + builderEffectSaveDraftSentence: "초안을 저장합니다", + builderEffectPublishTag: "게시", + builderEffectPublishSentence: "이 항목을 게시합니다", + builderEffectUnpublishTag: "게시 취소", + builderEffectUnpublishSentence: "게시된 항목의 게시를 취소합니다", publishUpdates: "업데이트 게시", writesUpdatesToLiveEntries: "라이브 항목에 업데이트를 씁니다.", sourcesFeedingThisColumn: "이 열에 공급하는 소스", diff --git a/templates/content/app/i18n/pt-BR.ts b/templates/content/app/i18n/pt-BR.ts index 3ff52cbcbb0..2b6133d54c6 100644 --- a/templates/content/app/i18n/pt-BR.ts +++ b/templates/content/app/i18n/pt-BR.ts @@ -277,6 +277,18 @@ const database = { noBuilderWrites: "Sem gravações no Builder.", stageOnly: "Apenas preparar", savesDraftsNeverPublishes: "Salva rascunhos no Builder — nunca publica.", + builderEffectNewTag: "Novo", + builderEffectNewSentence: "Cria uma nova entrada de rascunho", + builderEffectEditTag: "Editar", + builderEffectEditSentence: "Atualiza a entrada publicada", + builderEffectSaveTag: "Salvar", + builderEffectAutosaveSentence: + "Salvo, só fica no ar quando você publicar no Builder", + builderEffectSaveDraftSentence: "Salva o rascunho", + builderEffectPublishTag: "Publicar", + builderEffectPublishSentence: "Publica esta entrada", + builderEffectUnpublishTag: "Despublicar", + builderEffectUnpublishSentence: "Despublica a entrada publicada", publishUpdates: "Publicar atualizações", writesUpdatesToLiveEntries: "Grava atualizações nas entradas ao vivo.", sourcesFeedingThisColumn: "Fontes que alimentam esta coluna", diff --git a/templates/content/app/i18n/zh-CN.ts b/templates/content/app/i18n/zh-CN.ts index fe91c61d660..1117a98ecf2 100644 --- a/templates/content/app/i18n/zh-CN.ts +++ b/templates/content/app/i18n/zh-CN.ts @@ -246,6 +246,17 @@ const database = { noBuilderWrites: "无 Builder 写入。", stageOnly: "仅暂存", savesDraftsNeverPublishes: "将草稿保存到 Builder — 从不发布。", + builderEffectNewTag: "新建", + builderEffectNewSentence: "创建一个新的草稿条目", + builderEffectEditTag: "编辑", + builderEffectEditSentence: "更新已发布的条目", + builderEffectSaveTag: "保存", + builderEffectAutosaveSentence: "已保存,在 Builder 中发布后才会上线", + builderEffectSaveDraftSentence: "保存草稿", + builderEffectPublishTag: "发布", + builderEffectPublishSentence: "发布此条目", + builderEffectUnpublishTag: "取消发布", + builderEffectUnpublishSentence: "取消发布已上线的条目", publishUpdates: "发布更新", writesUpdatesToLiveEntries: "将更新写入实时条目。", sourcesFeedingThisColumn: "为此列供数的来源", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 695ec78d2d3..75db5d95bcc 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1429,6 +1429,17 @@ const overrides = { noBuilderWrites: "無 Builder 寫入。", stageOnly: "僅暫存", savesDraftsNeverPublishes: "將草稿儲存到 Builder — 從不發佈。", + builderEffectNewTag: "新增", + builderEffectNewSentence: "建立一個新的草稿項目", + builderEffectEditTag: "編輯", + builderEffectEditSentence: "更新已發佈的項目", + builderEffectSaveTag: "儲存", + builderEffectAutosaveSentence: "已儲存,在 Builder 中發佈後才會上線", + builderEffectSaveDraftSentence: "儲存草稿", + builderEffectPublishTag: "發佈", + builderEffectPublishSentence: "發佈此項目", + builderEffectUnpublishTag: "取消發佈", + builderEffectUnpublishSentence: "取消發佈已上線的項目", publishUpdates: "發佈更新", writesUpdatesToLiveEntries: "將更新寫入即時項目。", sourcesFeedingThisColumn: "為此欄供給的來源", diff --git a/templates/content/changelog/2026-10-23-builder-collections-show-each-entry-s-builder-status-publish.md b/templates/content/changelog/2026-10-23-builder-collections-show-each-entry-s-builder-status-publish.md new file mode 100644 index 00000000000..6b5564400e5 --- /dev/null +++ b/templates/content/changelog/2026-10-23-builder-collections-show-each-entry-s-builder-status-publish.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-23 +--- + +Builder collections show each entry's Builder status (Published, Draft, or Unpublished changes) in the table, board, and page info. diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index e46dfcb3a3f..32fecc7bab4 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -292,7 +292,8 @@ export interface DocumentPropertyDefinition { export type DocumentPropertySystemRole = | "files_kind" | "files_parent" - | "files_source"; + | "files_source" + | "builder_status"; /** A linked row of a relation property, resolved for the current viewer. */ export interface DocumentPropertyRelationTarget { From 47026ddcfeaa0d76587e48d8d9f30b1d70bb7b87 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:31:13 -0400 Subject: [PATCH 12/20] Keep each Builder status true through refreshes, copies, and source swaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refresh now stores its rows and their statuses in one transaction, so a save can no longer land between them and leave the status stale. The status property is added when a Builder source is attached, even to an empty model. A save whose reply leaves out the publication state keeps the stored one; only an autosave falls back to the live check. Copies of a row or page don't carry its Builder status, and a row keeps a status only while some Builder source in the collection still has it, so replacing the Builder source with a local one clears it. Setup discovery reports the status as a system definition agents can't edit, and the French save labels say "entrée publiée". --- .../_builder-status-property.db.test.ts | 201 +++++++++++++++++- .../actions/_builder-status-property.ts | 81 ++++++- .../content/actions/_database-row-mutation.ts | 1 + .../actions/_database-setup-discovery.ts | 12 +- .../content/actions/_database-source-utils.ts | 69 +++--- .../content/actions/database-setup.db.test.ts | 47 ++++ .../disconnect-content-database-source.ts | 14 +- .../actions/duplicate-database-item.ts | 16 +- .../actions/duplicate-database-items.ts | 3 +- templates/content/actions/duplicate-page.ts | 20 +- .../execute-builder-source-execution.test.ts | 11 + .../execute-builder-source-execution.ts | 11 +- .../resync-content-database-source.db.test.ts | 9 +- templates/content/app/i18n/fr-FR.ts | 4 +- ...n-database-create-property-preservation.ts | 2 + templates/content/shared/api.ts | 2 + 16 files changed, 421 insertions(+), 82 deletions(-) diff --git a/templates/content/actions/_builder-status-property.db.test.ts b/templates/content/actions/_builder-status-property.db.test.ts index 683b182192e..4fed4f7517c 100644 --- a/templates/content/actions/_builder-status-property.db.test.ts +++ b/templates/content/actions/_builder-status-property.db.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; // guard:allow-unscoped — isolated PGlite fixtures intentionally inspect rows directly. import { and, eq } from "drizzle-orm"; -import { afterAll, beforeAll, expect, it } from "vitest"; +import { afterAll, beforeAll, expect, it, vi } from "vitest"; import type { BuilderCmsSourceEntry, @@ -13,6 +13,23 @@ import type { } from "../shared/api"; import { BUILDER_CMS_UNPUBLISHED_CHANGES_KEY } from "./_builder-cms-unpublished-changes"; +const statusWrite = vi.hoisted(() => ({ nextFailure: null as Error | null })); +vi.mock("./_builder-status-property.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + async writeBuilderStatusValues( + ...args: Parameters + ) { + const failure = statusWrite.nextFailure; + statusWrite.nextFailure = null; + if (failure) throw failure; + return actual.writeBuilderStatusValues(...args); + }, + }; +}); + const TEST_DB_PATH = join( tmpdir(), `builder-status-property-test-${process.pid}-${Date.now()}.pglite`, @@ -301,3 +318,185 @@ it("never maps the Builder status to a Builder field", async () => { ); expect(author).toHaveLength(1); }); + +async function storedPublished(documentId: string) { + const [row] = await getDb() + .select({ + sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson, + }) + .from(schema.contentDatabaseSourceRows) + .where( + and( + eq(schema.contentDatabaseSourceRows.sourceId, SOURCE_ID), + eq(schema.contentDatabaseSourceRows.documentId, documentId), + ), + ); + return JSON.parse(row.sourceValuesJson).published; +} + +// A save racing a refresh can only land before or after both its rows and +// their statuses, never between them. +it("stores a refresh's rows and their statuses together", async () => { + const before = await storedPublished("doc_status_live"); + statusWrite.nextFailure = new Error("Status write failed"); + + await expect( + utils.replaceMockSourceRows({ + sourceId: SOURCE_ID, + ownerEmail: OWNER, + sourceType: "builder-cms", + sourceTable: MODEL, + items: items(["doc_status_live"]), + now: NOW, + builderEntriesByDocumentId: new Map([ + entry("doc_status_live", { + published: before === "draft" ? "published" : "draft", + }), + ]), + documentIds: ["doc_status_live"], + }), + ).rejects.toThrow("Status write failed"); + + expect(await storedPublished("doc_status_live")).toBe(before); + expect((await statuses()).doc_status_live).toBe(before); +}); + +it("adds the status property to a Builder collection with no rows yet", async () => { + for (const [databaseId, write] of [ + ["db_builder_status_empty_attach", utils.replaceMockSourceRows], + ["db_builder_status_empty_seed", utils.seedMockSourceRows], + ] as const) { + const sourceId = `src_${databaseId}`; + await getDb() + .insert(schema.documents) + .values({ + id: `doc_${databaseId}`, + ownerEmail: OWNER, + title: "Empty Builder collection", + createdAt: NOW, + updatedAt: NOW, + }); + await getDb() + .insert(schema.contentDatabases) + .values({ + id: databaseId, + ownerEmail: OWNER, + documentId: `doc_${databaseId}`, + title: "Empty Builder collection", + createdAt: NOW, + updatedAt: NOW, + }); + await getDb().insert(schema.contentDatabaseSources).values({ + id: sourceId, + ownerEmail: OWNER, + databaseId, + sourceType: "builder-cms", + sourceName: MODEL, + sourceTable: MODEL, + createdAt: NOW, + updatedAt: NOW, + }); + + await write({ + sourceId, + ownerEmail: OWNER, + sourceType: "builder-cms", + sourceTable: MODEL, + items: [], + now: NOW, + }); + + const definitions = await getDb() + .select({ + id: schema.documentPropertyDefinitions.id, + systemRole: schema.documentPropertyDefinitions.systemRole, + }) + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, databaseId)); + expect(definitions).toEqual([ + { + id: statusModule.builderStatusPropertyId(databaseId), + systemRole: "builder_status", + }, + ]); + } +}); + +it("leaves the status off a row's copy, which has no Builder entry", async () => { + await getDb() + .insert(schema.documentPropertyValues) + .values({ + id: "value_builder_status_author", + ownerEmail: OWNER, + documentId: "doc_status_live", + propertyId: "prop_builder_status_author", + valueJson: JSON.stringify("Ada"), + createdAt: NOW, + updatedAt: NOW, + }); + const values = await getDb() + .select() + .from(schema.documentPropertyValues) + .where(eq(schema.documentPropertyValues.documentId, "doc_status_live")); + expect( + values.map((value: { propertyId: string }) => value.propertyId).sort(), + ).toEqual( + [ + "prop_builder_status_author", + statusModule.builderStatusPropertyId(DATABASE_ID), + ].sort(), + ); + + const copied = await statusModule.withoutBuilderStatusValues(getDb(), values); + expect(copied.map((value) => value.propertyId)).toEqual([ + "prop_builder_status_author", + ]); +}); + +it("clears statuses when a local source replaces the Builder one, except rows another Builder source has", async () => { + await getDb().insert(schema.contentDatabaseSources).values({ + id: "src_builder_status_details", + ownerEmail: OWNER, + databaseId: DATABASE_ID, + sourceType: "builder-cms", + sourceName: MODEL, + sourceTable: MODEL, + createdAt: NOW, + updatedAt: NOW, + }); + await getDb() + .insert(schema.contentDatabaseSourceRows) + .values({ + id: "row_builder_status_details", + ownerEmail: OWNER, + sourceId: "src_builder_status_details", + databaseItemId: "item_doc_status_autosaved", + documentId: "doc_status_autosaved", + sourceRowId: "entry_builder_status_details", + sourceQualifiedId: `builder-cms://${MODEL}/entry_builder_status_details`, + sourceDisplayKey: "Autosaved entry", + sourceValuesJson: JSON.stringify({ published: "published" }), + createdAt: NOW, + updatedAt: NOW, + }); + // Attaching a local source over the Builder one keeps its source id. + await getDb() + .update(schema.contentDatabaseSources) + .set({ sourceType: "mock-local", sourceTable: DATABASE_ID }) + .where(eq(schema.contentDatabaseSources.id, SOURCE_ID)); + + await utils.replaceMockSourceRows({ + sourceId: SOURCE_ID, + ownerEmail: OWNER, + sourceType: "mock-local", + sourceTable: DATABASE_ID, + items: items([ + "doc_status_live", + "doc_status_autosaved", + "doc_status_unknown", + ]), + now: NOW, + }); + + expect(await statuses()).toEqual({ doc_status_autosaved: "published" }); +}); diff --git a/templates/content/actions/_builder-status-property.ts b/templates/content/actions/_builder-status-property.ts index eaa1217522f..174bba3b4da 100644 --- a/templates/content/actions/_builder-status-property.ts +++ b/templates/content/actions/_builder-status-property.ts @@ -130,12 +130,26 @@ async function createBuilderStatusProperty(args: { ); } +/** + * Adds a Builder source's status property, even to a collection with no rows + * yet, so its first saved entry has a status. Attach and refresh call it + * before they store rows. It takes its own lock, so it runs outside a + * transaction. + */ +export async function ensureBuilderStatusProperty(args: { + sourceId: string; + now: string; +}) { + const target = await builderStatusTarget(getDb(), args.sourceId); + if (!target) return null; + return createBuilderStatusProperty({ target, now: args.now }); +} + /** * Writes each row's Builder status from the source values just stored for it. * Every path that stores a Builder row's `published`, autosave flag, or - * unpublished-changes record calls this, or the status goes stale until the - * next refresh. `createProperty` is for attach and refresh, which run outside - * a transaction; inside one, rows are only updated once the property exists. + * unpublished-changes record calls this in the same transaction, or a write + * racing it can leave the status stale until the next refresh. */ export async function writeBuilderStatusValues( db: Db, @@ -146,7 +160,6 @@ export async function writeBuilderStatusValues( sourceValues: Record | null | undefined; }>; now: string; - createProperty?: boolean; }, ) { const rows = args.rows.filter( @@ -158,9 +171,10 @@ export async function writeBuilderStatusValues( if (rows.length === 0) return; const target = await builderStatusTarget(db, args.sourceId); if (!target) return; - const propertyId = args.createProperty - ? await createBuilderStatusProperty({ target, now: args.now }) - : await existingBuilderStatusPropertyId(db, target.databaseId); + const propertyId = await existingBuilderStatusPropertyId( + db, + target.databaseId, + ); if (!propertyId) return; const statusByDocumentId = new Map( @@ -236,7 +250,34 @@ export async function writeBuilderStatusValues( } } -/** Clears the status of rows leaving a Builder source, which no longer has one. */ +/** Drops Builder statuses from values being copied: a copy has no Builder entry. */ +export async function withoutBuilderStatusValues< + T extends { propertyId: string }, +>(db: Db, values: T[]) { + const propertyIds = [...new Set(values.map((value) => value.propertyId))]; + const statusPropertyIds = new Set(); + for (const chunk of chunks(propertyIds, bulkChunkSizeForColumnCount(1))) { + const definitions = await db + .select({ id: schema.documentPropertyDefinitions.id }) + .from(schema.documentPropertyDefinitions) + .where( + and( + inArray(schema.documentPropertyDefinitions.id, chunk), + eq(schema.documentPropertyDefinitions.systemRole, "builder_status"), + ), + ); + for (const definition of definitions) { + statusPropertyIds.add(definition.id); + } + } + return values.filter((value) => !statusPropertyIds.has(value.propertyId)); +} + +/** + * Clears the status of rows that left a source, unless another Builder source + * in the collection still has them. Call it after the source's rows or type + * have changed, in the same transaction. + */ export async function clearBuilderStatusValues( db: Db, args: { databaseId: string; documentIds: readonly string[] }, @@ -245,15 +286,35 @@ export async function clearBuilderStatusValues( const propertyId = await existingBuilderStatusPropertyId(db, args.databaseId); if (!propertyId) return; for (const chunk of chunks( - [...args.documentIds], + [...new Set(args.documentIds)], bulkChunkSizeForColumnCount(1), )) { + const stillBuilder = await db + .select({ documentId: schema.contentDatabaseSourceRows.documentId }) + .from(schema.contentDatabaseSourceRows) + .innerJoin( + schema.contentDatabaseSources, + eq( + schema.contentDatabaseSources.id, + schema.contentDatabaseSourceRows.sourceId, + ), + ) + .where( + and( + eq(schema.contentDatabaseSources.databaseId, args.databaseId), + eq(schema.contentDatabaseSources.sourceType, "builder-cms"), + inArray(schema.contentDatabaseSourceRows.documentId, chunk), + ), + ); + const kept = new Set(stillBuilder.map((row) => row.documentId)); + const cleared = chunk.filter((documentId) => !kept.has(documentId)); + if (cleared.length === 0) continue; await db .delete(schema.documentPropertyValues) .where( and( eq(schema.documentPropertyValues.propertyId, propertyId), - inArray(schema.documentPropertyValues.documentId, chunk), + inArray(schema.documentPropertyValues.documentId, cleared), ), ); } diff --git a/templates/content/actions/_database-row-mutation.ts b/templates/content/actions/_database-row-mutation.ts index 1524316f61f..e252d1e36c2 100644 --- a/templates/content/actions/_database-row-mutation.ts +++ b/templates/content/actions/_database-row-mutation.ts @@ -500,6 +500,7 @@ export async function getDatabaseMutationContract( type, writable, sourceManaged, + systemRole: definition.systemRole ?? null, acceptedShape: writable ? acceptedShape(type) : null, options: parsePropertyOptions(definition.optionsJson), }; diff --git a/templates/content/actions/_database-setup-discovery.ts b/templates/content/actions/_database-setup-discovery.ts index b39600417cd..88208b2aec0 100644 --- a/templates/content/actions/_database-setup-discovery.ts +++ b/templates/content/actions/_database-setup-discovery.ts @@ -48,11 +48,13 @@ export async function getDatabaseSetupContract( setupPropertyTypes.some((type) => type === property.type); const reason = !canEdit ? "Database edit access required" - : property.sourceManaged - ? "Source-managed definition" - : !editableMetadata - ? "Computed definitions use their own actions" - : null; + : property.systemRole + ? "System definition" + : property.sourceManaged + ? "Source-managed definition" + : !editableMetadata + ? "Computed definitions use their own actions" + : null; return { propertyId: property.id, editable: reason === null, reason }; }), }; diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index ca2e7e925d2..2c8c8c5804d 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -118,6 +118,7 @@ import { mergeBuilderCmsWriteSettingsIntoJson } from "./_builder-cms-write-setti import { assertNoActiveBuilderSourceExecutions } from "./_builder-source-execution-guard.js"; import { clearBuilderStatusValues, + ensureBuilderStatusProperty, writeBuilderStatusValues, } from "./_builder-status-property.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; @@ -5932,6 +5933,12 @@ export async function seedMockSourceRows(args: { existingBuilderRows?: Map; builderEntriesByDocumentId?: Map; }) { + if (args.sourceType === "builder-cms") { + await ensureBuilderStatusProperty({ + sourceId: args.sourceId, + now: args.now, + }); + } if (args.items.length === 0) return; const db = getDb(); const rows = mockSourceRowsForSeed(args); @@ -5941,15 +5948,14 @@ export async function seedMockSourceRows(args: { rows.map((row) => row.databaseItemId), ); await insertMockSourceRows(tx, rows, args.now); + if (args.sourceType === "builder-cms") { + await writeBuilderStatusValues(tx, { + sourceId: args.sourceId, + rows: builderStatusRows(rows), + now: args.now, + }); + } }); - if (args.sourceType === "builder-cms") { - await writeBuilderStatusValues(db, { - sourceId: args.sourceId, - rows: builderStatusRows(rows), - now: args.now, - createProperty: true, - }); - } } function builderStatusRows( @@ -6079,6 +6085,12 @@ export async function replaceMockSourceRows(args: { builderEntriesByDocumentId?: Map; documentIds?: string[]; }) { + if (args.sourceType === "builder-cms") { + await ensureBuilderStatusProperty({ + sourceId: args.sourceId, + now: args.now, + }); + } const db = getDb(); const rows = mockSourceRowsForSeed(args); await db.transaction(async (tx) => { @@ -6104,33 +6116,26 @@ export async function replaceMockSourceRows(args: { ]); await tx.delete(schema.contentDatabaseSourceRows).where(scope); await insertMockSourceRows(tx, rows, args.now); + const [source] = await tx + .select({ databaseId: schema.contentDatabaseSources.databaseId }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, args.sourceId)); + if (source) { + await clearBuilderStatusValues(tx, { + databaseId: source.databaseId, + documentIds: oldRows + .map((row) => row.documentId) + .filter((documentId): documentId is string => !!documentId), + }); + } if (args.sourceType === "builder-cms") { - const keptDocumentIds = new Set(rows.map((row) => row.documentId)); - const [source] = await tx - .select({ databaseId: schema.contentDatabaseSources.databaseId }) - .from(schema.contentDatabaseSources) - .where(eq(schema.contentDatabaseSources.id, args.sourceId)); - if (source) { - await clearBuilderStatusValues(tx, { - databaseId: source.databaseId, - documentIds: oldRows - .map((row) => row.documentId) - .filter( - (documentId): documentId is string => - !!documentId && !keptDocumentIds.has(documentId), - ), - }); - } + await writeBuilderStatusValues(tx, { + sourceId: args.sourceId, + rows: builderStatusRows(rows), + now: args.now, + }); } }); - if (args.sourceType === "builder-cms") { - await writeBuilderStatusValues(db, { - sourceId: args.sourceId, - rows: builderStatusRows(rows), - now: args.now, - createProperty: true, - }); - } } export function sourceValuesForSeededSourceRow(args: { diff --git a/templates/content/actions/database-setup.db.test.ts b/templates/content/actions/database-setup.db.test.ts index c3ee77f2ba0..c2d133832aa 100644 --- a/templates/content/actions/database-setup.db.test.ts +++ b/templates/content/actions/database-setup.db.test.ts @@ -115,6 +115,53 @@ describe("ordinary database setup", () => { ).toBe("Current draft"); }); + it("advertises a system property such as the Builder status as not editable", async () => { + const created = await as(owner, () => + create.run({ + spaceId, + title: "Builder articles", + idempotencyKey: "system-property-metadata", + }), + ); + const now = new Date().toISOString(); + await getDb() + .insert(schema.documentPropertyDefinitions) + .values({ + id: `builder_status_${runId}`, + ownerEmail: owner, + databaseId: created.database.id, + systemRole: "builder_status", + name: "Builder status", + type: "select", + position: -1, + createdAt: now, + updatedAt: now, + }); + const description = await as(owner, () => + describeDatabase.run({ databaseId: created.database.id }), + ); + if (!("mutationContract" in description) || !description.mutationContract) + throw new Error("Fixture database has no mutation contract."); + expect( + description.mutationContract.properties.find( + (property) => property.id === `builder_status_${runId}`, + ), + ).toMatchObject({ + writable: false, + sourceManaged: false, + systemRole: "builder_status", + }); + expect( + description.setupContract?.properties.find( + (property) => property.propertyId === `builder_status_${runId}`, + ), + ).toEqual({ + propertyId: `builder_status_${runId}`, + editable: false, + reason: "System definition", + }); + }); + it("preserves the UI optimistic Page identity through the shared reliable create path", async () => { const newDocumentId = `optimistic-${runId}`; const args = { diff --git a/templates/content/actions/disconnect-content-database-source.ts b/templates/content/actions/disconnect-content-database-source.ts index 96d8ae9f337..96e8592c26a 100644 --- a/templates/content/actions/disconnect-content-database-source.ts +++ b/templates/content/actions/disconnect-content-database-source.ts @@ -143,14 +143,12 @@ async function deleteSourceRecords(databaseId: string, sourceId: string) { .delete(schema.contentDatabaseSourceRows) .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)) .returning({ documentId: schema.contentDatabaseSourceRows.documentId }); - if (source.sourceType === "builder-cms") { - await clearBuilderStatusValues(tx, { - databaseId, - documentIds: deletedRows - .map((row) => row.documentId) - .filter((documentId): documentId is string => !!documentId), - }); - } + await clearBuilderStatusValues(tx, { + databaseId, + documentIds: deletedRows + .map((row) => row.documentId) + .filter((documentId): documentId is string => !!documentId), + }); await tx .delete(schema.contentDatabaseSourceFields) .where(eq(schema.contentDatabaseSourceFields.sourceId, sourceId)); diff --git a/templates/content/actions/duplicate-database-item.ts b/templates/content/actions/duplicate-database-item.ts index c2662edc87d..8576fb83e86 100644 --- a/templates/content/actions/duplicate-database-item.ts +++ b/templates/content/actions/duplicate-database-item.ts @@ -14,6 +14,7 @@ import { syncPrivateIconReference, verifyPrivateIconCopiedFromDocument, } from "../server/lib/private-icon-references.js"; +import { withoutBuilderStatusValues } from "./_builder-status-property.js"; import { lockContentDatabaseMutation, touchContentDatabase, @@ -152,12 +153,15 @@ export default defineAction({ row.database.systemRole === "files" ? lockedRow.document.parentId : row.database.documentId; - const values = await tx - .select() - .from(schema.documentPropertyValues) - .where( - eq(schema.documentPropertyValues.documentId, lockedRow.document.id), - ); + const values = await withoutBuilderStatusValues( + tx, + await tx + .select() + .from(schema.documentPropertyValues) + .where( + eq(schema.documentPropertyValues.documentId, lockedRow.document.id), + ), + ); const [claimedSource] = await tx .select({ id: schema.contentDatabaseItemKeyClaims.id }) .from(schema.contentDatabaseItemKeyClaims) diff --git a/templates/content/actions/duplicate-database-items.ts b/templates/content/actions/duplicate-database-items.ts index 1f1dcdc876e..ea9a9d2891f 100644 --- a/templates/content/actions/duplicate-database-items.ts +++ b/templates/content/actions/duplicate-database-items.ts @@ -13,6 +13,7 @@ import { syncPrivateIconReference, verifyPrivateIconCopiedFromDocument, } from "../server/lib/private-icon-references.js"; +import { withoutBuilderStatusValues } from "./_builder-status-property.js"; import { lockContentDatabaseMutation, touchContentDatabase, @@ -167,7 +168,7 @@ export default defineAction({ string, Array >(); - for (const value of values) { + for (const value of await withoutBuilderStatusValues(tx, values)) { const list = valuesByDocumentId.get(value.documentId) ?? []; list.push(value); valuesByDocumentId.set(value.documentId, list); diff --git a/templates/content/actions/duplicate-page.ts b/templates/content/actions/duplicate-page.ts index 002f3d6bcb9..85e87dd6ce5 100644 --- a/templates/content/actions/duplicate-page.ts +++ b/templates/content/actions/duplicate-page.ts @@ -7,6 +7,7 @@ import { eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { withoutBuilderStatusValues } from "./_builder-status-property.js"; import { flushOpenDocumentEditorToSql } from "./_document-flush.js"; import { assertSubtreeCanChangeSpace, @@ -166,15 +167,18 @@ export default defineAction({ } if (sameSpace) { - const values = await db - .select() - .from(schema.documentPropertyValues) - .where( - inArray( - schema.documentPropertyValues.documentId, - pages.map((page) => page.id), + const values = await withoutBuilderStatusValues( + db, + await db + .select() + .from(schema.documentPropertyValues) + .where( + inArray( + schema.documentPropertyValues.documentId, + pages.map((page) => page.id), + ), ), - ); + ); const owners = new Map(); for (const row of await db .select({ diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 2218ad7f7c7..41f33006c56 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -2846,6 +2846,17 @@ describe("Builder unpublished saves", () => { expect(publishedAfter({ effect: "save_draft", existing: "draft" })).toBe( "draft", ); + // A reply without the state keeps what a refresh stored since the check. + for (const effect of ["save_draft", "update_in_place"] as const) { + expect( + publishedAfter({ + effect, + existing: "published", + livePublished: "draft", + responseBody: { id: "builder-entry-1" }, + }), + ).toBe("published"); + } }); it("takes a live write's fields out of the record, even from a response stored without live values", () => { diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index 6a1a3bd0043..9f54e704eeb 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -443,7 +443,8 @@ function parseSourceValues( * The entry's publication state once the write landed. An autosave leaves the * entry as the live check found it; Builder's reply describes the autosave. * Otherwise Builder's reply wins: a draft save can land on an entry published - * since the check. + * since the check. A reply without the state leaves the stored state, which a + * refresh may have updated since the check. */ function builderCmsPublishedAfterWrite(args: { effect: BuilderCmsExecutionPayload["effect"]; @@ -454,11 +455,9 @@ function builderCmsPublishedAfterWrite(args: { if (args.effect === "unpublish" || args.effect === "create_draft") { return "draft"; } - if (args.effect !== "autosave") { - const replied = builderCmsResponseValue(args.responseBody, ["published"]); - if (typeof replied === "string") return replied; - } - return args.livePublished ?? null; + if (args.effect === "autosave") return args.livePublished ?? null; + const replied = builderCmsResponseValue(args.responseBody, ["published"]); + return typeof replied === "string" ? replied : null; } export function builderCmsReconciledSourceValuesJson(args: { diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index 4c46e81d9e6..29fc98a2210 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -5539,8 +5539,11 @@ it("lets an empty body Builder published replace a saved body through hydration" createdAt: now, updatedAt: now, }); - const { builderStatusPropertyId, writeBuilderStatusValues } = - await import("./_builder-status-property.js"); + const { + builderStatusPropertyId, + ensureBuilderStatusProperty, + writeBuilderStatusValues, + } = await import("./_builder-status-property.js"); const builderStatus = async () => { const [value] = await db .select({ valueJson: schema.documentPropertyValues.valueJson }) @@ -5562,11 +5565,11 @@ it("lets an empty body Builder published replace a saved body through hydration" }) .from(schema.contentDatabaseSourceRows) .where(eq(schema.contentDatabaseSourceRows.id, "row_published_empty_body")); + await ensureBuilderStatusProperty({ sourceId, now }); await writeBuilderStatusValues(db, { sourceId, rows: [{ documentId, sourceValues: JSON.parse(savedRow.sourceValuesJson) }], now, - createProperty: true, }); expect(await builderStatus()).toBe("unpublished_changes"); diff --git a/templates/content/app/i18n/fr-FR.ts b/templates/content/app/i18n/fr-FR.ts index 341bbdc0daf..d2851d0fe78 100644 --- a/templates/content/app/i18n/fr-FR.ts +++ b/templates/content/app/i18n/fr-FR.ts @@ -284,7 +284,7 @@ const database = { builderEffectNewTag: "Nouveau", builderEffectNewSentence: "Crée une nouvelle entrée en brouillon", builderEffectEditTag: "Modifier", - builderEffectEditSentence: "Met à jour l'entrée en direct", + builderEffectEditSentence: "Met à jour l'entrée publiée", builderEffectSaveTag: "Enregistrer", builderEffectAutosaveSentence: "Enregistré, pas en ligne tant que vous ne publiez pas dans Builder", @@ -292,7 +292,7 @@ const database = { builderEffectPublishTag: "Publier", builderEffectPublishSentence: "Publie cette entrée", builderEffectUnpublishTag: "Dépublier", - builderEffectUnpublishSentence: "Dépublie l'entrée en direct", + builderEffectUnpublishSentence: "Dépublie l'entrée publiée", publishUpdates: "Publier les mises à jour", writesUpdatesToLiveEntries: "Écrit les mises à jour dans les entrées en direct.", diff --git a/templates/content/parity/run-database-create-property-preservation.ts b/templates/content/parity/run-database-create-property-preservation.ts index ac1f4bc7553..65188755392 100644 --- a/templates/content/parity/run-database-create-property-preservation.ts +++ b/templates/content/parity/run-database-create-property-preservation.ts @@ -91,6 +91,7 @@ const runner = await createAgentRunner({ type: "status", writable: true, sourceManaged: false, + systemRole: null, acceptedShape: null, options: { options: [ @@ -108,6 +109,7 @@ const runner = await createAgentRunner({ type: "text", writable: true, sourceManaged: false, + systemRole: null, acceptedShape: null, options: {}, }, diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 32fecc7bab4..de34c86d54c 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -706,6 +706,8 @@ export interface ContentDatabaseMutationContract { type: DocumentPropertyType; writable: boolean; sourceManaged: boolean; + /** Set on properties Content maintains itself, such as the Builder status. */ + systemRole: string | null; acceptedShape: string | null; options: DocumentPropertyOptions; }>; From da94c0eb2a59209ac7226a67a07168e66c54a6a2 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:40:27 -0400 Subject: [PATCH 13/20] Say "save" in the Builder dialog's footer, button, and Stage only line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The save dialog's footer counted autosaves and draft saves as "draft saves" and its button said "Push N updates", while the rows say "Save". The footer now says "2 saves", the button says "Save 2 to Builder" when every selected row is a save, and the Stage only description says "Saves changes to Builder — never publishes." A save to a published entry isn't a draft. The footer and button labels move into the catalog, translated in all 10 locales. --- .../BuilderSourceReviewDialog.test.ts | 46 +++++++- .../BuilderSourceReviewDialog.tsx | 108 ++++++++++++------ .../editor/database/DatabaseView.tsx | 2 +- templates/content/app/i18n-data.ts | 20 +++- templates/content/app/i18n/ar-SA.ts | 20 +++- templates/content/app/i18n/de-DE.ts | 22 +++- templates/content/app/i18n/es-ES.ts | 20 +++- templates/content/app/i18n/fr-FR.ts | 22 +++- templates/content/app/i18n/hi-IN.ts | 20 +++- templates/content/app/i18n/ja-JP.ts | 20 +++- templates/content/app/i18n/ko-KR.ts | 21 +++- templates/content/app/i18n/pt-BR.ts | 20 +++- templates/content/app/i18n/zh-CN.ts | 20 +++- templates/content/app/i18n/zh-TW.ts | 20 +++- 14 files changed, 329 insertions(+), 52 deletions(-) diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts index d7a44159eb4..8ade1bcc9d4 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts @@ -7,11 +7,17 @@ import { builderReviewIntentSummary, builderReviewHasUnconfirmedUnpublish, builderReviewPublicationTransitionsMap, + builderReviewPushVerb, builderReviewResultStatus, builderReviewRowEffectLabel, builderReviewScopedTransitionSelections, } from "./BuilderSourceReviewDialog"; +function english(message: { key: string; count: number }) { + const database = messagesByLocale["en-US"].database as Record; + return database[message.key].replace("{{count}}", String(message.count)); +} + describe("BuilderSourceReviewDialog publication intent helpers", () => { it("labels each write effect in plain language", () => { expect(builderReviewRowEffectLabel("create_draft")).toEqual({ @@ -55,8 +61,12 @@ describe("BuilderSourceReviewDialog publication intent helpers", () => { }); it("summarizes per-row intent in plain language, honoring transitions", () => { + const summary = ( + rows: Parameters[0], + selections: Parameters[1] = {}, + ) => builderReviewIntentSummary(rows, selections).map(english).join(" · "); expect( - builderReviewIntentSummary( + summary( [ { changeSetId: "change-1", effect: "create_draft" }, { changeSetId: "change-2", effect: "update_in_place" }, @@ -71,6 +81,40 @@ describe("BuilderSourceReviewDialog publication intent helpers", () => { }, ), ).toBe("1 draft to create · 1 to publish · 1 to unpublish"); + // A save to a published entry isn't a draft, so the footer matches the rows. + expect( + summary([ + { changeSetId: "change-1", effect: "autosave" }, + { changeSetId: "change-2", effect: "save_draft" }, + ]), + ).toBe("2 saves"); + expect(summary([])).toBe("No changes"); + }); + + it("names the send button for what the selected rows do", () => { + const verb = ( + effects: Parameters< + typeof builderReviewPushVerb + >[0]["rows"][number]["effect"][], + liveWritesEnabled = true, + ) => + english( + builderReviewPushVerb({ + rows: effects.map((effect, index) => ({ + changeSetId: `change-${index}`, + effect, + })), + selections: {}, + liveWritesEnabled, + }), + ); + expect(verb(["autosave", "save_draft"])).toBe("Save 2 to Builder"); + expect(verb(["autosave"])).toBe("Save to Builder"); + expect(verb(["create_draft", "create_draft"])).toBe("Create 2 drafts"); + expect(verb(["autosave", "update_in_place"])).toBe("Push 2 updates"); + expect(verb(["update_in_place"])).toBe("Push update"); + expect(verb(["publish", "autosave"])).toBe("Publish"); + expect(verb(["autosave"], false)).toBe("Check"); }); it("describes the destination from the dominant effect", () => { diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx index b3da8a5c3f4..4a04090ee55 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx @@ -102,10 +102,17 @@ export function builderReviewEffectiveRowEffect( return baseEffect; } +type BuilderReviewMessage = { key: string; count: number }; + +function countedMessage(count: number, oneKey: string, manyKey: string) { + return { key: count > 1 ? manyKey : oneKey, count }; +} + +/** Message keys and counts for each effect in the selection, in row order. */ export function builderReviewIntentSummary( rows: { changeSetId: string; effect: BuilderCmsWriteEffect }[], selections: BuilderReviewPublicationTransitionSelections, -) { +): BuilderReviewMessage[] { const counts: Record = { create_draft: 0, update_in_place: 0, @@ -119,24 +126,62 @@ export function builderReviewIntentSummary( builderReviewEffectiveRowEffect(row.effect, selections[row.changeSetId]) ] += 1; } - const parts: string[] = []; - if (counts.create_draft) { - parts.push( - `${counts.create_draft} draft${counts.create_draft === 1 ? "" : "s"} to create`, + // Autosaves and draft saves share the rows' "Save" tag. + const saves = counts.autosave + counts.save_draft; + const parts = [ + countedMessage( + counts.create_draft, + "builderIntentNewOne", + "builderIntentNew", + ), + countedMessage( + counts.update_in_place, + "builderIntentEditOne", + "builderIntentEdit", + ), + countedMessage(saves, "builderIntentSaveOne", "builderIntentSave"), + { key: "builderIntentPublish", count: counts.publish }, + { key: "builderIntentUnpublish", count: counts.unpublish }, + ].filter((part) => part.count > 0); + return parts.length > 0 ? parts : [{ key: "builderIntentNone", count: 0 }]; +} + +/** The send button's message key, named for what the selected rows do. */ +export function builderReviewPushVerb(args: { + rows: { changeSetId: string; effect: BuilderCmsWriteEffect }[]; + selections: BuilderReviewPublicationTransitionSelections; + liveWritesEnabled: boolean; +}): BuilderReviewMessage { + const count = args.rows.length; + if (!args.liveWritesEnabled) return { key: "builderPushVerbCheck", count }; + const effects = new Set( + args.rows.map((row) => + builderReviewEffectiveRowEffect( + row.effect, + args.selections[row.changeSetId], + ), + ), + ); + if (effects.has("unpublish")) + return { key: "builderPushVerbUnpublish", count }; + if (effects.has("publish")) return { key: "builderPushVerbPublish", count }; + const only = (...kinds: BuilderCmsWriteEffect[]) => + effects.size > 0 && [...effects].every((effect) => kinds.includes(effect)); + if (only("create_draft")) { + return countedMessage( + count, + "builderPushVerbCreateOne", + "builderPushVerbCreate", ); } - if (counts.update_in_place) { - parts.push( - `${counts.update_in_place} update${counts.update_in_place === 1 ? "" : "s"}`, + if (only("autosave", "save_draft")) { + return countedMessage( + count, + "builderPushVerbSaveOne", + "builderPushVerbSave", ); } - const draftSaves = counts.autosave + counts.save_draft; - if (draftSaves) { - parts.push(`${draftSaves} draft save${draftSaves === 1 ? "" : "s"}`); - } - if (counts.publish) parts.push(`${counts.publish} to publish`); - if (counts.unpublish) parts.push(`${counts.unpublish} to unpublish`); - return parts.join(" · ") || "No changes"; + return countedMessage(count, "builderPushVerbPushOne", "builderPushVerbPush"); } export function builderReviewDestinationLine(args: { @@ -452,7 +497,9 @@ export function BuilderSourceReviewDialog({ const intentSummary = builderReviewIntentSummary( selectedReviewRows, scopedTransitionSelections, - ); + ) + .map((part) => t(`database.${part.key}`, { count: part.count })) + .join(" · "); const destinationLine = builderReviewDestinationLine({ rows: selectedReviewRows, selections: scopedTransitionSelections, @@ -512,27 +559,14 @@ export function BuilderSourceReviewDialog({ : review?.liveWritesEnabled ? "Prepare the full Builder payload before anything is sent." : null; - const effectiveEffects = new Set( - selectedReviewRows.map((row) => - builderReviewEffectiveRowEffect( - row.effect, - scopedTransitionSelections[row.changeSetId], - ), - ), - ); - const pushVerb = !review?.liveWritesEnabled - ? "Check" - : effectiveEffects.has("unpublish") - ? "Unpublish" - : effectiveEffects.has("publish") - ? "Publish" - : effectiveEffects.size === 1 && effectiveEffects.has("create_draft") - ? selectedReviewRows.length > 1 - ? `Create ${selectedReviewRows.length} drafts` - : "Create draft" - : selectedReviewRows.length > 1 - ? `Push ${selectedReviewRows.length} updates` - : "Push update"; + const pushVerbMessage = builderReviewPushVerb({ + rows: selectedReviewRows, + selections: scopedTransitionSelections, + liveWritesEnabled: review?.liveWritesEnabled === true, + }); + const pushVerb = t(`database.${pushVerbMessage.key}`, { + count: pushVerbMessage.count, + }); const buttonLabel = pending ? executionPending ? "Sending…" diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 986d99abe85..05ccdd0e17f 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -8491,7 +8491,7 @@ const BUILDER_WRITE_MODE_OPTIONS: Array<{ { mode: "stage_only", labelKey: "stageOnly", - descriptionKey: "savesDraftsNeverPublishes", + descriptionKey: "savesChangesNeverPublishes", }, { mode: "publish_updates", diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 7be7b073c9b..5b53080d07c 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -655,7 +655,7 @@ const databaseMessages = { "Connect a Builder collection or another table to map onto these rows.", noBuilderWrites: "No Builder writes.", stageOnly: "Stage only", - savesDraftsNeverPublishes: "Saves drafts to Builder — never publishes.", + savesChangesNeverPublishes: "Saves changes to Builder — never publishes.", builderEffectNewTag: "New", builderEffectNewSentence: "Creates a new draft entry", builderEffectEditTag: "Edit", @@ -667,6 +667,24 @@ const databaseMessages = { builderEffectPublishSentence: "Publishes this entry", builderEffectUnpublishTag: "Unpublish", builderEffectUnpublishSentence: "Unpublishes the live entry", + builderIntentNewOne: "1 draft to create", + builderIntentNew: "{{count}} drafts to create", + builderIntentEditOne: "1 update", + builderIntentEdit: "{{count}} updates", + builderIntentSaveOne: "1 save", + builderIntentSave: "{{count}} saves", + builderIntentPublish: "{{count}} to publish", + builderIntentUnpublish: "{{count}} to unpublish", + builderIntentNone: "No changes", + builderPushVerbCheck: "Check", + builderPushVerbPublish: "Publish", + builderPushVerbUnpublish: "Unpublish", + builderPushVerbCreateOne: "Create draft", + builderPushVerbCreate: "Create {{count}} drafts", + builderPushVerbSaveOne: "Save to Builder", + builderPushVerbSave: "Save {{count}} to Builder", + builderPushVerbPushOne: "Push update", + builderPushVerbPush: "Push {{count}} updates", publishUpdates: "Publish updates", writesUpdatesToLiveEntries: "Writes updates to live entries.", sourcesFeedingThisColumn: "Sources feeding this column", diff --git a/templates/content/app/i18n/ar-SA.ts b/templates/content/app/i18n/ar-SA.ts index 65e7ece2a06..a937271a58d 100644 --- a/templates/content/app/i18n/ar-SA.ts +++ b/templates/content/app/i18n/ar-SA.ts @@ -259,7 +259,7 @@ const database = { "اربط مجموعة Builder أو جدولًا آخر لتعيينه على هذه الصفوف.", noBuilderWrites: "لا توجد عمليات كتابة في Builder.", stageOnly: "التجهيز فقط", - savesDraftsNeverPublishes: "يحفظ المسودات في Builder — لا ينشر أبدًا.", + savesChangesNeverPublishes: "يحفظ التغييرات في Builder — لا ينشر أبدًا.", builderEffectNewTag: "جديد", builderEffectNewSentence: "ينشئ إدخال مسودة جديدًا", builderEffectEditTag: "تعديل", @@ -271,6 +271,24 @@ const database = { builderEffectPublishSentence: "ينشر هذا الإدخال", builderEffectUnpublishTag: "إلغاء النشر", builderEffectUnpublishSentence: "يلغي نشر الإدخال المنشور", + builderIntentNewOne: "مسودة واحدة للإنشاء", + builderIntentNew: "{{count}} مسودات للإنشاء", + builderIntentEditOne: "تحديث واحد", + builderIntentEdit: "{{count}} تحديثات", + builderIntentSaveOne: "حفظ واحد", + builderIntentSave: "{{count}} عمليات حفظ", + builderIntentPublish: "{{count}} للنشر", + builderIntentUnpublish: "{{count}} لإلغاء النشر", + builderIntentNone: "لا توجد تغييرات", + builderPushVerbCheck: "تحقق", + builderPushVerbPublish: "نشر", + builderPushVerbUnpublish: "إلغاء النشر", + builderPushVerbCreateOne: "إنشاء مسودة", + builderPushVerbCreate: "إنشاء {{count}} مسودات", + builderPushVerbSaveOne: "حفظ في Builder", + builderPushVerbSave: "حفظ {{count}} في Builder", + builderPushVerbPushOne: "دفع التحديث", + builderPushVerbPush: "دفع {{count}} تحديثات", publishUpdates: "نشر التحديثات", writesUpdatesToLiveEntries: "يكتب التحديثات في الإدخالات المباشرة.", sourcesFeedingThisColumn: "المصادر التي تغذّي هذا العمود", diff --git a/templates/content/app/i18n/de-DE.ts b/templates/content/app/i18n/de-DE.ts index 9ed7ccfabe4..9fc822d8a6c 100644 --- a/templates/content/app/i18n/de-DE.ts +++ b/templates/content/app/i18n/de-DE.ts @@ -278,8 +278,8 @@ const database = { "Verbinde eine Builder-Sammlung oder eine andere Tabelle, um sie diesen Zeilen zuzuordnen.", noBuilderWrites: "Keine Builder-Schreibvorgänge.", stageOnly: "Nur bereitstellen", - savesDraftsNeverPublishes: - "Speichert Entwürfe in Builder — veröffentlicht nie.", + savesChangesNeverPublishes: + "Speichert Änderungen in Builder — veröffentlicht nie.", builderEffectNewTag: "Neu", builderEffectNewSentence: "Erstellt einen neuen Entwurfseintrag", builderEffectEditTag: "Bearbeiten", @@ -292,6 +292,24 @@ const database = { builderEffectPublishSentence: "Veröffentlicht diesen Eintrag", builderEffectUnpublishTag: "Zurückziehen", builderEffectUnpublishSentence: "Zieht den Live-Eintrag zurück", + builderIntentNewOne: "1 Entwurf zu erstellen", + builderIntentNew: "{{count}} Entwürfe zu erstellen", + builderIntentEditOne: "1 Aktualisierung", + builderIntentEdit: "{{count}} Aktualisierungen", + builderIntentSaveOne: "1 Speicherung", + builderIntentSave: "{{count}} Speicherungen", + builderIntentPublish: "{{count}} zu veröffentlichen", + builderIntentUnpublish: "{{count}} zurückzuziehen", + builderIntentNone: "Keine Änderungen", + builderPushVerbCheck: "Prüfen", + builderPushVerbPublish: "Veröffentlichen", + builderPushVerbUnpublish: "Zurückziehen", + builderPushVerbCreateOne: "Entwurf erstellen", + builderPushVerbCreate: "{{count}} Entwürfe erstellen", + builderPushVerbSaveOne: "In Builder speichern", + builderPushVerbSave: "{{count}} in Builder speichern", + builderPushVerbPushOne: "Aktualisierung übertragen", + builderPushVerbPush: "{{count}} Aktualisierungen übertragen", publishUpdates: "Updates veröffentlichen", writesUpdatesToLiveEntries: "Schreibt Updates in Live-Einträge.", sourcesFeedingThisColumn: "Quellen, die diese Spalte speisen", diff --git a/templates/content/app/i18n/es-ES.ts b/templates/content/app/i18n/es-ES.ts index 6a45c3c2f95..1cd2e770d97 100644 --- a/templates/content/app/i18n/es-ES.ts +++ b/templates/content/app/i18n/es-ES.ts @@ -276,7 +276,7 @@ const database = { "Conecta una colección de Builder u otra tabla para asignarla a estas filas.", noBuilderWrites: "Sin escrituras en Builder.", stageOnly: "Solo preparar", - savesDraftsNeverPublishes: "Guarda borradores en Builder — nunca publica.", + savesChangesNeverPublishes: "Guarda cambios en Builder — nunca publica.", builderEffectNewTag: "Nuevo", builderEffectNewSentence: "Crea una nueva entrada en borrador", builderEffectEditTag: "Editar", @@ -289,6 +289,24 @@ const database = { builderEffectPublishSentence: "Publica esta entrada", builderEffectUnpublishTag: "Despublicar", builderEffectUnpublishSentence: "Despublica la entrada publicada", + builderIntentNewOne: "1 borrador por crear", + builderIntentNew: "{{count}} borradores por crear", + builderIntentEditOne: "1 actualización", + builderIntentEdit: "{{count}} actualizaciones", + builderIntentSaveOne: "1 guardado", + builderIntentSave: "{{count}} guardados", + builderIntentPublish: "{{count}} por publicar", + builderIntentUnpublish: "{{count}} por despublicar", + builderIntentNone: "Sin cambios", + builderPushVerbCheck: "Comprobar", + builderPushVerbPublish: "Publicar", + builderPushVerbUnpublish: "Despublicar", + builderPushVerbCreateOne: "Crear borrador", + builderPushVerbCreate: "Crear {{count}} borradores", + builderPushVerbSaveOne: "Guardar en Builder", + builderPushVerbSave: "Guardar {{count}} en Builder", + builderPushVerbPushOne: "Enviar actualización", + builderPushVerbPush: "Enviar {{count}} actualizaciones", publishUpdates: "Publicar actualizaciones", writesUpdatesToLiveEntries: "Escribe actualizaciones en las entradas en vivo.", diff --git a/templates/content/app/i18n/fr-FR.ts b/templates/content/app/i18n/fr-FR.ts index d2851d0fe78..9bf08d7ee3b 100644 --- a/templates/content/app/i18n/fr-FR.ts +++ b/templates/content/app/i18n/fr-FR.ts @@ -279,8 +279,8 @@ const database = { "Connectez une collection Builder ou une autre table pour la mapper sur ces lignes.", noBuilderWrites: "Aucune écriture Builder.", stageOnly: "Préparer seulement", - savesDraftsNeverPublishes: - "Enregistre des brouillons dans Builder — ne publie jamais.", + savesChangesNeverPublishes: + "Enregistre les modifications dans Builder — ne publie jamais.", builderEffectNewTag: "Nouveau", builderEffectNewSentence: "Crée une nouvelle entrée en brouillon", builderEffectEditTag: "Modifier", @@ -293,6 +293,24 @@ const database = { builderEffectPublishSentence: "Publie cette entrée", builderEffectUnpublishTag: "Dépublier", builderEffectUnpublishSentence: "Dépublie l'entrée publiée", + builderIntentNewOne: "1 brouillon à créer", + builderIntentNew: "{{count}} brouillons à créer", + builderIntentEditOne: "1 mise à jour", + builderIntentEdit: "{{count}} mises à jour", + builderIntentSaveOne: "1 enregistrement", + builderIntentSave: "{{count}} enregistrements", + builderIntentPublish: "{{count}} à publier", + builderIntentUnpublish: "{{count}} à dépublier", + builderIntentNone: "Aucune modification", + builderPushVerbCheck: "Vérifier", + builderPushVerbPublish: "Publier", + builderPushVerbUnpublish: "Dépublier", + builderPushVerbCreateOne: "Créer le brouillon", + builderPushVerbCreate: "Créer {{count}} brouillons", + builderPushVerbSaveOne: "Enregistrer dans Builder", + builderPushVerbSave: "Enregistrer {{count}} dans Builder", + builderPushVerbPushOne: "Envoyer la mise à jour", + builderPushVerbPush: "Envoyer {{count}} mises à jour", publishUpdates: "Publier les mises à jour", writesUpdatesToLiveEntries: "Écrit les mises à jour dans les entrées en direct.", diff --git a/templates/content/app/i18n/hi-IN.ts b/templates/content/app/i18n/hi-IN.ts index ba4221a9c3e..2248db152d9 100644 --- a/templates/content/app/i18n/hi-IN.ts +++ b/templates/content/app/i18n/hi-IN.ts @@ -257,7 +257,7 @@ const database = { "इन पंक्तियों पर मैप करने के लिए एक Builder संग्रह या अन्य तालिका कनेक्ट करें।", noBuilderWrites: "कोई Builder राइट नहीं।", stageOnly: "केवल स्टेज करें", - savesDraftsNeverPublishes: "Builder में ड्राफ़्ट सहेजता है — कभी प्रकाशित नहीं करता।", + savesChangesNeverPublishes: "Builder में बदलाव सहेजता है — कभी प्रकाशित नहीं करता।", builderEffectNewTag: "नया", builderEffectNewSentence: "नई ड्राफ़्ट एंट्री बनाता है", builderEffectEditTag: "संपादित करें", @@ -269,6 +269,24 @@ const database = { builderEffectPublishSentence: "यह एंट्री प्रकाशित करता है", builderEffectUnpublishTag: "अप्रकाशित करें", builderEffectUnpublishSentence: "लाइव एंट्री को अप्रकाशित करता है", + builderIntentNewOne: "बनाने के लिए 1 ड्राफ़्ट", + builderIntentNew: "बनाने के लिए {{count}} ड्राफ़्ट", + builderIntentEditOne: "1 अपडेट", + builderIntentEdit: "{{count}} अपडेट", + builderIntentSaveOne: "सहेजने के लिए 1", + builderIntentSave: "सहेजने के लिए {{count}}", + builderIntentPublish: "प्रकाशित करने के लिए {{count}}", + builderIntentUnpublish: "अप्रकाशित करने के लिए {{count}}", + builderIntentNone: "कोई बदलाव नहीं", + builderPushVerbCheck: "जाँचें", + builderPushVerbPublish: "प्रकाशित करें", + builderPushVerbUnpublish: "अप्रकाशित करें", + builderPushVerbCreateOne: "ड्राफ़्ट बनाएँ", + builderPushVerbCreate: "{{count}} ड्राफ़्ट बनाएँ", + builderPushVerbSaveOne: "Builder में सहेजें", + builderPushVerbSave: "Builder में {{count}} सहेजें", + builderPushVerbPushOne: "अपडेट पुश करें", + builderPushVerbPush: "{{count}} अपडेट पुश करें", publishUpdates: "अपडेट प्रकाशित करें", writesUpdatesToLiveEntries: "लाइव प्रविष्टियों में अपडेट लिखता है।", sourcesFeedingThisColumn: "इस कॉलम को आपूर्ति करने वाले स्रोत", diff --git a/templates/content/app/i18n/ja-JP.ts b/templates/content/app/i18n/ja-JP.ts index 5b9bb382900..6e54099a922 100644 --- a/templates/content/app/i18n/ja-JP.ts +++ b/templates/content/app/i18n/ja-JP.ts @@ -274,7 +274,7 @@ const database = { "Builder コレクションまたは別のテーブルを接続して、これらの行にマッピングします。", noBuilderWrites: "Builder への書き込みなし。", stageOnly: "ステージングのみ", - savesDraftsNeverPublishes: "Builder に下書きを保存します — 公開はしません。", + savesChangesNeverPublishes: "Builder に変更を保存します — 公開はしません。", builderEffectNewTag: "新規", builderEffectNewSentence: "新しい下書きエントリを作成します", builderEffectEditTag: "編集", @@ -287,6 +287,24 @@ const database = { builderEffectPublishSentence: "このエントリを公開します", builderEffectUnpublishTag: "非公開にする", builderEffectUnpublishSentence: "公開中のエントリを非公開にします", + builderIntentNewOne: "作成する下書き 1 件", + builderIntentNew: "作成する下書き {{count}} 件", + builderIntentEditOne: "更新 1 件", + builderIntentEdit: "更新 {{count}} 件", + builderIntentSaveOne: "保存 1 件", + builderIntentSave: "保存 {{count}} 件", + builderIntentPublish: "公開 {{count}} 件", + builderIntentUnpublish: "非公開 {{count}} 件", + builderIntentNone: "変更なし", + builderPushVerbCheck: "確認", + builderPushVerbPublish: "公開", + builderPushVerbUnpublish: "非公開にする", + builderPushVerbCreateOne: "下書きを作成", + builderPushVerbCreate: "下書きを {{count}} 件作成", + builderPushVerbSaveOne: "Builder に保存", + builderPushVerbSave: "{{count}} 件を Builder に保存", + builderPushVerbPushOne: "更新をプッシュ", + builderPushVerbPush: "{{count}} 件の更新をプッシュ", publishUpdates: "更新を公開", writesUpdatesToLiveEntries: "ライブエントリに更新を書き込みます。", sourcesFeedingThisColumn: "この列に供給するソース", diff --git a/templates/content/app/i18n/ko-KR.ts b/templates/content/app/i18n/ko-KR.ts index aeddad5f681..229b3ba19a6 100644 --- a/templates/content/app/i18n/ko-KR.ts +++ b/templates/content/app/i18n/ko-KR.ts @@ -269,7 +269,8 @@ const database = { "Builder 컬렉션 또는 다른 테이블을 연결하여 이 행에 매핑하세요.", noBuilderWrites: "Builder 쓰기 없음.", stageOnly: "준비만", - savesDraftsNeverPublishes: "Builder에 초안을 저장합니다 — 게시하지 않습니다.", + savesChangesNeverPublishes: + "Builder에 변경 사항을 저장합니다 — 게시하지 않습니다.", builderEffectNewTag: "새 항목", builderEffectNewSentence: "새 초안 항목을 만듭니다", builderEffectEditTag: "편집", @@ -282,6 +283,24 @@ const database = { builderEffectPublishSentence: "이 항목을 게시합니다", builderEffectUnpublishTag: "게시 취소", builderEffectUnpublishSentence: "게시된 항목의 게시를 취소합니다", + builderIntentNewOne: "만들 초안 1개", + builderIntentNew: "만들 초안 {{count}}개", + builderIntentEditOne: "업데이트 1개", + builderIntentEdit: "업데이트 {{count}}개", + builderIntentSaveOne: "저장 1개", + builderIntentSave: "저장 {{count}}개", + builderIntentPublish: "게시할 항목 {{count}}개", + builderIntentUnpublish: "게시 취소할 항목 {{count}}개", + builderIntentNone: "변경 사항 없음", + builderPushVerbCheck: "확인", + builderPushVerbPublish: "게시", + builderPushVerbUnpublish: "게시 취소", + builderPushVerbCreateOne: "초안 만들기", + builderPushVerbCreate: "초안 {{count}}개 만들기", + builderPushVerbSaveOne: "Builder에 저장", + builderPushVerbSave: "{{count}}개를 Builder에 저장", + builderPushVerbPushOne: "업데이트 푸시", + builderPushVerbPush: "업데이트 {{count}}개 푸시", publishUpdates: "업데이트 게시", writesUpdatesToLiveEntries: "라이브 항목에 업데이트를 씁니다.", sourcesFeedingThisColumn: "이 열에 공급하는 소스", diff --git a/templates/content/app/i18n/pt-BR.ts b/templates/content/app/i18n/pt-BR.ts index 2b6133d54c6..a20d758faeb 100644 --- a/templates/content/app/i18n/pt-BR.ts +++ b/templates/content/app/i18n/pt-BR.ts @@ -276,7 +276,7 @@ const database = { "Conecte uma coleção do Builder ou outra tabela para mapeá-la nessas linhas.", noBuilderWrites: "Sem gravações no Builder.", stageOnly: "Apenas preparar", - savesDraftsNeverPublishes: "Salva rascunhos no Builder — nunca publica.", + savesChangesNeverPublishes: "Salva alterações no Builder — nunca publica.", builderEffectNewTag: "Novo", builderEffectNewSentence: "Cria uma nova entrada de rascunho", builderEffectEditTag: "Editar", @@ -289,6 +289,24 @@ const database = { builderEffectPublishSentence: "Publica esta entrada", builderEffectUnpublishTag: "Despublicar", builderEffectUnpublishSentence: "Despublica a entrada publicada", + builderIntentNewOne: "1 rascunho a criar", + builderIntentNew: "{{count}} rascunhos a criar", + builderIntentEditOne: "1 atualização", + builderIntentEdit: "{{count}} atualizações", + builderIntentSaveOne: "1 salvamento", + builderIntentSave: "{{count}} salvamentos", + builderIntentPublish: "{{count}} a publicar", + builderIntentUnpublish: "{{count}} a despublicar", + builderIntentNone: "Nenhuma alteração", + builderPushVerbCheck: "Verificar", + builderPushVerbPublish: "Publicar", + builderPushVerbUnpublish: "Despublicar", + builderPushVerbCreateOne: "Criar rascunho", + builderPushVerbCreate: "Criar {{count}} rascunhos", + builderPushVerbSaveOne: "Salvar no Builder", + builderPushVerbSave: "Salvar {{count}} no Builder", + builderPushVerbPushOne: "Enviar atualização", + builderPushVerbPush: "Enviar {{count}} atualizações", publishUpdates: "Publicar atualizações", writesUpdatesToLiveEntries: "Grava atualizações nas entradas ao vivo.", sourcesFeedingThisColumn: "Fontes que alimentam esta coluna", diff --git a/templates/content/app/i18n/zh-CN.ts b/templates/content/app/i18n/zh-CN.ts index 1117a98ecf2..9ad0b29304e 100644 --- a/templates/content/app/i18n/zh-CN.ts +++ b/templates/content/app/i18n/zh-CN.ts @@ -245,7 +245,7 @@ const database = { "连接 Builder 集合或其他表以映射到这些行。", noBuilderWrites: "无 Builder 写入。", stageOnly: "仅暂存", - savesDraftsNeverPublishes: "将草稿保存到 Builder — 从不发布。", + savesChangesNeverPublishes: "将更改保存到 Builder — 从不发布。", builderEffectNewTag: "新建", builderEffectNewSentence: "创建一个新的草稿条目", builderEffectEditTag: "编辑", @@ -257,6 +257,24 @@ const database = { builderEffectPublishSentence: "发布此条目", builderEffectUnpublishTag: "取消发布", builderEffectUnpublishSentence: "取消发布已上线的条目", + builderIntentNewOne: "1 个待创建草稿", + builderIntentNew: "{{count}} 个待创建草稿", + builderIntentEditOne: "1 项更新", + builderIntentEdit: "{{count}} 项更新", + builderIntentSaveOne: "1 项保存", + builderIntentSave: "{{count}} 项保存", + builderIntentPublish: "{{count}} 项待发布", + builderIntentUnpublish: "{{count}} 项待取消发布", + builderIntentNone: "无更改", + builderPushVerbCheck: "检查", + builderPushVerbPublish: "发布", + builderPushVerbUnpublish: "取消发布", + builderPushVerbCreateOne: "创建草稿", + builderPushVerbCreate: "创建 {{count}} 个草稿", + builderPushVerbSaveOne: "保存到 Builder", + builderPushVerbSave: "将 {{count}} 项保存到 Builder", + builderPushVerbPushOne: "推送更新", + builderPushVerbPush: "推送 {{count}} 项更新", publishUpdates: "发布更新", writesUpdatesToLiveEntries: "将更新写入实时条目。", sourcesFeedingThisColumn: "为此列供数的来源", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index 75db5d95bcc..f9d6f46d1dc 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1428,7 +1428,7 @@ const overrides = { "連接 Builder 集合或其他表格以對應到這些列。", noBuilderWrites: "無 Builder 寫入。", stageOnly: "僅暫存", - savesDraftsNeverPublishes: "將草稿儲存到 Builder — 從不發佈。", + savesChangesNeverPublishes: "將變更儲存到 Builder — 從不發佈。", builderEffectNewTag: "新增", builderEffectNewSentence: "建立一個新的草稿項目", builderEffectEditTag: "編輯", @@ -1440,6 +1440,24 @@ const overrides = { builderEffectPublishSentence: "發佈此項目", builderEffectUnpublishTag: "取消發佈", builderEffectUnpublishSentence: "取消發佈已上線的項目", + builderIntentNewOne: "1 個待建立草稿", + builderIntentNew: "{{count}} 個待建立草稿", + builderIntentEditOne: "1 項更新", + builderIntentEdit: "{{count}} 項更新", + builderIntentSaveOne: "1 項儲存", + builderIntentSave: "{{count}} 項儲存", + builderIntentPublish: "{{count}} 項待發佈", + builderIntentUnpublish: "{{count}} 項待取消發佈", + builderIntentNone: "沒有變更", + builderPushVerbCheck: "檢查", + builderPushVerbPublish: "發佈", + builderPushVerbUnpublish: "取消發佈", + builderPushVerbCreateOne: "建立草稿", + builderPushVerbCreate: "建立 {{count}} 個草稿", + builderPushVerbSaveOne: "儲存到 Builder", + builderPushVerbSave: "將 {{count}} 項儲存到 Builder", + builderPushVerbPushOne: "送出更新", + builderPushVerbPush: "送出 {{count}} 項更新", publishUpdates: "發佈更新", writesUpdatesToLiveEntries: "將更新寫入即時項目。", sourcesFeedingThisColumn: "為此欄供給的來源", From 80e41f28b67fb04988323f5c078c95ed8255bd22 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:01:44 -0400 Subject: [PATCH 14/20] Clear Builder statuses when a Builder source turns into details --- .../_builder-status-property.db.test.ts | 27 +++++++ .../content/actions/_database-source-utils.ts | 78 ++++++++++++------- 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/templates/content/actions/_builder-status-property.db.test.ts b/templates/content/actions/_builder-status-property.db.test.ts index 4fed4f7517c..c4ed6e981ec 100644 --- a/templates/content/actions/_builder-status-property.db.test.ts +++ b/templates/content/actions/_builder-status-property.db.test.ts @@ -500,3 +500,30 @@ it("clears statuses when a local source replaces the Builder one, except rows an expect(await statuses()).toEqual({ doc_status_autosaved: "published" }); }); + +it("clears statuses when a Builder source turns into details for rows another source keeps", async () => { + // The local source from the test above still keeps the autosaved row. + expect(await statuses()).toEqual({ doc_status_autosaved: "published" }); + const { removeRowsOwnedOnlyBySource } = + await import("./change-content-database-source-role.js"); + + await removeRowsOwnedOnlyBySource({ + databaseId: DATABASE_ID, + sourceId: "src_builder_status_details", + }); + await utils.storeSecondarySourceRows({ + sourceId: "src_builder_status_details", + ownerEmail: OWNER, + sourceType: "builder-cms", + sourceTable: MODEL, + entries: [], + now: NOW, + }); + + expect(await statuses()).toEqual({}); + const [membership] = await getDb() + .select({ id: schema.contentDatabaseItems.id }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.id, "item_doc_status_autosaved")); + expect(membership).toBeTruthy(); +}); diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 2c8c8c5804d..005ab742c6a 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -7861,36 +7861,54 @@ export async function storeSecondarySourceRows(args: { now: string; }) { const db = getDb(); - await db - .delete(schema.contentDatabaseSourceRows) - .where(eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId)); - if (args.entries.length === 0) return; - await db.insert(schema.contentDatabaseSourceRows).values( - args.entries.map((entry, index) => ({ - id: crypto.randomUUID(), - ownerEmail: args.ownerEmail, - sourceId: args.sourceId, - databaseItemId: "", - documentId: "", - sourceRowId: entry.id || `${args.sourceType}-${index + 1}`, - sourceQualifiedId: `${args.sourceType}://${args.sourceTable}/${ - entry.id || index + 1 - }`, - sourceDisplayKey: - entry.title?.trim() || `${args.sourceTable}-${index + 1}`, - sourceValuesJson: JSON.stringify(entry.sourceValues ?? {}), - provenance: - args.sourceType === "notion-database" - ? "Notion database read adapter" - : "secondary source row", - syncState: "linked" as const, - freshness: "fresh" as const, - lastSyncedAt: args.now, - lastSourceUpdatedAt: entry.updatedAt || args.now, - createdAt: args.now, - updatedAt: args.now, - })), - ); + await db.transaction(async (tx) => { + const oldRows = await tx + .select({ documentId: schema.contentDatabaseSourceRows.documentId }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId)); + await tx + .delete(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId)); + const [source] = await tx + .select({ databaseId: schema.contentDatabaseSources.databaseId }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, args.sourceId)); + if (source) { + await clearBuilderStatusValues(tx, { + databaseId: source.databaseId, + documentIds: oldRows + .map((row) => row.documentId) + .filter((documentId): documentId is string => !!documentId), + }); + } + if (args.entries.length === 0) return; + await tx.insert(schema.contentDatabaseSourceRows).values( + args.entries.map((entry, index) => ({ + id: crypto.randomUUID(), + ownerEmail: args.ownerEmail, + sourceId: args.sourceId, + databaseItemId: "", + documentId: "", + sourceRowId: entry.id || `${args.sourceType}-${index + 1}`, + sourceQualifiedId: `${args.sourceType}://${args.sourceTable}/${ + entry.id || index + 1 + }`, + sourceDisplayKey: + entry.title?.trim() || `${args.sourceTable}-${index + 1}`, + sourceValuesJson: JSON.stringify(entry.sourceValues ?? {}), + provenance: + args.sourceType === "notion-database" + ? "Notion database read adapter" + : "secondary source row", + syncState: "linked" as const, + freshness: "fresh" as const, + lastSyncedAt: args.now, + lastSourceUpdatedAt: entry.updatedAt || args.now, + createdAt: args.now, + updatedAt: args.now, + })), + ); + }); } export async function seedSecondarySourceFields(args: { From a5e241d66ff01a24095effb809821eee5b114c3b Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:11:09 -0400 Subject: [PATCH 15/20] Check the review dialog's summary and button by their message keys --- .../BuilderSourceReviewDialog.ui.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx index e666687fc2d..cf889fc96df 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx @@ -202,7 +202,9 @@ describe("BuilderSourceReviewDialog row selection", () => { const confirmationCta = Array.from( document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("Create draft")); + ).find((button) => + button.textContent?.includes("database.builderPushVerbCreateOne"), + ); expect(confirmationCta?.disabled).toBe(false); act(() => confirmationCta?.click()); expect(onValidate).toHaveBeenCalledTimes(2); @@ -426,8 +428,12 @@ describe("BuilderSourceReviewDialog row selection", () => { ); }); - expect(document.body.textContent).toContain("1 draft to create"); - expect(document.body.textContent).not.toContain("No changes"); + expect(document.body.textContent).toContain( + 'database.builderIntentNewOne:{"count":1}', + ); + expect(document.body.textContent).not.toContain( + "database.builderIntentNone", + ); expect( document.body .querySelector('[role="checkbox"]') From 3ab7760cd1bec6baf78c88124f46f1418dff1067 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:19:42 -0400 Subject: [PATCH 16/20] Ask every projected Builder read for its autosave flag --- templates/content/actions/_builder-cms-read-client.test.ts | 2 ++ templates/content/actions/_builder-cms-read-client.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/templates/content/actions/_builder-cms-read-client.test.ts b/templates/content/actions/_builder-cms-read-client.test.ts index e5b2f9f3e6c..f111cfe71d6 100644 --- a/templates/content/actions/_builder-cms-read-client.test.ts +++ b/templates/content/actions/_builder-cms-read-client.test.ts @@ -151,6 +151,8 @@ describe("Builder CMS read client", () => { expect(fields).not.toContain("DATA.BLOCKS"); expect(fields).not.toContain("data.BlocksString"); expect(fields).not.toContain("sys.sync_state"); + // The Builder status reads Builder's own autosave flag from every read. + expect(fields).toContain("meta.hasAutosaves"); expect(fields.filter((field) => field === "data.tags")).toHaveLength(1); expect(fields).toContain("published"); expect(fields).toContain("data.published"); diff --git a/templates/content/actions/_builder-cms-read-client.ts b/templates/content/actions/_builder-cms-read-client.ts index dc66fafe78a..3d9d56c2ff1 100644 --- a/templates/content/actions/_builder-cms-read-client.ts +++ b/templates/content/actions/_builder-cms-read-client.ts @@ -230,6 +230,7 @@ const BUILDER_CMS_METADATA_ENTRY_FIELD_PATHS = [ "published", "lastUpdated", "createdDate", + "meta.hasAutosaves", "data.title", "data.handle", "data.url", @@ -275,7 +276,8 @@ function normalizeBuilderCmsListFieldPath(fieldPath: string) { } if ( normalized.includes(".") && - !normalized.toLowerCase().startsWith("data.") + !lower.startsWith("data.") && + !BUILDER_CMS_TOP_LEVEL_METADATA_FIELDS.has(lower) ) { return null; } From 1cdf02684107a6c2a84631afc457f11bf084679e Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:45:03 -0400 Subject: [PATCH 17/20] Take a save's publication state from the newer of the live check and the stored row --- .../execute-builder-source-execution.test.ts | 27 ++++++++++++- .../execute-builder-source-execution.ts | 38 ++++++++++++++----- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 41f33006c56..cf4129dbdef 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -2784,6 +2784,7 @@ describe("Builder unpublished saves", () => { const publishedAfter = (args: { effect: (typeof plan)["payload"]["effect"]; existing: string; + existingLastUpdated?: string; livePublished?: string; responseBody?: unknown; }) => @@ -2791,6 +2792,9 @@ describe("Builder unpublished saves", () => { builderCmsReconciledSourceValuesJson({ existingSourceValuesJson: JSON.stringify({ published: args.existing, + ...(args.existingLastUpdated + ? { lastUpdated: args.existingLastUpdated } + : {}), }), snapshotSourceValues: undefined, changeSet: approvedChangeSet, @@ -2846,17 +2850,38 @@ describe("Builder unpublished saves", () => { expect(publishedAfter({ effect: "save_draft", existing: "draft" })).toBe( "draft", ); - // A reply without the state keeps what a refresh stored since the check. + // A reply without the state takes the live check's, unless a refresh + // read Builder after the check. + const readAt = (offsetMs: number) => + new Date(BUILDER_LAST_UPDATED_MS + offsetMs).toISOString(); for (const effect of ["save_draft", "update_in_place"] as const) { expect( publishedAfter({ effect, existing: "published", + existingLastUpdated: readAt(-60_000), + livePublished: "draft", + responseBody: { id: "builder-entry-1" }, + }), + ).toBe("draft"); + expect( + publishedAfter({ + effect, + existing: "published", + existingLastUpdated: readAt(60_000), livePublished: "draft", responseBody: { id: "builder-entry-1" }, }), ).toBe("published"); } + expect( + publishedAfter({ + effect: "autosave", + existing: "draft", + existingLastUpdated: readAt(60_000), + livePublished: "published", + }), + ).toBe("draft"); }); it("takes a live write's fields out of the record, even from a response stored without live values", () => { diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index 9f54e704eeb..ce41096bd4d 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -440,24 +440,41 @@ function parseSourceValues( } /** - * The entry's publication state once the write landed. An autosave leaves the - * entry as the live check found it; Builder's reply describes the autosave. - * Otherwise Builder's reply wins: a draft save can land on an entry published - * since the check. A reply without the state leaves the stored state, which a - * refresh may have updated since the check. + * The entry's publication state once the write landed. Builder's reply wins + * for a draft save, which can land on an entry published since the check; its + * reply to an autosave describes the autosave, not the entry. Without a reply, + * the write left the state as the live check found it, unless the stored row + * was read from Builder after the check. Null keeps the stored state. */ function builderCmsPublishedAfterWrite(args: { effect: BuilderCmsExecutionPayload["effect"]; - livePublished: string | null | undefined; + livePreflight: BuilderCmsExecutionPayload["livePreflight"]; + storedLastUpdated: DocumentPropertyValue | undefined; responseBody: unknown; }) { if (args.effect === "publish") return "published"; if (args.effect === "unpublish" || args.effect === "create_draft") { return "draft"; } - if (args.effect === "autosave") return args.livePublished ?? null; - const replied = builderCmsResponseValue(args.responseBody, ["published"]); - return typeof replied === "string" ? replied : null; + if (args.effect !== "autosave") { + const replied = builderCmsResponseValue(args.responseBody, ["published"]); + if (typeof replied === "string") return replied; + } + const storedEpoch = toEpochMs( + typeof args.storedLastUpdated === "string" || + typeof args.storedLastUpdated === "number" + ? args.storedLastUpdated + : null, + ); + const checkedEpoch = toEpochMs(args.livePreflight?.lastUpdated); + if ( + storedEpoch !== null && + checkedEpoch !== null && + storedEpoch > checkedEpoch + ) { + return null; + } + return args.livePreflight?.published ?? null; } export function builderCmsReconciledSourceValuesJson(args: { @@ -474,7 +491,8 @@ export function builderCmsReconciledSourceValuesJson(args: { const next = { ...existing }; const published = builderCmsPublishedAfterWrite({ effect: args.plan.payload.effect, - livePublished: args.plan.payload.livePreflight?.published, + livePreflight: args.plan.payload.livePreflight, + storedLastUpdated: existing.lastUpdated, responseBody: args.responseBody, }); if (published) next.published = published; From defc0fc1270b49c00226741064d9aa02fd4112c0 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:12:25 -0400 Subject: [PATCH 18/20] Leave an empty Builder body pending when a save changes the row during the read --- .../content/actions/_database-source-utils.ts | 151 +++++++++-------- .../resync-content-database-source.db.test.ts | 159 ++++++++++++++++++ 2 files changed, 244 insertions(+), 66 deletions(-) diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 005ab742c6a..8de1fed6ad6 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -2326,11 +2326,12 @@ async function processBuilderBodyHydrationJob( .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); }; if (emptyBodyRead?.state === "empty_body") { - const [deleted] = await tx - .delete(schema.contentDatabaseBodyHydrationQueue) + const [stillOwnsQueueRow] = await tx + .update(schema.contentDatabaseBodyHydrationQueue) + .set({ updatedAt: now }) .where(queueRowCas) .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); - if (!deleted) { + if (!stillOwnsQueueRow) { await markPendingIfReplaced(); return; } @@ -2343,7 +2344,7 @@ async function processBuilderBodyHydrationJob( emptyBodyRead.entry, ), }; - await tx + const [updatedSourceRow] = await tx .update(schema.contentDatabaseSourceRows) .set({ sourceValuesJson: JSON.stringify(writtenEntry.sourceValues), @@ -2351,15 +2352,25 @@ async function processBuilderBodyHydrationJob( lastSourceUpdatedAt: writtenEntry.updatedAt, updatedAt: now, }) - .where( - and( - eq(schema.contentDatabaseSourceRows.sourceId, row.sourceId), - eq( - schema.contentDatabaseSourceRows.databaseItemId, - row.databaseItemId, - ), - ), - ); + .where(builderBodyHydrationSourceRowFilter(row, sourceRow)) + .returning({ id: schema.contentDatabaseSourceRows.id }); + if (!updatedSourceRow) { + await leaveBuilderBodyHydrationPending(tx, { + row, + queueRowCas, + now, + error: BUILDER_BODY_HYDRATION_SOURCE_ROW_CHANGED, + }); + return; + } + const [deleted] = await tx + .delete(schema.contentDatabaseBodyHydrationQueue) + .where(queueRowCas) + .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); + if (!deleted) { + await markPendingIfReplaced(); + return; + } await writeBuilderStatusValues(tx, { sourceId: row.sourceId, rows: [ @@ -2534,39 +2545,15 @@ async function processBuilderBodyHydrationJob( wroteBody = Boolean(updatedDocument); } if (shouldWriteBody && !wroteBody) { - const [stillQueued] = await tx - .update(schema.contentDatabaseBodyHydrationQueue) - .set({ - lastError: - "Skipped Builder body hydration because the document changed during sync.", - updatedAt: now, - }) - .where(queueRowCas) - .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); - if (stillQueued) { - await tx - .update(schema.contentDatabaseItems) - .set({ - bodyHydrationStatus: "pending", - bodyHydrationAttemptedAt: now, - bodyHydrationError: - "Skipped Builder body hydration because the document changed during sync.", - updatedAt: now, - }) - .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); - } + await leaveBuilderBodyHydrationPending(tx, { + row, + queueRowCas, + now, + error: + "Skipped Builder body hydration because the document changed during sync.", + }); return; } - const sourceRowWhere = and( - eq(schema.contentDatabaseSourceRows.sourceId, row.sourceId), - eq(schema.contentDatabaseSourceRows.databaseItemId, row.databaseItemId), - sourceRow - ? eq( - schema.contentDatabaseSourceRows.sourceValuesJson, - sourceRow.sourceValuesJson, - ) - : isNull(schema.contentDatabaseSourceRows.id), - ); // The next refresh compares the version with the row as written. const writtenEntry = { ...entryWithBody, @@ -2581,30 +2568,15 @@ async function processBuilderBodyHydrationJob( lastSourceUpdatedAt: writtenEntry.updatedAt, updatedAt: now, }) - .where(sourceRowWhere) + .where(builderBodyHydrationSourceRowFilter(row, sourceRow)) .returning({ id: schema.contentDatabaseSourceRows.id }); if (!updatedSourceRow) { - const [stillQueued] = await tx - .update(schema.contentDatabaseBodyHydrationQueue) - .set({ - lastError: - "Skipped Builder body hydration because the source row changed during sync.", - updatedAt: now, - }) - .where(queueRowCas) - .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); - if (stillQueued) { - await tx - .update(schema.contentDatabaseItems) - .set({ - bodyHydrationStatus: "pending", - bodyHydrationAttemptedAt: now, - bodyHydrationError: - "Skipped Builder body hydration because the source row changed during sync.", - updatedAt: now, - }) - .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); - } + await leaveBuilderBodyHydrationPending(tx, { + row, + queueRowCas, + now, + error: BUILDER_BODY_HYDRATION_SOURCE_ROW_CHANGED, + }); return; } const [deleted] = await tx @@ -2682,6 +2654,53 @@ function hydrationCaseSql( return sql`CASE ${idColumn} ${sql.join(cases, sql` `)} ELSE ${fallbackColumn} END`; } +const BUILDER_BODY_HYDRATION_SOURCE_ROW_CHANGED = + "Skipped Builder body hydration because the source row changed during sync."; + +/** The source row as the job read it, so a save or refresh since then wins. */ +function builderBodyHydrationSourceRowFilter( + job: ContentDatabaseBodyHydrationQueueRowDb, + sourceRow: ContentDatabaseSourceRecordRowDb | undefined, +) { + return and( + eq(schema.contentDatabaseSourceRows.sourceId, job.sourceId), + eq(schema.contentDatabaseSourceRows.databaseItemId, job.databaseItemId), + sourceRow + ? eq( + schema.contentDatabaseSourceRows.sourceValuesJson, + sourceRow.sourceValuesJson, + ) + : isNull(schema.contentDatabaseSourceRows.id), + ); +} + +/** Keeps a job queued, with the reason this attempt skipped its write. */ +async function leaveBuilderBodyHydrationPending( + tx: any, + args: { + row: ContentDatabaseBodyHydrationQueueRowDb; + queueRowCas: ReturnType; + now: string; + error: string; + }, +) { + const [stillQueued] = await tx + .update(schema.contentDatabaseBodyHydrationQueue) + .set({ lastError: args.error, updatedAt: args.now }) + .where(args.queueRowCas) + .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); + if (!stillQueued) return; + await tx + .update(schema.contentDatabaseItems) + .set({ + bodyHydrationStatus: "pending", + bodyHydrationAttemptedAt: args.now, + bodyHydrationError: args.error, + updatedAt: args.now, + }) + .where(eq(schema.contentDatabaseItems.id, args.row.databaseItemId)); +} + function builderBodyHydrationQueueOwnershipFilter( job: ContentDatabaseBodyHydrationQueueRowDb, sourceEntryJson = job.sourceEntryJson, diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index 29fc98a2210..8cbb511540a 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -5164,6 +5164,165 @@ it("hydrates a provider-confirmed empty Builder body with terminal evidence", as expect(after.queued).toBeNull(); }); +it("leaves an empty Builder body pending when a save changes the row during the read", async () => { + builderReadMock.mode = "full"; + builderReadMock.calls = []; + builderReadMock.singleEntryCalls = []; + const db = getDb(); + const now = new Date().toISOString(); + const model = "collection-hydration-empty-terminal"; + const databaseId = "db_hydration_empty_raced"; + const databaseDocId = "doc_db_hydration_empty_raced"; + const documentId = "doc_hydration_empty_raced"; + const itemId = "item_hydration_empty_raced"; + const sourceId = "src_hydration_empty_raced"; + const sourceRowId = "entry_hydration_empty_raced"; + const readValues = { + "data.title": "Hydration empty raced", + "data.url": "/blog/hydration-empty-raced", + lastUpdated: "2026-01-01T00:00:00.000Z", + published: "published", + }; + const savedValuesJson = JSON.stringify({ + ...readValues, + "data.title": "Saved during the read", + [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: JSON.stringify({ + liveValues: { "data.title": "Hydration empty raced" }, + liveLastUpdated: Date.parse(readValues.lastUpdated), + }), + }); + + await db.insert(schema.documents).values([ + { + id: databaseDocId, + ownerEmail: OWNER, + title: "DB hydration empty raced", + createdAt: now, + updatedAt: now, + }, + { + id: documentId, + ownerEmail: OWNER, + parentId: databaseDocId, + title: "Hydration empty raced", + content: "", + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(schema.contentDatabases).values({ + id: databaseId, + ownerEmail: OWNER, + documentId: databaseDocId, + title: "DB hydration empty raced", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseSources).values({ + id: sourceId, + ownerEmail: OWNER, + databaseId, + sourceType: "builder-cms", + sourceName: model, + sourceTable: model, + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseItems).values({ + id: itemId, + ownerEmail: OWNER, + databaseId, + documentId, + position: 0, + bodyHydrationStatus: "pending", + bodyHydrationError: null, + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseSourceRows).values({ + id: "row_hydration_empty_raced", + ownerEmail: OWNER, + sourceId, + databaseItemId: itemId, + documentId, + sourceRowId, + sourceQualifiedId: `builder-cms://${model}/${sourceRowId}`, + sourceDisplayKey: "Hydration empty raced", + sourceValuesJson: JSON.stringify(readValues), + provenance: "Builder CMS read adapter", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseBodyHydrationQueue).values({ + id: "queue_hydration_empty_raced", + ownerEmail: OWNER, + sourceId, + databaseItemId: itemId, + documentId, + sourceRowId, + sourceTable: model, + sourceEntryJson: JSON.stringify({ + id: sourceRowId, + model, + title: "Hydration empty raced", + urlPath: "/blog/hydration-empty-raced", + updatedAt: readValues.lastUpdated, + sourceValues: readValues, + }), + priority: 0, + attempts: 0, + createdAt: now, + updatedAt: now, + }); + // A save from Content lands while the body read is in flight. + builderReadMock.beforeSingleEntryRead = async ({ entryId }) => { + if (entryId !== sourceRowId) return; + await db + .update(schema.contentDatabaseSourceRows) + .set({ sourceValuesJson: savedValuesJson }) + .where( + eq(schema.contentDatabaseSourceRows.id, "row_hydration_empty_raced"), + ); + }; + + await hydrateQueuedBodies({ sourceId, limit: 1, preloadBodies: true }); + + expect(builderReadMock.singleEntryCalls).toContainEqual({ + model, + entryId: sourceRowId, + }); + const [after] = await db + .select({ + status: schema.contentDatabaseItems.bodyHydrationStatus, + error: schema.contentDatabaseItems.bodyHydrationError, + sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson, + queued: schema.contentDatabaseBodyHydrationQueue.id, + }) + .from(schema.contentDatabaseItems) + .innerJoin( + schema.contentDatabaseSourceRows, + eq( + schema.contentDatabaseSourceRows.databaseItemId, + schema.contentDatabaseItems.id, + ), + ) + .leftJoin( + schema.contentDatabaseBodyHydrationQueue, + eq( + schema.contentDatabaseBodyHydrationQueue.databaseItemId, + schema.contentDatabaseItems.id, + ), + ) + .where(eq(schema.contentDatabaseItems.id, itemId)); + + expect(after.sourceValuesJson).toBe(savedValuesJson); + expect(after.status).toBe("pending"); + expect(after.error).toBe( + "Skipped Builder body hydration because the source row changed during sync.", + ); + expect(after.queued).toBe("queue_hydration_empty_raced"); +}); + it("keeps an empty body Content saved over a published entry through hydration", async () => { builderReadMock.mode = "full"; builderReadMock.calls = []; From b46afb113abe643a902ea3df0bba087ef8eebb76 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:48:24 -0400 Subject: [PATCH 19/20] Clear the strict-lint and compiler-strictness findings main now reports on these lines --- .../actions/_builder-cms-read-client.test.ts | 6 +- .../_builder-cms-unpublished-changes.ts | 39 ++++++----- .../actions/_builder-cms-write-adapter.ts | 2 +- .../_builder-status-property.db.test.ts | 8 ++- .../actions/_builder-status-property.ts | 8 +-- .../content/actions/_database-source-utils.ts | 68 +++++++++---------- .../execute-builder-source-execution.test.ts | 4 +- .../execute-builder-source-execution.ts | 4 +- .../BuilderSourceReviewDialog.test.ts | 8 ++- .../BuilderSourceReviewDialog.ui.test.tsx | 2 +- 10 files changed, 79 insertions(+), 70 deletions(-) diff --git a/templates/content/actions/_builder-cms-read-client.test.ts b/templates/content/actions/_builder-cms-read-client.test.ts index f111cfe71d6..4415a2abd28 100644 --- a/templates/content/actions/_builder-cms-read-client.test.ts +++ b/templates/content/actions/_builder-cms-read-client.test.ts @@ -2653,8 +2653,10 @@ describe("Builder CMS read client", () => { // An empty body Builder returned, which the save records as such. "__builder.body.blockCount": 0, }); - const fields = - fetchImpl.mock.calls[0]![0].searchParams.get("fields")!.split(","); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const fields = ( + fetchImpl.mock.lastCall?.[0].searchParams.get("fields") ?? "" + ).split(","); expect(fields).toEqual( expect.arrayContaining([ "data.blurb", diff --git a/templates/content/actions/_builder-cms-unpublished-changes.ts b/templates/content/actions/_builder-cms-unpublished-changes.ts index 67d6abfccec..b7b176dc268 100644 --- a/templates/content/actions/_builder-cms-unpublished-changes.ts +++ b/templates/content/actions/_builder-cms-unpublished-changes.ts @@ -60,6 +60,13 @@ function hasKey( return !!record && Object.prototype.hasOwnProperty.call(record, key); } +function ownValue( + record: Record | null | undefined, + key: string, +): V | undefined { + return record && hasKey(record, key) ? record[key] : undefined; +} + function epochMs(value: unknown): number | null { if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value !== "string" || !value.trim()) return null; @@ -264,11 +271,10 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { console.error( "A Builder row's unpublished-changes record is unreadable, so Builder's values win; the next save from Content replaces the record.", ); - return { - ...args.incoming, - [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: - args.existing[BUILDER_CMS_UNPUBLISHED_CHANGES_KEY], - }; + const record = ownValue(args.existing, BUILDER_CMS_UNPUBLISHED_CHANGES_KEY); + return record === undefined + ? args.incoming + : { ...args.incoming, [BUILDER_CMS_UNPUBLISHED_CHANGES_KEY]: record }; } if (read.state !== "pending" || !args.existing) { return withBuilderCmsUnpublishedChanges(args.incoming, null); @@ -320,10 +326,10 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { })); if (!builderStillLive) continue; for (const keptKey of [key, referenceKey]) { - if (hasKey(existing, keptKey)) next[keptKey] = existing[keptKey]; - if (hasKey(recorded.liveValues, keptKey)) { - liveValues[keptKey] = recorded.liveValues[keptKey]; - } + const kept = ownValue(existing, keptKey); + if (kept !== undefined) next[keptKey] = kept; + const live = ownValue(recorded.liveValues, keptKey); + if (live !== undefined) liveValues[keptKey] = live; } } const bodyKept = readPredatesSave @@ -331,8 +337,9 @@ export function builderSourceValuesKeepingUnpublishedChanges(args: { : builderCmsBodyStillSavedOver(recorded, builder); if (bodyKept) { for (const key of BUILDER_CMS_BODY_BASELINE_KEYS) { - if (hasKey(existing, key)) next[key] = existing[key]; - else delete next[key]; + const kept = ownValue(existing, key); + if (kept === undefined) delete next[key]; + else next[key] = kept; } } return withBuilderCmsUnpublishedChanges(next, { @@ -389,13 +396,11 @@ export function builderCmsUnpublishedChangesAfterWrite(args: { for (const key of args.writtenFieldKeys) { // A field the live entry lacks is recorded as null, which a refresh // that also lacks it reads as unchanged. - liveValues[key] = hasKey(live, key) ? live[key] : null; + liveValues[key] = ownValue(live, key) ?? null; const referenceKey = builderCmsReferenceIdKey(key); - if (hasKey(live, referenceKey)) { - liveValues[referenceKey] = live[referenceKey]; - } else { - delete liveValues[referenceKey]; - } + const liveReference = ownValue(live, referenceKey); + if (liveReference === undefined) delete liveValues[referenceKey]; + else liveValues[referenceKey] = liveReference; } liveLastUpdated = args.live.lastUpdated ?? undefined; if (args.wroteBody) { diff --git a/templates/content/actions/_builder-cms-write-adapter.ts b/templates/content/actions/_builder-cms-write-adapter.ts index a9a8ad386f6..9a8a3bd0f0f 100644 --- a/templates/content/actions/_builder-cms-write-adapter.ts +++ b/templates/content/actions/_builder-cms-write-adapter.ts @@ -1084,7 +1084,7 @@ function gateComparablePayload( if (!isBuilderCmsUnpublishedSaveEffect(rest.effect) || !rest.request) { return rest; } - const { autoSaveOnly: _autoSaveOnly, ...query } = rest.request.query ?? {}; + const { autoSaveOnly: _autoSaveOnly, ...query } = rest.request.query; return { ...rest, effect: "autosave", diff --git a/templates/content/actions/_builder-status-property.db.test.ts b/templates/content/actions/_builder-status-property.db.test.ts index c4ed6e981ec..de0795a91b5 100644 --- a/templates/content/actions/_builder-status-property.db.test.ts +++ b/templates/content/actions/_builder-status-property.db.test.ts @@ -40,7 +40,7 @@ const SOURCE_ID = "src_builder_status"; const MODEL = "builder-status-model"; const NOW = "2026-10-23T00:00:00.000Z"; -let getDb: () => any; +let getDb: typeof import("../server/db/index.js").getDb; let schema: typeof import("../server/db/schema.js"); let utils: typeof import("./_database-source-utils.js"); let statusModule: typeof import("./_builder-status-property.js"); @@ -72,7 +72,9 @@ function entry( documentId: string, sourceValues: BuilderCmsSourceEntry["sourceValues"], ): [string, BuilderCmsSourceEntry] { - const title = rows.find((row) => row.documentId === documentId)!.title; + const fixture = rows.find((row) => row.documentId === documentId); + if (!fixture) throw new Error(`No fixture row for ${documentId}`); + const title = fixture.title; return [ documentId, { @@ -113,7 +115,7 @@ beforeAll(async () => { getDb = dbModule.getDb; schema = dbModule.schema; const plugin = (await import("../server/plugins/db.js")).default; - await plugin(undefined as any); + await plugin(undefined as never); utils = await import("./_database-source-utils.js"); statusModule = await import("./_builder-status-property.js"); diff --git a/templates/content/actions/_builder-status-property.ts b/templates/content/actions/_builder-status-property.ts index 174bba3b4da..7643237ec86 100644 --- a/templates/content/actions/_builder-status-property.ts +++ b/templates/content/actions/_builder-status-property.ts @@ -47,7 +47,7 @@ function builderStatusValueId(propertyId: string, documentId: string) { } async function builderStatusTarget(db: Db, sourceId: string) { - const [target] = await db + const [target = null] = await db .select({ databaseId: schema.contentDatabases.id, ownerEmail: schema.contentDatabases.ownerEmail, @@ -65,11 +65,11 @@ async function builderStatusTarget(db: Db, sourceId: string) { ), ) .limit(1); - return target ?? null; + return target; } async function existingBuilderStatusPropertyId(db: Db, databaseId: string) { - const [definition] = await db + const [definition = null] = await db .select({ id: schema.documentPropertyDefinitions.id }) .from(schema.documentPropertyDefinitions) .where( @@ -95,7 +95,7 @@ async function createBuilderStatusProperty(args: { args.target.databaseId, ); if (existing) return existing; - const [minPosition] = await db + const [minPosition = null] = await db .select({ min: sql`MIN(position)` }) .from(schema.documentPropertyDefinitions) .where( diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 8de1fed6ad6..c9c2972a6dd 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -2090,23 +2090,21 @@ async function processBuilderBodyHydrationJob( let entryWithBody = await refreshBuilderBodySourceValuesFromStoredLossless( await withBuilderBodySourceValues(bodyEntry), ); - const sourceRow = + const [sourceRow = undefined] = preloaded?.sourceRow != null - ? (preloaded.sourceRow ?? undefined) - : ( - await db - .select() - .from(schema.contentDatabaseSourceRows) - .where( - and( - eq(schema.contentDatabaseSourceRows.sourceId, row.sourceId), - eq( - schema.contentDatabaseSourceRows.databaseItemId, - row.databaseItemId, - ), + ? [preloaded.sourceRow] + : await db + .select() + .from(schema.contentDatabaseSourceRows) + .where( + and( + eq(schema.contentDatabaseSourceRows.sourceId, row.sourceId), + eq( + schema.contentDatabaseSourceRows.databaseItemId, + row.databaseItemId, ), - ) - )[0]; + ), + ); const sourceValues = parseObject>( sourceRow?.sourceValuesJson ?? "{}", @@ -2310,7 +2308,7 @@ async function processBuilderBodyHydrationJob( activeSourceEntryJson, ); const markPendingIfReplaced = async () => { - const [replacedByNewerJob] = await tx + const [replacedByNewerJob = null] = await tx .select({ id: schema.contentDatabaseBodyHydrationQueue.id }) .from(schema.contentDatabaseBodyHydrationQueue) .where(eq(schema.contentDatabaseBodyHydrationQueue.id, row.id)); @@ -2326,7 +2324,7 @@ async function processBuilderBodyHydrationJob( .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); }; if (emptyBodyRead?.state === "empty_body") { - const [stillOwnsQueueRow] = await tx + const [stillOwnsQueueRow = null] = await tx .update(schema.contentDatabaseBodyHydrationQueue) .set({ updatedAt: now }) .where(queueRowCas) @@ -2338,13 +2336,13 @@ async function processBuilderBodyHydrationJob( // The next refresh compares the version with the row as written. const writtenEntry = { ...emptyBodyRead.entry, - updatedAt: emptyBodyRead.entry.updatedAt ?? now, + updatedAt: emptyBodyRead.entry.updatedAt, sourceValues: builderBodyReadSourceValues( sourceValues, emptyBodyRead.entry, ), }; - const [updatedSourceRow] = await tx + const [updatedSourceRow = null] = await tx .update(schema.contentDatabaseSourceRows) .set({ sourceValuesJson: JSON.stringify(writtenEntry.sourceValues), @@ -2363,7 +2361,7 @@ async function processBuilderBodyHydrationJob( }); return; } - const [deleted] = await tx + const [deleted = null] = await tx .delete(schema.contentDatabaseBodyHydrationQueue) .where(queueRowCas) .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); @@ -2398,7 +2396,7 @@ async function processBuilderBodyHydrationJob( return; } if (builderBodyHydrationAttemptIsTerminal(attempts)) { - const [deleted] = await tx + const [deleted = null] = await tx .delete(schema.contentDatabaseBodyHydrationQueue) .where(queueRowCas) .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); @@ -2418,9 +2416,7 @@ async function processBuilderBodyHydrationJob( bodyHydrationError: reason === "not_found" ? "Builder no longer returns this source entry. Refresh the source or retry after restoring access." - : reason === "conversion_failed" - ? "Content could not construct a Builder body from the retained source record. Refresh the source to recover the authoritative body." - : null, + : "Content could not construct a Builder body from the retained source record. Refresh the source to recover the authoritative body.", bodyHydrationVersion: builderBodyUnavailableVersion(entryWithBody), bodyHydrationReason: reason, bodyHydrationProviderStatus: emptyBodyRead @@ -2433,7 +2429,7 @@ async function processBuilderBodyHydrationJob( .where(eq(schema.contentDatabaseItems.id, row.databaseItemId)); return; } - const [stillOwnsQueueRow] = await tx + const [stillOwnsQueueRow = null] = await tx .update(schema.contentDatabaseBodyHydrationQueue) .set({ lastAttemptedAt: null, @@ -2557,7 +2553,7 @@ async function processBuilderBodyHydrationJob( // The next refresh compares the version with the row as written. const writtenEntry = { ...entryWithBody, - updatedAt: entryWithBody.updatedAt ?? now, + updatedAt: entryWithBody.updatedAt, sourceValues: nextValues, }; const [updatedSourceRow] = await tx @@ -2676,7 +2672,7 @@ function builderBodyHydrationSourceRowFilter( /** Keeps a job queued, with the reason this attempt skipped its write. */ async function leaveBuilderBodyHydrationPending( - tx: any, + tx: Pick, "update">, args: { row: ContentDatabaseBodyHydrationQueueRowDb; queueRowCas: ReturnType; @@ -2684,7 +2680,7 @@ async function leaveBuilderBodyHydrationPending( error: string; }, ) { - const [stillQueued] = await tx + const [stillQueued = null] = await tx .update(schema.contentDatabaseBodyHydrationQueue) .set({ lastError: args.error, updatedAt: args.now }) .where(args.queueRowCas) @@ -6135,7 +6131,7 @@ export async function replaceMockSourceRows(args: { ]); await tx.delete(schema.contentDatabaseSourceRows).where(scope); await insertMockSourceRows(tx, rows, args.now); - const [source] = await tx + const [source = null] = await tx .select({ databaseId: schema.contentDatabaseSources.databaseId }) .from(schema.contentDatabaseSources) .where(eq(schema.contentDatabaseSources.id, args.sourceId)); @@ -6196,7 +6192,7 @@ export function sourceValuesForSeededSourceRow(args: { function builderSourceValuesWithPreservedBodyBaseline(args: { incoming: Record; existing?: Record | null; - existingLastSourceUpdatedAt?: string | null; + existingLastSourceUpdatedAt?: string | null | undefined; }) { const existing = args.existing; if (!existing) return args.incoming; @@ -7888,7 +7884,7 @@ export async function storeSecondarySourceRows(args: { await tx .delete(schema.contentDatabaseSourceRows) .where(eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId)); - const [source] = await tx + const [source = null] = await tx .select({ databaseId: schema.contentDatabaseSources.databaseId }) .from(schema.contentDatabaseSources) .where(eq(schema.contentDatabaseSources.id, args.sourceId)); @@ -7913,8 +7909,8 @@ export async function storeSecondarySourceRows(args: { entry.id || index + 1 }`, sourceDisplayKey: - entry.title?.trim() || `${args.sourceTable}-${index + 1}`, - sourceValuesJson: JSON.stringify(entry.sourceValues ?? {}), + entry.title.trim() || `${args.sourceTable}-${index + 1}`, + sourceValuesJson: JSON.stringify(entry.sourceValues), provenance: args.sourceType === "notion-database" ? "Notion database read adapter" @@ -8042,11 +8038,11 @@ export async function updateBuilderCmsSourceReadMetadata(args: { message: string | null; progress?: BuilderCmsReadProgress; sourceFetchState?: "idle" | "fetching" | "error"; - activeReadSourceRowIds?: string[]; + activeReadSourceRowIds?: string[] | undefined; syncState?: ContentDatabaseSourceSyncState; - builderModelFields?: BuilderCmsModelFieldSummary[]; + builderModelFields?: BuilderCmsModelFieldSummary[] | undefined; suspiciousEmpty?: boolean; - refreshClaimId?: string; + refreshClaimId?: string | undefined; }) { const db = getDb(); for (let attempt = 0; attempt < 5; attempt += 1) { diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index cf4129dbdef..72bc4caa22a 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -2284,8 +2284,8 @@ describe("Builder unpublished saves", () => { method: "PATCH", query: { triggerWebhooks: "false" }, }); - expect(request?.query).not.toHaveProperty("autoSaveOnly"); - expect(request?.body).not.toHaveProperty("published"); + expect(request).not.toHaveProperty("query.autoSaveOnly"); + expect(request).not.toHaveProperty("body.published"); expect(deps.reconcileWrite).toHaveBeenCalledWith( expect.objectContaining({ plan: expect.objectContaining({ diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index ce41096bd4d..5f801146c38 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -821,7 +821,7 @@ function builderSourceFieldLabel(source: ContentDatabaseSource) { ?.label?.trim() || source.fields .find((field) => field.sourceFieldKey === sourceFieldKey) - ?.sourceFieldLabel?.trim() || + ?.sourceFieldLabel.trim() || name ); }; @@ -909,7 +909,7 @@ async function reconcileBuilderCmsWrite(args: { .limit(1) : []; - const [row] = existingRow; + const [row = null] = existingRow; const snapshotRow = sourceRowForChangeSet(args.source, args.changeSet); const sourceValuesJson = builderCmsReconciledSourceValuesJson({ existingSourceValuesJson: row?.sourceValuesJson, diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts index 8ade1bcc9d4..5fb227c113b 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts @@ -14,8 +14,12 @@ import { } from "./BuilderSourceReviewDialog"; function english(message: { key: string; count: number }) { - const database = messagesByLocale["en-US"].database as Record; - return database[message.key].replace("{{count}}", String(message.count)); + const database = messagesByLocale["en-US"].database as Partial< + Record + >; + const text = database[message.key]; + if (text === undefined) throw new Error(`No English message ${message.key}`); + return text.replace("{{count}}", String(message.count)); } describe("BuilderSourceReviewDialog publication intent helpers", () => { diff --git a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx index cf889fc96df..b14309bea5a 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.ui.test.tsx @@ -203,7 +203,7 @@ describe("BuilderSourceReviewDialog row selection", () => { const confirmationCta = Array.from( document.body.querySelectorAll("button"), ).find((button) => - button.textContent?.includes("database.builderPushVerbCreateOne"), + button.textContent.includes("database.builderPushVerbCreateOne"), ); expect(confirmationCta?.disabled).toBe(false); act(() => confirmationCta?.click()); From 225970994db19505c510424ea0be7d41148ab377 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:55:25 -0400 Subject: [PATCH 20/20] Keep the fallbacks for a stored save's missing query and a queued body's missing update time --- .../_builder-cms-write-adapter.test.ts | 30 +++++ .../actions/_builder-cms-write-adapter.ts | 7 +- .../content/actions/_database-source-utils.ts | 7 +- .../resync-content-database-source.db.test.ts | 122 ++++++++++++++++++ 4 files changed, 164 insertions(+), 2 deletions(-) diff --git a/templates/content/actions/_builder-cms-write-adapter.test.ts b/templates/content/actions/_builder-cms-write-adapter.test.ts index 06692e3bc43..e623919cce3 100644 --- a/templates/content/actions/_builder-cms-write-adapter.test.ts +++ b/templates/content/actions/_builder-cms-write-adapter.test.ts @@ -1514,6 +1514,36 @@ describe("Builder CMS write adapter plan", () => { }); }); + it("marks a stored autosave payload stale when its request has no query", () => { + const plan = buildBuilderCmsExecutionPlan({ + source: source(false), + changeSet: approvedChangeSet(), + pushModeConfirmation: "autosave", + }); + expect(plan.payload.effect).toBe("autosave"); + const { query: _query, ...requestWithoutQuery } = plan.payload.request; + + for (const request of [ + requestWithoutQuery, + { ...requestWithoutQuery, query: null }, + ]) { + expect( + validateBuilderCmsExecutionDryRun({ + storedPayload: { ...plan.payload, request }, + plan, + now: "2026-06-08T01:00:00.000Z", + }), + ).toMatchObject({ + dryRun: { + status: "stale", + mismatches: [ + "Stored Builder request no longer matches the approved change.", + ], + }, + }); + } + }); + it("preserves stale stored payloads instead of self-healing them", () => { const plan = buildBuilderCmsExecutionPlan({ source: source(false), diff --git a/templates/content/actions/_builder-cms-write-adapter.ts b/templates/content/actions/_builder-cms-write-adapter.ts index 9a8a3bd0f0f..5ea84feb98d 100644 --- a/templates/content/actions/_builder-cms-write-adapter.ts +++ b/templates/content/actions/_builder-cms-write-adapter.ts @@ -1084,7 +1084,12 @@ function gateComparablePayload( if (!isBuilderCmsUnpublishedSaveEffect(rest.effect) || !rest.request) { return rest; } - const { autoSaveOnly: _autoSaveOnly, ...query } = rest.request.query; + // A stored payload is JSON a past run wrote, so its query can be missing. + const storedQuery = rest.request.query as + | typeof rest.request.query + | null + | undefined; + const { autoSaveOnly: _autoSaveOnly, ...query } = storedQuery ?? {}; return { ...rest, effect: "autosave", diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index c9c2972a6dd..56c664edb0e 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -2551,9 +2551,14 @@ async function processBuilderBodyHydrationJob( return; } // The next refresh compares the version with the row as written. + // A queued entry is JSON the queue stored, so its time can be missing. + const queuedUpdatedAt = entryWithBody.updatedAt as + | string + | null + | undefined; const writtenEntry = { ...entryWithBody, - updatedAt: entryWithBody.updatedAt, + updatedAt: queuedUpdatedAt ?? now, sourceValues: nextValues, }; const [updatedSourceRow] = await tx diff --git a/templates/content/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index 8cbb511540a..d56ce45a202 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -3788,6 +3788,128 @@ it("hydrates a queued full Builder body when the current body has multiline empt expect(after.queued).toBeNull(); }); +it("records the hydration time when a queued Builder body has no update time", async () => { + builderReadMock.mode = "full"; + builderReadMock.calls = []; + builderReadMock.singleEntryCalls = []; + const db = getDb(); + const now = new Date().toISOString(); + const databaseId = "db_untimed_hydration"; + const databaseDocId = "doc_db_untimed_hydration"; + const documentId = "doc_untimed_hydration"; + const itemId = "item_untimed_hydration"; + const sourceId = "src_untimed_hydration"; + const sourceRowId = "entry_untimed_hydration"; + const fullBody = "A queued Builder body without an update time."; + + await db.insert(schema.documents).values([ + { + id: databaseDocId, + ownerEmail: OWNER, + title: "DB untimed hydration", + createdAt: now, + updatedAt: now, + }, + { + id: documentId, + ownerEmail: OWNER, + parentId: databaseDocId, + title: "Untimed hydration", + content: "\n", + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(schema.contentDatabases).values({ + id: databaseId, + ownerEmail: OWNER, + documentId: databaseDocId, + title: "DB untimed hydration", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseItems).values({ + id: itemId, + ownerEmail: OWNER, + databaseId, + documentId, + position: 0, + bodyHydrationStatus: "pending", + bodyHydrationError: null, + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseSources).values({ + id: sourceId, + ownerEmail: OWNER, + databaseId, + sourceType: "builder-cms", + sourceName: "collection-untimed-hydration", + sourceTable: "collection-untimed-hydration", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseSourceRows).values({ + id: "row_untimed_hydration", + ownerEmail: OWNER, + sourceId, + databaseItemId: itemId, + documentId, + sourceRowId, + sourceQualifiedId: `builder-cms://collection-untimed-hydration/${sourceRowId}`, + sourceDisplayKey: "Untimed hydration", + sourceValuesJson: JSON.stringify({ + "data.title": "Untimed hydration", + "data.url": "/blog/untimed-hydration", + }), + provenance: "Builder CMS read adapter", + lastSourceUpdatedAt: "2026-01-01T00:00:00.000Z", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseBodyHydrationQueue).values({ + id: "queue_untimed_hydration", + ownerEmail: OWNER, + sourceId, + databaseItemId: itemId, + documentId, + sourceRowId, + sourceTable: "collection-untimed-hydration", + sourceEntryJson: JSON.stringify({ + id: sourceRowId, + model: "collection-untimed-hydration", + title: "Untimed hydration", + urlPath: "/blog/untimed-hydration", + updatedAt: null, + sourceValues: { + "data.title": "Untimed hydration", + "data.url": "/blog/untimed-hydration", + [BUILDER_CMS_BODY_CONTENT_KEY]: fullBody, + }, + }), + priority: 10, + attempts: 0, + createdAt: now, + updatedAt: now, + }); + + await hydrateQueuedBodies({ sourceId, documentId, limit: 1 }); + + const [after = null] = await db + .select({ + content: schema.documents.content, + lastSourceUpdatedAt: schema.contentDatabaseSourceRows.lastSourceUpdatedAt, + }) + .from(schema.documents) + .innerJoin( + schema.contentDatabaseSourceRows, + eq(schema.contentDatabaseSourceRows.documentId, schema.documents.id), + ) + .where(eq(schema.documents.id, documentId)); + expect(after?.content).toBe(fullBody); + expect((after?.lastSourceUpdatedAt ?? "") >= now).toBe(true); +}); + it("fetches a live Builder body when an opened row only has stored body metadata", async () => { builderReadMock.mode = "full"; builderReadMock.calls = [];