diff --git a/templates/content/actions/_builder-cms-read-client.test.ts b/templates/content/actions/_builder-cms-read-client.test.ts index 9d140c74e4d..4415a2abd28 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"); @@ -2598,6 +2600,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) { @@ -2609,6 +2613,60 @@ 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", + blocks: [], + }; + 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", + // An empty body Builder returned, which the save records as such. + "__builder.body.blockCount": 0, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const fields = ( + fetchImpl.mock.lastCall?.[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 53eb4000912..3d9d56c2ff1 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,8 @@ import { type BuilderContentEntry, } from "../shared/builder-mdx.js"; import { + builderCmsReferenceIdSourceValues, + builderEntryHasAutosaves, normalizeBuilderCmsApiEntry, type BuilderCmsSourceEntry, } from "./_builder-cms-source-adapter.js"; @@ -53,6 +56,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 { @@ -224,6 +230,7 @@ const BUILDER_CMS_METADATA_ENTRY_FIELD_PATHS = [ "published", "lastUpdated", "createdDate", + "meta.hasAutosaves", "data.title", "data.handle", "data.url", @@ -269,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; } @@ -319,16 +327,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() { @@ -388,10 +400,20 @@ function stringOrNumberFromUnknown(value: unknown) { return stringFromUnknown(value); } -function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState { +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, +): BuilderCmsEntryLiveState { if (Array.isArray(value)) { return value.length > 0 - ? liveStateFromBuilderEntry(value[0]) + ? liveStateFromBuilderEntry(value[0], model) : { exists: false, published: null, @@ -412,7 +434,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 +478,8 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState { stringOrNumberFromUnknown(data.updatedAt), blocksHash: blocks.length > 0 ? builderBlocksHash(blocks) : null, id, + values: liveSourceValues(record, model), + hasAutosaves: builderEntryHasAutosaves(record), }; } @@ -1744,6 +1768,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; @@ -1793,7 +1822,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 +1830,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) { @@ -1816,7 +1845,7 @@ export async function readBuilderCmsEntryLiveState(args: { )}`, builderContentApiHost(), ); - applyBuilderCmsBodyEntryReadParams(url, publicKey); + applyBuilderCmsBodyEntryReadParams(url, publicKey, args.fieldPaths); const response = await fetchBuilderContentPage({ fetchImpl: args.fetchImpl ?? fetch, @@ -1839,7 +1868,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..51611431c95 100644 --- a/templates/content/actions/_builder-cms-source-adapter.test.ts +++ b/templates/content/actions/_builder-cms-source-adapter.test.ts @@ -2,6 +2,10 @@ 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_ENTRY_NAME_KEY, + BUILDER_CMS_HAS_AUTOSAVES_KEY, buildBuilderCmsFixtureEntry, builderCmsQualifiedId, builderCmsSourceFieldKey, @@ -218,10 +222,54 @@ 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", }, }); }); + 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( + { + 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..7f021ad99cb 100644 --- a/templates/content/actions/_builder-cms-source-adapter.ts +++ b/templates/content/actions/_builder-cms-source-adapter.ts @@ -19,6 +19,59 @@ 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}`; +} + +/** 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; @@ -297,6 +350,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,10 +382,20 @@ 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; + } const blocks = builderEntryBlocks(args.rawEntry); 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 new file mode 100644 index 00000000000..4c4d44a26aa --- /dev/null +++ b/templates/content/actions/_builder-cms-unpublished-changes.test.ts @@ -0,0 +1,788 @@ +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_BODY_LOSSLESS_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; + liveLastUpdated?: number; + liveBlocksHash?: string; + liveBodyLastUpdated?: number; + }, +) { + 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, + incomingFilledFromRow: true, + }); + + expect(next["data.blurb"]).toBe("Saved blurb"); + expect(readBuilderCmsUnpublishedChanges(next)).toEqual({ + state: "pending", + changes: { liveValues: { "data.blurb": null } }, + }); + }); + + 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( + { + published: "published", + "data.author": "Saved author", + "__agent_native_builder_reference_id:data.author": "author-2", + }, + { + liveValues: { + "data.author": "Live author", + "__agent_native_builder_reference_id:data.author": "author-1", + }, + }, + ); + + 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("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" }, + { 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 a read made after the save says nothing is pending", () => { + const existing = saved( + { published: "published", "data.title": "Saved 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": "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("Saved title"); + expect(next).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("lets Builder win and keeps the unreadable record 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", + "data.blurb": "New Builder field", + }, + existing, + }); + + 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( + 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 { + error.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("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( + { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-hash", + }, + { + liveValues: {}, + liveBlocksHash: "live-hash", + liveBodyLastUpdated: 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, + builderValues: { + 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", + writtenFieldKeys: ["data.title", "data.blurb"], + wroteBody: true, + live: { + values: { "data.title": "Live title" }, + blocksHash: "live-hash", + lastUpdated: 1000, + }, + }); + expect(first).toEqual({ + liveValues: { "data.title": "Live title", "data.blurb": null }, + liveLastUpdated: 1000, + liveBlocksHash: "live-hash", + liveBodyLastUpdated: 1000, + }); + + const second = builderCmsUnpublishedChangesAfterWrite({ + existing: withBuilderCmsUnpublishedChanges( + { published: "published" }, + first, + ), + effect: "autosave", + writtenFieldKeys: ["data.title"], + wroteBody: false, + live: { + 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, + }, + liveLastUpdated: 2000, + liveBlocksHash: "live-hash", + liveBodyLastUpdated: 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", + }, + liveLastUpdated: 1000, + }); + }); + + 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, + bodyEmpty: true, + lastUpdated: 1000, + }, + }); + expect(changes).toEqual({ + liveValues: {}, + liveLastUpdated: 1000, + liveBodyEmpty: true, + liveBodyLastUpdated: 1000, + }); + const existing = withBuilderCmsUnpublishedChanges( + { + published: "published", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Saved body", + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "saved-hash", + }, + 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: 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", + 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("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({ + existing: { published: "published" }, + effect: "autosave", + writtenFieldKeys: ["data.title"], + wroteBody: false, + live: { values: null, blocksHash: null, lastUpdated: 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" }, + liveLastUpdated: 900, + liveBlocksHash: "live-hash", + liveBodyLastUpdated: 1000, + }, + ); + + expect( + builderCmsUnpublishedChangesAfterWrite({ + existing, + effect: "update_in_place", + writtenFieldKeys: ["data.title"], + wroteBody: true, + live: { values: null, blocksHash: null, lastUpdated: null }, + }), + ).toEqual({ + liveValues: { "data.blurb": "Live blurb" }, + liveLastUpdated: 900, + }); + for (const effect of ["publish", "unpublish"] as const) { + expect( + builderCmsUnpublishedChangesAfterWrite({ + existing, + effect, + writtenFieldKeys: [], + wroteBody: false, + live: { values: null, blocksHash: null, lastUpdated: 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: "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 new file mode 100644 index 00000000000..b7b176dc268 --- /dev/null +++ b/templates/content/actions/_builder-cms-unpublished-changes.ts @@ -0,0 +1,429 @@ +import type { + BuilderCmsWriteEffect, + 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_HAS_AUTOSAVES_KEY, + BUILDER_CMS_REFERENCE_ID_KEY_PREFIX, + builderCmsReferenceIdKey, +} 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 last saved over it, + * with a reference field's id under its reference-id key. + */ + liveValues: Record; + /** + * 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. */ + liveBlocksHash?: string; + /** + * 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. + */ + liveBodyEmpty?: true; + /** Builder's lastUpdated when Content last saved a body over it. */ + liveBodyLastUpdated?: number | string; +} + +export type BuilderCmsUnpublishedChangesRead = + | { state: "none" } + | { state: "pending"; changes: BuilderCmsUnpublishedChanges } + | { state: "invalid" }; + +export type BuilderCmsEntryStatus = + | "published" + | "draft" + | "unpublished_changes"; + +function hasKey( + record: Record | null | undefined, + key: string, +): boolean { + 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; + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + const parsed = Date.parse(value); + 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)) { + 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); +} + +/** + * 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; + 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 { + 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, + 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)) + ) { + return { state: "invalid" }; + } + return { + state: "pending", + changes: { + liveValues: liveValues as Record, + ...(liveLastUpdated !== undefined ? { liveLastUpdated } : {}), + ...(liveBlocksHash ? { liveBlocksHash } : {}), + ...(liveBodyEmpty ? { liveBodyEmpty } : {}), + ...(liveBodyLastUpdated !== undefined ? { liveBodyLastUpdated } : {}), + }, + }; +} + +function hasBodyRecord(changes: BuilderCmsUnpublishedChanges) { + return ( + !!changes.liveBlocksHash || + !!changes.liveBodyEmpty || + changes.liveBodyLastUpdated !== undefined + ); +} + +export function withBuilderCmsUnpublishedChanges( + sourceValues: Record, + changes: BuilderCmsUnpublishedChanges | null, +): Record { + const next = { ...sourceValues }; + if ( + changes && + (Object.keys(changes.liveValues).length > 0 || hasBodyRecord(changes)) + ) { + 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. An + * unreadable record still means Content saved some, so it counts too. + */ +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 !== "none" || + sourceValues?.[BUILDER_CMS_HAS_AUTOSAVES_KEY] === true + ? "unpublished_changes" + : "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, + builderValues: Record, +): boolean { + if (!hasBodyRecord(changes)) return false; + 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; + } + if (readHash ? readHash === changes.liveBlocksHash : changes.liveBodyEmpty) { + return true; + } + const recordedEpoch = epochMs(changes.liveBodyLastUpdated); + return ( + recordedEpoch !== null && + epochMs(builderValues.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; + * once Builder publishes, discards, or edits that field, Builder's value wins. + * 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; + existing: Record | null | undefined; + /** + * A body read fills fields Builder did not return with the row's stored + * values, so a field that equals Content's saved value says nothing. + */ + incomingFilledFromRow?: boolean; + /** + * 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. + */ + builderValues?: Record; +}): Record { + const read = readBuilderCmsUnpublishedChanges(args.existing); + if (read.state === "invalid" && args.existing) { + // 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( + "A Builder row's unpublished-changes record is unreadable, so Builder's values win; the next save from Content replaces the record.", + ); + 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); + } + 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 ( + !readPredatesSave && + (published !== "published" || builderDiscardedChanges) + ) { + return withBuilderCmsUnpublishedChanges(args.incoming, null); + } + const next = { ...args.incoming }; + const liveValues: Record = {}; + 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 = + readPredatesSave || + !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, referenceKey]) { + 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 + ? hasBodyRecord(recorded) + : builderCmsBodyStillSavedOver(recorded, builder); + if (bodyKept) { + for (const key of BUILDER_CMS_BODY_BASELINE_KEYS) { + const kept = ownValue(existing, key); + if (kept === undefined) delete next[key]; + else next[key] = kept; + } + } + return withBuilderCmsUnpublishedChanges(next, { + liveValues, + ...(recorded.liveLastUpdated !== undefined + ? { liveLastUpdated: recorded.liveLastUpdated } + : {}), + ...(bodyKept && recorded.liveBlocksHash + ? { liveBlocksHash: recorded.liveBlocksHash } + : {}), + ...(bodyKept && recorded.liveBodyLastUpdated !== undefined + ? { liveBodyLastUpdated: recorded.liveBodyLastUpdated } + : {}), + }); +} + +/** + * 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: { + existing: Record | null | undefined; + effect: BuilderCmsWriteEffect; + writtenFieldKeys: string[]; + wroteBody: boolean; + 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 { + 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 liveLastUpdated = current.liveLastUpdated; + let liveBlocksHash = current.liveBlocksHash; + let liveBodyEmpty = current.liveBodyEmpty; + let liveBodyLastUpdated = current.liveBodyLastUpdated; + 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) { + // A field the live entry lacks is recorded as null, which a refresh + // that also lacks it reads as unchanged. + liveValues[key] = ownValue(live, key) ?? null; + const referenceKey = builderCmsReferenceIdKey(key); + const liveReference = ownValue(live, referenceKey); + if (liveReference === undefined) delete liveValues[referenceKey]; + else liveValues[referenceKey] = liveReference; + } + 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 { + for (const key of args.writtenFieldKeys) { + delete liveValues[key]; + delete liveValues[builderCmsReferenceIdKey(key)]; + } + if (args.wroteBody) { + liveBlocksHash = undefined; + liveBodyEmpty = undefined; + liveBodyLastUpdated = undefined; + } + } + return { + liveValues, + ...(liveLastUpdated !== undefined ? { liveLastUpdated } : {}), + ...(liveBlocksHash ? { liveBlocksHash } : {}), + ...(liveBodyEmpty ? { liveBodyEmpty } : {}), + ...(liveBodyLastUpdated !== undefined ? { liveBodyLastUpdated } : {}), + }; +} diff --git a/templates/content/actions/_builder-cms-write-adapter.test.ts b/templates/content/actions/_builder-cms-write-adapter.test.ts index 12b638871b8..e623919cce3 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), @@ -1404,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 3795f0dc8b2..5ea84feb98d 100644 --- a/templates/content/actions/_builder-cms-write-adapter.ts +++ b/templates/content/actions/_builder-cms-write-adapter.ts @@ -7,10 +7,12 @@ 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 { 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"; @@ -58,6 +60,47 @@ 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; + /** 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; +} + +/** + * 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"], +) { + 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}`), + ...(typeof request.body.name === "string" + ? [BUILDER_CMS_ENTRY_NAME_KEY] + : []), + ], + wroteBody: record.blocks !== undefined, + }; } export interface BuilderCmsExecutionPlan { @@ -310,18 +353,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 +449,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 +457,7 @@ export function resolveBuilderCmsWriteEffect(args: { pushMode: effectivePushMode, writeMode: sourceWriteMode, entryId, + entryPublished: targetRow?.sourceValues?.published, publicationTransition: args.publicationTransition, }); } @@ -514,11 +581,31 @@ 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; 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"] { @@ -533,21 +620,20 @@ 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 (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 +776,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 +902,7 @@ export function buildBuilderCmsExecutionPlan(args: { pushMode: effectivePushMode, writeMode: sourceWriteMode, entryId: targetEntryId, + entryPublished: targetRow?.sourceValues?.published, publicationTransition: args.publicationTransition, }); const operations: BuilderCmsExecutionOperation[] = []; @@ -856,6 +950,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 @@ -976,11 +1074,27 @@ 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; + } + // 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", + request: { ...rest.request, query }, + }; } export function validateBuilderCmsExecutionDryRun(args: { @@ -990,8 +1104,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 +1175,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/_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..de0795a91b5 --- /dev/null +++ b/templates/content/actions/_builder-status-property.db.test.ts @@ -0,0 +1,531 @@ +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, vi } from "vitest"; + +import type { + BuilderCmsSourceEntry, + ContentDatabaseItem, + DocumentProperty, +} 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`, +); +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: 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"); + +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 fixture = rows.find((row) => row.documentId === documentId); + if (!fixture) throw new Error(`No fixture row for ${documentId}`); + const title = fixture.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 never); + 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); +}); + +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" }); +}); + +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/_builder-status-property.ts b/templates/content/actions/_builder-status-property.ts new file mode 100644 index 00000000000..7643237ec86 --- /dev/null +++ b/templates/content/actions/_builder-status-property.ts @@ -0,0 +1,321 @@ +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 = null] = 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; +} + +async function existingBuilderStatusPropertyId(db: Db, databaseId: string) { + const [definition = null] = 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 = null] = 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); + }, + ); +} + +/** + * 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 in the same transaction, or a write + * racing it can leave the status stale until the next refresh. + */ +export async function writeBuilderStatusValues( + db: Db, + args: { + sourceId: string; + rows: ReadonlyArray<{ + documentId: string | null | undefined; + sourceValues: Record | null | undefined; + }>; + now: string; + }, +) { + 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 = 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)); + } +} + +/** 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[] }, +) { + if (args.documentIds.length === 0) return; + const propertyId = await existingBuilderStatusPropertyId(db, args.databaseId); + if (!propertyId) return; + for (const chunk of chunks( + [...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, cleared), + ), + ); + } +} diff --git a/templates/content/actions/_database-row-mutation.ts b/templates/content/actions/_database-row-mutation.ts index 8f2ed3407a3..dd9705957a6 100644 --- a/templates/content/actions/_database-row-mutation.ts +++ b/templates/content/actions/_database-row-mutation.ts @@ -534,6 +534,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.test.ts b/templates/content/actions/_database-source-utils.test.ts index 48c27a61b1d..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, @@ -16,6 +17,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, @@ -28,6 +30,7 @@ import { builderBodyHydrationNextAttemptAt, builderBodyHydrationFailureEvidence, builderBodyNeedsSourceComponentWrite, + builderBodyReadSourceValues, knownBuilderReviewDocumentIds, builderSourcePropertyAssignments, builderBodyHydrationVersion, @@ -2995,6 +2998,224 @@ 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("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, { + 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, { + 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", + 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("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 6e02249834f..341c31a4a64 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -93,6 +93,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, @@ -101,7 +102,10 @@ import { BUILDER_CMS_BODY_SIDECARS_KEY, BUILDER_CMS_FIXTURE_ROW_PROVENANCE, buildBuilderCmsFixtureEntry, + builderCmsBodyKnownEmpty, builderCmsQualifiedId, + builderCmsReferenceIdKey, + builderCmsReferenceIdSourceValues, builderCmsSourceFieldKey, builderCmsSourceMetadata, builderCmsSourceRowIdentity, @@ -109,8 +113,18 @@ import { type BuilderCmsSourceEntry, type ExistingBuilderSourceRowIdentity, } from "./_builder-cms-source-adapter.js"; +import { + builderCmsBodyStillSavedOver, + 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 { + clearBuilderStatusValues, + ensureBuilderStatusProperty, + writeBuilderStatusValues, +} from "./_builder-status-property.js"; import { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { ensureDocumentsFilesMembership } from "./_content-files.js"; import { @@ -826,7 +840,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) { @@ -1478,10 +1492,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, @@ -1978,10 +1989,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, @@ -2004,6 +2015,53 @@ 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. + */ +/** + * `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, +) { + 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: builderReadOverValues(rowBase, builderValues), + existing: rowValues, + incomingFilledFromRow: true, + builderValues, + }); +} + function parseHydrationEntry( row: ContentDatabaseBodyHydrationQueueRowDb, ): BuilderCmsSourceEntry | null { @@ -2034,33 +2092,31 @@ 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; 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 ?? "{}", @@ -2114,8 +2170,17 @@ async function processBuilderBodyHydrationJob( const incomingLastUpdated = stringSourceValue(entryWithBody.sourceValues, "lastUpdated") ?? entryWithBody.updatedAt; + const unpublishedChanges = readBuilderCmsUnpublishedChanges(sourceValues); + const incomingIsBodySavedOver = + unpublishedChanges.state === "pending" && + !!incomingBlocksHash && + builderCmsBodyStillSavedOver( + unpublishedChanges.changes, + entryWithBody.sourceValues, + ); if ( !isCodecMigration && + !incomingIsBodySavedOver && builderBodyBaselineHasSameVersionConflict({ persistedBlocksHash: stringSourceValue( sourceValues, @@ -2137,17 +2202,20 @@ 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 = builderBodyReadSourceValues(sourceValues, entryWithBody); let nextContent = stringSourceValue(nextValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; let emptyBodyRead: Extract< BuilderLiveBodyReadResult, { state: "empty_body" | "not_found" } > | null = null; - 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, @@ -2159,13 +2227,13 @@ 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()) { + if (hasBody(rebuiltValues, rebuiltContent)) { const rebuiltSourceEntryJson = JSON.stringify(rebuiltEntry); const [upgraded] = await db .update(schema.contentDatabaseBodyHydrationQueue) @@ -2195,7 +2263,7 @@ async function processBuilderBodyHydrationJob( nextContent = rebuiltContent; } } - if (!nextContent.trim() && sourceRow) { + if (!hasBody(nextValues, nextContent) && sourceRow) { const liveRead = await readBuilderEntryWithLiveBodyFromSourceRow({ row: sourceRow, sourceTable: row.sourceTable, @@ -2203,15 +2271,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 = { - ...sourceValues, - ...liveEntry.sourceValues, - }; + 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) @@ -2239,151 +2306,173 @@ async function processBuilderBodyHydrationJob( entryWithBody = liveEntry; nextValues = liveValues; nextContent = liveContent; + } else if (liveRead.state === "empty_body") { + emptyBodyRead = liveRead; } - } 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 (!hasBody(nextValues, nextContent)) { + const attempts = row.attempts; + await db.transaction(async (tx) => { + const queueRowCas = builderBodyHydrationQueueOwnershipFilter( + row, + activeSourceEntryJson, + ); + const markPendingIfReplaced = async () => { + const [replacedByNewerJob = null] = 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 [stillOwnsQueueRow = null] = await tx + .update(schema.contentDatabaseBodyHydrationQueue) + .set({ updatedAt: now }) + .where(queueRowCas) + .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); + if (!stillOwnsQueueRow) { + await markPendingIfReplaced(); + return; + } + // The next refresh compares the version with the row as written. + const writtenEntry = { + ...emptyBodyRead.entry, + updatedAt: emptyBodyRead.entry.updatedAt, + sourceValues: builderBodyReadSourceValues( + sourceValues, + emptyBodyRead.entry, + ), }; - 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({ - ...sourceValues, - ...emptyBodyRead.entry.sourceValues, - }), - 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)); + const [updatedSourceRow = null] = await tx + .update(schema.contentDatabaseSourceRows) + .set({ + sourceValuesJson: JSON.stringify(writtenEntry.sourceValues), + lastSyncedAt: now, + lastSourceUpdatedAt: writtenEntry.updatedAt, + updatedAt: now, + }) + .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; } - 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)); + const [deleted = null] = await tx + .delete(schema.contentDatabaseBodyHydrationQueue) + .where(queueRowCas) + .returning({ id: schema.contentDatabaseBodyHydrationQueue.id }); + if (!deleted) { + await markPendingIfReplaced(); return; } - const [stillOwnsQueueRow] = await tx - .update(schema.contentDatabaseBodyHydrationQueue) + await writeBuilderStatusValues(tx, { + sourceId: row.sourceId, + rows: [ + { + documentId: row.documentId, + sourceValues: writtenEntry.sourceValues, + }, + ], + now, + }); + await tx + .update(schema.contentDatabaseItems) .set({ - lastAttemptedAt: null, - lastError: BUILDER_BODY_NOT_AVAILABLE_ERROR, - nextAttemptAt: builderBodyHydrationNextAttemptAt(attempts, now), + 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 = null] = 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." + : "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 ? 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 = null] = 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 @@ -2468,71 +2557,43 @@ 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. + // 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: queuedUpdatedAt ?? 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) + .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 @@ -2557,14 +2618,23 @@ async function processBuilderBodyHydrationJob( } return; } + await writeBuilderStatusValues(tx, { + sourceId: row.sourceId, + rows: [ + { documentId: row.documentId, sourceValues: writtenEntry.sourceValues }, + ], + now, + }); await tx .update(schema.contentDatabaseItems) .set({ bodyHydrationStatus: "hydrated", bodyHydrationAttemptedAt: now, bodyHydrationError: null, - bodyHydrationVersion: builderBodyHydrationVersion(entryWithBody), - bodyHydrationReason: null, + bodyHydrationVersion: builderBodyHydrationVersion(writtenEntry), + bodyHydrationReason: builderCmsBodyKnownEmpty(nextValues) + ? "empty_body" + : null, bodyHydrationProviderStatus: "http_200", bodyHydrationAttemptCount: row.attempts, bodyHydrationRetryable: 0, @@ -2584,6 +2654,7 @@ type PreparedPristineBuilderBodyHydration = { sourceRow: ContentDatabaseSourceRecordRowDb; documentContent: string; content: string; + sourceValues: Record; sourceValuesJson: string; lastSourceUpdatedAt: string; bodyHydrationVersion: string; @@ -2600,6 +2671,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: Pick, "update">, + args: { + row: ContentDatabaseBodyHydrationQueueRowDb; + queueRowCas: ReturnType; + now: string; + error: string; + }, +) { + const [stillQueued = null] = 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, @@ -2662,16 +2780,13 @@ async function preparePristineBuilderBodyHydration(args: { const entryWithBody = await refreshBuilderBodySourceValuesFromStoredLossless( await withBuilderBodySourceValues({ ...bodyEntry, - sourceValues: { - ...queuedEntry.sourceValues, - ...bodyEntry.sourceValues, - }, + sourceValues: builderReadOverValues( + queuedEntry.sourceValues, + bodyEntry.sourceValues, + ), }), ); - const nextValues = { - ...sourceValues, - ...entryWithBody.sourceValues, - }; + const nextValues = builderBodyReadSourceValues(sourceValues, entryWithBody); const content = stringSourceValue(nextValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? ""; if (!content.trim()) return null; @@ -2680,6 +2795,7 @@ async function preparePristineBuilderBodyHydration(args: { sourceRow, documentContent: documentContent ?? "", content, + sourceValues: nextValues, sourceValuesJson: JSON.stringify(nextValues), lastSourceUpdatedAt: entryWithBody.updatedAt ?? new Date().toISOString(), bodyHydrationVersion: builderBodyHydrationVersion(entryWithBody), @@ -2864,6 +2980,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) { @@ -5609,6 +5737,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()}`, @@ -5694,10 +5827,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)}`, })) @@ -5873,6 +6006,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); @@ -5882,9 +6021,27 @@ 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, + }); + } }); } +function builderStatusRows( + rows: Array<{ documentId: string; sourceValuesJson: string }>, +) { + return rows.map((row) => ({ + documentId: row.documentId, + sourceValues: parseObject>( + row.sourceValuesJson, + ), + })); +} + function mockSourceRowsForSeed(args: { sourceId: string; ownerEmail: string; @@ -6001,6 +6158,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) => { @@ -6016,6 +6179,7 @@ export async function replaceMockSourceRows(args: { const oldRows = await tx .select({ databaseItemId: schema.contentDatabaseSourceRows.databaseItemId, + documentId: schema.contentDatabaseSourceRows.documentId, }) .from(schema.contentDatabaseSourceRows) .where(scope); @@ -6025,6 +6189,25 @@ export async function replaceMockSourceRows(args: { ]); await tx.delete(schema.contentDatabaseSourceRows).where(scope); await insertMockSourceRows(tx, rows, args.now); + const [source = null] = 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") { + await writeBuilderStatusValues(tx, { + sourceId: args.sourceId, + rows: builderStatusRows(rows), + now: args.now, + }); + } }); } @@ -6041,34 +6224,19 @@ export function sourceValuesForSeededSourceRow(args: { Record >(args.existingSourceValuesJson); if (args.builderEntry?.sourceValues) { - const values = builderSourceValuesWithPreservedBodyBaseline({ - incoming: args.builderEntry.sourceValues, + const values = { + ...builderSourceValuesWithPreservedBodyBaseline({ + incoming: args.builderEntry.sourceValues, + existing: existingSourceValues, + existingLastSourceUpdatedAt: args.existingLastSourceUpdatedAt, + }), + ...builderCmsReferenceIdSourceValues(args.builderEntry.rawEntry?.data), + }; + return builderSourceValuesKeepingUnpublishedChanges({ + incoming: values, existing: existingSourceValues, - existingLastSourceUpdatedAt: args.existingLastSourceUpdatedAt, + builderValues: args.builderEntry.sourceValues, }); - 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(); - } - } - return values; } if (existingSourceValues) return existingSourceValues; if (args.sourceType !== "builder-cms") return {}; @@ -6082,7 +6250,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; @@ -6116,20 +6284,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]; } @@ -7464,6 +7627,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)) @@ -7481,6 +7645,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), + }); }); } @@ -7825,36 +7995,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 = null] = 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: { @@ -7969,11 +8157,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/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 1ac3d55c2a7..96e8592c26a 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,16 @@ 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 }); + 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 6c3ed4fa6df..1ac855acd70 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 { lockContentDatabaseMutation } from "./_content-database-mutation-lock.js"; import { flushOpenDocumentEditorToSql } from "./_document-flush.js"; import { @@ -167,15 +168,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 3de8ca91edd..72bc4caa22a 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -7,7 +7,16 @@ 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, +} from "./_builder-cms-unpublished-changes"; import { buildBuilderCmsExecutionPlan, builderCmsExecutionIdempotencyKey, @@ -269,6 +278,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 +480,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 +550,27 @@ 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, + bodyEmpty: false, + hasAutosaves: null, + values: { + "data.title": "Old title", + [BUILDER_CMS_ENTRY_NAME_KEY]: null, + }, + }, + }), + }), now: NOW, }), ); @@ -542,7 +583,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, @@ -563,6 +607,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", }); @@ -580,6 +625,109 @@ 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("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] }); @@ -1073,6 +1221,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", }); @@ -1107,6 +1256,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 +2201,859 @@ describe("execute Builder source execution", () => { ); }); }); + +describe("Builder unpublished saves", () => { + const publishedRow = ( + overrides: Partial = {}, + ) => + row({ + sourceValues: { + published: "published", + "data.title": "Old title", + [BUILDER_CMS_ENTRY_NAME_KEY]: "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 }, + { + name: "author", + label: "Author", + type: "reference", + model: "blog-author", + }, + ], + }, + }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + ...args.readLiveEntry, + values: args.readLiveEntry.values && { + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", + ...args.readLiveEntry.values, + }, + }, + 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).not.toHaveProperty("query.autoSaveOnly"); + expect(request).not.toHaveProperty("body.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("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", + values: { [BUILDER_CMS_ENTRY_NAME_KEY]: "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("checks and keeps Builder's own entry name on a save that leaves the 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); + 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 () => { + const { deps, run } = unpublishedSave({ + rows: [ + publishedRow({ + lastSourceUpdatedAt: String(STALE_BUILDER_LAST_UPDATED_MS), + sourceValues: withBuilderCmsUnpublishedChanges( + { + 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", + }, + }, + ), + }), + ], + readLiveEntry: { + exists: true, + published: "published", + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + values: { + "data.title": "Live title", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Live title", + }, + }, + }); + + await expect(run()).resolves.toMatchObject(RESPONSE); + 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", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", + }, + }); + 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", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", + }, + }), + }), + }), + }), + ); + }); + + 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", + [BUILDER_CMS_ENTRY_NAME_KEY]: "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", + [BUILDER_CMS_ENTRY_NAME_KEY]: "Old title", + }, + }, + }, + }, + }), + ); + + 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", + [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("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; + existingLastUpdated?: string; + livePublished?: string; + responseBody?: unknown; + }) => + JSON.parse( + builderCmsReconciledSourceValuesJson({ + existingSourceValuesJson: JSON.stringify({ + published: args.existing, + ...(args.existingLastUpdated + ? { lastUpdated: args.existingLastUpdated } + : {}), + }), + 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", + ); + // 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", () => { + 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("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({ + 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 f86de7ab0a6..d2179d4b7e8 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -39,18 +39,31 @@ 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"; +import { + builderCmsSourceFieldMatches, + 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, @@ -60,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 { @@ -184,6 +198,7 @@ export interface ExecuteBuilderSourceExecutionDeps { readLiveEntry: (args: { model: string; entryId: string; + fieldPaths: readonly string[]; expectedSourceSpace?: string | null; expectedSourceConnectionId?: string | null; }) => Promise; @@ -427,16 +442,63 @@ function parseSourceValues( } } +/** + * 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"]; + 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") { + 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: { existingSourceValuesJson: string | null | undefined; snapshotSourceValues: Record | undefined; changeSet: ContentDatabaseSourceChangeSet; plan: BuilderCmsExecutionPlan; + responseBody?: unknown; }) { - const next = { + const existing = { ...(args.snapshotSourceValues ?? {}), ...parseSourceValues(args.existingSourceValuesJson), }; + const next = { ...existing }; + const published = builderCmsPublishedAfterWrite({ + effect: args.plan.payload.effect, + livePreflight: args.plan.payload.livePreflight, + storedLastUpdated: existing.lastUpdated, + responseBody: args.responseBody, + }); + if (published) next.published = published; for (const field of args.changeSet.fieldChanges) { next[field.sourceFieldKey] = field.proposedValue; } @@ -452,12 +514,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}`; @@ -470,13 +540,36 @@ 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; } } } - return JSON.stringify(next); + const livePreflight = args.plan.payload.livePreflight; + // 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( + next, + builderCmsUnpublishedChangesAfterWrite({ + existing, + effect: args.plan.payload.effect, + writtenFieldKeys: written.fieldKeys, + wroteBody: written.wroteBody, + live: { + values: livePreflight?.values ?? null, + blocksHash: livePreflight?.blocksHash ?? null, + bodyEmpty: livePreflight?.bodyEmpty === true, + lastUpdated: livePreflight?.lastUpdated ?? null, + }, + }), + ), + ); } function sourceRowForChangeSet( @@ -494,7 +587,7 @@ function sourceRowForChangeSet( function requiresLivePreflight(effect: BuilderCmsExecutionPayload["effect"]) { return ( - effect === "autosave" || + isBuilderCmsUnpublishedSaveEffect(effect) || effect === "update_in_place" || effect === "publish" || effect === "unpublish" @@ -540,7 +633,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."; @@ -553,23 +649,95 @@ 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 fieldStillReviewed = ( + key: string, + liveValues: Record, + ) => + 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, 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) => !fieldStillReviewed(key, liveValues), + ); + 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 + args.liveState.blocksHash !== args.baselineBlocksHash && + args.liveState.blocksHash !== savedOver?.liveBlocksHash ) { 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" + ) { + return "This entry is archived in Builder; restore it there before saving."; + } if ( - args.effect === "publish" && + (args.effect === "publish" || + isBuilderCmsUnpublishedSaveEffect(args.effect)) && args.liveState.published !== "draft" && args.liveState.published !== "published" ) { @@ -581,6 +749,87 @@ 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, + bodyEmpty: liveValues?.[BUILDER_CMS_BODY_BLOCK_COUNT_KEY] === 0, + hasAutosaves: args.liveState.hasAutosaves ?? null, + values: liveValues + ? Object.fromEntries( + 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, + }; +} + +/** + * 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) => { + if (sourceFieldKey === BUILDER_CMS_ENTRY_NAME_KEY) return "Entry name"; + 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; @@ -663,13 +912,14 @@ 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, snapshotSourceValues: snapshotRow?.sourceValues, changeSet: args.changeSet, plan: args.plan, + responseBody: args.writeResult.responseBody, }); const patchWithValues = { ...patch, @@ -695,6 +945,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) @@ -1757,8 +2017,25 @@ 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, 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) + ? { + livePreflight: + storedPayload.livePreflight as BuilderCmsLivePreflight, + } + : {}), + }; const payloadWithResponse = executionResponsePayload({ - payload: validatedPayload, + payload: writtenPayload, writeResult: storedWriteResult, }); const reconciledAt = deps.now(); @@ -1767,7 +2044,7 @@ export async function executeBuilderSourceExecutionWithDeps( database, source, changeSet, - plan, + plan: { ...plan, payload: writtenPayload }, writeResult: storedWriteResult, now: reconciledAt, }); @@ -1790,7 +2067,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, }); @@ -1863,6 +2146,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 @@ -1873,6 +2157,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, }); @@ -1917,27 +2203,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, { @@ -1945,14 +2229,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, @@ -1969,7 +2264,7 @@ export async function executeBuilderSourceExecutionWithDeps( throw error; } const payloadWithResponse = executionResponsePayload({ - payload: validatedPayload, + payload: writePayload, writeResult, }); @@ -2045,7 +2340,7 @@ export async function executeBuilderSourceExecutionWithDeps( database, source, changeSet, - plan, + plan: writePlan, writeResult, now: succeededAt, }); @@ -2066,7 +2361,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: writePayload.effect, + writeResult, + executionId: execution.id, + changeSetId: changeSet.id, + }), 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/actions/resync-content-database-source.db.test.ts b/templates/content/actions/resync-content-database-source.db.test.ts index d163e778c5b..d56ce45a202 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -8,15 +8,23 @@ 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, 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,10 +86,36 @@ 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" && + 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 { @@ -3754,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 = []; @@ -5130,6 +5286,608 @@ 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 = []; + 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 }, + }); + + // 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: "Saved Content body.", + 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, + }); + const { + builderStatusPropertyId, + ensureBuilderStatusProperty, + 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 ensureBuilderStatusProperty({ sourceId, now }); + await writeBuilderStatusValues(db, { + sourceId, + rows: [{ documentId, sourceValues: JSON.parse(savedRow.sourceValuesJson) }], + now, + }); + 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({ + 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 () => { const db = getDb(); const now = new Date().toISOString(); 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..5fb227c113b 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.test.ts @@ -1,24 +1,50 @@ import { describe, expect, it } from "vitest"; +import { messagesByLocale } from "../../../i18n-data"; import { builderReviewDestinationLine, builderReviewEffectiveRowEffect, builderReviewIntentSummary, builderReviewHasUnconfirmedUnpublish, builderReviewPublicationTransitionsMap, + builderReviewPushVerb, builderReviewResultStatus, builderReviewRowEffectLabel, builderReviewScopedTransitionSelections, } from "./BuilderSourceReviewDialog"; +function english(message: { key: string; count: number }) { + 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", () => { 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", () => { @@ -39,8 +65,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" }, @@ -55,6 +85,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 3c36b8890b3..4a04090ee55 100644 --- a/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +++ b/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx @@ -56,15 +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" }, - 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) { @@ -81,14 +102,22 @@ 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, autosave: 0, + save_draft: 0, publish: 0, unpublish: 0, }; @@ -97,25 +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`, - ); - } - if (counts.update_in_place) { - parts.push( - `${counts.update_in_place} update${counts.update_in_place === 1 ? "" : "s"}`, + // 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.autosave) { - parts.push( - `${counts.autosave} draft save${counts.autosave === 1 ? "" : "s"}`, + if (only("autosave", "save_draft")) { + return countedMessage( + count, + "builderPushVerbSaveOne", + "builderPushVerbSave", ); } - 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: { @@ -431,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, @@ -491,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…" @@ -655,7 +710,7 @@ export function BuilderSourceReviewDialog({ row.effect, selection, ); - const { tag, sentence } = + const { tagKey, sentenceKey } = builderReviewRowEffectLabel(effect); const showConflict = effect !== "create_draft" && @@ -685,7 +740,7 @@ export function BuilderSourceReviewDialog({ ) : null}
- {sentence} + {t(`database.${sentenceKey}`)}
- {tag} + {t(`database.${tagKey}`)}
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..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 @@ -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"]') diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index 5aa7fa83d77..86673cd77fe 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -8617,7 +8617,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 c9d0865aaa4..5396546eda5 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -719,7 +719,36 @@ 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", + 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", + 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 84b0844c58d..8f4696aa709 100644 --- a/templates/content/app/i18n/ar-SA.ts +++ b/templates/content/app/i18n/ar-SA.ts @@ -262,7 +262,36 @@ const database = { "اربط مجموعة Builder أو جدولًا آخر لتعيينه على هذه الصفوف.", noBuilderWrites: "لا توجد عمليات كتابة في Builder.", stageOnly: "التجهيز فقط", - savesDraftsNeverPublishes: "يحفظ المسودات في Builder — لا ينشر أبدًا.", + savesChangesNeverPublishes: "يحفظ التغييرات في Builder — لا ينشر أبدًا.", + builderEffectNewTag: "جديد", + builderEffectNewSentence: "ينشئ إدخال مسودة جديدًا", + builderEffectEditTag: "تعديل", + builderEffectEditSentence: "يحدّث الإدخال المنشور", + builderEffectSaveTag: "حفظ", + builderEffectAutosaveSentence: "محفوظ، ولن يظهر للعامة حتى تنشره في Builder", + builderEffectSaveDraftSentence: "يحفظ المسودة", + builderEffectPublishTag: "نشر", + 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 b591a2d9b5f..23879e5f18f 100644 --- a/templates/content/app/i18n/de-DE.ts +++ b/templates/content/app/i18n/de-DE.ts @@ -281,8 +281,38 @@ 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", + 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", + 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 055e52d0cb6..6545c3ab39b 100644 --- a/templates/content/app/i18n/es-ES.ts +++ b/templates/content/app/i18n/es-ES.ts @@ -278,7 +278,37 @@ 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", + 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", + 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 f05cb7104d1..f7e5f412867 100644 --- a/templates/content/app/i18n/fr-FR.ts +++ b/templates/content/app/i18n/fr-FR.ts @@ -282,8 +282,38 @@ 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", + builderEffectEditSentence: "Met à jour l'entrée publiée", + 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 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 3093f8d1678..a638115211f 100644 --- a/templates/content/app/i18n/hi-IN.ts +++ b/templates/content/app/i18n/hi-IN.ts @@ -260,7 +260,36 @@ const database = { "इन पंक्तियों पर मैप करने के लिए एक Builder संग्रह या अन्य तालिका कनेक्ट करें।", noBuilderWrites: "कोई Builder राइट नहीं।", stageOnly: "केवल स्टेज करें", - savesDraftsNeverPublishes: "Builder में ड्राफ़्ट सहेजता है — कभी प्रकाशित नहीं करता।", + savesChangesNeverPublishes: "Builder में बदलाव सहेजता है — कभी प्रकाशित नहीं करता।", + builderEffectNewTag: "नया", + builderEffectNewSentence: "नई ड्राफ़्ट एंट्री बनाता है", + builderEffectEditTag: "संपादित करें", + builderEffectEditSentence: "लाइव एंट्री अपडेट करता है", + builderEffectSaveTag: "सहेजें", + builderEffectAutosaveSentence: "सहेजा गया, Builder में प्रकाशित करने तक लाइव नहीं", + builderEffectSaveDraftSentence: "ड्राफ़्ट सहेजता है", + builderEffectPublishTag: "प्रकाशित करें", + 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 23bc777dffa..113e66ed5c1 100644 --- a/templates/content/app/i18n/ja-JP.ts +++ b/templates/content/app/i18n/ja-JP.ts @@ -277,7 +277,37 @@ const database = { "Builder コレクションまたは別のテーブルを接続して、これらの行にマッピングします。", noBuilderWrites: "Builder への書き込みなし。", stageOnly: "ステージングのみ", - savesDraftsNeverPublishes: "Builder に下書きを保存します — 公開はしません。", + savesChangesNeverPublishes: "Builder に変更を保存します — 公開はしません。", + builderEffectNewTag: "新規", + builderEffectNewSentence: "新しい下書きエントリを作成します", + builderEffectEditTag: "編集", + builderEffectEditSentence: "公開中のエントリを更新します", + builderEffectSaveTag: "保存", + builderEffectAutosaveSentence: + "保存済み。Builder で公開するまで公開されません", + builderEffectSaveDraftSentence: "下書きを保存します", + builderEffectPublishTag: "公開", + 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 3e9b5653e19..2d5932d9d81 100644 --- a/templates/content/app/i18n/ko-KR.ts +++ b/templates/content/app/i18n/ko-KR.ts @@ -272,7 +272,38 @@ const database = { "Builder 컬렉션 또는 다른 테이블을 연결하여 이 행에 매핑하세요.", noBuilderWrites: "Builder 쓰기 없음.", stageOnly: "준비만", - savesDraftsNeverPublishes: "Builder에 초안을 저장합니다 — 게시하지 않습니다.", + savesChangesNeverPublishes: + "Builder에 변경 사항을 저장합니다 — 게시하지 않습니다.", + builderEffectNewTag: "새 항목", + builderEffectNewSentence: "새 초안 항목을 만듭니다", + builderEffectEditTag: "편집", + builderEffectEditSentence: "게시된 항목을 업데이트합니다", + builderEffectSaveTag: "저장", + builderEffectAutosaveSentence: + "저장됨. Builder에서 게시하기 전까지 공개되지 않습니다", + builderEffectSaveDraftSentence: "초안을 저장합니다", + builderEffectPublishTag: "게시", + 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 b56caad56b0..b169768c1b3 100644 --- a/templates/content/app/i18n/pt-BR.ts +++ b/templates/content/app/i18n/pt-BR.ts @@ -279,7 +279,37 @@ 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", + 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", + 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 dbb081fefde..231c625ad4e 100644 --- a/templates/content/app/i18n/zh-CN.ts +++ b/templates/content/app/i18n/zh-CN.ts @@ -246,7 +246,36 @@ const database = { "连接 Builder 集合或其他表以映射到这些行。", noBuilderWrites: "无 Builder 写入。", stageOnly: "仅暂存", - savesDraftsNeverPublishes: "将草稿保存到 Builder — 从不发布。", + savesChangesNeverPublishes: "将更改保存到 Builder — 从不发布。", + builderEffectNewTag: "新建", + builderEffectNewSentence: "创建一个新的草稿条目", + builderEffectEditTag: "编辑", + builderEffectEditSentence: "更新已发布的条目", + builderEffectSaveTag: "保存", + builderEffectAutosaveSentence: "已保存,在 Builder 中发布后才会上线", + builderEffectSaveDraftSentence: "保存草稿", + builderEffectPublishTag: "发布", + 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 a3d5407963f..80c91899bf4 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1457,7 +1457,36 @@ const overrides = { "連接 Builder 集合或其他表格以對應到這些列。", noBuilderWrites: "無 Builder 寫入。", stageOnly: "僅暫存", - savesDraftsNeverPublishes: "將草稿儲存到 Builder — 從不發佈。", + savesChangesNeverPublishes: "將變更儲存到 Builder — 從不發佈。", + builderEffectNewTag: "新增", + builderEffectNewSentence: "建立一個新的草稿項目", + builderEffectEditTag: "編輯", + builderEffectEditSentence: "更新已發佈的項目", + builderEffectSaveTag: "儲存", + builderEffectAutosaveSentence: "已儲存,在 Builder 中發佈後才會上線", + builderEffectSaveDraftSentence: "儲存草稿", + builderEffectPublishTag: "發佈", + 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/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/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/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/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 495558f9f72..85dbcfa8d48 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 { @@ -729,6 +730,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; }>; @@ -1812,12 +1815,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;