Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -4,8 +4,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { builderBlocksHash, builderEntryBlocks } from "../shared/builder-mdx";
import {
builderCmsListEntryFields,
BuilderCmsContentEntryReadError,
listBuilderCmsModels,
readBuilderCmsContentEntry,
readBuilderCmsContentEntryResult,
readBuilderCmsContentEntries,
readBuilderCmsEntryLiveState,
readBuilderCmsModelFields,
Expand Down Expand Up @@ -916,6 +918,60 @@ describe("Builder CMS read client", () => {
}
});

it("distinguishes a provider-confirmed empty entry from a missing entry", async () => {
process.env.BUILDER_CONTENT_API_HOST = "https://cdn.test.builder.io";
resolveBuilderCredentialMock.mockResolvedValue("public-key");

const found = await readBuilderCmsContentEntryResult({
model: "blog_article",
entryId: "empty-entry",
fetchImpl: vi.fn(
async () =>
new Response(
JSON.stringify({
id: "empty-entry",
data: { title: "Intentionally empty", blocks: [] },
}),
{ status: 200 },
),
) as unknown as typeof fetch,
});
const missing = await readBuilderCmsContentEntryResult({
model: "blog_article",
entryId: "missing-entry",
fetchImpl: vi.fn(
async () => new Response(null, { status: 404 }),
) as unknown as typeof fetch,
});

expect(found).toMatchObject({ state: "found", providerStatus: "http_200" });
expect(found.entry?.rawEntry?.data?.blocks).toEqual([]);
expect(missing).toEqual({
state: "not_found",
entry: null,
providerStatus: "http_404",
});
});

it("preserves actionable retry evidence for Builder read failures", async () => {
process.env.BUILDER_CONTENT_API_HOST = "https://cdn.test.builder.io";
resolveBuilderCredentialMock.mockResolvedValue("public-key");

await expect(
readBuilderCmsContentEntryResult({
model: "blog_article",
entryId: "rate-limited-entry",
fetchImpl: vi.fn(
async () => new Response(null, { status: 429 }),
) as unknown as typeof fetch,
}),
).rejects.toMatchObject<Partial<BuilderCmsContentEntryReadError>>({
reason: "transient_read_failure",
providerStatus: "http_429",
retryable: true,
});
});

