diff --git a/.changeset/bright-pages-persist.md b/.changeset/bright-pages-persist.md new file mode 100644 index 0000000000..c9106c2e1b --- /dev/null +++ b/.changeset/bright-pages-persist.md @@ -0,0 +1,5 @@ +--- +"@agent-native/toolkit": patch +--- + +Allow the first local edit in a newly synced empty collaborative document to reach the host application's canonical save path. diff --git a/.changeset/quiet-owls-relay.md b/.changeset/quiet-owls-relay.md new file mode 100644 index 0000000000..f21f172fd9 --- /dev/null +++ b/.changeset/quiet-owls-relay.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Route Builder authorization callbacks for Netlify previews through the immutable deployment URL while returning popup status to the visible preview. diff --git a/packages/core/src/deploy/build.ts b/packages/core/src/deploy/build.ts index bdad068ff2..9701b7c843 100644 --- a/packages/core/src/deploy/build.ts +++ b/packages/core/src/deploy/build.ts @@ -3705,6 +3705,14 @@ export default bundle; "virtual:agents-bundle": agentsBundleModuleSource, }, replace: { + // Netlify exposes DEPLOY_ID only while building. Embed it into the Nitro + // function so preview OAuth relays can target this immutable deployment + // even though the value is unavailable in the function runtime. + "process.env.AGENT_NATIVE_BUILD_ID": JSON.stringify( + process.env.DEPLOY_ID?.trim() || + process.env.AGENT_NATIVE_BUILD_ID?.trim() || + "", + ), "process.env.AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID": JSON.stringify( process.env.GA_MEASUREMENT_ID?.trim() || "", ), diff --git a/packages/core/src/server/builder-browser.spec.ts b/packages/core/src/server/builder-browser.spec.ts index 4976c0e819..c6d8223f78 100644 --- a/packages/core/src/server/builder-browser.spec.ts +++ b/packages/core/src/server/builder-browser.spec.ts @@ -25,6 +25,8 @@ import { getBuilderBrowserStatusForEvent, isBuilderBranchingEnabled, resolveBuilderCallbackReturnUrl, + resolveBuilderPreviewRelayParentOrigin, + resolveBuilderPreviewRelayTargetOrigin, runBuilderAgent, signBuilderConnectToken, signBuilderCallbackState, @@ -585,6 +587,74 @@ describe("Builder callback CSRF state", () => { ).toBeNull(); }); + it("uses the immutable Netlify deploy URL as the relay destination", () => { + process.env.AGENT_NATIVE_BUILD_ID = "6a62ed72f518f00008436fa3"; + process.env.SITE_NAME = "agent-native-content"; + + expect( + resolveBuilderPreviewRelayTargetOrigin( + "https://deploy-preview-2382--agent-native-content.netlify.app", + ), + ).toBe( + "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app", + ); + }); + + it("rejects an immutable Netlify deploy URL for a different site", () => { + process.env.AGENT_NATIVE_BUILD_ID = "6a62ed72f518f00008436fa3"; + process.env.SITE_NAME = "different-site"; + const previewOrigin = + "https://deploy-preview-2382--agent-native-content.netlify.app"; + + expect(resolveBuilderPreviewRelayTargetOrigin(previewOrigin)).toBe( + previewOrigin, + ); + }); + + it("leaves non-Netlify preview origins unchanged", () => { + process.env.AGENT_NATIVE_BUILD_ID = "6a62ed72f518f00008436fa3"; + process.env.SITE_NAME = "agent-native-content"; + const previewOrigin = "https://preview-example.builderio.xyz"; + + expect(resolveBuilderPreviewRelayTargetOrigin(previewOrigin)).toBe( + previewOrigin, + ); + }); + + it("keeps the visible preview opener separate from the immutable relay target", () => { + expect( + resolveBuilderPreviewRelayParentOrigin({ + openerOrigin: + "https://deploy-preview-2382--agent-native-content.netlify.app", + targetOrigin: + "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app", + }), + ).toBe("https://deploy-preview-2382--agent-native-content.netlify.app"); + }); + + it("falls back to the signed relay target for an unsafe opener", () => { + const targetOrigin = + "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app"; + expect( + resolveBuilderPreviewRelayParentOrigin({ + openerOrigin: "https://attacker.example", + targetOrigin, + }), + ).toBe(targetOrigin); + }); + + it("rejects an unsigned Netlify opener for a different site", () => { + const targetOrigin = + "https://6a62ed72f518f00008436fa3--agent-native-content.netlify.app"; + expect( + resolveBuilderPreviewRelayParentOrigin({ + openerOrigin: + "https://deploy-preview-2382--attacker-site.netlify.app", + targetOrigin, + }), + ).toBe(targetOrigin); + }); + it("returns users to the preview opener after a gateway callback", () => { process.env.NODE_ENV = "production"; process.env.AGENT_NATIVE_WORKSPACE = "1"; diff --git a/packages/core/src/server/builder-browser.ts b/packages/core/src/server/builder-browser.ts index da6a13fab8..9d9726dd0a 100644 --- a/packages/core/src/server/builder-browser.ts +++ b/packages/core/src/server/builder-browser.ts @@ -36,6 +36,10 @@ const BUILDER_RELAY_PURPOSE = "builder-preview-callback-relay"; const BUILDER_RELAY_STATE_VERSION = 1; const BUILDER_RELAY_TTL_MS = 10 * 60 * 1000; const BUILDER_RELAY_REQUEST_SKEW_MS = 2 * 60 * 1000; +const IMMUTABLE_NETLIFY_RELAY_HOST = + /^(?[a-f0-9]{24})--(?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.netlify\.app$/; +const NETLIFY_DEPLOY_PREVIEW_HOST = + /^deploy-preview-\d+--(?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.netlify\.app$/; export interface BuilderPreviewRelayState { v: 1; @@ -165,6 +169,45 @@ export function isTrustedBuilderRelayTargetOrigin(value: string): boolean { ) ); } + +/** + * Netlify's deploy-preview alias is convenient for people but mutable, so it + * must never be the signed relay destination. The deploy builder embeds + * Netlify's DEPLOY_ID into the Nitro server bundle, while SITE_NAME remains + * available to Functions at runtime. Use that pair only when it + * identifies the same site as the visible preview alias; otherwise preserve + * the visible origin so callback validation fails closed. + */ +export function resolveBuilderPreviewRelayTargetOrigin( + previewOrigin: string, +): string { + let previewUrl: URL; + try { + previewUrl = new URL(previewOrigin); + } catch { + return previewOrigin; + } + const previewMatch = NETLIFY_DEPLOY_PREVIEW_HOST.exec( + previewUrl.hostname.toLowerCase(), + ); + if (!previewMatch?.groups?.site) return previewOrigin; + + const buildId = process.env.AGENT_NATIVE_BUILD_ID?.trim().toLowerCase(); + const siteName = process.env.SITE_NAME?.trim().toLowerCase(); + if ( + !buildId || + !siteName || + !/^[a-f0-9]{24}$/.test(buildId) || + siteName !== previewMatch.groups.site + ) { + return previewOrigin; + } + + const immutableOrigin = `https://${buildId}--${siteName}.netlify.app`; + return IMMUTABLE_NETLIFY_RELAY_HOST.test(new URL(immutableOrigin).hostname) + ? immutableOrigin + : previewOrigin; +} export function signBuilderPreviewRelayState(input: { ownerEmail: string; targetOrigin: string; @@ -1228,6 +1271,39 @@ export function resolveSafePreviewUrl( return getBuilderBrowserOriginForEvent(event); } +export function resolveBuilderPreviewRelayParentOrigin(options: { + openerOrigin?: string | null; + targetOrigin: string; +}): string { + if (!options.openerOrigin) return options.targetOrigin; + let openerUrl: URL; + let targetUrl: URL; + try { + openerUrl = new URL(options.openerOrigin); + targetUrl = new URL(options.targetOrigin); + } catch { + return options.targetOrigin; + } + if ( + openerUrl.origin !== options.openerOrigin || + !isSafeBuilderRelayTargetOrigin(openerUrl.origin) + ) { + return options.targetOrigin; + } + if (openerUrl.origin === targetUrl.origin) return openerUrl.origin; + + const openerMatch = NETLIFY_DEPLOY_PREVIEW_HOST.exec( + openerUrl.hostname.toLowerCase(), + ); + const targetMatch = IMMUTABLE_NETLIFY_RELAY_HOST.exec( + targetUrl.hostname.toLowerCase(), + ); + return openerMatch?.groups?.site && + openerMatch.groups.site === targetMatch?.groups?.site + ? openerUrl.origin + : options.targetOrigin; +} + export function resolveBuilderCallbackReturnUrl(options: { event: H3Event; openerOrigin?: string | null; diff --git a/packages/core/src/server/core-routes-plugin.ts b/packages/core/src/server/core-routes-plugin.ts index d2cc0a2d98..3662170983 100644 --- a/packages/core/src/server/core-routes-plugin.ts +++ b/packages/core/src/server/core-routes-plugin.ts @@ -127,6 +127,8 @@ import { resolveBuilderCallbackReturnUrl, getBuilderBrowserStatusForEvent, resolveBuilderBranchProjectId, + resolveBuilderPreviewRelayParentOrigin, + resolveBuilderPreviewRelayTargetOrigin, resolveSafePreviewUrl, runBuilderAgent, signBuilderCallbackState, @@ -2049,7 +2051,8 @@ export function createCoreRoutesPlugin( try { relay = signBuilderPreviewRelayState({ ownerEmail, - targetOrigin: previewOrigin, + targetOrigin: + resolveBuilderPreviewRelayTargetOrigin(previewOrigin), basePath: getAppBasePath(), }); } catch (err) { @@ -2422,6 +2425,13 @@ export function createCoreRoutesPlugin( ); } + const relayOpenerOrigin = + requestUrl.searchParams.get(BUILDER_OPENER_PARAM); + const relayParentOrigin = resolveBuilderPreviewRelayParentOrigin({ + openerOrigin: relayOpenerOrigin, + targetOrigin: relayPayload.targetOrigin, + }); + const privateKey = requestUrl.searchParams.get("p-key"); const publicKey = requestUrl.searchParams.get("api-key"); if (!privateKey || !publicKey) { @@ -2433,7 +2443,7 @@ export function createCoreRoutesPlugin( ); return createBuilderBrowserCallbackErrorPage( "Builder didn't return credentials. Restart the connect flow from settings.", - { parentOrigin: relayPayload.targetOrigin }, + { parentOrigin: relayParentOrigin }, ); } @@ -2489,7 +2499,7 @@ export function createCoreRoutesPlugin( "text/html; charset=utf-8", ); return createBuilderBrowserCallbackErrorPage(message, { - parentOrigin: relayPayload.targetOrigin, + parentOrigin: relayParentOrigin, }); } @@ -2499,8 +2509,8 @@ export function createCoreRoutesPlugin( "text/html; charset=utf-8", ); return createBuilderBrowserCallbackPage( - `${relayPayload.targetOrigin}${relayPayload.basePath || "/"}`, - { parentOrigin: relayPayload.targetOrigin }, + `${relayParentOrigin}${relayPayload.basePath || "/"}`, + { parentOrigin: relayParentOrigin }, ); } diff --git a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts index cdcef92dca..c9ccf350e7 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts @@ -4,6 +4,7 @@ import { useEditor, type Editor } from "@tiptap/react"; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import * as Y from "yjs"; import { createRichMarkdownExtensions } from "./RichMarkdownEditor.js"; import { useCollabReconcile, getEditorMarkdown } from "./useCollabReconcile.js"; @@ -179,6 +180,52 @@ function render( } describe("useCollabReconcile — concurrent edit / lost-update guards", () => { + it("persists the first local edit after a synced empty collaborative document", async () => { + const captured: Captured = { + editor: null, + emitted: [], + setContentCalls: 0, + }; + + function EmptyDocumentHarness() { + const guardsRef = React.useRef | null>(null); + const ydoc = React.useMemo(() => new Y.Doc(), []); + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ dialect: "gfm", ydoc }), + onUpdate: ({ editor, transaction }) => { + const guards = guardsRef.current; + if (!guards || guards.shouldIgnoreUpdate(transaction)) return; + const markdown = getEditorMarkdown(editor); + if (!guards.registerEmitted(markdown)) return; + captured.emitted.push(markdown); + }, + }); + captured.editor = editor; + guardsRef.current = useCollabReconcile({ + editor, + ydoc, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + getMarkdown: getEditorMarkdown, + }); + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(EmptyDocumentHarness))); + await flush(); + + act(() => { + captured.editor?.commands.insertContent("First persisted edit"); + }); + await flush(); + + expect(captured.emitted).toContain("First persisted edit"); + }); + it("does not seed until initial collab sync has completed", async () => { const { captured, Harness } = makeCollabSeedHarness(); @@ -330,6 +377,36 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => { expect(results).toEqual([true]); }); + it("allows a local edit that arrives as a synced empty document finishes mounting", async () => { + const results: boolean[] = []; + + function Probe() { + const editor = useEditor({ + extensions: createRichMarkdownExtensions({ dialect: "gfm" }), + content: "", + }); + const fakeYdoc = { clientID: 1, getXmlFragment: () => ({ length: 0 }) }; + const guards = useCollabReconcile({ + editor, + ydoc: fakeYdoc as never, + collabSynced: true, + value: "", + contentUpdatedAt: "2024-01-01T00:00:01.000Z", + editable: true, + }); + // Runs during render, before the passive empty-seed completion effect. + if (editor && results.length === 0) { + results.push(guards.shouldIgnoreUpdate(editor.state.tr)); + } + return React.createElement("div", null); + } + + act(() => root.render(React.createElement(Probe))); + await flush(); + + expect(results).toEqual([false]); + }); + it("refuses to persist an empty doc in collab mode (registerEmitted guard)", async () => { // Directly exercise the guard contract: in collab mode an empty markdown // string must not be registered/persisted (would clobber stored content diff --git a/packages/toolkit/src/editor/useCollabReconcile.ts b/packages/toolkit/src/editor/useCollabReconcile.ts index 4d38864faa..efad5274e7 100644 --- a/packages/toolkit/src/editor/useCollabReconcile.ts +++ b/packages/toolkit/src/editor/useCollabReconcile.ts @@ -300,8 +300,16 @@ export function useCollabReconcile({ if (seededRef.current) return; if (!collabSynced) return; if (!isLeadClient) return; - if (!value.trim()) return; const fragment = ydoc.getXmlFragment("default"); + // A brand-new document has no SQL markdown to seed. Once its initial Yjs + // state has loaded, that empty fragment is nevertheless fully initialized: + // local edits must be allowed through the autosave guard. Leaving + // `seededRef` false here trapped every first edit in "pre-seed" forever — + // the text survived in Yjs, but the canonical SQL body stayed empty. + if (!value.trim()) { + seededRef.current = true; + return; + } const currentMarkdown = getMarkdown(editor); // Seed only when the shared doc is genuinely empty — either the fragment has // no nodes yet, or it holds no semantic markdown (an empty paragraph, or an @@ -558,7 +566,16 @@ export function useCollabReconcile({ // app's local mirror or autosave path (Content serializes an empty paragraph // as ``, which is non-empty text and bypasses the generic // registerEmitted empty-string guard). - if (collab && !seededRef.current) return true; + if (collab && !seededRef.current) { + // Passive effects normally mark a synced empty document initialized + // before a person can type. Keep the event boundary correct too: if a + // genuine local edit wins that tiny race, it is itself proof that the + // synced empty document is ready. Remote Yjs transactions stay ignored. + const firstSyncedEmptyLocalEdit = + collabSynced && !value.trim() && !isChangeOrigin(transaction); + if (!firstSyncedEmptyLocalEdit) return true; + seededRef.current = true; + } if (transaction.getMeta(RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION)) { return true; } diff --git a/templates/content/actions/_builder-cms-source-adapter.ts b/templates/content/actions/_builder-cms-source-adapter.ts index 208c3dcf85..9a5d2fb6b6 100644 --- a/templates/content/actions/_builder-cms-source-adapter.ts +++ b/templates/content/actions/_builder-cms-source-adapter.ts @@ -35,6 +35,7 @@ export interface ExistingBuilderSourceRowIdentity { sourceRowId: string; sourceQualifiedId: string; sourceDisplayKey: string; + provenance?: string | null; lastSourceUpdatedAt: string | null; sourceValuesJson?: string | null; } diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index 3d67522a57..1c95b7aff6 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -24,6 +24,9 @@ import { builderBodyHydrationPriorityForRequest, builderBodyHydrationAttemptIsTerminal, builderBodyNeedsSourceComponentWrite, + builderReviewBodyCandidateDocumentIds, + knownBuilderReviewDocumentIds, + builderSourcePropertyAssignments, builderBodyHydrationVersion, builderBodyUnavailableVersion, builderBodyHydrationNeedsLiveBaseline, @@ -33,6 +36,7 @@ import { builderAuthoritativeRawBodyHash, bulkChunkSizeForColumnCount, builderCmsEntryAlreadyRepresented, + builderCmsSourceContinuationIsCurrent, builderExecutionIsProvablyLocallyBlockedUnsent, canRefreshLocallyBlockedBuilderReview, buildMockBodyChange, @@ -100,6 +104,90 @@ function item(id: string, title: string): ContentDatabaseItem { } describe("database source helpers", () => { + it("keeps the provider-bound property when duplicate local labels collide", () => { + const existingFields = [ + { + propertyId: "local-blurb", + sourceFieldKey: "data.blurb", + provenance: "source field", + }, + { + propertyId: "builder-blurb", + sourceFieldKey: "data.blurb", + provenance: "Builder model field", + }, + ]; + const properties = [ + { + definition: { id: "local-blurb", name: "Blurb", type: "text" }, + }, + { + definition: { id: "builder-blurb", name: "Blurb", type: "text" }, + }, + { + definition: { id: "summary", name: "Summary", type: "text" }, + }, + ]; + + for (const fields of [existingFields, [...existingFields].reverse()]) { + const assignments = builderSourcePropertyAssignments({ + properties, + existingFields: fields, + }); + + expect( + assignments.map(({ property, sourceFieldKey }) => ({ + propertyId: property.definition.id, + sourceFieldKey, + })), + ).toEqual([ + { + propertyId: "builder-blurb", + sourceFieldKey: "data.blurb", + }, + { + propertyId: "summary", + sourceFieldKey: "data.summary", + }, + ]); + } + }); + + it("accepts only the persisted Builder continuation offset", () => { + const metadataJson = JSON.stringify({ + sourceFetchState: "fetching", + lastReadHasMore: true, + lastReadNextOffset: 400, + activeReadSourceRowIds: ["entry-1"], + }); + + expect(builderCmsSourceContinuationIsCurrent(metadataJson, 400)).toBe(true); + expect(builderCmsSourceContinuationIsCurrent(metadataJson, 100)).toBe( + false, + ); + expect(builderCmsSourceContinuationIsCurrent(metadataJson, 0)).toBe(false); + expect( + builderCmsSourceContinuationIsCurrent( + JSON.stringify({ + sourceFetchState: "fetching", + lastReadHasMore: true, + lastReadNextOffset: 400, + }), + 400, + ), + ).toBe(false); + expect( + builderCmsSourceContinuationIsCurrent( + JSON.stringify({ + sourceFetchState: "idle", + lastReadHasMore: false, + lastReadNextOffset: 580, + }), + 400, + ), + ).toBe(false); + }); + it("selects document bodies only for explicitly heavy Builder snapshots", () => { expect(sourceSnapshotDocumentSelection(false)).not.toHaveProperty( "content", @@ -291,6 +379,44 @@ describe("database source helpers", () => { }); }); + it("clears the Builder refresh claim when read metadata is finalized", () => { + const metadata = JSON.parse( + serializeBuilderCmsSourceReadMetadataRecord({ + sourceTable: "agent-native-blog-article-test", + readState: "live", + entryCount: 580, + matchedRowCount: 580, + existingMetadataJson: JSON.stringify({ + builderContinuationClaimId: "claim-1", + builderContinuationClaimOffset: 400, + }), + completedBuilderContinuationClaimId: "claim-1", + }), + ); + + expect(metadata).not.toHaveProperty("builderContinuationClaimId"); + expect(metadata).not.toHaveProperty("builderContinuationClaimOffset"); + }); + + it("preserves a Builder refresh claim for unrelated metadata writers", () => { + const metadata = JSON.parse( + serializeBuilderCmsSourceReadMetadataRecord({ + sourceTable: "agent-native-blog-article-test", + readState: "live", + entryCount: 400, + matchedRowCount: 400, + existingMetadataJson: JSON.stringify({ + builderContinuationClaimId: "claim-1", + builderContinuationClaimOffset: 400, + builderContinuationClaimedAt: "2026-01-01T00:00:00.000Z", + }), + }), + ); + + expect(metadata.builderContinuationClaimId).toBe("claim-1"); + expect(metadata.builderContinuationClaimOffset).toBe(400); + }); + it("preserves existing Builder model fields during metadata rewrites", () => { const existingMetadataJson = JSON.stringify({ builderModelFields: [ @@ -871,6 +997,84 @@ describe("database source helpers", () => { expect(pending).toHaveLength(0); }); + it("indexes only Builder documents that need a heavy body review", () => { + const imported = { + sourceRowId: "entry-1", + sourceQualifiedId: "builder-cms://safe-model/entry-1", + provenance: "Builder CMS read adapter", + bodyHydrationStatus: "hydrated", + currentHash: "hash-1", + currentContent: "Same readable body", + }; + + expect( + builderReviewBodyCandidateDocumentIds([ + { + ...imported, + documentId: "unchanged", + localContent: "Same readable body\n", + }, + { + ...imported, + documentId: "changed", + localContent: "Locally edited body", + }, + { + ...imported, + documentId: "media-reconversion", + currentContent: "![Image](https://example.com/image.png)", + localContent: "![Image](https://example.com/image.png)", + }, + { + ...imported, + documentId: "not-hydrated", + bodyHydrationStatus: "pending", + localContent: "Locally edited body", + }, + { + ...imported, + documentId: "empty-no-baseline", + currentHash: null, + currentContent: null, + localContent: "", + }, + { + documentId: "fixture", + sourceRowId: "builder-fixture", + sourceQualifiedId: "builder-cms://safe-model/fixture", + provenance: BUILDER_CMS_FIXTURE_ROW_PROVENANCE, + bodyHydrationStatus: "pending", + currentHash: null, + currentContent: null, + localContent: "Local draft body", + }, + ]), + ).toEqual(["changed", "media-reconversion", "fixture"]); + }); + + it("scopes a bounded known review batch before broad body discovery", () => { + expect( + knownBuilderReviewDocumentIds( + [ + { documentId: "doc-new" }, + { documentId: "doc-new" }, + { documentId: "doc-existing" }, + ], + 100, + ), + ).toEqual(["doc-new", "doc-existing"]); + expect(knownBuilderReviewDocumentIds([], 100)).toBeNull(); + expect( + knownBuilderReviewDocumentIds([{ documentId: null }], 100), + ).toBeNull(); + expect( + knownBuilderReviewDocumentIds( + [{ documentId: "doc-1" }, { documentId: "doc-2" }], + 1, + ), + ).toBeNull(); + }); + it("detects local Builder body edits as outbound pending changes", () => { const [changeSet] = buildBuilderLocalOutboundChangeSets({ source: { sourceType: "builder-cms" }, diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index ae77298c4a..9c80735e47 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -162,6 +162,9 @@ type SourceMetadataRecord = { lastReadSuspiciousEmpty?: boolean; activeReadSourceRowIds?: string[]; sourceFetchState?: "idle" | "fetching" | "error" | string; + builderContinuationClaimId?: string; + builderContinuationClaimOffset?: number; + builderContinuationClaimedAt?: string; allowDraftWrites?: boolean; allowPublishWrites?: boolean; allowedWriteModes?: ContentDatabaseSourcePushMode[]; @@ -183,6 +186,145 @@ function parseObject( } } +export function builderCmsSourceContinuationIsCurrent( + metadataJson: string | null | undefined, + expectedOffset: number, +) { + const metadata = parseObject(metadataJson); + return ( + metadata?.sourceFetchState === "fetching" && + metadata.lastReadHasMore === true && + expectedOffset > 0 && + metadata.lastReadNextOffset === expectedOffset && + Array.isArray(metadata.activeReadSourceRowIds) && + metadata.activeReadSourceRowIds.length > 0 && + metadata.activeReadSourceRowIds.every((id) => typeof id === "string") + ); +} + +// Content's hosted request wall is 75 seconds. Keep the orphan-recovery lease +// well beyond that so an expired owner cannot still be executing mutations. +export const BUILDER_CMS_REFRESH_CLAIM_LEASE_MS = 30 * 60 * 1000; + +export async function claimBuilderCmsSourceRefresh(args: { + source: ContentDatabaseSourceRowDb; + expectedOffset?: number; + now?: string; +}) { + const metadata = + parseObject(args.source.metadataJson) ?? {}; + const now = args.now ?? new Date().toISOString(); + const claimedAt = metadata.builderContinuationClaimedAt + ? Date.parse(metadata.builderContinuationClaimedAt) + : Number.NaN; + const activeClaim = + !!metadata.builderContinuationClaimId && + Number.isFinite(claimedAt) && + Date.parse(now) - claimedAt < BUILDER_CMS_REFRESH_CLAIM_LEASE_MS; + if ( + activeClaim || + (args.expectedOffset !== undefined && + !builderCmsSourceContinuationIsCurrent( + args.source.metadataJson, + args.expectedOffset, + )) + ) { + return null; + } + + const claimId = nanoid(); + const claimedMetadataJson = JSON.stringify({ + ...metadata, + builderContinuationClaimId: claimId, + builderContinuationClaimOffset: args.expectedOffset, + builderContinuationClaimedAt: now, + }); + const [claimed] = await getDb() + .update(schema.contentDatabaseSources) + .set({ metadataJson: claimedMetadataJson }) + .where( + and( + eq(schema.contentDatabaseSources.id, args.source.id), + args.source.metadataJson === null + ? isNull(schema.contentDatabaseSources.metadataJson) + : eq( + schema.contentDatabaseSources.metadataJson, + args.source.metadataJson, + ), + ), + ) + .returning(); + return claimed ? { source: claimed, claimId } : null; +} + +export async function releaseBuilderCmsSourceRefreshClaim(args: { + sourceId: string; + claimId: string; +}) { + const db = getDb(); + for (let attempt = 0; attempt < 5; attempt += 1) { + const [current] = await db + .select({ metadataJson: schema.contentDatabaseSources.metadataJson }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, args.sourceId)) + .limit(1); + const metadata = parseObject(current?.metadataJson); + if (!metadata || metadata.builderContinuationClaimId !== args.claimId) { + return false; + } + delete metadata.builderContinuationClaimId; + delete metadata.builderContinuationClaimOffset; + delete metadata.builderContinuationClaimedAt; + const nextMetadataJson = JSON.stringify(metadata); + const released = await db + .update(schema.contentDatabaseSources) + .set({ metadataJson: nextMetadataJson }) + .where( + and( + eq(schema.contentDatabaseSources.id, args.sourceId), + eq(schema.contentDatabaseSources.metadataJson, current.metadataJson!), + ), + ) + .returning({ id: schema.contentDatabaseSources.id }); + if (released.length > 0) return true; + } + return false; +} + +export async function renewBuilderCmsSourceRefreshClaim(args: { + sourceId: string; + claimId: string; + now?: string; +}) { + const db = getDb(); + const now = args.now ?? new Date().toISOString(); + for (let attempt = 0; attempt < 5; attempt += 1) { + const [current] = await db + .select({ metadataJson: schema.contentDatabaseSources.metadataJson }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, args.sourceId)) + .limit(1); + const metadata = parseObject(current?.metadataJson); + if (!metadata || metadata.builderContinuationClaimId !== args.claimId) { + return false; + } + metadata.builderContinuationClaimedAt = now; + const nextMetadataJson = JSON.stringify(metadata); + const renewed = await db + .update(schema.contentDatabaseSources) + .set({ metadataJson: nextMetadataJson }) + .where( + and( + eq(schema.contentDatabaseSources.id, args.sourceId), + eq(schema.contentDatabaseSources.metadataJson, current.metadataJson!), + ), + ) + .returning({ id: schema.contentDatabaseSources.id }); + if (renewed.length > 0) return true; + } + return false; +} + function parseArray(value: string | null | undefined): T[] { if (!value) return []; try { @@ -701,6 +843,15 @@ function normalizeBuilderBodyBaselineContent(value: string | null | undefined) { return (value ?? "").replace(/\r\n/g, "\n").trim(); } +function builderBodyUsesCurrentMediaConverter( + content: string | null | undefined, +) { + return ( + /!\[[^\]]*\]\(\s*https?:\/\//i.test(content ?? "") || + /<(?:img|video)\b/i.test(content ?? "") + ); +} + const BUILDER_BODY_HYDRATION_BACKGROUND_PRIORITY = 10; const BUILDER_BODY_HYDRATION_OPEN_PRIORITY = 0; const BUILDER_BODY_HYDRATION_BATCH_LIMIT = 25; @@ -2355,8 +2506,7 @@ export async function builderBodyChangeForLocalContent(args: { // legacy Text block containing a Markdown image into a real Builder Image // (or emit a native Video) without changing the document text at all. const usesCurrentMediaConverter = - /!\[[^\]]*\]\(\s*https?:\/\//i.test(localContent) || - /<(?:img|video)\b/i.test(localContent); + builderBodyUsesCurrentMediaConverter(localContent); const normalizedLocalContent = normalizeBuilderBodyBaselineContent(localContent); if ( @@ -3064,6 +3214,202 @@ export async function getContentDatabaseSourceSnapshotForWrite( : null; } +export type BuilderReviewBodyCandidateRow = { + documentId: string; + sourceRowId: string; + sourceQualifiedId: string; + provenance: string | null; + bodyHydrationStatus: string; + currentHash: string | null; + currentContent: string | null; + localContent: string | null; +}; + +export function builderReviewBodyCandidateDocumentIds( + rows: BuilderReviewBodyCandidateRow[], +) { + return rows.flatMap((row) => { + const identity = builderCmsSourceRowIdentityState({ row }); + const localContent = row.localContent ?? ""; + if (identity.isSyntheticFixture) { + return localContent.trim() ? [row.documentId] : []; + } + if (row.bodyHydrationStatus !== "hydrated") return []; + if (!row.currentHash && !row.currentContent && !localContent.trim()) { + return []; + } + if (builderBodyUsesCurrentMediaConverter(localContent)) { + return [row.documentId]; + } + const normalizedLocalContent = + normalizeBuilderBodyBaselineContent(localContent); + if ( + normalizedLocalContent && + normalizedLocalContent === + normalizeBuilderBodyBaselineContent(row.currentContent) + ) { + return []; + } + return [row.documentId]; + }); +} + +export function knownBuilderReviewDocumentIds( + changeSets: Array<{ documentId: string | null }>, + limit: number, +): string[] | null { + if (changeSets.length === 0 || changeSets.length > limit) return null; + const documentIds = new Set(); + for (const changeSet of changeSets) { + if (!changeSet.documentId) return null; + documentIds.add(changeSet.documentId); + } + return [...documentIds]; +} + +type BuilderReviewSourceValueTextKey = + | typeof BUILDER_CMS_BODY_BLOCKS_HASH_KEY + | typeof BUILDER_CMS_BODY_CONTENT_KEY; + +export function builderReviewSourceValueTextProjection( + key: BuilderReviewSourceValueTextKey, +) { + const sourceValuesJson = schema.contentDatabaseSourceRows.sourceValuesJson; + return getDialect() === "postgres" + ? sql`COALESCE(${sourceValuesJson}::jsonb ->> ${key}, '')` + : sql`COALESCE(json_extract(${sourceValuesJson}, ${`$."${key}"`}), '')`; +} + +async function findBuilderReviewBodyCandidateDocumentIds(args: { + database: ContentDatabaseRow | ContentDatabase; + source: ContentDatabaseSourceRowDb; +}) { + const sourceRows = schema.contentDatabaseSourceRows; + const items = schema.contentDatabaseItems; + const documents = schema.documents; + const rows = await getDb() + .select({ + documentId: sourceRows.documentId, + sourceRowId: sourceRows.sourceRowId, + sourceQualifiedId: sourceRows.sourceQualifiedId, + provenance: sourceRows.provenance, + bodyHydrationStatus: items.bodyHydrationStatus, + currentHash: builderReviewSourceValueTextProjection( + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + ), + currentContent: builderReviewSourceValueTextProjection( + BUILDER_CMS_BODY_CONTENT_KEY, + ), + localContent: documents.content, + }) + .from(sourceRows) + .innerJoin( + items, + and( + eq(items.databaseId, args.database.id), + eq(items.documentId, sourceRows.documentId), + eq(items.ownerEmail, args.source.ownerEmail), + ), + ) + .innerJoin( + documents, + and( + eq(documents.id, sourceRows.documentId), + eq(documents.ownerEmail, args.source.ownerEmail), + ), + ) + .where(eq(sourceRows.sourceId, args.source.id)); + return builderReviewBodyCandidateDocumentIds(rows); +} + +/** + * Load a complete Builder review snapshot without transferring every heavy + * Builder body baseline. A light pass identifies field/stored changes, while a + * narrow body index compares the editable document with the readable baseline. + * Only candidate documents then load lossless body data and sidecars. + */ +export async function getContentDatabaseSourceSnapshotForReview( + database: ContentDatabaseRow | ContentDatabase, + sourceId?: string | null, + documentIds?: string[], +): Promise { + if (documentIds?.length) { + return getContentDatabaseSourceSnapshotForWrite( + database, + sourceId, + documentIds, + ); + } + + const db = getDb(); + const [source] = sourceId + ? await db + .select() + .from(schema.contentDatabaseSources) + .where( + and( + eq(schema.contentDatabaseSources.id, sourceId), + eq(schema.contentDatabaseSources.databaseId, database.id), + ), + ) + : await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.databaseId, database.id)) + .orderBy( + asc(schema.contentDatabaseSources.createdAt), + asc(schema.contentDatabaseSources.id), + ); + if (!source) return null; + + const lightweight = await loadSourceSnapshot(source, database, { + includeHeavyBuilderBodyValues: false, + }); + const reviewableChanges = lightweight.changeSets.filter( + (changeSet) => + changeSet.direction === "outbound" && + (changeSet.state === "pending_push" || + changeSet.state === "staged_revision" || + changeSet.state === "approved"), + ); + // The interactive review surface is capped at 100 rows. When a complete + // pending batch is already known, load heavy body/sidecar data only for those + // documents instead of first transferring every hydrated Builder article to + // rediscover body-only candidates. That broad discovery remains the fallback + // when no pending batch exists (and after the current batch is resolved). + const knownReviewDocumentIds = knownBuilderReviewDocumentIds( + reviewableChanges, + 100, + ); + if (knownReviewDocumentIds) { + return loadSourceSnapshot(source, database, { + includeHeavyBuilderBodyValues: true, + documentIds: knownReviewDocumentIds, + }); + } + if (reviewableChanges.some((changeSet) => !changeSet.documentId)) { + return loadSourceSnapshot(source, database, { + includeHeavyBuilderBodyValues: true, + }); + } + const candidateDocumentIds = new Set( + reviewableChanges.flatMap((changeSet) => + changeSet.documentId ? [changeSet.documentId] : [], + ), + ); + for (const documentId of await findBuilderReviewBodyCandidateDocumentIds({ + database, + source, + })) { + candidateDocumentIds.add(documentId); + } + if (candidateDocumentIds.size === 0) return lightweight; + return loadSourceSnapshot(source, database, { + includeHeavyBuilderBodyValues: true, + documentIds: [...candidateDocumentIds], + }); +} + /** * Load every source attached to a database (oldest first → `[0]` is the * primary). Federation joins read this; single-source callers keep using @@ -3941,11 +4287,12 @@ export function serializeBuilderCmsSourceReadMetadataRecord(args: { suspiciousEmpty?: boolean; builderModelFields?: BuilderCmsModelFieldSummary[]; existingMetadataJson?: string | null; + completedBuilderContinuationClaimId?: string; }) { const existingMetadata = parseObject( args.existingMetadataJson, ); - return JSON.stringify({ + const metadata: SourceMetadataRecord = { ...builderCmsSourceMetadata(args.sourceTable), ...existingMetadata, builderModelFields: @@ -3970,7 +4317,17 @@ export function serializeBuilderCmsSourceReadMetadataRecord(args: { : args.readState === "error" ? "error" : "idle"), - }); + }; + if ( + args.completedBuilderContinuationClaimId && + existingMetadata?.builderContinuationClaimId === + args.completedBuilderContinuationClaimId + ) { + delete metadata.builderContinuationClaimId; + delete metadata.builderContinuationClaimOffset; + delete metadata.builderContinuationClaimedAt; + } + return JSON.stringify(metadata); } export function serializeSourceCapabilitiesRecord( @@ -4084,6 +4441,63 @@ export function mergeBuilderCmsModelFieldsPreservingReferenceModels(args: { }); } +function builderSourceFieldPriority(provenance: string | null) { + return provenance === "Builder model field" + ? 0 + : provenance === "Builder content field" + ? 1 + : 2; +} + +export function builderSourcePropertyAssignments(args: { + properties: Array<{ + definition: { id: string; name: string; type: string }; + }>; + existingFields?: Array<{ + propertyId: string | null; + sourceFieldKey: string; + provenance: string | null; + }>; +}) { + const existingByPropertyId = new Map( + (args.existingFields ?? []) + .filter((field) => field.propertyId) + .map((field) => [field.propertyId!, field]), + ); + const canonicalPropertyBySourceKey = new Map(); + for (const field of [...(args.existingFields ?? [])] + .filter((candidate) => candidate.propertyId) + .sort( + (left, right) => + builderSourceFieldPriority(left.provenance) - + builderSourceFieldPriority(right.provenance), + )) { + const key = field.sourceFieldKey.trim(); + if (!canonicalPropertyBySourceKey.has(key)) { + canonicalPropertyBySourceKey.set(key, field.propertyId!); + } + } + + const claimedSourceKeys = new Set(); + return args.properties.flatMap((property) => { + const existing = existingByPropertyId.get(property.definition.id); + const sourceFieldKey = + existing?.sourceFieldKey ?? + builderCmsSourceFieldKey( + property.definition.id, + property.definition.name, + ); + const normalizedKey = sourceFieldKey.trim(); + const canonicalPropertyId = canonicalPropertyBySourceKey.get(normalizedKey); + if (canonicalPropertyId && canonicalPropertyId !== property.definition.id) { + return []; + } + if (claimedSourceKeys.has(normalizedKey)) return []; + claimedSourceKeys.add(normalizedKey); + return [{ property, sourceFieldKey }]; + }); +} + export async function seedMockSourceFields(args: { sourceId: string; ownerEmail: string; @@ -4096,11 +4510,6 @@ export async function seedMockSourceFields(args: { }) { const db = getDb(); const isBuilder = args.sourceType === "builder-cms"; - const existingBuilderFieldByPropertyId = new Map( - (args.existingFields ?? []) - .filter((field) => isBuilder && field.propertyId) - .map((field) => [field.propertyId!, field]), - ); const builderModelFieldBySourceKey = new Map( (args.builderModelFields ?? []).map((field) => [ `data.${field.name.trim()}`, @@ -4193,40 +4602,36 @@ export async function seedMockSourceFields(args: { // "Source") and only for Builder sources, so a user's own field happening // to be named "Source" — or any non-Builder/local-table source — is left // untouched. - ...args.properties + ...(isBuilder + ? builderSourcePropertyAssignments({ + properties: args.properties, + existingFields: args.existingFields, + }) + : args.properties.map((property) => ({ + property, + sourceFieldKey: `fields.${slugifySourceField(property.definition.name)}`, + })) + ) .filter( - (property) => + ({ property }) => !( isBuilder && property.definition.name === SOURCE_PROPERTY_NAME && property.definition.type === "select" ), ) - .map((property) => ({ + .map(({ property, sourceFieldKey }) => ({ id: crypto.randomUUID(), ownerEmail: args.ownerEmail, sourceId: args.sourceId, propertyId: property.definition.id, localFieldKey: property.definition.id, - sourceFieldKey: isBuilder - ? (existingBuilderFieldByPropertyId.get(property.definition.id) - ?.sourceFieldKey ?? - builderCmsSourceFieldKey( - property.definition.id, - property.definition.name, - )) - : `fields.${slugifySourceField(property.definition.name)}`, + sourceFieldKey, sourceFieldLabel: property.definition.name, sourceFieldType: isBuilder ? normalizeBuilderCmsSourceFieldType( - builderModelFieldBySourceKey.get( - existingBuilderFieldByPropertyId.get(property.definition.id) - ?.sourceFieldKey ?? - builderCmsSourceFieldKey( - property.definition.id, - property.definition.name, - ), - )?.type ?? property.definition.type, + builderModelFieldBySourceKey.get(sourceFieldKey)?.type ?? + property.definition.type, ) : property.definition.type, mappingType: "property", @@ -4330,15 +4735,28 @@ export async function seedMockSourceFields(args: { } const existingFieldBySourceKey = new Map( - (args.existingFields ?? []).map((field) => [ - sourceFieldIdentityKey(field.sourceFieldKey), - field, - ]), + [...(args.existingFields ?? [])] + // `Map` keeps the last value for a duplicate key, so sort the preferred + // provider-discovered field last. This fallback must agree with + // `builderSourcePropertyAssignments`, independent of database row order. + .sort( + (left, right) => + builderSourceFieldPriority(right.provenance) - + builderSourceFieldPriority(left.provenance), + ) + .map((field) => [sourceFieldIdentityKey(field.sourceFieldKey), field]), + ); + const existingFieldByPropertyId = new Map( + (args.existingFields ?? []) + .filter((field) => field.propertyId) + .map((field) => [field.propertyId!, field]), ); const mergedRows = rows.map((row) => { - const existing = existingFieldBySourceKey.get( - sourceFieldIdentityKey(row.sourceFieldKey), - ); + const existing = + (row.propertyId + ? existingFieldByPropertyId.get(row.propertyId) + : undefined) ?? + existingFieldBySourceKey.get(sourceFieldIdentityKey(row.sourceFieldKey)); if (!existing) return row; return { ...row, @@ -4367,77 +4785,88 @@ export async function seedMockSourceRows(args: { }) { if (args.items.length === 0) return; const db = getDb(); + const rows = args.items.map((item, index) => { + const builderEntry = args.builderEntriesByDocumentId?.get(item.document.id); + const existingBuilderRow = args.existingBuilderRows?.get(item.document.id); + const builderIdentity = + args.sourceType === "builder-cms" + ? builderCmsSourceRowIdentity({ + item, + sourceTable: args.sourceTable, + now: args.now, + existing: existingBuilderRow, + entry: builderEntry, + }) + : null; + const sourceQualifiedId = builderIdentity + ? builderIdentity.sourceQualifiedId + : `${args.sourceType}://${args.sourceTable}/${item.document.id}`; + return { + id: builderIdentity + ? stableBuilderImportId("builder-row", [ + args.sourceId, + sourceQualifiedId, + item.document.id, + ]) + : crypto.randomUUID(), + ownerEmail: args.ownerEmail, + sourceId: args.sourceId, + databaseItemId: item.id, + documentId: item.document.id, + sourceRowId: builderIdentity + ? builderIdentity.sourceRowId + : `${args.sourceType}-${item.document.id}`, + sourceQualifiedId, + sourceDisplayKey: + builderIdentity?.sourceDisplayKey ?? + item.document.title?.trim() ?? + `${args.sourceType}-${index + 1}`, + sourceValuesJson: JSON.stringify( + sourceValuesForSeededSourceRow({ + sourceType: args.sourceType, + item, + sourceTable: args.sourceTable, + now: args.now, + builderEntry, + existingSourceValuesJson: existingBuilderRow?.sourceValuesJson, + existingLastSourceUpdatedAt: existingBuilderRow?.lastSourceUpdatedAt, + }), + ), + provenance: + args.sourceType === "builder-cms" + ? builderEntry + ? "Builder CMS read adapter" + : (existingBuilderRow?.provenance ?? + BUILDER_CMS_FIXTURE_ROW_PROVENANCE) + : "mock source row", + syncState: "linked", + freshness: "fresh", + lastSyncedAt: args.now, + lastSourceUpdatedAt: builderIdentity?.lastSourceUpdatedAt ?? args.now, + createdAt: args.now, + updatedAt: args.now, + }; + }); await db .insert(schema.contentDatabaseSourceRows) - .values( - args.items.map((item, index) => { - const builderEntry = args.builderEntriesByDocumentId?.get( - item.document.id, - ); - const existingBuilderRow = args.existingBuilderRows?.get( - item.document.id, - ); - const builderIdentity = - args.sourceType === "builder-cms" - ? builderCmsSourceRowIdentity({ - item, - sourceTable: args.sourceTable, - now: args.now, - existing: existingBuilderRow, - entry: builderEntry, - }) - : null; - const sourceQualifiedId = builderIdentity - ? builderIdentity.sourceQualifiedId - : `${args.sourceType}://${args.sourceTable}/${item.document.id}`; - return { - id: builderIdentity - ? stableBuilderImportId("builder-row", [ - args.sourceId, - sourceQualifiedId, - item.document.id, - ]) - : crypto.randomUUID(), - ownerEmail: args.ownerEmail, - sourceId: args.sourceId, - databaseItemId: item.id, - documentId: item.document.id, - sourceRowId: builderIdentity - ? builderIdentity.sourceRowId - : `${args.sourceType}-${item.document.id}`, - sourceQualifiedId, - sourceDisplayKey: - builderIdentity?.sourceDisplayKey ?? - item.document.title?.trim() ?? - `${args.sourceType}-${index + 1}`, - sourceValuesJson: JSON.stringify( - sourceValuesForSeededSourceRow({ - sourceType: args.sourceType, - item, - sourceTable: args.sourceTable, - now: args.now, - builderEntry, - existingSourceValuesJson: existingBuilderRow?.sourceValuesJson, - existingLastSourceUpdatedAt: - existingBuilderRow?.lastSourceUpdatedAt, - }), - ), - provenance: - args.sourceType === "builder-cms" - ? args.builderEntriesByDocumentId?.has(item.document.id) - ? "Builder CMS read adapter" - : BUILDER_CMS_FIXTURE_ROW_PROVENANCE - : "mock source row", - syncState: "linked", - freshness: "fresh", - lastSyncedAt: args.now, - lastSourceUpdatedAt: builderIdentity?.lastSourceUpdatedAt ?? args.now, - createdAt: args.now, - updatedAt: args.now, - }; - }), - ) + .values(rows) .onConflictDoNothing(); + + const fixtureItemIds = rows + .filter((row) => row.provenance === BUILDER_CMS_FIXTURE_ROW_PROVENANCE) + .map((row) => row.databaseItemId); + for (const idChunk of chunks(fixtureItemIds, idChunkSize())) { + await db + .update(schema.contentDatabaseItems) + .set({ + bodyHydrationStatus: "unavailable", + bodyHydrationAttemptedAt: args.now, + bodyHydrationError: null, + bodyHydrationVersion: null, + updatedAt: args.now, + }) + .where(inArray(schema.contentDatabaseItems.id, idChunk)); + } } export function sourceValuesForSeededSourceRow(args: { @@ -5339,6 +5768,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { source: ContentDatabaseSourceRowDb; now: string; runFullRefresh?: boolean; + refreshClaimId?: string; }) { let { properties, response } = await sourceSetupPayload(args.database.id); const db = getDb(); @@ -5395,12 +5825,29 @@ export async function resyncBuilderCmsSourceSnapshot(args: { limit: args.runFullRefresh ? 10_000 : undefined, offset: continueOffset, }); + if (builderRead.state === "error") { + throw new Error( + builderRead.message ?? + "Builder CMS read failed; the previous source snapshot was preserved.", + ); + } const incrementalRead = builderRead.state === "live" && (builderRead.progress?.partial === true || (builderRead.progress?.startOffset ?? 0) > 0); const builderEntries = builderRead.state === "live" ? builderRead.entries : []; + if ( + args.refreshClaimId && + !(await renewBuilderCmsSourceRefreshClaim({ + sourceId: args.source.id, + claimId: args.refreshClaimId, + })) + ) { + throw new Error( + "Builder source refresh claim was lost before snapshot mutation.", + ); + } let existingRows = await db .select() .from(schema.contentDatabaseSourceRows) @@ -5432,6 +5879,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { sourceFetchState: "error", syncState: "error", suspiciousEmpty: true, + refreshClaimId: args.refreshClaimId, }); return; } @@ -5478,6 +5926,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { sourceRowId: row.sourceRowId, sourceQualifiedId: row.sourceQualifiedId, sourceDisplayKey: row.sourceDisplayKey, + provenance: row.provenance, lastSourceUpdatedAt: row.lastSourceUpdatedAt, sourceValuesJson: row.sourceValuesJson, }, @@ -5597,6 +6046,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: { sourceFetchState: hasMore ? "fetching" : "idle", syncState: hasMore ? "refreshing" : "idle", activeReadSourceRowIds: hasMore ? nextActiveReadSourceRowIds : undefined, + refreshClaimId: args.refreshClaimId, }); return; } @@ -5708,9 +6158,10 @@ export async function resyncBuilderCmsSourceSnapshot(args: { message: builderRead.message, builderModelFields, progress: builderRead.progress, - sourceFetchState: builderRead.state === "error" ? "error" : "idle", + sourceFetchState: "idle", activeReadSourceRowIds: undefined, syncState: "idle", + refreshClaimId: args.refreshClaimId, }); } @@ -6053,58 +6504,87 @@ export async function updateBuilderCmsSourceReadMetadata(args: { syncState?: ContentDatabaseSourceSyncState; builderModelFields?: BuilderCmsModelFieldSummary[]; suspiciousEmpty?: boolean; + refreshClaimId?: string; }) { const db = getDb(); - const [currentSource] = await db - .select({ - capabilitiesJson: schema.contentDatabaseSources.capabilitiesJson, - metadataJson: schema.contentDatabaseSources.metadataJson, - lastSourceUpdatedAt: schema.contentDatabaseSources.lastSourceUpdatedAt, - }) - .from(schema.contentDatabaseSources) - .where(eq(schema.contentDatabaseSources.id, args.sourceId)) - .limit(1); - const nextJson = mergeBuilderCmsWriteSettingsIntoJson({ - sourceTable: args.sourceTable, - currentCapabilitiesJson: currentSource?.capabilitiesJson, - currentMetadataJson: currentSource?.metadataJson, - nextCapabilitiesJson: sourceCapabilitiesForType("builder-cms"), - nextMetadataJson: serializeBuilderCmsSourceReadMetadataRecord({ + for (let attempt = 0; attempt < 5; attempt += 1) { + const [currentSource] = await db + .select({ + capabilitiesJson: schema.contentDatabaseSources.capabilitiesJson, + metadataJson: schema.contentDatabaseSources.metadataJson, + lastSourceUpdatedAt: schema.contentDatabaseSources.lastSourceUpdatedAt, + }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, args.sourceId)) + .limit(1); + if (!currentSource) throw new Error("Builder source not found."); + const currentMetadata = parseObject( + currentSource.metadataJson, + ); + if ( + args.refreshClaimId && + currentMetadata?.builderContinuationClaimId !== args.refreshClaimId + ) { + throw new Error( + "Builder source refresh claim was lost before completion.", + ); + } + const nextJson = mergeBuilderCmsWriteSettingsIntoJson({ sourceTable: args.sourceTable, - readState: args.readState, - entryCount: args.entryCount, - matchedRowCount: args.matchedRowCount, - progress: args.progress, - sourceFetchState: args.sourceFetchState, - activeReadSourceRowIds: args.activeReadSourceRowIds, - suspiciousEmpty: args.suspiciousEmpty, - builderModelFields: args.builderModelFields, - existingMetadataJson: currentSource?.metadataJson, - }), - }); - await db - .update(schema.contentDatabaseSources) - .set({ - syncState: args.syncState ?? "linked", - freshness: - args.readState === "error" || - args.progress?.partial || - args.suspiciousEmpty - ? "stale" - : "fresh", - capabilitiesJson: nextJson.capabilitiesJson, - metadataJson: nextJson.metadataJson, - lastRefreshedAt: args.now, - lastSourceUpdatedAt: args.suspiciousEmpty - ? (currentSource?.lastSourceUpdatedAt ?? null) - : args.fetchedAt, - lastError: - args.readState === "error" || args.suspiciousEmpty - ? args.message - : null, - updatedAt: args.now, - }) - .where(eq(schema.contentDatabaseSources.id, args.sourceId)); + currentCapabilitiesJson: currentSource.capabilitiesJson, + currentMetadataJson: currentSource.metadataJson, + nextCapabilitiesJson: sourceCapabilitiesForType("builder-cms"), + nextMetadataJson: serializeBuilderCmsSourceReadMetadataRecord({ + sourceTable: args.sourceTable, + readState: args.readState, + entryCount: args.entryCount, + matchedRowCount: args.matchedRowCount, + progress: args.progress, + sourceFetchState: args.sourceFetchState, + activeReadSourceRowIds: args.activeReadSourceRowIds, + suspiciousEmpty: args.suspiciousEmpty, + builderModelFields: args.builderModelFields, + existingMetadataJson: currentSource.metadataJson, + completedBuilderContinuationClaimId: args.refreshClaimId, + }), + }); + const updated = await db + .update(schema.contentDatabaseSources) + .set({ + syncState: args.syncState ?? "linked", + freshness: + args.readState === "error" || + args.progress?.partial || + args.suspiciousEmpty + ? "stale" + : "fresh", + capabilitiesJson: nextJson.capabilitiesJson, + metadataJson: nextJson.metadataJson, + lastRefreshedAt: args.now, + lastSourceUpdatedAt: args.suspiciousEmpty + ? currentSource.lastSourceUpdatedAt + : args.fetchedAt, + lastError: + args.readState === "error" || args.suspiciousEmpty + ? args.message + : null, + updatedAt: args.now, + }) + .where( + and( + eq(schema.contentDatabaseSources.id, args.sourceId), + currentSource.metadataJson === null + ? isNull(schema.contentDatabaseSources.metadataJson) + : eq( + schema.contentDatabaseSources.metadataJson, + currentSource.metadataJson, + ), + ), + ) + .returning({ id: schema.contentDatabaseSources.id }); + if (updated.length > 0) return; + } + throw new Error("Builder source metadata changed repeatedly during refresh."); } export async function updateReadOnlySourceMetadata(args: { diff --git a/templates/content/actions/builder-source-review-gates.db.test.ts b/templates/content/actions/builder-source-review-gates.db.test.ts index 7d94357bc1..7c3770ff88 100644 --- a/templates/content/actions/builder-source-review-gates.db.test.ts +++ b/templates/content/actions/builder-source-review-gates.db.test.ts @@ -7,6 +7,10 @@ import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { BUILDER_CMS_SAFE_WRITE_MODEL } from "../shared/api"; +import { + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + BUILDER_CMS_BODY_CONTENT_KEY, +} from "./_builder-cms-source-adapter"; const TEST_DB_PATH = join( tmpdir(), @@ -16,9 +20,11 @@ const TEST_DB_PATH = join( const OWNER = "owner@example.com"; const heavySnapshotReads = vi.hoisted(() => ({ + review: 0, target: 0, allSources: 0, omitTargetRows: false, + reviewDocumentScopes: [] as Array, documentScopes: [] as Array, })); vi.mock("./_database-source-utils.js", async (importOriginal) => { @@ -26,6 +32,22 @@ vi.mock("./_database-source-utils.js", async (importOriginal) => { await importOriginal(); return { ...original, + getContentDatabaseSourceSnapshotForReview: async ( + ...args: Parameters< + typeof original.getContentDatabaseSourceSnapshotForReview + > + ) => { + heavySnapshotReads.review += 1; + heavySnapshotReads.reviewDocumentScopes.push( + args[2] ? [...args[2]] : null, + ); + const snapshot = await original.getContentDatabaseSourceSnapshotForReview( + ...args, + ); + return snapshot && heavySnapshotReads.omitTargetRows + ? { ...snapshot, rows: [] } + : snapshot; + }, getContentDatabaseSourceSnapshotForWrite: async ( ...args: Parameters< typeof original.getContentDatabaseSourceSnapshotForWrite @@ -55,6 +77,9 @@ let prepareReview: typeof import("./prepare-builder-source-review.js").default; let previewReview: typeof import("./preview-builder-source-review.js").default; let prepareExecution: typeof import("./prepare-builder-source-execution.js").default; let validateExecution: typeof import("./validate-builder-source-execution.js").default; +let realExecutionDeps: typeof import("./execute-builder-source-execution.js").realExecutionDeps; +let builderReviewBodyCandidateDocumentIds: typeof import("./_database-source-utils.js").builderReviewBodyCandidateDocumentIds; +let builderReviewSourceValueTextProjection: typeof import("./_database-source-utils.js").builderReviewSourceValueTextProjection; beforeAll(async () => { process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; @@ -69,6 +94,12 @@ beforeAll(async () => { .default; validateExecution = (await import("./validate-builder-source-execution.js")) .default; + realExecutionDeps = (await import("./execute-builder-source-execution.js")) + .realExecutionDeps; + ({ + builderReviewBodyCandidateDocumentIds, + builderReviewSourceValueTextProjection, + } = await import("./_database-source-utils.js")); }, 60000); afterAll(() => { @@ -224,6 +255,51 @@ async function seedBuilderSource(args: { } describe("Builder source review execution gates", () => { + it("reads dotted Builder body keys on SQLite and keeps local deletions reviewable", async () => { + const seeded = await seedBuilderSource({ + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + }); + await getDb() + .update(schema.contentDatabaseSourceRows) + .set({ + sourceValuesJson: JSON.stringify({ + [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: "stored-hash", + [BUILDER_CMS_BODY_CONTENT_KEY]: "Stored readable body", + }), + }) + .where(eq(schema.contentDatabaseSourceRows.sourceId, seeded.sourceId)); + + const [projected] = await getDb() + .select({ + documentId: schema.contentDatabaseSourceRows.documentId, + sourceRowId: schema.contentDatabaseSourceRows.sourceRowId, + sourceQualifiedId: schema.contentDatabaseSourceRows.sourceQualifiedId, + provenance: schema.contentDatabaseSourceRows.provenance, + currentHash: builderReviewSourceValueTextProjection( + BUILDER_CMS_BODY_BLOCKS_HASH_KEY, + ), + currentContent: builderReviewSourceValueTextProjection( + BUILDER_CMS_BODY_CONTENT_KEY, + ), + }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, seeded.sourceId)); + + expect(projected).toMatchObject({ + currentHash: "stored-hash", + currentContent: "Stored readable body", + }); + expect( + builderReviewBodyCandidateDocumentIds([ + { + ...projected, + bodyHydrationStatus: "hydrated", + localContent: "", + }, + ]), + ).toEqual([seeded.rowDocumentId]); + }); + it("restores authoritative Builder targets in selected read-only previews", async () => { const seeded = await seedBuilderSource({ sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, @@ -255,6 +331,7 @@ describe("Builder source review execution gates", () => { sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, }); heavySnapshotReads.documentScopes = []; + heavySnapshotReads.reviewDocumentScopes = []; heavySnapshotReads.omitTargetRows = true; try { const response = await asOwner(() => @@ -287,8 +364,10 @@ describe("Builder source review execution gates", () => { documentId: expect.stringMatching(/^doc_row_/), }); expect(JSON.parse(execution.payloadJson).request.method).toBe("PATCH"); - expect(heavySnapshotReads.documentScopes).toEqual([ + expect(heavySnapshotReads.reviewDocumentScopes).toEqual([ [seeded.rowDocumentId], + ]); + expect(heavySnapshotReads.documentScopes).toEqual([ [seeded.rowDocumentId], ]); } finally { @@ -296,6 +375,23 @@ describe("Builder source review execution gates", () => { } }); + it("scopes live execution snapshots to the prepared change-set document", async () => { + const seeded = await seedBuilderSource({ + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + }); + heavySnapshotReads.documentScopes = []; + + const deps = realExecutionDeps(seeded.sourceId, seeded.changeSetId); + const database = await deps.resolveDatabase({ + documentId: seeded.databaseDocumentId, + }); + expect(database).not.toBeNull(); + + await deps.getSourceSnapshot(database!); + + expect(heavySnapshotReads.documentScopes).toEqual([[seeded.rowDocumentId]]); + }); + it("refreshes a previously approved body only from a provably unsent blocked dry run", async () => { const staleBody = { summary: "Old blocked body", @@ -473,7 +569,7 @@ describe("Builder source review execution gates", () => { expect(response.review.rows[0]?.bodyChange).toEqual(staleBody); }); - it("builds the persisted gate from the exact approved selection with two heavy snapshots", async () => { + it("builds the persisted gate from the exact approved selection with bounded review snapshots", async () => { const bodyChange = { summary: "Body changed", currentExcerpt: "Old", @@ -494,6 +590,7 @@ describe("Builder source review execution gates", () => { sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, bodyChange, }); + heavySnapshotReads.review = 0; heavySnapshotReads.target = 0; heavySnapshotReads.allSources = 0; @@ -523,7 +620,11 @@ describe("Builder source review execution gates", () => { const persistedBody = JSON.parse(persistedChangeSet.bodyChangeJson); const executionPayload = JSON.parse(execution.payloadJson); - expect(heavySnapshotReads).toMatchObject({ target: 2, allSources: 1 }); + expect(heavySnapshotReads).toMatchObject({ + review: 1, + target: 1, + allSources: 1, + }); expect(persistedChangeSet.state).toBe("approved"); expect(response.review.rows[0]?.fieldChanges).toEqual(persistedFields); expect(response.review.rows[0]?.bodyChange).toEqual(persistedBody); diff --git a/templates/content/actions/content-database-source-actions.test.ts b/templates/content/actions/content-database-source-actions.test.ts index c7cfd2fa11..3539a5ecbf 100644 --- a/templates/content/actions/content-database-source-actions.test.ts +++ b/templates/content/actions/content-database-source-actions.test.ts @@ -25,6 +25,7 @@ import prepareExecution from "./prepare-builder-source-execution"; import prepareReview, { BUILDER_SOURCE_REVIEW_PREPARE_LIMIT, buildBuilderSourceReviewPayload, + reviewPreparePriority, } from "./prepare-builder-source-review"; import previewReview from "./preview-builder-source-review"; import refreshSource from "./refresh-content-database-source"; @@ -60,6 +61,35 @@ describe("content database source actions", () => { }); }); + it("surfaces new Builder drafts before in-place updates in the same review state", () => { + const source = { + metadata: { writeMode: "publish_updates", pushMode: "publish" }, + rows: [ + { + documentId: "existing-document", + databaseItemId: "existing-item", + sourceRowId: "existing-entry", + sourceQualifiedId: "builder-cms://safe-model/existing-entry", + provenance: "Builder CMS read adapter", + }, + ], + } as ContentDatabaseSource; + const create = { + id: "create", + documentId: "new-document", + state: "pending_push", + } as ContentDatabaseSource["changeSets"][number]; + const update = { + id: "update", + documentId: "existing-document", + state: "pending_push", + } as ContentDatabaseSource["changeSets"][number]; + + expect(reviewPreparePriority(create, source)).toBeLessThan( + reviewPreparePriority(update, source), + ); + }); + it("accepts Builder source batch execution args", () => { expect( executeBatch.schema.parse({ @@ -373,6 +403,18 @@ describe("content database source actions", () => { }); }); + it("accepts a guarded Builder continuation offset", () => { + expect( + refreshSource.schema.parse({ + databaseId: "database", + expectedBuilderContinuationOffset: 400, + }), + ).toEqual({ + databaseId: "database", + expectedBuilderContinuationOffset: 400, + }); + }); + it("caps content database page sizes for bounded responses", () => { expect( normalizeContentDatabasePageOptions({ limit: 1, offset: -5 }), diff --git a/templates/content/actions/execute-builder-source-execution.test.ts b/templates/content/actions/execute-builder-source-execution.test.ts index 0719364bdb..691773999e 100644 --- a/templates/content/actions/execute-builder-source-execution.test.ts +++ b/templates/content/actions/execute-builder-source-execution.test.ts @@ -682,7 +682,7 @@ describe("execute Builder source execution", () => { }); }); - it("blocks publish transitions when the entry is already published", async () => { + it("publishes an approved revision when the entry is already published", async () => { const approvedChangeSet = changeSet({ pushMode: "draft" }); const builderSource = source({ changeSets: [approvedChangeSet], @@ -717,15 +717,56 @@ describe("execute Builder source execution", () => { }, deps, ), - ).rejects.toThrow("Entry is already published."); + ).resolves.toMatchObject(RESPONSE); - expect(deps.updateExecutionState).toHaveBeenCalledWith( - expect.objectContaining({ - executionId: execution.id, - state: "blocked", - lastError: "Entry is already published.", + expect(deps.readLiveEntry).toHaveBeenCalledTimes(1); + expect(deps.executeWrite).toHaveBeenCalledWith({ + request: expect.objectContaining({ + body: expect.objectContaining({ + data: expect.objectContaining({ title: "New title" }), + published: "published", + }), }), + }); + }); + + it("fails closed when a publish preflight cannot verify publication state", async () => { + const approvedChangeSet = changeSet({ pushMode: "draft" }); + const builderSource = source({ + changeSets: [approvedChangeSet], + metadata: { allowPublicationTransitions: true }, + }); + const execution = executionFor({ + source: builderSource, + changeSet: approvedChangeSet, + publicationTransition: "publish", + }); + const deps = depsFor({ + source: builderSource, + execution, + readLiveEntry: { + exists: true, + published: null, + lastUpdated: BUILDER_LAST_UPDATED_MS, + blocksHash: null, + id: "builder-entry-1", + }, + }); + + await expect( + executeBuilderSourceExecutionWithDeps( + { + databaseId: "database-1", + changeSetId: approvedChangeSet.id, + pushModeConfirmation: "draft", + publicationTransition: "publish", + }, + deps, + ), + ).rejects.toThrow( + "Builder publication state could not be verified; refresh and re-review.", ); + expect(deps.claimExecution).not.toHaveBeenCalled(); expect(deps.executeWrite).not.toHaveBeenCalled(); }); diff --git a/templates/content/actions/execute-builder-source-execution.ts b/templates/content/actions/execute-builder-source-execution.ts index d3b454044a..e86c34fabd 100644 --- a/templates/content/actions/execute-builder-source-execution.ts +++ b/templates/content/actions/execute-builder-source-execution.ts @@ -494,8 +494,12 @@ function livePreflightBlockMessage(args: { ) { return "Builder body changed since this diff was approved; refresh and re-review."; } - if (args.effect === "publish" && args.liveState.published !== "draft") { - return "Entry is already published."; + if ( + args.effect === "publish" && + args.liveState.published !== "draft" && + args.liveState.published !== "published" + ) { + return "Builder publication state could not be verified; refresh and re-review."; } if (args.effect === "unpublish" && args.liveState.published !== "published") { return "Entry is not currently published."; @@ -637,6 +641,7 @@ async function reconcileBuilderCmsWrite(args: { export function realExecutionDeps( sourceId?: string, + changeSetId?: string, ): ExecuteBuilderSourceExecutionDeps { return { now: () => new Date().toISOString(), @@ -644,8 +649,39 @@ export function realExecutionDeps( assertEditor: async (database) => { await assertAccess("document", database.documentId, "editor"); }, - getSourceSnapshot: (database) => - getContentDatabaseSourceSnapshotForWrite(database, sourceId), + getSourceSnapshot: async (database) => { + // Execution always targets one prepared change set. Loading every + // Builder-backed document (and every heavy body baseline) here can spend + // the hosted request budget before the provider write is dispatched. + // Resolve the durable prepared target first, then build the authoritative + // write snapshot for that document only. The unscoped fallback preserves + // compatibility for legacy/non-persisted callers. + const [target] = changeSetId + ? await getDb() + .select({ + documentId: schema.contentDatabaseSourceChangeSets.documentId, + sourceId: schema.contentDatabaseSourceChangeSets.sourceId, + }) + .from(schema.contentDatabaseSourceChangeSets) + .where( + sourceId + ? and( + eq(schema.contentDatabaseSourceChangeSets.id, changeSetId), + eq( + schema.contentDatabaseSourceChangeSets.sourceId, + sourceId, + ), + ) + : eq(schema.contentDatabaseSourceChangeSets.id, changeSetId), + ) + .limit(1) + : []; + return getContentDatabaseSourceSnapshotForWrite( + database, + sourceId ?? target?.sourceId, + target?.documentId ? [target.documentId] : undefined, + ); + }, getExecution: async (args) => { const [claim] = await getDb() .select({ @@ -1297,7 +1333,7 @@ export default defineAction({ ): Promise => { return executeBuilderSourceExecutionWithDeps( args, - realExecutionDeps(args.sourceId), + realExecutionDeps(args.sourceId, args.changeSetId), ); }, }); diff --git a/templates/content/actions/prepare-builder-source-review.ts b/templates/content/actions/prepare-builder-source-review.ts index aa6d891a1b..2c005239b2 100644 --- a/templates/content/actions/prepare-builder-source-review.ts +++ b/templates/content/actions/prepare-builder-source-review.ts @@ -29,6 +29,7 @@ import { createBuilderSourceTiming } from "./_builder-source-timings.js"; import { canRefreshLocallyBlockedBuilderReview, findOpenSourceChangeSet, + getContentDatabaseSourceSnapshotForReview, getContentDatabaseSourceSnapshotForWrite, resolveDatabaseForSourceMutation, serializeSourceRowRecord, @@ -125,10 +126,20 @@ function maxRisk( export function reviewPreparePriority( changeSet: ContentDatabaseSourceChangeSet, + source?: ContentDatabaseSource, ) { - if (changeSet.state === "pending_push") return 0; - if (changeSet.state === "staged_revision") return 1; - return 2; + const statePriority = + changeSet.state === "pending_push" + ? 0 + : changeSet.state === "staged_revision" + ? 2 + : 4; + const effectPriority = + source && + resolveBuilderCmsWriteEffect({ source, changeSet }) === "create_draft" + ? 0 + : 1; + return statePriority + effectPriority; } function parsePayload(value: string) { @@ -667,7 +678,7 @@ export default defineAction({ const database = await resolveDatabaseForSourceMutation(args); if (!database) throw new Error("Database not found."); await assertAccess("document", database.documentId, "editor"); - const snapshot = await getContentDatabaseSourceSnapshotForWrite( + const snapshot = await getContentDatabaseSourceSnapshotForReview( database, args.sourceId, args.documentIds, @@ -716,7 +727,11 @@ export default defineAction({ throw new Error("No pending local Builder changes to review."); } const reviewableChanges = [...allReviewableChanges] - .sort((a, b) => reviewPreparePriority(a) - reviewPreparePriority(b)) + .sort( + (a, b) => + reviewPreparePriority(a, snapshot) - + reviewPreparePriority(b, snapshot), + ) .slice(0, BUILDER_SOURCE_REVIEW_PREPARE_LIMIT); const authoritativeSnapshot = await withAuthoritativeBuilderTargetRows({ source: snapshot, @@ -773,7 +788,9 @@ export default defineAction({ reviewedSnapshot: await getContentDatabaseSourceSnapshotForWrite( database, args.sourceId, - args.documentIds, + reviewableChanges.every((changeSet) => changeSet.documentId) + ? reviewableChanges.map((changeSet) => changeSet.documentId!) + : args.documentIds, ), response: await getContentDatabaseResponse(database.id), }), diff --git a/templates/content/actions/preview-builder-source-review.ts b/templates/content/actions/preview-builder-source-review.ts index 4e8a718775..bc620deea7 100644 --- a/templates/content/actions/preview-builder-source-review.ts +++ b/templates/content/actions/preview-builder-source-review.ts @@ -7,7 +7,7 @@ import type { PreviewBuilderSourceReviewResponse, } from "../shared/api.js"; import { - getContentDatabaseSourceSnapshotForWrite, + getContentDatabaseSourceSnapshotForReview, resolveDatabaseForSourceMutation, } from "./_database-source-utils.js"; import { @@ -45,7 +45,7 @@ export default defineAction({ throw new Error("Select at least one Builder row before reviewing it."); } - const source = await getContentDatabaseSourceSnapshotForWrite( + const source = await getContentDatabaseSourceSnapshotForReview( database, args.sourceId, args.scope === "selected" ? args.documentIds : undefined, @@ -72,7 +72,8 @@ export default defineAction({ const changeSets = [...allReviewableChanges] .sort( (left, right) => - reviewPreparePriority(left) - reviewPreparePriority(right), + reviewPreparePriority(left, source) - + reviewPreparePriority(right, source), ) .slice(0, BUILDER_SOURCE_REVIEW_PREPARE_LIMIT); const authoritativeSource = await withAuthoritativeBuilderTargetRows({ diff --git a/templates/content/actions/refresh-content-database-source.ts b/templates/content/actions/refresh-content-database-source.ts index 236eeff4b1..5d576815be 100644 --- a/templates/content/actions/refresh-content-database-source.ts +++ b/templates/content/actions/refresh-content-database-source.ts @@ -5,9 +5,11 @@ import { z } from "zod"; import type { ContentDatabaseSourceStatusResponse } from "../shared/api.js"; import { getContentDatabaseSourceAdapter } from "./_content-database-source-adapters.js"; import { + claimBuilderCmsSourceRefresh, getContentDatabaseSourceSnapshot, getContentDatabaseSourceSnapshotById, getExistingSourceForWrite, + releaseBuilderCmsSourceRefreshClaim, resyncBuilderCmsSourceSnapshot, resyncMockSourceSnapshot, resolveDatabaseForSourceMutation, @@ -33,6 +35,14 @@ export default defineAction({ .describe( "For paginated Builder CMS or Notion sources, read a bounded multi-page snapshot in this refresh.", ), + expectedBuilderContinuationOffset: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Internal Builder pagination guard. Continue only when the persisted source still expects this offset.", + ), }), run: async (args): Promise => { const database = await resolveDatabaseForSourceMutation(args); @@ -50,15 +60,30 @@ export default defineAction({ } const now = new Date().toISOString(); + let skippedOverlappingBuilderRefresh = false; if (source.sourceType === "mock-local") { await resyncMockSourceSnapshot({ database, source, now }); } else if (source.sourceType === "builder-cms") { - await resyncBuilderCmsSourceSnapshot({ - database, + const claimedSource = await claimBuilderCmsSourceRefresh({ source, - now, - runFullRefresh: args.fullRefresh === true, + expectedOffset: args.expectedBuilderContinuationOffset, }); + skippedOverlappingBuilderRefresh = !claimedSource; + if (claimedSource) { + await resyncBuilderCmsSourceSnapshot({ + database, + source: claimedSource.source, + now, + runFullRefresh: args.fullRefresh === true, + refreshClaimId: claimedSource.claimId, + }).catch(async (error: unknown) => { + await releaseBuilderCmsSourceRefreshClaim({ + sourceId: source.id, + claimId: claimedSource.claimId, + }); + throw error; + }); + } } else if (source.sourceType === "local-table") { // Read-only federated secondary; its rows are re-read on demand, nothing // to resync against the primary's local snapshot here. @@ -116,11 +141,13 @@ export default defineAction({ database: serializeDatabase(database), mode: "source-backed", summary: snapshot - ? builderFetching - ? `${snapshot.sourceName} fetched ${builderFetched ?? "some"} rows. Run refresh again to continue loading the remaining Builder rows.` - : snapshot.sourceType === "notion-database" - ? `${snapshot.sourceName} refreshed read-only from Notion.` - : `${snapshot.sourceName} resynced locally; field mappings and row identity now reflect the current database snapshot.` + ? skippedOverlappingBuilderRefresh + ? `${snapshot.sourceName} already advanced or has another refresh in progress; the current snapshot was preserved.` + : builderFetching + ? `${snapshot.sourceName} fetched ${builderFetched ?? "some"} rows. Run refresh again to continue loading the remaining Builder rows.` + : snapshot.sourceType === "notion-database" + ? `${snapshot.sourceName} refreshed read-only from Notion.` + : `${snapshot.sourceName} resynced locally; field mappings and row identity now reflect the current database snapshot.` : "Source metadata refreshed.", source: snapshot, }; 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 414edf893a..3563cc548f 100644 --- a/templates/content/actions/resync-content-database-source.db.test.ts +++ b/templates/content/actions/resync-content-database-source.db.test.ts @@ -63,6 +63,7 @@ vi.mock("./_builder-cms-read-client.js", async () => { } if (model === "collection-mapped-fields") { return [ + { name: "blurb", type: "string", required: false }, { name: "topics", type: "list", required: false }, { name: "tags", type: "list", required: false }, { name: "customModelField", type: "string", required: false }, @@ -227,6 +228,25 @@ vi.mock("./_builder-cms-read-client.js", async () => { }, }; } + if (model === "collection-read-error-continuation") { + const startOffset = offset ?? 500; + return { + state: "error", + entries: [], + fetchedAt: "2026-02-01T00:00:00.000Z", + message: "Builder CMS read failed with HTTP 503.", + progress: { + requestedLimit: 500, + pageSize: 100, + startOffset, + nextOffset: startOffset, + fetchedEntryCount: startOffset, + hasMore: false, + partial: false, + readMode: "builder-api", + }, + }; + } if (model === "collection-large-597") { const allEntries = Array.from({ length: 597 }, (_, index) => ({ id: `entry-large-${index + 1}`, @@ -273,6 +293,7 @@ vi.mock("./_builder-cms-read-client.js", async () => { updatedAt: "2026-02-01T00:00:00.000Z", sourceValues: { "data.title": "Mapped fields", + "data.blurb": "Remote Builder blurb", "data.topics": ["AI", "CMS"], "data.tags": ["Agents", "Content"], "data.customModelField": "Arbitrary value", @@ -551,6 +572,10 @@ let seedSourceRows: typeof import("./_database-source-utils.js").seedMockSourceR let materializeSourceFields: typeof import("./_database-source-utils.js").materializeSourceFieldPropertyValues; let getSnapshot: typeof import("./_database-source-utils.js").getContentDatabaseSourceSnapshotById; let getWriteSnapshot: typeof import("./_database-source-utils.js").getContentDatabaseSourceSnapshotForWrite; +let claimSourceRefresh: typeof import("./_database-source-utils.js").claimBuilderCmsSourceRefresh; +let releaseSourceRefreshClaim: typeof import("./_database-source-utils.js").releaseBuilderCmsSourceRefreshClaim; +let renewSourceRefreshClaim: typeof import("./_database-source-utils.js").renewBuilderCmsSourceRefreshClaim; +let updateSourceReadMetadata: typeof import("./_database-source-utils.js").updateBuilderCmsSourceReadMetadata; let hydrateQueuedBodies: typeof import("./_database-source-utils.js").processBuilderBodyHydrationQueue; let withBuilderBodyValues: typeof import("./_database-source-utils.js").withBuilderBodySourceValues; let seedCollabFromText: typeof import("@agent-native/core/collab").seedFromText; @@ -607,6 +632,14 @@ beforeAll(async () => { .getContentDatabaseSourceSnapshotById; getWriteSnapshot = (await import("./_database-source-utils.js")) .getContentDatabaseSourceSnapshotForWrite; + claimSourceRefresh = (await import("./_database-source-utils.js")) + .claimBuilderCmsSourceRefresh; + releaseSourceRefreshClaim = (await import("./_database-source-utils.js")) + .releaseBuilderCmsSourceRefreshClaim; + renewSourceRefreshClaim = (await import("./_database-source-utils.js")) + .renewBuilderCmsSourceRefreshClaim; + updateSourceReadMetadata = (await import("./_database-source-utils.js")) + .updateBuilderCmsSourceReadMetadata; hydrateQueuedBodies = (await import("./_database-source-utils.js")) .processBuilderBodyHydrationQueue; withBuilderBodyValues = (await import("./_database-source-utils.js")) @@ -628,6 +661,245 @@ afterAll(() => { } }); +it("atomically grants one Builder continuation claim per persisted offset", async () => { + const db = getDb(); + const now = new Date().toISOString(); + const metadataJson = JSON.stringify({ + sourceFetchState: "fetching", + lastReadHasMore: true, + lastReadNextOffset: 400, + activeReadSourceRowIds: ["entry-1"], + }); + await db.insert(schema.documents).values({ + id: "doc-continuation-claim", + ownerEmail: OWNER, + title: "Continuation claim", + content: "", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabases).values({ + id: "db-continuation-claim", + ownerEmail: OWNER, + documentId: "doc-continuation-claim", + title: "Continuation claim", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseSources).values({ + id: "source-continuation-claim", + ownerEmail: OWNER, + databaseId: "db-continuation-claim", + sourceType: "builder-cms", + sourceName: "Continuation claim", + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + syncState: "refreshing", + freshness: "stale", + metadataJson, + createdAt: now, + updatedAt: now, + }); + const [source] = await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, "source-continuation-claim")); + + const claims = await Promise.all([ + claimSourceRefresh({ source, expectedOffset: 400, now }), + claimSourceRefresh({ source, expectedOffset: 400, now }), + ]); + const granted = claims.filter((claim) => claim !== null); + expect(granted).toHaveLength(1); + expect( + JSON.parse(granted[0]!.source.metadataJson!).builderContinuationClaimOffset, + ).toBe(400); + + const claimedMetadata = JSON.parse(granted[0]!.source.metadataJson!); + claimedMetadata.writeMode = "publish_updates"; + await db + .update(schema.contentDatabaseSources) + .set({ metadataJson: JSON.stringify(claimedMetadata) }) + .where(eq(schema.contentDatabaseSources.id, source.id)); + await releaseSourceRefreshClaim({ + sourceId: source.id, + claimId: granted[0]!.claimId, + }); + const [released] = await db + .select({ metadataJson: schema.contentDatabaseSources.metadataJson }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, source.id)); + const releasedMetadata = JSON.parse(released.metadataJson!); + expect(releasedMetadata.writeMode).toBe("publish_updates"); + expect(releasedMetadata).not.toHaveProperty("builderContinuationClaimId"); + + const orphanedMetadataJson = JSON.stringify({ + ...releasedMetadata, + builderContinuationClaimId: "orphaned-claim", + builderContinuationClaimOffset: 400, + builderContinuationClaimedAt: "2026-01-01T00:00:00.000Z", + }); + await db + .update(schema.contentDatabaseSources) + .set({ metadataJson: orphanedMetadataJson }) + .where(eq(schema.contentDatabaseSources.id, source.id)); + const [orphanedSource] = await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, source.id)); + const recovered = await claimSourceRefresh({ + source: orphanedSource, + expectedOffset: 400, + now: "2026-01-01T00:31:00.000Z", + }); + expect(recovered?.claimId).toBeTruthy(); + expect(recovered?.claimId).not.toBe("orphaned-claim"); + expect( + await renewSourceRefreshClaim({ + sourceId: source.id, + claimId: "orphaned-claim", + now: "2026-01-01T00:31:30.000Z", + }), + ).toBe(false); + await releaseSourceRefreshClaim({ + sourceId: source.id, + claimId: recovered!.claimId, + }); + + const [readySource] = await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, source.id)); + const fenced = await claimSourceRefresh({ + source: readySource, + expectedOffset: 400, + now: "2026-01-01T00:12:00.000Z", + }); + await expect( + updateSourceReadMetadata({ + sourceId: source.id, + sourceTable: BUILDER_CMS_SAFE_WRITE_MODEL, + readState: "live", + entryCount: 580, + matchedRowCount: 580, + fetchedAt: "2026-01-01T00:12:30.000Z", + now: "2026-01-01T00:12:30.000Z", + message: null, + refreshClaimId: "not-the-owner", + }), + ).rejects.toThrow("refresh claim was lost"); + const [stillFenced] = await db + .select({ metadataJson: schema.contentDatabaseSources.metadataJson }) + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, source.id)); + expect(JSON.parse(stillFenced.metadataJson!).builderContinuationClaimId).toBe( + fenced!.claimId, + ); + await releaseSourceRefreshClaim({ + sourceId: source.id, + claimId: fenced!.claimId, + }); +}); + +it("preserves a partial Builder snapshot when a continuation read fails", async () => { + const db = getDb(); + const createdAt = "2026-01-01T00:00:00.000Z"; + const metadataJson = JSON.stringify({ + sourceFetchState: "fetching", + lastReadHasMore: true, + lastReadNextOffset: 500, + lastReadFetchedEntryCount: 500, + activeReadSourceRowIds: ["entry-preserved"], + }); + await db.insert(schema.documents).values([ + { + id: "doc-read-error-db", + ownerEmail: OWNER, + title: "Read error DB", + createdAt, + updatedAt: createdAt, + }, + { + id: "doc-read-error-row", + ownerEmail: OWNER, + parentId: "doc-read-error-db", + title: "Preserved row", + createdAt, + updatedAt: createdAt, + }, + ]); + await db.insert(schema.contentDatabases).values({ + id: "db-read-error", + ownerEmail: OWNER, + documentId: "doc-read-error-db", + title: "Read error DB", + createdAt, + updatedAt: createdAt, + }); + await db.insert(schema.contentDatabaseItems).values({ + id: "item-read-error-row", + ownerEmail: OWNER, + databaseId: "db-read-error", + documentId: "doc-read-error-row", + position: 0, + createdAt, + updatedAt: createdAt, + }); + await db.insert(schema.contentDatabaseSources).values({ + id: "source-read-error", + ownerEmail: OWNER, + databaseId: "db-read-error", + sourceType: "builder-cms", + sourceName: "Read error source", + sourceTable: "collection-read-error-continuation", + syncState: "refreshing", + freshness: "stale", + metadataJson, + createdAt, + updatedAt: createdAt, + }); + await db.insert(schema.contentDatabaseSourceRows).values({ + id: "source-row-read-error", + ownerEmail: OWNER, + sourceId: "source-read-error", + databaseItemId: "item-read-error-row", + documentId: "doc-read-error-row", + sourceRowId: "entry-preserved", + sourceQualifiedId: + "builder-cms://collection-read-error-continuation/entry-preserved", + sourceDisplayKey: "Preserved row", + sourceValuesJson: JSON.stringify({ "data.title": "Preserved row" }), + provenance: "Builder CMS read adapter", + freshness: "fresh", + createdAt, + updatedAt: createdAt, + }); + const [database] = await db + .select() + .from(schema.contentDatabases) + .where(eq(schema.contentDatabases.id, "db-read-error")); + const [source] = await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, "source-read-error")); + + await expect( + resync({ database, source, now: "2026-02-01T00:00:00.000Z" }), + ).rejects.toThrow("HTTP 503"); + + const rows = await db + .select() + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); + const [preservedSource] = await db + .select() + .from(schema.contentDatabaseSources) + .where(eq(schema.contentDatabaseSources.id, source.id)); + expect(rows).toHaveLength(1); + expect(rows[0]?.sourceRowId).toBe("entry-preserved"); + expect(preservedSource.metadataJson).toBe(metadataJson); + expect(preservedSource.syncState).toBe("refreshing"); +}); + it("preserves an established source snapshot when Builder unexpectedly returns zero entries", async () => { builderReadMock.mode = "full"; builderReadMock.calls = []; @@ -1018,6 +1290,24 @@ it("materializes topics, tags, and arbitrary Builder model fields", async () => updatedAt: now, }); await db.insert(schema.documentPropertyDefinitions).values([ + { + id: "prop-local-blurb", + ownerEmail: OWNER, + databaseId: "db-mapped-fields", + name: "Blurb", + type: "text", + createdAt: now, + updatedAt: now, + }, + { + id: "prop-builder-blurb", + ownerEmail: OWNER, + databaseId: "db-mapped-fields", + name: "Blurb", + type: "text", + createdAt: now, + updatedAt: now, + }, { id: "prop-topics", ownerEmail: OWNER, @@ -1087,6 +1377,36 @@ it("materializes topics, tags, and arbitrary Builder model fields", async () => updatedAt: now, }); await db.insert(schema.contentDatabaseSourceFields).values([ + { + id: "field-local-blurb", + ownerEmail: OWNER, + sourceId: "source-mapped-fields", + propertyId: "prop-local-blurb", + localFieldKey: "prop-local-blurb", + sourceFieldKey: "data.blurb", + sourceFieldLabel: "Blurb", + sourceFieldType: "text", + mappingType: "property", + writeOwner: "source", + provenance: "source field", + createdAt: now, + updatedAt: now, + }, + { + id: "field-builder-blurb", + ownerEmail: OWNER, + sourceId: "source-mapped-fields", + propertyId: "prop-builder-blurb", + localFieldKey: "prop-builder-blurb", + sourceFieldKey: "data.blurb", + sourceFieldLabel: "Blurb", + sourceFieldType: "text", + mappingType: "property", + writeOwner: "source", + provenance: "Builder model field", + createdAt: now, + updatedAt: now, + }, { id: "field-topics", ownerEmail: OWNER, @@ -1174,6 +1494,7 @@ it("materializes topics, tags, and arbitrary Builder model fields", async () => .from(schema.contentDatabaseSourceRows) .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id)); expect(JSON.parse(sourceRow.sourceValuesJson)).toMatchObject({ + "data.blurb": "Remote Builder blurb", "data.topics": ["AI", "CMS"], "data.tags": ["Agents", "Content"], "data.customModelField": "Arbitrary value", @@ -1196,6 +1517,7 @@ it("materializes topics, tags, and arbitrary Builder model fields", async () => ]), ), ).toMatchObject({ + "prop-builder-blurb": "Remote Builder blurb", "prop-topics": ["ai", "cms"], "prop-tags": ["agents", "content"], "prop-custom": "Arbitrary value", @@ -1264,6 +1586,18 @@ it("materializes topics, tags, and arbitrary Builder model fields", async () => sourceFieldKey: "data.Status", }, ]); + expect( + statusFieldMappings.filter( + (field: { sourceFieldKey: string }) => + field.sourceFieldKey === "data.blurb", + ), + ).toEqual([ + { + id: "field-builder-blurb", + propertyId: "prop-builder-blurb", + sourceFieldKey: "data.blurb", + }, + ]); const readCall = builderReadMock.calls.find( (call) => call.model === "collection-mapped-fields", ); @@ -2007,6 +2341,154 @@ it("full Builder refresh reads every page in one resync call", async () => { ).toEqual([{ model: "collection-a", maxPages: undefined, offset: 0 }]); }); +it("unlocks synthetic Builder fixture bodies without weakening remote hydration", async () => { + const db = getDb(); + const now = "2026-07-24T12:00:00.000Z"; + const databaseId = "db-builder-fixture-hydration"; + const databaseDocumentId = "doc-builder-fixture-hydration"; + const sourceId = "source-builder-fixture-hydration"; + const localDocumentId = "doc-builder-fixture-local"; + const remoteDocumentId = "doc-builder-fixture-remote"; + + await db.insert(schema.documents).values([ + { + id: databaseDocumentId, + ownerEmail: OWNER, + title: "Builder fixture hydration database", + content: "", + createdAt: now, + updatedAt: now, + }, + { + id: localDocumentId, + ownerEmail: OWNER, + parentId: databaseDocumentId, + title: "Local fixture", + content: "", + createdAt: now, + updatedAt: now, + }, + { + id: remoteDocumentId, + ownerEmail: OWNER, + parentId: databaseDocumentId, + title: "Preserved remote row", + content: "", + createdAt: now, + updatedAt: now, + }, + ]); + await db.insert(schema.contentDatabases).values({ + id: databaseId, + ownerEmail: OWNER, + documentId: databaseDocumentId, + title: "Builder fixture hydration database", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabaseItems).values([ + { + id: "item-builder-fixture-local", + ownerEmail: OWNER, + databaseId, + documentId: localDocumentId, + position: 0, + createdAt: now, + updatedAt: now, + }, + { + id: "item-builder-fixture-remote", + ownerEmail: OWNER, + databaseId, + documentId: remoteDocumentId, + position: 1, + createdAt: now, + updatedAt: now, + }, + ]); + + const items = [ + { + id: "item-builder-fixture-local", + databaseId, + document: { + id: localDocumentId, + title: "Local fixture", + content: "", + }, + position: 0, + properties: [], + }, + { + id: "item-builder-fixture-remote", + databaseId, + document: { + id: remoteDocumentId, + title: "Preserved remote row", + content: "", + }, + position: 1, + properties: [], + }, + ]; + await seedSourceRows({ + sourceId, + ownerEmail: OWNER, + sourceType: "builder-cms", + sourceTable: "safe-model", + items, + now, + existingBuilderRows: new Map([ + [ + remoteDocumentId, + { + documentId: remoteDocumentId, + sourceRowId: "remote-entry-1", + sourceQualifiedId: "builder-cms://safe-model/remote-entry-1", + sourceDisplayKey: "Preserved remote row", + provenance: "Builder CMS read adapter", + lastSourceUpdatedAt: now, + sourceValuesJson: JSON.stringify({ + "data.title": "Preserved remote row", + }), + }, + ], + ]), + }); + + const hydrationRows = await db + .select({ + documentId: schema.contentDatabaseItems.documentId, + status: schema.contentDatabaseItems.bodyHydrationStatus, + }) + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, databaseId)); + expect( + new Map(hydrationRows.map((row: any) => [row.documentId, row.status])), + ).toEqual( + new Map([ + [localDocumentId, "unavailable"], + [remoteDocumentId, "hydrated"], + ]), + ); + + const sourceRows = await db + .select({ + documentId: schema.contentDatabaseSourceRows.documentId, + provenance: schema.contentDatabaseSourceRows.provenance, + }) + .from(schema.contentDatabaseSourceRows) + .where(eq(schema.contentDatabaseSourceRows.sourceId, sourceId)); + expect( + new Map(sourceRows.map((row: any) => [row.documentId, row.provenance])), + ).toEqual( + new Map([ + [localDocumentId, "Builder CMS fixture adapter"], + [remoteDocumentId, "Builder CMS read adapter"], + ]), + ); +}); + it("keeps a materialized required Builder reference dispatchable after full refresh", async () => { const db = getDb(); const now = "2026-07-14T05:00:00.000Z"; diff --git a/templates/content/app/components/editor/BubbleToolbar.test.tsx b/templates/content/app/components/editor/BubbleToolbar.test.tsx new file mode 100644 index 0000000000..08ee68c75d --- /dev/null +++ b/templates/content/app/components/editor/BubbleToolbar.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom + +import { Editor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { BubbleToolbar, shouldShowBubbleToolbar } from "./BubbleToolbar"; + +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: () => (key: string) => key, +})); + +vi.mock("@tiptap/react/menus", () => ({ + BubbleMenu: ({ + children, + className, + updateDelay, + }: { + children: ReactNode; + className?: string; + updateDelay?: number; + }) => ( +
+ {children} +
+ ), +})); + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipContent: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ children }: { children: ReactNode }) => children, +})); + +describe("BubbleToolbar link shortcut", () => { + let editor: Editor | null = null; + let root: Root | null = null; + let editorElement: HTMLDivElement | null = null; + let toolbarElement: HTMLDivElement | null = null; + + afterEach(() => { + act(() => root?.unmount()); + editor?.destroy(); + editorElement?.remove(); + toolbarElement?.remove(); + editor = null; + root = null; + editorElement = null; + toolbarElement = null; + }); + + it("opens the link input for selected editor text on Mod+K", () => { + editorElement = document.createElement("div"); + toolbarElement = document.createElement("div"); + document.body.append(editorElement, toolbarElement); + editor = new Editor({ + element: editorElement, + extensions: [StarterKit], + content: "

Builder link

", + }); + editor.commands.setTextSelection({ from: 1, to: 8 }); + + root = createRoot(toolbarElement); + act(() => root!.render()); + expect( + toolbarElement.querySelector('.bubble-toolbar[data-update-delay="0"]'), + ).not.toBeNull(); + expect( + toolbarElement.querySelector('button[aria-label="editor.link"]'), + ).not.toBeNull(); + act(() => { + editor!.view.dom.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + + expect( + toolbarElement.querySelector('input[placeholder="editor.pasteLink"]'), + ).not.toBeNull(); + expect( + toolbarElement.querySelector('input[aria-label="editor.pasteLink"]'), + ).not.toBeNull(); + + const menu = toolbarElement.querySelector(".bubble-toolbar"); + expect(document.activeElement).toBe( + toolbarElement.querySelector('input[placeholder="editor.pasteLink"]'), + ); + expect( + shouldShowBubbleToolbar({ + editor, + element: menu!, + state: editor.state, + from: editor.state.selection.from, + to: editor.state.selection.to, + }), + ).toBe(true); + + const input = toolbarElement.querySelector( + 'input[aria-label="editor.pasteLink"]', + )!; + act(() => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, "https://www.builder.io/"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + const applyButton = [...toolbarElement.querySelectorAll("button")].find( + (button) => button.textContent === "editor.apply", + ); + act(() => applyButton!.click()); + + expect(editor.getHTML()).toContain( + 'Builder', + ); + }); + + it("opens the link input on pointer-down before the menu can reconcile", () => { + editorElement = document.createElement("div"); + toolbarElement = document.createElement("div"); + document.body.append(editorElement, toolbarElement); + editor = new Editor({ + element: editorElement, + extensions: [StarterKit], + content: "

Builder link

", + }); + editor.commands.setTextSelection({ from: 1, to: 8 }); + + root = createRoot(toolbarElement); + act(() => root!.render()); + const linkButton = toolbarElement.querySelector( + 'button[aria-label="editor.link"]', + )!; + act(() => { + linkButton.dispatchEvent(new Event("pointerdown", { bubbles: true })); + }); + + expect( + toolbarElement.querySelector('input[aria-label="editor.pasteLink"]'), + ).not.toBeNull(); + }); +}); diff --git a/templates/content/app/components/editor/BubbleToolbar.tsx b/templates/content/app/components/editor/BubbleToolbar.tsx index 5a9ab07d2c..2cccf27e0f 100644 --- a/templates/content/app/components/editor/BubbleToolbar.tsx +++ b/templates/content/app/components/editor/BubbleToolbar.tsx @@ -20,7 +20,7 @@ import { import { Decoration, DecorationSet } from "@tiptap/pm/view"; import type { Editor } from "@tiptap/react"; import { BubbleMenu } from "@tiptap/react/menus"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Tooltip, @@ -83,11 +83,35 @@ function selectionIncludesBubbleToolbarExcludedNode( return includesExcludedNode; } +export function shouldShowBubbleToolbar({ + editor, + element, + state, + from, + to, +}: { + editor: Editor; + element: HTMLElement; + state: EditorState; + from: number; + to: number; +}) { + const focusBelongsToToolbar = element.contains(document.activeElement); + if (!editor.view.hasFocus() && !focusBelongsToToolbar) return false; + if (from === to) return false; + return !selectionIncludesBubbleToolbarExcludedNode(state, from, to); +} + export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) { const t = useT(); const [showLinkInput, setShowLinkInput] = useState(false); const [linkUrl, setLinkUrl] = useState(""); + const openLinkInput = useCallback(() => { + setLinkUrl(editor.getAttributes("link").href || ""); + setShowLinkInput(true); + }, [editor]); + useEffect(() => { const plugin = new Plugin({ key: selectionFillPluginKey, @@ -105,6 +129,29 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) { }, }, props: { + handleKeyDown(_view, event) { + if ( + !(event.metaKey || event.ctrlKey) || + event.shiftKey || + event.altKey || + event.key.toLowerCase() !== "k" + ) { + return false; + } + + const { state } = editor; + const { from, to } = state.selection; + if ( + from === to || + selectionIncludesBubbleToolbarExcludedNode(state, from, to) + ) { + return false; + } + + event.preventDefault(); + openLinkInput(); + return true; + }, decorations(state) { const range = selectionFillPluginKey.getState(state); if (!range || range.from === range.to) return DecorationSet.empty; @@ -153,7 +200,7 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) { editor.off("blur", syncSelectionFill); editor.unregisterPlugin(selectionFillPluginKey); }; - }, [editor]); + }, [editor, openLinkInput]); const handleSetLink = () => { if (linkUrl.trim()) { @@ -175,9 +222,7 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) { editor.chain().focus().unsetLink().run(); return; } - const previousUrl = editor.getAttributes("link").href || ""; - setLinkUrl(previousUrl); - setShowLinkInput(true); + openLinkInput(); }; const items = [ @@ -276,12 +321,8 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) { { - if (!editor.isFocused) return false; - const isSelection = from !== to; - if (!isSelection) return false; - return !selectionIncludesBubbleToolbarExcludedNode(state, from, to); - }} + updateDelay={0} + shouldShow={shouldShowBubbleToolbar} > {showLinkInput ? (
setLinkUrl(e.target.value)} @@ -336,7 +378,15 @@ export function BubbleToolbar({ editor, onComment }: BubbleToolbarProps) {