@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,1146 @@
1
+ import {
2
+ resolveWorkspaceConnectionCredentialForApp,
3
+ resolveWorkspaceConnectionForApp,
4
+ type ResolvedWorkspaceConnectionForApp,
5
+ type WorkspaceConnectionForApp,
6
+ } from "@agent-native/core/workspace-connections";
7
+
8
+ import type {
9
+ CrmAccessScope,
10
+ CrmAdapter,
11
+ CrmAdapterCapabilities,
12
+ CrmConnectionRef,
13
+ CrmFieldDefinition,
14
+ CrmObjectDefinition,
15
+ CrmObjectKind,
16
+ CrmObjectRef,
17
+ CrmProvenance,
18
+ CrmRecord,
19
+ CrmRecordRef,
20
+ CrmRelationship,
21
+ CrmSyncPage,
22
+ CrmValue,
23
+ } from "../../shared/crm-contract.js";
24
+
25
+ const HUBSPOT_API_BASE = "https://api.hubapi.com";
26
+ const CRM_APP_ID = "crm";
27
+ const HUBSPOT_CREDENTIAL_KEYS = [
28
+ "HUBSPOT_ACCESS_TOKEN",
29
+ "HUBSPOT_PRIVATE_APP_TOKEN",
30
+ ] as const;
31
+ const CORE_OBJECT_TYPES = ["companies", "contacts", "deals"] as const;
32
+ const MAX_PAGE_SIZE = 100;
33
+ const MAX_RETRY_ATTEMPTS = 3;
34
+ const MAX_RETRY_DELAY_MS = 1_500;
35
+ const MAX_SINGLE_RETRY_DELAY_MS = 1_000;
36
+ const REQUEST_TIMEOUT_MS = 10_000;
37
+
38
+ type HubSpotObjectType = (typeof CORE_OBJECT_TYPES)[number] | string;
39
+
40
+ export interface HubSpotTransportRequest {
41
+ path: string;
42
+ method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
43
+ body?: unknown;
44
+ headers?: Record<string, string>;
45
+ retrySafe?: boolean;
46
+ }
47
+
48
+ export interface HubSpotTransportResponse {
49
+ status: number;
50
+ body?: unknown;
51
+ headers?: Record<string, string | undefined>;
52
+ }
53
+
54
+ export interface HubSpotTransport {
55
+ request(input: HubSpotTransportRequest): Promise<HubSpotTransportResponse>;
56
+ }
57
+
58
+ export interface CreateHubSpotCrmAdapterOptions {
59
+ connectionId?: string;
60
+ userEmail?: string;
61
+ orgId?: string | null;
62
+ transport?: HubSpotTransport;
63
+ }
64
+
65
+ export interface HubSpotCrmAdapterOptions {
66
+ connection: WorkspaceConnectionForApp;
67
+ transport: HubSpotTransport;
68
+ accessScope?: CrmAccessScope;
69
+ }
70
+
71
+ type HubSpotProperty = {
72
+ name?: string;
73
+ label?: string;
74
+ type?: string;
75
+ fieldType?: string;
76
+ hidden?: boolean;
77
+ readOnlyValue?: boolean;
78
+ calculated?: boolean;
79
+ required?: boolean;
80
+ options?: Array<{ value?: string; label?: string; hidden?: boolean }>;
81
+ referencedObjectType?: string;
82
+ sensitiveDataCategory?: string;
83
+ };
84
+
85
+ type HubSpotSchema = {
86
+ objectTypeId?: string | number;
87
+ name?: string;
88
+ labels?: { singular?: string; plural?: string };
89
+ metaType?: string;
90
+ properties?: HubSpotProperty[];
91
+ };
92
+
93
+ type HubSpotRecord = {
94
+ id?: string | number;
95
+ properties?: Record<string, unknown>;
96
+ createdAt?: string;
97
+ updatedAt?: string;
98
+ archived?: boolean;
99
+ };
100
+
101
+ type HubSpotListResponse = {
102
+ results?: HubSpotRecord[];
103
+ paging?: { next?: { after?: string } };
104
+ };
105
+
106
+ type HubSpotAssociation = {
107
+ toObjectId?: string | number;
108
+ id?: string | number;
109
+ associationTypes?: Array<{
110
+ associationCategory?: string;
111
+ associationTypeId?: string | number;
112
+ label?: string | null;
113
+ }>;
114
+ };
115
+
116
+ type HubSpotAssociationResponse = {
117
+ results?: HubSpotAssociation[];
118
+ paging?: { next?: { after?: string } };
119
+ };
120
+
121
+ function nonEmpty(value: string | undefined | null): string | undefined {
122
+ const trimmed = value?.trim();
123
+ return trimmed || undefined;
124
+ }
125
+
126
+ function uniqueStrings(values: readonly string[]): string[] {
127
+ return Array.from(
128
+ new Set(values.map((value) => value.trim()).filter(Boolean)),
129
+ );
130
+ }
131
+
132
+ function objectKind(objectType: string): CrmObjectKind {
133
+ switch (objectType.toLowerCase()) {
134
+ case "companies":
135
+ return "account";
136
+ case "contacts":
137
+ return "person";
138
+ case "deals":
139
+ return "opportunity";
140
+ case "tasks":
141
+ return "task";
142
+ case "calls":
143
+ case "emails":
144
+ case "meetings":
145
+ case "notes":
146
+ return "activity";
147
+ default:
148
+ return "custom";
149
+ }
150
+ }
151
+
152
+ function objectDisplayProperty(objectType: string): string[] {
153
+ switch (objectType.toLowerCase()) {
154
+ case "companies":
155
+ return ["name", "domain"];
156
+ case "contacts":
157
+ return ["firstname", "lastname", "email"];
158
+ case "deals":
159
+ return ["dealname"];
160
+ case "tickets":
161
+ return ["subject"];
162
+ default:
163
+ return ["name", "hs_object_id"];
164
+ }
165
+ }
166
+
167
+ function propertyValueType(
168
+ property: HubSpotProperty,
169
+ ): CrmFieldDefinition["valueType"] {
170
+ const type = property.type?.toLowerCase();
171
+ const fieldType = property.fieldType?.toLowerCase();
172
+ if (fieldType === "checkbox") return "multi-enum";
173
+ if (type === "enumeration") return "enum";
174
+ if (type === "bool" || type === "boolean") return "boolean";
175
+ if (type === "number") return "number";
176
+ if (type === "date") return "date";
177
+ if (type === "datetime") return "datetime";
178
+ if (type === "json") return "json";
179
+ if (fieldType === "currency" || property.name === "amount") return "currency";
180
+ if (fieldType === "percentage") return "percent";
181
+ if (type === "object_coordinates" || type === "reference") return "reference";
182
+ return "string";
183
+ }
184
+
185
+ function propertyIsSensitive(property: HubSpotProperty): boolean {
186
+ const category = property.sensitiveDataCategory?.trim().toLowerCase();
187
+ return Boolean(
188
+ category && category !== "none" && category !== "not_sensitive",
189
+ );
190
+ }
191
+
192
+ function scalarValue(value: unknown): CrmValue | undefined {
193
+ if (
194
+ value === null ||
195
+ typeof value === "string" ||
196
+ typeof value === "number" ||
197
+ typeof value === "boolean"
198
+ ) {
199
+ return value;
200
+ }
201
+ if (Array.isArray(value)) {
202
+ const values = value.filter(
203
+ (item): item is string | number | boolean | null =>
204
+ item === null ||
205
+ typeof item === "string" ||
206
+ typeof item === "number" ||
207
+ typeof item === "boolean",
208
+ );
209
+ return values.length === value.length ? values : undefined;
210
+ }
211
+ return undefined;
212
+ }
213
+
214
+ function toQuery(params: Record<string, string | undefined>): string {
215
+ const query = new URLSearchParams();
216
+ for (const [key, value] of Object.entries(params)) {
217
+ if (value) query.set(key, value);
218
+ }
219
+ const value = query.toString();
220
+ return value ? `?${value}` : "";
221
+ }
222
+
223
+ function encodeCursorState(value: { index: number; after?: string }): string {
224
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
225
+ let binary = "";
226
+ for (const byte of bytes) binary += String.fromCharCode(byte);
227
+ return btoa(binary)
228
+ .replace(/\+/g, "-")
229
+ .replace(/\//g, "_")
230
+ .replace(/=+$/, "");
231
+ }
232
+
233
+ function decodedCursor(cursor: string | undefined): {
234
+ index: number;
235
+ after?: string;
236
+ } {
237
+ if (!cursor) return { index: 0 };
238
+ try {
239
+ const padded = cursor
240
+ .replace(/-/g, "+")
241
+ .replace(/_/g, "/")
242
+ .padEnd(Math.ceil(cursor.length / 4) * 4, "=");
243
+ const binary = atob(padded);
244
+ const decoded = JSON.parse(
245
+ new TextDecoder().decode(
246
+ Uint8Array.from(binary, (character) => character.charCodeAt(0)),
247
+ ),
248
+ ) as { index?: unknown; after?: unknown };
249
+ const index = typeof decoded.index === "number" ? decoded.index : 0;
250
+ return {
251
+ index: Number.isInteger(index) && index >= 0 ? index : 0,
252
+ ...(typeof decoded.after === "string" && decoded.after
253
+ ? { after: decoded.after }
254
+ : {}),
255
+ };
256
+ } catch {
257
+ // HubSpot's `after` values are already opaque; accept them for one-object callers.
258
+ return { index: 0, after: cursor };
259
+ }
260
+ }
261
+
262
+ function encodedCursor(index: number, after?: string): string | undefined {
263
+ if (!after) return undefined;
264
+ return encodeCursorState({ index, after });
265
+ }
266
+
267
+ function scopeAllows(
268
+ connection: WorkspaceConnectionForApp,
269
+ objectType: string,
270
+ operation: "read" | "write",
271
+ ): boolean {
272
+ const scopes = [
273
+ ...connection.scopes,
274
+ ...(connection.explicitGrant?.scopes ?? []),
275
+ ].map((scope) => scope.toLowerCase());
276
+ if (scopes.length === 0) return operation === "read";
277
+ const singular = objectType.toLowerCase().replace(/s$/, "");
278
+ return scopes.some(
279
+ (scope) =>
280
+ scope === "all" ||
281
+ scope === "crm" ||
282
+ scope === `crm.${operation}` ||
283
+ scope === `crm.objects.${operation}` ||
284
+ scope === `crm.objects.${objectType.toLowerCase()}.${operation}` ||
285
+ scope === `crm.objects.${singular}.${operation}` ||
286
+ (operation === "write" && scope.endsWith(".write")),
287
+ );
288
+ }
289
+
290
+ function accessScopeFor(
291
+ connection: WorkspaceConnectionForApp,
292
+ objectType: string,
293
+ ): CrmAccessScope {
294
+ const grantId = connection.appAccess.grantId ?? undefined;
295
+ const mode = [
296
+ ...connection.credentialRefs,
297
+ ...(connection.explicitGrant?.credentialRefs ?? []),
298
+ ].some((ref) => ref.scope === "user")
299
+ ? "user"
300
+ : "service-account";
301
+ const readable = scopeAllows(connection, objectType, "read");
302
+ const writable = scopeAllows(connection, objectType, "write");
303
+ return {
304
+ key: `${connection.id}:${grantId ?? connection.appAccess.mode}`,
305
+ actorId: connection.ownerEmail,
306
+ grantId,
307
+ mode,
308
+ objectReadable: readable,
309
+ objectCreateable: writable,
310
+ objectUpdateable: writable,
311
+ objectDeleteable: false,
312
+ recordVisibility: mode === "user" ? "actor" : "unknown",
313
+ };
314
+ }
315
+
316
+ function definitionFrom(
317
+ connection: WorkspaceConnectionForApp,
318
+ objectType: string,
319
+ schema: HubSpotSchema | undefined,
320
+ properties: HubSpotProperty[],
321
+ ): CrmObjectDefinition {
322
+ const scope = accessScopeFor(connection, objectType);
323
+ const custom =
324
+ !CORE_OBJECT_TYPES.includes(
325
+ objectType as (typeof CORE_OBJECT_TYPES)[number],
326
+ ) && schema?.metaType !== "HUBSPOT";
327
+ return {
328
+ connectionId: connection.id,
329
+ provider: "hubspot",
330
+ ...(connection.accountId ? { accountId: connection.accountId } : {}),
331
+ actorId: connection.ownerEmail,
332
+ objectType,
333
+ kind: objectKind(objectType),
334
+ label: schema?.labels?.singular || objectType,
335
+ pluralLabel: schema?.labels?.plural || objectType,
336
+ custom,
337
+ queryable: scope.objectReadable,
338
+ searchable: scope.objectReadable,
339
+ createable: scope.objectCreateable,
340
+ updateable: scope.objectUpdateable,
341
+ deleteable: false,
342
+ fields: properties
343
+ .map((property): CrmFieldDefinition | null => {
344
+ const name = nonEmpty(property.name);
345
+ if (!name) return null;
346
+ const sensitive = propertyIsSensitive(property);
347
+ return {
348
+ name,
349
+ label: property.label || name,
350
+ valueType: propertyValueType(property),
351
+ storagePolicy: sensitive ? "redacted" : "remote-only",
352
+ sensitive,
353
+ readable: !property.hidden && scope.objectReadable,
354
+ createable:
355
+ !sensitive &&
356
+ !property.readOnlyValue &&
357
+ !property.calculated &&
358
+ scope.objectCreateable,
359
+ updateable:
360
+ !sensitive &&
361
+ !property.readOnlyValue &&
362
+ !property.calculated &&
363
+ scope.objectUpdateable,
364
+ required: property.required === true,
365
+ ...(property.options
366
+ ? {
367
+ options: property.options
368
+ .filter((option) => nonEmpty(option.value))
369
+ .map((option) => ({
370
+ value: option.value!.trim(),
371
+ label: option.label || option.value!.trim(),
372
+ ...(option.hidden ? { active: false } : {}),
373
+ })),
374
+ }
375
+ : {}),
376
+ ...(property.referencedObjectType
377
+ ? { referencedObjectType: property.referencedObjectType }
378
+ : {}),
379
+ };
380
+ })
381
+ .filter((field): field is CrmFieldDefinition => field !== null),
382
+ };
383
+ }
384
+
385
+ function recordRef(
386
+ connection: WorkspaceConnectionForApp,
387
+ objectType: string,
388
+ remoteId: string,
389
+ ): CrmRecordRef {
390
+ return {
391
+ connectionId: connection.id,
392
+ provider: "hubspot",
393
+ ...(connection.accountId ? { accountId: connection.accountId } : {}),
394
+ actorId: connection.ownerEmail,
395
+ objectType,
396
+ kind: objectKind(objectType),
397
+ remoteId,
398
+ };
399
+ }
400
+
401
+ function provenance(
402
+ connection: WorkspaceConnectionForApp,
403
+ objectType: string,
404
+ remoteId: string,
405
+ remoteRevision: string | undefined,
406
+ fieldNames: string[],
407
+ ): CrmProvenance[] {
408
+ const observedAt = new Date().toISOString();
409
+ return fieldNames.map((fieldName) => ({
410
+ provider: "hubspot",
411
+ connectionId: connection.id,
412
+ objectType,
413
+ remoteId,
414
+ fieldName,
415
+ ...(remoteRevision ? { remoteRevision } : {}),
416
+ observedAt,
417
+ }));
418
+ }
419
+
420
+ function projectRecord(
421
+ connection: WorkspaceConnectionForApp,
422
+ objectType: string,
423
+ source: HubSpotRecord,
424
+ fieldAllowList: string[],
425
+ ): CrmRecord | null {
426
+ const remoteId = source.id == null ? undefined : String(source.id);
427
+ if (!remoteId) return null;
428
+ const allowed = uniqueStrings(fieldAllowList);
429
+ const fields: Record<string, CrmValue> = {};
430
+ for (const fieldName of allowed) {
431
+ const value = scalarValue(source.properties?.[fieldName]);
432
+ if (value !== undefined) fields[fieldName] = value;
433
+ }
434
+ const displayValues = objectDisplayProperty(objectType)
435
+ .map((fieldName) => fields[fieldName])
436
+ .filter((value): value is CrmValue => value !== undefined)
437
+ .filter(
438
+ (value): value is string | number | boolean =>
439
+ typeof value === "string" ||
440
+ typeof value === "number" ||
441
+ typeof value === "boolean",
442
+ );
443
+ const displayName =
444
+ displayValues.map(String).filter(Boolean).join(" ") || remoteId;
445
+ const remoteRevision = source.updatedAt;
446
+ return {
447
+ ref: recordRef(connection, objectType, remoteId),
448
+ displayName,
449
+ fields,
450
+ ...(remoteRevision ? { remoteRevision } : {}),
451
+ ...(source.updatedAt ? { remoteUpdatedAt: source.updatedAt } : {}),
452
+ deleted: source.archived === true,
453
+ accessScope: accessScopeFor(connection, objectType),
454
+ provenance: provenance(
455
+ connection,
456
+ objectType,
457
+ remoteId,
458
+ remoteRevision,
459
+ Object.keys(fields),
460
+ ),
461
+ };
462
+ }
463
+
464
+ function isNotFound(error: unknown): boolean {
465
+ return error instanceof HubSpotApiError && error.status === 404;
466
+ }
467
+
468
+ class HubSpotApiError extends Error {
469
+ constructor(
470
+ readonly status: number,
471
+ message: string,
472
+ ) {
473
+ super(message);
474
+ }
475
+ }
476
+
477
+ function hubSpotErrorMessage(status: number): string {
478
+ if (status === 401 || status === 403)
479
+ return "HubSpot authentication or authorization failed.";
480
+ if (status === 404) return "HubSpot resource was not found.";
481
+ if (status === 429) return "HubSpot rate limit exceeded.";
482
+ if (status >= 500) return "HubSpot service is temporarily unavailable.";
483
+ return "HubSpot request failed.";
484
+ }
485
+
486
+ function headerValue(
487
+ headers: Record<string, string | undefined> | undefined,
488
+ name: string,
489
+ ): string | undefined {
490
+ if (!headers) return undefined;
491
+ const expected = name.toLowerCase();
492
+ return Object.entries(headers).find(
493
+ ([key]) => key.toLowerCase() === expected,
494
+ )?.[1];
495
+ }
496
+
497
+ export function hubSpotRetryDelayMs(
498
+ response: HubSpotTransportResponse,
499
+ attempt: number,
500
+ elapsedMs: number,
501
+ now = Date.now(),
502
+ ): number {
503
+ const retryAfter = headerValue(response.headers, "retry-after")?.trim();
504
+ const numericRetryAfter = retryAfter ? Number(retryAfter) : Number.NaN;
505
+ const requestedDelay = Number.isFinite(numericRetryAfter)
506
+ ? Math.max(0, numericRetryAfter * 1_000)
507
+ : retryAfter
508
+ ? Math.max(0, Date.parse(retryAfter) - now)
509
+ : 100 * 2 ** attempt;
510
+ return Math.max(
511
+ 0,
512
+ Math.min(
513
+ requestedDelay,
514
+ MAX_SINGLE_RETRY_DELAY_MS,
515
+ MAX_RETRY_DELAY_MS - elapsedMs,
516
+ ),
517
+ );
518
+ }
519
+
520
+ function canRetryHubSpotResponse(status: number): boolean {
521
+ return status === 429 || status >= 500;
522
+ }
523
+
524
+ function assertOwnedRecord(
525
+ connection: WorkspaceConnectionForApp,
526
+ record: CrmRecordRef,
527
+ ): void {
528
+ if (record.provider !== "hubspot" || record.connectionId !== connection.id) {
529
+ throw new Error(
530
+ "HubSpot record does not belong to this workspace connection.",
531
+ );
532
+ }
533
+ }
534
+
535
+ class FetchHubSpotTransport implements HubSpotTransport {
536
+ constructor(private readonly token: string) {}
537
+
538
+ async request(
539
+ input: HubSpotTransportRequest,
540
+ ): Promise<HubSpotTransportResponse> {
541
+ const response = await fetch(`${HUBSPOT_API_BASE}${input.path}`, {
542
+ method: input.method ?? "GET",
543
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
544
+ headers: {
545
+ Authorization: `Bearer ${this.token}`,
546
+ ...(input.body === undefined
547
+ ? {}
548
+ : { "Content-Type": "application/json" }),
549
+ ...input.headers,
550
+ },
551
+ ...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
552
+ });
553
+ const text = await response.text();
554
+ let body: unknown;
555
+ if (text) {
556
+ try {
557
+ body = JSON.parse(text);
558
+ } catch {
559
+ body = text;
560
+ }
561
+ }
562
+ return {
563
+ status: response.status,
564
+ body,
565
+ headers: {
566
+ "retry-after": response.headers.get("retry-after") ?? undefined,
567
+ },
568
+ };
569
+ }
570
+ }
571
+
572
+ export class HubSpotCrmAdapter implements CrmAdapter {
573
+ readonly connection: CrmConnectionRef;
574
+ readonly capabilities: CrmAdapterCapabilities = {
575
+ schemaDiscovery: true,
576
+ customObjects: true,
577
+ search: true,
578
+ incrementalSync: true,
579
+ deletedRecordSync: true,
580
+ conditionalMutations: false,
581
+ labeledRelationships: true,
582
+ perFieldPermissions: false,
583
+ perRecordPermissions: false,
584
+ };
585
+
586
+ private readonly workspaceConnection: WorkspaceConnectionForApp;
587
+ private readonly transport: HubSpotTransport;
588
+
589
+ constructor(options: HubSpotCrmAdapterOptions) {
590
+ this.workspaceConnection = options.connection;
591
+ this.transport = options.transport;
592
+ this.connection = {
593
+ connectionId: options.connection.id,
594
+ provider: "hubspot",
595
+ ...(options.connection.accountId
596
+ ? { accountId: options.connection.accountId }
597
+ : {}),
598
+ actorId: options.connection.ownerEmail,
599
+ };
600
+ }
601
+
602
+ private async request<T>(input: HubSpotTransportRequest): Promise<T> {
603
+ const readRetrySafe =
604
+ input.retrySafe === true ||
605
+ input.method === undefined ||
606
+ input.method === "GET";
607
+ let elapsedDelayMs = 0;
608
+ for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
609
+ const response = await this.transport.request(input);
610
+ if (response.status >= 200 && response.status < 300) {
611
+ return (response.body ?? {}) as T;
612
+ }
613
+ const mayRetry =
614
+ attempt + 1 < MAX_RETRY_ATTEMPTS &&
615
+ readRetrySafe &&
616
+ canRetryHubSpotResponse(response.status) &&
617
+ elapsedDelayMs < MAX_RETRY_DELAY_MS;
618
+ if (!mayRetry) {
619
+ throw new HubSpotApiError(
620
+ response.status,
621
+ `HubSpot API error ${response.status}: ${hubSpotErrorMessage(response.status)}`,
622
+ );
623
+ }
624
+ const delay = hubSpotRetryDelayMs(response, attempt, elapsedDelayMs);
625
+ if (delay <= 0) {
626
+ continue;
627
+ }
628
+ await new Promise<void>((resolve) => setTimeout(resolve, delay));
629
+ elapsedDelayMs += delay;
630
+ }
631
+ throw new Error("HubSpot retry budget was exhausted.");
632
+ }
633
+
634
+ private async schema(objectType: string): Promise<HubSpotSchema | undefined> {
635
+ try {
636
+ return await this.request<HubSpotSchema>({
637
+ path: `/crm/v3/schemas/${encodeURIComponent(objectType)}`,
638
+ });
639
+ } catch (error) {
640
+ if (isNotFound(error)) return undefined;
641
+ throw error;
642
+ }
643
+ }
644
+
645
+ private async properties(objectType: string): Promise<HubSpotProperty[]> {
646
+ const response = await this.request<{ results?: HubSpotProperty[] }>({
647
+ path: `/crm/v3/properties/${encodeURIComponent(objectType)}`,
648
+ });
649
+ return response.results ?? [];
650
+ }
651
+
652
+ private async objectDefinition(
653
+ objectType: string,
654
+ ): Promise<CrmObjectDefinition> {
655
+ const [schema, properties] = await Promise.all([
656
+ this.schema(objectType),
657
+ this.properties(objectType),
658
+ ]);
659
+ return definitionFrom(
660
+ this.workspaceConnection,
661
+ objectType,
662
+ schema,
663
+ properties,
664
+ );
665
+ }
666
+
667
+ async discoverObjects(): Promise<CrmObjectDefinition[]> {
668
+ const schemas = await this.request<{ results?: HubSpotSchema[] }>({
669
+ path: "/crm/v3/schemas",
670
+ });
671
+ const objectTypes = uniqueStrings([
672
+ ...CORE_OBJECT_TYPES,
673
+ ...(schemas.results ?? []).map(
674
+ (schema) =>
675
+ nonEmpty(schema.name) ??
676
+ (schema.objectTypeId == null ? "" : String(schema.objectTypeId)),
677
+ ),
678
+ ]);
679
+ return Promise.all(
680
+ objectTypes.map((objectType) => this.objectDefinition(objectType)),
681
+ );
682
+ }
683
+
684
+ async describeObject(objectType: string): Promise<CrmObjectDefinition> {
685
+ const normalized = nonEmpty(objectType);
686
+ if (!normalized) throw new Error("HubSpot objectType is required.");
687
+ return this.objectDefinition(normalized);
688
+ }
689
+
690
+ getAccessScope(objectType: string): CrmAccessScope {
691
+ const normalized = nonEmpty(objectType);
692
+ if (!normalized) throw new Error("HubSpot objectType is required.");
693
+ return accessScopeFor(this.workspaceConnection, normalized);
694
+ }
695
+
696
+ private async listPage(input: {
697
+ objectType: HubSpotObjectType;
698
+ fields: string[];
699
+ limit: number;
700
+ cursor?: string;
701
+ archived?: boolean;
702
+ filterGroups?: unknown[];
703
+ }): Promise<HubSpotListResponse> {
704
+ const objectType = encodeURIComponent(input.objectType);
705
+ if (input.filterGroups?.length) {
706
+ return this.request<HubSpotListResponse>({
707
+ method: "POST",
708
+ retrySafe: true,
709
+ path: `/crm/v3/objects/${objectType}/search`,
710
+ body: {
711
+ limit: input.limit,
712
+ ...(input.cursor ? { after: input.cursor } : {}),
713
+ ...(input.fields.length ? { properties: input.fields } : {}),
714
+ filterGroups: input.filterGroups,
715
+ },
716
+ });
717
+ }
718
+ return this.request<HubSpotListResponse>({
719
+ path: `/crm/v3/objects/${objectType}${toQuery({
720
+ limit: String(input.limit),
721
+ ...(input.cursor ? { after: input.cursor } : {}),
722
+ ...(input.fields.length ? { properties: input.fields.join(",") } : {}),
723
+ ...(input.archived ? { archived: "true" } : {}),
724
+ })}`,
725
+ });
726
+ }
727
+
728
+ private filtersForScope(scope: {
729
+ objectType: string;
730
+ pipelineIds?: string[];
731
+ ownerIds?: string[];
732
+ updatedAfter?: string;
733
+ }): unknown[] {
734
+ const filters: Array<Record<string, unknown>> = [];
735
+ if (
736
+ scope.objectType.toLowerCase() === "deals" &&
737
+ scope.pipelineIds?.length
738
+ ) {
739
+ filters.push({
740
+ propertyName: "pipeline",
741
+ operator: "IN",
742
+ values: uniqueStrings(scope.pipelineIds),
743
+ });
744
+ }
745
+ if (scope.ownerIds?.length) {
746
+ filters.push({
747
+ propertyName: "hubspot_owner_id",
748
+ operator: "IN",
749
+ values: uniqueStrings(scope.ownerIds),
750
+ });
751
+ }
752
+ if (scope.updatedAfter) {
753
+ filters.push({
754
+ propertyName:
755
+ scope.objectType.toLowerCase() === "contacts"
756
+ ? "lastmodifieddate"
757
+ : "hs_lastmodifieddate",
758
+ operator: "GTE",
759
+ value: scope.updatedAfter,
760
+ });
761
+ }
762
+ return filters.length ? [{ filters }] : [];
763
+ }
764
+
765
+ async syncPage(input: {
766
+ scope: {
767
+ objectType: string;
768
+ pipelineIds?: string[];
769
+ ownerIds?: string[];
770
+ recordIds?: string[];
771
+ associatedRecordIds?: string[];
772
+ updatedAfter?: string;
773
+ includeDeleted?: boolean;
774
+ };
775
+ fieldAllowList: string[];
776
+ cursor?: string;
777
+ limit: number;
778
+ }): Promise<CrmSyncPage> {
779
+ const objectType = nonEmpty(input.scope.objectType);
780
+ if (!objectType)
781
+ throw new Error("HubSpot sync scope objectType is required.");
782
+ const fields = uniqueStrings(input.fieldAllowList);
783
+ const limit = Math.max(1, Math.min(MAX_PAGE_SIZE, input.limit));
784
+ const recordIds = uniqueStrings(input.scope.recordIds ?? []);
785
+ let page: HubSpotListResponse;
786
+ if (recordIds.length) {
787
+ const state = decodedCursor(input.cursor);
788
+ const offset = Math.min(state.index, recordIds.length);
789
+ page = await this.request<HubSpotListResponse>({
790
+ method: "POST",
791
+ retrySafe: true,
792
+ path: `/crm/v3/objects/${encodeURIComponent(objectType)}/batch/read`,
793
+ body: {
794
+ inputs: recordIds.slice(offset, offset + limit).map((id) => ({ id })),
795
+ ...(fields.length ? { properties: fields } : {}),
796
+ ...(input.scope.includeDeleted ? { archived: true } : {}),
797
+ },
798
+ });
799
+ } else {
800
+ page = await this.listPage({
801
+ objectType,
802
+ fields,
803
+ limit,
804
+ cursor: input.cursor,
805
+ archived: input.scope.includeDeleted,
806
+ filterGroups: this.filtersForScope(input.scope),
807
+ });
808
+ }
809
+ const associatedIds = new Set(
810
+ uniqueStrings(input.scope.associatedRecordIds ?? []),
811
+ );
812
+ const records = (page.results ?? [])
813
+ .filter(
814
+ (record) => !associatedIds.size || associatedIds.has(String(record.id)),
815
+ )
816
+ .map((record) =>
817
+ projectRecord(this.workspaceConnection, objectType, record, fields),
818
+ )
819
+ .filter((record): record is CrmRecord => record !== null);
820
+ const nextCursor = recordIds.length
821
+ ? (() => {
822
+ const offset = decodedCursor(input.cursor).index + limit;
823
+ return offset < recordIds.length
824
+ ? encodeCursorState({ index: offset })
825
+ : undefined;
826
+ })()
827
+ : page.paging?.next?.after;
828
+ return {
829
+ records,
830
+ relationships: [],
831
+ ...(nextCursor ? { nextCursor } : {}),
832
+ complete: !nextCursor,
833
+ };
834
+ }
835
+
836
+ async getRecord(input: {
837
+ record: CrmRecordRef;
838
+ fields: string[];
839
+ }): Promise<CrmRecord | null> {
840
+ assertOwnedRecord(this.workspaceConnection, input.record);
841
+ const fields = uniqueStrings(input.fields);
842
+ try {
843
+ const record = await this.request<HubSpotRecord>({
844
+ path: `/crm/v3/objects/${encodeURIComponent(input.record.objectType)}/${encodeURIComponent(input.record.remoteId)}${toQuery(
845
+ {
846
+ ...(fields.length ? { properties: fields.join(",") } : {}),
847
+ },
848
+ )}`,
849
+ });
850
+ return projectRecord(
851
+ this.workspaceConnection,
852
+ input.record.objectType,
853
+ record,
854
+ fields,
855
+ );
856
+ } catch (error) {
857
+ if (isNotFound(error)) return null;
858
+ throw error;
859
+ }
860
+ }
861
+
862
+ async search(input: {
863
+ objectTypes: string[];
864
+ query: string;
865
+ fields: string[];
866
+ limit: number;
867
+ cursor?: string;
868
+ }): Promise<CrmSyncPage> {
869
+ const objectTypes = uniqueStrings(input.objectTypes);
870
+ if (!objectTypes.length)
871
+ return { records: [], relationships: [], complete: true };
872
+ const fields = uniqueStrings(input.fields);
873
+ const limit = Math.max(1, Math.min(MAX_PAGE_SIZE, input.limit));
874
+ const state = decodedCursor(input.cursor);
875
+ const index = Math.min(state.index, objectTypes.length - 1);
876
+ const objectType = objectTypes[index]!;
877
+ const page = await this.request<HubSpotListResponse>({
878
+ method: "POST",
879
+ path: `/crm/v3/objects/${encodeURIComponent(objectType)}/search`,
880
+ body: {
881
+ query: input.query,
882
+ limit,
883
+ ...(state.after ? { after: state.after } : {}),
884
+ ...(fields.length ? { properties: fields } : {}),
885
+ },
886
+ });
887
+ const records = (page.results ?? [])
888
+ .map((record) =>
889
+ projectRecord(this.workspaceConnection, objectType, record, fields),
890
+ )
891
+ .filter((record): record is CrmRecord => record !== null);
892
+ const after = page.paging?.next?.after;
893
+ const nextCursor = after
894
+ ? encodedCursor(index, after)
895
+ : index + 1 < objectTypes.length
896
+ ? encodeCursorState({ index: index + 1 })
897
+ : undefined;
898
+ return {
899
+ records,
900
+ relationships: [],
901
+ ...(nextCursor ? { nextCursor } : {}),
902
+ complete: !nextCursor,
903
+ };
904
+ }
905
+
906
+ async listRelationships(input: {
907
+ record: CrmRecordRef;
908
+ targetObjectTypes?: string[];
909
+ limit: number;
910
+ cursor?: string;
911
+ }): Promise<{
912
+ relationships: CrmRelationship[];
913
+ nextCursor?: string;
914
+ complete: boolean;
915
+ }> {
916
+ assertOwnedRecord(this.workspaceConnection, input.record);
917
+ const targetObjectTypes = uniqueStrings(
918
+ input.targetObjectTypes?.length
919
+ ? input.targetObjectTypes
920
+ : CORE_OBJECT_TYPES,
921
+ ).filter((objectType) => objectType !== input.record.objectType);
922
+ if (!targetObjectTypes.length) {
923
+ return { relationships: [], complete: true };
924
+ }
925
+ const state = decodedCursor(input.cursor);
926
+ const index = Math.min(state.index, targetObjectTypes.length - 1);
927
+ const targetObjectType = targetObjectTypes[index]!;
928
+ const response = await this.request<HubSpotAssociationResponse>({
929
+ path: `/crm/v4/objects/${encodeURIComponent(input.record.objectType)}/${encodeURIComponent(input.record.remoteId)}/associations/${encodeURIComponent(targetObjectType)}${toQuery(
930
+ {
931
+ limit: String(Math.max(1, Math.min(MAX_PAGE_SIZE, input.limit))),
932
+ ...(state.after ? { after: state.after } : {}),
933
+ },
934
+ )}`,
935
+ });
936
+ const relationships: CrmRelationship[] = [];
937
+ for (const association of response.results ?? []) {
938
+ const remoteId = association.toObjectId ?? association.id;
939
+ if (remoteId == null) continue;
940
+ const types = association.associationTypes?.length
941
+ ? association.associationTypes
942
+ : [
943
+ {
944
+ associationCategory: "HUBSPOT_DEFINED",
945
+ associationTypeId: "default",
946
+ },
947
+ ];
948
+ for (const type of types) {
949
+ const relationshipType = [
950
+ type.associationCategory ?? "HUBSPOT_DEFINED",
951
+ type.associationTypeId == null
952
+ ? "default"
953
+ : String(type.associationTypeId),
954
+ ].join(":");
955
+ relationships.push({
956
+ from: input.record,
957
+ to: recordRef(
958
+ this.workspaceConnection,
959
+ targetObjectType,
960
+ String(remoteId),
961
+ ),
962
+ relationshipType,
963
+ ...(type.label ? { label: type.label } : {}),
964
+ });
965
+ }
966
+ }
967
+ const after = response.paging?.next?.after;
968
+ const nextCursor = after
969
+ ? encodedCursor(index, after)
970
+ : index + 1 < targetObjectTypes.length
971
+ ? encodeCursorState({ index: index + 1 })
972
+ : undefined;
973
+ return {
974
+ relationships,
975
+ ...(nextCursor ? { nextCursor } : {}),
976
+ complete: !nextCursor,
977
+ };
978
+ }
979
+
980
+ async applyMutation(mutation: Parameters<CrmAdapter["applyMutation"]>[0]) {
981
+ assertOwnedRecord(this.workspaceConnection, mutation.record);
982
+ if (!mutation.idempotencyKey.trim()) {
983
+ return {
984
+ status: "rejected" as const,
985
+ message: "An idempotency key is required.",
986
+ };
987
+ }
988
+ if (mutation.operation === "delete") {
989
+ return {
990
+ status: "rejected" as const,
991
+ message:
992
+ "HubSpot deletion is disabled during the phase-1 CRM transport rollout.",
993
+ };
994
+ }
995
+ if (
996
+ mutation.operation === "associate" ||
997
+ mutation.operation === "disassociate"
998
+ ) {
999
+ return {
1000
+ status: "rejected" as const,
1001
+ message:
1002
+ "HubSpot association mutation requires an explicit association definition and is not enabled in phase 1.",
1003
+ };
1004
+ }
1005
+ if (
1006
+ !scopeAllows(
1007
+ this.workspaceConnection,
1008
+ mutation.record.objectType,
1009
+ "write",
1010
+ )
1011
+ ) {
1012
+ return {
1013
+ status: "rejected" as const,
1014
+ message:
1015
+ "The workspace connection does not prove write access for this HubSpot object.",
1016
+ };
1017
+ }
1018
+ const fields = Object.entries(mutation.fields ?? {}).reduce<
1019
+ Record<string, CrmValue>
1020
+ >((result, [key, value]) => {
1021
+ if (key.trim()) result[key] = value;
1022
+ return result;
1023
+ }, {});
1024
+ if (!Object.keys(fields).length) {
1025
+ return {
1026
+ status: "rejected" as const,
1027
+ message: "At least one field is required.",
1028
+ };
1029
+ }
1030
+ if (mutation.operation === "update" && !mutation.expectedRemoteRevision) {
1031
+ return {
1032
+ status: "rejected" as const,
1033
+ message: "HubSpot updates require a current remote revision.",
1034
+ };
1035
+ }
1036
+ if (mutation.expectedRemoteRevision) {
1037
+ const current = await this.getRecord({
1038
+ record: mutation.record,
1039
+ fields: [],
1040
+ });
1041
+ if (
1042
+ !current ||
1043
+ current.remoteRevision !== mutation.expectedRemoteRevision
1044
+ ) {
1045
+ return {
1046
+ status: "conflict" as const,
1047
+ ...(current?.remoteRevision
1048
+ ? { remoteRevision: current.remoteRevision }
1049
+ : {}),
1050
+ message:
1051
+ "The HubSpot record changed before this mutation could be applied.",
1052
+ };
1053
+ }
1054
+ if (!this.capabilities.conditionalMutations) {
1055
+ return {
1056
+ status: "rejected" as const,
1057
+ remoteRevision: current.remoteRevision,
1058
+ message:
1059
+ "HubSpot does not support an atomic conditional update for this record.",
1060
+ };
1061
+ }
1062
+ }
1063
+ try {
1064
+ const objectType = encodeURIComponent(mutation.record.objectType);
1065
+ const response = await this.request<HubSpotRecord>({
1066
+ method: mutation.operation === "create" ? "POST" : "PATCH",
1067
+ path:
1068
+ mutation.operation === "create"
1069
+ ? `/crm/v3/objects/${objectType}`
1070
+ : `/crm/v3/objects/${objectType}/${encodeURIComponent(mutation.record.remoteId)}`,
1071
+ body: { properties: fields },
1072
+ headers: { "X-Idempotency-Key": mutation.idempotencyKey },
1073
+ });
1074
+ const record = projectRecord(
1075
+ this.workspaceConnection,
1076
+ mutation.record.objectType,
1077
+ response,
1078
+ Object.keys(fields),
1079
+ );
1080
+ if (!record) {
1081
+ return {
1082
+ status: "rejected" as const,
1083
+ message: "HubSpot returned no record identity.",
1084
+ };
1085
+ }
1086
+ return {
1087
+ status: "applied" as const,
1088
+ record,
1089
+ ...(record.remoteRevision
1090
+ ? { remoteRevision: record.remoteRevision }
1091
+ : {}),
1092
+ };
1093
+ } catch (error) {
1094
+ return {
1095
+ status: "rejected" as const,
1096
+ message:
1097
+ error instanceof Error ? error.message : "HubSpot mutation failed.",
1098
+ };
1099
+ }
1100
+ }
1101
+ }
1102
+
1103
+ function requireResolvedConnection(
1104
+ result: ResolvedWorkspaceConnectionForApp,
1105
+ ): WorkspaceConnectionForApp {
1106
+ if (!result.available || !result.connection) {
1107
+ throw new Error(
1108
+ `HubSpot workspace connection is unavailable: ${result.reason}`,
1109
+ );
1110
+ }
1111
+ return result.connection;
1112
+ }
1113
+
1114
+ export async function createHubSpotCrmAdapter(
1115
+ options: CreateHubSpotCrmAdapterOptions = {},
1116
+ ): Promise<HubSpotCrmAdapter> {
1117
+ const resolved = await resolveWorkspaceConnectionForApp({
1118
+ appId: CRM_APP_ID,
1119
+ provider: "hubspot",
1120
+ ...(options.connectionId ? { connectionId: options.connectionId } : {}),
1121
+ requireConnected: true,
1122
+ });
1123
+ const connection = requireResolvedConnection(resolved);
1124
+ if (options.transport) {
1125
+ return new HubSpotCrmAdapter({ connection, transport: options.transport });
1126
+ }
1127
+ for (const key of HUBSPOT_CREDENTIAL_KEYS) {
1128
+ const credential = await resolveWorkspaceConnectionCredentialForApp({
1129
+ appId: CRM_APP_ID,
1130
+ provider: "hubspot",
1131
+ key,
1132
+ connectionId: connection.id,
1133
+ ...(options.userEmail ? { userEmail: options.userEmail } : {}),
1134
+ ...(options.orgId !== undefined ? { orgId: options.orgId } : {}),
1135
+ });
1136
+ if (!credential.available || !credential.value) continue;
1137
+ if (credential.provenance?.connectionId !== connection.id) continue;
1138
+ return new HubSpotCrmAdapter({
1139
+ connection,
1140
+ transport: new FetchHubSpotTransport(credential.value),
1141
+ });
1142
+ }
1143
+ throw new Error(
1144
+ "HubSpot workspace connection is unavailable: no scoped credential was resolved for the granted connection.",
1145
+ );
1146
+ }