@agent-native/core 0.101.7 → 0.101.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +28 -0
  3. package/corpus/core/docs/content/deployment.mdx +9 -7
  4. package/corpus/core/docs/content/locales/ar-SA/deployment.mdx +9 -7
  5. package/corpus/core/docs/content/locales/de-DE/deployment.mdx +9 -7
  6. package/corpus/core/docs/content/locales/es-ES/deployment.mdx +9 -7
  7. package/corpus/core/docs/content/locales/fr-FR/deployment.mdx +9 -7
  8. package/corpus/core/docs/content/locales/hi-IN/deployment.mdx +9 -7
  9. package/corpus/core/docs/content/locales/ja-JP/deployment.mdx +9 -7
  10. package/corpus/core/docs/content/locales/ko-KR/deployment.mdx +9 -7
  11. package/corpus/core/docs/content/locales/pt-BR/deployment.mdx +9 -7
  12. package/corpus/core/docs/content/locales/zh-CN/deployment.mdx +9 -7
  13. package/corpus/core/docs/content/locales/zh-TW/deployment.mdx +9 -7
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/a2a/client.ts +23 -1
  16. package/corpus/core/src/a2a/handlers.ts +65 -1
  17. package/corpus/core/src/a2a/server.ts +124 -0
  18. package/corpus/core/src/a2a/task-store.ts +253 -0
  19. package/corpus/core/src/a2a/types.ts +26 -0
  20. package/corpus/core/src/agent/production-agent.ts +5 -2
  21. package/corpus/core/src/client/review/ReviewCommentComposer.tsx +9 -1
  22. package/corpus/core/src/client/review/ReviewThreadPanel.tsx +17 -2
  23. package/corpus/core/src/client/settings/SettingsPanel.tsx +41 -21
  24. package/corpus/core/src/client/settings/useBuilderStatus.ts +28 -9
  25. package/corpus/core/src/client/setup-connections/BuilderConnectCard.tsx +2 -7
  26. package/corpus/core/src/mcp/builtin-tools.ts +25 -1
  27. package/corpus/core/src/scripts/call-agent.ts +22 -3
  28. package/corpus/core/src/server/agent-chat-plugin.ts +86 -1
  29. package/corpus/core/src/server/auth.ts +25 -1
  30. package/corpus/core/src/server/builder-browser.ts +353 -1
  31. package/corpus/core/src/server/core-routes-plugin.ts +601 -223
  32. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  33. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +80 -1
  34. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +28 -3
  35. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +5 -1
  36. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +30 -3
  37. package/corpus/templates/calendar/app/components/calendar/OutOfOfficeEvent.tsx +196 -0
  38. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +115 -1
  39. package/corpus/templates/calendar/app/lib/out-of-office.ts +45 -0
  40. package/corpus/templates/calendar/changelog/2026-07-14-out-of-office-blocks-now-sit-behind-meetings-and-event-stack.md +6 -0
  41. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +23 -0
  42. package/corpus/templates/content/AGENTS.md +3 -2
  43. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +87 -0
  44. package/corpus/templates/content/actions/_builder-cms-read-client.ts +173 -25
  45. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
  46. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +427 -18
  47. package/corpus/templates/content/actions/_builder-cms-write-client.ts +17 -73
  48. package/corpus/templates/content/actions/_builder-cms-write-settings.ts +27 -7
  49. package/corpus/templates/content/actions/_builder-source-execution-claim.ts +124 -0
  50. package/corpus/templates/content/actions/_builder-source-execution-preservation.ts +21 -0
  51. package/corpus/templates/content/actions/_builder-source-timings.ts +57 -0
  52. package/corpus/templates/content/actions/_database-source-utils.ts +1041 -154
  53. package/corpus/templates/content/actions/_database-utils.ts +89 -12
  54. package/corpus/templates/content/actions/_preview-document-draft.ts +31 -0
  55. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +309 -0
  56. package/corpus/templates/content/actions/execute-builder-source-batch.ts +120 -49
  57. package/corpus/templates/content/actions/execute-builder-source-execution.ts +608 -264
  58. package/corpus/templates/content/actions/get-preview-document-draft.ts +26 -0
  59. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +550 -0
  60. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +112 -65
  61. package/corpus/templates/content/actions/prepare-builder-source-review.ts +463 -170
  62. package/corpus/templates/content/actions/preview-builder-source-review.ts +99 -0
  63. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +2 -2
  64. package/corpus/templates/content/actions/update-preview-document-draft.ts +153 -0
  65. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +27 -15
  66. package/corpus/templates/content/app/components/editor/MathRenderer.tsx +43 -0
  67. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +260 -4
  68. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +57 -5
  69. package/corpus/templates/content/app/components/editor/body-hydration.ts +27 -0
  70. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2353 -576
  71. package/corpus/templates/content/app/components/editor/database/settings.tsx +13 -10
  72. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +358 -75
  73. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +78 -0
  74. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +124 -29
  75. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +2 -0
  76. package/corpus/templates/content/app/global.css +71 -1
  77. package/corpus/templates/content/app/hooks/use-content-database.ts +68 -0
  78. package/corpus/templates/content/app/hooks/use-documents.ts +53 -4
  79. package/corpus/templates/content/app/i18n/zh-TW.ts +38 -0
  80. package/corpus/templates/content/app/i18n-data.ts +450 -0
  81. package/corpus/templates/content/app/root.tsx +2 -0
  82. package/corpus/templates/content/changelog/2026-07-10-builder-backed-preview-edits-are-preserved-when-article-bodi.md +6 -0
  83. package/corpus/templates/content/changelog/2026-07-10-builder-cms-sources-can-now-be-enabled-for-guarded-staged-or.md +6 -0
  84. package/corpus/templates/content/changelog/2026-07-13-builder-connection-setup-is-now-available-directly-from-conn.md +6 -0
  85. package/corpus/templates/content/changelog/2026-07-13-builder-reviews-now-hide-already-applied-writes-detect-nativ.md +6 -0
  86. package/corpus/templates/content/changelog/2026-07-13-required-builder-publishing-fields-can-now-be-added-directly.md +6 -0
  87. package/corpus/templates/content/changelog/2026-07-13-you-can-now-cancel-a-prepared-builder-update-safely-before-i.md +6 -0
  88. package/corpus/templates/content/changelog/2026-07-14-builder-account-connection-now-remains-available-when-branch.md +6 -0
  89. package/corpus/templates/content/changelog/2026-07-14-builder-backed-articles-now-preserve-fifth-and-sixth-level-h.md +6 -0
  90. package/corpus/templates/content/changelog/2026-07-14-builder-sources-now-review-and-publish-rich-article-updates-.md +6 -0
  91. package/corpus/templates/content/changelog/2026-07-14-builder-writes-now-stop-retries-when-reconciliation-is-needed.md +6 -0
  92. package/corpus/templates/content/changelog/2026-07-14-file-and-media-links-now-validate-before-saving.md +6 -0
  93. package/corpus/templates/content/changelog/2026-07-14-latex-equations-render-in-documents-public-pages-and-exports.md +6 -0
  94. package/corpus/templates/content/package.json +1 -0
  95. package/corpus/templates/content/parity/matrix.md +27 -25
  96. package/corpus/templates/content/parity/matrix.ts +50 -2
  97. package/corpus/templates/content/server/db/schema.ts +47 -0
  98. package/corpus/templates/content/server/plugins/auth.ts +1 -0
  99. package/corpus/templates/content/server/plugins/db.ts +53 -0
  100. package/corpus/templates/content/shared/api.ts +70 -1
  101. package/corpus/templates/content/shared/builder-mdx.ts +705 -15
  102. package/corpus/templates/content/shared/document-export.ts +296 -25
  103. package/corpus/templates/content/shared/inline-math.ts +128 -0
  104. package/corpus/templates/content/shared/math-rendering.ts +50 -0
  105. package/corpus/templates/content/shared/nfm.ts +20 -17
  106. package/corpus/templates/content/vite.config.ts +0 -1
  107. package/corpus/templates/design/app/components/design/ReadOnlyDesignBanner.tsx +52 -7
  108. package/corpus/templates/design/app/components/design/ReviewCommentsPanel.tsx +36 -4
  109. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
  110. package/corpus/templates/design/app/i18n/ar-SA.ts +1 -0
  111. package/corpus/templates/design/app/i18n/de-DE.ts +1 -0
  112. package/corpus/templates/design/app/i18n/en-US.ts +1 -0
  113. package/corpus/templates/design/app/i18n/es-ES.ts +1 -0
  114. package/corpus/templates/design/app/i18n/fr-FR.ts +1 -0
  115. package/corpus/templates/design/app/i18n/hi-IN.ts +1 -0
  116. package/corpus/templates/design/app/i18n/ja-JP.ts +1 -0
  117. package/corpus/templates/design/app/i18n/ko-KR.ts +1 -0
  118. package/corpus/templates/design/app/i18n/pt-BR.ts +1 -0
  119. package/corpus/templates/design/app/i18n/zh-CN.ts +1 -0
  120. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  121. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +8 -0
  122. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +11 -0
  123. package/corpus/templates/design/changelog/2026-07-14-reviewers-can-now-pin-comments-from-the-canvas-toolbar-targe.md +6 -0
  124. package/corpus/templates/design/changelog/2026-07-15-viewer-comment-controls-now-sit-inside-the-read-only-notice-.md +6 -0
  125. package/corpus/templates/design/shared/review-anchor.ts +48 -0
  126. package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +17 -0
  127. package/corpus/templates/mail/AGENTS.md +21 -19
  128. package/corpus/templates/mail/actions/create-attachment-upload.ts +49 -0
  129. package/corpus/templates/mail/changelog/2026-07-14-connected-agents-can-now-securely-upload-local-files-for-mai.md +6 -0
  130. package/corpus/templates/mail/server/handlers/media.ts +84 -61
  131. package/corpus/templates/mail/server/lib/attachment-upload-ticket.ts +234 -0
  132. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -4
  133. package/corpus/templates/mail/server/lib/media-upload.ts +114 -0
  134. package/corpus/templates/mail/server/plugins/auth.ts +9 -1
  135. package/corpus/templates/mail/server/routes/api/media/attachment-upload/[uploadId].put.ts +1 -0
  136. package/corpus/templates/plan/server/plan-mdx.ts +53 -7
  137. package/dist/a2a/client.d.ts +5 -1
  138. package/dist/a2a/client.d.ts.map +1 -1
  139. package/dist/a2a/client.js +18 -1
  140. package/dist/a2a/client.js.map +1 -1
  141. package/dist/a2a/handlers.d.ts.map +1 -1
  142. package/dist/a2a/handlers.js +57 -8
  143. package/dist/a2a/handlers.js.map +1 -1
  144. package/dist/a2a/server.d.ts.map +1 -1
  145. package/dist/a2a/server.js +87 -1
  146. package/dist/a2a/server.js.map +1 -1
  147. package/dist/a2a/task-store.d.ts +27 -0
  148. package/dist/a2a/task-store.d.ts.map +1 -1
  149. package/dist/a2a/task-store.js +216 -0
  150. package/dist/a2a/task-store.js.map +1 -1
  151. package/dist/a2a/types.d.ts +24 -0
  152. package/dist/a2a/types.d.ts.map +1 -1
  153. package/dist/a2a/types.js.map +1 -1
  154. package/dist/agent/production-agent.d.ts +1 -0
  155. package/dist/agent/production-agent.d.ts.map +1 -1
  156. package/dist/agent/production-agent.js +5 -2
  157. package/dist/agent/production-agent.js.map +1 -1
  158. package/dist/client/review/ReviewCommentComposer.d.ts +2 -1
  159. package/dist/client/review/ReviewCommentComposer.d.ts.map +1 -1
  160. package/dist/client/review/ReviewCommentComposer.js +3 -3
  161. package/dist/client/review/ReviewCommentComposer.js.map +1 -1
  162. package/dist/client/review/ReviewThreadPanel.d.ts +9 -1
  163. package/dist/client/review/ReviewThreadPanel.d.ts.map +1 -1
  164. package/dist/client/review/ReviewThreadPanel.js +6 -4
  165. package/dist/client/review/ReviewThreadPanel.js.map +1 -1
  166. package/dist/client/settings/SettingsPanel.d.ts +3 -0
  167. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  168. package/dist/client/settings/SettingsPanel.js +11 -4
  169. package/dist/client/settings/SettingsPanel.js.map +1 -1
  170. package/dist/client/settings/useBuilderStatus.d.ts +8 -4
  171. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  172. package/dist/client/settings/useBuilderStatus.js +19 -6
  173. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  174. package/dist/client/setup-connections/BuilderConnectCard.d.ts.map +1 -1
  175. package/dist/client/setup-connections/BuilderConnectCard.js +2 -5
  176. package/dist/client/setup-connections/BuilderConnectCard.js.map +1 -1
  177. package/dist/collab/awareness.d.ts +2 -2
  178. package/dist/collab/awareness.d.ts.map +1 -1
  179. package/dist/collab/struct-routes.d.ts +1 -1
  180. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  181. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  182. package/dist/mcp/builtin-tools.js +22 -3
  183. package/dist/mcp/builtin-tools.js.map +1 -1
  184. package/dist/notifications/routes.d.ts +3 -3
  185. package/dist/resources/handlers.d.ts +2 -2
  186. package/dist/scripts/call-agent.d.ts +1 -1
  187. package/dist/scripts/call-agent.d.ts.map +1 -1
  188. package/dist/scripts/call-agent.js +19 -1
  189. package/dist/scripts/call-agent.js.map +1 -1
  190. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  191. package/dist/server/agent-chat-plugin.js +71 -2
  192. package/dist/server/agent-chat-plugin.js.map +1 -1
  193. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  194. package/dist/server/auth.d.ts +1 -0
  195. package/dist/server/auth.d.ts.map +1 -1
  196. package/dist/server/auth.js +19 -2
  197. package/dist/server/auth.js.map +1 -1
  198. package/dist/server/builder-browser.d.ts +78 -0
  199. package/dist/server/builder-browser.d.ts.map +1 -1
  200. package/dist/server/builder-browser.js +246 -1
  201. package/dist/server/builder-browser.js.map +1 -1
  202. package/dist/server/core-routes-plugin.d.ts +41 -0
  203. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  204. package/dist/server/core-routes-plugin.js +260 -14
  205. package/dist/server/core-routes-plugin.js.map +1 -1
  206. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  207. package/docs/content/deployment.mdx +9 -7
  208. package/docs/content/locales/ar-SA/deployment.mdx +9 -7
  209. package/docs/content/locales/de-DE/deployment.mdx +9 -7
  210. package/docs/content/locales/es-ES/deployment.mdx +9 -7
  211. package/docs/content/locales/fr-FR/deployment.mdx +9 -7
  212. package/docs/content/locales/hi-IN/deployment.mdx +9 -7
  213. package/docs/content/locales/ja-JP/deployment.mdx +9 -7
  214. package/docs/content/locales/ko-KR/deployment.mdx +9 -7
  215. package/docs/content/locales/pt-BR/deployment.mdx +9 -7
  216. package/docs/content/locales/zh-CN/deployment.mdx +9 -7
  217. package/docs/content/locales/zh-TW/deployment.mdx +9 -7
  218. package/package.json +2 -2
  219. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
