@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +24 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/templates-meta.ts +12 -0
- package/corpus/core/src/deploy/build.ts +8 -0
- package/corpus/core/src/server/builder-browser.ts +76 -0
- package/corpus/core/src/server/core-routes-plugin.ts +15 -5
- package/corpus/templates/assets/app/global.css +0 -4
- package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +195 -167
- package/corpus/templates/assets/changelog/2026-07-23-drafts-preview-panel-now-fills-the-full-width-with-a-large-p.md +6 -0
- package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
- package/corpus/templates/content/actions/_builder-cms-write-client.ts +55 -9
- package/corpus/templates/content/actions/_database-source-utils.ts +756 -182
- package/corpus/templates/content/actions/change-content-database-source-role.ts +21 -20
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +49 -8
- package/corpus/templates/content/actions/materialize-builder-required-fields.ts +42 -9
- package/corpus/templates/content/actions/prepare-builder-source-review.ts +88 -90
- package/corpus/templates/content/actions/preview-builder-source-review.ts +4 -3
- package/corpus/templates/content/actions/refresh-content-database-source.ts +36 -9
- package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +28 -24
- package/corpus/templates/content/app/components/editor/BubbleToolbar.tsx +62 -12
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +91 -16
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +15 -1
- package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +234 -44
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +194 -16
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +95 -42
- package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +4 -2
- package/corpus/templates/content/app/components/editor/extensions/CodeBlockNode.tsx +10 -166
- package/corpus/templates/content/app/components/editor/extensions/VideoBlock.tsx +32 -8
- package/corpus/templates/content/app/components/editor/extensions/VideoNode.ts +14 -0
- package/corpus/templates/content/app/components/editor/registrySlashItems.ts +5 -1
- package/corpus/templates/content/app/hooks/use-content-database.ts +59 -2
- package/corpus/templates/content/app/hooks/use-documents.ts +18 -26
- package/corpus/templates/content/app/root.tsx +1 -6
- package/corpus/templates/content/changelog/2026-07-24-builder-can-now-publish-approved-updates-to-entries-that-are.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-connections-now-complete-securely-from-netlify-deplo.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-draft-creation-now-scopes-execution-to-the-selected-.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-review-now-distinguishes-preparing-a-safe-dry-run-from.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-source-imports-now-recover-from-a-transient-paginati.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-source-refresh-now-preserves-the-provider-bound-fiel.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-update-reviews-now-open-reliably-on-large-collection.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-builder-write-policy-controls-now-reflect-successful-changes.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-date-properties-now-save-reliably-when-selected-with-the-nat.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-editor-slash-search-now-keeps-multi-word-block-names-open.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-exact-editor-slash-commands-now-execute-on-enter-even-if-the.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-new-collaborative-pages-now-save-their-first-edits.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-new-local-builder-rows-no-longer-remain-locked-behind-a-none.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-opening-or-closing-a-collaborative-document-no-longer-lets-u.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-selected-text-now-opens-the-link-editor-with-command-k-or-co.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-slash-menu-block-choices-no-longer-stall-pointer-interaction.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md +6 -0
- package/corpus/templates/content/changelog/2026-07-24-video-link-insertion-now-keeps-its-source-panel-open-while-t.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-builder-publishing-now-shows-safe-validation-details-instead.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-builder-reviews-stay-responsive-on-large-collections-and-pre.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-collaborative-documents-no-longer-flash-duplicated-recent-bl.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-corrected-builder-updates-are-now-reviewed-separately-from-f.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-documents-now-remain-read-only-until-their-live-collaborativ.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-slash-command-menus-now-stay-reachable-when-editing-near-the.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-the-slash-menu-now-shows-one-clear-callout-choice-instead-of.md +6 -0
- package/corpus/templates/content/shared/api.ts +1 -0
- package/corpus/templates/content/shared/builder-mdx.ts +73 -16
- package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
- package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
- package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
- package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
- package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
- package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
- package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
- package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
- package/corpus/templates/crm/.env.example +5 -0
- package/corpus/templates/crm/AGENTS.md +82 -0
- package/corpus/templates/crm/CHANGELOG.md +25 -0
- package/corpus/templates/crm/DEVELOPING.md +66 -0
- package/corpus/templates/crm/README.md +122 -0
- package/corpus/templates/crm/_gitignore +12 -0
- package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
- package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
- package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
- package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
- package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
- package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
- package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
- package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
- package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
- package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
- package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
- package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
- package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
- package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
- package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
- package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
- package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
- package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
- package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
- package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
- package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
- package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
- package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
- package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
- package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
- package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
- package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
- package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
- package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
- package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
- package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
- package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
- package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
- package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
- package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
- package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
- package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
- package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
- package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
- package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
- package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
- package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
- package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
- package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
- package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
- package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
- package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
- package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
- package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
- package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
- package/corpus/templates/crm/actions/navigate.ts +54 -0
- package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
- package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
- package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
- package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
- package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
- package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
- package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
- package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
- package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
- package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
- package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
- package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
- package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
- package/corpus/templates/crm/actions/run.ts +7 -0
- package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
- package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
- package/corpus/templates/crm/actions/sync-crm.ts +146 -0
- package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
- package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
- package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
- package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
- package/corpus/templates/crm/actions/view-screen.ts +324 -0
- package/corpus/templates/crm/agent-native.app-skill.json +69 -0
- package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
- package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
- package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
- package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
- package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
- package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
- package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
- package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
- package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
- package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
- package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
- package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
- package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
- package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
- package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
- package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
- package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
- package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
- package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
- package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
- package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
- package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
- package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
- package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
- package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
- package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
- package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
- package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
- package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
- package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
- package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
- package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
- package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
- package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
- package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
- package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
- package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
- package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
- package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
- package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
- package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
- package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
- package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
- package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
- package/corpus/templates/crm/app/design-system.ts +3 -0
- package/corpus/templates/crm/app/entry.client.tsx +19 -0
- package/corpus/templates/crm/app/entry.server.tsx +10 -0
- package/corpus/templates/crm/app/global.css +167 -0
- package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
- package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
- package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
- package/corpus/templates/crm/app/i18n/index.ts +34 -0
- package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
- package/corpus/templates/crm/app/lib/navigation.ts +17 -0
- package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
- package/corpus/templates/crm/app/lib/types.ts +172 -0
- package/corpus/templates/crm/app/lib/utils.ts +1 -0
- package/corpus/templates/crm/app/root.tsx +93 -0
- package/corpus/templates/crm/app/routes/_index.tsx +26 -0
- package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
- package/corpus/templates/crm/app/routes/agent.tsx +5 -0
- package/corpus/templates/crm/app/routes/ask.tsx +53 -0
- package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
- package/corpus/templates/crm/app/routes/lists.tsx +228 -0
- package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
- package/corpus/templates/crm/app/routes/people.tsx +41 -0
- package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
- package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
- package/corpus/templates/crm/app/routes/records.tsx +64 -0
- package/corpus/templates/crm/app/routes/settings.tsx +118 -0
- package/corpus/templates/crm/app/routes/setup.tsx +308 -0
- package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
- package/corpus/templates/crm/app/routes/views.tsx +683 -0
- package/corpus/templates/crm/app/routes.ts +19 -0
- package/corpus/templates/crm/app/vite-env.d.ts +6 -0
- 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
- package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
- 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
- package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
- package/corpus/templates/crm/components.json +20 -0
- package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
- package/corpus/templates/crm/docs/features/README.md +55 -0
- package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
- package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
- package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
- package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
- package/corpus/templates/crm/learnings.defaults.md +5 -0
- package/corpus/templates/crm/netlify.toml +11 -0
- package/corpus/templates/crm/package.json +61 -0
- package/corpus/templates/crm/public/favicon.svg +1 -0
- package/corpus/templates/crm/public/icon-180.svg +1 -0
- package/corpus/templates/crm/public/manifest.json +11 -0
- package/corpus/templates/crm/react-router.config.ts +7 -0
- package/corpus/templates/crm/server/crm/adapter.ts +32 -0
- package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
- package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
- package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
- package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
- package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
- package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
- package/corpus/templates/crm/server/crm/read-through.ts +207 -0
- package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
- package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
- package/corpus/templates/crm/server/db/index.ts +191 -0
- package/corpus/templates/crm/server/db/schema.ts +647 -0
- package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
- package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
- package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
- package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
- package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
- package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
- package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
- package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
- package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
- package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
- package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
- package/corpus/templates/crm/server/middleware/auth.ts +4 -0
- package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
- package/corpus/templates/crm/server/plugins/auth.ts +14 -0
- package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
- package/corpus/templates/crm/server/plugins/db.ts +738 -0
- package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
- package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
- package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
- package/corpus/templates/crm/shared/crm-contract.ts +417 -0
- package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
- package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
- package/corpus/templates/crm/ssr-entry.ts +11 -0
- package/corpus/templates/crm/tsconfig.json +24 -0
- package/corpus/templates/crm/vite.config.ts +15 -0
- package/corpus/templates/crm/vitest.config.ts +5 -0
- package/corpus/templates/design/app/i18n-data.ts +12 -12
- package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/editor/useCollabReconcile.ts +14 -1
- package/dist/cli/templates-meta.d.ts.map +1 -1
- package/dist/cli/templates-meta.js +12 -0
- package/dist/cli/templates-meta.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/deploy/build.js +6 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/server/builder-browser.d.ts +13 -0
- package/dist/server/builder-browser.d.ts.map +1 -1
- package/dist/server/builder-browser.js +59 -0
- package/dist/server/builder-browser.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +10 -5
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +2 -2
- package/src/cli/templates-meta.ts +12 -0
- package/src/deploy/build.ts +8 -0
- package/src/server/builder-browser.ts +76 -0
- package/src/server/core-routes-plugin.ts +15 -5
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
import { resolveProviderApiOAuthAccessToken } from "@agent-native/core/provider-api";
|
|
2
|
+
import {
|
|
3
|
+
resolveWorkspaceConnectionCredentialForApp,
|
|
4
|
+
resolveWorkspaceConnectionForApp,
|
|
5
|
+
type ResolvedWorkspaceConnectionForApp,
|
|
6
|
+
type WorkspaceConnectionForApp,
|
|
7
|
+
} from "@agent-native/core/workspace-connections";
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
CrmAccessScope,
|
|
11
|
+
CrmAdapter,
|
|
12
|
+
CrmAdapterCapabilities,
|
|
13
|
+
CrmConnectionRef,
|
|
14
|
+
CrmFieldDefinition,
|
|
15
|
+
CrmObjectDefinition,
|
|
16
|
+
CrmObjectKind,
|
|
17
|
+
CrmProvenance,
|
|
18
|
+
CrmRecord,
|
|
19
|
+
CrmRecordRef,
|
|
20
|
+
CrmRelationship,
|
|
21
|
+
CrmSyncPage,
|
|
22
|
+
CrmValue,
|
|
23
|
+
} from "../../shared/crm-contract.js";
|
|
24
|
+
|
|
25
|
+
const CRM_APP_ID = "crm";
|
|
26
|
+
const SALESFORCE_CREDENTIAL_KEYS = [
|
|
27
|
+
"SALESFORCE_ACCESS_TOKEN",
|
|
28
|
+
"SALESFORCE_OAUTH_TOKEN",
|
|
29
|
+
] as const;
|
|
30
|
+
const CORE_OBJECT_TYPES = ["Account", "Contact", "Opportunity"] as const;
|
|
31
|
+
const MAX_PAGE_SIZE = 200;
|
|
32
|
+
const MAX_DISCOVERED_OBJECTS = 100;
|
|
33
|
+
const DISCOVERY_CONCURRENCY = 5;
|
|
34
|
+
const MAX_RETRY_ATTEMPTS = 3;
|
|
35
|
+
const MAX_RETRY_DELAY_MS = 1_500;
|
|
36
|
+
const MAX_SINGLE_RETRY_DELAY_MS = 1_000;
|
|
37
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
38
|
+
const DEFAULT_API_VERSION = "v60.0";
|
|
39
|
+
const SAFE_OBJECT_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
40
|
+
const SAFE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
41
|
+
|
|
42
|
+
export interface SalesforceTransportRequest {
|
|
43
|
+
path: string;
|
|
44
|
+
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
|
45
|
+
body?: unknown;
|
|
46
|
+
headers?: Record<string, string>;
|
|
47
|
+
retrySafe?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SalesforceTransportResponse {
|
|
51
|
+
status: number;
|
|
52
|
+
body?: unknown;
|
|
53
|
+
headers?: Record<string, string | undefined>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SalesforceTransport {
|
|
57
|
+
request(
|
|
58
|
+
input: SalesforceTransportRequest,
|
|
59
|
+
): Promise<SalesforceTransportResponse>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SalesforceCrmAdapterOptions {
|
|
63
|
+
connection: WorkspaceConnectionForApp;
|
|
64
|
+
transport: SalesforceTransport;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CreateSalesforceCrmAdapterOptions {
|
|
68
|
+
connectionId?: string;
|
|
69
|
+
userEmail?: string;
|
|
70
|
+
orgId?: string | null;
|
|
71
|
+
transport?: SalesforceTransport;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type SalesforceField = {
|
|
75
|
+
name?: string;
|
|
76
|
+
label?: string;
|
|
77
|
+
type?: string;
|
|
78
|
+
accessible?: boolean;
|
|
79
|
+
createable?: boolean;
|
|
80
|
+
updateable?: boolean;
|
|
81
|
+
nillable?: boolean;
|
|
82
|
+
defaultedOnCreate?: boolean;
|
|
83
|
+
calculated?: boolean;
|
|
84
|
+
encrypted?: boolean;
|
|
85
|
+
restrictedPicklist?: boolean;
|
|
86
|
+
picklistValues?: Array<{
|
|
87
|
+
value?: string;
|
|
88
|
+
label?: string;
|
|
89
|
+
active?: boolean;
|
|
90
|
+
}>;
|
|
91
|
+
referenceTo?: string[];
|
|
92
|
+
relationshipName?: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type SalesforceObjectDescription = {
|
|
96
|
+
name?: string;
|
|
97
|
+
label?: string;
|
|
98
|
+
labelPlural?: string;
|
|
99
|
+
custom?: boolean;
|
|
100
|
+
queryable?: boolean;
|
|
101
|
+
searchable?: boolean;
|
|
102
|
+
createable?: boolean;
|
|
103
|
+
updateable?: boolean;
|
|
104
|
+
deletable?: boolean;
|
|
105
|
+
fields?: SalesforceField[];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type SalesforceGlobalObject = {
|
|
109
|
+
name?: string;
|
|
110
|
+
label?: string;
|
|
111
|
+
custom?: boolean;
|
|
112
|
+
queryable?: boolean;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
type SalesforceRecord = Record<string, unknown> & {
|
|
116
|
+
Id?: string;
|
|
117
|
+
SystemModstamp?: string;
|
|
118
|
+
LastModifiedDate?: string;
|
|
119
|
+
IsDeleted?: boolean;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
type SalesforceQueryResponse = {
|
|
123
|
+
records?: SalesforceRecord[];
|
|
124
|
+
done?: boolean;
|
|
125
|
+
nextRecordsUrl?: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
function nonEmpty(value: string | undefined | null): string | undefined {
|
|
129
|
+
const trimmed = value?.trim();
|
|
130
|
+
return trimmed || undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function uniqueStrings(values: readonly string[]): string[] {
|
|
134
|
+
return Array.from(
|
|
135
|
+
new Set(values.map((value) => value.trim()).filter(Boolean)),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function assertSafeObjectType(value: string): string {
|
|
140
|
+
const objectType = nonEmpty(value);
|
|
141
|
+
if (!objectType || !SAFE_OBJECT_NAME.test(objectType)) {
|
|
142
|
+
throw new Error("Salesforce objectType contains unsupported characters.");
|
|
143
|
+
}
|
|
144
|
+
return objectType;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function safeFields(fields: readonly string[]): string[] {
|
|
148
|
+
return uniqueStrings(fields).filter((field) => SAFE_FIELD_NAME.test(field));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function objectKind(objectType: string): CrmObjectKind {
|
|
152
|
+
switch (objectType.toLowerCase()) {
|
|
153
|
+
case "account":
|
|
154
|
+
return "account";
|
|
155
|
+
case "contact":
|
|
156
|
+
case "lead":
|
|
157
|
+
return "person";
|
|
158
|
+
case "opportunity":
|
|
159
|
+
return "opportunity";
|
|
160
|
+
case "task":
|
|
161
|
+
return "task";
|
|
162
|
+
case "event":
|
|
163
|
+
case "case":
|
|
164
|
+
return "activity";
|
|
165
|
+
default:
|
|
166
|
+
return "custom";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function displayFields(objectType: string): string[] {
|
|
171
|
+
switch (objectType.toLowerCase()) {
|
|
172
|
+
case "account":
|
|
173
|
+
return ["Name", "Website"];
|
|
174
|
+
case "contact":
|
|
175
|
+
return ["FirstName", "LastName", "Email"];
|
|
176
|
+
case "opportunity":
|
|
177
|
+
return ["Name"];
|
|
178
|
+
case "lead":
|
|
179
|
+
return ["FirstName", "LastName", "Email"];
|
|
180
|
+
default:
|
|
181
|
+
return ["Name"];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function searchField(objectType: string): string {
|
|
186
|
+
switch (objectType.toLowerCase()) {
|
|
187
|
+
case "task":
|
|
188
|
+
case "event":
|
|
189
|
+
case "case":
|
|
190
|
+
return "Subject";
|
|
191
|
+
default:
|
|
192
|
+
return "Name";
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function fieldValueType(
|
|
197
|
+
field: SalesforceField,
|
|
198
|
+
): CrmFieldDefinition["valueType"] {
|
|
199
|
+
switch (field.type?.toLowerCase()) {
|
|
200
|
+
case "boolean":
|
|
201
|
+
return "boolean";
|
|
202
|
+
case "currency":
|
|
203
|
+
return "currency";
|
|
204
|
+
case "percent":
|
|
205
|
+
return "percent";
|
|
206
|
+
case "double":
|
|
207
|
+
case "int":
|
|
208
|
+
case "long":
|
|
209
|
+
return "number";
|
|
210
|
+
case "date":
|
|
211
|
+
return "date";
|
|
212
|
+
case "datetime":
|
|
213
|
+
case "time":
|
|
214
|
+
return "datetime";
|
|
215
|
+
case "picklist":
|
|
216
|
+
return "enum";
|
|
217
|
+
case "multipicklist":
|
|
218
|
+
return "multi-enum";
|
|
219
|
+
case "reference":
|
|
220
|
+
return "reference";
|
|
221
|
+
case "address":
|
|
222
|
+
case "location":
|
|
223
|
+
case "json":
|
|
224
|
+
return "json";
|
|
225
|
+
default:
|
|
226
|
+
return "string";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isSensitiveField(field: SalesforceField): boolean {
|
|
231
|
+
const name = field.name?.toLowerCase() ?? "";
|
|
232
|
+
return (
|
|
233
|
+
field.encrypted === true || /(password|secret|token|ssn|social)/.test(name)
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function scalarValue(value: unknown): CrmValue | undefined {
|
|
238
|
+
if (
|
|
239
|
+
value === null ||
|
|
240
|
+
typeof value === "string" ||
|
|
241
|
+
typeof value === "number" ||
|
|
242
|
+
typeof value === "boolean"
|
|
243
|
+
) {
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
if (Array.isArray(value)) {
|
|
247
|
+
const entries = value.filter(
|
|
248
|
+
(item): item is string | number | boolean | null =>
|
|
249
|
+
item === null ||
|
|
250
|
+
typeof item === "string" ||
|
|
251
|
+
typeof item === "number" ||
|
|
252
|
+
typeof item === "boolean",
|
|
253
|
+
);
|
|
254
|
+
return entries.length === value.length ? entries : undefined;
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function encodedCursor(value: unknown): string {
|
|
260
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
261
|
+
let binary = "";
|
|
262
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
263
|
+
return btoa(binary)
|
|
264
|
+
.replace(/\+/g, "-")
|
|
265
|
+
.replace(/\//g, "_")
|
|
266
|
+
.replace(/=+$/, "");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function decodeCursor<T>(cursor: string | undefined): T | null {
|
|
270
|
+
if (!cursor) return null;
|
|
271
|
+
try {
|
|
272
|
+
const padded = cursor
|
|
273
|
+
.replace(/-/g, "+")
|
|
274
|
+
.replace(/_/g, "/")
|
|
275
|
+
.padEnd(Math.ceil(cursor.length / 4) * 4, "=");
|
|
276
|
+
const binary = atob(padded);
|
|
277
|
+
return JSON.parse(
|
|
278
|
+
new TextDecoder().decode(
|
|
279
|
+
Uint8Array.from(binary, (character) => character.charCodeAt(0)),
|
|
280
|
+
),
|
|
281
|
+
) as T;
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function scopeAllows(
|
|
288
|
+
connection: WorkspaceConnectionForApp,
|
|
289
|
+
objectType: string,
|
|
290
|
+
operation: "read" | "write",
|
|
291
|
+
): boolean {
|
|
292
|
+
const scopes = [
|
|
293
|
+
...connection.scopes,
|
|
294
|
+
...(connection.explicitGrant?.scopes ?? []),
|
|
295
|
+
].map((scope) => scope.toLowerCase());
|
|
296
|
+
if (scopes.length === 0) return operation === "read";
|
|
297
|
+
const object = objectType.toLowerCase();
|
|
298
|
+
return scopes.some(
|
|
299
|
+
(scope) =>
|
|
300
|
+
scope === "all" ||
|
|
301
|
+
scope === "api" ||
|
|
302
|
+
scope === "crm" ||
|
|
303
|
+
scope === `crm.${operation}` ||
|
|
304
|
+
scope === `crm.objects.${operation}` ||
|
|
305
|
+
scope === `crm.objects.${object}.${operation}` ||
|
|
306
|
+
(operation === "write" && scope.endsWith(".write")),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function configurationValue(
|
|
311
|
+
connection: WorkspaceConnectionForApp,
|
|
312
|
+
keys: string[],
|
|
313
|
+
): string | undefined {
|
|
314
|
+
const sources = [connection.explicitGrant?.config, connection.config];
|
|
315
|
+
for (const source of sources) {
|
|
316
|
+
for (const key of keys) {
|
|
317
|
+
const value = source?.[key];
|
|
318
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function resolveInstanceUrl(connection: WorkspaceConnectionForApp): string {
|
|
325
|
+
const configured = configurationValue(connection, [
|
|
326
|
+
"instanceUrl",
|
|
327
|
+
"instance_url",
|
|
328
|
+
"salesforceInstanceUrl",
|
|
329
|
+
]);
|
|
330
|
+
if (!configured) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
"Salesforce workspace connection is missing a trusted instance URL in connection metadata.",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
let url: URL;
|
|
336
|
+
try {
|
|
337
|
+
url = new URL(configured);
|
|
338
|
+
} catch {
|
|
339
|
+
throw new Error(
|
|
340
|
+
"Salesforce workspace connection has an invalid instance URL.",
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const host = url.hostname.toLowerCase();
|
|
344
|
+
const trustedHost =
|
|
345
|
+
host === "salesforce.com" || host.endsWith(".salesforce.com");
|
|
346
|
+
if (
|
|
347
|
+
url.protocol !== "https:" ||
|
|
348
|
+
!trustedHost ||
|
|
349
|
+
url.port ||
|
|
350
|
+
url.username ||
|
|
351
|
+
url.password ||
|
|
352
|
+
url.pathname !== "/" ||
|
|
353
|
+
url.search ||
|
|
354
|
+
url.hash
|
|
355
|
+
) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
"Salesforce workspace connection must use an HTTPS Salesforce instance origin.",
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
return url.origin;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function actorIdForConnection(connection: WorkspaceConnectionForApp): string {
|
|
364
|
+
const identityUrl = configurationValue(connection, ["salesforceIdentityUrl"]);
|
|
365
|
+
if (!identityUrl) return connection.ownerEmail;
|
|
366
|
+
try {
|
|
367
|
+
const url = new URL(identityUrl);
|
|
368
|
+
const host = url.hostname.toLowerCase();
|
|
369
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
370
|
+
const trustedHost =
|
|
371
|
+
host === "salesforce.com" || host.endsWith(".salesforce.com");
|
|
372
|
+
const organizationId = configurationValue(connection, [
|
|
373
|
+
"salesforceOrganizationId",
|
|
374
|
+
]);
|
|
375
|
+
if (
|
|
376
|
+
url.protocol === "https:" &&
|
|
377
|
+
trustedHost &&
|
|
378
|
+
!url.port &&
|
|
379
|
+
!url.username &&
|
|
380
|
+
!url.password &&
|
|
381
|
+
parts[0] === "id" &&
|
|
382
|
+
parts[1] &&
|
|
383
|
+
parts[2] &&
|
|
384
|
+
(!organizationId || organizationId === parts[1])
|
|
385
|
+
) {
|
|
386
|
+
return parts[2];
|
|
387
|
+
}
|
|
388
|
+
} catch {
|
|
389
|
+
return connection.ownerEmail;
|
|
390
|
+
}
|
|
391
|
+
return connection.ownerEmail;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function resolveApiVersion(connection: WorkspaceConnectionForApp): string {
|
|
395
|
+
const value = configurationValue(connection, [
|
|
396
|
+
"apiVersion",
|
|
397
|
+
"salesforceApiVersion",
|
|
398
|
+
]);
|
|
399
|
+
return value && /^v\d{2}\.\d$/.test(value) ? value : DEFAULT_API_VERSION;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function hashPermissions(fields: SalesforceField[]): string | undefined {
|
|
403
|
+
const values = fields
|
|
404
|
+
.map((field) => {
|
|
405
|
+
const name = nonEmpty(field.name);
|
|
406
|
+
return name
|
|
407
|
+
? `${name}:${field.accessible === false ? 0 : 1}:${field.createable === true ? 1 : 0}:${field.updateable === true ? 1 : 0}`
|
|
408
|
+
: "";
|
|
409
|
+
})
|
|
410
|
+
.filter(Boolean)
|
|
411
|
+
.sort();
|
|
412
|
+
if (!values.length) return undefined;
|
|
413
|
+
let hash = 2_166_136_261;
|
|
414
|
+
for (const character of values.join("|")) {
|
|
415
|
+
hash ^= character.charCodeAt(0);
|
|
416
|
+
hash = Math.imul(hash, 16_777_619);
|
|
417
|
+
}
|
|
418
|
+
return `sf-fp-${(hash >>> 0).toString(36)}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function stableFingerprint(value: string): string {
|
|
422
|
+
let hash = 2_166_136_261;
|
|
423
|
+
for (const character of value) {
|
|
424
|
+
hash ^= character.charCodeAt(0);
|
|
425
|
+
hash = Math.imul(hash, 16_777_619);
|
|
426
|
+
}
|
|
427
|
+
return (hash >>> 0).toString(36);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function accessScopeFor(
|
|
431
|
+
connection: WorkspaceConnectionForApp,
|
|
432
|
+
objectType: string,
|
|
433
|
+
fields: SalesforceField[] = [],
|
|
434
|
+
): CrmAccessScope {
|
|
435
|
+
const grantId = connection.appAccess.grantId ?? undefined;
|
|
436
|
+
const oauthConnection =
|
|
437
|
+
configurationValue(connection, ["credentialMode"]) === "oauth";
|
|
438
|
+
const mode =
|
|
439
|
+
oauthConnection ||
|
|
440
|
+
[
|
|
441
|
+
...connection.credentialRefs,
|
|
442
|
+
...(connection.explicitGrant?.credentialRefs ?? []),
|
|
443
|
+
].some((ref) => ref.scope === "user")
|
|
444
|
+
? "user"
|
|
445
|
+
: "service-account";
|
|
446
|
+
const configuredVisibility = configurationValue(connection, [
|
|
447
|
+
"recordVisibility",
|
|
448
|
+
"record_visibility",
|
|
449
|
+
]);
|
|
450
|
+
const recordVisibility =
|
|
451
|
+
configuredVisibility === "actor" ||
|
|
452
|
+
configuredVisibility === "cohort" ||
|
|
453
|
+
configuredVisibility === "workspace"
|
|
454
|
+
? configuredVisibility
|
|
455
|
+
: mode === "user"
|
|
456
|
+
? "actor"
|
|
457
|
+
: "unknown";
|
|
458
|
+
const sharingFingerprint =
|
|
459
|
+
configurationValue(connection, [
|
|
460
|
+
"sharingFingerprint",
|
|
461
|
+
"sharing_fingerprint",
|
|
462
|
+
]) ??
|
|
463
|
+
`sf-share-${stableFingerprint(
|
|
464
|
+
`${connection.id}:${grantId ?? connection.appAccess.mode}:${objectType}:${recordVisibility}`,
|
|
465
|
+
)}`;
|
|
466
|
+
return {
|
|
467
|
+
key: `${connection.id}:${grantId ?? connection.appAccess.mode}`,
|
|
468
|
+
actorId:
|
|
469
|
+
mode === "user"
|
|
470
|
+
? actorIdForConnection(connection)
|
|
471
|
+
: connection.ownerEmail,
|
|
472
|
+
grantId,
|
|
473
|
+
mode,
|
|
474
|
+
objectReadable: scopeAllows(connection, objectType, "read"),
|
|
475
|
+
objectCreateable: scopeAllows(connection, objectType, "write"),
|
|
476
|
+
objectUpdateable: scopeAllows(connection, objectType, "write"),
|
|
477
|
+
objectDeleteable: false,
|
|
478
|
+
recordVisibility,
|
|
479
|
+
...(hashPermissions(fields)
|
|
480
|
+
? { fieldPermissionsHash: hashPermissions(fields) }
|
|
481
|
+
: {}),
|
|
482
|
+
sharingFingerprint,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function definitionFrom(
|
|
487
|
+
connection: WorkspaceConnectionForApp,
|
|
488
|
+
objectType: string,
|
|
489
|
+
description: SalesforceObjectDescription,
|
|
490
|
+
): CrmObjectDefinition {
|
|
491
|
+
const fields = description.fields ?? [];
|
|
492
|
+
const scope = accessScopeFor(connection, objectType, fields);
|
|
493
|
+
return {
|
|
494
|
+
connectionId: connection.id,
|
|
495
|
+
provider: "salesforce",
|
|
496
|
+
...(connection.accountId ? { accountId: connection.accountId } : {}),
|
|
497
|
+
actorId: actorIdForConnection(connection),
|
|
498
|
+
objectType,
|
|
499
|
+
kind: objectKind(objectType),
|
|
500
|
+
label: description.label || objectType,
|
|
501
|
+
pluralLabel: description.labelPlural || description.label || objectType,
|
|
502
|
+
custom: description.custom === true || objectType.endsWith("__c"),
|
|
503
|
+
queryable: description.queryable !== false && scope.objectReadable,
|
|
504
|
+
searchable: description.searchable !== false && scope.objectReadable,
|
|
505
|
+
createable: description.createable === true && scope.objectCreateable,
|
|
506
|
+
updateable: description.updateable === true && scope.objectUpdateable,
|
|
507
|
+
deleteable: false,
|
|
508
|
+
fields: fields
|
|
509
|
+
.map((field): CrmFieldDefinition | null => {
|
|
510
|
+
const name = nonEmpty(field.name);
|
|
511
|
+
if (!name) return null;
|
|
512
|
+
const sensitive = isSensitiveField(field);
|
|
513
|
+
return {
|
|
514
|
+
name,
|
|
515
|
+
label: field.label || name,
|
|
516
|
+
valueType: fieldValueType(field),
|
|
517
|
+
storagePolicy: sensitive ? "redacted" : "remote-only",
|
|
518
|
+
sensitive,
|
|
519
|
+
readable:
|
|
520
|
+
field.accessible !== false && !sensitive && scope.objectReadable,
|
|
521
|
+
createable:
|
|
522
|
+
!sensitive &&
|
|
523
|
+
field.createable === true &&
|
|
524
|
+
field.calculated !== true &&
|
|
525
|
+
scope.objectCreateable,
|
|
526
|
+
updateable:
|
|
527
|
+
!sensitive &&
|
|
528
|
+
field.updateable === true &&
|
|
529
|
+
field.calculated !== true &&
|
|
530
|
+
scope.objectUpdateable,
|
|
531
|
+
required:
|
|
532
|
+
field.nillable === false &&
|
|
533
|
+
field.defaultedOnCreate !== true &&
|
|
534
|
+
field.calculated !== true,
|
|
535
|
+
...(field.picklistValues
|
|
536
|
+
? {
|
|
537
|
+
options: field.picklistValues
|
|
538
|
+
.filter((option) => nonEmpty(option.value))
|
|
539
|
+
.map((option) => ({
|
|
540
|
+
value: option.value!.trim(),
|
|
541
|
+
label: option.label || option.value!.trim(),
|
|
542
|
+
...(option.active === false ? { active: false } : {}),
|
|
543
|
+
})),
|
|
544
|
+
}
|
|
545
|
+
: {}),
|
|
546
|
+
...(field.referenceTo?.[0]
|
|
547
|
+
? { referencedObjectType: field.referenceTo[0] }
|
|
548
|
+
: {}),
|
|
549
|
+
};
|
|
550
|
+
})
|
|
551
|
+
.filter((field): field is CrmFieldDefinition => field !== null),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function recordRef(
|
|
556
|
+
connection: WorkspaceConnectionForApp,
|
|
557
|
+
objectType: string,
|
|
558
|
+
remoteId: string,
|
|
559
|
+
): CrmRecordRef {
|
|
560
|
+
return {
|
|
561
|
+
connectionId: connection.id,
|
|
562
|
+
provider: "salesforce",
|
|
563
|
+
...(connection.accountId ? { accountId: connection.accountId } : {}),
|
|
564
|
+
actorId: actorIdForConnection(connection),
|
|
565
|
+
objectType,
|
|
566
|
+
kind: objectKind(objectType),
|
|
567
|
+
remoteId,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function projectRecord(
|
|
572
|
+
connection: WorkspaceConnectionForApp,
|
|
573
|
+
objectType: string,
|
|
574
|
+
source: SalesforceRecord,
|
|
575
|
+
fields: string[],
|
|
576
|
+
definition?: SalesforceObjectDescription,
|
|
577
|
+
): CrmRecord | null {
|
|
578
|
+
const remoteId = nonEmpty(source.Id);
|
|
579
|
+
if (!remoteId) return null;
|
|
580
|
+
const allowed = safeFields(fields);
|
|
581
|
+
const values: Record<string, CrmValue> = {};
|
|
582
|
+
for (const field of allowed) {
|
|
583
|
+
const value = scalarValue(source[field]);
|
|
584
|
+
if (value !== undefined) values[field] = value;
|
|
585
|
+
}
|
|
586
|
+
const displayName =
|
|
587
|
+
displayFields(objectType)
|
|
588
|
+
.map((field) => values[field])
|
|
589
|
+
.filter(
|
|
590
|
+
(value): value is string | number | boolean =>
|
|
591
|
+
typeof value === "string" ||
|
|
592
|
+
typeof value === "number" ||
|
|
593
|
+
typeof value === "boolean",
|
|
594
|
+
)
|
|
595
|
+
.map(String)
|
|
596
|
+
.filter(Boolean)
|
|
597
|
+
.join(" ") || remoteId;
|
|
598
|
+
const remoteRevision =
|
|
599
|
+
nonEmpty(source.SystemModstamp) ?? nonEmpty(source.LastModifiedDate);
|
|
600
|
+
const observedAt = new Date().toISOString();
|
|
601
|
+
return {
|
|
602
|
+
ref: recordRef(connection, objectType, remoteId),
|
|
603
|
+
displayName,
|
|
604
|
+
fields: values,
|
|
605
|
+
...(remoteRevision
|
|
606
|
+
? { remoteRevision, remoteUpdatedAt: remoteRevision }
|
|
607
|
+
: {}),
|
|
608
|
+
deleted: source.IsDeleted === true,
|
|
609
|
+
accessScope: accessScopeFor(connection, objectType, definition?.fields),
|
|
610
|
+
provenance: Object.keys(values).map((fieldName) => ({
|
|
611
|
+
provider: "salesforce",
|
|
612
|
+
connectionId: connection.id,
|
|
613
|
+
objectType,
|
|
614
|
+
remoteId,
|
|
615
|
+
fieldName,
|
|
616
|
+
...(remoteRevision ? { remoteRevision } : {}),
|
|
617
|
+
observedAt,
|
|
618
|
+
})),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function soqlString(value: string): string {
|
|
623
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function soqlDateTime(value: string): string | undefined {
|
|
627
|
+
const timestamp = Date.parse(value);
|
|
628
|
+
return Number.isNaN(timestamp)
|
|
629
|
+
? undefined
|
|
630
|
+
: new Date(timestamp).toISOString();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function queryPath(query: string, includeDeleted = false): string {
|
|
634
|
+
return `/query${includeDeleted ? "All" : ""}?q=${encodeURIComponent(query)}`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function headerValue(
|
|
638
|
+
headers: Record<string, string | undefined> | undefined,
|
|
639
|
+
name: string,
|
|
640
|
+
): string | undefined {
|
|
641
|
+
if (!headers) return undefined;
|
|
642
|
+
const expected = name.toLowerCase();
|
|
643
|
+
return Object.entries(headers).find(
|
|
644
|
+
([key]) => key.toLowerCase() === expected,
|
|
645
|
+
)?.[1];
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function salesforceRetryDelayMs(
|
|
649
|
+
response: SalesforceTransportResponse,
|
|
650
|
+
attempt: number,
|
|
651
|
+
elapsedMs: number,
|
|
652
|
+
now = Date.now(),
|
|
653
|
+
): number {
|
|
654
|
+
const retryAfter = headerValue(response.headers, "retry-after")?.trim();
|
|
655
|
+
const numeric = retryAfter ? Number(retryAfter) : Number.NaN;
|
|
656
|
+
const requested = Number.isFinite(numeric)
|
|
657
|
+
? Math.max(0, numeric * 1_000)
|
|
658
|
+
: retryAfter
|
|
659
|
+
? Math.max(0, Date.parse(retryAfter) - now)
|
|
660
|
+
: 100 * 2 ** attempt;
|
|
661
|
+
return Math.max(
|
|
662
|
+
0,
|
|
663
|
+
Math.min(
|
|
664
|
+
requested,
|
|
665
|
+
MAX_SINGLE_RETRY_DELAY_MS,
|
|
666
|
+
MAX_RETRY_DELAY_MS - elapsedMs,
|
|
667
|
+
),
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
class SalesforceApiError extends Error {
|
|
672
|
+
constructor(
|
|
673
|
+
readonly status: number,
|
|
674
|
+
message: string,
|
|
675
|
+
) {
|
|
676
|
+
super(message);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function salesforceErrorMessage(status: number): string {
|
|
681
|
+
if (status === 401 || status === 403)
|
|
682
|
+
return "Salesforce authentication or authorization failed.";
|
|
683
|
+
if (status === 404) return "Salesforce resource was not found.";
|
|
684
|
+
if (status === 429) return "Salesforce rate limit exceeded.";
|
|
685
|
+
if (status >= 500) return "Salesforce service is temporarily unavailable.";
|
|
686
|
+
return "Salesforce request failed.";
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function isNotFound(error: unknown): boolean {
|
|
690
|
+
return error instanceof SalesforceApiError && error.status === 404;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function isSafeContinuation(path: string): boolean {
|
|
694
|
+
return /^\/services\/data\/v\d{2}\.\d\/query(?:All)?\/.+/.test(path);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function assertOwnedRecord(
|
|
698
|
+
connection: WorkspaceConnectionForApp,
|
|
699
|
+
record: CrmRecordRef,
|
|
700
|
+
): void {
|
|
701
|
+
if (
|
|
702
|
+
record.provider !== "salesforce" ||
|
|
703
|
+
record.connectionId !== connection.id
|
|
704
|
+
) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
"Salesforce record does not belong to this workspace connection.",
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
class FetchSalesforceTransport implements SalesforceTransport {
|
|
712
|
+
constructor(
|
|
713
|
+
private readonly instanceOrigin: string,
|
|
714
|
+
private readonly apiVersion: string,
|
|
715
|
+
private readonly accessToken: string,
|
|
716
|
+
) {}
|
|
717
|
+
|
|
718
|
+
async request(
|
|
719
|
+
input: SalesforceTransportRequest,
|
|
720
|
+
): Promise<SalesforceTransportResponse> {
|
|
721
|
+
const path = input.path.startsWith("/services/data/")
|
|
722
|
+
? input.path
|
|
723
|
+
: `/services/data/${this.apiVersion}${input.path}`;
|
|
724
|
+
const response = await fetch(`${this.instanceOrigin}${path}`, {
|
|
725
|
+
method: input.method ?? "GET",
|
|
726
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
727
|
+
headers: {
|
|
728
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
729
|
+
...(input.body === undefined
|
|
730
|
+
? {}
|
|
731
|
+
: { "Content-Type": "application/json" }),
|
|
732
|
+
...input.headers,
|
|
733
|
+
},
|
|
734
|
+
...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
|
|
735
|
+
});
|
|
736
|
+
const text = await response.text();
|
|
737
|
+
let body: unknown;
|
|
738
|
+
if (text) {
|
|
739
|
+
try {
|
|
740
|
+
body = JSON.parse(text);
|
|
741
|
+
} catch {
|
|
742
|
+
body = text;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
status: response.status,
|
|
747
|
+
body,
|
|
748
|
+
headers: {
|
|
749
|
+
"retry-after": response.headers.get("retry-after") ?? undefined,
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export class SalesforceCrmAdapter implements CrmAdapter {
|
|
756
|
+
readonly connection: CrmConnectionRef;
|
|
757
|
+
readonly capabilities: CrmAdapterCapabilities = {
|
|
758
|
+
schemaDiscovery: true,
|
|
759
|
+
customObjects: true,
|
|
760
|
+
search: true,
|
|
761
|
+
incrementalSync: true,
|
|
762
|
+
deletedRecordSync: true,
|
|
763
|
+
conditionalMutations: false,
|
|
764
|
+
labeledRelationships: true,
|
|
765
|
+
perFieldPermissions: true,
|
|
766
|
+
perRecordPermissions: true,
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
private readonly descriptions = new Map<
|
|
770
|
+
string,
|
|
771
|
+
SalesforceObjectDescription
|
|
772
|
+
>();
|
|
773
|
+
|
|
774
|
+
constructor(private readonly options: SalesforceCrmAdapterOptions) {
|
|
775
|
+
this.connection = {
|
|
776
|
+
connectionId: options.connection.id,
|
|
777
|
+
provider: "salesforce",
|
|
778
|
+
...(options.connection.accountId
|
|
779
|
+
? { accountId: options.connection.accountId }
|
|
780
|
+
: {}),
|
|
781
|
+
actorId: actorIdForConnection(options.connection),
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private get workspaceConnection(): WorkspaceConnectionForApp {
|
|
786
|
+
return this.options.connection;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
private async request<T>(input: SalesforceTransportRequest): Promise<T> {
|
|
790
|
+
const readRetrySafe =
|
|
791
|
+
input.retrySafe === true ||
|
|
792
|
+
input.method === undefined ||
|
|
793
|
+
input.method === "GET";
|
|
794
|
+
let elapsedDelayMs = 0;
|
|
795
|
+
for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
|
|
796
|
+
const response = await this.options.transport.request(input);
|
|
797
|
+
if (response.status >= 200 && response.status < 300) {
|
|
798
|
+
return (response.body ?? {}) as T;
|
|
799
|
+
}
|
|
800
|
+
const retryable =
|
|
801
|
+
attempt + 1 < MAX_RETRY_ATTEMPTS &&
|
|
802
|
+
readRetrySafe &&
|
|
803
|
+
(response.status === 429 || response.status >= 500) &&
|
|
804
|
+
elapsedDelayMs < MAX_RETRY_DELAY_MS;
|
|
805
|
+
if (!retryable) {
|
|
806
|
+
throw new SalesforceApiError(
|
|
807
|
+
response.status,
|
|
808
|
+
`Salesforce API error ${response.status}: ${salesforceErrorMessage(response.status)}`,
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
const delay = salesforceRetryDelayMs(response, attempt, elapsedDelayMs);
|
|
812
|
+
if (delay > 0) {
|
|
813
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delay));
|
|
814
|
+
elapsedDelayMs += delay;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
throw new Error("Salesforce retry budget was exhausted.");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
private async describe(
|
|
821
|
+
objectType: string,
|
|
822
|
+
): Promise<SalesforceObjectDescription> {
|
|
823
|
+
const normalized = assertSafeObjectType(objectType);
|
|
824
|
+
const cached = this.descriptions.get(normalized);
|
|
825
|
+
if (cached) return cached;
|
|
826
|
+
const description = await this.request<SalesforceObjectDescription>({
|
|
827
|
+
path: `/sobjects/${encodeURIComponent(normalized)}/describe`,
|
|
828
|
+
});
|
|
829
|
+
this.descriptions.set(normalized, description);
|
|
830
|
+
return description;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async discoverObjects(): Promise<CrmObjectDefinition[]> {
|
|
834
|
+
const global = await this.request<{ sobjects?: SalesforceGlobalObject[] }>({
|
|
835
|
+
path: "/sobjects/",
|
|
836
|
+
});
|
|
837
|
+
const types = uniqueStrings([
|
|
838
|
+
...CORE_OBJECT_TYPES,
|
|
839
|
+
...(global.sobjects ?? [])
|
|
840
|
+
.filter((object) => object.queryable !== false)
|
|
841
|
+
.map((object) => object.name ?? ""),
|
|
842
|
+
])
|
|
843
|
+
.filter((type) => SAFE_OBJECT_NAME.test(type))
|
|
844
|
+
.slice(0, MAX_DISCOVERED_OBJECTS);
|
|
845
|
+
const definitions: CrmObjectDefinition[] = [];
|
|
846
|
+
for (let index = 0; index < types.length; index += DISCOVERY_CONCURRENCY) {
|
|
847
|
+
definitions.push(
|
|
848
|
+
...(await Promise.all(
|
|
849
|
+
types
|
|
850
|
+
.slice(index, index + DISCOVERY_CONCURRENCY)
|
|
851
|
+
.map((type) => this.describeObject(type)),
|
|
852
|
+
)),
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
return definitions;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async describeObject(objectType: string): Promise<CrmObjectDefinition> {
|
|
859
|
+
const normalized = assertSafeObjectType(objectType);
|
|
860
|
+
const description = await this.describe(normalized);
|
|
861
|
+
return definitionFrom(this.workspaceConnection, normalized, description);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async getAccessScope(objectType: string): Promise<CrmAccessScope> {
|
|
865
|
+
const normalized = assertSafeObjectType(objectType);
|
|
866
|
+
const description = await this.describe(normalized);
|
|
867
|
+
return accessScopeFor(
|
|
868
|
+
this.workspaceConnection,
|
|
869
|
+
normalized,
|
|
870
|
+
description?.fields,
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
private async query(path: string): Promise<SalesforceQueryResponse> {
|
|
875
|
+
return this.request<SalesforceQueryResponse>({ path });
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
private selectFields(fields: string[]): string[] {
|
|
879
|
+
return uniqueStrings([
|
|
880
|
+
"Id",
|
|
881
|
+
"SystemModstamp",
|
|
882
|
+
"LastModifiedDate",
|
|
883
|
+
"IsDeleted",
|
|
884
|
+
...safeFields(fields),
|
|
885
|
+
]);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private scopeWhere(input: {
|
|
889
|
+
objectType: string;
|
|
890
|
+
pipelineIds?: string[];
|
|
891
|
+
ownerIds?: string[];
|
|
892
|
+
updatedAfter?: string;
|
|
893
|
+
}): string[] {
|
|
894
|
+
const filters: string[] = [];
|
|
895
|
+
if (input.objectType === "Opportunity" && input.pipelineIds?.length) {
|
|
896
|
+
const ids = uniqueStrings(input.pipelineIds).map(soqlString);
|
|
897
|
+
if (ids.length) filters.push(`RecordTypeId IN (${ids.join(",")})`);
|
|
898
|
+
}
|
|
899
|
+
if (input.ownerIds?.length) {
|
|
900
|
+
const ids = uniqueStrings(input.ownerIds).map(soqlString);
|
|
901
|
+
if (ids.length) filters.push(`OwnerId IN (${ids.join(",")})`);
|
|
902
|
+
}
|
|
903
|
+
const updatedAfter = input.updatedAfter
|
|
904
|
+
? soqlDateTime(input.updatedAfter)
|
|
905
|
+
: undefined;
|
|
906
|
+
if (updatedAfter) filters.push(`SystemModstamp >= ${updatedAfter}`);
|
|
907
|
+
return filters;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async syncPage(
|
|
911
|
+
input: Parameters<CrmAdapter["syncPage"]>[0],
|
|
912
|
+
): Promise<CrmSyncPage> {
|
|
913
|
+
const objectType = assertSafeObjectType(input.scope.objectType);
|
|
914
|
+
if (input.scope.associatedRecordIds?.length) {
|
|
915
|
+
throw new Error(
|
|
916
|
+
"Salesforce associated-record cohorts are not enabled; scope the sync with recordIds, ownerIds, or Opportunity record-type IDs.",
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
const limit = Math.max(1, Math.min(MAX_PAGE_SIZE, input.limit));
|
|
920
|
+
const fields = safeFields(input.fieldAllowList);
|
|
921
|
+
const description = await this.describe(objectType);
|
|
922
|
+
const recordIds = uniqueStrings(input.scope.recordIds ?? []);
|
|
923
|
+
const cursor = decodeCursor<{ index?: number; next?: string }>(
|
|
924
|
+
input.cursor,
|
|
925
|
+
);
|
|
926
|
+
let page: SalesforceQueryResponse;
|
|
927
|
+
let nextCursor: string | undefined;
|
|
928
|
+
if (cursor?.next && isSafeContinuation(cursor.next)) {
|
|
929
|
+
page = await this.query(cursor.next);
|
|
930
|
+
nextCursor =
|
|
931
|
+
page.nextRecordsUrl && isSafeContinuation(page.nextRecordsUrl)
|
|
932
|
+
? encodedCursor({ next: page.nextRecordsUrl })
|
|
933
|
+
: undefined;
|
|
934
|
+
} else if (recordIds.length) {
|
|
935
|
+
const index = Math.max(0, Math.min(cursor?.index ?? 0, recordIds.length));
|
|
936
|
+
const ids = recordIds.slice(index, index + limit).map(soqlString);
|
|
937
|
+
if (!ids.length)
|
|
938
|
+
return { records: [], relationships: [], complete: true };
|
|
939
|
+
page = await this.query(
|
|
940
|
+
queryPath(
|
|
941
|
+
`SELECT ${this.selectFields(fields).join(",")} FROM ${objectType} WHERE Id IN (${ids.join(",")}) LIMIT ${limit}`,
|
|
942
|
+
input.scope.includeDeleted,
|
|
943
|
+
),
|
|
944
|
+
);
|
|
945
|
+
const offset = index + limit;
|
|
946
|
+
nextCursor =
|
|
947
|
+
offset < recordIds.length
|
|
948
|
+
? encodedCursor({ index: offset })
|
|
949
|
+
: undefined;
|
|
950
|
+
} else {
|
|
951
|
+
const where = this.scopeWhere({ ...input.scope, objectType });
|
|
952
|
+
page = await this.query(
|
|
953
|
+
queryPath(
|
|
954
|
+
`SELECT ${this.selectFields(fields).join(",")} FROM ${objectType}${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY SystemModstamp ASC, Id ASC LIMIT ${limit}`,
|
|
955
|
+
input.scope.includeDeleted,
|
|
956
|
+
),
|
|
957
|
+
);
|
|
958
|
+
nextCursor =
|
|
959
|
+
page.nextRecordsUrl && isSafeContinuation(page.nextRecordsUrl)
|
|
960
|
+
? encodedCursor({ next: page.nextRecordsUrl })
|
|
961
|
+
: undefined;
|
|
962
|
+
}
|
|
963
|
+
const records = (page.records ?? [])
|
|
964
|
+
.map((record) =>
|
|
965
|
+
projectRecord(
|
|
966
|
+
this.workspaceConnection,
|
|
967
|
+
objectType,
|
|
968
|
+
record,
|
|
969
|
+
fields,
|
|
970
|
+
description,
|
|
971
|
+
),
|
|
972
|
+
)
|
|
973
|
+
.filter((record): record is CrmRecord => record !== null);
|
|
974
|
+
return {
|
|
975
|
+
records,
|
|
976
|
+
relationships: [],
|
|
977
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
978
|
+
complete: !nextCursor,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async getRecord(
|
|
983
|
+
input: Parameters<CrmAdapter["getRecord"]>[0],
|
|
984
|
+
): Promise<CrmRecord | null> {
|
|
985
|
+
assertOwnedRecord(this.workspaceConnection, input.record);
|
|
986
|
+
const objectType = assertSafeObjectType(input.record.objectType);
|
|
987
|
+
const description = await this.describe(objectType);
|
|
988
|
+
const fields = safeFields(input.fields);
|
|
989
|
+
try {
|
|
990
|
+
const record = await this.request<SalesforceRecord>({
|
|
991
|
+
path: `/sobjects/${encodeURIComponent(objectType)}/${encodeURIComponent(input.record.remoteId)}?fields=${encodeURIComponent(this.selectFields(fields).join(","))}`,
|
|
992
|
+
});
|
|
993
|
+
return projectRecord(
|
|
994
|
+
this.workspaceConnection,
|
|
995
|
+
objectType,
|
|
996
|
+
record,
|
|
997
|
+
fields,
|
|
998
|
+
description,
|
|
999
|
+
);
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
if (isNotFound(error)) return null;
|
|
1002
|
+
throw error;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async search(
|
|
1007
|
+
input: Parameters<CrmAdapter["search"]>[0],
|
|
1008
|
+
): Promise<CrmSyncPage> {
|
|
1009
|
+
const objectTypes = uniqueStrings(input.objectTypes).filter((type) =>
|
|
1010
|
+
SAFE_OBJECT_NAME.test(type),
|
|
1011
|
+
);
|
|
1012
|
+
if (!objectTypes.length)
|
|
1013
|
+
return { records: [], relationships: [], complete: true };
|
|
1014
|
+
const state = decodeCursor<{ index?: number; next?: string }>(input.cursor);
|
|
1015
|
+
const index = Math.max(
|
|
1016
|
+
0,
|
|
1017
|
+
Math.min(state?.index ?? 0, objectTypes.length - 1),
|
|
1018
|
+
);
|
|
1019
|
+
const objectType = objectTypes[index]!;
|
|
1020
|
+
const fields = safeFields(input.fields);
|
|
1021
|
+
const limit = Math.max(1, Math.min(MAX_PAGE_SIZE, input.limit));
|
|
1022
|
+
const description = await this.describe(objectType);
|
|
1023
|
+
const page =
|
|
1024
|
+
state?.next && isSafeContinuation(state.next)
|
|
1025
|
+
? await this.query(state.next)
|
|
1026
|
+
: await this.query(
|
|
1027
|
+
queryPath(
|
|
1028
|
+
`SELECT ${this.selectFields(fields).join(",")} FROM ${objectType} WHERE ${searchField(objectType)} LIKE ${soqlString(`%${input.query.trim()}%`)} ORDER BY SystemModstamp DESC, Id ASC LIMIT ${limit}`,
|
|
1029
|
+
),
|
|
1030
|
+
);
|
|
1031
|
+
const records = (page.records ?? [])
|
|
1032
|
+
.map((record) =>
|
|
1033
|
+
projectRecord(
|
|
1034
|
+
this.workspaceConnection,
|
|
1035
|
+
objectType,
|
|
1036
|
+
record,
|
|
1037
|
+
fields,
|
|
1038
|
+
description,
|
|
1039
|
+
),
|
|
1040
|
+
)
|
|
1041
|
+
.filter((record): record is CrmRecord => record !== null);
|
|
1042
|
+
const nextCursor =
|
|
1043
|
+
page.nextRecordsUrl && isSafeContinuation(page.nextRecordsUrl)
|
|
1044
|
+
? encodedCursor({ index, next: page.nextRecordsUrl })
|
|
1045
|
+
: index + 1 < objectTypes.length
|
|
1046
|
+
? encodedCursor({ index: index + 1 })
|
|
1047
|
+
: undefined;
|
|
1048
|
+
return {
|
|
1049
|
+
records,
|
|
1050
|
+
relationships: [],
|
|
1051
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
1052
|
+
complete: !nextCursor,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async listRelationships(
|
|
1057
|
+
input: Parameters<CrmAdapter["listRelationships"]>[0],
|
|
1058
|
+
): Promise<{
|
|
1059
|
+
relationships: CrmRelationship[];
|
|
1060
|
+
nextCursor?: string;
|
|
1061
|
+
complete: boolean;
|
|
1062
|
+
}> {
|
|
1063
|
+
assertOwnedRecord(this.workspaceConnection, input.record);
|
|
1064
|
+
const objectType = assertSafeObjectType(input.record.objectType);
|
|
1065
|
+
const description = await this.describe(objectType);
|
|
1066
|
+
const targets = new Set(uniqueStrings(input.targetObjectTypes ?? []));
|
|
1067
|
+
const state = decodeCursor<{ index?: number }>(input.cursor);
|
|
1068
|
+
const fields = (description.fields ?? []).filter(
|
|
1069
|
+
(field) =>
|
|
1070
|
+
field.type === "reference" &&
|
|
1071
|
+
field.accessible !== false &&
|
|
1072
|
+
field.name &&
|
|
1073
|
+
(!targets.size ||
|
|
1074
|
+
field.referenceTo?.some((target) => targets.has(target))),
|
|
1075
|
+
);
|
|
1076
|
+
const start = Math.max(0, Math.min(state?.index ?? 0, fields.length));
|
|
1077
|
+
const boundedFields = fields.slice(
|
|
1078
|
+
start,
|
|
1079
|
+
start + Math.max(1, Math.min(MAX_PAGE_SIZE, input.limit)),
|
|
1080
|
+
);
|
|
1081
|
+
if (!boundedFields.length) return { relationships: [], complete: true };
|
|
1082
|
+
const record = await this.request<SalesforceRecord>({
|
|
1083
|
+
path: `/sobjects/${encodeURIComponent(objectType)}/${encodeURIComponent(input.record.remoteId)}?fields=${encodeURIComponent(["Id", ...boundedFields.map((field) => field.name!)].join(","))}`,
|
|
1084
|
+
});
|
|
1085
|
+
const relationships = boundedFields.flatMap((field): CrmRelationship[] => {
|
|
1086
|
+
const remoteId = scalarValue(record[field.name!]);
|
|
1087
|
+
const target = field.referenceTo?.[0];
|
|
1088
|
+
if (typeof remoteId !== "string" || !target) return [];
|
|
1089
|
+
return [
|
|
1090
|
+
{
|
|
1091
|
+
from: input.record,
|
|
1092
|
+
to: recordRef(this.workspaceConnection, target, remoteId),
|
|
1093
|
+
relationshipType: field.name!,
|
|
1094
|
+
...(field.label ? { label: field.label } : {}),
|
|
1095
|
+
},
|
|
1096
|
+
];
|
|
1097
|
+
});
|
|
1098
|
+
const nextCursor =
|
|
1099
|
+
start + boundedFields.length < fields.length
|
|
1100
|
+
? encodedCursor({ index: start + boundedFields.length })
|
|
1101
|
+
: undefined;
|
|
1102
|
+
return {
|
|
1103
|
+
relationships,
|
|
1104
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
1105
|
+
complete: !nextCursor,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async applyMutation(mutation: Parameters<CrmAdapter["applyMutation"]>[0]) {
|
|
1110
|
+
assertOwnedRecord(this.workspaceConnection, mutation.record);
|
|
1111
|
+
if (!mutation.idempotencyKey.trim()) {
|
|
1112
|
+
return {
|
|
1113
|
+
status: "rejected" as const,
|
|
1114
|
+
message: "An idempotency key is required.",
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
if (mutation.operation === "delete") {
|
|
1118
|
+
return {
|
|
1119
|
+
status: "rejected" as const,
|
|
1120
|
+
message:
|
|
1121
|
+
"Salesforce deletion is disabled during the initial CRM transport rollout.",
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
if (
|
|
1125
|
+
mutation.operation === "associate" ||
|
|
1126
|
+
mutation.operation === "disassociate"
|
|
1127
|
+
) {
|
|
1128
|
+
return {
|
|
1129
|
+
status: "rejected" as const,
|
|
1130
|
+
message:
|
|
1131
|
+
"Salesforce association mutation requires an explicit relationship definition and is not enabled in the initial rollout.",
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
if (
|
|
1135
|
+
!scopeAllows(
|
|
1136
|
+
this.workspaceConnection,
|
|
1137
|
+
mutation.record.objectType,
|
|
1138
|
+
"write",
|
|
1139
|
+
)
|
|
1140
|
+
) {
|
|
1141
|
+
return {
|
|
1142
|
+
status: "rejected" as const,
|
|
1143
|
+
message:
|
|
1144
|
+
"The workspace connection does not prove write access for this Salesforce object.",
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
if (mutation.operation === "create") {
|
|
1148
|
+
return {
|
|
1149
|
+
status: "rejected" as const,
|
|
1150
|
+
message:
|
|
1151
|
+
"Salesforce creation is disabled because this adapter cannot guarantee an atomic idempotent create.",
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
if (!mutation.expectedRemoteRevision) {
|
|
1155
|
+
return {
|
|
1156
|
+
status: "rejected" as const,
|
|
1157
|
+
message: "Salesforce mutations require a current remote revision.",
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
const current = await this.getRecord({
|
|
1161
|
+
record: mutation.record,
|
|
1162
|
+
fields: [],
|
|
1163
|
+
});
|
|
1164
|
+
if (
|
|
1165
|
+
!current ||
|
|
1166
|
+
current.remoteRevision !== mutation.expectedRemoteRevision
|
|
1167
|
+
) {
|
|
1168
|
+
return {
|
|
1169
|
+
status: "conflict" as const,
|
|
1170
|
+
...(current?.remoteRevision
|
|
1171
|
+
? { remoteRevision: current.remoteRevision }
|
|
1172
|
+
: {}),
|
|
1173
|
+
message:
|
|
1174
|
+
"The Salesforce record changed before this mutation could be applied.",
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
return {
|
|
1178
|
+
status: "rejected" as const,
|
|
1179
|
+
remoteRevision: current.remoteRevision,
|
|
1180
|
+
message:
|
|
1181
|
+
"Salesforce does not support an atomic conditional update through this CRM adapter.",
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function requireResolvedConnection(
|
|
1187
|
+
result: ResolvedWorkspaceConnectionForApp,
|
|
1188
|
+
): WorkspaceConnectionForApp {
|
|
1189
|
+
if (!result.available || !result.connection) {
|
|
1190
|
+
throw new Error(
|
|
1191
|
+
`Salesforce workspace connection is unavailable: ${result.reason}`,
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
return result.connection;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
export async function createSalesforceCrmAdapter(
|
|
1198
|
+
options: CreateSalesforceCrmAdapterOptions = {},
|
|
1199
|
+
): Promise<SalesforceCrmAdapter> {
|
|
1200
|
+
const resolved = await resolveWorkspaceConnectionForApp({
|
|
1201
|
+
appId: CRM_APP_ID,
|
|
1202
|
+
provider: "salesforce",
|
|
1203
|
+
...(options.connectionId ? { connectionId: options.connectionId } : {}),
|
|
1204
|
+
requireConnected: true,
|
|
1205
|
+
});
|
|
1206
|
+
const connection = requireResolvedConnection(resolved);
|
|
1207
|
+
if (options.transport) {
|
|
1208
|
+
return new SalesforceCrmAdapter({
|
|
1209
|
+
connection,
|
|
1210
|
+
transport: options.transport,
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
const instanceUrl = resolveInstanceUrl(connection);
|
|
1214
|
+
try {
|
|
1215
|
+
const oauth = await resolveProviderApiOAuthAccessToken(
|
|
1216
|
+
{ provider: "salesforce", connectionId: connection.id },
|
|
1217
|
+
{ appId: CRM_APP_ID, providerIds: ["salesforce"] },
|
|
1218
|
+
);
|
|
1219
|
+
if (oauth.connectionId === connection.id && oauth.accessToken) {
|
|
1220
|
+
return new SalesforceCrmAdapter({
|
|
1221
|
+
connection,
|
|
1222
|
+
transport: new FetchSalesforceTransport(
|
|
1223
|
+
instanceUrl,
|
|
1224
|
+
resolveApiVersion(connection),
|
|
1225
|
+
oauth.accessToken,
|
|
1226
|
+
),
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
} catch {
|
|
1230
|
+
// Manual bearer credentials remain supported for non-OAuth connections.
|
|
1231
|
+
}
|
|
1232
|
+
for (const key of SALESFORCE_CREDENTIAL_KEYS) {
|
|
1233
|
+
const credential = await resolveWorkspaceConnectionCredentialForApp({
|
|
1234
|
+
appId: CRM_APP_ID,
|
|
1235
|
+
provider: "salesforce",
|
|
1236
|
+
key,
|
|
1237
|
+
connectionId: connection.id,
|
|
1238
|
+
...(options.userEmail ? { userEmail: options.userEmail } : {}),
|
|
1239
|
+
...(options.orgId !== undefined ? { orgId: options.orgId } : {}),
|
|
1240
|
+
});
|
|
1241
|
+
if (!credential.available || !credential.value) continue;
|
|
1242
|
+
if (credential.provenance?.connectionId !== connection.id) continue;
|
|
1243
|
+
return new SalesforceCrmAdapter({
|
|
1244
|
+
connection,
|
|
1245
|
+
transport: new FetchSalesforceTransport(
|
|
1246
|
+
instanceUrl,
|
|
1247
|
+
resolveApiVersion(connection),
|
|
1248
|
+
credential.value,
|
|
1249
|
+
),
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
throw new Error(
|
|
1253
|
+
"Salesforce workspace connection is unavailable: no scoped credential was resolved for the granted connection.",
|
|
1254
|
+
);
|
|
1255
|
+
}
|