Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
23eb1ce
Keep Content's Builder saves from snapping back
3mdistal Oct 23, 2026
107e59f
Keep later Builder saves, saved bodies, and reference ids through ref…
3mdistal Oct 23, 2026
9b16026
Keep unpublished Builder saves through every body read
3mdistal Oct 23, 2026
a7a850b
Ignore Builder reads from before a save, check the entry name, and ke…
3mdistal Oct 23, 2026
3b76eb3
Keep a saved empty body, let an empty Builder body win, and check Bui…
3mdistal Oct 23, 2026
b77ebfe
Keep an empty Builder body through hydration and record the version t…
3mdistal Oct 23, 2026
5723c56
Keep Builder's own entry name on saves that leave the title, and empt…
3mdistal Oct 23, 2026
5971931
Keep a Builder row as saved when its unpublished-changes record can't…
3mdistal Oct 23, 2026
5370e8a
Let Builder win over an unreadable record, read the fields a save wri…
3mdistal Oct 23, 2026
c7f926d
Record when the live body was empty so an empty read can't keep a sav…
3mdistal Oct 23, 2026
c32554a
Merge remote-tracking branch 'origin/main' into alice/content-builder…
3mdistal Oct 23, 2026
4f0a354
Show each Builder row's status and say what each save does
3mdistal Oct 23, 2026
47026dd
Keep each Builder status true through refreshes, copies, and source s…
3mdistal Oct 23, 2026
da94c0e
Say "save" in the Builder dialog's footer, button, and Stage only line
3mdistal Oct 23, 2026
80e41f2
Clear Builder statuses when a Builder source turns into details
3mdistal Oct 23, 2026
a5e241d
Check the review dialog's summary and button by their message keys
3mdistal Oct 23, 2026
3ab7760
Ask every projected Builder read for its autosave flag
3mdistal Oct 23, 2026
1cdf026
Take a save's publication state from the newer of the live check and …
3mdistal Oct 23, 2026
defc0fc
Leave an empty Builder body pending when a save changes the row durin…
3mdistal Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions templates/content/actions/_builder-cms-read-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@
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");
Expand Down Expand Up @@ -2055,7 +2057,7 @@
{ id: "legacy-entry", title: "Legacy test entry" },
]);
const browseBody = JSON.parse(
String((fetchImpl.mock.calls[2]?.[1] as RequestInit).body),

Check warning on line 2060 in templates/content/actions/_builder-cms-read-client.test.ts

View workflow job for this annotation

GitHub Actions / Lint & format

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining
);
expect(browseBody.params).toMatchObject({
name: "get_builder_content",
Expand Down Expand Up @@ -2120,7 +2122,7 @@
});

const browseBody = JSON.parse(
String((fetchImpl.mock.calls[2]?.[1] as RequestInit).body),

Check warning on line 2125 in templates/content/actions/_builder-cms-read-client.test.ts

View workflow job for this annotation

GitHub Actions / Lint & format

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining
);
expect(browseBody.params.arguments.limit).toBe(100);
expect(result).toMatchObject({
Expand Down Expand Up @@ -2214,7 +2216,7 @@
});
expect(result.entries).toHaveLength(101);
const secondPageBody = JSON.parse(
String((fetchImpl.mock.calls[3]?.[1] as RequestInit).body),

Check warning on line 2219 in templates/content/actions/_builder-cms-read-client.test.ts

View workflow job for this annotation

GitHub Actions / Lint & format

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining
);
expect(secondPageBody.params.arguments.offset).toBe(100);
});
Expand Down Expand Up @@ -2280,7 +2282,7 @@
]),
});
const secondPageBody = JSON.parse(
String((fetchImpl.mock.calls[2]?.[1] as RequestInit).body),

Check warning on line 2285 in templates/content/actions/_builder-cms-read-client.test.ts

View workflow job for this annotation

GitHub Actions / Lint & format

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining
);
expect(secondPageBody.params.arguments.offset).toBe(100);
});
Expand Down Expand Up @@ -2598,6 +2600,8 @@
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) {
Expand All @@ -2609,6 +2613,58 @@
}
});

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,
});
const fields =
fetchImpl.mock.calls[0]![0].searchParams.get("fields")!.split(",");
expect(fields).toEqual(
expect.arrayContaining([
"data.blurb",
"data.blocks",
"data.blocksString",
]),
);
expect(fields).not.toContain("__builder.entry.name");
});

