@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,691 @@
1
+ import { defineAction, type ActionRunContext } from "@agent-native/core/action";
2
+ import { accessFilter, assertAccess } from "@agent-native/core/sharing";
3
+ import { and, eq, inArray, isNull, or } from "drizzle-orm";
4
+ import { z } from "zod";
5
+
6
+ import { getDb, schema } from "../server/db/index.js";
7
+ import {
8
+ writeCrmRecordField,
9
+ CrmAttributeValueError,
10
+ type CrmFieldWriteDb,
11
+ type CrmWritableAttribute,
12
+ } from "../server/lib/record-fields.js";
13
+ import { storageColumnFor } from "../shared/crm-attributes.js";
14
+ import { decideCrmWritePolicy, type CrmValue } from "../shared/crm-contract.js";
15
+ import {
16
+ crmInitiatedBy,
17
+ scopedCrmIdempotencyKey,
18
+ toJson,
19
+ } from "./_crm-action-utils.js";
20
+
21
+ /**
22
+ * Merging is destructive-shaped and touches identity, so it is approval-gated
23
+ * for every non-human caller by `decideCrmWritePolicy`. The same input feeds the
24
+ * `needsApproval` gate and the ledger row, so what the agent was gated on and
25
+ * what the audit trail records cannot drift apart.
26
+ */
27
+ const MERGE_WRITE_POLICY = {
28
+ target: "local",
29
+ reversibility: "destructive",
30
+ scope: "single-record",
31
+ risk: "ownership",
32
+ delegatedAuthority: false,
33
+ storedAutomationPolicy: false,
34
+ } as const;
35
+
36
+ /** Most rows of one kind a single merge will move. Beyond this, fail loud. */
37
+ const MAX_MERGE_ROWS = 500;
38
+
39
+ /** Relationship type that points a merged-away record at the record that won. */
40
+ export const CRM_MERGED_INTO_RELATIONSHIP = "merged-into";
41
+
42
+ class CrmMergeError extends CrmAttributeValueError {
43
+ constructor(code: string, message: string) {
44
+ super(code, message);
45
+ this.name = "CrmMergeError";
46
+ }
47
+ }
48
+
49
+ type CrmTransaction = Parameters<
50
+ Parameters<ReturnType<typeof getDb>["transaction"]>[0]
51
+ >[0];
52
+
53
+ interface StoredValue {
54
+ stringValue: string | null;
55
+ numberValue: number | null;
56
+ booleanValue: boolean | null;
57
+ jsonValue: string | null;
58
+ }
59
+
60
+ /**
61
+ * Decode a stored value using the attribute's declared storage column rather
62
+ * than guessing from which column is non-null — a `false` checkbox and an empty
63
+ * text field are otherwise indistinguishable, and a merge that guessed would
64
+ * promote the wrong one.
65
+ */
66
+ function decodeStoredValue(
67
+ attribute: CrmWritableAttribute,
68
+ row: StoredValue,
69
+ ): CrmValue {
70
+ const column = storageColumnFor(attribute.attributeType, attribute.multi);
71
+ if (column === "numberValue") return row.numberValue;
72
+ if (column === "booleanValue") return row.booleanValue;
73
+ if (column === "jsonValue") {
74
+ if (row.jsonValue === null) return null;
75
+ try {
76
+ return JSON.parse(row.jsonValue) as CrmValue;
77
+ } catch {
78
+ throw new CrmMergeError(
79
+ "crm-merge-value-unreadable",
80
+ `A stored value for "${attribute.apiSlug}" on the duplicate is not readable JSON, so this merge cannot promote it. Repair the record before merging.`,
81
+ );
82
+ }
83
+ }
84
+ return row.stringValue;
85
+ }
86
+
87
+ async function idsToMove(
88
+ label: string,
89
+ rows: Promise<Array<{ id: string }>>,
90
+ ): Promise<string[]> {
91
+ const found = await rows;
92
+ if (found.length > MAX_MERGE_ROWS) {
93
+ throw new CrmMergeError(
94
+ "crm-merge-too-large",
95
+ `The duplicate has more than ${MAX_MERGE_ROWS} ${label} rows. Split the merge or archive some of them first — a partial merge is worse than none.`,
96
+ );
97
+ }
98
+ return found.map((row) => row.id);
99
+ }
100
+
101
+ export default defineAction({
102
+ description:
103
+ "Merge a duplicate CRM record into a survivor. Field values the survivor is missing are promoted through the bitemporal writer, and list entries, tasks, notes, interactions, signals, call evidence, and relationships from BOTH records end up on the survivor. The duplicate is never deleted: it is tombstoned and linked to the survivor, so the merge is reversible by history and safe to re-run. Find candidates with find-crm-duplicates first; this action never picks the survivor for you.",
104
+ schema: z.object({
105
+ survivorRecordId: z
106
+ .string()
107
+ .trim()
108
+ .min(1)
109
+ .max(128)
110
+ .describe("The record that keeps its identity."),
111
+ duplicateRecordId: z
112
+ .string()
113
+ .trim()
114
+ .min(1)
115
+ .max(128)
116
+ .describe("The record that is merged away and tombstoned."),
117
+ idempotencyKey: z.string().trim().min(1).max(256).optional(),
118
+ }),
119
+ needsApproval: (_args, ctx?: ActionRunContext) =>
120
+ decideCrmWritePolicy({
121
+ ...MERGE_WRITE_POLICY,
122
+ initiatedBy: crmInitiatedBy(ctx),
123
+ }) !== "execute",
124
+ audit: {
125
+ target: (_args, result) => {
126
+ const merge = result as {
127
+ survivorRecordId: string;
128
+ ownerEmail: string;
129
+ orgId: string | null;
130
+ visibility: "private" | "org" | "public";
131
+ };
132
+ return {
133
+ type: "crm-record",
134
+ id: merge.survivorRecordId,
135
+ ownerEmail: merge.ownerEmail,
136
+ orgId: merge.orgId,
137
+ visibility: merge.visibility,
138
+ };
139
+ },
140
+ summary: (args) =>
141
+ `Merged CRM record ${args.duplicateRecordId} into ${args.survivorRecordId}`,
142
+ recordInputs: false,
143
+ },
144
+ run: async (args, ctx?: ActionRunContext) => {
145
+ if (args.survivorRecordId === args.duplicateRecordId) {
146
+ throw new CrmMergeError(
147
+ "crm-merge-same-record",
148
+ "A record cannot be merged into itself.",
149
+ );
150
+ }
151
+ await assertAccess("crm-record", args.survivorRecordId, "editor");
152
+ await assertAccess("crm-record", args.duplicateRecordId, "editor");
153
+
154
+ const db = getDb();
155
+ const rows = await db
156
+ .select()
157
+ .from(schema.crmRecords)
158
+ .where(
159
+ and(
160
+ inArray(schema.crmRecords.id, [
161
+ args.survivorRecordId,
162
+ args.duplicateRecordId,
163
+ ]),
164
+ accessFilter(
165
+ schema.crmRecords,
166
+ schema.crmRecordShares,
167
+ undefined,
168
+ "editor",
169
+ ),
170
+ ),
171
+ );
172
+ const survivor = rows.find((row) => row.id === args.survivorRecordId);
173
+ const duplicate = rows.find((row) => row.id === args.duplicateRecordId);
174
+ if (!survivor || !duplicate) {
175
+ throw new CrmMergeError(
176
+ "crm-merge-record-not-found",
177
+ `Both records must exist and be editable by you. Missing: ${[
178
+ !survivor ? args.survivorRecordId : null,
179
+ !duplicate ? args.duplicateRecordId : null,
180
+ ]
181
+ .filter(Boolean)
182
+ .join(", ")}.`,
183
+ );
184
+ }
185
+ if (survivor.objectType !== duplicate.objectType) {
186
+ throw new CrmMergeError(
187
+ "crm-merge-object-type-mismatch",
188
+ `Cannot merge a "${duplicate.objectType}" record into a "${survivor.objectType}" record. Merge only applies within one object type.`,
189
+ );
190
+ }
191
+ if (survivor.tombstone) {
192
+ throw new CrmMergeError(
193
+ "crm-merge-survivor-tombstoned",
194
+ "The survivor is tombstoned. Pick a live record to merge into.",
195
+ );
196
+ }
197
+
198
+ const initiatedBy = crmInitiatedBy(ctx);
199
+ const decision = decideCrmWritePolicy({
200
+ ...MERGE_WRITE_POLICY,
201
+ initiatedBy,
202
+ });
203
+ if (decision === "deny") {
204
+ throw new CrmMergeError(
205
+ "crm-merge-denied",
206
+ "This CRM merge is not authorized by the current write policy.",
207
+ );
208
+ }
209
+
210
+ const ownership = {
211
+ ownerEmail: survivor.ownerEmail,
212
+ orgId: survivor.orgId,
213
+ visibility: survivor.visibility,
214
+ };
215
+ const idempotencyKey = await scopedCrmIdempotencyKey({
216
+ ownerEmail: ownership.ownerEmail,
217
+ orgId: ownership.orgId,
218
+ recordId: survivor.id,
219
+ key: args.idempotencyKey ?? `merge:${duplicate.id}`,
220
+ });
221
+
222
+ // Re-running a merge must be a no-op, not a second one. The prior ledger row
223
+ // is the authority; a duplicate already merged somewhere ELSE is an error,
224
+ // because silently re-parenting it would rewrite a decision a human made.
225
+ const [priorMutation] = await db
226
+ .select()
227
+ .from(schema.crmMutations)
228
+ .where(
229
+ and(
230
+ eq(schema.crmMutations.idempotencyKey, idempotencyKey),
231
+ accessFilter(schema.crmMutations, schema.crmMutationShares),
232
+ ),
233
+ )
234
+ .limit(1);
235
+ if (priorMutation) {
236
+ return {
237
+ mutationId: priorMutation.id,
238
+ survivorRecordId: survivor.id,
239
+ duplicateRecordId: duplicate.id,
240
+ status: "applied" as const,
241
+ replayed: true as const,
242
+ decision: priorMutation.policyDecision,
243
+ ...ownership,
244
+ };
245
+ }
246
+ if (duplicate.tombstone) {
247
+ const [existingLink] = await db
248
+ .select({ toRecordId: schema.crmRelationships.toRecordId })
249
+ .from(schema.crmRelationships)
250
+ .where(
251
+ and(
252
+ eq(schema.crmRelationships.fromRecordId, duplicate.id),
253
+ eq(
254
+ schema.crmRelationships.relationshipType,
255
+ CRM_MERGED_INTO_RELATIONSHIP,
256
+ ),
257
+ accessFilter(schema.crmRelationships, schema.crmRelationshipShares),
258
+ ),
259
+ )
260
+ .limit(1);
261
+ throw new CrmMergeError(
262
+ "crm-merge-duplicate-tombstoned",
263
+ existingLink
264
+ ? `"${duplicate.displayName}" was already merged into record ${existingLink.toRecordId}. Merge that record instead.`
265
+ : `"${duplicate.displayName}" is tombstoned and cannot be merged.`,
266
+ );
267
+ }
268
+
269
+ const now = new Date().toISOString();
270
+ const mutationId = crypto.randomUUID();
271
+
272
+ const outcome = await db.transaction(async (tx) => {
273
+ // --- field values ------------------------------------------------------
274
+ const attributeRows = await tx
275
+ .select({
276
+ id: schema.crmFieldPolicies.id,
277
+ apiSlug: schema.crmFieldPolicies.apiSlug,
278
+ fieldName: schema.crmFieldPolicies.fieldName,
279
+ attributeType: schema.crmFieldPolicies.attributeType,
280
+ multi: schema.crmFieldPolicies.multi,
281
+ historyTracked: schema.crmFieldPolicies.historyTracked,
282
+ valueType: schema.crmFieldPolicies.valueType,
283
+ storagePolicy: schema.crmFieldPolicies.storagePolicy,
284
+ })
285
+ .from(schema.crmFieldPolicies)
286
+ .where(
287
+ and(
288
+ eq(schema.crmFieldPolicies.connectionId, survivor.connectionId),
289
+ eq(schema.crmFieldPolicies.objectType, survivor.objectType),
290
+ eq(schema.crmFieldPolicies.archived, false),
291
+ accessFilter(schema.crmFieldPolicies, schema.crmFieldPolicyShares),
292
+ ),
293
+ );
294
+ const attributeBySlug = new Map<string, CrmWritableAttribute>();
295
+ for (const row of attributeRows) {
296
+ if (
297
+ row.storagePolicy !== "mirrored" &&
298
+ row.storagePolicy !== "derived-local" &&
299
+ row.storagePolicy !== "local-authoritative"
300
+ ) {
301
+ continue;
302
+ }
303
+ attributeBySlug.set(row.apiSlug ?? row.fieldName, {
304
+ id: row.id,
305
+ apiSlug: row.apiSlug ?? row.fieldName,
306
+ attributeType: row.attributeType,
307
+ multi: row.multi,
308
+ historyTracked: row.historyTracked,
309
+ valueType: row.valueType,
310
+ storagePolicy: row.storagePolicy,
311
+ fieldPolicyId: row.id,
312
+ });
313
+ }
314
+
315
+ const currentValues = await tx
316
+ .select({
317
+ recordId: schema.crmRecordFields.recordId,
318
+ fieldName: schema.crmRecordFields.fieldName,
319
+ stringValue: schema.crmRecordFields.stringValue,
320
+ numberValue: schema.crmRecordFields.numberValue,
321
+ booleanValue: schema.crmRecordFields.booleanValue,
322
+ jsonValue: schema.crmRecordFields.jsonValue,
323
+ })
324
+ .from(schema.crmRecordFields)
325
+ .where(
326
+ and(
327
+ inArray(schema.crmRecordFields.recordId, [
328
+ survivor.id,
329
+ duplicate.id,
330
+ ]),
331
+ isNull(schema.crmRecordFields.entryId),
332
+ isNull(schema.crmRecordFields.activeUntil),
333
+ accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
334
+ ),
335
+ );
336
+ const survivorSlugs = new Set(
337
+ currentValues
338
+ .filter((row) => row.recordId === survivor.id)
339
+ .map((row) => row.fieldName),
340
+ );
341
+
342
+ const promotedFields: string[] = [];
343
+ // Slugs that exist on the duplicate but not as an attribute of the
344
+ // survivor's object. Reported, never dropped in silence — the value is
345
+ // still readable on the tombstoned record's history.
346
+ const unmappedFields: string[] = [];
347
+ for (const row of currentValues) {
348
+ if (row.recordId !== duplicate.id) continue;
349
+ if (survivorSlugs.has(row.fieldName)) continue;
350
+ const attribute = attributeBySlug.get(row.fieldName);
351
+ if (!attribute) {
352
+ unmappedFields.push(row.fieldName);
353
+ continue;
354
+ }
355
+ const value = decodeStoredValue(attribute, row);
356
+ if (value === null) continue;
357
+ const result = await writeCrmRecordField({
358
+ db: tx as CrmFieldWriteDb,
359
+ target: { recordId: survivor.id, entryId: null },
360
+ attribute,
361
+ value,
362
+ actor: { type: "user", id: ctx?.userEmail ?? null },
363
+ ownership,
364
+ provenanceJson: toJson(
365
+ [
366
+ {
367
+ provider: duplicate.provider,
368
+ connectionId: duplicate.connectionId,
369
+ objectType: duplicate.objectType,
370
+ remoteId: duplicate.remoteId,
371
+ fieldName: row.fieldName,
372
+ observedAt: now,
373
+ evidenceRef: `crm-merge:${duplicate.id}`,
374
+ },
375
+ ],
376
+ 2_000,
377
+ ),
378
+ now,
379
+ });
380
+ if (result.changed) promotedFields.push(row.fieldName);
381
+ }
382
+
383
+ // --- everything that hangs off the duplicate ---------------------------
384
+ const entryIds = await idsToMove(
385
+ "list entry",
386
+ tx
387
+ .select({ id: schema.crmListEntries.id })
388
+ .from(schema.crmListEntries)
389
+ .where(
390
+ and(
391
+ eq(schema.crmListEntries.recordId, duplicate.id),
392
+ accessFilter(
393
+ schema.crmListEntries,
394
+ schema.crmListEntryShares,
395
+ undefined,
396
+ "editor",
397
+ ),
398
+ ),
399
+ )
400
+ .limit(MAX_MERGE_ROWS + 1),
401
+ );
402
+ if (entryIds.length) {
403
+ await tx
404
+ .update(schema.crmListEntries)
405
+ .set({ recordId: survivor.id, updatedAt: now })
406
+ .where(inArray(schema.crmListEntries.id, entryIds));
407
+ // Entry attribute values carry `record_id` alongside `entry_id`. This is
408
+ // a RE-PARENT, not a value change: the value is unchanged, so it must
409
+ // not open a history row the way the attribute writer would.
410
+ await tx
411
+ .update(schema.crmRecordFields)
412
+ .set({ recordId: survivor.id, updatedAt: now })
413
+ .where(
414
+ and(
415
+ inArray(schema.crmRecordFields.entryId, entryIds),
416
+ accessFilter(
417
+ schema.crmRecordFields,
418
+ schema.crmRecordFieldShares,
419
+ undefined,
420
+ "editor",
421
+ ),
422
+ ),
423
+ );
424
+ }
425
+
426
+ const taskIds = await idsToMove(
427
+ "task",
428
+ tx
429
+ .select({ id: schema.crmTasks.id })
430
+ .from(schema.crmTasks)
431
+ .where(
432
+ and(
433
+ eq(schema.crmTasks.recordId, duplicate.id),
434
+ accessFilter(
435
+ schema.crmTasks,
436
+ schema.crmTaskShares,
437
+ undefined,
438
+ "editor",
439
+ ),
440
+ ),
441
+ )
442
+ .limit(MAX_MERGE_ROWS + 1),
443
+ );
444
+ if (taskIds.length) {
445
+ await tx
446
+ .update(schema.crmTasks)
447
+ .set({ recordId: survivor.id, updatedAt: now })
448
+ .where(inArray(schema.crmTasks.id, taskIds));
449
+ }
450
+
451
+ // Notes are interactions with `kind: "note"`; they move with every other
452
+ // interaction so the survivor keeps the whole conversation.
453
+ const interactionIds = await idsToMove(
454
+ "interaction",
455
+ tx
456
+ .select({ id: schema.crmInteractions.id })
457
+ .from(schema.crmInteractions)
458
+ .where(
459
+ and(
460
+ eq(schema.crmInteractions.recordId, duplicate.id),
461
+ accessFilter(
462
+ schema.crmInteractions,
463
+ schema.crmInteractionShares,
464
+ undefined,
465
+ "editor",
466
+ ),
467
+ ),
468
+ )
469
+ .limit(MAX_MERGE_ROWS + 1),
470
+ );
471
+ if (interactionIds.length) {
472
+ await tx
473
+ .update(schema.crmInteractions)
474
+ .set({ recordId: survivor.id, updatedAt: now })
475
+ .where(inArray(schema.crmInteractions.id, interactionIds));
476
+ }
477
+
478
+ const evidenceIds = await idsToMove(
479
+ "call evidence",
480
+ tx
481
+ .select({ id: schema.crmCallEvidence.id })
482
+ .from(schema.crmCallEvidence)
483
+ .where(
484
+ and(
485
+ eq(schema.crmCallEvidence.recordId, duplicate.id),
486
+ accessFilter(
487
+ schema.crmCallEvidence,
488
+ schema.crmCallEvidenceShares,
489
+ undefined,
490
+ "editor",
491
+ ),
492
+ ),
493
+ )
494
+ .limit(MAX_MERGE_ROWS + 1),
495
+ );
496
+ if (evidenceIds.length) {
497
+ await tx
498
+ .update(schema.crmCallEvidence)
499
+ .set({ recordId: survivor.id, updatedAt: now })
500
+ .where(inArray(schema.crmCallEvidence.id, evidenceIds));
501
+ }
502
+
503
+ const signalIds = await idsToMove(
504
+ "signal",
505
+ tx
506
+ .select({ id: schema.crmSignals.id })
507
+ .from(schema.crmSignals)
508
+ .where(
509
+ and(
510
+ eq(schema.crmSignals.recordId, duplicate.id),
511
+ accessFilter(
512
+ schema.crmSignals,
513
+ schema.crmSignalShares,
514
+ undefined,
515
+ "editor",
516
+ ),
517
+ ),
518
+ )
519
+ .limit(MAX_MERGE_ROWS + 1),
520
+ );
521
+ if (signalIds.length) {
522
+ await tx
523
+ .update(schema.crmSignals)
524
+ .set({ recordId: survivor.id, updatedAt: now })
525
+ .where(inArray(schema.crmSignals.id, signalIds));
526
+ }
527
+
528
+ const signalRunIds = await idsToMove(
529
+ "signal run",
530
+ tx
531
+ .select({ id: schema.crmSignalRuns.id })
532
+ .from(schema.crmSignalRuns)
533
+ .where(
534
+ and(
535
+ eq(schema.crmSignalRuns.recordId, duplicate.id),
536
+ accessFilter(
537
+ schema.crmSignalRuns,
538
+ schema.crmSignalRunShares,
539
+ undefined,
540
+ "editor",
541
+ ),
542
+ ),
543
+ )
544
+ .limit(MAX_MERGE_ROWS + 1),
545
+ );
546
+ if (signalRunIds.length) {
547
+ await tx
548
+ .update(schema.crmSignalRuns)
549
+ .set({ recordId: survivor.id, updatedAt: now })
550
+ .where(inArray(schema.crmSignalRuns.id, signalRunIds));
551
+ }
552
+
553
+ const relationshipIds = await idsToMove(
554
+ "relationship",
555
+ tx
556
+ .select({ id: schema.crmRelationships.id })
557
+ .from(schema.crmRelationships)
558
+ .where(
559
+ and(
560
+ or(
561
+ eq(schema.crmRelationships.fromRecordId, duplicate.id),
562
+ eq(schema.crmRelationships.toRecordId, duplicate.id),
563
+ ),
564
+ accessFilter(
565
+ schema.crmRelationships,
566
+ schema.crmRelationshipShares,
567
+ undefined,
568
+ "editor",
569
+ ),
570
+ ),
571
+ )
572
+ .limit(MAX_MERGE_ROWS + 1),
573
+ );
574
+ if (relationshipIds.length) {
575
+ await tx
576
+ .update(schema.crmRelationships)
577
+ .set({ fromRecordId: survivor.id, updatedAt: now })
578
+ .where(
579
+ and(
580
+ inArray(schema.crmRelationships.id, relationshipIds),
581
+ eq(schema.crmRelationships.fromRecordId, duplicate.id),
582
+ ),
583
+ );
584
+ await tx
585
+ .update(schema.crmRelationships)
586
+ .set({ toRecordId: survivor.id, updatedAt: now })
587
+ .where(
588
+ and(
589
+ inArray(schema.crmRelationships.id, relationshipIds),
590
+ eq(schema.crmRelationships.toRecordId, duplicate.id),
591
+ ),
592
+ );
593
+ }
594
+
595
+ // --- tombstone and link ------------------------------------------------
596
+ // The loser is never hard-deleted: its history, provenance, and provider
597
+ // identity tuple stay readable, and this edge is what makes the merge
598
+ // traceable in both directions.
599
+ await tx.insert(schema.crmRelationships).values({
600
+ id: crypto.randomUUID(),
601
+ connectionId: duplicate.connectionId,
602
+ fromRecordId: duplicate.id,
603
+ toRecordId: survivor.id,
604
+ relationshipType: CRM_MERGED_INTO_RELATIONSHIP,
605
+ label: "Merged into",
606
+ inverseLabel: "Merged from",
607
+ ...ownership,
608
+ createdAt: now,
609
+ updatedAt: now,
610
+ });
611
+ await tx
612
+ .update(schema.crmRecords)
613
+ .set({ tombstone: true, updatedAt: now })
614
+ .where(
615
+ and(
616
+ eq(schema.crmRecords.id, duplicate.id),
617
+ accessFilter(
618
+ schema.crmRecords,
619
+ schema.crmRecordShares,
620
+ undefined,
621
+ "editor",
622
+ ),
623
+ ),
624
+ );
625
+
626
+ const moved = {
627
+ listEntries: entryIds.length,
628
+ tasks: taskIds.length,
629
+ interactions: interactionIds.length,
630
+ callEvidence: evidenceIds.length,
631
+ signals: signalIds.length,
632
+ signalRuns: signalRunIds.length,
633
+ relationships: relationshipIds.length,
634
+ };
635
+ await tx.insert(schema.crmMutations).values({
636
+ id: mutationId,
637
+ recordId: survivor.id,
638
+ connectionId: survivor.connectionId,
639
+ operation: "update",
640
+ initiatedBy,
641
+ target: "local",
642
+ policyDecision: decision,
643
+ risk: MERGE_WRITE_POLICY.risk,
644
+ status: "applied",
645
+ patchJson: toJson(
646
+ {
647
+ merge: {
648
+ survivorRecordId: survivor.id,
649
+ duplicateRecordId: duplicate.id,
650
+ },
651
+ promotedFields,
652
+ unmappedFields,
653
+ moved,
654
+ },
655
+ 12_000,
656
+ ),
657
+ beforeJson: toJson(
658
+ {
659
+ duplicate: {
660
+ id: duplicate.id,
661
+ displayName: duplicate.displayName,
662
+ remoteId: duplicate.remoteId,
663
+ provider: duplicate.provider,
664
+ },
665
+ },
666
+ 2_000,
667
+ ),
668
+ afterJson: toJson({ survivorRecordId: survivor.id, moved }, 2_000),
669
+ idempotencyKey,
670
+ appliedAt: now,
671
+ ...ownership,
672
+ createdAt: now,
673
+ updatedAt: now,
674
+ });
675
+
676
+ return { promotedFields, unmappedFields, moved };
677
+ });
678
+
679
+ return {
680
+ mutationId,
681
+ survivorRecordId: survivor.id,
682
+ duplicateRecordId: duplicate.id,
683
+ status: "applied" as const,
684
+ replayed: false as const,
685
+ decision,
686
+ duplicateTombstoned: true,
687
+ ...outcome,
688
+ ...ownership,
689
+ };
690
+ },
691
+ });