@agent-native/core 0.101.6 → 0.101.10

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 (237) 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/artifact-response.ts +255 -12
  16. package/corpus/core/src/agent/thread-data-builder.ts +84 -11
  17. package/corpus/core/src/client/review/ReviewCommentComposer.tsx +9 -1
  18. package/corpus/core/src/client/review/ReviewThreadPanel.tsx +17 -2
  19. package/corpus/core/src/client/settings/SettingsPanel.tsx +41 -21
  20. package/corpus/core/src/client/settings/useBuilderStatus.ts +28 -9
  21. package/corpus/core/src/client/setup-connections/BuilderConnectCard.tsx +2 -7
  22. package/corpus/core/src/integrations/a2a-continuation-processor.ts +99 -7
  23. package/corpus/core/src/integrations/adapters/discord.ts +3 -1
  24. package/corpus/core/src/integrations/adapters/email.ts +4 -1
  25. package/corpus/core/src/integrations/adapters/google-docs.ts +4 -1
  26. package/corpus/core/src/integrations/adapters/microsoft-teams.ts +3 -1
  27. package/corpus/core/src/integrations/adapters/slack.ts +38 -9
  28. package/corpus/core/src/integrations/adapters/telegram.ts +32 -10
  29. package/corpus/core/src/integrations/adapters/whatsapp.ts +5 -1
  30. package/corpus/core/src/integrations/index.ts +1 -0
  31. package/corpus/core/src/integrations/types.ts +9 -2
  32. package/corpus/core/src/integrations/webhook-handler.ts +69 -45
  33. package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +1 -0
  34. package/corpus/core/src/server/auth.ts +25 -1
  35. package/corpus/core/src/server/builder-browser.ts +353 -1
  36. package/corpus/core/src/server/core-routes-plugin.ts +601 -223
  37. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +80 -1
  38. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +28 -3
  39. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +5 -1
  40. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +30 -3
  41. package/corpus/templates/calendar/app/components/calendar/OutOfOfficeEvent.tsx +196 -0
  42. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +115 -1
  43. package/corpus/templates/calendar/app/lib/out-of-office.ts +45 -0
  44. package/corpus/templates/calendar/changelog/2026-07-14-out-of-office-blocks-now-sit-behind-meetings-and-event-stack.md +6 -0
  45. package/corpus/templates/content/.agents/skills/content/SKILL.md +31 -0
  46. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +35 -21
  47. package/corpus/templates/content/AGENTS.md +32 -9
  48. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +87 -0
  49. package/corpus/templates/content/actions/_builder-cms-read-client.ts +173 -25
  50. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
  51. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +427 -18
  52. package/corpus/templates/content/actions/_builder-cms-write-client.ts +17 -73
  53. package/corpus/templates/content/actions/_builder-cms-write-settings.ts +27 -7
  54. package/corpus/templates/content/actions/_builder-source-execution-claim.ts +124 -0
  55. package/corpus/templates/content/actions/_builder-source-execution-preservation.ts +21 -0
  56. package/corpus/templates/content/actions/_builder-source-timings.ts +57 -0
  57. package/corpus/templates/content/actions/_database-source-utils.ts +1071 -170
  58. package/corpus/templates/content/actions/_database-utils.ts +104 -13
  59. package/corpus/templates/content/actions/_local-file-documents.ts +53 -1
  60. package/corpus/templates/content/actions/_preview-document-draft.ts +31 -0
  61. package/corpus/templates/content/actions/_property-utils.ts +7 -1
  62. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +309 -0
  63. package/corpus/templates/content/actions/configure-document-property.ts +11 -0
  64. package/corpus/templates/content/actions/create-content-database.ts +11 -0
  65. package/corpus/templates/content/actions/create-document.ts +9 -0
  66. package/corpus/templates/content/actions/create-inline-content-database.ts +3 -0
  67. package/corpus/templates/content/actions/execute-builder-source-batch.ts +120 -49
  68. package/corpus/templates/content/actions/execute-builder-source-execution.ts +608 -264
  69. package/corpus/templates/content/actions/export-content-source.ts +1 -0
  70. package/corpus/templates/content/actions/get-content-database.ts +13 -1
  71. package/corpus/templates/content/actions/get-document.ts +12 -2
  72. package/corpus/templates/content/actions/get-preview-document-draft.ts +26 -0
  73. package/corpus/templates/content/actions/import-content-source.ts +6 -0
  74. package/corpus/templates/content/actions/list-documents.ts +3 -0
  75. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +550 -0
  76. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +112 -65
  77. package/corpus/templates/content/actions/prepare-builder-source-review.ts +463 -170
  78. package/corpus/templates/content/actions/preview-builder-source-review.ts +99 -0
  79. package/corpus/templates/content/actions/pull-document.ts +2 -0
  80. package/corpus/templates/content/actions/search-documents.ts +5 -1
  81. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +2 -2
  82. package/corpus/templates/content/actions/update-document.ts +16 -1
  83. package/corpus/templates/content/actions/update-preview-document-draft.ts +153 -0
  84. package/corpus/templates/content/actions/view-screen.ts +15 -2
  85. package/corpus/templates/content/app/components/editor/DescriptionField.tsx +121 -0
  86. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +14 -0
  87. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +250 -49
  88. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +57 -5
  89. package/corpus/templates/content/app/components/editor/body-hydration.ts +27 -0
  90. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2414 -589
  91. package/corpus/templates/content/app/components/editor/database/settings.tsx +13 -10
  92. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +358 -75
  93. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +124 -29
  94. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +2 -0
  95. package/corpus/templates/content/app/global.css +18 -1
  96. package/corpus/templates/content/app/hooks/use-content-database.ts +68 -0
  97. package/corpus/templates/content/app/hooks/use-documents.ts +53 -4
  98. package/corpus/templates/content/app/i18n/zh-TW.ts +30 -0
  99. package/corpus/templates/content/app/i18n-data.ts +339 -0
  100. package/corpus/templates/content/changelog/2026-07-10-builder-backed-preview-edits-are-preserved-when-article-bodi.md +6 -0
  101. package/corpus/templates/content/changelog/2026-07-10-builder-cms-sources-can-now-be-enabled-for-guarded-staged-or.md +6 -0
  102. package/corpus/templates/content/changelog/2026-07-13-builder-connection-setup-is-now-available-directly-from-conn.md +6 -0
  103. package/corpus/templates/content/changelog/2026-07-13-builder-reviews-now-hide-already-applied-writes-detect-nativ.md +6 -0
  104. package/corpus/templates/content/changelog/2026-07-13-required-builder-publishing-fields-can-now-be-added-directly.md +6 -0
  105. package/corpus/templates/content/changelog/2026-07-13-you-can-now-cancel-a-prepared-builder-update-safely-before-i.md +6 -0
  106. package/corpus/templates/content/changelog/2026-07-14-builder-account-connection-now-remains-available-when-branch.md +6 -0
  107. package/corpus/templates/content/changelog/2026-07-14-builder-backed-articles-now-preserve-fifth-and-sixth-level-h.md +6 -0
  108. package/corpus/templates/content/changelog/2026-07-14-builder-sources-now-review-and-publish-rich-article-updates-.md +6 -0
  109. package/corpus/templates/content/changelog/2026-07-14-builder-writes-now-stop-retries-when-reconciliation-is-needed.md +6 -0
  110. package/corpus/templates/content/changelog/2026-07-14-file-and-media-links-now-validate-before-saving.md +6 -0
  111. package/corpus/templates/content/changelog/2026-07-14-pages-and-databases-can-describe-themselves-with-guidance-fo.md +6 -0
  112. package/corpus/templates/content/changelog/2026-07-14-slack-follow-ups-now-retain-created-content-identity-and-per.md +6 -0
  113. package/corpus/templates/content/parity/matrix.md +27 -25
  114. package/corpus/templates/content/parity/matrix.ts +50 -2
  115. package/corpus/templates/content/server/db/schema.ts +51 -0
  116. package/corpus/templates/content/server/lib/document-context.ts +76 -0
  117. package/corpus/templates/content/server/lib/public-documents.ts +3 -0
  118. package/corpus/templates/content/server/plugins/auth.ts +1 -0
  119. package/corpus/templates/content/server/plugins/db.ts +59 -0
  120. package/corpus/templates/content/server/routes/api/document-agent-context.json.get.ts +5 -0
  121. package/corpus/templates/content/shared/api.ts +87 -1
  122. package/corpus/templates/content/shared/builder-mdx.ts +705 -15
  123. package/corpus/templates/content/shared/content-source.ts +7 -0
  124. package/corpus/templates/content/shared/nfm.ts +3 -3
  125. package/corpus/templates/content/shared/properties.ts +2 -0
  126. package/corpus/templates/design/app/components/design/ReadOnlyDesignBanner.tsx +52 -7
  127. package/corpus/templates/design/app/components/design/ReviewCommentsPanel.tsx +36 -4
  128. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
  129. package/corpus/templates/design/app/i18n/ar-SA.ts +1 -0
  130. package/corpus/templates/design/app/i18n/de-DE.ts +1 -0
  131. package/corpus/templates/design/app/i18n/en-US.ts +1 -0
  132. package/corpus/templates/design/app/i18n/es-ES.ts +1 -0
  133. package/corpus/templates/design/app/i18n/fr-FR.ts +1 -0
  134. package/corpus/templates/design/app/i18n/hi-IN.ts +1 -0
  135. package/corpus/templates/design/app/i18n/ja-JP.ts +1 -0
  136. package/corpus/templates/design/app/i18n/ko-KR.ts +1 -0
  137. package/corpus/templates/design/app/i18n/pt-BR.ts +1 -0
  138. package/corpus/templates/design/app/i18n/zh-CN.ts +1 -0
  139. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  140. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +8 -0
  141. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +11 -0
  142. package/corpus/templates/design/changelog/2026-07-14-reviewers-can-now-pin-comments-from-the-canvas-toolbar-targe.md +6 -0
  143. package/corpus/templates/design/changelog/2026-07-15-viewer-comment-controls-now-sit-inside-the-read-only-notice-.md +6 -0
  144. package/corpus/templates/design/shared/review-anchor.ts +48 -0
  145. package/corpus/templates/plan/server/plan-mdx.ts +53 -7
  146. package/dist/a2a/artifact-response.d.ts +15 -0
  147. package/dist/a2a/artifact-response.d.ts.map +1 -1
  148. package/dist/a2a/artifact-response.js +204 -5
  149. package/dist/a2a/artifact-response.js.map +1 -1
  150. package/dist/agent/thread-data-builder.d.ts +6 -0
  151. package/dist/agent/thread-data-builder.d.ts.map +1 -1
  152. package/dist/agent/thread-data-builder.js +71 -8
  153. package/dist/agent/thread-data-builder.js.map +1 -1
  154. package/dist/client/review/ReviewCommentComposer.d.ts +2 -1
  155. package/dist/client/review/ReviewCommentComposer.d.ts.map +1 -1
  156. package/dist/client/review/ReviewCommentComposer.js +3 -3
  157. package/dist/client/review/ReviewCommentComposer.js.map +1 -1
  158. package/dist/client/review/ReviewThreadPanel.d.ts +9 -1
  159. package/dist/client/review/ReviewThreadPanel.d.ts.map +1 -1
  160. package/dist/client/review/ReviewThreadPanel.js +6 -4
  161. package/dist/client/review/ReviewThreadPanel.js.map +1 -1
  162. package/dist/client/settings/SettingsPanel.d.ts +3 -0
  163. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  164. package/dist/client/settings/SettingsPanel.js +11 -4
  165. package/dist/client/settings/SettingsPanel.js.map +1 -1
  166. package/dist/client/settings/useBuilderStatus.d.ts +8 -4
  167. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  168. package/dist/client/settings/useBuilderStatus.js +19 -6
  169. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  170. package/dist/client/setup-connections/BuilderConnectCard.d.ts.map +1 -1
  171. package/dist/client/setup-connections/BuilderConnectCard.js +2 -5
  172. package/dist/client/setup-connections/BuilderConnectCard.js.map +1 -1
  173. package/dist/collab/awareness.d.ts +2 -2
  174. package/dist/collab/awareness.d.ts.map +1 -1
  175. package/dist/integrations/a2a-continuation-processor.d.ts.map +1 -1
  176. package/dist/integrations/a2a-continuation-processor.js +72 -6
  177. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  178. package/dist/integrations/adapters/discord.d.ts.map +1 -1
  179. package/dist/integrations/adapters/discord.js +1 -0
  180. package/dist/integrations/adapters/discord.js.map +1 -1
  181. package/dist/integrations/adapters/email.d.ts.map +1 -1
  182. package/dist/integrations/adapters/email.js +2 -0
  183. package/dist/integrations/adapters/email.js.map +1 -1
  184. package/dist/integrations/adapters/google-docs.d.ts.map +1 -1
  185. package/dist/integrations/adapters/google-docs.js +2 -0
  186. package/dist/integrations/adapters/google-docs.js.map +1 -1
  187. package/dist/integrations/adapters/microsoft-teams.d.ts.map +1 -1
  188. package/dist/integrations/adapters/microsoft-teams.js +1 -0
  189. package/dist/integrations/adapters/microsoft-teams.js.map +1 -1
  190. package/dist/integrations/adapters/slack.d.ts.map +1 -1
  191. package/dist/integrations/adapters/slack.js +21 -6
  192. package/dist/integrations/adapters/slack.js.map +1 -1
  193. package/dist/integrations/adapters/telegram.d.ts.map +1 -1
  194. package/dist/integrations/adapters/telegram.js +11 -2
  195. package/dist/integrations/adapters/telegram.js.map +1 -1
  196. package/dist/integrations/adapters/whatsapp.d.ts.map +1 -1
  197. package/dist/integrations/adapters/whatsapp.js +3 -0
  198. package/dist/integrations/adapters/whatsapp.js.map +1 -1
  199. package/dist/integrations/index.d.ts +1 -1
  200. package/dist/integrations/index.d.ts.map +1 -1
  201. package/dist/integrations/index.js.map +1 -1
  202. package/dist/integrations/types.d.ts +8 -2
  203. package/dist/integrations/types.d.ts.map +1 -1
  204. package/dist/integrations/types.js.map +1 -1
  205. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  206. package/dist/integrations/webhook-handler.js +40 -43
  207. package/dist/integrations/webhook-handler.js.map +1 -1
  208. package/dist/observability/routes.d.ts +5 -5
  209. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  210. package/dist/secrets/routes.d.ts +9 -9
  211. package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
  212. package/dist/server/agent-chat/action-filters-a2a.js +1 -0
  213. package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
  214. package/dist/server/auth.d.ts +1 -0
  215. package/dist/server/auth.d.ts.map +1 -1
  216. package/dist/server/auth.js +19 -2
  217. package/dist/server/auth.js.map +1 -1
  218. package/dist/server/builder-browser.d.ts +78 -0
  219. package/dist/server/builder-browser.d.ts.map +1 -1
  220. package/dist/server/builder-browser.js +246 -1
  221. package/dist/server/builder-browser.js.map +1 -1
  222. package/dist/server/core-routes-plugin.d.ts +41 -0
  223. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  224. package/dist/server/core-routes-plugin.js +260 -14
  225. package/dist/server/core-routes-plugin.js.map +1 -1
  226. package/docs/content/deployment.mdx +9 -7
  227. package/docs/content/locales/ar-SA/deployment.mdx +9 -7
  228. package/docs/content/locales/de-DE/deployment.mdx +9 -7
  229. package/docs/content/locales/es-ES/deployment.mdx +9 -7
  230. package/docs/content/locales/fr-FR/deployment.mdx +9 -7
  231. package/docs/content/locales/hi-IN/deployment.mdx +9 -7
  232. package/docs/content/locales/ja-JP/deployment.mdx +9 -7
  233. package/docs/content/locales/ko-KR/deployment.mdx +9 -7
  234. package/docs/content/locales/pt-BR/deployment.mdx +9 -7
  235. package/docs/content/locales/zh-CN/deployment.mdx +9 -7
  236. package/docs/content/locales/zh-TW/deployment.mdx +9 -7
  237. package/package.json +2 -2