it("hydrates an exact entry through MCP with OAuth-only authorization", async () => {
resolveBuilderRequestAuthorizationMock.mockResolvedValue({
token: "oauth-access-token",
Expand Down Expand Up @@ -3095,7 +3151,7 @@
}),
).resolves.toMatchObject({ state: "found", providerStatus: "mcp_200" });
const secondPageBody = JSON.parse(
String((fetchImpl.mock.calls[2]?.[1] as RequestInit).body),

Check warning on line 3154 in templates/content/actions/_builder-cms-read-client.test.ts

View workflow job for this annotation

GitHub Actions / Lint & format

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining
);
expect(secondPageBody.params.arguments.offset).toBe(100);
});
Expand Down Expand Up @@ -3150,7 +3206,7 @@
}),
).resolves.toMatchObject({ state: "found" });
const body = JSON.parse(
String((fetchImpl.mock.calls[2]?.[1] as RequestInit).body),

Check warning on line 3209 in templates/content/actions/_builder-cms-read-client.test.ts

View workflow job for this annotation

GitHub Actions / Lint & format

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining
);
expect(body.params).toMatchObject({
name: "get_builder_content",
Expand Down Expand Up @@ -3571,7 +3627,7 @@
}),
).resolves.toMatchObject({ state: "live", entries: [] });
const body = JSON.parse(
String((fetchImpl.mock.calls[2]?.[1] as RequestInit).body),

Check warning on line 3630 in templates/content/actions/_builder-cms-read-client.test.ts

View workflow job for this annotation

GitHub Actions / Lint & format

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining
);
expect(body.params).toMatchObject({
name: "get_builder_content",
Expand Down
55 changes: 42 additions & 13 deletions templates/content/actions/_builder-cms-read-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ import type {
BuilderCmsModelFieldSummary,
BuilderCmsModelSummary,
BuilderCmsModelsResponse,
DocumentPropertyValue,
} from "../shared/api.js";
import {
builderBlocksHash,
builderEntryBlocks,
type BuilderContentEntry,
} from "../shared/builder-mdx.js";
import {
builderCmsReferenceIdSourceValues,
builderEntryHasAutosaves,
normalizeBuilderCmsApiEntry,
type BuilderCmsSourceEntry,
} from "./_builder-cms-source-adapter.js";
Expand Down Expand Up @@ -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<string, DocumentPropertyValue> | null;
hasAutosaves?: boolean | null;
}

export interface BuilderCmsEntryFidelitySummary {
Expand Down Expand Up @@ -224,6 +230,7 @@ const BUILDER_CMS_METADATA_ENTRY_FIELD_PATHS = [
"published",
"lastUpdated",
"createdDate",
"meta.hasAutosaves",
"data.title",
"data.handle",
"data.url",
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -388,10 +400,20 @@ function stringOrNumberFromUnknown(value: unknown) {
return stringFromUnknown(value);
}

function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
function liveSourceValues(record: Record<string, unknown>, 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,
Expand All @@ -412,7 +434,7 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {

const record = value as Record<string, unknown>;
if (Array.isArray(record.results)) {
return liveStateFromBuilderEntry(record.results);
return liveStateFromBuilderEntry(record.results, model);
}
if (Object.keys(record).length === 0) {
return {
Expand Down Expand Up @@ -456,6 +478,8 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
stringOrNumberFromUnknown(data.updatedAt),
blocksHash: blocks.length > 0 ? builderBlocksHash(blocks) : null,
id,
values: liveSourceValues(record, model),
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
hasAutosaves: builderEntryHasAutosaves(record),
};
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1793,15 +1822,15 @@ 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(
"Builder CMS live entry read returned an unexpected entry payload.",
{ 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) {
Expand All @@ -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,
Expand All @@ -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: {
Expand Down
48 changes: 48 additions & 0 deletions templates/content/actions/_builder-cms-source-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>) =>
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<string, unknown>) =>
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(
Expand Down
Loading
Loading