@agent-native/core 0.123.0 → 0.123.1

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 (263) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/templates-meta.ts +12 -0
  5. package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
  6. package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
  7. package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
  8. package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
  9. package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
  10. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
  11. package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
  12. package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
  13. package/corpus/templates/crm/.env.example +5 -0
  14. package/corpus/templates/crm/AGENTS.md +82 -0
  15. package/corpus/templates/crm/CHANGELOG.md +25 -0
  16. package/corpus/templates/crm/DEVELOPING.md +66 -0
  17. package/corpus/templates/crm/README.md +122 -0
  18. package/corpus/templates/crm/_gitignore +12 -0
  19. package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
  20. package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
  21. package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
  22. package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
  23. package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
  24. package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
  25. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
  26. package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
  27. package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
  28. package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
  29. package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
  30. package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
  31. package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
  32. package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
  33. package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
  34. package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
  35. package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
  36. package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
  37. package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
  38. package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
  39. package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
  40. package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
  41. package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
  42. package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
  43. package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
  44. package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
  45. package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
  46. package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
  47. package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
  48. package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
  49. package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
  50. package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
  51. package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
  52. package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
  53. package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
  54. package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
  55. package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
  56. package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
  57. package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
  58. package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
  59. package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
  60. package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
  61. package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
  62. package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
  63. package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
  64. package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
  65. package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
  66. package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
  67. package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
  68. package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
  69. package/corpus/templates/crm/actions/navigate.ts +54 -0
  70. package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
  71. package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
  72. package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
  73. package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
  74. package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
  75. package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
  76. package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
  77. package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
  78. package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
  79. package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
  80. package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
  81. package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
  82. package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
  83. package/corpus/templates/crm/actions/run.ts +7 -0
  84. package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
  85. package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
  86. package/corpus/templates/crm/actions/sync-crm.ts +146 -0
  87. package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
  88. package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
  89. package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
  90. package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
  91. package/corpus/templates/crm/actions/view-screen.ts +324 -0
  92. package/corpus/templates/crm/agent-native.app-skill.json +69 -0
  93. package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
  94. package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
  95. package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
  96. package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
  97. package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
  98. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
  99. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
  100. package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
  101. package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
  102. package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
  103. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
  104. package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
  105. package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
  106. package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
  107. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
  108. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
  109. package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
  110. package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
  111. package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
  112. package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
  113. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
  114. package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
  115. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
  116. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
  117. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
  118. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
  119. package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
  120. package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
  121. package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
  122. package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
  123. package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
  124. package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
  125. package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
  126. package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
  127. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
  128. package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
  129. package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
  130. package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
  131. package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
  132. package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
  133. package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
  134. package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
  135. package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
  136. package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
  137. package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
  138. package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
  139. package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
  140. package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
  141. package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
  142. package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
  143. package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
  144. package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
  145. package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
  146. package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
  147. package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
  148. package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
  149. package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
  150. package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
  151. package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
  152. package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
  153. package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
  154. package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
  155. package/corpus/templates/crm/app/design-system.ts +3 -0
  156. package/corpus/templates/crm/app/entry.client.tsx +19 -0
  157. package/corpus/templates/crm/app/entry.server.tsx +10 -0
  158. package/corpus/templates/crm/app/global.css +167 -0
  159. package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
  160. package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
  161. package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
  162. package/corpus/templates/crm/app/i18n/index.ts +34 -0
  163. package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
  164. package/corpus/templates/crm/app/lib/navigation.ts +17 -0
  165. package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
  166. package/corpus/templates/crm/app/lib/types.ts +172 -0
  167. package/corpus/templates/crm/app/lib/utils.ts +1 -0
  168. package/corpus/templates/crm/app/root.tsx +93 -0
  169. package/corpus/templates/crm/app/routes/_index.tsx +26 -0
  170. package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
  171. package/corpus/templates/crm/app/routes/agent.tsx +5 -0
  172. package/corpus/templates/crm/app/routes/ask.tsx +53 -0
  173. package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
  174. package/corpus/templates/crm/app/routes/lists.tsx +228 -0
  175. package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
  176. package/corpus/templates/crm/app/routes/people.tsx +41 -0
  177. package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
  178. package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
  179. package/corpus/templates/crm/app/routes/records.tsx +64 -0
  180. package/corpus/templates/crm/app/routes/settings.tsx +118 -0
  181. package/corpus/templates/crm/app/routes/setup.tsx +308 -0
  182. package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
  183. package/corpus/templates/crm/app/routes/views.tsx +683 -0
  184. package/corpus/templates/crm/app/routes.ts +19 -0
  185. package/corpus/templates/crm/app/vite-env.d.ts +6 -0
  186. 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
  187. package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
  188. package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
  189. package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
  190. package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
  191. package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
  192. 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
  193. package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
  194. package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
  195. package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
  196. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
  197. package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
  198. package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
  199. package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
  200. package/corpus/templates/crm/components.json +20 -0
  201. package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
  202. package/corpus/templates/crm/docs/features/README.md +55 -0
  203. package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
  204. package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
  205. package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
  206. package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
  207. package/corpus/templates/crm/learnings.defaults.md +5 -0
  208. package/corpus/templates/crm/netlify.toml +11 -0
  209. package/corpus/templates/crm/package.json +61 -0
  210. package/corpus/templates/crm/public/favicon.svg +1 -0
  211. package/corpus/templates/crm/public/icon-180.svg +1 -0
  212. package/corpus/templates/crm/public/manifest.json +11 -0
  213. package/corpus/templates/crm/react-router.config.ts +7 -0
  214. package/corpus/templates/crm/server/crm/adapter.ts +32 -0
  215. package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
  216. package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
  217. package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
  218. package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
  219. package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
  220. package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
  221. package/corpus/templates/crm/server/crm/read-through.ts +207 -0
  222. package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
  223. package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
  224. package/corpus/templates/crm/server/db/index.ts +191 -0
  225. package/corpus/templates/crm/server/db/schema.ts +647 -0
  226. package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
  227. package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
  228. package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
  229. package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
  230. package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
  231. package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
  232. package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
  233. package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
  234. package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
  235. package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
  236. package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
  237. package/corpus/templates/crm/server/middleware/auth.ts +4 -0
  238. package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
  239. package/corpus/templates/crm/server/plugins/auth.ts +14 -0
  240. package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
  241. package/corpus/templates/crm/server/plugins/db.ts +738 -0
  242. package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
  243. package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
  244. package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
  245. package/corpus/templates/crm/shared/crm-contract.ts +417 -0
  246. package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
  247. package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
  248. package/corpus/templates/crm/ssr-entry.ts +11 -0
  249. package/corpus/templates/crm/tsconfig.json +24 -0
  250. package/corpus/templates/crm/vite.config.ts +15 -0
  251. package/corpus/templates/crm/vitest.config.ts +5 -0
  252. package/corpus/templates/design/app/i18n-data.ts +12 -12
  253. package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
  254. package/dist/cli/templates-meta.d.ts.map +1 -1
  255. package/dist/cli/templates-meta.js +12 -0
  256. package/dist/cli/templates-meta.js.map +1 -1
  257. package/dist/collab/routes.d.ts +1 -1
  258. package/dist/observability/routes.d.ts +3 -3
  259. package/dist/resources/handlers.d.ts +1 -1
  260. package/dist/server/realtime-token.d.ts +1 -1
  261. package/dist/server/transcribe-voice.d.ts +1 -1
  262. package/package.json +1 -1
  263. package/src/cli/templates-meta.ts +12 -0
@@ -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
+ }