@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
@@ -0,0 +1,1810 @@
1
+ import { accessFilter, assertAccess } from "@agent-native/core/sharing";
2
+ import { and, asc, eq, gt, inArray, isNull, like, or } from "drizzle-orm";
3
+
4
+ import {
5
+ ATTRIBUTE_TYPE_SPECS,
6
+ legacyValueTypeFor,
7
+ type CrmAttributeType,
8
+ } from "../../shared/crm-attributes.js";
9
+ import type {
10
+ CrmAccessScope,
11
+ CrmAdapter,
12
+ CrmAdapterCapabilities,
13
+ CrmConnectionRef,
14
+ CrmFieldDefinition,
15
+ CrmMutation,
16
+ CrmMutationResult,
17
+ CrmObjectDefinition,
18
+ CrmObjectKind,
19
+ CrmRecord,
20
+ CrmRecordRef,
21
+ CrmRelationship,
22
+ CrmSyncScope,
23
+ CrmSyncPage,
24
+ CrmValue,
25
+ } from "../../shared/crm-contract.js";
26
+ import { getDb, schema } from "../db/index.js";
27
+ import {
28
+ isBoundedCrmValue,
29
+ isSafeCrmMutationFieldName,
30
+ } from "./crm-field-firewall.js";
31
+ import { crmAttributeColumnsFor } from "./field-policy-attributes.js";
32
+
33
+ const MAX_PAGE_SIZE = 100;
34
+ const MAX_FIELDS = 80;
35
+ const MAX_MUTATION_FIELDS = 20;
36
+ const STANDARD_OBJECTS = ["accounts", "people", "opportunities"] as const;
37
+
38
+ type Ownership = {
39
+ ownerEmail: string;
40
+ orgId: string | null;
41
+ visibility: "private" | "org" | "public";
42
+ };
43
+
44
+ type NativeConnection = Ownership & {
45
+ id: string;
46
+ accountId: string | null;
47
+ accessScopeKey: string;
48
+ accessScopeJson: string;
49
+ };
50
+
51
+ type StoredField = {
52
+ fieldName: string;
53
+ valueType: string;
54
+ stringValue: string | null;
55
+ numberValue: number | null;
56
+ booleanValue: boolean | null;
57
+ jsonValue: string | null;
58
+ };
59
+
60
+ function kindForObject(objectType: string): CrmObjectKind {
61
+ if (objectType === "accounts") return "account";
62
+ if (objectType === "people") return "person";
63
+ if (objectType === "opportunities") return "opportunity";
64
+ return "custom";
65
+ }
66
+
67
+ function labelForObject(objectType: string): string {
68
+ if (objectType === "accounts") return "Account";
69
+ if (objectType === "people") return "Person";
70
+ if (objectType === "opportunities") return "Opportunity";
71
+ return objectType
72
+ .replace(/[_-]+/g, " ")
73
+ .replace(/\b\w/g, (character) => character.toUpperCase());
74
+ }
75
+
76
+ /** Default managed options seeded for the native opportunities `stage`
77
+ * attribute — see `server/lib/record-fields.ts`: a status write against an
78
+ * attribute with no options is a 422, not a silent auto-create. Kept separate
79
+ * from `create-crm-list.ts`'s `DEFAULT_STAGE_OPTIONS` (a list's workflow
80
+ * stage is a different vocabulary from an opportunity's sales stage, even
81
+ * though today's starter values happen to read the same). */
82
+ export const DEFAULT_OPPORTUNITY_STAGE_OPTIONS: NonNullable<
83
+ CrmFieldDefinition["options"]
84
+ > = [
85
+ { value: "new", label: "New" },
86
+ { value: "in-progress", label: "In Progress" },
87
+ { value: "won", label: "Won" },
88
+ { value: "lost", label: "Lost" },
89
+ ];
90
+
91
+ function nativeField(
92
+ name: string,
93
+ attributeType: CrmAttributeType,
94
+ options: {
95
+ required?: boolean;
96
+ multi?: boolean;
97
+ config?: Record<string, unknown>;
98
+ fieldOptions?: CrmFieldDefinition["options"];
99
+ } = {},
100
+ ): CrmFieldDefinition {
101
+ const multi = options.multi ?? false;
102
+ return {
103
+ name,
104
+ label: name
105
+ .replace(/([A-Z])/g, " $1")
106
+ .replace(/^./, (character) => character.toUpperCase()),
107
+ // Derived, not hand-kept in sync: the legacy column exists only for
108
+ // pre-typed read paths that still branch on it.
109
+ valueType: legacyValueTypeFor(attributeType, multi),
110
+ attributeType,
111
+ ...(multi ? { multi } : {}),
112
+ ...(options.config ? { config: options.config } : {}),
113
+ ...(options.fieldOptions ? { options: options.fieldOptions } : {}),
114
+ storagePolicy: "local-authoritative",
115
+ sensitive: false,
116
+ readable: true,
117
+ createable: true,
118
+ updateable: true,
119
+ required: options.required ?? false,
120
+ };
121
+ }
122
+
123
+ export function nativeObjectTemplate(objectType: string): CrmObjectDefinition {
124
+ const objectFields =
125
+ objectType === "accounts"
126
+ ? [
127
+ nativeField("name", "text", { required: true }),
128
+ nativeField("domain", "domain"),
129
+ nativeField("industry", "text"),
130
+ nativeField("ownerName", "text"),
131
+ ]
132
+ : objectType === "people"
133
+ ? [
134
+ nativeField("firstName", "text"),
135
+ nativeField("lastName", "text"),
136
+ nativeField("email", "email-address"),
137
+ nativeField("title", "text"),
138
+ nativeField("accountId", "record-reference"),
139
+ nativeField("ownerName", "text"),
140
+ ]
141
+ : objectType === "opportunities"
142
+ ? [
143
+ nativeField("name", "text", { required: true }),
144
+ nativeField("amount", "currency", {
145
+ config: { currency: { code: "USD" } },
146
+ }),
147
+ nativeField("stage", "status", {
148
+ fieldOptions: DEFAULT_OPPORTUNITY_STAGE_OPTIONS,
149
+ }),
150
+ nativeField("closeDate", "date"),
151
+ nativeField("accountId", "record-reference"),
152
+ nativeField("ownerName", "text"),
153
+ ]
154
+ : [];
155
+ const fields = objectFields.length
156
+ ? [
157
+ ...objectFields,
158
+ nativeField("desiredCadenceDays", "number"),
159
+ nativeField("lastMeaningfulInteractionAt", "timestamp"),
160
+ nativeField("nextContactAt", "timestamp"),
161
+ ]
162
+ : [];
163
+ const label = labelForObject(objectType);
164
+ return {
165
+ connectionId: "",
166
+ provider: "native",
167
+ objectType,
168
+ kind: kindForObject(objectType),
169
+ label,
170
+ pluralLabel: `${label}s`,
171
+ custom: !STANDARD_OBJECTS.includes(
172
+ objectType as (typeof STANDARD_OBJECTS)[number],
173
+ ),
174
+ queryable: true,
175
+ searchable: true,
176
+ createable: true,
177
+ updateable: true,
178
+ deleteable: true,
179
+ fields,
180
+ };
181
+ }
182
+
183
+ function valueType(value: CrmValue): CrmFieldDefinition["valueType"] {
184
+ if (typeof value === "boolean") return "boolean";
185
+ if (typeof value === "number") return "number";
186
+ if (Array.isArray(value) || typeof value === "object") return "json";
187
+ return "string";
188
+ }
189
+
190
+ /**
191
+ * Best-effort attribute type for a field a mutation introduces that the
192
+ * object template does not already declare (an ad hoc custom field). A
193
+ * template-declared field always wins over this inference — see the merge
194
+ * order in `ensureNativeObject`.
195
+ */
196
+ function attributeTypeForValue(value: CrmValue): CrmAttributeType {
197
+ if (typeof value === "boolean") return "checkbox";
198
+ if (typeof value === "number") return "number";
199
+ if (Array.isArray(value) || (typeof value === "object" && value !== null))
200
+ return "location";
201
+ return "text";
202
+ }
203
+
204
+ function fieldColumns(value: CrmValue) {
205
+ if (typeof value === "string") {
206
+ return {
207
+ stringValue: value,
208
+ numberValue: null,
209
+ booleanValue: null,
210
+ jsonValue: null,
211
+ };
212
+ }
213
+ if (typeof value === "number") {
214
+ return {
215
+ stringValue: null,
216
+ numberValue: value,
217
+ booleanValue: null,
218
+ jsonValue: null,
219
+ };
220
+ }
221
+ if (typeof value === "boolean") {
222
+ return {
223
+ stringValue: null,
224
+ numberValue: null,
225
+ booleanValue: value,
226
+ jsonValue: null,
227
+ };
228
+ }
229
+ if (value === null) {
230
+ return {
231
+ stringValue: null,
232
+ numberValue: null,
233
+ booleanValue: null,
234
+ jsonValue: null,
235
+ };
236
+ }
237
+ return {
238
+ stringValue: null,
239
+ numberValue: null,
240
+ booleanValue: null,
241
+ jsonValue: JSON.stringify(value),
242
+ };
243
+ }
244
+
245
+ function fieldValue(field: StoredField): CrmValue {
246
+ if (field.stringValue !== null) return field.stringValue;
247
+ if (field.numberValue !== null) return field.numberValue;
248
+ if (field.booleanValue !== null) return field.booleanValue;
249
+ if (!field.jsonValue) return null;
250
+ try {
251
+ const value = JSON.parse(field.jsonValue) as unknown;
252
+ return isBoundedCrmValue(value) ? (value as CrmValue) : null;
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ function encodeCursor(offset: number): string {
259
+ return btoa(String(offset)).replace(/=+$/, "");
260
+ }
261
+
262
+ function decodeCursor(cursor: string | undefined): number {
263
+ if (!cursor) return 0;
264
+ try {
265
+ const value = Number.parseInt(atob(cursor), 10);
266
+ return Number.isSafeInteger(value) && value >= 0 ? value : 0;
267
+ } catch {
268
+ return 0;
269
+ }
270
+ }
271
+
272
+ export function nextNativeRevision(value: string | null | undefined): string {
273
+ try {
274
+ const parsed = BigInt(value ?? "0");
275
+ return (parsed >= 0n ? parsed + 1n : 1n).toString();
276
+ } catch {
277
+ return "1";
278
+ }
279
+ }
280
+
281
+ function scopeFromConnection(connection: NativeConnection): CrmAccessScope {
282
+ try {
283
+ const stored = JSON.parse(
284
+ connection.accessScopeJson,
285
+ ) as Partial<CrmAccessScope>;
286
+ if (stored.mode === "native" && stored.key === connection.accessScopeKey) {
287
+ return {
288
+ key: stored.key,
289
+ actorId: stored.actorId ?? connection.ownerEmail,
290
+ mode: "native",
291
+ objectReadable: true,
292
+ objectCreateable: true,
293
+ objectUpdateable: true,
294
+ objectDeleteable: true,
295
+ recordVisibility:
296
+ stored.recordVisibility ??
297
+ (connection.visibility === "org" ? "workspace" : "actor"),
298
+ ...(stored.fieldPermissionsHash
299
+ ? { fieldPermissionsHash: stored.fieldPermissionsHash }
300
+ : {}),
301
+ ...(stored.sharingFingerprint
302
+ ? { sharingFingerprint: stored.sharingFingerprint }
303
+ : {}),
304
+ };
305
+ }
306
+ } catch {}
307
+ return {
308
+ key: connection.accessScopeKey,
309
+ actorId: connection.ownerEmail,
310
+ mode: "native",
311
+ objectReadable: true,
312
+ objectCreateable: true,
313
+ objectUpdateable: true,
314
+ objectDeleteable: true,
315
+ recordVisibility: connection.visibility === "org" ? "workspace" : "actor",
316
+ };
317
+ }
318
+
319
+ function json(value: unknown, maximum = 12_000): string {
320
+ const encoded = JSON.stringify(value);
321
+ if (encoded.length > maximum)
322
+ throw new Error("CRM mutation payload exceeds the allowed size.");
323
+ return encoded;
324
+ }
325
+
326
+ function mutationFields(mutation: CrmMutation): Record<string, CrmValue> {
327
+ const fields = mutation.fields ?? {};
328
+ const entries = Object.entries(fields);
329
+ if (
330
+ entries.length > MAX_MUTATION_FIELDS ||
331
+ entries.some(
332
+ ([name, value]) =>
333
+ !name ||
334
+ name.length > 120 ||
335
+ !isSafeCrmMutationFieldName(name) ||
336
+ !isBoundedCrmValue(value),
337
+ )
338
+ ) {
339
+ throw new Error(
340
+ "Native CRM mutations require bounded, non-media field values.",
341
+ );
342
+ }
343
+ return fields;
344
+ }
345
+
346
+ function summaryColumns(fields: Record<string, CrmValue>, fallback: string) {
347
+ const string = (...names: string[]) => {
348
+ for (const name of names) {
349
+ const value = fields[name];
350
+ if (typeof value === "string") return value.slice(0, 500);
351
+ }
352
+ return null;
353
+ };
354
+ const number = (...names: string[]) => {
355
+ for (const name of names) {
356
+ const value = fields[name];
357
+ if (typeof value === "number") return value;
358
+ }
359
+ return null;
360
+ };
361
+ const desiredCadenceDays = fields.desiredCadenceDays;
362
+ if (
363
+ desiredCadenceDays !== undefined &&
364
+ desiredCadenceDays !== null &&
365
+ (typeof desiredCadenceDays !== "number" ||
366
+ !Number.isInteger(desiredCadenceDays) ||
367
+ desiredCadenceDays < 1 ||
368
+ desiredCadenceDays > 365)
369
+ ) {
370
+ throw new Error(
371
+ "desiredCadenceDays must be null or an integer from 1 to 365.",
372
+ );
373
+ }
374
+ return {
375
+ displayName: string("displayName", "name", "Name") ?? fallback,
376
+ primaryEmail: string("email", "Email"),
377
+ domain: string("domain", "website", "Website"),
378
+ stage: string("stage", "StageName"),
379
+ ownerName: string("ownerName"),
380
+ amount: number("amount", "Amount"),
381
+ closeDate: string("closeDate", "CloseDate"),
382
+ desiredCadenceDays: desiredCadenceDays ?? null,
383
+ lastMeaningfulInteractionAt: string("lastMeaningfulInteractionAt"),
384
+ nextContactAt: string("nextContactAt"),
385
+ };
386
+ }
387
+
388
+ async function loadNativeConnection(
389
+ connectionId: string,
390
+ accessTier: "viewer" | "editor" = "viewer",
391
+ ): Promise<NativeConnection> {
392
+ await assertAccess("crm-connection", connectionId, accessTier);
393
+ const db = getDb();
394
+ const [connection] = await db
395
+ .select({
396
+ id: schema.crmConnections.id,
397
+ accountId: schema.crmConnections.accountId,
398
+ accessScopeKey: schema.crmConnections.accessScopeKey,
399
+ accessScopeJson: schema.crmConnections.accessScopeJson,
400
+ ownerEmail: schema.crmConnections.ownerEmail,
401
+ orgId: schema.crmConnections.orgId,
402
+ visibility: schema.crmConnections.visibility,
403
+ })
404
+ .from(schema.crmConnections)
405
+ .where(
406
+ and(
407
+ eq(schema.crmConnections.id, connectionId),
408
+ eq(schema.crmConnections.provider, "native"),
409
+ accessFilter(
410
+ schema.crmConnections,
411
+ schema.crmConnectionShares,
412
+ undefined,
413
+ accessTier,
414
+ ),
415
+ ),
416
+ )
417
+ .limit(1);
418
+ if (!connection) throw new Error("Native CRM connection is unavailable.");
419
+ return connection as NativeConnection;
420
+ }
421
+
422
+ async function ensureNativeObject(input: {
423
+ connection: NativeConnection;
424
+ objectType: string;
425
+ fields: CrmFieldDefinition[];
426
+ }) {
427
+ const db = getDb();
428
+ const template = nativeObjectTemplate(input.objectType);
429
+ const object = { ...template, connectionId: input.connection.id };
430
+ const [existing] = await db
431
+ .select({ id: schema.crmObjects.id })
432
+ .from(schema.crmObjects)
433
+ .where(
434
+ and(
435
+ eq(schema.crmObjects.connectionId, input.connection.id),
436
+ eq(schema.crmObjects.objectType, input.objectType),
437
+ accessFilter(schema.crmObjects, schema.crmObjectShares),
438
+ ),
439
+ )
440
+ .limit(1);
441
+ const now = new Date().toISOString();
442
+ const objectValues = {
443
+ provider: "native" as const,
444
+ objectType: input.objectType,
445
+ kind: object.kind,
446
+ label: object.label,
447
+ pluralLabel: object.pluralLabel,
448
+ custom: object.custom,
449
+ queryable: true,
450
+ searchable: true,
451
+ createable: true,
452
+ updateable: true,
453
+ deleteable: true,
454
+ capabilitiesJson: json(NATIVE_CAPABILITIES, 4_000),
455
+ updatedAt: now,
456
+ };
457
+ if (existing) {
458
+ await assertAccess("crm-object", existing.id, "editor");
459
+ await db
460
+ .update(schema.crmObjects)
461
+ .set(objectValues)
462
+ .where(
463
+ and(
464
+ eq(schema.crmObjects.id, existing.id),
465
+ accessFilter(
466
+ schema.crmObjects,
467
+ schema.crmObjectShares,
468
+ undefined,
469
+ "editor",
470
+ ),
471
+ ),
472
+ );
473
+ } else {
474
+ await db.insert(schema.crmObjects).values({
475
+ id: crypto.randomUUID(),
476
+ connectionId: input.connection.id,
477
+ ...objectValues,
478
+ ownerEmail: input.connection.ownerEmail,
479
+ orgId: input.connection.orgId,
480
+ visibility: input.connection.visibility,
481
+ createdAt: now,
482
+ });
483
+ }
484
+ // `input.fields` is rebuilt from the raw values of every mutation — including
485
+ // template fields, since a create/update passes its whole `fields` payload
486
+ // through. Template definitions go LAST so their genuine types (currency,
487
+ // status, …) always win; putting them first would let every write silently
488
+ // downgrade the field back to the value-shape guess in `attributeTypeForValue`.
489
+ const known = new Map<string, CrmFieldDefinition>();
490
+ for (const field of [...input.fields, ...template.fields])
491
+ known.set(field.name, field);
492
+ const existingPolicies = await db
493
+ .select({
494
+ id: schema.crmFieldPolicies.id,
495
+ fieldName: schema.crmFieldPolicies.fieldName,
496
+ })
497
+ .from(schema.crmFieldPolicies)
498
+ .where(
499
+ and(
500
+ eq(schema.crmFieldPolicies.connectionId, input.connection.id),
501
+ eq(schema.crmFieldPolicies.objectType, input.objectType),
502
+ accessFilter(schema.crmFieldPolicies, schema.crmFieldPolicyShares),
503
+ ),
504
+ );
505
+ const policyByName = new Map(
506
+ existingPolicies.map((policy) => [policy.fieldName, policy]),
507
+ );
508
+ for (const field of known.values()) {
509
+ const existingPolicy = policyByName.get(field.name);
510
+ const attributeColumns = crmAttributeColumnsFor(
511
+ field,
512
+ "local-authoritative",
513
+ );
514
+ const values = {
515
+ label: field.label,
516
+ valueType: field.valueType,
517
+ storagePolicy: "local-authoritative" as const,
518
+ sensitive: false,
519
+ readable: true,
520
+ createable: true,
521
+ updateable: true,
522
+ required: field.required,
523
+ metadataJson: "{}",
524
+ ...attributeColumns,
525
+ updatedAt: now,
526
+ };
527
+ if (existingPolicy) {
528
+ await assertAccess("crm-field-policy", existingPolicy.id, "editor");
529
+ await db
530
+ .update(schema.crmFieldPolicies)
531
+ .set(values)
532
+ .where(
533
+ and(
534
+ eq(schema.crmFieldPolicies.id, existingPolicy.id),
535
+ accessFilter(
536
+ schema.crmFieldPolicies,
537
+ schema.crmFieldPolicyShares,
538
+ undefined,
539
+ "editor",
540
+ ),
541
+ ),
542
+ );
543
+ continue;
544
+ }
545
+ const policyId = crypto.randomUUID();
546
+ await db.insert(schema.crmFieldPolicies).values({
547
+ id: policyId,
548
+ connectionId: input.connection.id,
549
+ objectType: input.objectType,
550
+ fieldName: field.name,
551
+ ...values,
552
+ ownerEmail: input.connection.ownerEmail,
553
+ orgId: input.connection.orgId,
554
+ visibility: input.connection.visibility,
555
+ createdAt: now,
556
+ });
557
+ // Seeded only at creation: a status field needs at least one managed
558
+ // option before any write to it (see `assertKnownOptions` in
559
+ // `server/lib/record-fields.ts`), and re-checking on every later mutation
560
+ // would cost a query per status field per write for no benefit — the
561
+ // backfill migration handles pre-existing rows this never ran for.
562
+ if (
563
+ ATTRIBUTE_TYPE_SPECS[attributeColumns.attributeType].usesOptions &&
564
+ field.options?.length
565
+ ) {
566
+ await db.insert(schema.crmAttributeOptions).values(
567
+ field.options.map((option, index) => ({
568
+ id: crypto.randomUUID(),
569
+ attributeId: policyId,
570
+ value: option.value,
571
+ title: option.label,
572
+ position: index,
573
+ archived: false,
574
+ celebrate: false,
575
+ ownerEmail: input.connection.ownerEmail,
576
+ orgId: input.connection.orgId,
577
+ visibility: input.connection.visibility,
578
+ createdAt: now,
579
+ updatedAt: now,
580
+ })),
581
+ );
582
+ }
583
+ }
584
+ }
585
+
586
+ export async function initializeNativeCrmDomain(connectionId: string) {
587
+ const connection = await loadNativeConnection(connectionId, "editor");
588
+ await Promise.all(
589
+ STANDARD_OBJECTS.map((objectType) =>
590
+ ensureNativeObject({ connection, objectType, fields: [] }),
591
+ ),
592
+ );
593
+ }
594
+
595
+ export async function configureNativeCrmConnection(input: {
596
+ label?: string;
597
+ ownership: Ownership;
598
+ }) {
599
+ const db = getDb();
600
+ const label = input.label?.trim() || "Native SQL";
601
+ const [existing] = await db
602
+ .select({ id: schema.crmConnections.id })
603
+ .from(schema.crmConnections)
604
+ .where(
605
+ and(
606
+ eq(schema.crmConnections.provider, "native"),
607
+ eq(schema.crmConnections.label, label),
608
+ accessFilter(schema.crmConnections, schema.crmConnectionShares),
609
+ ),
610
+ )
611
+ .limit(1);
612
+ const id = existing?.id ?? crypto.randomUUID();
613
+ const now = new Date().toISOString();
614
+ const accessScope: CrmAccessScope = {
615
+ key: `native:${id}`,
616
+ actorId: input.ownership.ownerEmail,
617
+ mode: "native",
618
+ objectReadable: true,
619
+ objectCreateable: true,
620
+ objectUpdateable: true,
621
+ objectDeleteable: true,
622
+ recordVisibility:
623
+ input.ownership.visibility === "org" ? "workspace" : "actor",
624
+ };
625
+ const values = {
626
+ provider: "native" as const,
627
+ workspaceConnectionId: null,
628
+ label,
629
+ accountId: null,
630
+ mode: "native" as const,
631
+ status: "connected" as const,
632
+ selectedPipelinesJson: "[]",
633
+ selectedObjectTypesJson: json(STANDARD_OBJECTS, 1_000),
634
+ accessScopeKey: accessScope.key,
635
+ accessScopeJson: json(accessScope, 4_000),
636
+ lastError: null,
637
+ updatedAt: now,
638
+ };
639
+ if (existing) {
640
+ await assertAccess("crm-connection", existing.id, "editor");
641
+ await db
642
+ .update(schema.crmConnections)
643
+ .set(values)
644
+ .where(
645
+ and(
646
+ eq(schema.crmConnections.id, existing.id),
647
+ accessFilter(
648
+ schema.crmConnections,
649
+ schema.crmConnectionShares,
650
+ undefined,
651
+ "editor",
652
+ ),
653
+ ),
654
+ );
655
+ } else {
656
+ await db.insert(schema.crmConnections).values({
657
+ id,
658
+ ...values,
659
+ ownerEmail: input.ownership.ownerEmail,
660
+ orgId: input.ownership.orgId,
661
+ visibility: input.ownership.visibility,
662
+ createdAt: now,
663
+ });
664
+ }
665
+ await initializeNativeCrmDomain(id);
666
+ return { id, label, accessScope, ...input.ownership };
667
+ }
668
+
669
+ export async function resolveNativeCrmAccessScope(input: {
670
+ connectionId: string;
671
+ objectType: string;
672
+ }): Promise<CrmAccessScope | null> {
673
+ const connection = await loadNativeConnection(input.connectionId);
674
+ const db = getDb();
675
+ const [object] = await db
676
+ .select({ id: schema.crmObjects.id })
677
+ .from(schema.crmObjects)
678
+ .where(
679
+ and(
680
+ eq(schema.crmObjects.connectionId, connection.id),
681
+ eq(schema.crmObjects.objectType, input.objectType),
682
+ accessFilter(schema.crmObjects, schema.crmObjectShares),
683
+ ),
684
+ )
685
+ .limit(1);
686
+ return object ? scopeFromConnection(connection) : null;
687
+ }
688
+
689
+ async function fieldsForRecord(recordId: string, requested?: string[]) {
690
+ const db = getDb();
691
+ const rows = await db
692
+ .select({
693
+ fieldName: schema.crmRecordFields.fieldName,
694
+ valueType: schema.crmRecordFields.valueType,
695
+ stringValue: schema.crmRecordFields.stringValue,
696
+ numberValue: schema.crmRecordFields.numberValue,
697
+ booleanValue: schema.crmRecordFields.booleanValue,
698
+ jsonValue: schema.crmRecordFields.jsonValue,
699
+ })
700
+ .from(schema.crmRecordFields)
701
+ .where(
702
+ and(
703
+ eq(schema.crmRecordFields.recordId, recordId),
704
+ eq(schema.crmRecordFields.storagePolicy, "local-authoritative"),
705
+ accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
706
+ ),
707
+ );
708
+ const allowed = requested?.length ? new Set(requested) : null;
709
+ return Object.fromEntries(
710
+ rows
711
+ .filter((field) => !allowed || allowed.has(field.fieldName))
712
+ .map((field) => [field.fieldName, fieldValue(field as StoredField)]),
713
+ ) as Record<string, CrmValue>;
714
+ }
715
+
716
+ async function materializeRecord(input: {
717
+ row: typeof schema.crmRecords.$inferSelect;
718
+ connection: NativeConnection;
719
+ fields?: string[];
720
+ }): Promise<CrmRecord> {
721
+ const fields = await fieldsForRecord(input.row.id, input.fields);
722
+ const scope = scopeFromConnection(input.connection);
723
+ const observedAt = input.row.updatedAt;
724
+ return {
725
+ ref: {
726
+ connectionId: input.connection.id,
727
+ provider: "native",
728
+ accountId: input.connection.accountId ?? undefined,
729
+ actorId: scope.actorId,
730
+ objectType: input.row.objectType,
731
+ kind: input.row.kind as CrmObjectKind,
732
+ remoteId: input.row.remoteId,
733
+ localId: input.row.id,
734
+ },
735
+ displayName: input.row.displayName,
736
+ fields,
737
+ remoteRevision: input.row.remoteRevision ?? undefined,
738
+ remoteUpdatedAt: input.row.remoteUpdatedAt ?? undefined,
739
+ deleted: input.row.tombstone,
740
+ accessScope: scope,
741
+ provenance: Object.keys(fields).map((fieldName) => ({
742
+ provider: "native",
743
+ connectionId: input.connection.id,
744
+ objectType: input.row.objectType,
745
+ remoteId: input.row.remoteId,
746
+ fieldName,
747
+ remoteRevision: input.row.remoteRevision ?? undefined,
748
+ observedAt,
749
+ })),
750
+ };
751
+ }
752
+
753
+ const NATIVE_CAPABILITIES: CrmAdapterCapabilities = {
754
+ schemaDiscovery: true,
755
+ customObjects: true,
756
+ search: true,
757
+ incrementalSync: true,
758
+ deletedRecordSync: true,
759
+ conditionalMutations: true,
760
+ labeledRelationships: true,
761
+ perFieldPermissions: true,
762
+ perRecordPermissions: true,
763
+ };
764
+
765
+ export class NativeCrmAdapter implements CrmAdapter {
766
+ readonly capabilities = NATIVE_CAPABILITIES;
767
+ readonly connection: CrmConnectionRef;
768
+ readonly #nativeConnection: NativeConnection;
769
+ readonly #initiatedBy: "human" | "agent" | "automation";
770
+
771
+ constructor(
772
+ connection: NativeConnection,
773
+ initiatedBy: "human" | "agent" | "automation" = "agent",
774
+ ) {
775
+ this.#nativeConnection = connection;
776
+ this.#initiatedBy = initiatedBy;
777
+ const scope = scopeFromConnection(connection);
778
+ this.connection = {
779
+ connectionId: connection.id,
780
+ provider: "native",
781
+ ...(connection.accountId ? { accountId: connection.accountId } : {}),
782
+ ...(scope.actorId ? { actorId: scope.actorId } : {}),
783
+ };
784
+ }
785
+
786
+ getAccessScope(): CrmAccessScope {
787
+ return scopeFromConnection(this.#nativeConnection);
788
+ }
789
+
790
+ async discoverObjects(): Promise<CrmObjectDefinition[]> {
791
+ const db = getDb();
792
+ const objects = await db
793
+ .select()
794
+ .from(schema.crmObjects)
795
+ .where(
796
+ and(
797
+ eq(schema.crmObjects.connectionId, this.connection.connectionId),
798
+ eq(schema.crmObjects.provider, "native"),
799
+ accessFilter(schema.crmObjects, schema.crmObjectShares),
800
+ ),
801
+ )
802
+ .orderBy(asc(schema.crmObjects.objectType));
803
+ return Promise.all(
804
+ objects.map((object) => this.describeObject(object.objectType)),
805
+ );
806
+ }
807
+
808
+ async describeObject(objectType: string): Promise<CrmObjectDefinition> {
809
+ const db = getDb();
810
+ const [object] = await db
811
+ .select()
812
+ .from(schema.crmObjects)
813
+ .where(
814
+ and(
815
+ eq(schema.crmObjects.connectionId, this.connection.connectionId),
816
+ eq(schema.crmObjects.objectType, objectType),
817
+ accessFilter(schema.crmObjects, schema.crmObjectShares),
818
+ ),
819
+ )
820
+ .limit(1);
821
+ if (!object)
822
+ throw new Error(`Native CRM object ${objectType} is not registered.`);
823
+ const policies = await db
824
+ .select()
825
+ .from(schema.crmFieldPolicies)
826
+ .where(
827
+ and(
828
+ eq(
829
+ schema.crmFieldPolicies.connectionId,
830
+ this.connection.connectionId,
831
+ ),
832
+ eq(schema.crmFieldPolicies.objectType, objectType),
833
+ accessFilter(schema.crmFieldPolicies, schema.crmFieldPolicyShares),
834
+ ),
835
+ )
836
+ .orderBy(asc(schema.crmFieldPolicies.fieldName));
837
+ return {
838
+ ...this.connection,
839
+ objectType,
840
+ kind: object.kind as CrmObjectKind,
841
+ label: object.label,
842
+ pluralLabel: object.pluralLabel,
843
+ custom: object.custom,
844
+ queryable: object.queryable,
845
+ searchable: object.searchable,
846
+ createable: object.createable,
847
+ updateable: object.updateable,
848
+ deleteable: object.deleteable,
849
+ fields: policies.map((policy) => ({
850
+ name: policy.fieldName,
851
+ label: policy.label,
852
+ valueType: policy.valueType as CrmFieldDefinition["valueType"],
853
+ storagePolicy: "local-authoritative",
854
+ sensitive: policy.sensitive,
855
+ readable: policy.readable,
856
+ createable: policy.createable,
857
+ updateable: policy.updateable,
858
+ required: policy.required,
859
+ })),
860
+ };
861
+ }
862
+
863
+ async syncPage(input: {
864
+ scope: CrmSyncScope;
865
+ fieldAllowList: string[];
866
+ cursor?: string;
867
+ limit: number;
868
+ }): Promise<CrmSyncPage> {
869
+ const db = getDb();
870
+ const limit = Math.min(Math.max(input.limit, 1), MAX_PAGE_SIZE);
871
+ const offset = decodeCursor(input.cursor);
872
+ const conditions = [
873
+ eq(schema.crmRecords.connectionId, this.connection.connectionId),
874
+ eq(schema.crmRecords.provider, "native"),
875
+ eq(schema.crmRecords.objectType, input.scope.objectType),
876
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
877
+ ];
878
+ if (!input.scope.includeDeleted)
879
+ conditions.push(eq(schema.crmRecords.tombstone, false));
880
+ if (input.scope.recordIds?.length)
881
+ conditions.push(
882
+ inArray(schema.crmRecords.remoteId, input.scope.recordIds),
883
+ );
884
+ if (input.scope.ownerIds?.length)
885
+ conditions.push(
886
+ inArray(schema.crmRecords.ownerRemoteId, input.scope.ownerIds),
887
+ );
888
+ if (input.scope.pipelineIds?.length)
889
+ conditions.push(
890
+ inArray(schema.crmRecords.pipelineId, input.scope.pipelineIds),
891
+ );
892
+ if (input.scope.updatedAfter)
893
+ conditions.push(
894
+ gt(schema.crmRecords.updatedAt, input.scope.updatedAfter),
895
+ );
896
+ if (input.scope.associatedRecordIds?.length) {
897
+ throw new Error(
898
+ "Native CRM sync does not infer associated-record cohorts; use explicit recordIds.",
899
+ );
900
+ }
901
+ const rows = await db
902
+ .select()
903
+ .from(schema.crmRecords)
904
+ .where(and(...conditions))
905
+ .orderBy(asc(schema.crmRecords.updatedAt), asc(schema.crmRecords.id))
906
+ .limit(limit + 1)
907
+ .offset(offset);
908
+ const page = rows.slice(0, limit);
909
+ return {
910
+ records: await Promise.all(
911
+ page.map((row) =>
912
+ materializeRecord({
913
+ row,
914
+ connection: this.#nativeConnection,
915
+ fields: input.fieldAllowList.slice(0, MAX_FIELDS),
916
+ }),
917
+ ),
918
+ ),
919
+ relationships: [],
920
+ ...(rows.length > limit
921
+ ? { nextCursor: encodeCursor(offset + limit) }
922
+ : {}),
923
+ complete: rows.length <= limit,
924
+ };
925
+ }
926
+
927
+ async getRecord(input: {
928
+ record: CrmRecordRef;
929
+ fields: string[];
930
+ }): Promise<CrmRecord | null> {
931
+ const db = getDb();
932
+ const conditions = [
933
+ eq(schema.crmRecords.connectionId, this.connection.connectionId),
934
+ eq(schema.crmRecords.provider, "native"),
935
+ eq(schema.crmRecords.objectType, input.record.objectType),
936
+ eq(schema.crmRecords.remoteId, input.record.remoteId),
937
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
938
+ ];
939
+ if (input.record.localId)
940
+ conditions.push(eq(schema.crmRecords.id, input.record.localId));
941
+ const [row] = await db
942
+ .select()
943
+ .from(schema.crmRecords)
944
+ .where(and(...conditions))
945
+ .limit(1);
946
+ return row
947
+ ? materializeRecord({
948
+ row,
949
+ connection: this.#nativeConnection,
950
+ fields: input.fields.slice(0, MAX_FIELDS),
951
+ })
952
+ : null;
953
+ }
954
+
955
+ async search(input: {
956
+ objectTypes: string[];
957
+ query: string;
958
+ fields: string[];
959
+ limit: number;
960
+ cursor?: string;
961
+ }): Promise<CrmSyncPage> {
962
+ if (!input.objectTypes.length || input.objectTypes.length > 50)
963
+ throw new Error("Native CRM search requires 1 to 50 object types.");
964
+ const db = getDb();
965
+ const limit = Math.min(Math.max(input.limit, 1), MAX_PAGE_SIZE);
966
+ const offset = decodeCursor(input.cursor);
967
+ const query = input.query.trim().slice(0, 120);
968
+ const rows = await db
969
+ .select()
970
+ .from(schema.crmRecords)
971
+ .where(
972
+ and(
973
+ eq(schema.crmRecords.connectionId, this.connection.connectionId),
974
+ eq(schema.crmRecords.provider, "native"),
975
+ eq(schema.crmRecords.tombstone, false),
976
+ inArray(schema.crmRecords.objectType, input.objectTypes),
977
+ or(
978
+ like(schema.crmRecords.displayName, `%${query}%`),
979
+ like(schema.crmRecords.primaryEmail, `%${query}%`),
980
+ like(schema.crmRecords.domain, `%${query}%`),
981
+ ),
982
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
983
+ ),
984
+ )
985
+ .orderBy(asc(schema.crmRecords.displayName), asc(schema.crmRecords.id))
986
+ .limit(limit + 1)
987
+ .offset(offset);
988
+ const page = rows.slice(0, limit);
989
+ return {
990
+ records: await Promise.all(
991
+ page.map((row) =>
992
+ materializeRecord({
993
+ row,
994
+ connection: this.#nativeConnection,
995
+ fields: input.fields.slice(0, MAX_FIELDS),
996
+ }),
997
+ ),
998
+ ),
999
+ relationships: [],
1000
+ ...(rows.length > limit
1001
+ ? { nextCursor: encodeCursor(offset + limit) }
1002
+ : {}),
1003
+ complete: rows.length <= limit,
1004
+ };
1005
+ }
1006
+
1007
+ async listRelationships(input: {
1008
+ record: CrmRecordRef;
1009
+ targetObjectTypes?: string[];
1010
+ limit: number;
1011
+ cursor?: string;
1012
+ }): Promise<{
1013
+ relationships: CrmRelationship[];
1014
+ nextCursor?: string;
1015
+ complete: boolean;
1016
+ }> {
1017
+ const source = await this.getRecord({ record: input.record, fields: [] });
1018
+ if (!source?.ref.localId) return { relationships: [], complete: true };
1019
+ const db = getDb();
1020
+ const limit = Math.min(Math.max(input.limit, 1), MAX_PAGE_SIZE);
1021
+ const rows = await db
1022
+ .select()
1023
+ .from(schema.crmRelationships)
1024
+ .where(
1025
+ and(
1026
+ eq(
1027
+ schema.crmRelationships.connectionId,
1028
+ this.connection.connectionId,
1029
+ ),
1030
+ eq(schema.crmRelationships.fromRecordId, source.ref.localId),
1031
+ eq(schema.crmRelationships.tombstone, false),
1032
+ accessFilter(schema.crmRelationships, schema.crmRelationshipShares),
1033
+ ),
1034
+ )
1035
+ .orderBy(asc(schema.crmRelationships.id))
1036
+ .limit(limit + 1)
1037
+ .offset(decodeCursor(input.cursor));
1038
+ const page = rows.slice(0, limit);
1039
+ const targetIds = page.map((row) => row.toRecordId);
1040
+ if (!targetIds.length)
1041
+ return { relationships: [], complete: rows.length <= limit };
1042
+ const targets = await db
1043
+ .select()
1044
+ .from(schema.crmRecords)
1045
+ .where(
1046
+ and(
1047
+ inArray(schema.crmRecords.id, targetIds),
1048
+ eq(schema.crmRecords.connectionId, this.connection.connectionId),
1049
+ eq(schema.crmRecords.tombstone, false),
1050
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
1051
+ ),
1052
+ );
1053
+ const byId = new Map(targets.map((target) => [target.id, target]));
1054
+ const relationships = page.flatMap((relationship) => {
1055
+ const target = byId.get(relationship.toRecordId);
1056
+ if (
1057
+ !target ||
1058
+ (input.targetObjectTypes?.length &&
1059
+ !input.targetObjectTypes.includes(target.objectType))
1060
+ )
1061
+ return [];
1062
+ return [
1063
+ {
1064
+ from: source.ref,
1065
+ to: {
1066
+ ...this.connection,
1067
+ objectType: target.objectType,
1068
+ kind: target.kind as CrmObjectKind,
1069
+ remoteId: target.remoteId,
1070
+ localId: target.id,
1071
+ },
1072
+ relationshipType: relationship.relationshipType,
1073
+ ...(relationship.label ? { label: relationship.label } : {}),
1074
+ ...(relationship.inverseLabel
1075
+ ? { inverseLabel: relationship.inverseLabel }
1076
+ : {}),
1077
+ ...(relationship.sourceField
1078
+ ? { sourceField: relationship.sourceField }
1079
+ : {}),
1080
+ },
1081
+ ];
1082
+ });
1083
+ const offset = decodeCursor(input.cursor);
1084
+ return {
1085
+ relationships,
1086
+ ...(rows.length > limit
1087
+ ? { nextCursor: encodeCursor(offset + limit) }
1088
+ : {}),
1089
+ complete: rows.length <= limit,
1090
+ };
1091
+ }
1092
+
1093
+ async applyMutation(mutation: CrmMutation): Promise<CrmMutationResult> {
1094
+ if (
1095
+ mutation.record.connectionId !== this.connection.connectionId ||
1096
+ mutation.record.provider !== "native"
1097
+ ) {
1098
+ return {
1099
+ status: "rejected",
1100
+ message: "Native CRM mutations must target this native connection.",
1101
+ };
1102
+ }
1103
+ if (!mutation.idempotencyKey || mutation.idempotencyKey.length > 256) {
1104
+ return {
1105
+ status: "rejected",
1106
+ message: "Native CRM mutations require a bounded idempotency key.",
1107
+ };
1108
+ }
1109
+ const fields = mutationFields(mutation);
1110
+ if (mutation.operation === "create")
1111
+ return this.createRecord(mutation, fields);
1112
+ if (mutation.operation === "update")
1113
+ return this.updateRecord(mutation, fields);
1114
+ if (mutation.operation === "delete") return this.deleteRecord(mutation);
1115
+ if (
1116
+ mutation.operation === "associate" ||
1117
+ mutation.operation === "disassociate"
1118
+ ) {
1119
+ return this.mutateRelationship(mutation);
1120
+ }
1121
+ return { status: "rejected", message: "Unsupported native CRM mutation." };
1122
+ }
1123
+
1124
+ private async replayOrStart(mutation: CrmMutation, patchJson: string) {
1125
+ const db = getDb();
1126
+ const [existing] = await db
1127
+ .select()
1128
+ .from(schema.crmMutations)
1129
+ .where(
1130
+ and(
1131
+ eq(schema.crmMutations.idempotencyKey, mutation.idempotencyKey),
1132
+ accessFilter(schema.crmMutations, schema.crmMutationShares),
1133
+ ),
1134
+ )
1135
+ .limit(1);
1136
+ if (!existing) return null;
1137
+ if (
1138
+ existing.operation !== mutation.operation ||
1139
+ existing.patchJson !== patchJson
1140
+ ) {
1141
+ throw new Error(
1142
+ "Native CRM idempotency key was already used for a different mutation.",
1143
+ );
1144
+ }
1145
+ if (existing.status === "conflict")
1146
+ return {
1147
+ status: "conflict" as const,
1148
+ message: existing.error ?? undefined,
1149
+ };
1150
+ const record = existing.recordId
1151
+ ? await this.getRecord({
1152
+ record: { ...mutation.record, localId: existing.recordId },
1153
+ fields: [],
1154
+ })
1155
+ : null;
1156
+ return {
1157
+ status: "applied" as const,
1158
+ ...(record ? { record, remoteRevision: record.remoteRevision } : {}),
1159
+ };
1160
+ }
1161
+
1162
+ private async createRecord(
1163
+ mutation: CrmMutation,
1164
+ fields: Record<string, CrmValue>,
1165
+ ): Promise<CrmMutationResult> {
1166
+ const objectType = mutation.record.objectType;
1167
+ await ensureNativeObject({
1168
+ connection: this.#nativeConnection,
1169
+ objectType,
1170
+ fields: Object.entries(fields).map(([name, value]) =>
1171
+ nativeField(name, attributeTypeForValue(value)),
1172
+ ),
1173
+ });
1174
+ const patchJson = json({ fields });
1175
+ const replay = await this.replayOrStart(mutation, patchJson);
1176
+ if (replay) return replay;
1177
+ const db = getDb();
1178
+ const remoteId = mutation.record.remoteId || crypto.randomUUID();
1179
+ const recordId = crypto.randomUUID();
1180
+ const now = new Date().toISOString();
1181
+ const revision = "1";
1182
+ try {
1183
+ await db.transaction(async (tx) => {
1184
+ const [duplicate] = await tx
1185
+ .select({ id: schema.crmRecords.id })
1186
+ .from(schema.crmRecords)
1187
+ .where(
1188
+ and(
1189
+ eq(schema.crmRecords.connectionId, this.connection.connectionId),
1190
+ eq(schema.crmRecords.objectType, objectType),
1191
+ eq(schema.crmRecords.remoteId, remoteId),
1192
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
1193
+ ),
1194
+ )
1195
+ .limit(1);
1196
+ if (duplicate) throw new Error("NATIVE_CRM_DUPLICATE_RECORD");
1197
+ await tx.insert(schema.crmRecords).values({
1198
+ id: recordId,
1199
+ connectionId: this.connection.connectionId,
1200
+ provider: "native",
1201
+ objectType,
1202
+ kind: kindForObject(objectType),
1203
+ remoteId,
1204
+ ...summaryColumns(fields, remoteId),
1205
+ remoteRevision: revision,
1206
+ remoteUpdatedAt: now,
1207
+ accessScopeKey: this.getAccessScope().key,
1208
+ accessScopeJson: json(this.getAccessScope(), 4_000),
1209
+ tombstone: false,
1210
+ ownerEmail: this.#nativeConnection.ownerEmail,
1211
+ orgId: this.#nativeConnection.orgId,
1212
+ visibility: this.#nativeConnection.visibility,
1213
+ createdAt: now,
1214
+ updatedAt: now,
1215
+ });
1216
+ for (const [fieldName, value] of Object.entries(fields)) {
1217
+ await tx.insert(schema.crmRecordFields).values({
1218
+ id: crypto.randomUUID(),
1219
+ recordId,
1220
+ fieldName,
1221
+ valueType: valueType(value),
1222
+ storagePolicy: "local-authoritative",
1223
+ ...fieldColumns(value),
1224
+ provenanceJson: json(
1225
+ [
1226
+ {
1227
+ provider: "native",
1228
+ connectionId: this.connection.connectionId,
1229
+ objectType,
1230
+ remoteId,
1231
+ fieldName,
1232
+ remoteRevision: revision,
1233
+ observedAt: now,
1234
+ },
1235
+ ],
1236
+ 4_000,
1237
+ ),
1238
+ accessScopeKey: this.getAccessScope().key,
1239
+ accessScopeJson: json(this.getAccessScope(), 4_000),
1240
+ remoteRevision: revision,
1241
+ ownerEmail: this.#nativeConnection.ownerEmail,
1242
+ orgId: this.#nativeConnection.orgId,
1243
+ visibility: this.#nativeConnection.visibility,
1244
+ createdAt: now,
1245
+ updatedAt: now,
1246
+ });
1247
+ }
1248
+ await tx.insert(schema.crmMutations).values({
1249
+ id: crypto.randomUUID(),
1250
+ recordId,
1251
+ connectionId: this.connection.connectionId,
1252
+ operation: "create",
1253
+ initiatedBy: this.#initiatedBy,
1254
+ target: "local",
1255
+ policyDecision: "execute",
1256
+ risk: "routine",
1257
+ status: "applied",
1258
+ patchJson,
1259
+ beforeJson: "{}",
1260
+ afterJson: json({ remoteId, revision }),
1261
+ idempotencyKey: mutation.idempotencyKey,
1262
+ providerRemoteRevision: revision,
1263
+ appliedAt: now,
1264
+ ownerEmail: this.#nativeConnection.ownerEmail,
1265
+ orgId: this.#nativeConnection.orgId,
1266
+ visibility: this.#nativeConnection.visibility,
1267
+ createdAt: now,
1268
+ updatedAt: now,
1269
+ });
1270
+ });
1271
+ } catch (error) {
1272
+ if (
1273
+ error instanceof Error &&
1274
+ error.message === "NATIVE_CRM_DUPLICATE_RECORD"
1275
+ )
1276
+ return {
1277
+ status: "conflict",
1278
+ message: "A native CRM record with this id already exists.",
1279
+ };
1280
+ throw error;
1281
+ }
1282
+ const record = await this.getRecord({
1283
+ record: { ...mutation.record, remoteId, localId: recordId },
1284
+ fields: Object.keys(fields),
1285
+ });
1286
+ return record
1287
+ ? { status: "applied", record, remoteRevision: revision }
1288
+ : { status: "rejected", message: "Native CRM record was not persisted." };
1289
+ }
1290
+
1291
+ private async updateRecord(
1292
+ mutation: CrmMutation,
1293
+ fields: Record<string, CrmValue>,
1294
+ ): Promise<CrmMutationResult> {
1295
+ await ensureNativeObject({
1296
+ connection: this.#nativeConnection,
1297
+ objectType: mutation.record.objectType,
1298
+ fields: Object.entries(fields).map(([name, value]) =>
1299
+ nativeField(name, attributeTypeForValue(value)),
1300
+ ),
1301
+ });
1302
+ const patchJson = json({ fields });
1303
+ const replay = await this.replayOrStart(mutation, patchJson);
1304
+ if (replay) return replay;
1305
+ const db = getDb();
1306
+ const [record] = await db
1307
+ .select()
1308
+ .from(schema.crmRecords)
1309
+ .where(
1310
+ and(
1311
+ eq(schema.crmRecords.connectionId, this.connection.connectionId),
1312
+ eq(schema.crmRecords.objectType, mutation.record.objectType),
1313
+ eq(schema.crmRecords.remoteId, mutation.record.remoteId),
1314
+ accessFilter(
1315
+ schema.crmRecords,
1316
+ schema.crmRecordShares,
1317
+ undefined,
1318
+ "editor",
1319
+ ),
1320
+ ),
1321
+ )
1322
+ .limit(1);
1323
+ if (!record || record.tombstone)
1324
+ return {
1325
+ status: "rejected",
1326
+ message: "Native CRM record is unavailable.",
1327
+ };
1328
+ await assertAccess("crm-record", record.id, "editor");
1329
+ if (
1330
+ mutation.expectedRemoteRevision &&
1331
+ mutation.expectedRemoteRevision !== record.remoteRevision
1332
+ )
1333
+ return {
1334
+ status: "conflict",
1335
+ message: "Native CRM record revision changed.",
1336
+ };
1337
+ const current = await fieldsForRecord(record.id);
1338
+ const merged = { ...current, ...fields };
1339
+ const revision = nextNativeRevision(record.remoteRevision);
1340
+ const now = new Date().toISOString();
1341
+ const revisionGuard =
1342
+ record.remoteRevision === null
1343
+ ? isNull(schema.crmRecords.remoteRevision)
1344
+ : eq(schema.crmRecords.remoteRevision, record.remoteRevision);
1345
+ try {
1346
+ await db.transaction(async (tx) => {
1347
+ const guarded = await tx
1348
+ .update(schema.crmRecords)
1349
+ .set({
1350
+ ...summaryColumns(merged, record.remoteId),
1351
+ remoteRevision: revision,
1352
+ remoteUpdatedAt: now,
1353
+ updatedAt: now,
1354
+ })
1355
+ .where(
1356
+ and(
1357
+ eq(schema.crmRecords.id, record.id),
1358
+ revisionGuard,
1359
+ accessFilter(
1360
+ schema.crmRecords,
1361
+ schema.crmRecordShares,
1362
+ undefined,
1363
+ "editor",
1364
+ ),
1365
+ ),
1366
+ )
1367
+ .returning({ id: schema.crmRecords.id });
1368
+ if (guarded.length !== 1)
1369
+ throw new Error("NATIVE_CRM_REVISION_CONFLICT");
1370
+ for (const [fieldName, value] of Object.entries(fields)) {
1371
+ const [existing] = await tx
1372
+ .select({ id: schema.crmRecordFields.id })
1373
+ .from(schema.crmRecordFields)
1374
+ .where(
1375
+ and(
1376
+ eq(schema.crmRecordFields.recordId, record.id),
1377
+ eq(schema.crmRecordFields.fieldName, fieldName),
1378
+ accessFilter(
1379
+ schema.crmRecordFields,
1380
+ schema.crmRecordFieldShares,
1381
+ undefined,
1382
+ "editor",
1383
+ ),
1384
+ ),
1385
+ )
1386
+ .limit(1);
1387
+ const values = {
1388
+ valueType: valueType(value),
1389
+ storagePolicy: "local-authoritative" as const,
1390
+ ...fieldColumns(value),
1391
+ provenanceJson: json(
1392
+ [
1393
+ {
1394
+ provider: "native",
1395
+ connectionId: this.connection.connectionId,
1396
+ objectType: record.objectType,
1397
+ remoteId: record.remoteId,
1398
+ fieldName,
1399
+ remoteRevision: revision,
1400
+ observedAt: now,
1401
+ },
1402
+ ],
1403
+ 4_000,
1404
+ ),
1405
+ accessScopeKey: this.getAccessScope().key,
1406
+ accessScopeJson: json(this.getAccessScope(), 4_000),
1407
+ remoteRevision: revision,
1408
+ updatedAt: now,
1409
+ };
1410
+ if (existing) {
1411
+ await tx
1412
+ .update(schema.crmRecordFields)
1413
+ .set(values)
1414
+ .where(
1415
+ and(
1416
+ eq(schema.crmRecordFields.id, existing.id),
1417
+ accessFilter(
1418
+ schema.crmRecordFields,
1419
+ schema.crmRecordFieldShares,
1420
+ undefined,
1421
+ "editor",
1422
+ ),
1423
+ ),
1424
+ );
1425
+ } else {
1426
+ await tx.insert(schema.crmRecordFields).values({
1427
+ id: crypto.randomUUID(),
1428
+ recordId: record.id,
1429
+ fieldName,
1430
+ ...values,
1431
+ ownerEmail: record.ownerEmail,
1432
+ orgId: record.orgId,
1433
+ visibility: record.visibility,
1434
+ createdAt: now,
1435
+ });
1436
+ }
1437
+ }
1438
+ await tx.insert(schema.crmMutations).values({
1439
+ id: crypto.randomUUID(),
1440
+ recordId: record.id,
1441
+ connectionId: this.connection.connectionId,
1442
+ operation: "update",
1443
+ initiatedBy: this.#initiatedBy,
1444
+ target: "local",
1445
+ policyDecision: "execute",
1446
+ risk: "routine",
1447
+ status: "applied",
1448
+ patchJson,
1449
+ beforeJson: json({ remoteRevision: record.remoteRevision }),
1450
+ afterJson: json({ remoteRevision: revision }),
1451
+ idempotencyKey: mutation.idempotencyKey,
1452
+ expectedRemoteRevision: mutation.expectedRemoteRevision ?? null,
1453
+ providerRemoteRevision: revision,
1454
+ appliedAt: now,
1455
+ ownerEmail: record.ownerEmail,
1456
+ orgId: record.orgId,
1457
+ visibility: record.visibility,
1458
+ createdAt: now,
1459
+ updatedAt: now,
1460
+ });
1461
+ });
1462
+ } catch (error) {
1463
+ if (
1464
+ error instanceof Error &&
1465
+ error.message === "NATIVE_CRM_REVISION_CONFLICT"
1466
+ )
1467
+ return {
1468
+ status: "conflict",
1469
+ message: "Native CRM record revision changed.",
1470
+ };
1471
+ throw error;
1472
+ }
1473
+ const updated = await this.getRecord({
1474
+ record: mutation.record,
1475
+ fields: Object.keys(merged),
1476
+ });
1477
+ return updated
1478
+ ? { status: "applied", record: updated, remoteRevision: revision }
1479
+ : { status: "rejected", message: "Native CRM record was not updated." };
1480
+ }
1481
+
1482
+ private async deleteRecord(
1483
+ mutation: CrmMutation,
1484
+ ): Promise<CrmMutationResult> {
1485
+ const patchJson = "{}";
1486
+ const replay = await this.replayOrStart(mutation, patchJson);
1487
+ if (replay) return replay;
1488
+ const db = getDb();
1489
+ const [record] = await db
1490
+ .select()
1491
+ .from(schema.crmRecords)
1492
+ .where(
1493
+ and(
1494
+ eq(schema.crmRecords.connectionId, this.connection.connectionId),
1495
+ eq(schema.crmRecords.objectType, mutation.record.objectType),
1496
+ eq(schema.crmRecords.remoteId, mutation.record.remoteId),
1497
+ accessFilter(
1498
+ schema.crmRecords,
1499
+ schema.crmRecordShares,
1500
+ undefined,
1501
+ "editor",
1502
+ ),
1503
+ ),
1504
+ )
1505
+ .limit(1);
1506
+ if (!record)
1507
+ return {
1508
+ status: "rejected",
1509
+ message: "Native CRM record is unavailable.",
1510
+ };
1511
+ await assertAccess("crm-record", record.id, "editor");
1512
+ if (
1513
+ mutation.expectedRemoteRevision &&
1514
+ mutation.expectedRemoteRevision !== record.remoteRevision
1515
+ )
1516
+ return {
1517
+ status: "conflict",
1518
+ message: "Native CRM record revision changed.",
1519
+ };
1520
+ const revision = nextNativeRevision(record.remoteRevision);
1521
+ const now = new Date().toISOString();
1522
+ const revisionGuard =
1523
+ record.remoteRevision === null
1524
+ ? isNull(schema.crmRecords.remoteRevision)
1525
+ : eq(schema.crmRecords.remoteRevision, record.remoteRevision);
1526
+ try {
1527
+ await db.transaction(async (tx) => {
1528
+ const guarded = await tx
1529
+ .update(schema.crmRecords)
1530
+ .set({
1531
+ tombstone: true,
1532
+ remoteRevision: revision,
1533
+ remoteUpdatedAt: now,
1534
+ updatedAt: now,
1535
+ })
1536
+ .where(
1537
+ and(
1538
+ eq(schema.crmRecords.id, record.id),
1539
+ revisionGuard,
1540
+ accessFilter(
1541
+ schema.crmRecords,
1542
+ schema.crmRecordShares,
1543
+ undefined,
1544
+ "editor",
1545
+ ),
1546
+ ),
1547
+ )
1548
+ .returning({ id: schema.crmRecords.id });
1549
+ if (guarded.length !== 1)
1550
+ throw new Error("NATIVE_CRM_REVISION_CONFLICT");
1551
+ const relationships = await tx
1552
+ .select({ id: schema.crmRelationships.id })
1553
+ .from(schema.crmRelationships)
1554
+ .where(
1555
+ and(
1556
+ eq(
1557
+ schema.crmRelationships.connectionId,
1558
+ this.connection.connectionId,
1559
+ ),
1560
+ or(
1561
+ eq(schema.crmRelationships.fromRecordId, record.id),
1562
+ eq(schema.crmRelationships.toRecordId, record.id),
1563
+ ),
1564
+ accessFilter(
1565
+ schema.crmRelationships,
1566
+ schema.crmRelationshipShares,
1567
+ undefined,
1568
+ "editor",
1569
+ ),
1570
+ ),
1571
+ );
1572
+ for (const relationship of relationships) {
1573
+ await assertAccess("crm-relationship", relationship.id, "editor");
1574
+ await tx
1575
+ .update(schema.crmRelationships)
1576
+ .set({ tombstone: true, updatedAt: now })
1577
+ .where(
1578
+ and(
1579
+ eq(schema.crmRelationships.id, relationship.id),
1580
+ accessFilter(
1581
+ schema.crmRelationships,
1582
+ schema.crmRelationshipShares,
1583
+ undefined,
1584
+ "editor",
1585
+ ),
1586
+ ),
1587
+ );
1588
+ }
1589
+ await tx.insert(schema.crmMutations).values({
1590
+ id: crypto.randomUUID(),
1591
+ recordId: record.id,
1592
+ connectionId: this.connection.connectionId,
1593
+ operation: "delete",
1594
+ initiatedBy: this.#initiatedBy,
1595
+ target: "local",
1596
+ policyDecision: "execute",
1597
+ risk: "routine",
1598
+ status: "applied",
1599
+ patchJson,
1600
+ beforeJson: json({ remoteRevision: record.remoteRevision }),
1601
+ afterJson: json({ remoteRevision: revision, tombstone: true }),
1602
+ idempotencyKey: mutation.idempotencyKey,
1603
+ expectedRemoteRevision: mutation.expectedRemoteRevision ?? null,
1604
+ providerRemoteRevision: revision,
1605
+ appliedAt: now,
1606
+ ownerEmail: record.ownerEmail,
1607
+ orgId: record.orgId,
1608
+ visibility: record.visibility,
1609
+ createdAt: now,
1610
+ updatedAt: now,
1611
+ });
1612
+ });
1613
+ } catch (error) {
1614
+ if (
1615
+ error instanceof Error &&
1616
+ error.message === "NATIVE_CRM_REVISION_CONFLICT"
1617
+ )
1618
+ return {
1619
+ status: "conflict",
1620
+ message: "Native CRM record revision changed.",
1621
+ };
1622
+ throw error;
1623
+ }
1624
+ const deleted = await this.getRecord({
1625
+ record: mutation.record,
1626
+ fields: [],
1627
+ });
1628
+ return {
1629
+ status: "applied",
1630
+ ...(deleted ? { record: deleted } : {}),
1631
+ remoteRevision: revision,
1632
+ };
1633
+ }
1634
+
1635
+ private async mutateRelationship(
1636
+ mutation: CrmMutation,
1637
+ ): Promise<CrmMutationResult> {
1638
+ if (!mutation.relationship)
1639
+ return {
1640
+ status: "rejected",
1641
+ message: "Native relationship mutations require an edge.",
1642
+ };
1643
+ const patchJson = json({ relationship: mutation.relationship });
1644
+ const replay = await this.replayOrStart(mutation, patchJson);
1645
+ if (replay) return replay;
1646
+ const from = await this.getRecord({
1647
+ record: mutation.relationship.from,
1648
+ fields: [],
1649
+ });
1650
+ const to = await this.getRecord({
1651
+ record: mutation.relationship.to,
1652
+ fields: [],
1653
+ });
1654
+ if (!from?.ref.localId || !to?.ref.localId)
1655
+ return {
1656
+ status: "rejected",
1657
+ message: "Native relationship records are unavailable.",
1658
+ };
1659
+ await assertAccess("crm-record", from.ref.localId, "editor");
1660
+ await assertAccess("crm-record", to.ref.localId, "editor");
1661
+ const db = getDb();
1662
+ const now = new Date().toISOString();
1663
+ await db.transaction(async (tx) => {
1664
+ const [existing] = await tx
1665
+ .select({ id: schema.crmRelationships.id })
1666
+ .from(schema.crmRelationships)
1667
+ .where(
1668
+ and(
1669
+ eq(
1670
+ schema.crmRelationships.connectionId,
1671
+ this.connection.connectionId,
1672
+ ),
1673
+ eq(schema.crmRelationships.fromRecordId, from.ref.localId!),
1674
+ eq(schema.crmRelationships.toRecordId, to.ref.localId!),
1675
+ eq(
1676
+ schema.crmRelationships.relationshipType,
1677
+ mutation.relationship!.relationshipType,
1678
+ ),
1679
+ accessFilter(
1680
+ schema.crmRelationships,
1681
+ schema.crmRelationshipShares,
1682
+ undefined,
1683
+ "editor",
1684
+ ),
1685
+ ),
1686
+ )
1687
+ .limit(1);
1688
+ if (existing) {
1689
+ await assertAccess("crm-relationship", existing.id, "editor");
1690
+ await tx
1691
+ .update(schema.crmRelationships)
1692
+ .set({
1693
+ tombstone: mutation.operation === "disassociate",
1694
+ label: mutation.relationship!.label ?? null,
1695
+ inverseLabel: mutation.relationship!.inverseLabel ?? null,
1696
+ sourceField: mutation.relationship!.sourceField ?? null,
1697
+ updatedAt: now,
1698
+ })
1699
+ .where(
1700
+ and(
1701
+ eq(schema.crmRelationships.id, existing.id),
1702
+ accessFilter(
1703
+ schema.crmRelationships,
1704
+ schema.crmRelationshipShares,
1705
+ undefined,
1706
+ "editor",
1707
+ ),
1708
+ ),
1709
+ );
1710
+ } else if (mutation.operation === "associate") {
1711
+ await tx.insert(schema.crmRelationships).values({
1712
+ id: crypto.randomUUID(),
1713
+ connectionId: this.connection.connectionId,
1714
+ fromRecordId: from.ref.localId!,
1715
+ toRecordId: to.ref.localId!,
1716
+ relationshipType: mutation.relationship!.relationshipType,
1717
+ label: mutation.relationship!.label ?? null,
1718
+ inverseLabel: mutation.relationship!.inverseLabel ?? null,
1719
+ sourceField: mutation.relationship!.sourceField ?? null,
1720
+ tombstone: false,
1721
+ ownerEmail: this.#nativeConnection.ownerEmail,
1722
+ orgId: this.#nativeConnection.orgId,
1723
+ visibility: this.#nativeConnection.visibility,
1724
+ createdAt: now,
1725
+ updatedAt: now,
1726
+ });
1727
+ }
1728
+ await tx.insert(schema.crmMutations).values({
1729
+ id: crypto.randomUUID(),
1730
+ recordId: from.ref.localId,
1731
+ connectionId: this.connection.connectionId,
1732
+ operation: mutation.operation,
1733
+ initiatedBy: this.#initiatedBy,
1734
+ target: "local",
1735
+ policyDecision: "execute",
1736
+ risk: "routine",
1737
+ status: "applied",
1738
+ patchJson,
1739
+ beforeJson: "{}",
1740
+ afterJson: json({ relationship: mutation.operation }),
1741
+ idempotencyKey: mutation.idempotencyKey,
1742
+ appliedAt: now,
1743
+ ownerEmail: this.#nativeConnection.ownerEmail,
1744
+ orgId: this.#nativeConnection.orgId,
1745
+ visibility: this.#nativeConnection.visibility,
1746
+ createdAt: now,
1747
+ updatedAt: now,
1748
+ });
1749
+ });
1750
+ return {
1751
+ status: "applied",
1752
+ record: from,
1753
+ remoteRevision: from.remoteRevision,
1754
+ };
1755
+ }
1756
+ }
1757
+
1758
+ export async function createNativeCrmAdapter(options: {
1759
+ connectionId: string;
1760
+ initiatedBy?: "human" | "agent" | "automation";
1761
+ accessTier?: "viewer" | "editor";
1762
+ }): Promise<NativeCrmAdapter> {
1763
+ const accessTier = options.accessTier ?? "editor";
1764
+ const connection = await loadNativeConnection(
1765
+ options.connectionId,
1766
+ accessTier,
1767
+ );
1768
+ if (accessTier === "editor") {
1769
+ await initializeNativeCrmDomain(options.connectionId);
1770
+ }
1771
+ return new NativeCrmAdapter(connection, options.initiatedBy);
1772
+ }
1773
+
1774
+ export async function createNativeCrmRecord(input: {
1775
+ connectionId: string;
1776
+ kind: "account" | "person" | "opportunity";
1777
+ displayName: string;
1778
+ fields: Record<string, CrmValue>;
1779
+ idempotencyKey?: string;
1780
+ initiatedBy?: "human" | "agent" | "automation";
1781
+ }) {
1782
+ const objectType =
1783
+ input.kind === "account"
1784
+ ? "accounts"
1785
+ : input.kind === "person"
1786
+ ? "people"
1787
+ : "opportunities";
1788
+ const adapter = await createNativeCrmAdapter({
1789
+ connectionId: input.connectionId,
1790
+ initiatedBy: input.initiatedBy,
1791
+ });
1792
+ return adapter.applyMutation({
1793
+ operation: "create",
1794
+ record: {
1795
+ ...adapter.connection,
1796
+ objectType,
1797
+ kind: input.kind,
1798
+ remoteId: `native-${crypto.randomUUID()}`,
1799
+ },
1800
+ // `summaryColumns` derives the record's display name from `name` first —
1801
+ // accounts and opportunities already carry a `name` attribute, so only
1802
+ // people (which have no `name` field) need a separate stored `displayName`
1803
+ // attribute. Minting both would leave two attributes for one value.
1804
+ fields:
1805
+ input.kind === "person"
1806
+ ? { ...input.fields, displayName: input.displayName }
1807
+ : { ...input.fields, name: input.displayName },
1808
+ idempotencyKey: input.idempotencyKey ?? crypto.randomUUID(),
1809
+ });
1810
+ }