it("can return an initial partial Builder Content API page for fast refresh", async () => {
process.env.BUILDER_CONTENT_API_HOST = "https://cdn.test.builder.io";
resolveBuilderCredentialMock.mockImplementation(async (key) =>
Expand Down
117 changes: 108 additions & 9 deletions templates/content/actions/_builder-cms-read-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,34 @@ export function summarizeBuilderCmsEntryFidelity(

type FetchLike = typeof fetch;

export type BuilderCmsContentEntryReadResult =
| {
state: "found";
entry: BuilderCmsSourceEntry;
providerStatus: "http_200";
}
| {
state: "not_found";
entry: null;
providerStatus: "http_404" | "http_200_unexpected_entry";
};

export class BuilderCmsContentEntryReadError extends Error {
constructor(
message: string,
readonly reason:
| "auth_failed"
| "access_denied"
| "transient_read_failure"
| "malformed_body",
readonly providerStatus: string,
readonly retryable: boolean,
) {
super(message);
this.name = "BuilderCmsContentEntryReadError";
}
}

type BuilderMcpContentPart = {
type?: string;
text?: string;
Expand Down Expand Up @@ -1190,10 +1218,26 @@ export async function readBuilderCmsContentEntry(args: {
entryId: string;
fetchImpl?: FetchLike;
}): Promise<BuilderCmsSourceEntry | null> {
const result = await readBuilderCmsContentEntryResult({
...args,
strictEntryIdentity: false,
});
return result.entry;
}

export async function readBuilderCmsContentEntryResult(args: {
model: string;
entryId: string;
fetchImpl?: FetchLike;
strictEntryIdentity?: boolean;
}): Promise<BuilderCmsContentEntryReadResult> {
const publicKey = await resolveBuilderCredential("BUILDER_PUBLIC_KEY");
if (!publicKey) {
throw new Error(
throw new BuilderCmsContentEntryReadError(
"Builder CMS entry read skipped because BUILDER_PUBLIC_KEY is not configured.",
"auth_failed",
"credential_missing",
false,
);
}

Expand All @@ -1205,23 +1249,78 @@ export async function readBuilderCmsContentEntry(args: {
);
applyBuilderCmsBodyEntryReadParams(url, publicKey);

const response = await fetchBuilderContentPage({
fetchImpl: args.fetchImpl ?? fetch,
url,
});
if (response.status === 404) return null;
let response: Response;
try {
response = await fetchBuilderContentPage({
fetchImpl: args.fetchImpl ?? fetch,
url,
});
} catch (error) {
if (error instanceof BuilderCmsContentEntryReadError) throw error;
throw new BuilderCmsContentEntryReadError(
`Builder CMS entry read failed before a response was received: ${
error instanceof Error ? error.message : String(error)
}`,
"transient_read_failure",
"network_error",
true,
);
}
if (response.status === 404) {
return {
state: "not_found",
entry: null,
providerStatus: "http_404",
};
}
if (!response.ok) {
throw new Error(
const reason =
response.status === 401
? "auth_failed"
: response.status === 403
? "access_denied"
: response.status === 429 || response.status >= 500
? "transient_read_failure"
: "malformed_body";
throw new BuilderCmsContentEntryReadError(
`Builder CMS entry read failed with HTTP ${response.status}.`,
reason,
`http_${response.status}`,
reason === "transient_read_failure",
);
}

const json = (await response.json()) as unknown;
let json: unknown;
try {
json = (await response.json()) as unknown;
} catch {
throw new BuilderCmsContentEntryReadError(
"Builder CMS entry read returned malformed JSON.",
"malformed_body",
"http_200_invalid_json",
false,
);
}
const rawEntry = Array.isArray(json)
? json[0]
: (entryArrayFromResponse(json)[0] ?? json);
const entry = normalizeBuilderCmsApiEntry(rawEntry, args.model);
return entry?.id === args.entryId ? entry : null;
if (!entry || entry.id !== args.entryId) {
if (args.strictEntryIdentity === false) {
return {
state: "not_found",
entry: null,
providerStatus: "http_200_unexpected_entry",
};
}
throw new BuilderCmsContentEntryReadError(
"Builder CMS entry read returned an unexpected entry payload.",
"malformed_body",
"http_200_unexpected_entry",
false,
);
}
return { state: "found", entry, providerStatus: "http_200" };
}

export async function listBuilderCmsModels(
Expand Down
11 changes: 11 additions & 0 deletions templates/content/actions/_database-source-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
builderBodyChangeForUnsourcedLocalCreate,
builderBodyHydrationPriorityForRequest,
builderBodyHydrationAttemptIsTerminal,
builderBodyHydrationNextAttemptAt,
builderBodyNeedsSourceComponentWrite,
knownBuilderReviewDocumentIds,
builderSourcePropertyAssignments,
Expand Down Expand Up @@ -875,6 +876,16 @@ describe("database source helpers", () => {
expect(builderBodyHydrationAttemptIsTerminal(5)).toBe(true);
});

it("backs Builder body retries off without exceeding five minutes", () => {
const attemptedAt = "2026-08-21T12:00:00.000Z";
expect(builderBodyHydrationNextAttemptAt(1, attemptedAt)).toBe(
"2026-08-21T12:00:30.000Z",
);
expect(builderBodyHydrationNextAttemptAt(5, attemptedAt)).toBe(
"2026-08-21T12:05:00.000Z",
);
});

it("prioritizes opened Builder body hydration ahead of background work", () => {
expect(
builderBodyHydrationPriorityForRequest({ documentId: "doc-open" }),
Expand Down
Loading
Loading