@agent-native/core 0.123.0 → 0.123.2

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 (334) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +24 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/templates-meta.ts +12 -0
  5. package/corpus/core/src/deploy/build.ts +8 -0
  6. package/corpus/core/src/server/builder-browser.ts +76 -0
  7. package/corpus/core/src/server/core-routes-plugin.ts +15 -5
  8. package/corpus/templates/assets/app/global.css +0 -4
  9. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +195 -167
  10. package/corpus/templates/assets/changelog/2026-07-23-drafts-preview-panel-now-fills-the-full-width-with-a-large-p.md +6 -0
  11. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
  12. package/corpus/templates/content/actions/_builder-cms-write-client.ts +55 -9
  13. package/corpus/templates/content/actions/_database-source-utils.ts +756 -182
  14. package/corpus/templates/content/actions/change-content-database-source-role.ts +21 -20
  15. package/corpus/templates/content/actions/execute-builder-source-execution.ts +49 -8
  16. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +42 -9
  17. package/corpus/templates/content/actions/prepare-builder-source-review.ts +88 -90
  18. package/corpus/templates/content/actions/preview-builder-source-review.ts +4 -3
  19. package/corpus/templates/content/actions/refresh-content-database-source.ts +36 -9
  20. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +28 -24
  21. package/corpus/templates/content/app/components/editor/BubbleToolbar.tsx +62 -12
  22. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +91 -16
  23. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +15 -1
  24. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +234 -44
  25. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +194 -16
  26. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +95 -42
  27. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +4 -2
  28. package/corpus/templates/content/app/components/editor/extensions/CodeBlockNode.tsx +10 -166
  29. package/corpus/templates/content/app/components/editor/extensions/VideoBlock.tsx +32 -8
  30. package/corpus/templates/content/app/components/editor/extensions/VideoNode.ts +14 -0
  31. package/corpus/templates/content/app/components/editor/registrySlashItems.ts +5 -1
  32. package/corpus/templates/content/app/hooks/use-content-database.ts +59 -2
  33. package/corpus/templates/content/app/hooks/use-documents.ts +18 -26
  34. package/corpus/templates/content/app/root.tsx +1 -6
  35. package/corpus/templates/content/changelog/2026-07-24-builder-can-now-publish-approved-updates-to-entries-that-are.md +6 -0
  36. package/corpus/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md +6 -0
  37. package/corpus/templates/content/changelog/2026-07-24-builder-connections-now-complete-securely-from-netlify-deplo.md +6 -0
  38. package/corpus/templates/content/changelog/2026-07-24-builder-draft-creation-now-scopes-execution-to-the-selected-.md +6 -0
  39. package/corpus/templates/content/changelog/2026-07-24-builder-review-now-distinguishes-preparing-a-safe-dry-run-from.md +6 -0
  40. package/corpus/templates/content/changelog/2026-07-24-builder-source-imports-now-recover-from-a-transient-paginati.md +6 -0
  41. package/corpus/templates/content/changelog/2026-07-24-builder-source-refresh-now-preserves-the-provider-bound-fiel.md +6 -0
  42. package/corpus/templates/content/changelog/2026-07-24-builder-update-reviews-now-open-reliably-on-large-collection.md +6 -0
  43. package/corpus/templates/content/changelog/2026-07-24-builder-write-policy-controls-now-reflect-successful-changes.md +6 -0
  44. package/corpus/templates/content/changelog/2026-07-24-date-properties-now-save-reliably-when-selected-with-the-nat.md +6 -0
  45. package/corpus/templates/content/changelog/2026-07-24-editor-slash-search-now-keeps-multi-word-block-names-open.md +6 -0
  46. package/corpus/templates/content/changelog/2026-07-24-exact-editor-slash-commands-now-execute-on-enter-even-if-the.md +6 -0
  47. package/corpus/templates/content/changelog/2026-07-24-new-collaborative-pages-now-save-their-first-edits.md +6 -0
  48. package/corpus/templates/content/changelog/2026-07-24-new-local-builder-rows-no-longer-remain-locked-behind-a-none.md +6 -0
  49. package/corpus/templates/content/changelog/2026-07-24-opening-or-closing-a-collaborative-document-no-longer-lets-u.md +6 -0
  50. package/corpus/templates/content/changelog/2026-07-24-selected-text-now-opens-the-link-editor-with-command-k-or-co.md +6 -0
  51. package/corpus/templates/content/changelog/2026-07-24-slash-menu-block-choices-no-longer-stall-pointer-interaction.md +6 -0
  52. package/corpus/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md +6 -0
  53. package/corpus/templates/content/changelog/2026-07-24-video-link-insertion-now-keeps-its-source-panel-open-while-t.md +6 -0
  54. package/corpus/templates/content/changelog/2026-07-26-builder-publishing-now-shows-safe-validation-details-instead.md +6 -0
  55. package/corpus/templates/content/changelog/2026-07-26-builder-reviews-stay-responsive-on-large-collections-and-pre.md +6 -0
  56. package/corpus/templates/content/changelog/2026-07-26-collaborative-documents-no-longer-flash-duplicated-recent-bl.md +6 -0
  57. package/corpus/templates/content/changelog/2026-07-26-corrected-builder-updates-are-now-reviewed-separately-from-f.md +6 -0
  58. package/corpus/templates/content/changelog/2026-07-26-documents-now-remain-read-only-until-their-live-collaborativ.md +6 -0
  59. package/corpus/templates/content/changelog/2026-07-26-slash-command-menus-now-stay-reachable-when-editing-near-the.md +6 -0
  60. package/corpus/templates/content/changelog/2026-07-26-the-slash-menu-now-shows-one-clear-callout-choice-instead-of.md +6 -0
  61. package/corpus/templates/content/shared/api.ts +1 -0
  62. package/corpus/templates/content/shared/builder-mdx.ts +73 -16
  63. package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
  64. package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
  65. package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
  66. package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
  67. package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
  68. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
  69. package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
  70. package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
  71. package/corpus/templates/crm/.env.example +5 -0
  72. package/corpus/templates/crm/AGENTS.md +82 -0
  73. package/corpus/templates/crm/CHANGELOG.md +25 -0
  74. package/corpus/templates/crm/DEVELOPING.md +66 -0
  75. package/corpus/templates/crm/README.md +122 -0
  76. package/corpus/templates/crm/_gitignore +12 -0
  77. package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
  78. package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
  79. package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
  80. package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
  81. package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
  82. package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
  83. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
  84. package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
  85. package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
  86. package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
  87. package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
  88. package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
  89. package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
  90. package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
  91. package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
  92. package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
  93. package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
  94. package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
  95. package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
  96. package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
  97. package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
  98. package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
  99. package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
  100. package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
  101. package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
  102. package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
  103. package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
  104. package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
  105. package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
  106. package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
  107. package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
  108. package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
  109. package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
  110. package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
  111. package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
  112. package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
  113. package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
  114. package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
  115. package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
  116. package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
  117. package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
  118. package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
  119. package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
  120. package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
  121. package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
  122. package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
  123. package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
  124. package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
  125. package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
  126. package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
  127. package/corpus/templates/crm/actions/navigate.ts +54 -0
  128. package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
  129. package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
  130. package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
  131. package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
  132. package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
  133. package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
  134. package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
  135. package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
  136. package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
  137. package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
  138. package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
  139. package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
  140. package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
  141. package/corpus/templates/crm/actions/run.ts +7 -0
  142. package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
  143. package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
  144. package/corpus/templates/crm/actions/sync-crm.ts +146 -0
  145. package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
  146. package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
  147. package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
  148. package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
  149. package/corpus/templates/crm/actions/view-screen.ts +324 -0
  150. package/corpus/templates/crm/agent-native.app-skill.json +69 -0
  151. package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
  152. package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
  153. package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
  154. package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
  155. package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
  156. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
  157. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
  158. package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
  159. package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
  160. package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
  161. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
  162. package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
  163. package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
  164. package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
  165. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
  166. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
  167. package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
  168. package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
  169. package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
  170. package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
  171. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
  172. package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
  173. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
  174. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
  175. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
  176. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
  177. package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
  178. package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
  179. package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
  180. package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
  181. package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
  182. package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
  183. package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
  184. package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
  185. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
  186. package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
  187. package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
  188. package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
  189. package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
  190. package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
  191. package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
  192. package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
  193. package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
  194. package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
  195. package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
  196. package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
  197. package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
  198. package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
  199. package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
  200. package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
  201. package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
  202. package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
  203. package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
  204. package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
  205. package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
  206. package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
  207. package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
  208. package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
  209. package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
  210. package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
  211. package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
  212. package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
  213. package/corpus/templates/crm/app/design-system.ts +3 -0
  214. package/corpus/templates/crm/app/entry.client.tsx +19 -0
  215. package/corpus/templates/crm/app/entry.server.tsx +10 -0
  216. package/corpus/templates/crm/app/global.css +167 -0
  217. package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
  218. package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
  219. package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
  220. package/corpus/templates/crm/app/i18n/index.ts +34 -0
  221. package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
  222. package/corpus/templates/crm/app/lib/navigation.ts +17 -0
  223. package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
  224. package/corpus/templates/crm/app/lib/types.ts +172 -0
  225. package/corpus/templates/crm/app/lib/utils.ts +1 -0
  226. package/corpus/templates/crm/app/root.tsx +93 -0
  227. package/corpus/templates/crm/app/routes/_index.tsx +26 -0
  228. package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
  229. package/corpus/templates/crm/app/routes/agent.tsx +5 -0
  230. package/corpus/templates/crm/app/routes/ask.tsx +53 -0
  231. package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
  232. package/corpus/templates/crm/app/routes/lists.tsx +228 -0
  233. package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
  234. package/corpus/templates/crm/app/routes/people.tsx +41 -0
  235. package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
  236. package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
  237. package/corpus/templates/crm/app/routes/records.tsx +64 -0
  238. package/corpus/templates/crm/app/routes/settings.tsx +118 -0
  239. package/corpus/templates/crm/app/routes/setup.tsx +308 -0
  240. package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
  241. package/corpus/templates/crm/app/routes/views.tsx +683 -0
  242. package/corpus/templates/crm/app/routes.ts +19 -0
  243. package/corpus/templates/crm/app/vite-env.d.ts +6 -0
  244. package/corpus/templates/crm/changelog/2026-07-26-any-list-or-view-can-be-shown-as-a-board-grouped-by-stage-so.md +6 -0
  245. package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
  246. package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
  247. package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
  248. package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
  249. package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
  250. package/corpus/templates/crm/changelog/2026-07-26-press-cmd-k-to-jump-to-any-record-list-view-or-setting-and-u.md +6 -0
  251. package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
  252. package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
  253. package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
  254. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
  255. package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
  256. package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
  257. package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
  258. package/corpus/templates/crm/components.json +20 -0
  259. package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
  260. package/corpus/templates/crm/docs/features/README.md +55 -0
  261. package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
  262. package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
  263. package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
  264. package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
  265. package/corpus/templates/crm/learnings.defaults.md +5 -0
  266. package/corpus/templates/crm/netlify.toml +11 -0
  267. package/corpus/templates/crm/package.json +61 -0
  268. package/corpus/templates/crm/public/favicon.svg +1 -0
  269. package/corpus/templates/crm/public/icon-180.svg +1 -0
  270. package/corpus/templates/crm/public/manifest.json +11 -0
  271. package/corpus/templates/crm/react-router.config.ts +7 -0
  272. package/corpus/templates/crm/server/crm/adapter.ts +32 -0
  273. package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
  274. package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
  275. package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
  276. package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
  277. package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
  278. package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
  279. package/corpus/templates/crm/server/crm/read-through.ts +207 -0
  280. package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
  281. package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
  282. package/corpus/templates/crm/server/db/index.ts +191 -0
  283. package/corpus/templates/crm/server/db/schema.ts +647 -0
  284. package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
  285. package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
  286. package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
  287. package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
  288. package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
  289. package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
  290. package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
  291. package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
  292. package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
  293. package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
  294. package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
  295. package/corpus/templates/crm/server/middleware/auth.ts +4 -0
  296. package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
  297. package/corpus/templates/crm/server/plugins/auth.ts +14 -0
  298. package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
  299. package/corpus/templates/crm/server/plugins/db.ts +738 -0
  300. package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
  301. package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
  302. package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
  303. package/corpus/templates/crm/shared/crm-contract.ts +417 -0
  304. package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
  305. package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
  306. package/corpus/templates/crm/ssr-entry.ts +11 -0
  307. package/corpus/templates/crm/tsconfig.json +24 -0
  308. package/corpus/templates/crm/vite.config.ts +15 -0
  309. package/corpus/templates/crm/vitest.config.ts +5 -0
  310. package/corpus/templates/design/app/i18n-data.ts +12 -12
  311. package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
  312. package/corpus/toolkit/CHANGELOG.md +6 -0
  313. package/corpus/toolkit/package.json +1 -1
  314. package/corpus/toolkit/src/editor/useCollabReconcile.ts +14 -1
  315. package/dist/cli/templates-meta.d.ts.map +1 -1
  316. package/dist/cli/templates-meta.js +12 -0
  317. package/dist/cli/templates-meta.js.map +1 -1
  318. package/dist/collab/routes.d.ts +2 -2
  319. package/dist/deploy/build.js +6 -0
  320. package/dist/deploy/build.js.map +1 -1
  321. package/dist/observability/routes.d.ts +3 -3
  322. package/dist/server/builder-browser.d.ts +13 -0
  323. package/dist/server/builder-browser.d.ts.map +1 -1
  324. package/dist/server/builder-browser.js +59 -0
  325. package/dist/server/builder-browser.js.map +1 -1
  326. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  327. package/dist/server/core-routes-plugin.js +10 -5
  328. package/dist/server/core-routes-plugin.js.map +1 -1
  329. package/dist/server/transcribe-voice.d.ts +1 -1
  330. package/package.json +2 -2
  331. package/src/cli/templates-meta.ts +12 -0
  332. package/src/deploy/build.ts +8 -0
  333. package/src/server/builder-browser.ts +76 -0
  334. package/src/server/core-routes-plugin.ts +15 -5