@@ -1,4 +1,5 @@
1
- import { deleteCollabState, releaseDoc } from "@agent-native/core/collab";
1
+ import { createHash } from "node:crypto";
2
+
2
3
  import { getDialect, type Dialect } from "@agent-native/core/db";
3
4
  import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm";
4
5
 
@@ -38,6 +39,7 @@ import type {
38
39
  import {
39
40
  builderBlocksHash,
40
41
  builderBlocksToReadableMarkdown,
42
+ builderEntryBlocks,
41
43
  builderEntryToReadableMdxBundle,
42
44
  builderEntryToMdxBundle,
43
45
  builderReadableBodyToBuilderBlocks,
@@ -64,6 +66,7 @@ import {
64
66
  import {
65
67
  BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
66
68
  BUILDER_CMS_BODY_CONTENT_KEY,
69
+ BUILDER_CMS_BODY_LAST_UPDATED_KEY,
67
70
  BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
68
71
  BUILDER_CMS_BODY_READABLE_MAP_KEY,
69
72
  BUILDER_CMS_BODY_SIDECARS_KEY,
@@ -73,6 +76,7 @@ import {
73
76
  builderCmsSourceFieldKey,
74
77
  builderCmsSourceMetadata,
75
78
  builderCmsSourceRowIdentity,
79
+ builderCmsSourceRowIdentityState,
76
80
  type BuilderCmsSourceEntry,
77
81
  type ExistingBuilderSourceRowIdentity,
78
82
  } from "./_builder-cms-source-adapter.js";
@@ -103,6 +107,16 @@ type ContentDatabaseSourceChangeReviewRowDb =
103
107
  type ContentDatabaseSourceExecutionRowDb =
104
108
  typeof schema.contentDatabaseSourceExecutions.$inferSelect;
105
109
 
110
+ function stableBuilderImportId(
111
+ prefix: "builder-doc" | "builder-item" | "builder-row",
112
+ parts: readonly string[],
113
+ ) {
114
+ return `${prefix}_${createHash("sha256")
115
+ .update(parts.join("\0"))
116
+ .digest("base64url")
117
+ .slice(0, 24)}`;
118
+ }
119
+
106
120
  const DEFAULT_SOURCE_CAPABILITIES: ContentDatabaseSourceCapabilities = {
107
121
  canRefresh: true,
108
122
  canCreateChangeSets: true,
@@ -319,7 +333,7 @@ export function serializeSourceField(
319
333
  };
320
334
  }
321
335
 
322
- function serializeSourceRowRecord(
336
+ export function serializeSourceRowRecord(
323
337
  row: ContentDatabaseSourceRecordRowDb,
324
338
  options: { includeHeavyBuilderBodyValues?: boolean } = {},
325
339
  ): ContentDatabaseSourceRow {
@@ -393,6 +407,37 @@ function sourceSnapshotRowSelection(args: {
393
407
  };
394
408
  }
395
409
 
410
+ // Snapshot reads need document titles for change-set summaries. Body text is
411
+ // required only by write/review paths that explicitly request heavy Builder
412
+ // body values; routine list and source reads must not transfer it.
413
+ export function sourceSnapshotDocumentSelection(
414
+ includeHeavyBuilderBodyValues: true,
415
+ ): {
416
+ id: typeof schema.documents.id;
417
+ title: typeof schema.documents.title;
418
+ content: typeof schema.documents.content;
419
+ };
420
+ export function sourceSnapshotDocumentSelection(
421
+ includeHeavyBuilderBodyValues: false,
422
+ ): {
423
+ id: typeof schema.documents.id;
424
+ title: typeof schema.documents.title;
425
+ };
426
+ export function sourceSnapshotDocumentSelection(
427
+ includeHeavyBuilderBodyValues: boolean,
428
+ ) {
429
+ return includeHeavyBuilderBodyValues
430
+ ? {
431
+ id: schema.documents.id,
432
+ title: schema.documents.title,
433
+ content: schema.documents.content,
434
+ }
435
+ : {
436
+ id: schema.documents.id,
437
+ title: schema.documents.title,
438
+ };
439
+ }
440
+
396
441
  export function sourceValuesForSnapshot(
397
442
  sourceValues: Record<string, DocumentPropertyValue>,
398
443
  options: { includeHeavyBuilderBodyValues?: boolean } = {},
@@ -468,6 +513,47 @@ function serializeExecution(
468
513
  };
469
514
  }
470
515
 
516
+ /**
517
+ * A blocked execution with neither an attempt token nor a response proves it
518
+ * stopped before the Builder dispatch claim. This includes a blocked dry run
519
+ * and a validated plan stopped by the final live preflight. Any other state or
520
+ * remote evidence stays frozen until normal reconciliation resolves it.
521
+ */
522
+ export function builderExecutionIsProvablyLocallyBlockedUnsent(execution: {
523
+ state: string;
524
+ payloadJson: string;
525
+ attemptToken?: string | null;
526
+ }) {
527
+ const payload = parseObject<Record<string, unknown>>(execution.payloadJson);
528
+ return (
529
+ execution.state === "blocked" &&
530
+ !execution.attemptToken &&
531
+ !(payload?.response && typeof payload.response === "object")
532
+ );
533
+ }
534
+
535
+ export function canRefreshLocallyBlockedBuilderReview(
536
+ executions: Array<{
537
+ state: string;
538
+ payloadJson: string;
539
+ attemptToken?: string | null;
540
+ }>,
541
+ ) {
542
+ return (
543
+ executions.length === 0 ||
544
+ executions.every(builderExecutionIsProvablyLocallyBlockedUnsent)
545
+ );
546
+ }
547
+
548
+ export const CANCELLED_BUILDER_EXECUTION_SUMMARY =
549
+ "Cancelled before Builder dispatch.";
550
+ export const CANCELLED_BUILDER_REVIEW_NOTE_PREFIX =
551
+ "Cancelled prepared Builder update before dispatch";
552
+
553
+ export function builderReferenceIdSourceValueKey(sourceFieldKey: string) {
554
+ return `__agent_native_builder_reference_id:${sourceFieldKey}`;
555
+ }
556
+
471
557
  function riskRank(level: ContentDatabaseSourceRiskLevel) {
472
558
  if (level === "high") return 3;
473
559
  if (level === "medium") return 2;
@@ -609,7 +695,8 @@ const BUILDER_BODY_HYDRATION_OPEN_PRIORITY = 0;
609
695
  const BUILDER_BODY_HYDRATION_BATCH_LIMIT = 25;
610
696
  const BUILDER_BODY_HYDRATION_PROCESS_CONCURRENCY = 6;
611
697
  const BUILDER_BODY_HYDRATION_MAX_ATTEMPTS = 5;
612
- const BUILDER_BODY_HYDRATION_CODEC_VERSION = "readable-native-images-v5";
698
+ const BUILDER_BODY_HYDRATION_CODEC_VERSION =
699
+ "readable-native-images-authoritative-raw-baseline-v9";
613
700
  const BUILDER_CMS_REFRESH_INITIAL_PAGES = 1;
614
701
  const BUILDER_BODY_NOT_AVAILABLE_ERROR = "body not yet available from Builder";
615
702
 
@@ -686,12 +773,27 @@ async function builderBodySnapshotForEntry(entry: BuilderCmsSourceEntry) {
686
773
  return {
687
774
  content: readableBundle.mdx.body,
688
775
  losslessContent: losslessBundle.mdx.body,
689
- blocksHash: builderBlocksHash(losslessBundle.blocks),
776
+ blocksHash: builderAuthoritativeRawBodyHash({
777
+ entry,
778
+ generatedBlocks: losslessBundle.blocks,
779
+ }),
690
780
  readableMapJson: null,
691
781
  sidecarsJson: JSON.stringify(sidecars),
692
782
  };
693
783
  }
694
784
 
785
+ export function builderAuthoritativeRawBodyHash(args: {
786
+ entry: BuilderCmsSourceEntry;
787
+ generatedBlocks: unknown[];
788
+ }) {
789
+ const rawBlocks = args.entry.rawEntry
790
+ ? builderEntryBlocks(args.entry.rawEntry)
791
+ : [];
792
+ return builderBlocksHash(
793
+ rawBlocks.length > 0 ? rawBlocks : args.generatedBlocks,
794
+ );
795
+ }
796
+
695
797
  export function builderBodyHydrationVersion(entry: BuilderCmsSourceEntry) {
696
798
  const hash = stringSourceValue(
697
799
  entry.sourceValues,
@@ -700,6 +802,15 @@ export function builderBodyHydrationVersion(entry: BuilderCmsSourceEntry) {
700
802
  return `${hash ?? entry.updatedAt ?? entry.id}:${BUILDER_BODY_HYDRATION_CODEC_VERSION}`;
701
803
  }
702
804
 
805
+ export function builderBodyUnavailableVersion(entry: BuilderCmsSourceEntry) {
806
+ const lastUpdated =
807
+ stringSourceValue(entry.sourceValues, BUILDER_CMS_BODY_LAST_UPDATED_KEY) ??
808
+ stringSourceValue(entry.sourceValues, "lastUpdated") ??
809
+ entry.updatedAt ??
810
+ entry.id;
811
+ return `${lastUpdated}:${BUILDER_BODY_HYDRATION_CODEC_VERSION}`;
812
+ }
813
+
703
814
  function normalizeHydrationLimit(limit: number | null | undefined) {
704
815
  if (typeof limit !== "number" || !Number.isFinite(limit)) {
705
816
  return BUILDER_BODY_HYDRATION_BATCH_LIMIT;
@@ -800,9 +911,14 @@ function builderStoredBodyIsStale(args: {
800
911
  >;
801
912
  entry: BuilderCmsSourceEntry;
802
913
  }) {
914
+ const expectedVersion =
915
+ args.item.bodyHydrationStatus === "unavailable"
916
+ ? builderBodyUnavailableVersion(args.entry)
917
+ : builderBodyHydrationVersion(args.entry);
803
918
  return (
804
- args.item.bodyHydrationStatus !== "hydrated" ||
805
- args.item.bodyHydrationVersion !== builderBodyHydrationVersion(args.entry)
919
+ (args.item.bodyHydrationStatus !== "hydrated" &&
920
+ args.item.bodyHydrationStatus !== "unavailable") ||
921
+ args.item.bodyHydrationVersion !== expectedVersion
806
922
  );
807
923
  }
808
924
 
@@ -813,7 +929,7 @@ function builderEntryHasBodyContent(entry: BuilderCmsSourceEntry | null) {
813
929
  )?.trim();
814
930
  }
815
931
 
816
- async function readableBuilderBodyFromStoredLossless(args: {
932
+ async function builderBodySnapshotFromStoredLossless(args: {
817
933
  losslessContent: string | null;
818
934
  sidecarsJson: string | null;
819
935
  }) {
@@ -825,12 +941,60 @@ async function readableBuilderBodyFromStoredLossless(args: {
825
941
  sidecars,
826
942
  );
827
943
  if (blocks.length === 0) return null;
828
- return builderBlocksToReadableMarkdown(blocks);
944
+ return {
945
+ content: await builderBlocksToReadableMarkdown(blocks),
946
+ blocksHash: builderBlocksHash(blocks),
947
+ };
948
+ }
949
+
950
+ export function builderBodyHydrationNeedsLiveBaseline(args: {
951
+ bodyHydrationVersion: string | null | undefined;
952
+ storedBlocksHash: string | null | undefined;
953
+ rebuiltBlocksHash: string | null | undefined;
954
+ }) {
955
+ if (!args.storedBlocksHash) return false;
956
+ if (
957
+ args.bodyHydrationVersion &&
958
+ !args.bodyHydrationVersion.endsWith(
959
+ `:${BUILDER_BODY_HYDRATION_CODEC_VERSION}`,
960
+ )
961
+ ) {
962
+ return true;
963
+ }
964
+ return Boolean(
965
+ !args.rebuiltBlocksHash || args.storedBlocksHash !== args.rebuiltBlocksHash,
966
+ );
967
+ }
968
+
969
+ export function builderBodyHydrationIsCodecMigration(
970
+ bodyHydrationVersion: string | null | undefined,
971
+ ) {
972
+ return Boolean(
973
+ bodyHydrationVersion &&
974
+ !bodyHydrationVersion.endsWith(`:${BUILDER_BODY_HYDRATION_CODEC_VERSION}`),
975
+ );
976
+ }
977
+
978
+ export function builderBodyHydrationCanAdoptSameVersionVariant(args: {
979
+ documentContent: string | null | undefined;
980
+ persistedContent: string | null | undefined;
981
+ }) {
982
+ return (
983
+ args.documentContent !== undefined &&
984
+ normalizeBuilderBodyBaselineContent(args.documentContent) ===
985
+ normalizeBuilderBodyBaselineContent(args.persistedContent)
986
+ );
829
987
  }
830
988
 
831
- async function refreshBuilderBodySourceValuesFromStoredLossless(
989
+ export async function refreshBuilderBodySourceValuesFromStoredLossless(
832
990
  entry: BuilderCmsSourceEntry,
833
991
  ) {
992
+ if (entry.rawEntry && builderEntryBlocks(entry.rawEntry).length > 0) {
993
+ // A fresh single-entry Builder read is the authoritative preflight
994
+ // representation. Never replace its raw block hash with a hash rebuilt
995
+ // from generated lossless MDX.
996
+ return entry;
997
+ }
834
998
  const losslessContent = stringSourceValue(
835
999
  entry.sourceValues,
836
1000
  BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
@@ -839,20 +1003,51 @@ async function refreshBuilderBodySourceValuesFromStoredLossless(
839
1003
  entry.sourceValues,
840
1004
  BUILDER_CMS_BODY_SIDECARS_KEY,
841
1005
  );
842
- const content = await readableBuilderBodyFromStoredLossless({
1006
+ const snapshot = await builderBodySnapshotFromStoredLossless({
843
1007
  losslessContent,
844
1008
  sidecarsJson,
845
1009
  });
846
- if (!content) return entry;
1010
+ if (!snapshot) return entry;
847
1011
  return {
848
1012
  ...entry,
849
1013
  sourceValues: {
850
1014
  ...entry.sourceValues,
851
- [BUILDER_CMS_BODY_CONTENT_KEY]: content,
1015
+ [BUILDER_CMS_BODY_CONTENT_KEY]: snapshot.content,
1016
+ [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: snapshot.blocksHash,
852
1017
  },
853
1018
  };
854
1019
  }
855
1020
 
1021
+ function builderTimestampEpoch(value: string | null | undefined) {
1022
+ if (!value?.trim()) return null;
1023
+ const numeric = Number(value);
1024
+ if (Number.isFinite(numeric)) return numeric;
1025
+ const parsed = Date.parse(value);
1026
+ return Number.isFinite(parsed) ? parsed : null;
1027
+ }
1028
+
1029
+ export function builderBodyBaselineHasSameVersionConflict(args: {
1030
+ persistedBlocksHash: string | null | undefined;
1031
+ incomingBlocksHash: string | null | undefined;
1032
+ persistedLastUpdated: string | null | undefined;
1033
+ incomingLastUpdated: string | null | undefined;
1034
+ }) {
1035
+ if (
1036
+ !args.persistedBlocksHash ||
1037
+ !args.incomingBlocksHash ||
1038
+ args.persistedBlocksHash === args.incomingBlocksHash
1039
+ ) {
1040
+ return false;
1041
+ }
1042
+ const persistedEpoch = builderTimestampEpoch(args.persistedLastUpdated);
1043
+ const incomingEpoch = builderTimestampEpoch(args.incomingLastUpdated);
1044
+ return (
1045
+ persistedEpoch !== null &&
1046
+ incomingEpoch !== null &&
1047
+ persistedEpoch === incomingEpoch
1048
+ );
1049
+ }
1050
+
856
1051
  function builderEntryFromSourceRow(args: {
857
1052
  row: Pick<
858
1053
  ContentDatabaseSourceRecordRowDb,
@@ -911,8 +1106,12 @@ async function readBuilderEntryWithLiveBodyFromSourceRow(args: {
911
1106
  ...liveEntry.sourceValues,
912
1107
  },
913
1108
  };
914
- const refreshedEntry = await refreshBuilderBodySourceValuesFromStoredLossless(
915
- await withBuilderBodySourceValues(entryWithStoredValues),
1109
+ // This entry came from a fresh Builder response. Preserve the block hash
1110
+ // computed from those authoritative raw blocks; rebuilding the generated
1111
+ // lossless MDX can normalize block details and produce a different hash than
1112
+ // the execute-time live preflight sees for the same response.
1113
+ const refreshedEntry = await withBuilderBodySourceValues(
1114
+ entryWithStoredValues,
916
1115
  );
917
1116
  return builderEntryHasBodyContent(refreshedEntry) ? refreshedEntry : null;
918
1117
  }
@@ -1068,15 +1267,84 @@ export async function enqueueBuilderBodyHydrationForItems(args: {
1068
1267
  now: string;
1069
1268
  }) {
1070
1269
  if (!args.builderEntriesByDocumentId?.size) return;
1270
+ const persistedStateByDocumentId = new Map<
1271
+ string,
1272
+ {
1273
+ entry: BuilderCmsSourceEntry;
1274
+ bodyHydrationStatus: string | null;
1275
+ bodyHydrationVersion: string | null;
1276
+ documentContent: string | null;
1277
+ }
1278
+ >();
1279
+ const documentIds = args.items.map((item) => item.document.id);
1280
+ for (const documentIdChunk of chunks(documentIds, idChunkSize())) {
1281
+ const rows = await getDb()
1282
+ .select({
1283
+ documentId: schema.contentDatabaseSourceRows.documentId,
1284
+ sourceRowId: schema.contentDatabaseSourceRows.sourceRowId,
1285
+ sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson,
1286
+ lastSourceUpdatedAt:
1287
+ schema.contentDatabaseSourceRows.lastSourceUpdatedAt,
1288
+ bodyHydrationStatus: schema.contentDatabaseItems.bodyHydrationStatus,
1289
+ bodyHydrationVersion: schema.contentDatabaseItems.bodyHydrationVersion,
1290
+ documentContent: schema.documents.content,
1291
+ })
1292
+ .from(schema.contentDatabaseSourceRows)
1293
+ .innerJoin(
1294
+ schema.contentDatabaseItems,
1295
+ eq(
1296
+ schema.contentDatabaseItems.id,
1297
+ schema.contentDatabaseSourceRows.databaseItemId,
1298
+ ),
1299
+ )
1300
+ .innerJoin(
1301
+ schema.documents,
1302
+ eq(schema.documents.id, schema.contentDatabaseSourceRows.documentId),
1303
+ )
1304
+ .where(
1305
+ and(
1306
+ eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId),
1307
+ inArray(schema.contentDatabaseSourceRows.documentId, documentIdChunk),
1308
+ ),
1309
+ );
1310
+ for (const row of rows) {
1311
+ const entry = builderEntryFromSourceRow({
1312
+ row,
1313
+ sourceTable: args.sourceTable,
1314
+ fallbackTitle: row.sourceRowId ?? row.documentId,
1315
+ });
1316
+ if (entry) {
1317
+ persistedStateByDocumentId.set(row.documentId, {
1318
+ entry,
1319
+ bodyHydrationStatus: row.bodyHydrationStatus,
1320
+ bodyHydrationVersion: row.bodyHydrationVersion,
1321
+ documentContent: row.documentContent,
1322
+ });
1323
+ }
1324
+ }
1325
+ }
1071
1326
  const requests: BuilderBodyHydrationEnqueueRequest[] = [];
1072
1327
  for (const item of args.items) {
1073
1328
  const entry = args.builderEntriesByDocumentId.get(item.document.id);
1074
1329
  if (!entry) continue;
1330
+ const persistedState = persistedStateByDocumentId.get(item.document.id);
1331
+ const persistedEntry = persistedState?.entry ?? entry;
1332
+ const bodyHydrationStatus =
1333
+ persistedState?.bodyHydrationStatus ?? item.bodyHydration?.status;
1334
+ const bodyHydrationVersion =
1335
+ persistedState?.bodyHydrationVersion ?? item.bodyHydration?.version;
1336
+ const documentContent =
1337
+ persistedState?.documentContent ?? item.document.content;
1338
+ const expectedVersion =
1339
+ bodyHydrationStatus === "unavailable"
1340
+ ? builderBodyUnavailableVersion(persistedEntry)
1341
+ : builderBodyHydrationVersion(persistedEntry);
1075
1342
  if (
1076
- item.bodyHydration?.status === "hydrated" &&
1077
- item.bodyHydration.version === builderBodyHydrationVersion(entry) &&
1078
- !isEffectivelyEmptyDocumentContent(item.document.content) &&
1079
- !builderBodyIsRawPlaceholderOnly(item.document.content)
1343
+ (bodyHydrationStatus === "unavailable" ||
1344
+ (bodyHydrationStatus === "hydrated" &&
1345
+ !isEffectivelyEmptyDocumentContent(documentContent) &&
1346
+ !builderBodyIsRawPlaceholderOnly(documentContent))) &&
1347
+ bodyHydrationVersion === expectedVersion
1080
1348
  ) {
1081
1349
  continue;
1082
1350
  }
@@ -1197,11 +1465,16 @@ async function processBuilderBodyHydrationJob(
1197
1465
  preloaded?: {
1198
1466
  sourceRow?: ContentDatabaseSourceRecordRowDb | null;
1199
1467
  documentContent?: string | null;
1468
+ bodyHydrationVersion?: string | null;
1200
1469
  },
1201
1470
  ) {
1202
1471
  const db = getDb();
1203
1472
  const entry = parseHydrationEntry(row);
1204
1473
  if (!entry) throw new Error("Builder body hydration entry is missing.");
1474
+ const queuedBlocksHash = stringSourceValue(
1475
+ entry.sourceValues,
1476
+ BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
1477
+ );
1205
1478
  let activeSourceEntryJson = row.sourceEntryJson;
1206
1479
  let entryWithBody = await refreshBuilderBodySourceValuesFromStoredLossless(
1207
1480
  await withBuilderBodySourceValues(entry),
@@ -1227,6 +1500,73 @@ async function processBuilderBodyHydrationJob(
1227
1500
  parseObject<Record<string, DocumentPropertyValue>>(
1228
1501
  sourceRow?.sourceValuesJson ?? "{}",
1229
1502
  ) ?? {};
1503
+ const rebuiltBlocksHash = stringSourceValue(
1504
+ entryWithBody.sourceValues,
1505
+ BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
1506
+ );
1507
+ const storedBlocksHash =
1508
+ queuedBlocksHash ??
1509
+ stringSourceValue(sourceValues, BUILDER_CMS_BODY_BLOCKS_HASH_KEY);
1510
+ const isCodecMigration = builderBodyHydrationIsCodecMigration(
1511
+ preloaded?.bodyHydrationVersion,
1512
+ );
1513
+ if (
1514
+ builderBodyHydrationNeedsLiveBaseline({
1515
+ bodyHydrationVersion: preloaded?.bodyHydrationVersion,
1516
+ storedBlocksHash,
1517
+ rebuiltBlocksHash,
1518
+ })
1519
+ ) {
1520
+ if (!sourceRow) {
1521
+ throw new Error(
1522
+ "Builder body baseline migration requires a linked source row.",
1523
+ );
1524
+ }
1525
+ const liveEntry = await readBuilderEntryWithLiveBodyFromSourceRow({
1526
+ row: sourceRow,
1527
+ sourceTable: row.sourceTable,
1528
+ fallbackTitle: entry.title,
1529
+ });
1530
+ if (!liveEntry) {
1531
+ throw new Error(
1532
+ "Builder body baseline migration could not read a fresh remote body; retry the refresh before reviewing or publishing.",
1533
+ );
1534
+ }
1535
+ entryWithBody = liveEntry;
1536
+ }
1537
+ const incomingBlocksHash = stringSourceValue(
1538
+ entryWithBody.sourceValues,
1539
+ BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
1540
+ );
1541
+ const persistedBaselineLastUpdated =
1542
+ stringSourceValue(sourceValues, BUILDER_CMS_BODY_LAST_UPDATED_KEY) ??
1543
+ stringSourceValue(sourceValues, "lastUpdated");
1544
+ const incomingLastUpdated =
1545
+ stringSourceValue(entryWithBody.sourceValues, "lastUpdated") ??
1546
+ entryWithBody.updatedAt;
1547
+ if (
1548
+ !isCodecMigration &&
1549
+ builderBodyBaselineHasSameVersionConflict({
1550
+ persistedBlocksHash: stringSourceValue(
1551
+ sourceValues,
1552
+ BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
1553
+ ),
1554
+ incomingBlocksHash,
1555
+ persistedLastUpdated: persistedBaselineLastUpdated,
1556
+ incomingLastUpdated,
1557
+ }) &&
1558
+ !builderBodyHydrationCanAdoptSameVersionVariant({
1559
+ documentContent: preloaded?.documentContent,
1560
+ persistedContent: stringSourceValue(
1561
+ sourceValues,
1562
+ BUILDER_CMS_BODY_CONTENT_KEY,
1563
+ ),
1564
+ })
1565
+ ) {
1566
+ throw new Error(
1567
+ "Builder returned inconsistent body variants for the same lastUpdated value; the stored baseline and local document were preserved for retry.",
1568
+ );
1569
+ }
1230
1570
  let nextValues = {
1231
1571
  ...sourceValues,
1232
1572
  ...entryWithBody.sourceValues,
@@ -1355,9 +1695,11 @@ async function processBuilderBodyHydrationJob(
1355
1695
  await tx
1356
1696
  .update(schema.contentDatabaseItems)
1357
1697
  .set({
1358
- bodyHydrationStatus: "error",
1698
+ bodyHydrationStatus: "unavailable",
1359
1699
  bodyHydrationAttemptedAt: now,
1360
- bodyHydrationError: BUILDER_BODY_NOT_AVAILABLE_ERROR,
1700
+ bodyHydrationError: null,
1701
+ bodyHydrationVersion:
1702
+ builderBodyUnavailableVersion(entryWithBody),
1361
1703
  updatedAt: now,
1362
1704
  })
1363
1705
  .where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
@@ -1570,14 +1912,11 @@ async function processBuilderBodyHydrationJob(
1570
1912
  })
1571
1913
  .where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
1572
1914
  });
1573
- if (wroteBody) {
1574
- try {
1575
- await deleteCollabState(row.documentId);
1576
- releaseDoc(row.documentId);
1577
- } catch {
1578
- // Non-fatal: the updated document row still lets open editors reconcile.
1579
- }
1580
- }
1915
+ // Keep persisted and in-memory Yjs state intact. The SQL content + updatedAt
1916
+ // written above are authoritative; an open full-page editor reconciles that
1917
+ // snapshot into its Y.Doc. Deleting collab state here can race a connected
1918
+ // client, briefly bootstrap an empty fragment, and let that stale client
1919
+ // repersist the empty state over the newly hydrated Builder body.
1581
1920
  }
1582
1921
 
1583
1922
  async function enqueueStaleBuilderBodyHydrationForOpenDocument(args: {
@@ -1796,6 +2135,29 @@ export async function processBuilderBodyHydrationQueue(args: {
1796
2135
  const sourceRowsByItemId = new Map(
1797
2136
  sourceRows.map((row) => [row.databaseItemId, row]),
1798
2137
  );
2138
+ const itemVersions =
2139
+ claimedJobs.length > 0
2140
+ ? (
2141
+ await Promise.all(
2142
+ chunks(
2143
+ claimedJobs.map((job) => job.databaseItemId),
2144
+ idChunkSize(),
2145
+ ).map((idChunk) =>
2146
+ db
2147
+ .select({
2148
+ id: schema.contentDatabaseItems.id,
2149
+ bodyHydrationVersion:
2150
+ schema.contentDatabaseItems.bodyHydrationVersion,
2151
+ })
2152
+ .from(schema.contentDatabaseItems)
2153
+ .where(inArray(schema.contentDatabaseItems.id, idChunk)),
2154
+ ),
2155
+ )
2156
+ ).flat()
2157
+ : [];
2158
+ const bodyHydrationVersionByItemId = new Map(
2159
+ itemVersions.map((item) => [item.id, item.bodyHydrationVersion]),
2160
+ );
1799
2161
  const documents =
1800
2162
  claimedJobs.length > 0
1801
2163
  ? (
@@ -1828,6 +2190,8 @@ export async function processBuilderBodyHydrationQueue(args: {
1828
2190
  if (delayMs > 0) await sleep(delayMs);
1829
2191
  await processBuilderBodyHydrationJob(job, attemptNow, {
1830
2192
  sourceRow: sourceRowsByItemId.get(job.databaseItemId) ?? null,
2193
+ bodyHydrationVersion:
2194
+ bodyHydrationVersionByItemId.get(job.databaseItemId) ?? null,
1831
2195
  documentContent: documentContentById.has(job.documentId)
1832
2196
  ? (documentContentById.get(job.documentId) ?? null)
1833
2197
  : undefined,
@@ -1935,6 +2299,8 @@ export async function withBuilderBodySourceValues(
1935
2299
  [BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY]: snapshot.losslessContent,
1936
2300
  [BUILDER_CMS_BODY_READABLE_MAP_KEY]: snapshot.readableMapJson,
1937
2301
  [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: snapshot.blocksHash,
2302
+ [BUILDER_CMS_BODY_LAST_UPDATED_KEY]:
2303
+ stringSourceValue(entry.sourceValues, "lastUpdated") ?? entry.updatedAt,
1938
2304
  [BUILDER_CMS_BODY_SIDECARS_KEY]: snapshot.sidecarsJson,
1939
2305
  },
1940
2306
  };
@@ -1973,9 +2339,17 @@ export async function builderBodyChangeForLocalContent(args: {
1973
2339
  const localContent = args.localContent ?? "";
1974
2340
  if (!currentHash && !currentContent && !localContent.trim()) return null;
1975
2341
  if (!currentContent?.trim() && !losslessContent?.trim()) return null;
2342
+ // Native media is converter-owned output. Re-run it even when the editable
2343
+ // Markdown text is byte-for-byte unchanged: a converter upgrade can turn a
2344
+ // legacy Text block containing a Markdown image into a real Builder Image
2345
+ // (or emit a native Video) without changing the document text at all.
2346
+ const usesCurrentMediaConverter =
2347
+ /!\[[^\]]*\]\(\s*https?:\/\//i.test(localContent) ||
2348
+ /<(?:img|video)\b/i.test(localContent);
1976
2349
  const normalizedLocalContent =
1977
2350
  normalizeBuilderBodyBaselineContent(localContent);
1978
2351
  if (
2352
+ !usesCurrentMediaConverter &&
1979
2353
  normalizedLocalContent &&
1980
2354
  normalizedLocalContent ===
1981
2355
  normalizeBuilderBodyBaselineContent(currentContent)
@@ -1983,6 +2357,7 @@ export async function builderBodyChangeForLocalContent(args: {
1983
2357
  return null;
1984
2358
  }
1985
2359
  if (
2360
+ !usesCurrentMediaConverter &&
1986
2361
  normalizedLocalContent &&
1987
2362
  normalizedLocalContent ===
1988
2363
  normalizeBuilderBodyBaselineContent(losslessContent)
@@ -2005,8 +2380,15 @@ export async function builderBodyChangeForLocalContent(args: {
2005
2380
  }
2006
2381
 
2007
2382
  try {
2008
- const proposed =
2009
- losslessContent && !localContent.includes("<Builder")
2383
+ const proposed = usesCurrentMediaConverter
2384
+ ? {
2385
+ blocks: await builderMdxBodyToBuilderBlocks(
2386
+ normalizeUnsourcedBuilderCreateMdx(localContent),
2387
+ sidecars,
2388
+ ),
2389
+ warnings: [] as string[],
2390
+ }
2391
+ : losslessContent && !localContent.includes("<Builder")
2010
2392
  ? await builderReadableBodyToBuilderBlocks({
2011
2393
  localContent,
2012
2394
  losslessContent,
@@ -2062,6 +2444,98 @@ export async function builderBodyChangeForLocalContent(args: {
2062
2444
  }
2063
2445
  }
2064
2446
 
2447
+ function htmlMediaAttribute(tag: string, name: string) {
2448
+ const match = tag.match(
2449
+ new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"),
2450
+ );
2451
+ return match?.[1] ?? match?.[2] ?? null;
2452
+ }
2453
+
2454
+ function normalizeUnsourcedBuilderCreateMdx(content: string) {
2455
+ return content.replace(/<img\b[^>]*\/?\s*>/gi, (tag) => {
2456
+ const src = htmlMediaAttribute(tag, "src");
2457
+ if (!src) return tag;
2458
+ const alt = htmlMediaAttribute(tag, "alt") ?? "";
2459
+ return `![${alt.replace(/]/g, "\\]")}](${src.replace(/\)/g, "%29")})`;
2460
+ });
2461
+ }
2462
+
2463
+ export async function builderBodyChangeForUnsourcedLocalCreate(args: {
2464
+ localContent: string | null | undefined;
2465
+ }): Promise<ContentDatabaseSourceBodyChange | null> {
2466
+ const localContent = args.localContent ?? "";
2467
+ if (!localContent.trim()) return null;
2468
+ const sidecarsJson = "{}";
2469
+
2470
+ try {
2471
+ const blocks = await builderMdxBodyToBuilderBlocks(
2472
+ normalizeUnsourcedBuilderCreateMdx(localContent),
2473
+ {},
2474
+ );
2475
+ return {
2476
+ summary: "Builder draft body ready to create.",
2477
+ currentExcerpt: null,
2478
+ proposedExcerpt: bodyExcerpt(localContent),
2479
+ currentHash: null,
2480
+ proposedHash: builderBlocksHash(blocks),
2481
+ proposedContent: localContent,
2482
+ proposedBlocksJson: JSON.stringify(blocks),
2483
+ sidecarsJson,
2484
+ warnings: [],
2485
+ };
2486
+ } catch (error) {
2487
+ const message =
2488
+ error instanceof Error
2489
+ ? error.message
2490
+ : "Builder body could not be converted.";
2491
+ return {
2492
+ summary: "Builder draft body needs attention before create.",
2493
+ currentExcerpt: null,
2494
+ proposedExcerpt: bodyExcerpt(localContent),
2495
+ currentHash: null,
2496
+ proposedHash: null,
2497
+ proposedContent: localContent,
2498
+ proposedBlocksJson: null,
2499
+ sidecarsJson,
2500
+ warnings: [message],
2501
+ };
2502
+ }
2503
+ }
2504
+
2505
+ export async function builderBodyChangeForSourceSnapshotDocument(args: {
2506
+ row?: {
2507
+ documentId: string;
2508
+ sourceRowId: string;
2509
+ sourceQualifiedId: string;
2510
+ provenance: string | null;
2511
+ sourceValuesJson: string;
2512
+ };
2513
+ isHydrated: boolean;
2514
+ allowUnsourcedCreate: boolean;
2515
+ localContent: string | null | undefined;
2516
+ }): Promise<ContentDatabaseSourceBodyChange | null> {
2517
+ if (args.row) {
2518
+ const identity = builderCmsSourceRowIdentityState({ row: args.row });
2519
+ if (identity.isSyntheticFixture) {
2520
+ // Fixture rows are local placeholders, not imported Builder baselines.
2521
+ // Execution resolves their synthetic identity to create_draft, so their
2522
+ // local body must follow the same create path even before hydration.
2523
+ return builderBodyChangeForUnsourcedLocalCreate({
2524
+ localContent: args.localContent,
2525
+ });
2526
+ }
2527
+ if (!args.isHydrated) return null;
2528
+ return builderBodyChangeForLocalContent({
2529
+ row: args.row,
2530
+ localContent: args.localContent,
2531
+ });
2532
+ }
2533
+ if (!args.allowUnsourcedCreate) return null;
2534
+ return builderBodyChangeForUnsourcedLocalCreate({
2535
+ localContent: args.localContent,
2536
+ });
2537
+ }
2538
+
2065
2539
  export function buildBuilderLocalOutboundChangeSets(args: {
2066
2540
  source: ContentDatabaseSourceRowDb;
2067
2541
  rowRows: ContentDatabaseSourceRecordRowDb[];
@@ -2078,6 +2552,8 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2078
2552
  sourceFieldLabel: string;
2079
2553
  propertyType?: DocumentProperty["definition"]["type"] | null;
2080
2554
  propertyOptions?: DocumentPropertyOptions;
2555
+ sourceFieldType?: string;
2556
+ sourceFieldModel?: string;
2081
2557
  }>;
2082
2558
  // Row-union scoping (multi-source). Documents owned by ANOTHER source must
2083
2559
  // never be create candidates for this one — each row belongs to exactly one
@@ -2093,6 +2569,12 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2093
2569
  taggedSourceByDocumentId?: Map<string, string>;
2094
2570
  bodyChangeByDocumentId?: Map<string, ContentDatabaseSourceBodyChange>;
2095
2571
  sourceImportedDocumentIds?: Set<string>;
2572
+ /**
2573
+ * Rejected change-sets that are provably cancellations of a prepared,
2574
+ * pre-dispatch Builder gate. Their exact snapshot remains durable audit
2575
+ * history and suppresses only the byte-equivalent local-vs-source diff.
2576
+ */
2577
+ cancelledRejectedChangeSetIds?: Set<string>;
2096
2578
  }): ContentDatabaseSourceChangeSet[] {
2097
2579
  if (normalizeSourceType(args.source.sourceType) !== "builder-cms") return [];
2098
2580
 
@@ -2103,6 +2585,106 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2103
2585
  normalizeCapabilities(args.source.capabilitiesJson).liveWritesEnabled ===
2104
2586
  true;
2105
2587
  const pending: ContentDatabaseSourceChangeSet[] = [];
2588
+
2589
+ const sameCancelledSnapshot = (
2590
+ candidate: Pick<
2591
+ ContentDatabaseSourceChangeSet,
2592
+ | "databaseItemId"
2593
+ | "documentId"
2594
+ | "kind"
2595
+ | "direction"
2596
+ | "pushMode"
2597
+ | "fieldChanges"
2598
+ | "bodyChange"
2599
+ >,
2600
+ ) =>
2601
+ args.storedChangeSets.some(
2602
+ (stored) =>
2603
+ stored.state === "rejected" &&
2604
+ args.cancelledRejectedChangeSetIds?.has(stored.id) === true &&
2605
+ stored.databaseItemId === candidate.databaseItemId &&
2606
+ stored.documentId === candidate.documentId &&
2607
+ stored.kind === candidate.kind &&
2608
+ stored.direction === candidate.direction &&
2609
+ stored.pushMode === candidate.pushMode &&
2610
+ stableValueString(stored.fieldChanges) ===
2611
+ stableValueString(candidate.fieldChanges) &&
2612
+ stableValueString(stored.bodyChange) ===
2613
+ stableValueString(candidate.bodyChange),
2614
+ );
2615
+
2616
+ const reviewableBuilderValue = (
2617
+ field: NonNullable<typeof args.writableFields>[number],
2618
+ localValue: unknown,
2619
+ ): Pick<
2620
+ ContentDatabaseSourceFieldChange,
2621
+ "proposedValue" | "builderValueJson"
2622
+ > => {
2623
+ const type = field.propertyType;
2624
+ const options = field.propertyOptions?.options ?? [];
2625
+ const optionNameById = new Map(
2626
+ options.map((option) => [option.id, option.name]),
2627
+ );
2628
+ const sourceType = field.sourceFieldType?.trim().toLowerCase();
2629
+ if (sourceType === "reference") {
2630
+ const id = typeof localValue === "string" ? localValue.trim() : "";
2631
+ const label = optionNameById.get(id) ?? id;
2632
+ const builderValue =
2633
+ id && field.sourceFieldModel
2634
+ ? {
2635
+ "@type": "@builder.io/core:Reference",
2636
+ id,
2637
+ model: field.sourceFieldModel,
2638
+ }
2639
+ : null;
2640
+ return {
2641
+ proposedValue: label || null,
2642
+ builderValueJson: JSON.stringify(builderValue),
2643
+ };
2644
+ }
2645
+ if (sourceType === "file") {
2646
+ const values = Array.isArray(localValue)
2647
+ ? localValue.filter(
2648
+ (value): value is string => typeof value === "string",
2649
+ )
2650
+ : typeof localValue === "string"
2651
+ ? [localValue]
2652
+ : [];
2653
+ return {
2654
+ proposedValue: values,
2655
+ builderValueJson: JSON.stringify(
2656
+ values.length === 1 ? values[0] : null,
2657
+ ),
2658
+ };
2659
+ }
2660
+ if (type === "multi_select" && Array.isArray(localValue)) {
2661
+ const labels = localValue
2662
+ .filter((value): value is string => typeof value === "string")
2663
+ .map((value) => optionNameById.get(value) ?? value);
2664
+ return {
2665
+ proposedValue: labels,
2666
+ builderValueJson: JSON.stringify(labels),
2667
+ };
2668
+ }
2669
+ if (
2670
+ (sourceType === "datetime" || sourceType === "date") &&
2671
+ localValue &&
2672
+ typeof localValue === "object" &&
2673
+ !Array.isArray(localValue) &&
2674
+ typeof (localValue as { start?: unknown }).start === "string"
2675
+ ) {
2676
+ return {
2677
+ proposedValue: localValue as DocumentPropertyValue,
2678
+ builderValueJson: JSON.stringify(
2679
+ (localValue as { start: string }).start,
2680
+ ),
2681
+ };
2682
+ }
2683
+ return {
2684
+ proposedValue: (localValue ?? null) as DocumentPropertyValue,
2685
+ builderValueJson: JSON.stringify(localValue ?? null),
2686
+ };
2687
+ };
2106
2688
  for (const row of args.rowRows) {
2107
2689
  if (
2108
2690
  skipFixtureRows &&
@@ -2111,7 +2693,15 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2111
2693
  continue;
2112
2694
  }
2113
2695
 
2114
- const sourceTitle = row.sourceDisplayKey.trim();
2696
+ const rowSourceValues =
2697
+ parseObject<Record<string, DocumentPropertyValue>>(
2698
+ row.sourceValuesJson,
2699
+ ) ?? {};
2700
+ const sourceTitleValue = rowSourceValues["data.title"];
2701
+ const sourceTitle =
2702
+ typeof sourceTitleValue === "string" && sourceTitleValue.trim()
2703
+ ? sourceTitleValue.trim()
2704
+ : row.sourceDisplayKey.trim();
2115
2705
  const localTitle = args.documentTitleById.get(row.documentId)?.trim() ?? "";
2116
2706
  const fieldChanges: ContentDatabaseSourceFieldChange[] = [];
2117
2707
  if (localTitle && localTitle !== sourceTitle) {
@@ -2129,18 +2719,20 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2129
2719
  // local value means "not loaded", not "cleared" — skip it.
2130
2720
  const rowLocalValues = args.localValuesByDocument?.get(row.documentId);
2131
2721
  if (rowLocalValues) {
2132
- const rowSourceValues =
2133
- parseObject<Record<string, DocumentPropertyValue>>(
2134
- row.sourceValuesJson,
2135
- ) ?? {};
2136
2722
  for (const field of args.writableFields ?? []) {
2137
2723
  if (!rowLocalValues.has(field.localFieldKey)) continue;
2138
2724
  const localValue = rowLocalValues.get(field.localFieldKey);
2139
2725
  const baseValue = rowSourceValues[field.sourceFieldKey];
2726
+ const comparisonBaseValue =
2727
+ field.sourceFieldType?.trim().toLowerCase() === "reference"
2728
+ ? (rowSourceValues[
2729
+ builderReferenceIdSourceValueKey(field.sourceFieldKey)
2730
+ ] ?? baseValue)
2731
+ : baseValue;
2140
2732
  if (
2141
2733
  sameMappedSourceFieldValue(
2142
2734
  localValue,
2143
- baseValue,
2735
+ comparisonBaseValue,
2144
2736
  field.propertyType,
2145
2737
  field.propertyOptions,
2146
2738
  )
@@ -2159,7 +2751,7 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2159
2751
  field.propertyOptions,
2160
2752
  )
2161
2753
  : (baseValue ?? null)) as DocumentPropertyValue,
2162
- proposedValue: localValue as DocumentPropertyValue,
2754
+ ...reviewableBuilderValue(field, localValue),
2163
2755
  });
2164
2756
  }
2165
2757
  }
@@ -2206,8 +2798,12 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2206
2798
 
2207
2799
  const now = new Date().toISOString();
2208
2800
  const displayTitle = localTitle || sourceTitle;
2209
- pending.push({
2210
- id: `local-pending-${row.id}-${bodyChange ? "body" : "fields"}`,
2801
+ const candidate: ContentDatabaseSourceChangeSet = {
2802
+ // Keep the synthetic identity stable while body hydration catches up.
2803
+ // The lightweight UI snapshot may initially see only field changes while
2804
+ // the authoritative write snapshot also sees a body diff. The payload
2805
+ // fingerprint, not an ID suffix, distinguishes material revisions.
2806
+ id: `local-pending-${row.id}-change`,
2211
2807
  databaseItemId: row.databaseItemId,
2212
2808
  documentId: row.documentId,
2213
2809
  kind:
@@ -2234,7 +2830,8 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2234
2830
  executions: [],
2235
2831
  createdAt: now,
2236
2832
  updatedAt: now,
2237
- });
2833
+ };
2834
+ if (!sameCancelledSnapshot(candidate)) pending.push(candidate);
2238
2835
  }
2239
2836
 
2240
2837
  // New-row creates: a local database item NOT linked to a Builder entry (no
@@ -2297,12 +2894,14 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2297
2894
  localFieldKey: field.localFieldKey,
2298
2895
  sourceFieldKey: field.sourceFieldKey,
2299
2896
  currentValue: null,
2300
- proposedValue: (localValues.get(field.localFieldKey) ??
2301
- null) as DocumentPropertyValue,
2897
+ ...reviewableBuilderValue(
2898
+ field,
2899
+ localValues.get(field.localFieldKey) ?? null,
2900
+ ),
2302
2901
  });
2303
2902
  }
2304
2903
  const now = new Date().toISOString();
2305
- pending.push({
2904
+ const candidate: ContentDatabaseSourceChangeSet = {
2306
2905
  id: `local-pending-create-${item.databaseItemId}`,
2307
2906
  databaseItemId: item.databaseItemId,
2308
2907
  documentId: item.documentId,
@@ -2323,7 +2922,8 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2323
2922
  executions: [],
2324
2923
  createdAt: now,
2325
2924
  updatedAt: now,
2326
- });
2925
+ };
2926
+ if (!sameCancelledSnapshot(candidate)) pending.push(candidate);
2327
2927
  }
2328
2928
  }
2329
2929
 
@@ -2521,21 +3121,31 @@ async function readSourceSnapshotRowsOnce(args: {
2521
3121
  ...databaseItemRows.map((item) => item.documentId),
2522
3122
  ]),
2523
3123
  );
2524
- const rowDocuments =
3124
+ const rowDocuments: Array<{
3125
+ id: string;
3126
+ title: string;
3127
+ content?: string;
3128
+ }> =
2525
3129
  allDocumentIds.length > 0
2526
- ? await db
2527
- .select({
2528
- id: schema.documents.id,
2529
- title: schema.documents.title,
2530
- content: schema.documents.content,
2531
- })
2532
- .from(schema.documents)
2533
- .where(
2534
- and(
2535
- inArray(schema.documents.id, allDocumentIds),
2536
- eq(schema.documents.ownerEmail, args.source.ownerEmail),
2537
- ),
2538
- )
3130
+ ? args.includeHeavyBuilderBodyValues
3131
+ ? await db
3132
+ .select(sourceSnapshotDocumentSelection(true))
3133
+ .from(schema.documents)
3134
+ .where(
3135
+ and(
3136
+ inArray(schema.documents.id, allDocumentIds),
3137
+ eq(schema.documents.ownerEmail, args.source.ownerEmail),
3138
+ ),
3139
+ )
3140
+ : await db
3141
+ .select(sourceSnapshotDocumentSelection(false))
3142
+ .from(schema.documents)
3143
+ .where(
3144
+ and(
3145
+ inArray(schema.documents.id, allDocumentIds),
3146
+ eq(schema.documents.ownerEmail, args.source.ownerEmail),
3147
+ ),
3148
+ )
2539
3149
  : [];
2540
3150
  const propertyValueRows =
2541
3151
  args.isBuilderSource && allDocumentIds.length > 0
@@ -2687,13 +3297,19 @@ async function loadSourceSnapshot(
2687
3297
  const propertyOptionsById = new Map(
2688
3298
  propertyDefs.map((row) => [row.id, parsePropertyOptions(row.optionsJson)]),
2689
3299
  );
3300
+ const builderModelFieldBySourceKey = new Map(
3301
+ (
3302
+ parseObject<SourceMetadataRecord>(source.metadataJson)
3303
+ ?.builderModelFields ?? []
3304
+ ).map((field) => [`data.${field.name.trim()}`, field]),
3305
+ );
2690
3306
  const fields = fieldRows.map((row) =>
2691
3307
  serializeSourceField(
2692
3308
  row,
2693
3309
  row.propertyId ? (propertyNameById.get(row.propertyId) ?? null) : null,
2694
3310
  ),
2695
3311
  );
2696
- const storedChangeSets = changeRows.map(serializeSourceChangeSet);
3312
+ let storedChangeSets = changeRows.map(serializeSourceChangeSet);
2697
3313
  const reviewEventsByChangeSetId = new Map<
2698
3314
  string,
2699
3315
  ContentDatabaseSourceReviewEvent[]
@@ -2765,6 +3381,9 @@ async function loadSourceSnapshot(
2765
3381
  propertyOptions: row.propertyId
2766
3382
  ? propertyOptionsById.get(row.propertyId)
2767
3383
  : undefined,
3384
+ sourceFieldType: row.sourceFieldType,
3385
+ sourceFieldModel: builderModelFieldBySourceKey.get(row.sourceFieldKey)
3386
+ ?.model,
2768
3387
  }));
2769
3388
  // Row-union ownership scoping (Builder only). Determine which documents belong
2770
3389
  // to OTHER sources and whether this source is the primary (oldest), so the
@@ -2845,11 +3464,19 @@ async function loadSourceSnapshot(
2845
3464
  );
2846
3465
  await Promise.all(
2847
3466
  allDocumentIds.map(async (documentId) => {
2848
- if (!hydratedDocumentIds.has(documentId)) return;
2849
3467
  const row = sourceRowByDocumentId.get(documentId);
2850
- if (!row) return;
2851
- const bodyChange = await builderBodyChangeForLocalContent({
3468
+ let bodyChange: ContentDatabaseSourceBodyChange | null = null;
3469
+ // Only the primary source may adopt a genuinely local row with no source
3470
+ // identity. Synthetic fixture rows already belong to this source, but
3471
+ // still represent create_draft targets rather than imported baselines.
3472
+ const allowUnsourcedCreate =
3473
+ isPrimarySource &&
3474
+ !otherSourceDocumentIds.has(documentId) &&
3475
+ !taggedSourceByDocumentId.has(documentId);
3476
+ bodyChange = await builderBodyChangeForSourceSnapshotDocument({
2852
3477
  row,
3478
+ isHydrated: hydratedDocumentIds.has(documentId),
3479
+ allowUnsourcedCreate,
2853
3480
  localContent: documentContentById.get(documentId),
2854
3481
  });
2855
3482
  if (bodyChange) bodyChangeByDocumentId.set(documentId, bodyChange);
@@ -2857,6 +3484,77 @@ async function loadSourceSnapshot(
2857
3484
  );
2858
3485
  }
2859
3486
 
3487
+ // A locally blocked dry run never crossed the Builder dispatch boundary, so
3488
+ // its approved body payload may be refreshed from the current document. This
3489
+ // is deliberately narrower than ordinary "retryable" state: running,
3490
+ // response-bearing, failed, reconciliatory, and otherwise ambiguous gates
3491
+ // retain the exact body that was originally approved.
3492
+ if (isBuilderSource && bodyChangeByDocumentId.size > 0) {
3493
+ const executionRowsByChangeSetId = new Map<
3494
+ string,
3495
+ ContentDatabaseSourceExecutionRowDb[]
3496
+ >();
3497
+ for (const execution of executionRows) {
3498
+ const rows = executionRowsByChangeSetId.get(execution.changeSetId) ?? [];
3499
+ rows.push(execution);
3500
+ executionRowsByChangeSetId.set(execution.changeSetId, rows);
3501
+ }
3502
+ storedChangeSets = storedChangeSets.map((changeSet) => {
3503
+ // Rejected snapshots are durable review/audit evidence. In particular,
3504
+ // a cancelled gate must retain the exact diff the user cancelled so a
3505
+ // later local or remote body change can be distinguished from it.
3506
+ if (changeSet.state !== "approved") return changeSet;
3507
+ const currentBody = changeSet.documentId
3508
+ ? bodyChangeByDocumentId.get(changeSet.documentId)
3509
+ : null;
3510
+ if (!currentBody) return changeSet;
3511
+ const executions = executionRowsByChangeSetId.get(changeSet.id) ?? [];
3512
+ if (!canRefreshLocallyBlockedBuilderReview(executions)) return changeSet;
3513
+ return { ...changeSet, bodyChange: currentBody };
3514
+ });
3515
+ }
3516
+
3517
+ const cancellationReviewChangeSetIds = new Set(
3518
+ reviewRows
3519
+ .filter(
3520
+ (review) =>
3521
+ review.decision === "rejected" &&
3522
+ review.stateTo === "rejected" &&
3523
+ review.note?.startsWith(CANCELLED_BUILDER_REVIEW_NOTE_PREFIX),
3524
+ )
3525
+ .map((review) => review.changeSetId),
3526
+ );
3527
+ const executionRowsByChangeSetId = new Map<
3528
+ string,
3529
+ ContentDatabaseSourceExecutionRowDb[]
3530
+ >();
3531
+ for (const execution of executionRows) {
3532
+ const rows = executionRowsByChangeSetId.get(execution.changeSetId) ?? [];
3533
+ rows.push(execution);
3534
+ executionRowsByChangeSetId.set(execution.changeSetId, rows);
3535
+ }
3536
+ const cancelledRejectedChangeSetIds = new Set(
3537
+ storedChangeSets
3538
+ .filter((changeSet) => {
3539
+ if (
3540
+ changeSet.state !== "rejected" ||
3541
+ !cancellationReviewChangeSetIds.has(changeSet.id)
3542
+ ) {
3543
+ return false;
3544
+ }
3545
+ const executions = executionRowsByChangeSetId.get(changeSet.id) ?? [];
3546
+ return (
3547
+ executions.length > 0 &&
3548
+ executions.every(
3549
+ (execution) =>
3550
+ execution.state === "blocked" &&
3551
+ execution.summary === CANCELLED_BUILDER_EXECUTION_SUMMARY,
3552
+ )
3553
+ );
3554
+ })
3555
+ .map((changeSet) => changeSet.id),
3556
+ );
3557
+
2860
3558
  const localOutboundChangeSets = buildBuilderLocalOutboundChangeSets({
2861
3559
  source,
2862
3560
  rowRows,
@@ -2877,10 +3575,11 @@ async function loadSourceSnapshot(
2877
3575
  .filter((row) => row.provenance === "Builder CMS read adapter")
2878
3576
  .map((row) => row.documentId),
2879
3577
  ),
3578
+ cancelledRejectedChangeSetIds,
2880
3579
  });
2881
3580
  const rowByDocumentId = new Map(rowRows.map((row) => [row.documentId, row]));
2882
- const changeSets = [...storedChangeSets, ...localOutboundChangeSets].map(
2883
- (changeSet) =>
3581
+ const changeSets = [
3582
+ ...storedChangeSets.map((changeSet) =>
2884
3583
  reviewedChangeSet({
2885
3584
  changeSet,
2886
3585
  source,
@@ -2888,7 +3587,20 @@ async function loadSourceSnapshot(
2888
3587
  reviewEvents: reviewEventsByChangeSetId.get(changeSet.id) ?? [],
2889
3588
  executions: executionsByChangeSetId.get(changeSet.id) ?? [],
2890
3589
  }),
2891
- );
3590
+ ),
3591
+ ...localOutboundChangeSets.map((changeSet) =>
3592
+ reviewedChangeSet({
3593
+ changeSet,
3594
+ source,
3595
+ rowByDocumentId,
3596
+ // Synthetic current diffs deliberately reuse a stable identity. A
3597
+ // closed stored revision with that ID is historical evidence, not the
3598
+ // execution state of this new payload.
3599
+ reviewEvents: [],
3600
+ executions: [],
3601
+ }),
3602
+ ),
3603
+ ];
2892
3604
  const metadata = parseObject<SourceMetadataRecord>(source.metadataJson) ?? {};
2893
3605
  const normalizedWriteMode =
2894
3606
  metadata.writeMode === "read_only" ||
@@ -2989,6 +3701,7 @@ async function loadSourceSnapshot(
2989
3701
  .map((mode) => normalizePushMode(mode))
2990
3702
  .filter((mode): mode is ContentDatabaseSourcePushMode => !!mode)
2991
3703
  : undefined,
3704
+ builderModelFields: metadata.builderModelFields,
2992
3705
  federation: normalizeSourceFederation(metadata.federation),
2993
3706
  },
2994
3707
  fields,
@@ -3035,6 +3748,7 @@ async function sourceBodyHydrationSummary(args: {
3035
3748
  pending: 0,
3036
3749
  hydrating: 0,
3037
3750
  hydrated: 0,
3751
+ unavailable: 0,
3038
3752
  error: 0,
3039
3753
  total: rows.length,
3040
3754
  };
@@ -3042,6 +3756,7 @@ async function sourceBodyHydrationSummary(args: {
3042
3756
  if (row.status === "pending" || (!row.status && row.queueId)) {
3043
3757
  summary.pending += 1;
3044
3758
  } else if (row.status === "hydrating") summary.hydrating += 1;
3759
+ else if (row.status === "unavailable") summary.unavailable! += 1;
3045
3760
  else if (row.status === "error") summary.error += 1;
3046
3761
  else summary.hydrated += 1;
3047
3762
  }
@@ -3158,6 +3873,7 @@ export function serializeBuilderCmsSourceReadMetadataRecord(args: {
3158
3873
  );
3159
3874
  return JSON.stringify({
3160
3875
  ...builderCmsSourceMetadata(args.sourceTable),
3876
+ ...existingMetadata,
3161
3877
  builderModelFields:
3162
3878
  args.builderModelFields ?? existingMetadata?.builderModelFields,
3163
3879
  readMode: args.readState === "live" ? "builder-api" : "fixture",
@@ -3259,6 +3975,41 @@ function normalizeBuilderCmsSourceFieldType(type: string) {
3259
3975
  return normalized || "text";
3260
3976
  }
3261
3977
 
3978
+ function isBuilderReferenceModelField(field: BuilderCmsModelFieldSummary) {
3979
+ return [field.type, field.inputType]
3980
+ .filter((value): value is string => typeof value === "string")
3981
+ .some((value) => /\b(reference|relation)\b/i.test(value));
3982
+ }
3983
+
3984
+ /**
3985
+ * A raw Builder entry can teach required-field setup the reference model even
3986
+ * when Builder's model endpoint omits it. Refreshes must not throw that
3987
+ * provider-native enrichment away: the canonical local value is only an entry
3988
+ * id, and dispatch needs the learned model to reconstruct a Builder reference.
3989
+ */
3990
+ export function mergeBuilderCmsModelFieldsPreservingReferenceModels(args: {
3991
+ existing?: BuilderCmsModelFieldSummary[];
3992
+ refreshed?: BuilderCmsModelFieldSummary[];
3993
+ }) {
3994
+ if (!args.refreshed || args.refreshed.length === 0) {
3995
+ return args.existing ?? args.refreshed;
3996
+ }
3997
+ const existingByName = new Map(
3998
+ (args.existing ?? []).map((field) => [field.name.trim(), field]),
3999
+ );
4000
+ return args.refreshed.map((field) => {
4001
+ const existing = existingByName.get(field.name.trim());
4002
+ if (
4003
+ field.model?.trim() ||
4004
+ !existing?.model?.trim() ||
4005
+ !isBuilderReferenceModelField(field)
4006
+ ) {
4007
+ return field;
4008
+ }
4009
+ return { ...field, model: existing.model.trim() };
4010
+ });
4011
+ }
4012
+
3262
4013
  export async function seedMockSourceFields(args: {
3263
4014
  sourceId: string;
3264
4015
  ownerEmail: string;
@@ -3276,6 +4027,12 @@ export async function seedMockSourceFields(args: {
3276
4027
  .filter((field) => isBuilder && field.propertyId)
3277
4028
  .map((field) => [field.propertyId!, field]),
3278
4029
  );
4030
+ const builderModelFieldBySourceKey = new Map(
4031
+ (args.builderModelFields ?? []).map((field) => [
4032
+ `data.${field.name.trim()}`,
4033
+ field,
4034
+ ]),
4035
+ );
3279
4036
  const rows = [
3280
4037
  {
3281
4038
  id: crypto.randomUUID(),
@@ -3386,7 +4143,18 @@ export async function seedMockSourceFields(args: {
3386
4143
  ))
3387
4144
  : `fields.${slugifySourceField(property.definition.name)}`,
3388
4145
  sourceFieldLabel: property.definition.name,
3389
- sourceFieldType: property.definition.type,
4146
+ sourceFieldType: isBuilder
4147
+ ? normalizeBuilderCmsSourceFieldType(
4148
+ builderModelFieldBySourceKey.get(
4149
+ existingBuilderFieldByPropertyId.get(property.definition.id)
4150
+ ?.sourceFieldKey ??
4151
+ builderCmsSourceFieldKey(
4152
+ property.definition.id,
4153
+ property.definition.name,
4154
+ ),
4155
+ )?.type ?? property.definition.type,
4156
+ )
4157
+ : property.definition.type,
3390
4158
  mappingType: "property",
3391
4159
  writeOwner:
3392
4160
  property.definition.type === "created_time" ||
@@ -3525,65 +4293,77 @@ export async function seedMockSourceRows(args: {
3525
4293
  }) {
3526
4294
  if (args.items.length === 0) return;
3527
4295
  const db = getDb();
3528
- await db.insert(schema.contentDatabaseSourceRows).values(
3529
- args.items.map((item, index) => {
3530
- const builderEntry = args.builderEntriesByDocumentId?.get(
3531
- item.document.id,
3532
- );
3533
- const existingBuilderRow = args.existingBuilderRows?.get(
3534
- item.document.id,
3535
- );
3536
- const builderIdentity =
3537
- args.sourceType === "builder-cms"
3538
- ? builderCmsSourceRowIdentity({
4296
+ await db
4297
+ .insert(schema.contentDatabaseSourceRows)
4298
+ .values(
4299
+ args.items.map((item, index) => {
4300
+ const builderEntry = args.builderEntriesByDocumentId?.get(
4301
+ item.document.id,
4302
+ );
4303
+ const existingBuilderRow = args.existingBuilderRows?.get(
4304
+ item.document.id,
4305
+ );
4306
+ const builderIdentity =
4307
+ args.sourceType === "builder-cms"
4308
+ ? builderCmsSourceRowIdentity({
4309
+ item,
4310
+ sourceTable: args.sourceTable,
4311
+ now: args.now,
4312
+ existing: existingBuilderRow,
4313
+ entry: builderEntry,
4314
+ })
4315
+ : null;
4316
+ const sourceQualifiedId = builderIdentity
4317
+ ? builderIdentity.sourceQualifiedId
4318
+ : `${args.sourceType}://${args.sourceTable}/${item.document.id}`;
4319
+ return {
4320
+ id: builderIdentity
4321
+ ? stableBuilderImportId("builder-row", [
4322
+ args.sourceId,
4323
+ sourceQualifiedId,
4324
+ item.document.id,
4325
+ ])
4326
+ : crypto.randomUUID(),
4327
+ ownerEmail: args.ownerEmail,
4328
+ sourceId: args.sourceId,
4329
+ databaseItemId: item.id,
4330
+ documentId: item.document.id,
4331
+ sourceRowId: builderIdentity
4332
+ ? builderIdentity.sourceRowId
4333
+ : `${args.sourceType}-${item.document.id}`,
4334
+ sourceQualifiedId,
4335
+ sourceDisplayKey:
4336
+ builderIdentity?.sourceDisplayKey ??
4337
+ item.document.title?.trim() ??
4338
+ `${args.sourceType}-${index + 1}`,
4339
+ sourceValuesJson: JSON.stringify(
4340
+ sourceValuesForSeededSourceRow({
4341
+ sourceType: args.sourceType,
3539
4342
  item,
3540
4343
  sourceTable: args.sourceTable,
3541
4344
  now: args.now,
3542
- existing: existingBuilderRow,
3543
- entry: builderEntry,
3544
- })
3545
- : null;
3546
- return {
3547
- id: crypto.randomUUID(),
3548
- ownerEmail: args.ownerEmail,
3549
- sourceId: args.sourceId,
3550
- databaseItemId: item.id,
3551
- documentId: item.document.id,
3552
- sourceRowId: builderIdentity
3553
- ? builderIdentity.sourceRowId
3554
- : `${args.sourceType}-${item.document.id}`,
3555
- sourceQualifiedId: builderIdentity
3556
- ? builderIdentity.sourceQualifiedId
3557
- : `${args.sourceType}://${args.sourceTable}/${item.document.id}`,
3558
- sourceDisplayKey:
3559
- builderIdentity?.sourceDisplayKey ??
3560
- item.document.title?.trim() ??
3561
- `${args.sourceType}-${index + 1}`,
3562
- sourceValuesJson: JSON.stringify(
3563
- sourceValuesForSeededSourceRow({
3564
- sourceType: args.sourceType,
3565
- item,
3566
- sourceTable: args.sourceTable,
3567
- now: args.now,
3568
- builderEntry,
3569
- existingSourceValuesJson: existingBuilderRow?.sourceValuesJson,
3570
- }),
3571
- ),
3572
- provenance:
3573
- args.sourceType === "builder-cms"
3574
- ? args.builderEntriesByDocumentId?.has(item.document.id)
3575
- ? "Builder CMS read adapter"
3576
- : BUILDER_CMS_FIXTURE_ROW_PROVENANCE
3577
- : "mock source row",
3578
- syncState: "linked",
3579
- freshness: "fresh",
3580
- lastSyncedAt: args.now,
3581
- lastSourceUpdatedAt: builderIdentity?.lastSourceUpdatedAt ?? args.now,
3582
- createdAt: args.now,
3583
- updatedAt: args.now,
3584
- };
3585
- }),
3586
- );
4345
+ builderEntry,
4346
+ existingSourceValuesJson: existingBuilderRow?.sourceValuesJson,
4347
+ existingLastSourceUpdatedAt:
4348
+ existingBuilderRow?.lastSourceUpdatedAt,
4349
+ }),
4350
+ ),
4351
+ provenance:
4352
+ args.sourceType === "builder-cms"
4353
+ ? args.builderEntriesByDocumentId?.has(item.document.id)
4354
+ ? "Builder CMS read adapter"
4355
+ : BUILDER_CMS_FIXTURE_ROW_PROVENANCE
4356
+ : "mock source row",
4357
+ syncState: "linked",
4358
+ freshness: "fresh",
4359
+ lastSyncedAt: args.now,
4360
+ lastSourceUpdatedAt: builderIdentity?.lastSourceUpdatedAt ?? args.now,
4361
+ createdAt: args.now,
4362
+ updatedAt: args.now,
4363
+ };
4364
+ }),
4365
+ )
4366
+ .onConflictDoNothing();
3587
4367
  }
3588
4368
 
3589
4369
  export function sourceValuesForSeededSourceRow(args: {
@@ -3593,15 +4373,40 @@ export function sourceValuesForSeededSourceRow(args: {
3593
4373
  now: string;
3594
4374
  builderEntry?: BuilderCmsSourceEntry | null;
3595
4375
  existingSourceValuesJson?: string | null;
4376
+ existingLastSourceUpdatedAt?: string | null;
3596
4377
  }): Record<string, DocumentPropertyValue> {
3597
4378
  const existingSourceValues = parseObject<
3598
4379
  Record<string, DocumentPropertyValue>
3599
4380
  >(args.existingSourceValuesJson);
3600
4381
  if (args.builderEntry?.sourceValues) {
3601
- return builderSourceValuesWithPreservedBodyBaseline({
4382
+ const values = builderSourceValuesWithPreservedBodyBaseline({
3602
4383
  incoming: args.builderEntry.sourceValues,
3603
4384
  existing: existingSourceValues,
4385
+ existingLastSourceUpdatedAt: args.existingLastSourceUpdatedAt,
3604
4386
  });
4387
+ const rawData = args.builderEntry.rawEntry?.data;
4388
+ if (rawData && typeof rawData === "object" && !Array.isArray(rawData)) {
4389
+ for (const [name, rawValue] of Object.entries(rawData)) {
4390
+ if (
4391
+ !rawValue ||
4392
+ typeof rawValue !== "object" ||
4393
+ Array.isArray(rawValue)
4394
+ )
4395
+ continue;
4396
+ const reference = rawValue as Record<string, unknown>;
4397
+ if (
4398
+ reference["@type"] !== "@builder.io/core:Reference" ||
4399
+ typeof reference.id !== "string" ||
4400
+ !reference.id.trim()
4401
+ ) {
4402
+ continue;
4403
+ }
4404
+ const sourceFieldKey = `data.${name}`;
4405
+ values[builderReferenceIdSourceValueKey(sourceFieldKey)] =
4406
+ reference.id.trim();
4407
+ }
4408
+ }
4409
+ return values;
3605
4410
  }
3606
4411
  if (existingSourceValues) return existingSourceValues;
3607
4412
  if (args.sourceType !== "builder-cms") return {};
@@ -3615,6 +4420,7 @@ export function sourceValuesForSeededSourceRow(args: {
3615
4420
  function builderSourceValuesWithPreservedBodyBaseline(args: {
3616
4421
  incoming: Record<string, DocumentPropertyValue>;
3617
4422
  existing?: Record<string, DocumentPropertyValue> | null;
4423
+ existingLastSourceUpdatedAt?: string | null;
3618
4424
  }) {
3619
4425
  const existing = args.existing;
3620
4426
  if (!existing) return args.incoming;
@@ -3634,24 +4440,66 @@ function builderSourceValuesWithPreservedBodyBaseline(args: {
3634
4440
  existing,
3635
4441
  BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
3636
4442
  );
3637
- if (
3638
- incomingContent?.trim() ||
3639
- !existingContent?.trim() ||
3640
- (incomingHash && existingHash && incomingHash !== existingHash)
3641
- ) {
3642
- return args.incoming;
3643
- }
3644
-
4443
+ const incomingLastUpdated =
4444
+ stringSourceValue(args.incoming, "lastUpdated") ?? null;
4445
+ const existingLastUpdated =
4446
+ stringSourceValue(existing, BUILDER_CMS_BODY_LAST_UPDATED_KEY) ??
4447
+ stringSourceValue(existing, "lastUpdated") ??
4448
+ args.existingLastSourceUpdatedAt ??
4449
+ null;
4450
+ const incomingEpoch = builderTimestampEpoch(incomingLastUpdated);
4451
+ const existingEpoch = builderTimestampEpoch(existingLastUpdated);
4452
+ const sameRemoteVersion =
4453
+ incomingEpoch !== null &&
4454
+ existingEpoch !== null &&
4455
+ incomingEpoch === existingEpoch;
3645
4456
  const next = { ...args.incoming };
3646
- for (const key of [
3647
- BUILDER_CMS_BODY_CONTENT_KEY,
3648
- BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
3649
- BUILDER_CMS_BODY_READABLE_MAP_KEY,
3650
- BUILDER_CMS_BODY_SIDECARS_KEY,
3651
- BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
3652
- ]) {
3653
- if (next[key] === undefined && existing[key] !== undefined) {
3654
- next[key] = existing[key];
4457
+ const preserveExistingBody =
4458
+ !incomingContent?.trim() &&
4459
+ !!existingContent?.trim() &&
4460
+ (sameRemoteVersion ||
4461
+ !(incomingHash && existingHash && incomingHash !== existingHash));
4462
+ if (preserveExistingBody) {
4463
+ for (const key of [
4464
+ BUILDER_CMS_BODY_CONTENT_KEY,
4465
+ BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
4466
+ BUILDER_CMS_BODY_READABLE_MAP_KEY,
4467
+ BUILDER_CMS_BODY_SIDECARS_KEY,
4468
+ BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
4469
+ BUILDER_CMS_BODY_LAST_UPDATED_KEY,
4470
+ ]) {
4471
+ if (existing[key] !== undefined) {
4472
+ next[key] = existing[key];
4473
+ }
4474
+ }
4475
+ if (
4476
+ next[BUILDER_CMS_BODY_LAST_UPDATED_KEY] === undefined &&
4477
+ args.existingLastSourceUpdatedAt
4478
+ ) {
4479
+ next[BUILDER_CMS_BODY_LAST_UPDATED_KEY] =
4480
+ args.existingLastSourceUpdatedAt;
4481
+ }
4482
+ }
4483
+ // Required-reference materialization stores the canonical Builder entry id
4484
+ // beside the readable projection. Refresh receives only that projection, so
4485
+ // retain the id while the projected value is unchanged. If the remote label
4486
+ // changes, discard the old id instead of pairing stale identity with it.
4487
+ const referenceIdPrefix = "__agent_native_builder_reference_id:";
4488
+ for (const [key, value] of Object.entries(existing)) {
4489
+ if (!key.startsWith(referenceIdPrefix)) continue;
4490
+ const sourceFieldKey = key.slice(referenceIdPrefix.length);
4491
+ if (
4492
+ stableValueString(existing[sourceFieldKey]) ===
4493
+ stableValueString(args.incoming[sourceFieldKey])
4494
+ ) {
4495
+ next[key] = value;
4496
+ }
4497
+ }
4498
+ if (next[BUILDER_CMS_BODY_LAST_UPDATED_KEY] === undefined) {
4499
+ const existingBaselineLastUpdated =
4500
+ existing[BUILDER_CMS_BODY_LAST_UPDATED_KEY] ?? existing.lastUpdated;
4501
+ if (existingBaselineLastUpdated !== undefined) {
4502
+ next[BUILDER_CMS_BODY_LAST_UPDATED_KEY] = existingBaselineLastUpdated;
3655
4503
  }
3656
4504
  }
3657
4505
  return next;
@@ -4114,13 +4962,19 @@ export function mapBuilderCmsEntriesToLocalItems(args: {
4114
4962
  sourceTable: args.sourceTable,
4115
4963
  now: args.now,
4116
4964
  });
4965
+ const exactIdentityMatch = existing
4966
+ ? (entriesById.get(existing.sourceRowId) ??
4967
+ entriesByQualifiedId.get(existing.sourceQualifiedId))
4968
+ : null;
4969
+ const allowNaturalKeyFallback =
4970
+ !existing ||
4971
+ builderCmsSourceRowIdentityState({ row: existing }).isSyntheticFixture;
4117
4972
  const match =
4118
- (existing
4119
- ? (entriesById.get(existing.sourceRowId) ??
4120
- entriesByQualifiedId.get(existing.sourceQualifiedId))
4121
- : null) ??
4122
- entriesByUrlPath.get(fixtureEntry.urlPath.toLowerCase()) ??
4123
- entriesByTitle.get(item.document.title.trim().toLowerCase());
4973
+ exactIdentityMatch ??
4974
+ (allowNaturalKeyFallback
4975
+ ? (entriesByUrlPath.get(fixtureEntry.urlPath.toLowerCase()) ??
4976
+ entriesByTitle.get(item.document.title.trim().toLowerCase()))
4977
+ : null);
4124
4978
  if (match) entriesByDocumentId.set(item.document.id, match);
4125
4979
  }
4126
4980
 
@@ -4207,8 +5061,18 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
4207
5061
  eq(schema.documents.id, schema.contentDatabaseItems.documentId),
4208
5062
  )
4209
5063
  .where(eq(schema.contentDatabaseItems.databaseId, args.database.id));
4210
- const existingTitles = new Set(
4211
- currentItems.map((row) => row.document.title.trim().toLowerCase()),
5064
+ const representedDocumentIds = new Set(
5065
+ (args.existingSourceRows ?? [])
5066
+ .map((row) => row.documentId)
5067
+ .filter((documentId): documentId is string => Boolean(documentId)),
5068
+ );
5069
+ // Builder entry IDs, not titles, are the remote identity. Preserve every
5070
+ // same-title entry in a collection while retaining the legacy guard against
5071
+ // adopting an already-present local row during source replacement.
5072
+ const existingUnlinkedTitles = new Set(
5073
+ currentItems
5074
+ .filter((row) => !representedDocumentIds.has(row.document.id))
5075
+ .map((row) => row.document.title.trim().toLowerCase()),
4212
5076
  );
4213
5077
 
4214
5078
  // Reads MAX(position) for both `documents` and `content_database_items`
@@ -4257,11 +5121,24 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
4257
5121
 
4258
5122
  const title = entry.title.trim() || entry.id;
4259
5123
  const titleKey = title.toLowerCase();
4260
- if (!args.skipTitleDedup && existingTitles.has(titleKey)) continue;
4261
- existingTitles.add(titleKey);
5124
+ if (!args.skipTitleDedup && existingUnlinkedTitles.has(titleKey)) {
5125
+ continue;
5126
+ }
4262
5127
 
4263
- const documentId = nanoid();
4264
- const itemId = nanoid();
5128
+ const importIdentity = [
5129
+ args.database.ownerEmail,
5130
+ args.database.id,
5131
+ args.sourceTable,
5132
+ entry.id,
5133
+ ];
5134
+ const documentId = stableBuilderImportId(
5135
+ "builder-doc",
5136
+ importIdentity,
5137
+ );
5138
+ const itemId = stableBuilderImportId(
5139
+ "builder-item",
5140
+ importIdentity,
5141
+ );
4265
5142
  documentRows.push({
4266
5143
  id: documentId,
4267
5144
  ownerEmail: args.database.ownerEmail,
@@ -4296,13 +5173,19 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
4296
5173
  documentRows,
4297
5174
  bulkChunkSizeForColumnCount(13),
4298
5175
  )) {
4299
- await tx.insert(schema.documents).values(chunk);
5176
+ await tx
5177
+ .insert(schema.documents)
5178
+ .values(chunk)
5179
+ .onConflictDoNothing();
4300
5180
  }
4301
5181
  for (const chunk of chunks(
4302
5182
  itemRows,
4303
5183
  bulkChunkSizeForColumnCount(10),
4304
5184
  )) {
4305
- await tx.insert(schema.contentDatabaseItems).values(chunk);
5185
+ await tx
5186
+ .insert(schema.contentDatabaseItems)
5187
+ .values(chunk)
5188
+ .onConflictDoNothing();
4306
5189
  }
4307
5190
  });
4308
5191
 
@@ -4346,6 +5229,10 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
4346
5229
  builderModelFields = await readBuilderCmsModelFields({
4347
5230
  model: args.source.sourceTable,
4348
5231
  });
5232
+ builderModelFields = mergeBuilderCmsModelFieldsPreservingReferenceModels({
5233
+ existing: sourceMetadata.builderModelFields,
5234
+ refreshed: builderModelFields,
5235
+ });
4349
5236
  } catch (error) {
4350
5237
  builderModelFieldsReadFailed = true;
4351
5238
  const message = error instanceof Error ? error.message : String(error);