@agent-native/core 0.123.0 → 0.123.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (334) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +24 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/templates-meta.ts +12 -0
  5. package/corpus/core/src/deploy/build.ts +8 -0
  6. package/corpus/core/src/server/builder-browser.ts +76 -0
  7. package/corpus/core/src/server/core-routes-plugin.ts +15 -5
  8. package/corpus/templates/assets/app/global.css +0 -4
  9. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +195 -167
  10. package/corpus/templates/assets/changelog/2026-07-23-drafts-preview-panel-now-fills-the-full-width-with-a-large-p.md +6 -0
  11. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
  12. package/corpus/templates/content/actions/_builder-cms-write-client.ts +55 -9
  13. package/corpus/templates/content/actions/_database-source-utils.ts +756 -182
  14. package/corpus/templates/content/actions/change-content-database-source-role.ts +21 -20
  15. package/corpus/templates/content/actions/execute-builder-source-execution.ts +49 -8
  16. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +42 -9
  17. package/corpus/templates/content/actions/prepare-builder-source-review.ts +88 -90
  18. package/corpus/templates/content/actions/preview-builder-source-review.ts +4 -3
  19. package/corpus/templates/content/actions/refresh-content-database-source.ts +36 -9
  20. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +28 -24
  21. package/corpus/templates/content/app/components/editor/BubbleToolbar.tsx +62 -12
  22. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +91 -16
  23. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +15 -1
  24. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +234 -44
  25. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +194 -16
  26. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +95 -42
  27. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +4 -2
  28. package/corpus/templates/content/app/components/editor/extensions/CodeBlockNode.tsx +10 -166
  29. package/corpus/templates/content/app/components/editor/extensions/VideoBlock.tsx +32 -8
  30. package/corpus/templates/content/app/components/editor/extensions/VideoNode.ts +14 -0
  31. package/corpus/templates/content/app/components/editor/registrySlashItems.ts +5 -1
  32. package/corpus/templates/content/app/hooks/use-content-database.ts +59 -2
  33. package/corpus/templates/content/app/hooks/use-documents.ts +18 -26
  34. package/corpus/templates/content/app/root.tsx +1 -6
  35. package/corpus/templates/content/changelog/2026-07-24-builder-can-now-publish-approved-updates-to-entries-that-are.md +6 -0
  36. package/corpus/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md +6 -0
  37. package/corpus/templates/content/changelog/2026-07-24-builder-connections-now-complete-securely-from-netlify-deplo.md +6 -0
  38. package/corpus/templates/content/changelog/2026-07-24-builder-draft-creation-now-scopes-execution-to-the-selected-.md +6 -0
  39. package/corpus/templates/content/changelog/2026-07-24-builder-review-now-distinguishes-preparing-a-safe-dry-run-from.md +6 -0
  40. package/corpus/templates/content/changelog/2026-07-24-builder-source-imports-now-recover-from-a-transient-paginati.md +6 -0
  41. package/corpus/templates/content/changelog/2026-07-24-builder-source-refresh-now-preserves-the-provider-bound-fiel.md +6 -0
  42. package/corpus/templates/content/changelog/2026-07-24-builder-update-reviews-now-open-reliably-on-large-collection.md +6 -0
  43. package/corpus/templates/content/changelog/2026-07-24-builder-write-policy-controls-now-reflect-successful-changes.md +6 -0
  44. package/corpus/templates/content/changelog/2026-07-24-date-properties-now-save-reliably-when-selected-with-the-nat.md +6 -0
  45. package/corpus/templates/content/changelog/2026-07-24-editor-slash-search-now-keeps-multi-word-block-names-open.md +6 -0
  46. package/corpus/templates/content/changelog/2026-07-24-exact-editor-slash-commands-now-execute-on-enter-even-if-the.md +6 -0
  47. package/corpus/templates/content/changelog/2026-07-24-new-collaborative-pages-now-save-their-first-edits.md +6 -0
  48. package/corpus/templates/content/changelog/2026-07-24-new-local-builder-rows-no-longer-remain-locked-behind-a-none.md +6 -0
  49. package/corpus/templates/content/changelog/2026-07-24-opening-or-closing-a-collaborative-document-no-longer-lets-u.md +6 -0
  50. package/corpus/templates/content/changelog/2026-07-24-selected-text-now-opens-the-link-editor-with-command-k-or-co.md +6 -0
  51. package/corpus/templates/content/changelog/2026-07-24-slash-menu-block-choices-no-longer-stall-pointer-interaction.md +6 -0
  52. package/corpus/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md +6 -0
  53. package/corpus/templates/content/changelog/2026-07-24-video-link-insertion-now-keeps-its-source-panel-open-while-t.md +6 -0
  54. package/corpus/templates/content/changelog/2026-07-26-builder-publishing-now-shows-safe-validation-details-instead.md +6 -0
  55. package/corpus/templates/content/changelog/2026-07-26-builder-reviews-stay-responsive-on-large-collections-and-pre.md +6 -0
  56. package/corpus/templates/content/changelog/2026-07-26-collaborative-documents-no-longer-flash-duplicated-recent-bl.md +6 -0
  57. package/corpus/templates/content/changelog/2026-07-26-corrected-builder-updates-are-now-reviewed-separately-from-f.md +6 -0
  58. package/corpus/templates/content/changelog/2026-07-26-documents-now-remain-read-only-until-their-live-collaborativ.md +6 -0
  59. package/corpus/templates/content/changelog/2026-07-26-slash-command-menus-now-stay-reachable-when-editing-near-the.md +6 -0
  60. package/corpus/templates/content/changelog/2026-07-26-the-slash-menu-now-shows-one-clear-callout-choice-instead-of.md +6 -0
  61. package/corpus/templates/content/shared/api.ts +1 -0
  62. package/corpus/templates/content/shared/builder-mdx.ts +73 -16
  63. package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
  64. package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
  65. package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
  66. package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
  67. package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
  68. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
  69. package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
  70. package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
  71. package/corpus/templates/crm/.env.example +5 -0
  72. package/corpus/templates/crm/AGENTS.md +82 -0
  73. package/corpus/templates/crm/CHANGELOG.md +25 -0
  74. package/corpus/templates/crm/DEVELOPING.md +66 -0
  75. package/corpus/templates/crm/README.md +122 -0
  76. package/corpus/templates/crm/_gitignore +12 -0
  77. package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
  78. package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
  79. package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
  80. package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
  81. package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
  82. package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
  83. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
  84. package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
  85. package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
  86. package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
  87. package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
  88. package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
  89. package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
  90. package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
  91. package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
  92. package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
  93. package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
  94. package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
  95. package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
  96. package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
  97. package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
  98. package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
  99. package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
  100. package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
  101. package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
  102. package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
  103. package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
  104. package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
  105. package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
  106. package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
  107. package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
  108. package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
  109. package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
  110. package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
  111. package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
  112. package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
  113. package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
  114. package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
  115. package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
  116. package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
  117. package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
  118. package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
  119. package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
  120. package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
  121. package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
  122. package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
  123. package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
  124. package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
  125. package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
  126. package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
  127. package/corpus/templates/crm/actions/navigate.ts +54 -0
  128. package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
  129. package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
  130. package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
  131. package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
  132. package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
  133. package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
  134. package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
  135. package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
  136. package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
  137. package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
  138. package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
  139. package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
  140. package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
  141. package/corpus/templates/crm/actions/run.ts +7 -0
  142. package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
  143. package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
  144. package/corpus/templates/crm/actions/sync-crm.ts +146 -0
  145. package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
  146. package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
  147. package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
  148. package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
  149. package/corpus/templates/crm/actions/view-screen.ts +324 -0
  150. package/corpus/templates/crm/agent-native.app-skill.json +69 -0
  151. package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
  152. package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
  153. package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
  154. package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
  155. package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
  156. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
  157. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
  158. package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
  159. package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
  160. package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
  161. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
  162. package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
  163. package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
  164. package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
  165. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
  166. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
  167. package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
  168. package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
  169. package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
  170. package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
  171. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
  172. package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
  173. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
  174. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
  175. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
  176. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
  177. package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
  178. package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
  179. package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
  180. package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
  181. package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
  182. package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
  183. package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
  184. package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
  185. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
  186. package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
  187. package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
  188. package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
  189. package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
  190. package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
  191. package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
  192. package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
  193. package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
  194. package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
  195. package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
  196. package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
  197. package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
  198. package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
  199. package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
  200. package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
  201. package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
  202. package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
  203. package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
  204. package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
  205. package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
  206. package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
  207. package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
  208. package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
  209. package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
  210. package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
  211. package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
  212. package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
  213. package/corpus/templates/crm/app/design-system.ts +3 -0
  214. package/corpus/templates/crm/app/entry.client.tsx +19 -0
  215. package/corpus/templates/crm/app/entry.server.tsx +10 -0
  216. package/corpus/templates/crm/app/global.css +167 -0
  217. package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
  218. package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
  219. package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
  220. package/corpus/templates/crm/app/i18n/index.ts +34 -0
  221. package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
  222. package/corpus/templates/crm/app/lib/navigation.ts +17 -0
  223. package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
  224. package/corpus/templates/crm/app/lib/types.ts +172 -0
  225. package/corpus/templates/crm/app/lib/utils.ts +1 -0
  226. package/corpus/templates/crm/app/root.tsx +93 -0
  227. package/corpus/templates/crm/app/routes/_index.tsx +26 -0
  228. package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
  229. package/corpus/templates/crm/app/routes/agent.tsx +5 -0
  230. package/corpus/templates/crm/app/routes/ask.tsx +53 -0
  231. package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
  232. package/corpus/templates/crm/app/routes/lists.tsx +228 -0
  233. package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
  234. package/corpus/templates/crm/app/routes/people.tsx +41 -0
  235. package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
  236. package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
  237. package/corpus/templates/crm/app/routes/records.tsx +64 -0
  238. package/corpus/templates/crm/app/routes/settings.tsx +118 -0
  239. package/corpus/templates/crm/app/routes/setup.tsx +308 -0
  240. package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
  241. package/corpus/templates/crm/app/routes/views.tsx +683 -0
  242. package/corpus/templates/crm/app/routes.ts +19 -0
  243. package/corpus/templates/crm/app/vite-env.d.ts +6 -0
  244. package/corpus/templates/crm/changelog/2026-07-26-any-list-or-view-can-be-shown-as-a-board-grouped-by-stage-so.md +6 -0
  245. package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
  246. package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
  247. package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
  248. package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
  249. package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
  250. package/corpus/templates/crm/changelog/2026-07-26-press-cmd-k-to-jump-to-any-record-list-view-or-setting-and-u.md +6 -0
  251. package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
  252. package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
  253. package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
  254. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
  255. package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
  256. package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
  257. package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
  258. package/corpus/templates/crm/components.json +20 -0
  259. package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
  260. package/corpus/templates/crm/docs/features/README.md +55 -0
  261. package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
  262. package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
  263. package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
  264. package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
  265. package/corpus/templates/crm/learnings.defaults.md +5 -0
  266. package/corpus/templates/crm/netlify.toml +11 -0
  267. package/corpus/templates/crm/package.json +61 -0
  268. package/corpus/templates/crm/public/favicon.svg +1 -0
  269. package/corpus/templates/crm/public/icon-180.svg +1 -0
  270. package/corpus/templates/crm/public/manifest.json +11 -0
  271. package/corpus/templates/crm/react-router.config.ts +7 -0
  272. package/corpus/templates/crm/server/crm/adapter.ts +32 -0
  273. package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
  274. package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
  275. package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
  276. package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
  277. package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
  278. package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
  279. package/corpus/templates/crm/server/crm/read-through.ts +207 -0
  280. package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
  281. package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
  282. package/corpus/templates/crm/server/db/index.ts +191 -0
  283. package/corpus/templates/crm/server/db/schema.ts +647 -0
  284. package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
  285. package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
  286. package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
  287. package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
  288. package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
  289. package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
  290. package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
  291. package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
  292. package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
  293. package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
  294. package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
  295. package/corpus/templates/crm/server/middleware/auth.ts +4 -0
  296. package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
  297. package/corpus/templates/crm/server/plugins/auth.ts +14 -0
  298. package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
  299. package/corpus/templates/crm/server/plugins/db.ts +738 -0
  300. package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
  301. package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
  302. package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
  303. package/corpus/templates/crm/shared/crm-contract.ts +417 -0
  304. package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
  305. package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
  306. package/corpus/templates/crm/ssr-entry.ts +11 -0
  307. package/corpus/templates/crm/tsconfig.json +24 -0
  308. package/corpus/templates/crm/vite.config.ts +15 -0
  309. package/corpus/templates/crm/vitest.config.ts +5 -0
  310. package/corpus/templates/design/app/i18n-data.ts +12 -12
  311. package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
  312. package/corpus/toolkit/CHANGELOG.md +6 -0
  313. package/corpus/toolkit/package.json +1 -1
  314. package/corpus/toolkit/src/editor/useCollabReconcile.ts +14 -1
  315. package/dist/cli/templates-meta.d.ts.map +1 -1
  316. package/dist/cli/templates-meta.js +12 -0
  317. package/dist/cli/templates-meta.js.map +1 -1
  318. package/dist/collab/routes.d.ts +2 -2
  319. package/dist/deploy/build.js +6 -0
  320. package/dist/deploy/build.js.map +1 -1
  321. package/dist/observability/routes.d.ts +3 -3
  322. package/dist/server/builder-browser.d.ts +13 -0
  323. package/dist/server/builder-browser.d.ts.map +1 -1
  324. package/dist/server/builder-browser.js +59 -0
  325. package/dist/server/builder-browser.js.map +1 -1
  326. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  327. package/dist/server/core-routes-plugin.js +10 -5
  328. package/dist/server/core-routes-plugin.js.map +1 -1
  329. package/dist/server/transcribe-voice.d.ts +1 -1
  330. package/package.json +2 -2
  331. package/src/cli/templates-meta.ts +12 -0
  332. package/src/deploy/build.ts +8 -0
  333. package/src/server/builder-browser.ts +76 -0
  334. package/src/server/core-routes-plugin.ts +15 -5