@@ -162,6 +162,9 @@ type SourceMetadataRecord = {
162
162
  lastReadSuspiciousEmpty?: boolean;
163
163
  activeReadSourceRowIds?: string[];
164
164
  sourceFetchState?: "idle" | "fetching" | "error" | string;
165
+ builderContinuationClaimId?: string;
166
+ builderContinuationClaimOffset?: number;
167
+ builderContinuationClaimedAt?: string;
165
168
  allowDraftWrites?: boolean;
166
169
  allowPublishWrites?: boolean;
167
170
  allowedWriteModes?: ContentDatabaseSourcePushMode[];
@@ -183,6 +186,229 @@ function parseObject<T extends object>(
183
186
  }
184
187
  }
185
188
 
189
+ type ContentDatabaseSourceMetadataMutationRow = Pick<
190
+ ContentDatabaseSourceRowDb,
191
+ | "capabilitiesJson"
192
+ | "metadataJson"
193
+ | "sourceTable"
194
+ | "sourceType"
195
+ | "lastSourceUpdatedAt"
196
+ >;
197
+
198
+ type ContentDatabaseSourceMetadataMutationPatch = Pick<
199
+ ContentDatabaseSourceRowDb,
200
+ "metadataJson"
201
+ > &
202
+ Partial<
203
+ Pick<
204
+ ContentDatabaseSourceRowDb,
205
+ | "capabilitiesJson"
206
+ | "syncState"
207
+ | "freshness"
208
+ | "lastRefreshedAt"
209
+ | "lastSourceUpdatedAt"
210
+ | "lastError"
211
+ >
212
+ >;
213
+
214
+ /**
215
+ * Merge source metadata against the latest persisted row and compare-and-swap
216
+ * both JSON envelopes. Refresh ownership lives in metadataJson, so every
217
+ * unrelated writer must retry from the winning value instead of restoring a
218
+ * stale claim snapshot.
219
+ */
220
+ export async function mutateContentDatabaseSourceMetadata(args: {
221
+ sourceId: string;
222
+ now: string;
223
+ buildPatch: (
224
+ current: ContentDatabaseSourceMetadataMutationRow,
225
+ ) =>
226
+ | ContentDatabaseSourceMetadataMutationPatch
227
+ | Promise<ContentDatabaseSourceMetadataMutationPatch>;
228
+ failureMessage?: string;
229
+ }) {
230
+ const db = getDb();
231
+ for (let attempt = 0; attempt < 5; attempt += 1) {
232
+ const [current] = await db
233
+ .select({
234
+ capabilitiesJson: schema.contentDatabaseSources.capabilitiesJson,
235
+ metadataJson: schema.contentDatabaseSources.metadataJson,
236
+ sourceTable: schema.contentDatabaseSources.sourceTable,
237
+ sourceType: schema.contentDatabaseSources.sourceType,
238
+ lastSourceUpdatedAt: schema.contentDatabaseSources.lastSourceUpdatedAt,
239
+ })
240
+ .from(schema.contentDatabaseSources)
241
+ .where(eq(schema.contentDatabaseSources.id, args.sourceId))
242
+ .limit(1);
243
+ if (!current) throw new Error("Content database source not found.");
244
+
245
+ const patch = await args.buildPatch(current);
246
+ const updated = await db
247
+ .update(schema.contentDatabaseSources)
248
+ .set({ ...patch, updatedAt: args.now })
249
+ .where(
250
+ and(
251
+ eq(schema.contentDatabaseSources.id, args.sourceId),
252
+ eq(
253
+ schema.contentDatabaseSources.capabilitiesJson,
254
+ current.capabilitiesJson,
255
+ ),
256
+ current.metadataJson === null
257
+ ? isNull(schema.contentDatabaseSources.metadataJson)
258
+ : eq(
259
+ schema.contentDatabaseSources.metadataJson,
260
+ current.metadataJson,
261
+ ),
262
+ ),
263
+ )
264
+ .returning({ id: schema.contentDatabaseSources.id });
265
+ if (updated.length > 0) return;
266
+ }
267
+ throw new Error(
268
+ args.failureMessage ??
269
+ "Content database source metadata changed repeatedly; retry the action.",
270
+ );
271
+ }
272
+
273
+ export function builderCmsSourceContinuationIsCurrent(
274
+ metadataJson: string | null | undefined,
275
+ expectedOffset: number,
276
+ ) {
277
+ const metadata = parseObject<SourceMetadataRecord>(metadataJson);
278
+ return (
279
+ metadata?.sourceFetchState === "fetching" &&
280
+ metadata.lastReadHasMore === true &&
281
+ expectedOffset > 0 &&
282
+ metadata.lastReadNextOffset === expectedOffset &&
283
+ Array.isArray(metadata.activeReadSourceRowIds) &&
284
+ metadata.activeReadSourceRowIds.length > 0 &&
285
+ metadata.activeReadSourceRowIds.every((id) => typeof id === "string")
286
+ );
287
+ }
288
+
289
+ // Content's hosted request wall is 75 seconds. Keep the orphan-recovery lease
290
+ // well beyond that so an expired owner cannot still be executing mutations.
291
+ export const BUILDER_CMS_REFRESH_CLAIM_LEASE_MS = 30 * 60 * 1000;
292
+
293
+ export async function claimBuilderCmsSourceRefresh(args: {
294
+ source: ContentDatabaseSourceRowDb;
295
+ expectedOffset?: number;
296
+ now?: string;
297
+ }) {
298
+ const metadata =
299
+ parseObject<SourceMetadataRecord>(args.source.metadataJson) ?? {};
300
+ const now = args.now ?? new Date().toISOString();
301
+ const claimedAt = metadata.builderContinuationClaimedAt
302
+ ? Date.parse(metadata.builderContinuationClaimedAt)
303
+ : Number.NaN;
304
+ const activeClaim =
305
+ !!metadata.builderContinuationClaimId &&
306
+ Number.isFinite(claimedAt) &&
307
+ Date.parse(now) - claimedAt < BUILDER_CMS_REFRESH_CLAIM_LEASE_MS;
308
+ if (
309
+ activeClaim ||
310
+ (args.expectedOffset !== undefined &&
311
+ !builderCmsSourceContinuationIsCurrent(
312
+ args.source.metadataJson,
313
+ args.expectedOffset,
314
+ ))
315
+ ) {
316
+ return null;
317
+ }
318
+
319
+ const claimId = nanoid();
320
+ const claimedMetadataJson = JSON.stringify({
321
+ ...metadata,
322
+ builderContinuationClaimId: claimId,
323
+ builderContinuationClaimOffset: args.expectedOffset,
324
+ builderContinuationClaimedAt: now,
325
+ });
326
+ const [claimed] = await getDb()
327
+ .update(schema.contentDatabaseSources)
328
+ .set({ metadataJson: claimedMetadataJson })
329
+ .where(
330
+ and(
331
+ eq(schema.contentDatabaseSources.id, args.source.id),
332
+ args.source.metadataJson === null
333
+ ? isNull(schema.contentDatabaseSources.metadataJson)
334
+ : eq(
335
+ schema.contentDatabaseSources.metadataJson,
336
+ args.source.metadataJson,
337
+ ),
338
+ ),
339
+ )
340
+ .returning();
341
+ return claimed ? { source: claimed, claimId } : null;
342
+ }
343
+
344
+ export async function releaseBuilderCmsSourceRefreshClaim(args: {
345
+ sourceId: string;
346
+ claimId: string;
347
+ }) {
348
+ const db = getDb();
349
+ for (let attempt = 0; attempt < 5; attempt += 1) {
350
+ const [current] = await db
351
+ .select({ metadataJson: schema.contentDatabaseSources.metadataJson })
352
+ .from(schema.contentDatabaseSources)
353
+ .where(eq(schema.contentDatabaseSources.id, args.sourceId))
354
+ .limit(1);
355
+ const metadata = parseObject<SourceMetadataRecord>(current?.metadataJson);
356
+ if (!metadata || metadata.builderContinuationClaimId !== args.claimId) {
357
+ return false;
358
+ }
359
+ delete metadata.builderContinuationClaimId;
360
+ delete metadata.builderContinuationClaimOffset;
361
+ delete metadata.builderContinuationClaimedAt;
362
+ const nextMetadataJson = JSON.stringify(metadata);
363
+ const released = await db
364
+ .update(schema.contentDatabaseSources)
365
+ .set({ metadataJson: nextMetadataJson })
366
+ .where(
367
+ and(
368
+ eq(schema.contentDatabaseSources.id, args.sourceId),
369
+ eq(schema.contentDatabaseSources.metadataJson, current.metadataJson!),
370
+ ),
371
+ )
372
+ .returning({ id: schema.contentDatabaseSources.id });
373
+ if (released.length > 0) return true;
374
+ }
375
+ return false;
376
+ }
377
+
378
+ export async function renewBuilderCmsSourceRefreshClaim(args: {
379
+ sourceId: string;
380
+ claimId: string;
381
+ now?: string;
382
+ }) {
383
+ const db = getDb();
384
+ const now = args.now ?? new Date().toISOString();
385
+ for (let attempt = 0; attempt < 5; attempt += 1) {
386
+ const [current] = await db
387
+ .select({ metadataJson: schema.contentDatabaseSources.metadataJson })
388
+ .from(schema.contentDatabaseSources)
389
+ .where(eq(schema.contentDatabaseSources.id, args.sourceId))
390
+ .limit(1);
391
+ const metadata = parseObject<SourceMetadataRecord>(current?.metadataJson);
392
+ if (!metadata || metadata.builderContinuationClaimId !== args.claimId) {
393
+ return false;
394
+ }
395
+ metadata.builderContinuationClaimedAt = now;
396
+ const nextMetadataJson = JSON.stringify(metadata);
397
+ const renewed = await db
398
+ .update(schema.contentDatabaseSources)
399
+ .set({ metadataJson: nextMetadataJson })
400
+ .where(
401
+ and(
402
+ eq(schema.contentDatabaseSources.id, args.sourceId),
403
+ eq(schema.contentDatabaseSources.metadataJson, current.metadataJson!),
404
+ ),
405
+ )
406
+ .returning({ id: schema.contentDatabaseSources.id });
407
+ if (renewed.length > 0) return true;
408
+ }
409
+ return false;
410
+ }
411
+
186
412
  function parseArray<T>(value: string | null | undefined): T[] {
187
413
  if (!value) return [];
188
414
  try {
@@ -646,6 +872,26 @@ function stableValueString(value: unknown): string {
646
872
  return JSON.stringify(value);
647
873
  }
648
874
 
875
+ export function reviewedBuilderChangeSetRevisionId(
876
+ changeSet: ContentDatabaseSourceChangeSet,
877
+ ) {
878
+ const revision = createHash("sha256")
879
+ .update(
880
+ stableValueString({
881
+ databaseItemId: changeSet.databaseItemId,
882
+ documentId: changeSet.documentId,
883
+ kind: changeSet.kind,
884
+ direction: "outbound",
885
+ pushMode: changeSet.pushMode ?? "autosave",
886
+ fieldChanges: changeSet.fieldChanges,
887
+ bodyChange: changeSet.bodyChange,
888
+ }),
889
+ )
890
+ .digest("hex")
891
+ .slice(0, 16);
892
+ return `${changeSet.id}-revision-${revision}`;
893
+ }
894
+
649
895
  // Equal when both normalize the same. null/undefined/"" are all "empty"; strings
650
896
  // are trimmed; objects compared by stable serialization.
651
897
  function sameSourceFieldValue(a: unknown, b: unknown): boolean {
@@ -701,6 +947,15 @@ function normalizeBuilderBodyBaselineContent(value: string | null | undefined) {
701
947
  return (value ?? "").replace(/\r\n/g, "\n").trim();
702
948
  }
703
949
 
950
+ function builderBodyUsesCurrentMediaConverter(
951
+ content: string | null | undefined,
952
+ ) {
953
+ return (
954
+ /!\[[^\]]*\]\(\s*https?:\/\//i.test(content ?? "") ||
955
+ /<(?:img|video)\b/i.test(content ?? "")
956
+ );
957
+ }
958
+
704
959
  const BUILDER_BODY_HYDRATION_BACKGROUND_PRIORITY = 10;
705
960
  const BUILDER_BODY_HYDRATION_OPEN_PRIORITY = 0;
706
961
  const BUILDER_BODY_HYDRATION_BATCH_LIMIT = 25;
@@ -2355,8 +2610,7 @@ export async function builderBodyChangeForLocalContent(args: {
2355
2610
  // legacy Text block containing a Markdown image into a real Builder Image
2356
2611
  // (or emit a native Video) without changing the document text at all.
2357
2612
  const usesCurrentMediaConverter =
2358
- /!\[[^\]]*\]\(\s*https?:\/\//i.test(localContent) ||
2359
- /<(?:img|video)\b/i.test(localContent);
2613
+ builderBodyUsesCurrentMediaConverter(localContent);
2360
2614
  const normalizedLocalContent =
2361
2615
  normalizeBuilderBodyBaselineContent(localContent);
2362
2616
  if (
@@ -2624,6 +2878,16 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2624
2878
  stableValueString(candidate.bodyChange),
2625
2879
  );
2626
2880
 
2881
+ const withUniqueReviewIdentity = (
2882
+ candidate: ContentDatabaseSourceChangeSet,
2883
+ ): ContentDatabaseSourceChangeSet =>
2884
+ args.storedChangeSets.some((stored) => stored.id === candidate.id)
2885
+ ? {
2886
+ ...candidate,
2887
+ id: reviewedBuilderChangeSetRevisionId(candidate),
2888
+ }
2889
+ : candidate;
2890
+
2627
2891
  const reviewableBuilderValue = (
2628
2892
  field: NonNullable<typeof args.writableFields>[number],
2629
2893
  localValue: unknown,
@@ -2842,7 +3106,9 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2842
3106
  createdAt: now,
2843
3107
  updatedAt: now,
2844
3108
  };
2845
- if (!sameCancelledSnapshot(candidate)) pending.push(candidate);
3109
+ if (!sameCancelledSnapshot(candidate)) {
3110
+ pending.push(withUniqueReviewIdentity(candidate));
3111
+ }
2846
3112
  }
2847
3113
 
2848
3114
  // New-row creates: a local database item NOT linked to a Builder entry (no
@@ -2934,7 +3200,9 @@ export function buildBuilderLocalOutboundChangeSets(args: {
2934
3200
  createdAt: now,
2935
3201
  updatedAt: now,
2936
3202
  };
2937
- if (!sameCancelledSnapshot(candidate)) pending.push(candidate);
3203
+ if (!sameCancelledSnapshot(candidate)) {
3204
+ pending.push(withUniqueReviewIdentity(candidate));
3205
+ }
2938
3206
  }
2939
3207
  }
2940
3208
 
@@ -3064,6 +3332,181 @@ export async function getContentDatabaseSourceSnapshotForWrite(
3064
3332
  : null;
3065
3333
  }
3066
3334
 
3335
+ export function knownBuilderReviewDocumentIds(
3336
+ changeSets: Array<{ documentId: string | null }>,
3337
+ limit: number,
3338
+ ): string[] | null {
3339
+ if (changeSets.length === 0 || changeSets.length > limit) return null;
3340
+ const documentIds = new Set<string>();
3341
+ for (const changeSet of changeSets) {
3342
+ if (!changeSet.documentId) return null;
3343
+ documentIds.add(changeSet.documentId);
3344
+ }
3345
+ return [...documentIds];
3346
+ }
3347
+
3348
+ type BuilderReviewSourceValueTextKey =
3349
+ | typeof BUILDER_CMS_BODY_BLOCKS_HASH_KEY
3350
+ | typeof BUILDER_CMS_BODY_CONTENT_KEY;
3351
+
3352
+ export function builderReviewSourceValueTextProjection(
3353
+ key: BuilderReviewSourceValueTextKey,
3354
+ ) {
3355
+ const sourceValuesJson = schema.contentDatabaseSourceRows.sourceValuesJson;
3356
+ return getDialect() === "postgres"
3357
+ ? sql<string>`COALESCE(${sourceValuesJson}::jsonb ->> ${key}, '')`
3358
+ : sql<string>`COALESCE(json_extract(${sourceValuesJson}, ${`$."${key}"`}), '')`;
3359
+ }
3360
+
3361
+ async function findBuilderReviewBodyCandidateDocumentIds(args: {
3362
+ database: ContentDatabaseRow | ContentDatabase;
3363
+ source: ContentDatabaseSourceRowDb;
3364
+ }) {
3365
+ const sourceRows = schema.contentDatabaseSourceRows;
3366
+ const items = schema.contentDatabaseItems;
3367
+ const documents = schema.documents;
3368
+ const currentContent = builderReviewSourceValueTextProjection(
3369
+ BUILDER_CMS_BODY_CONTENT_KEY,
3370
+ );
3371
+ const localContentIsNonEmpty = sql<boolean>`TRIM(${documents.content}) <> ''`;
3372
+ const localContentDiffers = sql<boolean>`TRIM(${documents.content}) <> TRIM(${currentContent})`;
3373
+ const usesCurrentMediaConverter = sql<boolean>`(
3374
+ LOWER(${documents.content}) LIKE '%![%](http://%'
3375
+ OR LOWER(${documents.content}) LIKE '%![%](https://%'
3376
+ OR LOWER(${documents.content}) LIKE '%<img%'
3377
+ OR LOWER(${documents.content}) LIKE '%<video%'
3378
+ )`;
3379
+ const isSyntheticFixture = and(
3380
+ sql<boolean>`${sourceRows.sourceRowId} = 'builder-' || ${sourceRows.documentId}`,
3381
+ or(
3382
+ isNull(sourceRows.provenance),
3383
+ eq(sourceRows.provenance, BUILDER_CMS_FIXTURE_ROW_PROVENANCE),
3384
+ ),
3385
+ );
3386
+ const rows = await getDb()
3387
+ .select({
3388
+ documentId: sourceRows.documentId,
3389
+ })
3390
+ .from(sourceRows)
3391
+ .innerJoin(
3392
+ items,
3393
+ and(
3394
+ eq(items.databaseId, args.database.id),
3395
+ eq(items.documentId, sourceRows.documentId),
3396
+ eq(items.ownerEmail, args.source.ownerEmail),
3397
+ ),
3398
+ )
3399
+ .innerJoin(
3400
+ documents,
3401
+ and(
3402
+ eq(documents.id, sourceRows.documentId),
3403
+ eq(documents.ownerEmail, args.source.ownerEmail),
3404
+ ),
3405
+ )
3406
+ .where(
3407
+ and(
3408
+ eq(sourceRows.sourceId, args.source.id),
3409
+ or(
3410
+ and(isSyntheticFixture, localContentIsNonEmpty),
3411
+ and(
3412
+ eq(items.bodyHydrationStatus, "hydrated"),
3413
+ or(localContentDiffers, usesCurrentMediaConverter),
3414
+ ),
3415
+ ),
3416
+ ),
3417
+ );
3418
+ return rows.map((row) => row.documentId);
3419
+ }
3420
+
3421
+ /**
3422
+ * Load a complete Builder review snapshot without transferring every heavy
3423
+ * Builder body baseline. A light pass identifies field/stored changes, while a
3424
+ * narrow body index compares the editable document with the readable baseline.
3425
+ * Only candidate documents then load lossless body data and sidecars.
3426
+ */
3427
+ export async function getContentDatabaseSourceSnapshotForReview(
3428
+ database: ContentDatabaseRow | ContentDatabase,
3429
+ sourceId?: string | null,
3430
+ documentIds?: string[],
3431
+ ): Promise<ContentDatabaseSource | null> {
3432
+ if (documentIds?.length) {
3433
+ return getContentDatabaseSourceSnapshotForWrite(
3434
+ database,
3435
+ sourceId,
3436
+ documentIds,
3437
+ );
3438
+ }
3439
+
3440
+ const db = getDb();
3441
+ const [source] = sourceId
3442
+ ? await db
3443
+ .select()
3444
+ .from(schema.contentDatabaseSources)
3445
+ .where(
3446
+ and(
3447
+ eq(schema.contentDatabaseSources.id, sourceId),
3448
+ eq(schema.contentDatabaseSources.databaseId, database.id),
3449
+ ),
3450
+ )
3451
+ : await db
3452
+ .select()
3453
+ .from(schema.contentDatabaseSources)
3454
+ .where(eq(schema.contentDatabaseSources.databaseId, database.id))
3455
+ .orderBy(
3456
+ asc(schema.contentDatabaseSources.createdAt),
3457
+ asc(schema.contentDatabaseSources.id),
3458
+ );
3459
+ if (!source) return null;
3460
+
3461
+ const lightweight = await loadSourceSnapshot(source, database, {
3462
+ includeHeavyBuilderBodyValues: false,
3463
+ });
3464
+ const reviewableChanges = lightweight.changeSets.filter(
3465
+ (changeSet) =>
3466
+ changeSet.direction === "outbound" &&
3467
+ (changeSet.state === "pending_push" ||
3468
+ changeSet.state === "staged_revision" ||
3469
+ changeSet.state === "approved"),
3470
+ );
3471
+ // The interactive review surface is capped at 100 rows. When a complete
3472
+ // pending batch is already known, load heavy body/sidecar data only for those
3473
+ // documents instead of first discovering body-only candidates. When no
3474
+ // pending batch exists, the fallback compares bodies inside SQL and returns
3475
+ // only candidate document IDs; article bodies never cross into application
3476
+ // memory merely to prove that they are unchanged.
3477
+ const knownReviewDocumentIds = knownBuilderReviewDocumentIds(
3478
+ reviewableChanges,
3479
+ 100,
3480
+ );
3481
+ if (knownReviewDocumentIds) {
3482
+ return loadSourceSnapshot(source, database, {
3483
+ includeHeavyBuilderBodyValues: true,
3484
+ documentIds: knownReviewDocumentIds,
3485
+ });
3486
+ }
3487
+ if (reviewableChanges.some((changeSet) => !changeSet.documentId)) {
3488
+ return loadSourceSnapshot(source, database, {
3489
+ includeHeavyBuilderBodyValues: true,
3490
+ });
3491
+ }
3492
+ const candidateDocumentIds = new Set(
3493
+ reviewableChanges.flatMap((changeSet) =>
3494
+ changeSet.documentId ? [changeSet.documentId] : [],
3495
+ ),
3496
+ );
3497
+ for (const documentId of await findBuilderReviewBodyCandidateDocumentIds({
3498
+ database,
3499
+ source,
3500
+ })) {
3501
+ candidateDocumentIds.add(documentId);
3502
+ }
3503
+ if (candidateDocumentIds.size === 0) return lightweight;
3504
+ return loadSourceSnapshot(source, database, {
3505
+ includeHeavyBuilderBodyValues: true,
3506
+ documentIds: [...candidateDocumentIds],
3507
+ });
3508
+ }
3509
+
3067
3510
  /**
3068
3511
  * Load every source attached to a database (oldest first → `[0]` is the
3069
3512
  * primary). Federation joins read this; single-source callers keep using
@@ -3941,11 +4384,12 @@ export function serializeBuilderCmsSourceReadMetadataRecord(args: {
3941
4384
  suspiciousEmpty?: boolean;
3942
4385
  builderModelFields?: BuilderCmsModelFieldSummary[];
3943
4386
  existingMetadataJson?: string | null;
4387
+ completedBuilderContinuationClaimId?: string;
3944
4388
  }) {
3945
4389
  const existingMetadata = parseObject<SourceMetadataRecord>(
3946
4390
  args.existingMetadataJson,
3947
4391
  );
3948
- return JSON.stringify({
4392
+ const metadata: SourceMetadataRecord = {
3949
4393
  ...builderCmsSourceMetadata(args.sourceTable),
3950
4394
  ...existingMetadata,
3951
4395
  builderModelFields:
@@ -3970,7 +4414,17 @@ export function serializeBuilderCmsSourceReadMetadataRecord(args: {
3970
4414
  : args.readState === "error"
3971
4415
  ? "error"
3972
4416
  : "idle"),
3973
- });
4417
+ };
4418
+ if (
4419
+ args.completedBuilderContinuationClaimId &&
4420
+ existingMetadata?.builderContinuationClaimId ===
4421
+ args.completedBuilderContinuationClaimId
4422
+ ) {
4423
+ delete metadata.builderContinuationClaimId;
4424
+ delete metadata.builderContinuationClaimOffset;
4425
+ delete metadata.builderContinuationClaimedAt;
4426
+ }
4427
+ return JSON.stringify(metadata);
3974
4428
  }
3975
4429
 
3976
4430
  export function serializeSourceCapabilitiesRecord(
@@ -4084,6 +4538,63 @@ export function mergeBuilderCmsModelFieldsPreservingReferenceModels(args: {
4084
4538
  });
4085
4539
  }
4086
4540
 
4541
+ function builderSourceFieldPriority(provenance: string | null) {
4542
+ return provenance === "Builder model field"
4543
+ ? 0
4544
+ : provenance === "Builder content field"
4545
+ ? 1
4546
+ : 2;
4547
+ }
4548
+
4549
+ export function builderSourcePropertyAssignments(args: {
4550
+ properties: Array<{
4551
+ definition: { id: string; name: string; type: string };
4552
+ }>;
4553
+ existingFields?: Array<{
4554
+ propertyId: string | null;
4555
+ sourceFieldKey: string;
4556
+ provenance: string | null;
4557
+ }>;
4558
+ }) {
4559
+ const existingByPropertyId = new Map(
4560
+ (args.existingFields ?? [])
4561
+ .filter((field) => field.propertyId)
4562
+ .map((field) => [field.propertyId!, field]),
4563
+ );
4564
+ const canonicalPropertyBySourceKey = new Map<string, string>();
4565
+ for (const field of [...(args.existingFields ?? [])]
4566
+ .filter((candidate) => candidate.propertyId)
4567
+ .sort(
4568
+ (left, right) =>
4569
+ builderSourceFieldPriority(left.provenance) -
4570
+ builderSourceFieldPriority(right.provenance),
4571
+ )) {
4572
+ const key = field.sourceFieldKey.trim();
4573
+ if (!canonicalPropertyBySourceKey.has(key)) {
4574
+ canonicalPropertyBySourceKey.set(key, field.propertyId!);
4575
+ }
4576
+ }
4577
+
4578
+ const claimedSourceKeys = new Set<string>();
4579
+ return args.properties.flatMap((property) => {
4580
+ const existing = existingByPropertyId.get(property.definition.id);
4581
+ const sourceFieldKey =
4582
+ existing?.sourceFieldKey ??
4583
+ builderCmsSourceFieldKey(
4584
+ property.definition.id,
4585
+ property.definition.name,
4586
+ );
4587
+ const normalizedKey = sourceFieldKey.trim();
4588
+ const canonicalPropertyId = canonicalPropertyBySourceKey.get(normalizedKey);
4589
+ if (canonicalPropertyId && canonicalPropertyId !== property.definition.id) {
4590
+ return [];
4591
+ }
4592
+ if (claimedSourceKeys.has(normalizedKey)) return [];
4593
+ claimedSourceKeys.add(normalizedKey);
4594
+ return [{ property, sourceFieldKey }];
4595
+ });
4596
+ }
4597
+
4087
4598
  export async function seedMockSourceFields(args: {
4088
4599
  sourceId: string;
4089
4600
  ownerEmail: string;
@@ -4096,11 +4607,6 @@ export async function seedMockSourceFields(args: {
4096
4607
  }) {
4097
4608
  const db = getDb();
4098
4609
  const isBuilder = args.sourceType === "builder-cms";
4099
- const existingBuilderFieldByPropertyId = new Map(
4100
- (args.existingFields ?? [])
4101
- .filter((field) => isBuilder && field.propertyId)
4102
- .map((field) => [field.propertyId!, field]),
4103
- );
4104
4610
  const builderModelFieldBySourceKey = new Map(
4105
4611
  (args.builderModelFields ?? []).map((field) => [
4106
4612
  `data.${field.name.trim()}`,
@@ -4193,40 +4699,36 @@ export async function seedMockSourceFields(args: {
4193
4699
  // "Source") and only for Builder sources, so a user's own field happening
4194
4700
  // to be named "Source" — or any non-Builder/local-table source — is left
4195
4701
  // untouched.
4196
- ...args.properties
4702
+ ...(isBuilder
4703
+ ? builderSourcePropertyAssignments({
4704
+ properties: args.properties,
4705
+ existingFields: args.existingFields,
4706
+ })
4707
+ : args.properties.map((property) => ({
4708
+ property,
4709
+ sourceFieldKey: `fields.${slugifySourceField(property.definition.name)}`,
4710
+ }))
4711
+ )
4197
4712
  .filter(
4198
- (property) =>
4713
+ ({ property }) =>
4199
4714
  !(
4200
4715
  isBuilder &&
4201
4716
  property.definition.name === SOURCE_PROPERTY_NAME &&
4202
4717
  property.definition.type === "select"
4203
4718
  ),
4204
4719
  )
4205
- .map((property) => ({
4720
+ .map(({ property, sourceFieldKey }) => ({
4206
4721
  id: crypto.randomUUID(),
4207
4722
  ownerEmail: args.ownerEmail,
4208
4723
  sourceId: args.sourceId,
4209
4724
  propertyId: property.definition.id,
4210
4725
  localFieldKey: property.definition.id,
4211
- sourceFieldKey: isBuilder
4212
- ? (existingBuilderFieldByPropertyId.get(property.definition.id)
4213
- ?.sourceFieldKey ??
4214
- builderCmsSourceFieldKey(
4215
- property.definition.id,
4216
- property.definition.name,
4217
- ))
4218
- : `fields.${slugifySourceField(property.definition.name)}`,
4726
+ sourceFieldKey,
4219
4727
  sourceFieldLabel: property.definition.name,
4220
4728
  sourceFieldType: isBuilder
4221
4729
  ? normalizeBuilderCmsSourceFieldType(
4222
- builderModelFieldBySourceKey.get(
4223
- existingBuilderFieldByPropertyId.get(property.definition.id)
4224
- ?.sourceFieldKey ??
4225
- builderCmsSourceFieldKey(
4226
- property.definition.id,
4227
- property.definition.name,
4228
- ),
4229
- )?.type ?? property.definition.type,
4730
+ builderModelFieldBySourceKey.get(sourceFieldKey)?.type ??
4731
+ property.definition.type,
4230
4732
  )
4231
4733
  : property.definition.type,
4232
4734
  mappingType: "property",
@@ -4330,15 +4832,28 @@ export async function seedMockSourceFields(args: {
4330
4832
  }
4331
4833
 
4332
4834
  const existingFieldBySourceKey = new Map(
4333
- (args.existingFields ?? []).map((field) => [
4334
- sourceFieldIdentityKey(field.sourceFieldKey),
4335
- field,
4336
- ]),
4835
+ [...(args.existingFields ?? [])]
4836
+ // `Map` keeps the last value for a duplicate key, so sort the preferred
4837
+ // provider-discovered field last. This fallback must agree with
4838
+ // `builderSourcePropertyAssignments`, independent of database row order.
4839
+ .sort(
4840
+ (left, right) =>
4841
+ builderSourceFieldPriority(right.provenance) -
4842
+ builderSourceFieldPriority(left.provenance),
4843
+ )
4844
+ .map((field) => [sourceFieldIdentityKey(field.sourceFieldKey), field]),
4845
+ );
4846
+ const existingFieldByPropertyId = new Map(
4847
+ (args.existingFields ?? [])
4848
+ .filter((field) => field.propertyId)
4849
+ .map((field) => [field.propertyId!, field]),
4337
4850
  );
4338
4851
  const mergedRows = rows.map((row) => {
4339
- const existing = existingFieldBySourceKey.get(
4340
- sourceFieldIdentityKey(row.sourceFieldKey),
4341
- );
4852
+ const existing =
4853
+ (row.propertyId
4854
+ ? existingFieldByPropertyId.get(row.propertyId)
4855
+ : undefined) ??
4856
+ existingFieldBySourceKey.get(sourceFieldIdentityKey(row.sourceFieldKey));
4342
4857
  if (!existing) return row;
4343
4858
  return {
4344
4859
  ...row,
@@ -4367,77 +4882,88 @@ export async function seedMockSourceRows(args: {
4367
4882
  }) {
4368
4883
  if (args.items.length === 0) return;
4369
4884
  const db = getDb();
4885
+ const rows = args.items.map((item, index) => {
4886
+ const builderEntry = args.builderEntriesByDocumentId?.get(item.document.id);
4887
+ const existingBuilderRow = args.existingBuilderRows?.get(item.document.id);
4888
+ const builderIdentity =
4889
+ args.sourceType === "builder-cms"
4890
+ ? builderCmsSourceRowIdentity({
4891
+ item,
4892
+ sourceTable: args.sourceTable,
4893
+ now: args.now,
4894
+ existing: existingBuilderRow,
4895
+ entry: builderEntry,
4896
+ })
4897
+ : null;
4898
+ const sourceQualifiedId = builderIdentity
4899
+ ? builderIdentity.sourceQualifiedId
4900
+ : `${args.sourceType}://${args.sourceTable}/${item.document.id}`;
4901
+ return {
4902
+ id: builderIdentity
4903
+ ? stableBuilderImportId("builder-row", [
4904
+ args.sourceId,
4905
+ sourceQualifiedId,
4906
+ item.document.id,
4907
+ ])
4908
+ : crypto.randomUUID(),
4909
+ ownerEmail: args.ownerEmail,
4910
+ sourceId: args.sourceId,
4911
+ databaseItemId: item.id,
4912
+ documentId: item.document.id,
4913
+ sourceRowId: builderIdentity
4914
+ ? builderIdentity.sourceRowId
4915
+ : `${args.sourceType}-${item.document.id}`,
4916
+ sourceQualifiedId,
4917
+ sourceDisplayKey:
4918
+ builderIdentity?.sourceDisplayKey ??
4919
+ item.document.title?.trim() ??
4920
+ `${args.sourceType}-${index + 1}`,
4921
+ sourceValuesJson: JSON.stringify(
4922
+ sourceValuesForSeededSourceRow({
4923
+ sourceType: args.sourceType,
4924
+ item,
4925
+ sourceTable: args.sourceTable,
4926
+ now: args.now,
4927
+ builderEntry,
4928
+ existingSourceValuesJson: existingBuilderRow?.sourceValuesJson,
4929
+ existingLastSourceUpdatedAt: existingBuilderRow?.lastSourceUpdatedAt,
4930
+ }),
4931
+ ),
4932
+ provenance:
4933
+ args.sourceType === "builder-cms"
4934
+ ? builderEntry
4935
+ ? "Builder CMS read adapter"
4936
+ : (existingBuilderRow?.provenance ??
4937
+ BUILDER_CMS_FIXTURE_ROW_PROVENANCE)
4938
+ : "mock source row",
4939
+ syncState: "linked",
4940
+ freshness: "fresh",
4941
+ lastSyncedAt: args.now,
4942
+ lastSourceUpdatedAt: builderIdentity?.lastSourceUpdatedAt ?? args.now,
4943
+ createdAt: args.now,
4944
+ updatedAt: args.now,
4945
+ };
4946
+ });
4370
4947
  await db
4371
4948
  .insert(schema.contentDatabaseSourceRows)
4372
- .values(
4373
- args.items.map((item, index) => {
4374
- const builderEntry = args.builderEntriesByDocumentId?.get(
4375
- item.document.id,
4376
- );
4377
- const existingBuilderRow = args.existingBuilderRows?.get(
4378
- item.document.id,
4379
- );
4380
- const builderIdentity =
4381
- args.sourceType === "builder-cms"
4382
- ? builderCmsSourceRowIdentity({
4383
- item,
4384
- sourceTable: args.sourceTable,
4385
- now: args.now,
4386
- existing: existingBuilderRow,
4387
- entry: builderEntry,
4388
- })
4389
- : null;
4390
- const sourceQualifiedId = builderIdentity
4391
- ? builderIdentity.sourceQualifiedId
4392
- : `${args.sourceType}://${args.sourceTable}/${item.document.id}`;
4393
- return {
4394
- id: builderIdentity
4395
- ? stableBuilderImportId("builder-row", [
4396
- args.sourceId,
4397
- sourceQualifiedId,
4398
- item.document.id,
4399
- ])
4400
- : crypto.randomUUID(),
4401
- ownerEmail: args.ownerEmail,
4402
- sourceId: args.sourceId,
4403
- databaseItemId: item.id,
4404
- documentId: item.document.id,
4405
- sourceRowId: builderIdentity
4406
- ? builderIdentity.sourceRowId
4407
- : `${args.sourceType}-${item.document.id}`,
4408
- sourceQualifiedId,
4409
- sourceDisplayKey:
4410
- builderIdentity?.sourceDisplayKey ??
4411
- item.document.title?.trim() ??
4412
- `${args.sourceType}-${index + 1}`,
4413
- sourceValuesJson: JSON.stringify(
4414
- sourceValuesForSeededSourceRow({
4415
- sourceType: args.sourceType,
4416
- item,
4417
- sourceTable: args.sourceTable,
4418
- now: args.now,
4419
- builderEntry,
4420
- existingSourceValuesJson: existingBuilderRow?.sourceValuesJson,
4421
- existingLastSourceUpdatedAt:
4422
- existingBuilderRow?.lastSourceUpdatedAt,
4423
- }),
4424
- ),
4425
- provenance:
4426
- args.sourceType === "builder-cms"
4427
- ? args.builderEntriesByDocumentId?.has(item.document.id)
4428
- ? "Builder CMS read adapter"
4429
- : BUILDER_CMS_FIXTURE_ROW_PROVENANCE
4430
- : "mock source row",
4431
- syncState: "linked",
4432
- freshness: "fresh",
4433
- lastSyncedAt: args.now,
4434
- lastSourceUpdatedAt: builderIdentity?.lastSourceUpdatedAt ?? args.now,
4435
- createdAt: args.now,
4436
- updatedAt: args.now,
4437
- };
4438
- }),
4439
- )
4949
+ .values(rows)
4440
4950
  .onConflictDoNothing();
4951
+
4952
+ const fixtureItemIds = rows
4953
+ .filter((row) => row.provenance === BUILDER_CMS_FIXTURE_ROW_PROVENANCE)
4954
+ .map((row) => row.databaseItemId);
4955
+ for (const idChunk of chunks(fixtureItemIds, idChunkSize())) {
4956
+ await db
4957
+ .update(schema.contentDatabaseItems)
4958
+ .set({
4959
+ bodyHydrationStatus: "unavailable",
4960
+ bodyHydrationAttemptedAt: args.now,
4961
+ bodyHydrationError: null,
4962
+ bodyHydrationVersion: null,
4963
+ updatedAt: args.now,
4964
+ })
4965
+ .where(inArray(schema.contentDatabaseItems.id, idChunk));
4966
+ }
4441
4967
  }
4442
4968
 
4443
4969
  export function sourceValuesForSeededSourceRow(args: {
@@ -5339,6 +5865,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
5339
5865
  source: ContentDatabaseSourceRowDb;
5340
5866
  now: string;
5341
5867
  runFullRefresh?: boolean;
5868
+ refreshClaimId?: string;
5342
5869
  }) {
5343
5870
  let { properties, response } = await sourceSetupPayload(args.database.id);
5344
5871
  const db = getDb();
@@ -5395,12 +5922,29 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
5395
5922
  limit: args.runFullRefresh ? 10_000 : undefined,
5396
5923
  offset: continueOffset,
5397
5924
  });
5925
+ if (builderRead.state === "error") {
5926
+ throw new Error(
5927
+ builderRead.message ??
5928
+ "Builder CMS read failed; the previous source snapshot was preserved.",
5929
+ );
5930
+ }
5398
5931
  const incrementalRead =
5399
5932
  builderRead.state === "live" &&
5400
5933
  (builderRead.progress?.partial === true ||
5401
5934
  (builderRead.progress?.startOffset ?? 0) > 0);
5402
5935
  const builderEntries =
5403
5936
  builderRead.state === "live" ? builderRead.entries : [];
5937
+ if (
5938
+ args.refreshClaimId &&
5939
+ !(await renewBuilderCmsSourceRefreshClaim({
5940
+ sourceId: args.source.id,
5941
+ claimId: args.refreshClaimId,
5942
+ }))
5943
+ ) {
5944
+ throw new Error(
5945
+ "Builder source refresh claim was lost before snapshot mutation.",
5946
+ );
5947
+ }
5404
5948
  let existingRows = await db
5405
5949
  .select()
5406
5950
  .from(schema.contentDatabaseSourceRows)
@@ -5432,6 +5976,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
5432
5976
  sourceFetchState: "error",
5433
5977
  syncState: "error",
5434
5978
  suspiciousEmpty: true,
5979
+ refreshClaimId: args.refreshClaimId,
5435
5980
  });
5436
5981
  return;
5437
5982
  }
@@ -5478,6 +6023,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
5478
6023
  sourceRowId: row.sourceRowId,
5479
6024
  sourceQualifiedId: row.sourceQualifiedId,
5480
6025
  sourceDisplayKey: row.sourceDisplayKey,
6026
+ provenance: row.provenance,
5481
6027
  lastSourceUpdatedAt: row.lastSourceUpdatedAt,
5482
6028
  sourceValuesJson: row.sourceValuesJson,
5483
6029
  },
@@ -5597,6 +6143,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
5597
6143
  sourceFetchState: hasMore ? "fetching" : "idle",
5598
6144
  syncState: hasMore ? "refreshing" : "idle",
5599
6145
  activeReadSourceRowIds: hasMore ? nextActiveReadSourceRowIds : undefined,
6146
+ refreshClaimId: args.refreshClaimId,
5600
6147
  });
5601
6148
  return;
5602
6149
  }
@@ -5708,9 +6255,10 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
5708
6255
  message: builderRead.message,
5709
6256
  builderModelFields,
5710
6257
  progress: builderRead.progress,
5711
- sourceFetchState: builderRead.state === "error" ? "error" : "idle",
6258
+ sourceFetchState: "idle",
5712
6259
  activeReadSourceRowIds: undefined,
5713
6260
  syncState: "idle",
6261
+ refreshClaimId: args.refreshClaimId,
5714
6262
  });
5715
6263
  }
5716
6264
 
@@ -6024,18 +6572,18 @@ export async function writeSourceFederation(args: {
6024
6572
  federation: ContentDatabaseSourceFederation;
6025
6573
  now: string;
6026
6574
  }) {
6027
- const db = getDb();
6028
- const [current] = await db
6029
- .select({ metadataJson: schema.contentDatabaseSources.metadataJson })
6030
- .from(schema.contentDatabaseSources)
6031
- .where(eq(schema.contentDatabaseSources.id, args.sourceId));
6032
- const metadata =
6033
- parseObject<SourceMetadataRecord>(current?.metadataJson) ?? {};
6034
- metadata.federation = args.federation;
6035
- await db
6036
- .update(schema.contentDatabaseSources)
6037
- .set({ metadataJson: JSON.stringify(metadata), updatedAt: args.now })
6038
- .where(eq(schema.contentDatabaseSources.id, args.sourceId));
6575
+ await mutateContentDatabaseSourceMetadata({
6576
+ sourceId: args.sourceId,
6577
+ now: args.now,
6578
+ buildPatch: (current) => ({
6579
+ metadataJson: JSON.stringify({
6580
+ ...(parseObject<SourceMetadataRecord>(current.metadataJson) ?? {}),
6581
+ federation: args.federation,
6582
+ }),
6583
+ }),
6584
+ failureMessage:
6585
+ "Source metadata changed repeatedly while saving federation settings.",
6586
+ });
6039
6587
  }
6040
6588
 
6041
6589
  export async function updateBuilderCmsSourceReadMetadata(args: {
@@ -6053,58 +6601,87 @@ export async function updateBuilderCmsSourceReadMetadata(args: {
6053
6601
  syncState?: ContentDatabaseSourceSyncState;
6054
6602
  builderModelFields?: BuilderCmsModelFieldSummary[];
6055
6603
  suspiciousEmpty?: boolean;
6604
+ refreshClaimId?: string;
6056
6605
  }) {
6057
6606
  const db = getDb();
6058
- const [currentSource] = await db
6059
- .select({
6060
- capabilitiesJson: schema.contentDatabaseSources.capabilitiesJson,
6061
- metadataJson: schema.contentDatabaseSources.metadataJson,
6062
- lastSourceUpdatedAt: schema.contentDatabaseSources.lastSourceUpdatedAt,
6063
- })
6064
- .from(schema.contentDatabaseSources)
6065
- .where(eq(schema.contentDatabaseSources.id, args.sourceId))
6066
- .limit(1);
6067
- const nextJson = mergeBuilderCmsWriteSettingsIntoJson({
6068
- sourceTable: args.sourceTable,
6069
- currentCapabilitiesJson: currentSource?.capabilitiesJson,
6070
- currentMetadataJson: currentSource?.metadataJson,
6071
- nextCapabilitiesJson: sourceCapabilitiesForType("builder-cms"),
6072
- nextMetadataJson: serializeBuilderCmsSourceReadMetadataRecord({
6607
+ for (let attempt = 0; attempt < 5; attempt += 1) {
6608
+ const [currentSource] = await db
6609
+ .select({
6610
+ capabilitiesJson: schema.contentDatabaseSources.capabilitiesJson,
6611
+ metadataJson: schema.contentDatabaseSources.metadataJson,
6612
+ lastSourceUpdatedAt: schema.contentDatabaseSources.lastSourceUpdatedAt,
6613
+ })
6614
+ .from(schema.contentDatabaseSources)
6615
+ .where(eq(schema.contentDatabaseSources.id, args.sourceId))
6616
+ .limit(1);
6617
+ if (!currentSource) throw new Error("Builder source not found.");
6618
+ const currentMetadata = parseObject<SourceMetadataRecord>(
6619
+ currentSource.metadataJson,
6620
+ );
6621
+ if (
6622
+ args.refreshClaimId &&
6623
+ currentMetadata?.builderContinuationClaimId !== args.refreshClaimId
6624
+ ) {
6625
+ throw new Error(
6626
+ "Builder source refresh claim was lost before completion.",
6627
+ );
6628
+ }
6629
+ const nextJson = mergeBuilderCmsWriteSettingsIntoJson({
6073
6630
  sourceTable: args.sourceTable,
6074
- readState: args.readState,
6075
- entryCount: args.entryCount,
6076
- matchedRowCount: args.matchedRowCount,
6077
- progress: args.progress,
6078
- sourceFetchState: args.sourceFetchState,
6079
- activeReadSourceRowIds: args.activeReadSourceRowIds,
6080
- suspiciousEmpty: args.suspiciousEmpty,
6081
- builderModelFields: args.builderModelFields,
6082
- existingMetadataJson: currentSource?.metadataJson,
6083
- }),
6084
- });
6085
- await db
6086
- .update(schema.contentDatabaseSources)
6087
- .set({
6088
- syncState: args.syncState ?? "linked",
6089
- freshness:
6090
- args.readState === "error" ||
6091
- args.progress?.partial ||
6092
- args.suspiciousEmpty
6093
- ? "stale"
6094
- : "fresh",
6095
- capabilitiesJson: nextJson.capabilitiesJson,
6096
- metadataJson: nextJson.metadataJson,
6097
- lastRefreshedAt: args.now,
6098
- lastSourceUpdatedAt: args.suspiciousEmpty
6099
- ? (currentSource?.lastSourceUpdatedAt ?? null)
6100
- : args.fetchedAt,
6101
- lastError:
6102
- args.readState === "error" || args.suspiciousEmpty
6103
- ? args.message
6104
- : null,
6105
- updatedAt: args.now,
6106
- })
6107
- .where(eq(schema.contentDatabaseSources.id, args.sourceId));
6631
+ currentCapabilitiesJson: currentSource.capabilitiesJson,
6632
+ currentMetadataJson: currentSource.metadataJson,
6633
+ nextCapabilitiesJson: sourceCapabilitiesForType("builder-cms"),
6634
+ nextMetadataJson: serializeBuilderCmsSourceReadMetadataRecord({
6635
+ sourceTable: args.sourceTable,
6636
+ readState: args.readState,
6637
+ entryCount: args.entryCount,
6638
+ matchedRowCount: args.matchedRowCount,
6639
+ progress: args.progress,
6640
+ sourceFetchState: args.sourceFetchState,
6641
+ activeReadSourceRowIds: args.activeReadSourceRowIds,
6642
+ suspiciousEmpty: args.suspiciousEmpty,
6643
+ builderModelFields: args.builderModelFields,
6644
+ existingMetadataJson: currentSource.metadataJson,
6645
+ completedBuilderContinuationClaimId: args.refreshClaimId,
6646
+ }),
6647
+ });
6648
+ const updated = await db
6649
+ .update(schema.contentDatabaseSources)
6650
+ .set({
6651
+ syncState: args.syncState ?? "linked",
6652
+ freshness:
6653
+ args.readState === "error" ||
6654
+ args.progress?.partial ||
6655
+ args.suspiciousEmpty
6656
+ ? "stale"
6657
+ : "fresh",
6658
+ capabilitiesJson: nextJson.capabilitiesJson,
6659
+ metadataJson: nextJson.metadataJson,
6660
+ lastRefreshedAt: args.now,
6661
+ lastSourceUpdatedAt: args.suspiciousEmpty
6662
+ ? currentSource.lastSourceUpdatedAt
6663
+ : args.fetchedAt,
6664
+ lastError:
6665
+ args.readState === "error" || args.suspiciousEmpty
6666
+ ? args.message
6667
+ : null,
6668
+ updatedAt: args.now,
6669
+ })
6670
+ .where(
6671
+ and(
6672
+ eq(schema.contentDatabaseSources.id, args.sourceId),
6673
+ currentSource.metadataJson === null
6674
+ ? isNull(schema.contentDatabaseSources.metadataJson)
6675
+ : eq(
6676
+ schema.contentDatabaseSources.metadataJson,
6677
+ currentSource.metadataJson,
6678
+ ),
6679
+ ),
6680
+ )
6681
+ .returning({ id: schema.contentDatabaseSources.id });
6682
+ if (updated.length > 0) return;
6683
+ }
6684
+ throw new Error("Builder source metadata changed repeatedly during refresh.");
6108
6685
  }
6109
6686
 
6110
6687
  export async function updateReadOnlySourceMetadata(args: {
@@ -6117,27 +6694,24 @@ export async function updateReadOnlySourceMetadata(args: {
6117
6694
  metadata?: Record<string, unknown>;
6118
6695
  syncState?: ContentDatabaseSourceSyncState;
6119
6696
  }) {
6120
- const db = getDb();
6121
- const [current] = await db
6122
- .select({ metadataJson: schema.contentDatabaseSources.metadataJson })
6123
- .from(schema.contentDatabaseSources)
6124
- .where(eq(schema.contentDatabaseSources.id, args.sourceId))
6125
- .limit(1);
6126
- const existing =
6127
- parseObject<Record<string, unknown>>(current?.metadataJson) ?? {};
6128
- await db
6129
- .update(schema.contentDatabaseSources)
6130
- .set({
6697
+ await mutateContentDatabaseSourceMetadata({
6698
+ sourceId: args.sourceId,
6699
+ now: args.now,
6700
+ buildPatch: (current) => ({
6131
6701
  syncState: args.syncState ?? (args.message ? "refreshing" : "linked"),
6132
6702
  freshness: args.message ? "stale" : "fresh",
6133
6703
  capabilitiesJson: sourceCapabilitiesForType(args.sourceType),
6134
- metadataJson: JSON.stringify({ ...existing, ...(args.metadata ?? {}) }),
6704
+ metadataJson: JSON.stringify({
6705
+ ...(parseObject<Record<string, unknown>>(current.metadataJson) ?? {}),
6706
+ ...(args.metadata ?? {}),
6707
+ }),
6135
6708
  lastRefreshedAt: args.now,
6136
6709
  lastSourceUpdatedAt: args.fetchedAt,
6137
6710
  lastError: null,
6138
- updatedAt: args.now,
6139
- })
6140
- .where(eq(schema.contentDatabaseSources.id, args.sourceId));
6711
+ }),
6712
+ failureMessage:
6713
+ "Source metadata changed repeatedly while saving read state.",
6714
+ });
6141
6715
  }
6142
6716
 
6143
6717
  export async function getExistingSource(databaseId: string) {