@@ -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,
@@ -49,6 +51,7 @@ import {
49
51
  parsePropertyOptions,
50
52
  serializePropertyOptions,
51
53
  serializePropertyValue,
54
+ type DocumentPropertyOption,
52
55
  type DocumentPropertyOptionColor,
53
56
  } from "../shared/properties.js";
54
57
  import { sanitizeNormalizationFormula } from "../shared/properties.js";
@@ -63,6 +66,7 @@ import {
63
66
  import {
64
67
  BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
65
68
  BUILDER_CMS_BODY_CONTENT_KEY,
69
+ BUILDER_CMS_BODY_LAST_UPDATED_KEY,
66
70
  BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
67
71
  BUILDER_CMS_BODY_READABLE_MAP_KEY,
68
72
  BUILDER_CMS_BODY_SIDECARS_KEY,
@@ -72,6 +76,7 @@ import {
72
76
  builderCmsSourceFieldKey,
73
77
  builderCmsSourceMetadata,
74
78
  builderCmsSourceRowIdentity,
79
+ builderCmsSourceRowIdentityState,
75
80
  type BuilderCmsSourceEntry,
76
81
  type ExistingBuilderSourceRowIdentity,
77
82
  } from "./_builder-cms-source-adapter.js";
@@ -102,6 +107,16 @@ type ContentDatabaseSourceChangeReviewRowDb =
102
107
  type ContentDatabaseSourceExecutionRowDb =
103
108
  typeof schema.contentDatabaseSourceExecutions.$inferSelect;
104
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
+
105
120
  const DEFAULT_SOURCE_CAPABILITIES: ContentDatabaseSourceCapabilities = {
106
121
  canRefresh: true,
107
122
  canCreateChangeSets: true,
@@ -318,7 +333,7 @@ export function serializeSourceField(
318
333
  };
319
334
  }
320
335
 
321
- function serializeSourceRowRecord(
336
+ export function serializeSourceRowRecord(
322
337
  row: ContentDatabaseSourceRecordRowDb,
323
338
  options: { includeHeavyBuilderBodyValues?: boolean } = {},
324
339
  ): ContentDatabaseSourceRow {
@@ -392,6 +407,37 @@ function sourceSnapshotRowSelection(args: {
392
407
  };
393
408
  }
394
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
+
395
441
  export function sourceValuesForSnapshot(
396
442
  sourceValues: Record<string, DocumentPropertyValue>,
397
443
  options: { includeHeavyBuilderBodyValues?: boolean } = {},
@@ -467,6 +513,47 @@ function serializeExecution(
467
513
  };
468
514
  }
469
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
+
470
557
  function riskRank(level: ContentDatabaseSourceRiskLevel) {
471
558
  if (level === "high") return 3;
472
559
  if (level === "medium") return 2;
@@ -608,7 +695,8 @@ const BUILDER_BODY_HYDRATION_OPEN_PRIORITY = 0;
608
695
  const BUILDER_BODY_HYDRATION_BATCH_LIMIT = 25;
609
696
  const BUILDER_BODY_HYDRATION_PROCESS_CONCURRENCY = 6;
610
697
  const BUILDER_BODY_HYDRATION_MAX_ATTEMPTS = 5;
611
- 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";
612
700
  const BUILDER_CMS_REFRESH_INITIAL_PAGES = 1;
613
701
  const BUILDER_BODY_NOT_AVAILABLE_ERROR = "body not yet available from Builder";
614
702
 
@@ -685,12 +773,27 @@ async function builderBodySnapshotForEntry(entry: BuilderCmsSourceEntry) {
685
773
  return {
686
774
  content: readableBundle.mdx.body,
687
775
  losslessContent: losslessBundle.mdx.body,
688
- blocksHash: builderBlocksHash(losslessBundle.blocks),
776
+ blocksHash: builderAuthoritativeRawBodyHash({
777
+ entry,
778
+ generatedBlocks: losslessBundle.blocks,
779
+ }),
689
780
  readableMapJson: null,
690
781
  sidecarsJson: JSON.stringify(sidecars),
691
782
  };
692
783
  }
693
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
+
694
797
  export function builderBodyHydrationVersion(entry: BuilderCmsSourceEntry) {
695
798
  const hash = stringSourceValue(
696
799
  entry.sourceValues,
@@ -699,6 +802,15 @@ export function builderBodyHydrationVersion(entry: BuilderCmsSourceEntry) {
699
802
  return `${hash ?? entry.updatedAt ?? entry.id}:${BUILDER_BODY_HYDRATION_CODEC_VERSION}`;
700
803
  }
701
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
+
702
814
  function normalizeHydrationLimit(limit: number | null | undefined) {
703
815
  if (typeof limit !== "number" || !Number.isFinite(limit)) {
704
816
  return BUILDER_BODY_HYDRATION_BATCH_LIMIT;
@@ -799,9 +911,14 @@ function builderStoredBodyIsStale(args: {
799
911
  >;
800
912
  entry: BuilderCmsSourceEntry;
801
913
  }) {
914
+ const expectedVersion =
915
+ args.item.bodyHydrationStatus === "unavailable"
916
+ ? builderBodyUnavailableVersion(args.entry)
917
+ : builderBodyHydrationVersion(args.entry);
802
918
  return (
803
- args.item.bodyHydrationStatus !== "hydrated" ||
804
- args.item.bodyHydrationVersion !== builderBodyHydrationVersion(args.entry)
919
+ (args.item.bodyHydrationStatus !== "hydrated" &&
920
+ args.item.bodyHydrationStatus !== "unavailable") ||
921
+ args.item.bodyHydrationVersion !== expectedVersion
805
922
  );
806
923
  }
807
924
 
@@ -812,7 +929,7 @@ function builderEntryHasBodyContent(entry: BuilderCmsSourceEntry | null) {
812
929
  )?.trim();
813
930
  }
814
931
 
815
- async function readableBuilderBodyFromStoredLossless(args: {
932
+ async function builderBodySnapshotFromStoredLossless(args: {
816
933
  losslessContent: string | null;
817
934
  sidecarsJson: string | null;
818
935
  }) {
@@ -824,12 +941,60 @@ async function readableBuilderBodyFromStoredLossless(args: {
824
941
  sidecars,
825
942
  );
826
943
  if (blocks.length === 0) return null;
827
- 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
+ );
828
967
  }
829
968
 
830
- async function refreshBuilderBodySourceValuesFromStoredLossless(
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
+ );
987
+ }
988
+
989
+ export async function refreshBuilderBodySourceValuesFromStoredLossless(
831
990
  entry: BuilderCmsSourceEntry,
832
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
+ }
833
998
  const losslessContent = stringSourceValue(
834
999
  entry.sourceValues,
835
1000
  BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
@@ -838,20 +1003,51 @@ async function refreshBuilderBodySourceValuesFromStoredLossless(
838
1003
  entry.sourceValues,
839
1004
  BUILDER_CMS_BODY_SIDECARS_KEY,
840
1005
  );
841
- const content = await readableBuilderBodyFromStoredLossless({
1006
+ const snapshot = await builderBodySnapshotFromStoredLossless({
842
1007
  losslessContent,
843
1008
  sidecarsJson,
844
1009
  });
845
- if (!content) return entry;
1010
+ if (!snapshot) return entry;
846
1011
  return {
847
1012
  ...entry,
848
1013
  sourceValues: {
849
1014
  ...entry.sourceValues,
850
- [BUILDER_CMS_BODY_CONTENT_KEY]: content,
1015
+ [BUILDER_CMS_BODY_CONTENT_KEY]: snapshot.content,
1016
+ [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: snapshot.blocksHash,
851
1017
  },
852
1018
  };
853
1019
  }
854
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
+
855
1051
  function builderEntryFromSourceRow(args: {
856
1052
  row: Pick<
857
1053
  ContentDatabaseSourceRecordRowDb,
@@ -910,8 +1106,12 @@ async function readBuilderEntryWithLiveBodyFromSourceRow(args: {
910
1106
  ...liveEntry.sourceValues,
911
1107
  },
912
1108
  };
913
- const refreshedEntry = await refreshBuilderBodySourceValuesFromStoredLossless(
914
- 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,
915
1115
  );
916
1116
  return builderEntryHasBodyContent(refreshedEntry) ? refreshedEntry : null;
917
1117
  }
@@ -1067,15 +1267,84 @@ export async function enqueueBuilderBodyHydrationForItems(args: {
1067
1267
  now: string;
1068
1268
  }) {
1069
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
+ }
1070
1326
  const requests: BuilderBodyHydrationEnqueueRequest[] = [];
1071
1327
  for (const item of args.items) {
1072
1328
  const entry = args.builderEntriesByDocumentId.get(item.document.id);
1073
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);
1074
1342
  if (
1075
- item.bodyHydration?.status === "hydrated" &&
1076
- item.bodyHydration.version === builderBodyHydrationVersion(entry) &&
1077
- !isEffectivelyEmptyDocumentContent(item.document.content) &&
1078
- !builderBodyIsRawPlaceholderOnly(item.document.content)
1343
+ (bodyHydrationStatus === "unavailable" ||
1344
+ (bodyHydrationStatus === "hydrated" &&
1345
+ !isEffectivelyEmptyDocumentContent(documentContent) &&
1346
+ !builderBodyIsRawPlaceholderOnly(documentContent))) &&
1347
+ bodyHydrationVersion === expectedVersion
1079
1348
  ) {
1080
1349
  continue;
1081
1350
  }
@@ -1196,11 +1465,16 @@ async function processBuilderBodyHydrationJob(
1196
1465
  preloaded?: {
1197
1466
  sourceRow?: ContentDatabaseSourceRecordRowDb | null;
1198
1467
  documentContent?: string | null;
1468
+ bodyHydrationVersion?: string | null;
1199
1469
  },
1200
1470
  ) {
1201
1471
  const db = getDb();
1202
1472
  const entry = parseHydrationEntry(row);
1203
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
+ );
1204
1478
  let activeSourceEntryJson = row.sourceEntryJson;
1205
1479
  let entryWithBody = await refreshBuilderBodySourceValuesFromStoredLossless(
1206
1480
  await withBuilderBodySourceValues(entry),
@@ -1226,6 +1500,73 @@ async function processBuilderBodyHydrationJob(
1226
1500
  parseObject<Record<string, DocumentPropertyValue>>(
1227
1501
  sourceRow?.sourceValuesJson ?? "{}",
1228
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
+ }
1229
1570
  let nextValues = {
1230
1571
  ...sourceValues,
1231
1572
  ...entryWithBody.sourceValues,
@@ -1354,9 +1695,11 @@ async function processBuilderBodyHydrationJob(
1354
1695
  await tx
1355
1696
  .update(schema.contentDatabaseItems)
1356
1697
  .set({
1357
- bodyHydrationStatus: "error",
1698
+ bodyHydrationStatus: "unavailable",
1358
1699
  bodyHydrationAttemptedAt: now,
1359
- bodyHydrationError: BUILDER_BODY_NOT_AVAILABLE_ERROR,
1700
+ bodyHydrationError: null,
1701
+ bodyHydrationVersion:
1702
+ builderBodyUnavailableVersion(entryWithBody),
1360
1703
  updatedAt: now,
1361
1704
  })
1362
1705
  .where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
@@ -1569,14 +1912,11 @@ async function processBuilderBodyHydrationJob(
1569
1912
  })
1570
1913
  .where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
1571
1914
  });
1572
- if (wroteBody) {
1573
- try {
1574
- await deleteCollabState(row.documentId);
1575
- releaseDoc(row.documentId);
1576
- } catch {
1577
- // Non-fatal: the updated document row still lets open editors reconcile.
1578
- }
1579
- }
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.
1580
1920
  }
1581
1921
 
1582
1922
  async function enqueueStaleBuilderBodyHydrationForOpenDocument(args: {
@@ -1795,6 +2135,29 @@ export async function processBuilderBodyHydrationQueue(args: {
1795
2135
  const sourceRowsByItemId = new Map(
1796
2136
  sourceRows.map((row) => [row.databaseItemId, row]),
1797
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
+ );
1798
2161
  const documents =
1799
2162
  claimedJobs.length > 0
1800
2163
  ? (
@@ -1827,6 +2190,8 @@ export async function processBuilderBodyHydrationQueue(args: {
1827
2190
  if (delayMs > 0) await sleep(delayMs);
1828
2191
  await processBuilderBodyHydrationJob(job, attemptNow, {
1829
2192
  sourceRow: sourceRowsByItemId.get(job.databaseItemId) ?? null,
2193
+ bodyHydrationVersion:
2194
+ bodyHydrationVersionByItemId.get(job.databaseItemId) ?? null,
1830
2195
  documentContent: documentContentById.has(job.documentId)
1831
2196
  ? (documentContentById.get(job.documentId) ?? null)
1832
2197
  : undefined,
@@ -1934,6 +2299,8 @@ export async function withBuilderBodySourceValues(
1934
2299
  [BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY]: snapshot.losslessContent,
1935
2300
  [BUILDER_CMS_BODY_READABLE_MAP_KEY]: snapshot.readableMapJson,
1936
2301
  [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: snapshot.blocksHash,
2302
+ [BUILDER_CMS_BODY_LAST_UPDATED_KEY]:
2303
+ stringSourceValue(entry.sourceValues, "lastUpdated") ?? entry.updatedAt,
1937
2304
  [BUILDER_CMS_BODY_SIDECARS_KEY]: snapshot.sidecarsJson,
1938
2305
  },
1939
2306
  };
@@ -1972,9 +2339,17 @@ export async function builderBodyChangeForLocalContent(args: {
1972
2339
  const localContent = args.localContent ?? "";
1973
2340
  if (!currentHash && !currentContent && !localContent.trim()) return null;
1974
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);
1975
2349
  const normalizedLocalContent =
1976
2350
  normalizeBuilderBodyBaselineContent(localContent);
1977
2351
  if (
2352
+ !usesCurrentMediaConverter &&
1978
2353
  normalizedLocalContent &&
1979
2354
  normalizedLocalContent ===
1980
2355
  normalizeBuilderBodyBaselineContent(currentContent)
@@ -1982,6 +2357,7 @@ export async function builderBodyChangeForLocalContent(args: {
1982
2357
  return null;
1983
2358
  }
1984
2359
  if (
2360
+ !usesCurrentMediaConverter &&
1985
2361
  normalizedLocalContent &&
1986
2362
  normalizedLocalContent ===
1987
2363
  normalizeBuilderBodyBaselineContent(losslessContent)
@@ -2004,8 +2380,15 @@ export async function builderBodyChangeForLocalContent(args: {
2004
2380
  }
2005
2381
 
2006
2382
  try {
2007
- const proposed =
2008
- 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")
2009
2392
  ? await builderReadableBodyToBuilderBlocks({
2010
2393
  localContent,
2011
2394
  losslessContent,
@@ -2061,6 +2444,98 @@ export async function builderBodyChangeForLocalContent(args: {
2061
2444
  }
2062
2445
  }
2063
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
+
2064
2539
  export function buildBuilderLocalOutboundChangeSets(args: {
2065
2540
  source: ContentDatabaseSourceRowDb;
2066
2541
  rowRows: ContentDatabaseSourceRecordRowDb[];
@@ -2077,6 +2552,8 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2077
2552
  sourceFieldLabel: string;
2078
2553
  propertyType?: DocumentProperty["definition"]["type"] | null;
2079
2554
  propertyOptions?: DocumentPropertyOptions;
2555
+ sourceFieldType?: string;
2556
+ sourceFieldModel?: string;
2080
2557
  }>;
2081
2558
  // Row-union scoping (multi-source). Documents owned by ANOTHER source must
2082
2559
  // never be create candidates for this one — each row belongs to exactly one
@@ -2092,6 +2569,12 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2092
2569
  taggedSourceByDocumentId?: Map<string, string>;
2093
2570
  bodyChangeByDocumentId?: Map<string, ContentDatabaseSourceBodyChange>;
2094
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>;
2095
2578
  }): ContentDatabaseSourceChangeSet[] {
2096
2579
  if (normalizeSourceType(args.source.sourceType) !== "builder-cms") return [];
2097
2580
 
@@ -2102,6 +2585,106 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2102
2585
  normalizeCapabilities(args.source.capabilitiesJson).liveWritesEnabled ===
2103
2586
  true;
2104
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
+ };
2105
2688
  for (const row of args.rowRows) {
2106
2689
  if (
2107
2690
  skipFixtureRows &&
@@ -2110,7 +2693,15 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2110
2693
  continue;
2111
2694
  }
2112
2695
 
2113
- 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();
2114
2705
  const localTitle = args.documentTitleById.get(row.documentId)?.trim() ?? "";
2115
2706
  const fieldChanges: ContentDatabaseSourceFieldChange[] = [];
2116
2707
  if (localTitle && localTitle !== sourceTitle) {
@@ -2128,18 +2719,20 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2128
2719
  // local value means "not loaded", not "cleared" — skip it.
2129
2720
  const rowLocalValues = args.localValuesByDocument?.get(row.documentId);
2130
2721
  if (rowLocalValues) {
2131
- const rowSourceValues =
2132
- parseObject<Record<string, DocumentPropertyValue>>(
2133
- row.sourceValuesJson,
2134
- ) ?? {};
2135
2722
  for (const field of args.writableFields ?? []) {
2136
2723
  if (!rowLocalValues.has(field.localFieldKey)) continue;
2137
2724
  const localValue = rowLocalValues.get(field.localFieldKey);
2138
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;
2139
2732
  if (
2140
2733
  sameMappedSourceFieldValue(
2141
2734
  localValue,
2142
- baseValue,
2735
+ comparisonBaseValue,
2143
2736
  field.propertyType,
2144
2737
  field.propertyOptions,
2145
2738
  )
@@ -2158,7 +2751,7 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2158
2751
  field.propertyOptions,
2159
2752
  )
2160
2753
  : (baseValue ?? null)) as DocumentPropertyValue,
2161
- proposedValue: localValue as DocumentPropertyValue,
2754
+ ...reviewableBuilderValue(field, localValue),
2162
2755
  });
2163
2756
  }
2164
2757
  }
@@ -2205,8 +2798,12 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2205
2798
 
2206
2799
  const now = new Date().toISOString();
2207
2800
  const displayTitle = localTitle || sourceTitle;
2208
- pending.push({
2209
- 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`,
2210
2807
  databaseItemId: row.databaseItemId,
2211
2808
  documentId: row.documentId,
2212
2809
  kind:
@@ -2233,7 +2830,8 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2233
2830
  executions: [],
2234
2831
  createdAt: now,
2235
2832
  updatedAt: now,
2236
- });
2833
+ };
2834
+ if (!sameCancelledSnapshot(candidate)) pending.push(candidate);
2237
2835
  }
2238
2836
 
2239
2837
  // New-row creates: a local database item NOT linked to a Builder entry (no
@@ -2296,12 +2894,14 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2296
2894
  localFieldKey: field.localFieldKey,
2297
2895
  sourceFieldKey: field.sourceFieldKey,
2298
2896
  currentValue: null,
2299
- proposedValue: (localValues.get(field.localFieldKey) ??
2300
- null) as DocumentPropertyValue,
2897
+ ...reviewableBuilderValue(
2898
+ field,
2899
+ localValues.get(field.localFieldKey) ?? null,
2900
+ ),
2301
2901
  });
2302
2902
  }
2303
2903
  const now = new Date().toISOString();
2304
- pending.push({
2904
+ const candidate: ContentDatabaseSourceChangeSet = {
2305
2905
  id: `local-pending-create-${item.databaseItemId}`,
2306
2906
  databaseItemId: item.databaseItemId,
2307
2907
  documentId: item.documentId,
@@ -2322,7 +2922,8 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2322
2922
  executions: [],
2323
2923
  createdAt: now,
2324
2924
  updatedAt: now,
2325
- });
2925
+ };
2926
+ if (!sameCancelledSnapshot(candidate)) pending.push(candidate);
2326
2927
  }
2327
2928
  }
2328
2929
 
@@ -2520,21 +3121,31 @@ async function readSourceSnapshotRowsOnce(args: {
2520
3121
  ...databaseItemRows.map((item) => item.documentId),
2521
3122
  ]),
2522
3123
  );
2523
- const rowDocuments =
3124
+ const rowDocuments: Array<{
3125
+ id: string;
3126
+ title: string;
3127
+ content?: string;
3128
+ }> =
2524
3129
  allDocumentIds.length > 0
2525
- ? await db
2526
- .select({
2527
- id: schema.documents.id,
2528
- title: schema.documents.title,
2529
- content: schema.documents.content,
2530
- })
2531
- .from(schema.documents)
2532
- .where(
2533
- and(
2534
- inArray(schema.documents.id, allDocumentIds),
2535
- eq(schema.documents.ownerEmail, args.source.ownerEmail),
2536
- ),
2537
- )
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
+ )
2538
3149
  : [];
2539
3150
  const propertyValueRows =
2540
3151
  args.isBuilderSource && allDocumentIds.length > 0
@@ -2686,13 +3297,19 @@ async function loadSourceSnapshot(
2686
3297
  const propertyOptionsById = new Map(
2687
3298
  propertyDefs.map((row) => [row.id, parsePropertyOptions(row.optionsJson)]),
2688
3299
  );
3300
+ const builderModelFieldBySourceKey = new Map(
3301
+ (
3302
+ parseObject<SourceMetadataRecord>(source.metadataJson)
3303
+ ?.builderModelFields ?? []
3304
+ ).map((field) => [`data.${field.name.trim()}`, field]),
3305
+ );
2689
3306
  const fields = fieldRows.map((row) =>
2690
3307
  serializeSourceField(
2691
3308
  row,
2692
3309
  row.propertyId ? (propertyNameById.get(row.propertyId) ?? null) : null,
2693
3310
  ),
2694
3311
  );
2695
- const storedChangeSets = changeRows.map(serializeSourceChangeSet);
3312
+ let storedChangeSets = changeRows.map(serializeSourceChangeSet);
2696
3313
  const reviewEventsByChangeSetId = new Map<
2697
3314
  string,
2698
3315
  ContentDatabaseSourceReviewEvent[]
@@ -2764,6 +3381,9 @@ async function loadSourceSnapshot(
2764
3381
  propertyOptions: row.propertyId
2765
3382
  ? propertyOptionsById.get(row.propertyId)
2766
3383
  : undefined,
3384
+ sourceFieldType: row.sourceFieldType,
3385
+ sourceFieldModel: builderModelFieldBySourceKey.get(row.sourceFieldKey)
3386
+ ?.model,
2767
3387
  }));
2768
3388
  // Row-union ownership scoping (Builder only). Determine which documents belong
2769
3389
  // to OTHER sources and whether this source is the primary (oldest), so the
@@ -2844,11 +3464,19 @@ async function loadSourceSnapshot(
2844
3464
  );
2845
3465
  await Promise.all(
2846
3466
  allDocumentIds.map(async (documentId) => {
2847
- if (!hydratedDocumentIds.has(documentId)) return;
2848
3467
  const row = sourceRowByDocumentId.get(documentId);
2849
- if (!row) return;
2850
- 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({
2851
3477
  row,
3478
+ isHydrated: hydratedDocumentIds.has(documentId),
3479
+ allowUnsourcedCreate,
2852
3480
  localContent: documentContentById.get(documentId),
2853
3481
  });
2854
3482
  if (bodyChange) bodyChangeByDocumentId.set(documentId, bodyChange);
@@ -2856,6 +3484,77 @@ async function loadSourceSnapshot(
2856
3484
  );
2857
3485
  }
2858
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
+
2859
3558
  const localOutboundChangeSets = buildBuilderLocalOutboundChangeSets({
2860
3559
  source,
2861
3560
  rowRows,
@@ -2876,10 +3575,11 @@ async function loadSourceSnapshot(
2876
3575
  .filter((row) => row.provenance === "Builder CMS read adapter")
2877
3576
  .map((row) => row.documentId),
2878
3577
  ),
3578
+ cancelledRejectedChangeSetIds,
2879
3579
  });
2880
3580
  const rowByDocumentId = new Map(rowRows.map((row) => [row.documentId, row]));
2881
- const changeSets = [...storedChangeSets, ...localOutboundChangeSets].map(
2882
- (changeSet) =>
3581
+ const changeSets = [
3582
+ ...storedChangeSets.map((changeSet) =>
2883
3583
  reviewedChangeSet({
2884
3584
  changeSet,
2885
3585
  source,
@@ -2887,7 +3587,20 @@ async function loadSourceSnapshot(
2887
3587
  reviewEvents: reviewEventsByChangeSetId.get(changeSet.id) ?? [],
2888
3588
  executions: executionsByChangeSetId.get(changeSet.id) ?? [],
2889
3589
  }),
2890
- );
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
+ ];
2891
3604
  const metadata = parseObject<SourceMetadataRecord>(source.metadataJson) ?? {};
2892
3605
  const normalizedWriteMode =
2893
3606
  metadata.writeMode === "read_only" ||
@@ -2988,6 +3701,7 @@ async function loadSourceSnapshot(
2988
3701
  .map((mode) => normalizePushMode(mode))
2989
3702
  .filter((mode): mode is ContentDatabaseSourcePushMode => !!mode)
2990
3703
  : undefined,
3704
+ builderModelFields: metadata.builderModelFields,
2991
3705
  federation: normalizeSourceFederation(metadata.federation),
2992
3706
  },
2993
3707
  fields,
@@ -3034,6 +3748,7 @@ async function sourceBodyHydrationSummary(args: {
3034
3748
  pending: 0,
3035
3749
  hydrating: 0,
3036
3750
  hydrated: 0,
3751
+ unavailable: 0,
3037
3752
  error: 0,
3038
3753
  total: rows.length,
3039
3754
  };
@@ -3041,6 +3756,7 @@ async function sourceBodyHydrationSummary(args: {
3041
3756
  if (row.status === "pending" || (!row.status && row.queueId)) {
3042
3757
  summary.pending += 1;
3043
3758
  } else if (row.status === "hydrating") summary.hydrating += 1;
3759
+ else if (row.status === "unavailable") summary.unavailable! += 1;
3044
3760
  else if (row.status === "error") summary.error += 1;
3045
3761
  else summary.hydrated += 1;
3046
3762
  }
@@ -3157,6 +3873,7 @@ export function serializeBuilderCmsSourceReadMetadataRecord(args: {
3157
3873
  );
3158
3874
  return JSON.stringify({
3159
3875
  ...builderCmsSourceMetadata(args.sourceTable),
3876
+ ...existingMetadata,
3160
3877
  builderModelFields:
3161
3878
  args.builderModelFields ?? existingMetadata?.builderModelFields,
3162
3879
  readMode: args.readState === "live" ? "builder-api" : "fixture",
@@ -3258,6 +3975,41 @@ function normalizeBuilderCmsSourceFieldType(type: string) {
3258
3975
  return normalized || "text";
3259
3976
  }
3260
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
+
3261
4013
  export async function seedMockSourceFields(args: {
3262
4014
  sourceId: string;
3263
4015
  ownerEmail: string;
@@ -3275,6 +4027,12 @@ export async function seedMockSourceFields(args: {
3275
4027
  .filter((field) => isBuilder && field.propertyId)
3276
4028
  .map((field) => [field.propertyId!, field]),
3277
4029
  );
4030
+ const builderModelFieldBySourceKey = new Map(
4031
+ (args.builderModelFields ?? []).map((field) => [
4032
+ `data.${field.name.trim()}`,
4033
+ field,
4034
+ ]),
4035
+ );
3278
4036
  const rows = [
3279
4037
  {
3280
4038
  id: crypto.randomUUID(),
@@ -3385,7 +4143,18 @@ export async function seedMockSourceFields(args: {
3385
4143
  ))
3386
4144
  : `fields.${slugifySourceField(property.definition.name)}`,
3387
4145
  sourceFieldLabel: property.definition.name,
3388
- 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,
3389
4158
  mappingType: "property",
3390
4159
  writeOwner:
3391
4160
  property.definition.type === "created_time" ||
@@ -3524,65 +4293,77 @@ export async function seedMockSourceRows(args: {
3524
4293
  }) {
3525
4294
  if (args.items.length === 0) return;
3526
4295
  const db = getDb();
3527
- await db.insert(schema.contentDatabaseSourceRows).values(
3528
- args.items.map((item, index) => {
3529
- const builderEntry = args.builderEntriesByDocumentId?.get(
3530
- item.document.id,
3531
- );
3532
- const existingBuilderRow = args.existingBuilderRows?.get(
3533
- item.document.id,
3534
- );
3535
- const builderIdentity =
3536
- args.sourceType === "builder-cms"
3537
- ? 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,
3538
4342
  item,
3539
4343
  sourceTable: args.sourceTable,
3540
4344
  now: args.now,
3541
- existing: existingBuilderRow,
3542
- entry: builderEntry,
3543
- })
3544
- : null;
3545
- return {
3546
- id: crypto.randomUUID(),
3547
- ownerEmail: args.ownerEmail,
3548
- sourceId: args.sourceId,
3549
- databaseItemId: item.id,
3550
- documentId: item.document.id,
3551
- sourceRowId: builderIdentity
3552
- ? builderIdentity.sourceRowId
3553
- : `${args.sourceType}-${item.document.id}`,
3554
- sourceQualifiedId: builderIdentity
3555
- ? builderIdentity.sourceQualifiedId
3556
- : `${args.sourceType}://${args.sourceTable}/${item.document.id}`,
3557
- sourceDisplayKey:
3558
- builderIdentity?.sourceDisplayKey ??
3559
- item.document.title?.trim() ??
3560
- `${args.sourceType}-${index + 1}`,
3561
- sourceValuesJson: JSON.stringify(
3562
- sourceValuesForSeededSourceRow({
3563
- sourceType: args.sourceType,
3564
- item,
3565
- sourceTable: args.sourceTable,
3566
- now: args.now,
3567
- builderEntry,
3568
- existingSourceValuesJson: existingBuilderRow?.sourceValuesJson,
3569
- }),
3570
- ),
3571
- provenance:
3572
- args.sourceType === "builder-cms"
3573
- ? args.builderEntriesByDocumentId?.has(item.document.id)
3574
- ? "Builder CMS read adapter"
3575
- : BUILDER_CMS_FIXTURE_ROW_PROVENANCE
3576
- : "mock source row",
3577
- syncState: "linked",
3578
- freshness: "fresh",
3579
- lastSyncedAt: args.now,
3580
- lastSourceUpdatedAt: builderIdentity?.lastSourceUpdatedAt ?? args.now,
3581
- createdAt: args.now,
3582
- updatedAt: args.now,
3583
- };
3584
- }),
3585
- );
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();
3586
4367
  }
3587
4368
 
3588
4369
  export function sourceValuesForSeededSourceRow(args: {
@@ -3592,15 +4373,40 @@ export function sourceValuesForSeededSourceRow(args: {
3592
4373
  now: string;
3593
4374
  builderEntry?: BuilderCmsSourceEntry | null;
3594
4375
  existingSourceValuesJson?: string | null;
4376
+ existingLastSourceUpdatedAt?: string | null;
3595
4377
  }): Record<string, DocumentPropertyValue> {
3596
4378
  const existingSourceValues = parseObject<
3597
4379
  Record<string, DocumentPropertyValue>
3598
4380
  >(args.existingSourceValuesJson);
3599
4381
  if (args.builderEntry?.sourceValues) {
3600
- return builderSourceValuesWithPreservedBodyBaseline({
4382
+ const values = builderSourceValuesWithPreservedBodyBaseline({
3601
4383
  incoming: args.builderEntry.sourceValues,
3602
4384
  existing: existingSourceValues,
4385
+ existingLastSourceUpdatedAt: args.existingLastSourceUpdatedAt,
3603
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;
3604
4410
  }
3605
4411
  if (existingSourceValues) return existingSourceValues;
3606
4412
  if (args.sourceType !== "builder-cms") return {};
@@ -3614,6 +4420,7 @@ export function sourceValuesForSeededSourceRow(args: {
3614
4420
  function builderSourceValuesWithPreservedBodyBaseline(args: {
3615
4421
  incoming: Record<string, DocumentPropertyValue>;
3616
4422
  existing?: Record<string, DocumentPropertyValue> | null;
4423
+ existingLastSourceUpdatedAt?: string | null;
3617
4424
  }) {
3618
4425
  const existing = args.existing;
3619
4426
  if (!existing) return args.incoming;
@@ -3633,24 +4440,66 @@ function builderSourceValuesWithPreservedBodyBaseline(args: {
3633
4440
  existing,
3634
4441
  BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
3635
4442
  );
3636
- if (
3637
- incomingContent?.trim() ||
3638
- !existingContent?.trim() ||
3639
- (incomingHash && existingHash && incomingHash !== existingHash)
3640
- ) {
3641
- return args.incoming;
3642
- }
3643
-
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;
3644
4456
  const next = { ...args.incoming };
3645
- for (const key of [
3646
- BUILDER_CMS_BODY_CONTENT_KEY,
3647
- BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
3648
- BUILDER_CMS_BODY_READABLE_MAP_KEY,
3649
- BUILDER_CMS_BODY_SIDECARS_KEY,
3650
- BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
3651
- ]) {
3652
- if (next[key] === undefined && existing[key] !== undefined) {
3653
- 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;
3654
4503
  }
3655
4504
  }
3656
4505
  return next;
@@ -4113,13 +4962,19 @@ export function mapBuilderCmsEntriesToLocalItems(args: {
4113
4962
  sourceTable: args.sourceTable,
4114
4963
  now: args.now,
4115
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;
4116
4972
  const match =
4117
- (existing
4118
- ? (entriesById.get(existing.sourceRowId) ??
4119
- entriesByQualifiedId.get(existing.sourceQualifiedId))
4120
- : null) ??
4121
- entriesByUrlPath.get(fixtureEntry.urlPath.toLowerCase()) ??
4122
- 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);
4123
4978
  if (match) entriesByDocumentId.set(item.document.id, match);
4124
4979
  }
4125
4980
 
@@ -4206,8 +5061,18 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
4206
5061
  eq(schema.documents.id, schema.contentDatabaseItems.documentId),
4207
5062
  )
4208
5063
  .where(eq(schema.contentDatabaseItems.databaseId, args.database.id));
4209
- const existingTitles = new Set(
4210
- 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()),
4211
5076
  );
4212
5077
 
4213
5078
  // Reads MAX(position) for both `documents` and `content_database_items`
@@ -4256,11 +5121,24 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
4256
5121
 
4257
5122
  const title = entry.title.trim() || entry.id;
4258
5123
  const titleKey = title.toLowerCase();
4259
- if (!args.skipTitleDedup && existingTitles.has(titleKey)) continue;
4260
- existingTitles.add(titleKey);
5124
+ if (!args.skipTitleDedup && existingUnlinkedTitles.has(titleKey)) {
5125
+ continue;
5126
+ }
4261
5127
 
4262
- const documentId = nanoid();
4263
- 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
+ );
4264
5142
  documentRows.push({
4265
5143
  id: documentId,
4266
5144
  ownerEmail: args.database.ownerEmail,
@@ -4295,13 +5173,19 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
4295
5173
  documentRows,
4296
5174
  bulkChunkSizeForColumnCount(13),
4297
5175
  )) {
4298
- await tx.insert(schema.documents).values(chunk);
5176
+ await tx
5177
+ .insert(schema.documents)
5178
+ .values(chunk)
5179
+ .onConflictDoNothing();
4299
5180
  }
4300
5181
  for (const chunk of chunks(
4301
5182
  itemRows,
4302
5183
  bulkChunkSizeForColumnCount(10),
4303
5184
  )) {
4304
- await tx.insert(schema.contentDatabaseItems).values(chunk);
5185
+ await tx
5186
+ .insert(schema.contentDatabaseItems)
5187
+ .values(chunk)
5188
+ .onConflictDoNothing();
4305
5189
  }
4306
5190
  });
4307
5191
 
@@ -4345,6 +5229,10 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
4345
5229
  builderModelFields = await readBuilderCmsModelFields({
4346
5230
  model: args.source.sourceTable,
4347
5231
  });
5232
+ builderModelFields = mergeBuilderCmsModelFieldsPreservingReferenceModels({
5233
+ existing: sourceMetadata.builderModelFields,
5234
+ refreshed: builderModelFields,
5235
+ });
4348
5236
  } catch (error) {
4349
5237
  builderModelFieldsReadFailed = true;
4350
5238
  const message = error instanceof Error ? error.message : String(error);
@@ -5193,6 +6081,34 @@ const SOURCE_OPTION_PALETTE: DocumentPropertyOptionColor[] = [
5193
6081
  "red",
5194
6082
  ];
5195
6083
 
6084
+ export function sourcePropertyOptionsForSources(
6085
+ sources: Array<{ id: string; sourceName: string }>,
6086
+ priorOptions: DocumentPropertyOption[],
6087
+ ) {
6088
+ const priorById = new Map(priorOptions.map((option) => [option.id, option]));
6089
+ const retainDescription = (id: string) => {
6090
+ const description = priorById.get(id)?.description;
6091
+ return description ? { description } : {};
6092
+ };
6093
+ return [
6094
+ ...sources.map((source, index) => ({
6095
+ id: source.id,
6096
+ name: source.sourceName,
6097
+ color:
6098
+ priorById.get(source.id)?.color ??
6099
+ SOURCE_OPTION_PALETTE[index % SOURCE_OPTION_PALETTE.length],
6100
+ ...retainDescription(source.id),
6101
+ })),
6102
+ {
6103
+ id: SOURCE_LOCAL_OPTION_ID,
6104
+ name: "Local",
6105
+ color: (priorById.get(SOURCE_LOCAL_OPTION_ID)?.color ??
6106
+ "gray") as DocumentPropertyOptionColor,
6107
+ ...retainDescription(SOURCE_LOCAL_OPTION_ID),
6108
+ },
6109
+ ];
6110
+ }
6111
+
5196
6112
  /**
5197
6113
  * Ensure a "Source" select property exists tagging each row with the collection
5198
6114
  * it belongs to, and (re)set every item's value. Rows with no source binding are
@@ -5233,22 +6149,7 @@ export async function ensureDatabaseSourceProperty(args: {
5233
6149
  // that can't collide with a UUID source id). Resolving a row's tag back to a
5234
6150
  // source is then pure id matching — no source-name hop — so duplicate display
5235
6151
  // names or a collection literally named "Local" can never misroute a row.
5236
- const priorById = new Map(priorOptions.map((option) => [option.id, option]));
5237
- const options = [
5238
- ...sources.map((source, index) => ({
5239
- id: source.id,
5240
- name: source.sourceName,
5241
- color:
5242
- priorById.get(source.id)?.color ??
5243
- SOURCE_OPTION_PALETTE[index % SOURCE_OPTION_PALETTE.length],
5244
- })),
5245
- {
5246
- id: SOURCE_LOCAL_OPTION_ID,
5247
- name: "Local",
5248
- color: (priorById.get(SOURCE_LOCAL_OPTION_ID)?.color ??
5249
- "gray") as DocumentPropertyOptionColor,
5250
- },
5251
- ];
6152
+ const options = sourcePropertyOptionsForSources(sources, priorOptions);
5252
6153
  const optionsJson = serializePropertyOptions({ options });
5253
6154
 
5254
6155
  let propertyId: string;