@@ -0,0 +1,1558 @@
1
+ /**
2
+ * The CRM grid's server-side query: a typed filter/sort tree compiled into one
3
+ * dialect-agnostic Drizzle statement.
4
+ *
5
+ * Two rules shape everything here.
6
+ *
7
+ * 1. A filter that cannot be compiled is an ERROR, never a dropped condition.
8
+ * Silently ignoring an unknown attribute returns a full unfiltered page that
9
+ * looks exactly like a correct answer, which is the failure mode the root
10
+ * CLAUDE.md forbids.
11
+ * 2. Attribute values live in `crm_record_fields`, one bitemporal row per
12
+ * attribute with `active_until IS NULL` for the current value. Filtering is
13
+ * therefore EXISTS/NOT EXISTS against that table, and sorting is a correlated
14
+ * scalar subquery — never `json_extract`, which diverges across dialects.
15
+ */
16
+
17
+ import { accessFilter } from "@agent-native/core/sharing";
18
+ import {
19
+ and,
20
+ eq,
21
+ exists,
22
+ inArray,
23
+ isNull,
24
+ notExists,
25
+ or,
26
+ sql,
27
+ type SQL,
28
+ } from "drizzle-orm";
29
+ import { z } from "zod";
30
+
31
+ import {
32
+ ATTRIBUTE_TYPE_SPECS,
33
+ type CrmAttributeType,
34
+ } from "../../shared/crm-attributes.js";
35
+ import {
36
+ CRM_CURRENT_USER_TOKEN,
37
+ type CrmAccessScope,
38
+ type CrmObjectKind,
39
+ } from "../../shared/crm-contract.js";
40
+ import {
41
+ createConnectedCrmAdapter,
42
+ isConnectedCrmProvider,
43
+ } from "../crm/adapter.js";
44
+ import { resolveNativeCrmAccessScope } from "../crm/native-adapter.js";
45
+ import {
46
+ parseCrmAccessScope,
47
+ scopesAreCompatible,
48
+ } from "../crm/read-through.js";
49
+ import { getDb, schema } from "../db/index.js";
50
+
51
+ const MAX_RECORD_LIMIT = 100;
52
+ const MAX_SCOPE_VALIDATIONS = 20;
53
+
54
+ /** A filter the caller must fix before retrying — surfaces as HTTP 422. */
55
+ export class CrmFilterError extends Error {
56
+ readonly statusCode = 422;
57
+ readonly code: string;
58
+
59
+ constructor(code: string, message: string) {
60
+ super(message);
61
+ this.name = "CrmFilterError";
62
+ this.code = code;
63
+ }
64
+ }
65
+
66
+ /** A cursor that does not belong to this query — surfaces as HTTP 400. */
67
+ export class CrmCursorError extends Error {
68
+ readonly statusCode = 400;
69
+ readonly code = "crm-cursor-mismatch";
70
+
71
+ constructor(message: string) {
72
+ super(message);
73
+ this.name = "CrmCursorError";
74
+ }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Input shapes
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export const CRM_FILTER_CONDITIONS = [
82
+ "is",
83
+ "is-not",
84
+ "contains",
85
+ "not-contains",
86
+ "starts-with",
87
+ "ends-with",
88
+ "is-empty",
89
+ "is-not-empty",
90
+ "=",
91
+ "!=",
92
+ ">",
93
+ ">=",
94
+ "<",
95
+ "<=",
96
+ "between",
97
+ "before",
98
+ "after",
99
+ "is-any-of",
100
+ "is-none-of",
101
+ ] as const;
102
+
103
+ export type CrmFilterCondition = (typeof CRM_FILTER_CONDITIONS)[number];
104
+
105
+ const filterValueSchema = z.union([
106
+ z.string().max(2_000),
107
+ z.number(),
108
+ z.boolean(),
109
+ z.null(),
110
+ z.array(z.union([z.string().max(2_000), z.number(), z.boolean()])).max(50),
111
+ ]);
112
+
113
+ const leafSchema = z.object({
114
+ /** Attribute id, `api_slug`, or `field_name` on `crm_field_policies`. */
115
+ attributeId: z.string().trim().min(1).max(128).optional(),
116
+ /** A column on `crm_records` — see RECORD_COLUMNS. */
117
+ field: z.string().trim().min(1).max(64).optional(),
118
+ condition: z.enum(CRM_FILTER_CONDITIONS),
119
+ value: filterValueSchema.optional(),
120
+ });
121
+
122
+ const groupSchema = z.object({
123
+ op: z.enum(["and", "or"]).default("and"),
124
+ conditions: z.array(leafSchema).min(1).max(30),
125
+ });
126
+
127
+ /**
128
+ * One level of nesting, spelled out rather than recursive: an unbounded tree is
129
+ * an unbounded number of correlated subqueries in one statement.
130
+ */
131
+ export const crmFilterSchema = z.object({
132
+ op: z.enum(["and", "or"]).default("and"),
133
+ conditions: z.array(z.union([groupSchema, leafSchema])).max(30),
134
+ });
135
+
136
+ export const crmSortSchema = z
137
+ .array(
138
+ z.object({
139
+ attributeId: z.string().trim().min(1).max(128).optional(),
140
+ field: z.string().trim().min(1).max(64).optional(),
141
+ direction: z.enum(["asc", "desc"]).default("asc"),
142
+ }),
143
+ )
144
+ .max(4);
145
+
146
+ export type CrmFilterLeaf = z.infer<typeof leafSchema>;
147
+ export type CrmFilterGroup = z.infer<typeof groupSchema>;
148
+ export type CrmFilter = z.infer<typeof crmFilterSchema>;
149
+ export type CrmSort = z.infer<typeof crmSortSchema>;
150
+
151
+ /**
152
+ * The tree the compiler walks. Input is capped at one level of nesting by the
153
+ * schema; this shape lets the query builder wrap an already-parsed filter in
154
+ * another group without re-typing it.
155
+ */
156
+ export interface CrmFilterNode {
157
+ op: "and" | "or";
158
+ conditions: Array<CrmFilterLeaf | CrmFilterNode>;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Targets: an attribute value or a `crm_records` column
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * The condition vocabulary a target accepts. Derived from the attribute-type
167
+ * registry so a new attribute type inherits a family instead of needing a new
168
+ * branch at every call site.
169
+ */
170
+ type ConditionFamily =
171
+ | "text"
172
+ | "number"
173
+ | "date"
174
+ | "boolean"
175
+ | "option"
176
+ | "reference"
177
+ | "json";
178
+
179
+ const FAMILY_CONDITIONS: Record<
180
+ ConditionFamily,
181
+ readonly CrmFilterCondition[]
182
+ > = {
183
+ text: [
184
+ "is",
185
+ "is-not",
186
+ "contains",
187
+ "not-contains",
188
+ "starts-with",
189
+ "ends-with",
190
+ "is-empty",
191
+ "is-not-empty",
192
+ ],
193
+ number: [
194
+ "=",
195
+ "!=",
196
+ ">",
197
+ ">=",
198
+ "<",
199
+ "<=",
200
+ "between",
201
+ "is-empty",
202
+ "is-not-empty",
203
+ ],
204
+ date: ["is", "before", "after", "between", "is-empty", "is-not-empty"],
205
+ boolean: ["is"],
206
+ option: ["is", "is-any-of", "is-none-of", "is-empty", "is-not-empty"],
207
+ reference: ["is", "is-any-of", "is-empty", "is-not-empty"],
208
+ json: ["is-empty", "is-not-empty"],
209
+ };
210
+
211
+ function conditionFamily(type: CrmAttributeType): ConditionFamily {
212
+ const spec = ATTRIBUTE_TYPE_SPECS[type];
213
+ if (spec.usesOptions) return "option";
214
+ if (type === "date" || type === "timestamp") return "date";
215
+ if (type === "record-reference" || type === "actor-reference") {
216
+ return "reference";
217
+ }
218
+ if (spec.storageColumn === "numberValue") return "number";
219
+ if (spec.storageColumn === "booleanValue") return "boolean";
220
+ if (spec.storageColumn === "jsonValue") return "json";
221
+ return "text";
222
+ }
223
+
224
+ interface RecordColumnSpec {
225
+ column: SQL;
226
+ family: ConditionFamily;
227
+ /** Compared case-insensitively and eligible for `@currentUser`. */
228
+ actor?: boolean;
229
+ }
230
+
231
+ /**
232
+ * Filterable/sortable columns on `crm_records`. An allow-list, not a lookup by
233
+ * string: an unlisted name is a typed error, so a filter can never reach a
234
+ * column the summary does not expose.
235
+ */
236
+ const RECORD_COLUMNS: Record<string, RecordColumnSpec> = {
237
+ displayName: {
238
+ column: sql`${schema.crmRecords.displayName}`,
239
+ family: "text",
240
+ },
241
+ kind: { column: sql`${schema.crmRecords.kind}`, family: "text" },
242
+ objectType: { column: sql`${schema.crmRecords.objectType}`, family: "text" },
243
+ provider: { column: sql`${schema.crmRecords.provider}`, family: "text" },
244
+ connectionId: {
245
+ column: sql`${schema.crmRecords.connectionId}`,
246
+ family: "text",
247
+ },
248
+ primaryEmail: {
249
+ column: sql`${schema.crmRecords.primaryEmail}`,
250
+ family: "text",
251
+ },
252
+ domain: { column: sql`${schema.crmRecords.domain}`, family: "text" },
253
+ stage: { column: sql`${schema.crmRecords.stage}`, family: "text" },
254
+ pipelineName: {
255
+ column: sql`${schema.crmRecords.pipelineName}`,
256
+ family: "text",
257
+ },
258
+ ownerName: { column: sql`${schema.crmRecords.ownerName}`, family: "text" },
259
+ ownerRemoteId: {
260
+ column: sql`${schema.crmRecords.ownerRemoteId}`,
261
+ family: "text",
262
+ },
263
+ ownerEmail: {
264
+ column: sql`${schema.crmRecords.ownerEmail}`,
265
+ family: "text",
266
+ actor: true,
267
+ },
268
+ amount: { column: sql`${schema.crmRecords.amount}`, family: "number" },
269
+ currencyCode: {
270
+ column: sql`${schema.crmRecords.currencyCode}`,
271
+ family: "text",
272
+ },
273
+ desiredCadenceDays: {
274
+ column: sql`${schema.crmRecords.desiredCadenceDays}`,
275
+ family: "number",
276
+ },
277
+ closeDate: { column: sql`${schema.crmRecords.closeDate}`, family: "date" },
278
+ nextContactAt: {
279
+ column: sql`${schema.crmRecords.nextContactAt}`,
280
+ family: "date",
281
+ },
282
+ lastMeaningfulInteractionAt: {
283
+ column: sql`${schema.crmRecords.lastMeaningfulInteractionAt}`,
284
+ family: "date",
285
+ },
286
+ remoteUpdatedAt: {
287
+ column: sql`${schema.crmRecords.remoteUpdatedAt}`,
288
+ family: "date",
289
+ },
290
+ createdAt: { column: sql`${schema.crmRecords.createdAt}`, family: "date" },
291
+ updatedAt: { column: sql`${schema.crmRecords.updatedAt}`, family: "date" },
292
+ };
293
+
294
+ interface ResolvedAttribute {
295
+ id: string;
296
+ apiSlug: string;
297
+ attributeType: CrmAttributeType;
298
+ multi: boolean;
299
+ family: ConditionFamily;
300
+ }
301
+
302
+ type ResolvedTarget =
303
+ | { kind: "attribute"; label: string; attribute: ResolvedAttribute }
304
+ | { kind: "column"; label: string; spec: RecordColumnSpec };
305
+
306
+ function targetFamily(target: ResolvedTarget): ConditionFamily {
307
+ return target.kind === "attribute"
308
+ ? target.attribute.family
309
+ : target.spec.family;
310
+ }
311
+
312
+ function isActorTarget(target: ResolvedTarget): boolean {
313
+ return target.kind === "attribute"
314
+ ? target.attribute.attributeType === "actor-reference"
315
+ : target.spec.actor === true;
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Attribute resolution — one query for every reference in the tree
320
+ // ---------------------------------------------------------------------------
321
+
322
+ interface AttributeRow {
323
+ id: string;
324
+ fieldName: string;
325
+ apiSlug: string | null;
326
+ attributeType: CrmAttributeType;
327
+ multi: boolean;
328
+ archived: boolean;
329
+ }
330
+
331
+ function collectRefs(node: CrmFilterNode, refs: Set<string>): void {
332
+ for (const entry of node.conditions) {
333
+ if ("conditions" in entry) collectRefs(entry, refs);
334
+ else if (entry.attributeId) refs.add(entry.attributeId);
335
+ }
336
+ }
337
+
338
+ function attributeRefs(
339
+ filter: CrmFilterNode | undefined,
340
+ sort: CrmSort,
341
+ ): string[] {
342
+ const refs = new Set<string>();
343
+ if (filter) collectRefs(filter, refs);
344
+ for (const entry of sort) {
345
+ if (entry.attributeId) refs.add(entry.attributeId);
346
+ }
347
+ return Array.from(refs);
348
+ }
349
+
350
+ /**
351
+ * Resolve every attribute reference in one pass. A reference may name the
352
+ * attribute id, its `api_slug`, or its legacy `field_name`; unknown, archived,
353
+ * and ambiguous references are all typed errors that name the reference.
354
+ */
355
+ async function resolveAttributes(
356
+ refs: string[],
357
+ ): Promise<Map<string, ResolvedAttribute>> {
358
+ const resolved = new Map<string, ResolvedAttribute>();
359
+ if (refs.length === 0) return resolved;
360
+
361
+ const rows: AttributeRow[] = await getDb()
362
+ .select({
363
+ id: schema.crmFieldPolicies.id,
364
+ fieldName: schema.crmFieldPolicies.fieldName,
365
+ apiSlug: schema.crmFieldPolicies.apiSlug,
366
+ attributeType: schema.crmFieldPolicies.attributeType,
367
+ multi: schema.crmFieldPolicies.multi,
368
+ archived: schema.crmFieldPolicies.archived,
369
+ })
370
+ .from(schema.crmFieldPolicies)
371
+ .where(
372
+ and(
373
+ or(
374
+ inArray(schema.crmFieldPolicies.id, refs),
375
+ inArray(schema.crmFieldPolicies.apiSlug, refs),
376
+ inArray(schema.crmFieldPolicies.fieldName, refs),
377
+ ),
378
+ accessFilter(schema.crmFieldPolicies, schema.crmFieldPolicyShares),
379
+ ),
380
+ );
381
+
382
+ for (const ref of refs) {
383
+ const matches = rows.filter(
384
+ (row) => row.id === ref || row.apiSlug === ref || row.fieldName === ref,
385
+ );
386
+ if (matches.length === 0) {
387
+ throw new CrmFilterError(
388
+ "crm-filter-unknown-attribute",
389
+ `Filter references unknown attribute "${ref}".`,
390
+ );
391
+ }
392
+ const live = matches.filter((row) => !row.archived);
393
+ if (live.length === 0) {
394
+ throw new CrmFilterError(
395
+ "crm-filter-archived-attribute",
396
+ `Filter references archived attribute "${ref}".`,
397
+ );
398
+ }
399
+ // The same slug can exist on several objects/connections. That is fine as
400
+ // long as they agree on how the value is stored — the predicate keys on the
401
+ // slug, not on one row's id.
402
+ const shapes = new Set(
403
+ live.map((row) => `${row.attributeType}:${row.multi ? 1 : 0}`),
404
+ );
405
+ if (shapes.size > 1) {
406
+ throw new CrmFilterError(
407
+ "crm-filter-ambiguous-attribute",
408
+ `Attribute "${ref}" resolves to more than one attribute type; filter by attribute id instead.`,
409
+ );
410
+ }
411
+ const first = live[0]!;
412
+ resolved.set(ref, {
413
+ id: first.id,
414
+ apiSlug: first.apiSlug ?? first.fieldName,
415
+ attributeType: first.attributeType,
416
+ multi: first.multi,
417
+ family: conditionFamily(first.attributeType),
418
+ });
419
+ }
420
+ return resolved;
421
+ }
422
+
423
+ function resolveTarget(
424
+ ref: { attributeId?: string; field?: string },
425
+ attributes: Map<string, ResolvedAttribute>,
426
+ ): ResolvedTarget {
427
+ if (ref.attributeId && ref.field) {
428
+ throw new CrmFilterError(
429
+ "crm-filter-target",
430
+ `Filter entry sets both attributeId "${ref.attributeId}" and field "${ref.field}"; pick one.`,
431
+ );
432
+ }
433
+ if (ref.attributeId) {
434
+ const attribute = attributes.get(ref.attributeId);
435
+ if (!attribute) {
436
+ throw new CrmFilterError(
437
+ "crm-filter-unknown-attribute",
438
+ `Filter references unknown attribute "${ref.attributeId}".`,
439
+ );
440
+ }
441
+ return { kind: "attribute", label: ref.attributeId, attribute };
442
+ }
443
+ if (ref.field) {
444
+ const spec = RECORD_COLUMNS[ref.field];
445
+ if (!spec) {
446
+ throw new CrmFilterError(
447
+ "crm-filter-unknown-field",
448
+ `Filter references unknown record field "${ref.field}". Known fields: ${Object.keys(
449
+ RECORD_COLUMNS,
450
+ ).join(", ")}.`,
451
+ );
452
+ }
453
+ return { kind: "column", label: ref.field, spec };
454
+ }
455
+ throw new CrmFilterError(
456
+ "crm-filter-target",
457
+ "Every filter and sort entry needs either an attributeId or a field.",
458
+ );
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Values
463
+ // ---------------------------------------------------------------------------
464
+
465
+ type Scalar = string | number | boolean;
466
+
467
+ /**
468
+ * `@currentUser` is resolved here and nowhere else. Resolving it on the client
469
+ * would make one shared view render the author's rows for everybody.
470
+ */
471
+ function resolveToken(
472
+ value: Scalar,
473
+ target: ResolvedTarget,
474
+ actorEmail: string | null,
475
+ ): Scalar {
476
+ if (value !== CRM_CURRENT_USER_TOKEN) return value;
477
+ if (!isActorTarget(target)) {
478
+ throw new CrmFilterError(
479
+ "crm-filter-current-user-target",
480
+ `"${CRM_CURRENT_USER_TOKEN}" only resolves on an actor-reference attribute or an owner field, not on "${target.label}".`,
481
+ );
482
+ }
483
+ if (!actorEmail) {
484
+ throw new CrmFilterError(
485
+ "crm-filter-no-actor",
486
+ `"${CRM_CURRENT_USER_TOKEN}" cannot be resolved without a signed-in caller.`,
487
+ );
488
+ }
489
+ return actorEmail.toLowerCase();
490
+ }
491
+
492
+ function requireValue(
493
+ leaf: CrmFilterLeaf,
494
+ target: ResolvedTarget,
495
+ actorEmail: string | null,
496
+ ): Scalar {
497
+ const value = leaf.value;
498
+ if (value === undefined || value === null || Array.isArray(value)) {
499
+ throw new CrmFilterError(
500
+ "crm-filter-value",
501
+ `Condition "${leaf.condition}" on "${target.label}" needs a single value.`,
502
+ );
503
+ }
504
+ return resolveToken(value, target, actorEmail);
505
+ }
506
+
507
+ function requireValues(
508
+ leaf: CrmFilterLeaf,
509
+ target: ResolvedTarget,
510
+ actorEmail: string | null,
511
+ ): Scalar[] {
512
+ const value = leaf.value;
513
+ const list = Array.isArray(value) ? value : value == null ? [] : [value];
514
+ if (list.length === 0) {
515
+ throw new CrmFilterError(
516
+ "crm-filter-value",
517
+ `Condition "${leaf.condition}" on "${target.label}" needs at least one value.`,
518
+ );
519
+ }
520
+ return list.map((entry) => resolveToken(entry, target, actorEmail));
521
+ }
522
+
523
+ function requireRange(
524
+ leaf: CrmFilterLeaf,
525
+ target: ResolvedTarget,
526
+ ): [Scalar, Scalar] {
527
+ const value = leaf.value;
528
+ if (!Array.isArray(value) || value.length !== 2) {
529
+ throw new CrmFilterError(
530
+ "crm-filter-value",
531
+ `Condition "between" on "${target.label}" needs a two-element [from, to] value.`,
532
+ );
533
+ }
534
+ return [value[0]!, value[1]!];
535
+ }
536
+
537
+ function requireNumber(value: Scalar, target: ResolvedTarget): number {
538
+ const parsed = typeof value === "number" ? value : Number(value);
539
+ if (!Number.isFinite(parsed)) {
540
+ throw new CrmFilterError(
541
+ "crm-filter-value",
542
+ `"${target.label}" is numeric; "${String(value)}" is not a number.`,
543
+ );
544
+ }
545
+ return parsed;
546
+ }
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // Relative dates
550
+ //
551
+ // Boundaries are emitted as bare `YYYY-MM-DD`, which compares correctly against
552
+ // both a `date` value ("2026-07-26") and a `timestamp` value
553
+ // ("2026-07-26T13:00:00.000Z") under lexicographic ordering. Days are UTC: the
554
+ // server has no caller timezone, and inventing one would silently shift every
555
+ // "today" filter for half the world.
556
+ // ---------------------------------------------------------------------------
557
+
558
+ const RELATIVE_DAYS = /^(last|next)-(\d{1,3})-days$/;
559
+
560
+ function isoDay(time: number): string {
561
+ return new Date(time).toISOString().slice(0, 10);
562
+ }
563
+
564
+ const DAY_MS = 86_400_000;
565
+
566
+ export function resolveRelativeDateToken(
567
+ token: string,
568
+ now: Date,
569
+ ): { from: string; to: string } | null {
570
+ const startOfDay = Date.UTC(
571
+ now.getUTCFullYear(),
572
+ now.getUTCMonth(),
573
+ now.getUTCDate(),
574
+ );
575
+ if (token === "today") {
576
+ return { from: isoDay(startOfDay), to: isoDay(startOfDay + DAY_MS) };
577
+ }
578
+ if (token === "this-week") {
579
+ // ISO weeks start Monday; getUTCDay() is 0 for Sunday.
580
+ const weekday = (new Date(startOfDay).getUTCDay() + 6) % 7;
581
+ const start = startOfDay - weekday * DAY_MS;
582
+ return { from: isoDay(start), to: isoDay(start + 7 * DAY_MS) };
583
+ }
584
+ const match = RELATIVE_DAYS.exec(token);
585
+ if (!match) return null;
586
+ const days = Number.parseInt(match[2]!, 10);
587
+ if (days < 1) {
588
+ throw new CrmFilterError(
589
+ "crm-filter-value",
590
+ `Relative date "${token}" needs a positive day count.`,
591
+ );
592
+ }
593
+ return match[1] === "last"
594
+ ? {
595
+ from: isoDay(startOfDay - (days - 1) * DAY_MS),
596
+ to: isoDay(startOfDay + DAY_MS),
597
+ }
598
+ : { from: isoDay(startOfDay), to: isoDay(startOfDay + days * DAY_MS) };
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // Predicate compilation
603
+ // ---------------------------------------------------------------------------
604
+
605
+ function likePattern(value: Scalar, mode: "contains" | "prefix" | "suffix") {
606
+ const escaped = String(value).replace(/([\\%_])/g, "\\$1");
607
+ if (mode === "prefix") return `${escaped}%`;
608
+ if (mode === "suffix") return `%${escaped}`;
609
+ return `%${escaped}%`;
610
+ }
611
+
612
+ // LIKE is case-insensitive in SQLite and case-sensitive in PostgreSQL. Lowering
613
+ // both sides is the only portable way to make "contains" mean the same thing.
614
+ function likeSql(column: SQL, pattern: string): SQL {
615
+ return sql`lower(${column}) like lower(${pattern}) escape '\\'`;
616
+ }
617
+
618
+ /**
619
+ * Membership in a multi-valued attribute, whose set is stored as one canonical
620
+ * JSON array in `json_value`.
621
+ *
622
+ * The delimiters matter: an element is JSON-encoded, so its own quotes are
623
+ * escaped as `\"`. Searching for `,"acme",` (with the surrounding separator)
624
+ * therefore cannot match inside another element, while a bare `%"acme"%` could.
625
+ *
626
+ * ponytail: a text scan, not an index probe. Fine at CRM row counts; if a
627
+ * workspace ever filters millions of rows on a multi attribute, normalize the
628
+ * set into its own value rows instead of widening this.
629
+ */
630
+ function jsonArrayContains(value: Scalar): SQL {
631
+ const encoded = JSON.stringify(String(value));
632
+ const column = sql`${schema.crmRecordFields.jsonValue}`;
633
+ return or(
634
+ likeSql(column, likePattern(`[${encoded},`, "contains")),
635
+ likeSql(column, likePattern(`,${encoded},`, "contains")),
636
+ likeSql(column, likePattern(`,${encoded}]`, "contains")),
637
+ likeSql(column, likePattern(`[${encoded}]`, "contains")),
638
+ )!;
639
+ }
640
+
641
+ /** The `crm_record_fields` column a target's values live in. */
642
+ function valueColumnFor(attribute: ResolvedAttribute): SQL {
643
+ if (attribute.multi) return sql`${schema.crmRecordFields.jsonValue}`;
644
+ const storage = ATTRIBUTE_TYPE_SPECS[attribute.attributeType].storageColumn;
645
+ if (storage === "numberValue")
646
+ return sql`${schema.crmRecordFields.numberValue}`;
647
+ if (storage === "booleanValue") {
648
+ return sql`${schema.crmRecordFields.booleanValue}`;
649
+ }
650
+ if (storage === "jsonValue") return sql`${schema.crmRecordFields.jsonValue}`;
651
+ return sql`${schema.crmRecordFields.stringValue}`;
652
+ }
653
+
654
+ function assertConditionSupported(
655
+ target: ResolvedTarget,
656
+ condition: CrmFilterCondition,
657
+ ): void {
658
+ const family = targetFamily(target);
659
+ if (!FAMILY_CONDITIONS[family].includes(condition)) {
660
+ throw new CrmFilterError(
661
+ "crm-filter-condition",
662
+ `Condition "${condition}" is not available on "${target.label}" (${family}). Available: ${FAMILY_CONDITIONS[
663
+ family
664
+ ].join(", ")}.`,
665
+ );
666
+ }
667
+ if (
668
+ target.kind === "attribute" &&
669
+ target.attribute.multi &&
670
+ !MULTI_CONDITIONS.includes(condition)
671
+ ) {
672
+ throw new CrmFilterError(
673
+ "crm-filter-multi-condition",
674
+ `"${target.label}" is multi-valued; condition "${condition}" would compare against the whole set. Available: ${MULTI_CONDITIONS.join(", ")}.`,
675
+ );
676
+ }
677
+ }
678
+
679
+ const MULTI_CONDITIONS: readonly CrmFilterCondition[] = [
680
+ "is",
681
+ "is-not",
682
+ "is-any-of",
683
+ "is-none-of",
684
+ "is-empty",
685
+ "is-not-empty",
686
+ ];
687
+
688
+ interface CompileContext {
689
+ attributes: Map<string, ResolvedAttribute>;
690
+ actorEmail: string | null;
691
+ now: Date;
692
+ }
693
+
694
+ /**
695
+ * A positive predicate over one value column, plus whether the caller must
696
+ * negate the surrounding EXISTS. `is-not` means "has no such value", which
697
+ * includes records that have no value at all — so it is NOT EXISTS of the
698
+ * positive test, not EXISTS of an inequality.
699
+ */
700
+ interface Predicate {
701
+ sql: SQL;
702
+ negate: boolean;
703
+ }
704
+
705
+ function comparePredicate(
706
+ column: SQL,
707
+ target: ResolvedTarget,
708
+ leaf: CrmFilterLeaf,
709
+ ctx: CompileContext,
710
+ ): Predicate {
711
+ const family = targetFamily(target);
712
+ const condition = leaf.condition;
713
+ const multi = target.kind === "attribute" ? target.attribute.multi : false;
714
+
715
+ if (condition === "is-empty") {
716
+ return { sql: sql`${column} is not null`, negate: true };
717
+ }
718
+ if (condition === "is-not-empty") {
719
+ return { sql: sql`${column} is not null`, negate: false };
720
+ }
721
+
722
+ if (multi) {
723
+ if (condition === "is" || condition === "is-not") {
724
+ const value = requireValue(leaf, target, ctx.actorEmail);
725
+ return { sql: jsonArrayContains(value), negate: condition === "is-not" };
726
+ }
727
+ const values = requireValues(leaf, target, ctx.actorEmail);
728
+ return {
729
+ sql: or(...values.map((value) => jsonArrayContains(value)))!,
730
+ negate: condition === "is-none-of",
731
+ };
732
+ }
733
+
734
+ if (family === "boolean") {
735
+ const value = requireValue(leaf, target, ctx.actorEmail);
736
+ const wanted =
737
+ typeof value === "boolean" ? value : value === "true" || value === 1;
738
+ // `portableBoolean` is an INTEGER column in both dialects, and a raw `sql`
739
+ // fragment bypasses the column codec that would have converted this.
740
+ return { sql: sql`${column} = ${wanted ? 1 : 0}`, negate: false };
741
+ }
742
+
743
+ if (family === "number") {
744
+ if (condition === "between") {
745
+ const [from, to] = requireRange(leaf, target);
746
+ return {
747
+ sql: and(
748
+ sql`${column} >= ${requireNumber(from, target)}`,
749
+ sql`${column} <= ${requireNumber(to, target)}`,
750
+ )!,
751
+ negate: false,
752
+ };
753
+ }
754
+ const value = requireNumber(
755
+ requireValue(leaf, target, ctx.actorEmail),
756
+ target,
757
+ );
758
+ if (condition === "=")
759
+ return { sql: sql`${column} = ${value}`, negate: false };
760
+ if (condition === "!=")
761
+ return { sql: sql`${column} = ${value}`, negate: true };
762
+ if (condition === ">")
763
+ return { sql: sql`${column} > ${value}`, negate: false };
764
+ if (condition === ">=")
765
+ return { sql: sql`${column} >= ${value}`, negate: false };
766
+ if (condition === "<")
767
+ return { sql: sql`${column} < ${value}`, negate: false };
768
+ return { sql: sql`${column} <= ${value}`, negate: false };
769
+ }
770
+
771
+ if (family === "date") {
772
+ return datePredicate(column, target, leaf, ctx);
773
+ }
774
+
775
+ // text / option / reference
776
+ if (condition === "is" || condition === "is-not") {
777
+ const value = requireValue(leaf, target, ctx.actorEmail);
778
+ return {
779
+ sql: isActorTarget(target)
780
+ ? sql`lower(${column}) = lower(${String(value)})`
781
+ : sql`${column} = ${value}`,
782
+ negate: condition === "is-not",
783
+ };
784
+ }
785
+ if (condition === "is-any-of" || condition === "is-none-of") {
786
+ const values = requireValues(leaf, target, ctx.actorEmail);
787
+ const comparisons = values.map((value) =>
788
+ isActorTarget(target)
789
+ ? sql`lower(${column}) = lower(${String(value)})`
790
+ : sql`${column} = ${value}`,
791
+ );
792
+ return { sql: or(...comparisons)!, negate: condition === "is-none-of" };
793
+ }
794
+ const value = requireValue(leaf, target, ctx.actorEmail);
795
+ if (condition === "starts-with") {
796
+ return {
797
+ sql: likeSql(column, likePattern(value, "prefix")),
798
+ negate: false,
799
+ };
800
+ }
801
+ if (condition === "ends-with") {
802
+ return {
803
+ sql: likeSql(column, likePattern(value, "suffix")),
804
+ negate: false,
805
+ };
806
+ }
807
+ return {
808
+ sql: likeSql(column, likePattern(value, "contains")),
809
+ negate: condition === "not-contains",
810
+ };
811
+ }
812
+
813
+ function datePredicate(
814
+ column: SQL,
815
+ target: ResolvedTarget,
816
+ leaf: CrmFilterLeaf,
817
+ ctx: CompileContext,
818
+ ): Predicate {
819
+ const condition = leaf.condition;
820
+ if (condition === "between") {
821
+ const [from, to] = requireRange(leaf, target);
822
+ const range = resolveRelativeDateToken(String(from), ctx.now);
823
+ if (range) {
824
+ return {
825
+ sql: and(
826
+ sql`${column} >= ${range.from}`,
827
+ sql`${column} < ${range.to}`,
828
+ )!,
829
+ negate: false,
830
+ };
831
+ }
832
+ return {
833
+ sql: and(
834
+ sql`${column} >= ${String(from)}`,
835
+ sql`${column} <= ${String(to)}`,
836
+ )!,
837
+ negate: false,
838
+ };
839
+ }
840
+ const raw = String(requireValue(leaf, target, ctx.actorEmail));
841
+ const range = resolveRelativeDateToken(raw, ctx.now);
842
+ if (range) {
843
+ if (condition === "before") {
844
+ return { sql: sql`${column} < ${range.from}`, negate: false };
845
+ }
846
+ if (condition === "after") {
847
+ return { sql: sql`${column} >= ${range.to}`, negate: false };
848
+ }
849
+ return {
850
+ sql: and(sql`${column} >= ${range.from}`, sql`${column} < ${range.to}`)!,
851
+ negate: false,
852
+ };
853
+ }
854
+ if (condition === "before")
855
+ return { sql: sql`${column} < ${raw}`, negate: false };
856
+ if (condition === "after")
857
+ return { sql: sql`${column} > ${raw}`, negate: false };
858
+ return { sql: sql`${column} = ${raw}`, negate: false };
859
+ }
860
+
861
+ /** Correlated current-value rows for one attribute of the outer record. */
862
+ function currentValueRows(attribute: ResolvedAttribute, predicate?: SQL) {
863
+ const conditions = [
864
+ eq(schema.crmRecordFields.recordId, schema.crmRecords.id),
865
+ isNull(schema.crmRecordFields.entryId),
866
+ eq(schema.crmRecordFields.fieldName, attribute.apiSlug),
867
+ isNull(schema.crmRecordFields.activeUntil),
868
+ accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
869
+ ];
870
+ if (predicate) conditions.push(predicate);
871
+ return getDb()
872
+ .select({ id: schema.crmRecordFields.id })
873
+ .from(schema.crmRecordFields)
874
+ .where(and(...conditions));
875
+ }
876
+
877
+ function compileLeaf(leaf: CrmFilterLeaf, ctx: CompileContext): SQL {
878
+ const target = resolveTarget(leaf, ctx.attributes);
879
+ assertConditionSupported(target, leaf.condition);
880
+
881
+ if (target.kind === "column") {
882
+ const predicate = comparePredicate(target.spec.column, target, leaf, ctx);
883
+ return predicate.negate ? sql`not (${predicate.sql})` : predicate.sql;
884
+ }
885
+
886
+ const column = valueColumnFor(target.attribute);
887
+ const predicate = comparePredicate(column, target, leaf, ctx);
888
+ const rows = currentValueRows(target.attribute, predicate.sql);
889
+ return predicate.negate ? notExists(rows) : exists(rows);
890
+ }
891
+
892
+ function compileNode(
893
+ node: CrmFilterNode,
894
+ ctx: CompileContext,
895
+ ): SQL | undefined {
896
+ const parts = node.conditions.map((entry) =>
897
+ "conditions" in entry ? compileNode(entry, ctx) : compileLeaf(entry, ctx),
898
+ );
899
+ const kept = parts.filter((part): part is SQL => part !== undefined);
900
+ if (kept.length === 0) return undefined;
901
+ return node.op === "or" ? or(...kept)! : and(...kept)!;
902
+ }
903
+
904
+ export async function compileCrmFilter(input: {
905
+ filter: CrmFilter;
906
+ actorEmail: string | null;
907
+ now?: Date;
908
+ }): Promise<SQL | undefined> {
909
+ const attributes = await resolveAttributes(attributeRefs(input.filter, []));
910
+ return compileNode(input.filter, {
911
+ attributes,
912
+ actorEmail: input.actorEmail,
913
+ now: input.now ?? new Date(),
914
+ });
915
+ }
916
+
917
+ // ---------------------------------------------------------------------------
918
+ // Sorting and keyset pagination
919
+ // ---------------------------------------------------------------------------
920
+
921
+ interface SortKey {
922
+ expression: SQL;
923
+ direction: "asc" | "desc";
924
+ }
925
+
926
+ interface CursorPayload {
927
+ f: string;
928
+ v: Array<string | number | boolean | null>;
929
+ id: string;
930
+ }
931
+
932
+ function fingerprint(value: unknown): string {
933
+ const text = JSON.stringify(value) ?? "";
934
+ let hash = 5381;
935
+ for (let index = 0; index < text.length; index += 1) {
936
+ hash = ((hash * 33) ^ text.charCodeAt(index)) >>> 0;
937
+ }
938
+ return hash.toString(36);
939
+ }
940
+
941
+ function decodeCursor(cursor: string, expected: string): CursorPayload {
942
+ let parsed: unknown;
943
+ try {
944
+ parsed = JSON.parse(cursor) as unknown;
945
+ } catch {
946
+ throw new CrmCursorError("CRM list cursor is not readable.");
947
+ }
948
+ const payload = parsed as Partial<CursorPayload>;
949
+ if (
950
+ !payload ||
951
+ typeof payload.id !== "string" ||
952
+ !Array.isArray(payload.v) ||
953
+ typeof payload.f !== "string"
954
+ ) {
955
+ throw new CrmCursorError("CRM list cursor is not readable.");
956
+ }
957
+ if (payload.f !== expected) {
958
+ throw new CrmCursorError(
959
+ "CRM list cursor belongs to a different filter or sort. Restart the list without a cursor.",
960
+ );
961
+ }
962
+ return payload as CursorPayload;
963
+ }
964
+
965
+ /**
966
+ * "Strictly after the cursor" under NULLS LAST ordering, expanded
967
+ * lexicographically over the sort keys with the record id as the final,
968
+ * always-ascending tiebreak. Without that tiebreak two rows with equal sort
969
+ * values could swap between pages and be skipped or repeated.
970
+ */
971
+ function keysetPredicate(
972
+ keys: SortKey[],
973
+ cursor: CursorPayload,
974
+ ): SQL | undefined {
975
+ let predicate: SQL = sql`${schema.crmRecords.id} > ${cursor.id}`;
976
+ for (let index = keys.length - 1; index >= 0; index -= 1) {
977
+ const key = keys[index]!;
978
+ const value = cursor.v[index] ?? null;
979
+ const equal =
980
+ value === null
981
+ ? sql`${key.expression} is null`
982
+ : sql`${key.expression} = ${value}`;
983
+ // NULLs sort last, so nothing follows a null on this key: only the later
984
+ // keys can still order two rows apart.
985
+ const after =
986
+ value === null
987
+ ? undefined
988
+ : key.direction === "asc"
989
+ ? sql`(${key.expression} is null or ${key.expression} > ${value})`
990
+ : sql`(${key.expression} is null or ${key.expression} < ${value})`;
991
+ const tail = and(equal, predicate)!;
992
+ predicate = after ? or(after, tail)! : tail;
993
+ }
994
+ return predicate;
995
+ }
996
+
997
+ function orderByFor(keys: SortKey[]): SQL[] {
998
+ const clauses: SQL[] = [];
999
+ for (const key of keys) {
1000
+ // Explicit null rank: SQLite orders NULLs first, PostgreSQL orders them
1001
+ // last. Neither default is portable, so rank them by hand.
1002
+ clauses.push(
1003
+ sql`case when ${key.expression} is null then 1 else 0 end asc`,
1004
+ );
1005
+ clauses.push(
1006
+ key.direction === "asc"
1007
+ ? sql`${key.expression} asc`
1008
+ : sql`${key.expression} desc`,
1009
+ );
1010
+ }
1011
+ clauses.push(sql`${schema.crmRecords.id} asc`);
1012
+ return clauses;
1013
+ }
1014
+
1015
+ // ---------------------------------------------------------------------------
1016
+ // Saved views
1017
+ // ---------------------------------------------------------------------------
1018
+
1019
+ export interface StoredCrmView {
1020
+ id: string;
1021
+ name: string;
1022
+ description: string;
1023
+ kind: string | null;
1024
+ viewKind: "table" | "board";
1025
+ targetKind: "object" | "list";
1026
+ targetId: string | null;
1027
+ groupByAttributeId: string | null;
1028
+ filter: CrmFilter;
1029
+ sort: CrmSort;
1030
+ columns: Array<{ attributeId: string; width?: number; hidden?: boolean }>;
1031
+ dataProgramId: string | null;
1032
+ pinned: boolean;
1033
+ audience: "personal" | "shared";
1034
+ updatedAt: string;
1035
+ }
1036
+
1037
+ /**
1038
+ * Views saved before typed filters stored `{query, fieldEquals}`. Translate them
1039
+ * at the boundary so the rest of the pipeline only ever sees one filter shape.
1040
+ */
1041
+ export function normalizeStoredFilter(raw: unknown): CrmFilter {
1042
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1043
+ return { op: "and", conditions: [] };
1044
+ }
1045
+ const value = raw as Record<string, unknown>;
1046
+ if (Array.isArray(value.conditions)) {
1047
+ const parsed = crmFilterSchema.safeParse(value);
1048
+ if (!parsed.success) {
1049
+ throw new CrmFilterError(
1050
+ "crm-filter-stored",
1051
+ `Saved view filter is not readable: ${parsed.error.issues[0]?.message ?? "invalid"}.`,
1052
+ );
1053
+ }
1054
+ return parsed.data;
1055
+ }
1056
+ const conditions: CrmFilterLeaf[] = [];
1057
+ if (typeof value.query === "string" && value.query.trim()) {
1058
+ conditions.push({
1059
+ field: "displayName",
1060
+ condition: "contains",
1061
+ value: value.query.trim(),
1062
+ });
1063
+ }
1064
+ const fieldEquals = value.fieldEquals;
1065
+ if (
1066
+ fieldEquals &&
1067
+ typeof fieldEquals === "object" &&
1068
+ !Array.isArray(fieldEquals)
1069
+ ) {
1070
+ for (const [name, entry] of Object.entries(
1071
+ fieldEquals as Record<string, unknown>,
1072
+ )) {
1073
+ if (
1074
+ typeof entry !== "string" &&
1075
+ typeof entry !== "number" &&
1076
+ typeof entry !== "boolean"
1077
+ ) {
1078
+ throw new CrmFilterError(
1079
+ "crm-filter-stored",
1080
+ `Saved view filter on "${name}" is not a comparable value.`,
1081
+ );
1082
+ }
1083
+ conditions.push({ attributeId: name, condition: "is", value: entry });
1084
+ }
1085
+ }
1086
+ const unknownKeys = Object.keys(value).filter(
1087
+ (key) => key !== "query" && key !== "fieldEquals" && key !== "op",
1088
+ );
1089
+ if (unknownKeys.length > 0) {
1090
+ throw new CrmFilterError(
1091
+ "crm-filter-stored",
1092
+ `Saved view filter has unsupported keys: ${unknownKeys.join(", ")}.`,
1093
+ );
1094
+ }
1095
+ return { op: "and", conditions };
1096
+ }
1097
+
1098
+ export function normalizeStoredColumns(
1099
+ raw: unknown,
1100
+ ): Array<{ attributeId: string; width?: number; hidden?: boolean }> {
1101
+ if (!Array.isArray(raw)) return [];
1102
+ return raw.flatMap((entry) => {
1103
+ if (typeof entry === "string") return [{ attributeId: entry }];
1104
+ if (!entry || typeof entry !== "object") return [];
1105
+ const value = entry as Record<string, unknown>;
1106
+ const attributeId = value.attributeId;
1107
+ if (typeof attributeId !== "string" || !attributeId) return [];
1108
+ return [
1109
+ {
1110
+ attributeId,
1111
+ ...(typeof value.width === "number" ? { width: value.width } : {}),
1112
+ ...(typeof value.hidden === "boolean" ? { hidden: value.hidden } : {}),
1113
+ },
1114
+ ];
1115
+ });
1116
+ }
1117
+
1118
+ function parseJson(value: string, label: string): unknown {
1119
+ try {
1120
+ return JSON.parse(value) as unknown;
1121
+ } catch {
1122
+ throw new CrmFilterError(
1123
+ "crm-filter-stored",
1124
+ `Saved view ${label} is not valid JSON.`,
1125
+ );
1126
+ }
1127
+ }
1128
+
1129
+ export async function loadCrmSavedView(viewId: string): Promise<StoredCrmView> {
1130
+ const [row] = await getDb()
1131
+ .select()
1132
+ .from(schema.crmSavedViews)
1133
+ .where(
1134
+ and(
1135
+ eq(schema.crmSavedViews.id, viewId),
1136
+ accessFilter(schema.crmSavedViews, schema.crmSavedViewShares),
1137
+ ),
1138
+ )
1139
+ .limit(1);
1140
+ if (!row) throw new Error("CRM saved view was not found.");
1141
+ return hydrateSavedViewRow(row);
1142
+ }
1143
+
1144
+ export function hydrateSavedViewRow(row: {
1145
+ id: string;
1146
+ name: string;
1147
+ description: string;
1148
+ kind: string | null;
1149
+ viewKind: "table" | "board";
1150
+ targetKind: "object" | "list";
1151
+ targetId: string | null;
1152
+ groupByAttributeId: string | null;
1153
+ filtersJson: string;
1154
+ columnsJson: string;
1155
+ sortJson: string;
1156
+ dataProgramId: string | null;
1157
+ pinned: boolean;
1158
+ visibility: string;
1159
+ updatedAt: string;
1160
+ }): StoredCrmView {
1161
+ const sort = crmSortSchema.safeParse(
1162
+ normalizeStoredSort(parseJson(row.sortJson, "sort")),
1163
+ );
1164
+ if (!sort.success) {
1165
+ throw new CrmFilterError(
1166
+ "crm-filter-stored",
1167
+ `Saved view sort is not readable: ${sort.error.issues[0]?.message ?? "invalid"}.`,
1168
+ );
1169
+ }
1170
+ return {
1171
+ id: row.id,
1172
+ name: row.name,
1173
+ description: row.description,
1174
+ kind: row.kind,
1175
+ viewKind: row.viewKind,
1176
+ targetKind: row.targetKind,
1177
+ targetId: row.targetId,
1178
+ groupByAttributeId: row.groupByAttributeId,
1179
+ filter: normalizeStoredFilter(parseJson(row.filtersJson, "filters")),
1180
+ sort: sort.data,
1181
+ columns: normalizeStoredColumns(parseJson(row.columnsJson, "columns")),
1182
+ dataProgramId: row.dataProgramId,
1183
+ pinned: row.pinned,
1184
+ audience: row.visibility === "private" ? "personal" : "shared",
1185
+ updatedAt: row.updatedAt,
1186
+ };
1187
+ }
1188
+
1189
+ /** Legacy sort entries used `{field}` for record columns; that still parses. */
1190
+ function normalizeStoredSort(raw: unknown): unknown {
1191
+ return Array.isArray(raw) ? raw : [];
1192
+ }
1193
+
1194
+ // ---------------------------------------------------------------------------
1195
+ // The query
1196
+ // ---------------------------------------------------------------------------
1197
+
1198
+ type ScopeValidationTarget = {
1199
+ connectionId: string;
1200
+ workspaceConnectionId: string | null;
1201
+ provider: string;
1202
+ objectType: string;
1203
+ };
1204
+
1205
+ export type CrmScopeResolver = (
1206
+ target: ScopeValidationTarget,
1207
+ ) => Promise<CrmAccessScope | null>;
1208
+
1209
+ async function defaultScopeResolver(
1210
+ target: ScopeValidationTarget,
1211
+ ): Promise<CrmAccessScope | null> {
1212
+ if (target.provider === "native") {
1213
+ return resolveNativeCrmAccessScope({
1214
+ connectionId: target.connectionId,
1215
+ objectType: target.objectType,
1216
+ });
1217
+ }
1218
+ if (
1219
+ !isConnectedCrmProvider(target.provider) ||
1220
+ !target.workspaceConnectionId
1221
+ ) {
1222
+ return null;
1223
+ }
1224
+ const adapter = await createConnectedCrmAdapter({
1225
+ provider: target.provider,
1226
+ connectionId: target.workspaceConnectionId,
1227
+ });
1228
+ return adapter.getAccessScope(target.objectType);
1229
+ }
1230
+
1231
+ const SUMMARY_COLUMNS = new Set([
1232
+ "displayName",
1233
+ "primaryEmail",
1234
+ "domain",
1235
+ "stage",
1236
+ "ownerName",
1237
+ "nextContactAt",
1238
+ "remoteUpdatedAt",
1239
+ "updatedAt",
1240
+ ]);
1241
+
1242
+ interface SummaryRow {
1243
+ id: string;
1244
+ displayName: string;
1245
+ kind: string;
1246
+ primaryEmail: string | null;
1247
+ domain: string | null;
1248
+ stage: string | null;
1249
+ ownerName: string | null;
1250
+ nextContactAt: string | null;
1251
+ remoteUpdatedAt: string | null;
1252
+ updatedAt: string;
1253
+ remoteRevision: string | null;
1254
+ }
1255
+
1256
+ function toRecordSummary(row: SummaryRow, columns?: string[]) {
1257
+ const included = columns?.length ? new Set(columns) : SUMMARY_COLUMNS;
1258
+ return {
1259
+ id: row.id,
1260
+ displayName: row.displayName,
1261
+ kind: row.kind,
1262
+ // Not a display column: it is the concurrency token every write needs, so
1263
+ // a view's column selection must never drop it.
1264
+ remoteRevision: row.remoteRevision,
1265
+ ...(included.has("domain") || included.has("primaryEmail")
1266
+ ? { subtitle: row.domain ?? row.primaryEmail ?? undefined }
1267
+ : {}),
1268
+ ...(included.has("ownerName") ? { owner: row.ownerName ?? undefined } : {}),
1269
+ ...(included.has("stage") ? { stage: row.stage ?? undefined } : {}),
1270
+ ...(included.has("nextContactAt")
1271
+ ? { nextStep: row.nextContactAt ?? undefined }
1272
+ : {}),
1273
+ ...(included.has("remoteUpdatedAt") || included.has("updatedAt")
1274
+ ? { updatedAt: row.remoteUpdatedAt ?? row.updatedAt }
1275
+ : {}),
1276
+ };
1277
+ }
1278
+
1279
+ export interface QueryCrmRecordsInput {
1280
+ kind?: CrmObjectKind;
1281
+ connectionId?: string;
1282
+ /** Display-name substring; sugar for a `displayName contains` condition. */
1283
+ query?: string;
1284
+ viewId?: string;
1285
+ filter?: CrmFilter;
1286
+ sort?: CrmSort;
1287
+ limit: number;
1288
+ cursor?: string;
1289
+ includeTotal?: boolean;
1290
+ }
1291
+
1292
+ export interface QueryCrmRecordsOptions {
1293
+ resolveScope?: CrmScopeResolver;
1294
+ actorEmail?: string | null;
1295
+ now?: Date;
1296
+ }
1297
+
1298
+ export async function queryCrmRecords(
1299
+ input: QueryCrmRecordsInput,
1300
+ options: QueryCrmRecordsOptions = {},
1301
+ ) {
1302
+ const db = getDb();
1303
+ const limit = Math.min(Math.max(input.limit, 1), MAX_RECORD_LIMIT);
1304
+ const now = options.now ?? new Date();
1305
+
1306
+ if (input.viewId && input.filter) {
1307
+ throw new CrmFilterError(
1308
+ "crm-filter-conflict",
1309
+ "Pass either viewId or an inline filter, not both — a saved view owns its filter.",
1310
+ );
1311
+ }
1312
+
1313
+ const view = input.viewId ? await loadCrmSavedView(input.viewId) : undefined;
1314
+ if (input.kind && view?.kind && input.kind !== view.kind) {
1315
+ throw new CrmFilterError(
1316
+ "crm-filter-conflict",
1317
+ "CRM saved view kind cannot be overridden.",
1318
+ );
1319
+ }
1320
+
1321
+ const base: CrmFilterNode = view?.filter ??
1322
+ input.filter ?? { op: "and", conditions: [] };
1323
+ // A `query` NARROWS whatever it is combined with. Appending it to an
1324
+ // or-rooted filter would widen the result set instead, so an or-root is
1325
+ // wrapped as a group under a new and-root.
1326
+ const search: CrmFilterLeaf | undefined = input.query
1327
+ ? { field: "displayName", condition: "contains", value: input.query }
1328
+ : undefined;
1329
+ const rootFilter: CrmFilterNode = !search
1330
+ ? base
1331
+ : base.op === "or" && base.conditions.length > 0
1332
+ ? { op: "and", conditions: [base, search] }
1333
+ : { op: "and", conditions: [...base.conditions, search] };
1334
+
1335
+ const sort = view?.sort?.length ? view.sort : (input.sort ?? []);
1336
+ const attributes = await resolveAttributes(attributeRefs(rootFilter, sort));
1337
+ const ctx: CompileContext = {
1338
+ attributes,
1339
+ actorEmail: options.actorEmail ?? null,
1340
+ now,
1341
+ };
1342
+
1343
+ const conditions = [
1344
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
1345
+ accessFilter(schema.crmConnections, schema.crmConnectionShares),
1346
+ eq(schema.crmRecords.tombstone, false),
1347
+ ];
1348
+ const kind = (view?.kind as CrmObjectKind | undefined) ?? input.kind;
1349
+ if (kind) conditions.push(eq(schema.crmRecords.kind, kind));
1350
+ if (input.connectionId) {
1351
+ conditions.push(eq(schema.crmRecords.connectionId, input.connectionId));
1352
+ }
1353
+ const compiled = compileNode(rootFilter, ctx);
1354
+ if (compiled) conditions.push(compiled);
1355
+
1356
+ const keys: SortKey[] = sort.map((entry) => ({
1357
+ expression: sortKeyExpression(entry, attributes),
1358
+ direction: entry.direction,
1359
+ }));
1360
+
1361
+ const shape = fingerprint({
1362
+ kind: kind ?? null,
1363
+ connectionId: input.connectionId ?? null,
1364
+ viewId: input.viewId ?? null,
1365
+ filter: rootFilter,
1366
+ sort,
1367
+ actor: options.actorEmail ?? null,
1368
+ });
1369
+ const pageConditions = [...conditions];
1370
+ if (input.cursor) {
1371
+ const cursor = decodeCursor(input.cursor, shape);
1372
+ const predicate = keysetPredicate(keys, cursor);
1373
+ if (predicate) pageConditions.push(predicate);
1374
+ }
1375
+
1376
+ const selection: Record<string, unknown> = {
1377
+ id: schema.crmRecords.id,
1378
+ displayName: schema.crmRecords.displayName,
1379
+ kind: schema.crmRecords.kind,
1380
+ primaryEmail: schema.crmRecords.primaryEmail,
1381
+ domain: schema.crmRecords.domain,
1382
+ stage: schema.crmRecords.stage,
1383
+ ownerName: schema.crmRecords.ownerName,
1384
+ nextContactAt: schema.crmRecords.nextContactAt,
1385
+ remoteUpdatedAt: schema.crmRecords.remoteUpdatedAt,
1386
+ updatedAt: schema.crmRecords.updatedAt,
1387
+ remoteRevision: schema.crmRecords.remoteRevision,
1388
+ connectionId: schema.crmRecords.connectionId,
1389
+ objectType: schema.crmRecords.objectType,
1390
+ provider: schema.crmRecords.provider,
1391
+ accessScopeJson: schema.crmRecords.accessScopeJson,
1392
+ workspaceConnectionId: schema.crmConnections.workspaceConnectionId,
1393
+ };
1394
+ keys.forEach((key, index) => {
1395
+ selection[`sortKey${index}`] = key.expression.as(`sort_key_${index}`);
1396
+ });
1397
+
1398
+ const rows = (await db
1399
+ .select(selection as never)
1400
+ .from(schema.crmRecords)
1401
+ .innerJoin(
1402
+ schema.crmConnections,
1403
+ eq(schema.crmRecords.connectionId, schema.crmConnections.id),
1404
+ )
1405
+ .where(and(...pageConditions))
1406
+ .orderBy(...orderByFor(keys))
1407
+ .limit(limit + 1)) as unknown as Array<
1408
+ SummaryRow & {
1409
+ connectionId: string;
1410
+ objectType: string;
1411
+ provider: string;
1412
+ accessScopeJson: string;
1413
+ workspaceConnectionId: string | null;
1414
+ } & Record<string, unknown>
1415
+ >;
1416
+
1417
+ const hasMore = rows.length > limit;
1418
+ const pageRows = rows.slice(0, limit);
1419
+ const last = pageRows[pageRows.length - 1];
1420
+ const nextCursor =
1421
+ hasMore && last
1422
+ ? JSON.stringify({
1423
+ f: shape,
1424
+ v: keys.map((_, index) =>
1425
+ normalizeCursorValue(last[`sortKey${index}`]),
1426
+ ),
1427
+ id: last.id,
1428
+ } satisfies CursorPayload)
1429
+ : undefined;
1430
+
1431
+ const resolveScope = options.resolveScope ?? defaultScopeResolver;
1432
+ const scopeTargets = Array.from(
1433
+ new Map(
1434
+ pageRows.map((row) => [
1435
+ `${row.connectionId}:${row.objectType}`,
1436
+ {
1437
+ connectionId: row.connectionId,
1438
+ workspaceConnectionId: row.workspaceConnectionId,
1439
+ provider: row.provider,
1440
+ objectType: row.objectType,
1441
+ },
1442
+ ]),
1443
+ ).values(),
1444
+ ).slice(0, MAX_SCOPE_VALIDATIONS);
1445
+ const currentScopes = new Map(
1446
+ await Promise.all(
1447
+ scopeTargets.map(
1448
+ async (target) =>
1449
+ [
1450
+ `${target.connectionId}:${target.objectType}`,
1451
+ await resolveScope(target).catch(() => null),
1452
+ ] as const,
1453
+ ),
1454
+ ),
1455
+ );
1456
+
1457
+ const columnNames = view?.columns.map((column) => column.attributeId);
1458
+ const records = pageRows
1459
+ .filter((row) => {
1460
+ const current = currentScopes.get(
1461
+ `${row.connectionId}:${row.objectType}`,
1462
+ );
1463
+ return Boolean(
1464
+ current &&
1465
+ scopesAreCompatible(parseCrmAccessScope(row.accessScopeJson), current),
1466
+ );
1467
+ })
1468
+ .map((row) => toRecordSummary(row, columnNames));
1469
+
1470
+ let totalEstimate: number | undefined;
1471
+ if (input.includeTotal) {
1472
+ const [count] = await db
1473
+ .select({ total: sql<number>`count(*)` })
1474
+ .from(schema.crmRecords)
1475
+ .innerJoin(
1476
+ schema.crmConnections,
1477
+ eq(schema.crmRecords.connectionId, schema.crmConnections.id),
1478
+ )
1479
+ .where(and(...conditions));
1480
+ totalEstimate = Number(count?.total ?? 0);
1481
+ }
1482
+
1483
+ return {
1484
+ records,
1485
+ nextCursor,
1486
+ complete: !hasMore,
1487
+ ...(totalEstimate === undefined ? {} : { totalEstimate }),
1488
+ ...(view
1489
+ ? {
1490
+ appliedView: {
1491
+ id: view.id,
1492
+ name: view.name,
1493
+ viewKind: view.viewKind,
1494
+ ...(view.kind ? { kind: view.kind } : {}),
1495
+ ...(view.dataProgramId
1496
+ ? { dataProgramId: view.dataProgramId }
1497
+ : {}),
1498
+ ...(view.groupByAttributeId
1499
+ ? { groupByAttributeId: view.groupByAttributeId }
1500
+ : {}),
1501
+ filter: view.filter,
1502
+ sort: view.sort,
1503
+ columns: view.columns,
1504
+ },
1505
+ }
1506
+ : {}),
1507
+ };
1508
+ }
1509
+
1510
+ function normalizeCursorValue(
1511
+ value: unknown,
1512
+ ): string | number | boolean | null {
1513
+ if (value === null || value === undefined) return null;
1514
+ if (
1515
+ typeof value === "string" ||
1516
+ typeof value === "number" ||
1517
+ typeof value === "boolean"
1518
+ ) {
1519
+ return value;
1520
+ }
1521
+ // A sort key that is not a comparable scalar cannot be resumed from; failing
1522
+ // here beats emitting a cursor that silently reorders the next page.
1523
+ throw new CrmCursorError(
1524
+ "CRM list sort key is not a comparable value; remove it from the sort.",
1525
+ );
1526
+ }
1527
+
1528
+ function sortKeyExpression(
1529
+ entry: CrmSort[number],
1530
+ attributes: Map<string, ResolvedAttribute>,
1531
+ ): SQL {
1532
+ const target = resolveTarget(entry, attributes);
1533
+ if (target.kind === "column") return target.spec.column;
1534
+ if (target.attribute.multi) {
1535
+ throw new CrmFilterError(
1536
+ "crm-filter-sort",
1537
+ `"${target.label}" is multi-valued and has no single sortable value.`,
1538
+ );
1539
+ }
1540
+ const column = valueColumnFor(target.attribute);
1541
+ // A record has at most one current row per attribute (the partial unique
1542
+ // index enforces it); LIMIT 1 keeps PostgreSQL from erroring if that ever
1543
+ // slips rather than failing the whole page.
1544
+ const subquery = getDb()
1545
+ .select({ value: sql`${column}`.as("value") })
1546
+ .from(schema.crmRecordFields)
1547
+ .where(
1548
+ and(
1549
+ eq(schema.crmRecordFields.recordId, schema.crmRecords.id),
1550
+ isNull(schema.crmRecordFields.entryId),
1551
+ eq(schema.crmRecordFields.fieldName, target.attribute.apiSlug),
1552
+ isNull(schema.crmRecordFields.activeUntil),
1553
+ accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
1554
+ ),
1555
+ )
1556
+ .limit(1);
1557
+ return sql`${subquery}`;
1558
+ }