@memberjunction/integration-connectors 5.40.2 → 5.42.0
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/dist/CventConnector.d.ts +146 -0
- package/dist/CventConnector.d.ts.map +1 -0
- package/dist/CventConnector.js +540 -0
- package/dist/CventConnector.js.map +1 -0
- package/dist/DynamicsDataverseConnector.d.ts +296 -0
- package/dist/DynamicsDataverseConnector.d.ts.map +1 -0
- package/dist/DynamicsDataverseConnector.js +790 -0
- package/dist/DynamicsDataverseConnector.js.map +1 -0
- package/dist/FontevaConnector.d.ts +153 -0
- package/dist/FontevaConnector.d.ts.map +1 -0
- package/dist/FontevaConnector.js +421 -0
- package/dist/FontevaConnector.js.map +1 -0
- package/dist/GrowthZoneConnector.d.ts +123 -125
- package/dist/GrowthZoneConnector.d.ts.map +1 -1
- package/dist/GrowthZoneConnector.js +456 -499
- package/dist/GrowthZoneConnector.js.map +1 -1
- package/dist/HivebriteConnector.d.ts +151 -0
- package/dist/HivebriteConnector.d.ts.map +1 -0
- package/dist/HivebriteConnector.js +533 -0
- package/dist/HivebriteConnector.js.map +1 -0
- package/dist/IMISConnector.d.ts +112 -96
- package/dist/IMISConnector.d.ts.map +1 -1
- package/dist/IMISConnector.js +430 -417
- package/dist/IMISConnector.js.map +1 -1
- package/dist/MemberSuiteConnector.d.ts +185 -0
- package/dist/MemberSuiteConnector.d.ts.map +1 -0
- package/dist/MemberSuiteConnector.js +762 -0
- package/dist/MemberSuiteConnector.js.map +1 -0
- package/dist/NeonCRMConnector.d.ts +273 -0
- package/dist/NeonCRMConnector.d.ts.map +1 -0
- package/dist/NeonCRMConnector.js +1039 -0
- package/dist/NeonCRMConnector.js.map +1 -0
- package/dist/NetForumConnector.d.ts +140 -47
- package/dist/NetForumConnector.d.ts.map +1 -1
- package/dist/NetForumConnector.js +668 -395
- package/dist/NetForumConnector.js.map +1 -1
- package/dist/NetSuiteConnector.d.ts +116 -50
- package/dist/NetSuiteConnector.d.ts.map +1 -1
- package/dist/NetSuiteConnector.js +608 -510
- package/dist/NetSuiteConnector.js.map +1 -1
- package/dist/NimbleAMSConnector.d.ts +104 -11
- package/dist/NimbleAMSConnector.d.ts.map +1 -1
- package/dist/NimbleAMSConnector.js +552 -258
- package/dist/NimbleAMSConnector.js.map +1 -1
- package/dist/NoviConnector.d.ts +178 -0
- package/dist/NoviConnector.d.ts.map +1 -0
- package/dist/NoviConnector.js +566 -0
- package/dist/NoviConnector.js.map +1 -0
- package/dist/ORCIDConnector.d.ts +207 -0
- package/dist/ORCIDConnector.d.ts.map +1 -0
- package/dist/ORCIDConnector.js +708 -0
- package/dist/ORCIDConnector.js.map +1 -0
- package/dist/OpenWaterConnector.d.ts +173 -0
- package/dist/OpenWaterConnector.d.ts.map +1 -0
- package/dist/OpenWaterConnector.js +893 -0
- package/dist/OpenWaterConnector.js.map +1 -0
- package/dist/PathLMSConnector.d.ts +395 -0
- package/dist/PathLMSConnector.d.ts.map +1 -0
- package/dist/PathLMSConnector.js +1281 -0
- package/dist/PathLMSConnector.js.map +1 -0
- package/dist/PheedLoopConnector.d.ts +119 -0
- package/dist/PheedLoopConnector.d.ts.map +1 -0
- package/dist/PheedLoopConnector.js +429 -0
- package/dist/PheedLoopConnector.js.map +1 -0
- package/dist/PropFuelConnector.d.ts +135 -61
- package/dist/PropFuelConnector.d.ts.map +1 -1
- package/dist/PropFuelConnector.js +366 -467
- package/dist/PropFuelConnector.js.map +1 -1
- package/dist/RhythmConnector.d.ts +188 -0
- package/dist/RhythmConnector.d.ts.map +1 -0
- package/dist/RhythmConnector.js +784 -0
- package/dist/RhythmConnector.js.map +1 -0
- package/dist/SalesforceConnector.d.ts +69 -3
- package/dist/SalesforceConnector.d.ts.map +1 -1
- package/dist/SalesforceConnector.js +188 -166
- package/dist/SalesforceConnector.js.map +1 -1
- package/dist/SharePointConnector.d.ts +37 -30
- package/dist/SharePointConnector.d.ts.map +1 -1
- package/dist/SharePointConnector.js +163 -243
- package/dist/SharePointConnector.js.map +1 -1
- package/dist/YourMembershipConnector.d.ts.map +1 -1
- package/dist/YourMembershipConnector.js +13 -5
- package/dist/YourMembershipConnector.js.map +1 -1
- package/dist/index.d.ts +14 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/dist/BettyConnector.d.ts +0 -288
- package/dist/BettyConnector.d.ts.map +0 -1
- package/dist/BettyConnector.js +0 -909
- package/dist/BettyConnector.js.map +0 -1
|
@@ -7,128 +7,52 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
7
7
|
/**
|
|
8
8
|
* NimbleAMSConnector — Integration connector for Nimble AMS (Salesforce-native association management).
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Nimble AMS is a Salesforce-native AMS: data lives as Salesforce sObjects — standard SF objects
|
|
11
|
+
* (Account, Contact) and NU__/NUINT__-prefixed managed-package custom objects (`__c` suffix). The
|
|
12
|
+
* connector surfaces THREE access doors, each carried on the per-IO `Configuration` from the frozen
|
|
13
|
+
* contract — never hardcoded here:
|
|
14
|
+
*
|
|
15
|
+
* 1. Salesforce REST SOQL — `/services/data/v{apiVersion}/query` with `nextRecordsUrl` cursor
|
|
16
|
+
* pagination + `WHERE LastModifiedDate >= :watermark` incremental. The default read door, and
|
|
17
|
+
* the write door for sObject CRUD (`/sobjects/{ObjectName}` flat body, path-delete).
|
|
18
|
+
* 2. Nimble Fuse — POST `/services/apexrest/NUINT/NUIntegrationService`. OUTBOUND reads run a
|
|
19
|
+
* named Integration-Setting SOQL and return `{Records, RecordCount, Message}` (null fields are
|
|
20
|
+
* OMITTED — missing fields are nullable, NOT a schema error). INBOUND writes are an
|
|
21
|
+
* External-ID-keyed JSON upsert returning `{RecordCount, InboundResults:[{...,SalesforceId,
|
|
22
|
+
* ErrorMessages}], ErrorMessages}` with per-record partial success.
|
|
23
|
+
* 3. NAMS LMS REST — `/services/apexrest/nams/api/lms/v1/...` GET with a `lastUpdated` incremental.
|
|
14
24
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* Rate limits: Salesforce org limits (100K API calls/24hr Enterprise, 15K Professional)
|
|
20
|
-
* Incremental: SOQL WHERE LastModifiedDate >= watermark
|
|
21
|
-
* CRUD: Full via Salesforce REST API /sobjects/{objectName}
|
|
25
|
+
* API Documentation:
|
|
26
|
+
* - Nimble AMS API: https://help.nimbleams.com/help/live/api
|
|
27
|
+
* - Call the Integration API: https://help.nimbleams.com/help/live/call-the-integration-api
|
|
28
|
+
* - Object Reference: https://nimbleuser.github.io/nams-api-docs/
|
|
22
29
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* Standard Salesforce objects (Account, Contact) are also used alongside custom objects.
|
|
30
|
+
* Auth: Salesforce OAuth 2.0 bearer (Connected App; client_credentials or refresh_token grant).
|
|
31
|
+
* Base URL: the per-org Salesforce instance URL (from the OAuth token response `instance_url`).
|
|
26
32
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* - Salesforce Bulk API 2.0 (NOT implemented) — for 10K+ record batches
|
|
31
|
-
* - Salesforce Streaming API (NOT implemented) — real-time CDC via PushTopics/CDC channels
|
|
32
|
-
* - Salesforce Metadata API (NOT implemented) — schema/config deployment, not data sync
|
|
33
|
+
* NO BAKED CATALOG. The object/field universe is Declared (credential-free docs → metadata file) +
|
|
34
|
+
* Discovered (runtime SF global-describe MECHANISM, scoped to the NU__/NUINT__ managed-package
|
|
35
|
+
* namespace). This file is pure mechanism: auth, HTTP, discover, fetch, normalize, transform, write.
|
|
33
36
|
*/
|
|
34
37
|
import { RegisterClass } from '@memberjunction/global';
|
|
35
38
|
import { Metadata } from '@memberjunction/core';
|
|
36
39
|
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
|
|
37
|
-
// ─── Constants
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
40
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
41
|
+
const DEFAULT_SF_API_VERSION = '59.0';
|
|
42
|
+
const FUSE_PATH = '/services/apexrest/NUINT/NUIntegrationService';
|
|
43
|
+
/** SF managed-package namespaces that scope the global describe to Nimble objects. */
|
|
44
|
+
const NIMBLE_NAMESPACES = ['NU__', 'NUINT__'];
|
|
45
|
+
/** Standard SF objects used by Nimble AMS alongside the managed package. */
|
|
46
|
+
const NIMBLE_STANDARD_OBJECTS = new Set(['Account', 'Contact']);
|
|
47
|
+
/** Nimble Fuse OUTBOUND hard limits per call — 50,000 records OR 3MB, whichever is reached first. */
|
|
48
|
+
const FUSE_MAX_RECORDS_PER_CALL = 50_000;
|
|
49
|
+
const FUSE_MAX_BYTES_PER_CALL = 3 * 1024 * 1024; // 3MB
|
|
42
50
|
const TOKEN_REFRESH_BUFFER_MS = 60_000;
|
|
51
|
+
const TOKEN_LIFETIME_MS = 3_600_000;
|
|
43
52
|
const MAX_RETRIES = 3;
|
|
44
53
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
45
54
|
const MIN_REQUEST_INTERVAL_MS = 50;
|
|
46
|
-
// ───
|
|
47
|
-
// Lean overlay: PK + key FKs + date fields. Full field list from Salesforce describe API at runtime.
|
|
48
|
-
const NIMBLE_OBJECTS = [
|
|
49
|
-
// Standard Salesforce objects used by Nimble AMS
|
|
50
|
-
{ Name: 'Account', DisplayName: 'Account', Description: 'Organization/company accounts', SupportsWrite: true, Fields: [
|
|
51
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
|
|
52
|
-
{ Name: 'LastModifiedDate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Incremental date' },
|
|
53
|
-
] },
|
|
54
|
-
{ Name: 'Contact', DisplayName: 'Contact', Description: 'Individual contacts/members', SupportsWrite: true, Fields: [
|
|
55
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Salesforce record ID' },
|
|
56
|
-
{ Name: 'AccountId', DisplayName: 'Account ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
|
|
57
|
-
{ Name: 'LastModifiedDate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Incremental date' },
|
|
58
|
-
] },
|
|
59
|
-
// Nimble AMS custom objects (NU__ namespace)
|
|
60
|
-
{ Name: 'NU__Membership__c', DisplayName: 'Membership', Description: 'Membership records', SupportsWrite: true, Fields: [
|
|
61
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
62
|
-
{ Name: 'NU__Account__c', DisplayName: 'Account', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
|
|
63
|
-
{ Name: 'LastModifiedDate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Incremental date' },
|
|
64
|
-
] },
|
|
65
|
-
{ Name: 'NU__MembershipType__c', DisplayName: 'Membership Type', Description: 'Membership type definitions', SupportsWrite: true, Fields: [
|
|
66
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
67
|
-
] },
|
|
68
|
-
{ Name: 'NU__Event__c', DisplayName: 'Event', Description: 'Events and conferences', SupportsWrite: true, Fields: [
|
|
69
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
70
|
-
{ Name: 'LastModifiedDate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Incremental date' },
|
|
71
|
-
] },
|
|
72
|
-
{ Name: 'NU__EventRegistration__c', DisplayName: 'Event Registration', Description: 'Event registrations', SupportsWrite: true, Fields: [
|
|
73
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
74
|
-
{ Name: 'NU__Event__c', DisplayName: 'Event', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Event' },
|
|
75
|
-
{ Name: 'NU__Contact__c', DisplayName: 'Contact', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Contact' },
|
|
76
|
-
] },
|
|
77
|
-
{ Name: 'NU__Order__c', DisplayName: 'Order', Description: 'Sales orders', SupportsWrite: true, Fields: [
|
|
78
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
79
|
-
{ Name: 'NU__Account__c', DisplayName: 'Account', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
|
|
80
|
-
{ Name: 'LastModifiedDate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Incremental date' },
|
|
81
|
-
] },
|
|
82
|
-
{ Name: 'NU__OrderItem__c', DisplayName: 'Order Item', Description: 'Order line items', SupportsWrite: true, Fields: [
|
|
83
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
84
|
-
{ Name: 'NU__Order__c', DisplayName: 'Order', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Order' },
|
|
85
|
-
] },
|
|
86
|
-
{ Name: 'NU__Payment__c', DisplayName: 'Payment', Description: 'Payment records', SupportsWrite: true, Fields: [
|
|
87
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
88
|
-
{ Name: 'NU__Order__c', DisplayName: 'Order', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Order' },
|
|
89
|
-
] },
|
|
90
|
-
{ Name: 'NU__Invoice__c', DisplayName: 'Invoice', Description: 'Invoices', SupportsWrite: true, Fields: [
|
|
91
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
92
|
-
{ Name: 'NU__Account__c', DisplayName: 'Account', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
|
|
93
|
-
] },
|
|
94
|
-
{ Name: 'NU__Committee__c', DisplayName: 'Committee', Description: 'Committees and boards', SupportsWrite: true, Fields: [
|
|
95
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
96
|
-
] },
|
|
97
|
-
{ Name: 'NU__CommitteeMembership__c', DisplayName: 'Committee Member', Description: 'Committee membership records', SupportsWrite: true, Fields: [
|
|
98
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
99
|
-
{ Name: 'NU__Committee__c', DisplayName: 'Committee', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Committee' },
|
|
100
|
-
{ Name: 'NU__Contact__c', DisplayName: 'Contact', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Contact' },
|
|
101
|
-
] },
|
|
102
|
-
{ Name: 'NU__Donation__c', DisplayName: 'Donation', Description: 'Donation/gift records', SupportsWrite: true, Fields: [
|
|
103
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
104
|
-
{ Name: 'NU__Account__c', DisplayName: 'Account', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
|
|
105
|
-
] },
|
|
106
|
-
{ Name: 'NU__Product__c', DisplayName: 'Product', Description: 'Products/items for sale', SupportsWrite: true, Fields: [
|
|
107
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
108
|
-
] },
|
|
109
|
-
{ Name: 'NU__Badge__c', DisplayName: 'Badge', Description: 'Badges/certifications', SupportsWrite: true, Fields: [
|
|
110
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
111
|
-
] },
|
|
112
|
-
{ Name: 'NU__Subscription__c', DisplayName: 'Subscription', Description: 'Publication subscriptions', SupportsWrite: true, Fields: [
|
|
113
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
114
|
-
{ Name: 'NU__Account__c', DisplayName: 'Account', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Account' },
|
|
115
|
-
] },
|
|
116
|
-
{ Name: 'NU__Campaign__c', DisplayName: 'Campaign', Description: 'Fundraising campaigns', SupportsWrite: true, Fields: [
|
|
117
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
118
|
-
] },
|
|
119
|
-
{ Name: 'NU__Session__c', DisplayName: 'Session', Description: 'Event sessions', SupportsWrite: true, Fields: [
|
|
120
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
121
|
-
{ Name: 'NU__Event__c', DisplayName: 'Event', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Event' },
|
|
122
|
-
] },
|
|
123
|
-
{ Name: 'NU__Credential__c', DisplayName: 'Credential', Description: 'Professional credentials/certifications', SupportsWrite: true, Fields: [
|
|
124
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
125
|
-
{ Name: 'NU__Contact__c', DisplayName: 'Contact', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'FK → Contact' },
|
|
126
|
-
] },
|
|
127
|
-
{ Name: 'NU__Chapter__c', DisplayName: 'Chapter', Description: 'Chapters/branches', SupportsWrite: true, Fields: [
|
|
128
|
-
{ Name: 'Id', DisplayName: 'ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'Record ID' },
|
|
129
|
-
] },
|
|
130
|
-
];
|
|
131
|
-
// ─── Connector ────────────────────────────────────────────────────────────
|
|
55
|
+
// ─── Connector ──────────────────────────────────────────────────────────────
|
|
132
56
|
let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationConnector {
|
|
133
57
|
constructor() {
|
|
134
58
|
super(...arguments);
|
|
@@ -136,103 +60,188 @@ let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationCon
|
|
|
136
60
|
this.lastRequestTime = 0;
|
|
137
61
|
}
|
|
138
62
|
get IntegrationName() { return 'Nimble AMS'; }
|
|
63
|
+
// Capability getters reflect the contract: writeable objects expose SF-REST sObject CRUD
|
|
64
|
+
// (Create/Update/Delete) and Fuse upsert (Create/Update). The per-IO write columns gate which
|
|
65
|
+
// verb actually fires; these getters declare the connector CAN write where metadata says so.
|
|
139
66
|
get SupportsCreate() { return true; }
|
|
140
67
|
get SupportsUpdate() { return true; }
|
|
141
68
|
get SupportsDelete() { return true; }
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
69
|
+
/**
|
|
70
|
+
* AUTHORITATIVE discovery (§ deactivation gate): the live SF global describe returns the FULL set
|
|
71
|
+
* of NU__/NUINT__ + standard objects the credential can see, so absence on a later refresh genuinely
|
|
72
|
+
* means the source dropped it. The Declared baseline plus the describe extension are the complete
|
|
73
|
+
* gamut for the scoped namespace.
|
|
74
|
+
*/
|
|
75
|
+
get DiscoveryIsAuthoritative() { return true; }
|
|
76
|
+
/**
|
|
77
|
+
* Route schema introspection through the connector's LIVE SF global-describe discovery.
|
|
78
|
+
*
|
|
79
|
+
* `BaseRESTIntegrationConnector` (our parent) overrides the grandparent's real discover loop with a
|
|
80
|
+
* CACHE-DRIVEN `IntrospectSchema` that re-reads persisted ACTIVE metadata and is hard-coded
|
|
81
|
+
* non-authoritative — so it never calls our `DiscoverObjects`/`DiscoverFields` and can never drive
|
|
82
|
+
* the authoritative deactivation overlay. Nimble HAS live describe-backed discovery (and declares
|
|
83
|
+
* `DiscoveryIsAuthoritative = true`), so we invoke the grandparent `BaseIntegrationConnector`
|
|
84
|
+
* implementation directly. It calls `this.DiscoverObjects` → `this.DiscoverFields` per object and
|
|
85
|
+
* sets `IsAuthoritative` from our getter, so the full field set is discovered and absent
|
|
86
|
+
* objects/fields deactivate (reversibly) on a comprehensive refresh.
|
|
87
|
+
*/
|
|
88
|
+
async IntrospectSchema(companyIntegration, contextUser, options) {
|
|
89
|
+
return BaseIntegrationConnector.prototype.IntrospectSchema.call(this, companyIntegration, contextUser, options);
|
|
90
|
+
}
|
|
91
|
+
// ── §7 sync-efficiency hooks ─────────────────────────────────────────────
|
|
92
|
+
/** Salesforce REST is monotonic when we ORDER BY the watermark column — narrow the next incremental. */
|
|
93
|
+
get MonotonicWatermark() { return true; }
|
|
94
|
+
/** Every Nimble sObject carries the immutable 18-char `Id`; the LMS REST objects carry `id`. */
|
|
95
|
+
StableOrderingKey(objectName) {
|
|
96
|
+
return objectName.startsWith('Lms') ? 'id' : 'Id';
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Salesforce enforces a per-org rolling 24h API-call cap (Enterprise ~100k/day) rather than a
|
|
100
|
+
* tokens/sec rate — a conservative sustained rate keeps the connector well clear of the daily cap.
|
|
101
|
+
*/
|
|
102
|
+
get RateLimitPolicy() {
|
|
103
|
+
return { TokensPerSec: 20, Burst: 25, ThrottleBackoffFactor: 0.5 };
|
|
104
|
+
}
|
|
105
|
+
/** Salesforce 429 / REQUEST_LIMIT_EXCEEDED → honor the standard `Retry-After` header (seconds). */
|
|
106
|
+
ExtractRetryAfterMs(error) {
|
|
107
|
+
if (!error || typeof error !== 'object')
|
|
108
|
+
return undefined;
|
|
109
|
+
const headers = error.Headers;
|
|
110
|
+
const ra = headers?.['retry-after'];
|
|
111
|
+
if (ra) {
|
|
112
|
+
const secs = Number(ra);
|
|
113
|
+
if (!isNaN(secs))
|
|
114
|
+
return secs * 1000;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
// ── Discovery (runtime SF global-describe MECHANISM, namespace-scoped) ────
|
|
155
119
|
async DiscoverObjects(companyIntegration, contextUser) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
162
|
-
if (response.Status === 200) {
|
|
163
|
-
const body = response.Body;
|
|
164
|
-
const results = [];
|
|
165
|
-
for (const obj of (body.sobjects ?? [])) {
|
|
166
|
-
// Include NU__ namespace objects + standard objects used by Nimble
|
|
167
|
-
if (obj.name.startsWith('NU__') || ['Account', 'Contact', 'Lead', 'Opportunity', 'Campaign'].includes(obj.name)) {
|
|
168
|
-
if (obj.queryable) {
|
|
169
|
-
results.push({
|
|
170
|
-
Name: obj.name, Label: obj.label, Description: `${obj.label} (Nimble AMS)`,
|
|
171
|
-
SupportsIncrementalSync: true, SupportsWrite: obj.createable || obj.updateable,
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
if (results.length > 0)
|
|
177
|
-
return results;
|
|
178
|
-
}
|
|
120
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
121
|
+
const url = `${this.SFDataBase(auth)}/sobjects/`;
|
|
122
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
|
|
123
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
124
|
+
throw new Error(`Nimble AMS DiscoverObjects failed: HTTP ${response.Status} at ${url}`);
|
|
179
125
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
126
|
+
const body = response.Body;
|
|
127
|
+
const results = [];
|
|
128
|
+
for (const obj of (body.sobjects ?? [])) {
|
|
129
|
+
if (!obj.queryable)
|
|
130
|
+
continue;
|
|
131
|
+
if (!this.IsNimbleScopedObject(obj.name))
|
|
132
|
+
continue;
|
|
133
|
+
results.push({
|
|
134
|
+
Name: obj.name,
|
|
135
|
+
Label: obj.label,
|
|
136
|
+
Description: `${obj.label} (Nimble AMS)`,
|
|
137
|
+
SupportsIncrementalSync: true,
|
|
138
|
+
SupportsWrite: obj.createable || obj.updateable,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return results;
|
|
185
142
|
}
|
|
186
143
|
async DiscoverFields(companyIntegration, objectName, contextUser) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
193
|
-
if (response.Status === 200) {
|
|
194
|
-
const body = response.Body;
|
|
195
|
-
return (body.fields ?? []).map(f => ({
|
|
196
|
-
Name: f.name, Label: f.label, Description: '', DataType: this.MapSFType(f.type),
|
|
197
|
-
IsRequired: !f.nillable, IsUniqueKey: f.name === 'Id' || f.unique, IsReadOnly: !f.createable && !f.updateable,
|
|
198
|
-
}));
|
|
199
|
-
}
|
|
144
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
145
|
+
const url = `${this.SFDataBase(auth)}/sobjects/${encodeURIComponent(objectName)}/describe`;
|
|
146
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', this.BuildHeaders(auth));
|
|
147
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
148
|
+
throw new Error(`Nimble AMS DiscoverFields failed for "${objectName}": HTTP ${response.Status}`);
|
|
200
149
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
150
|
+
const body = response.Body;
|
|
151
|
+
return (body.fields ?? []).map(f => {
|
|
152
|
+
const schema = {
|
|
153
|
+
Name: f.name,
|
|
154
|
+
Label: f.label,
|
|
155
|
+
Description: '',
|
|
156
|
+
DataType: this.MapSFType(f.type),
|
|
157
|
+
// SF describe states create-time required via `nillable=false` (provable from the source).
|
|
158
|
+
IsRequired: f.nillable === false,
|
|
159
|
+
// Provable-only: the SF `id` system field is the PK; uniqueness alone is NOT PK.
|
|
160
|
+
IsPrimaryKey: f.type === 'id',
|
|
161
|
+
IsUniqueKey: f.type === 'id' || f.unique === true,
|
|
162
|
+
IsReadOnly: !f.createable && !f.updateable,
|
|
163
|
+
};
|
|
164
|
+
// Bounded typing — propagate the source's length/precision/scale when present.
|
|
165
|
+
if (typeof f.length === 'number' && f.length > 0)
|
|
166
|
+
schema.MaxLength = f.length;
|
|
167
|
+
if (typeof f.precision === 'number' && f.precision > 0)
|
|
168
|
+
schema.Precision = f.precision;
|
|
169
|
+
if (typeof f.scale === 'number' && f.scale >= 0)
|
|
170
|
+
schema.Scale = f.scale;
|
|
171
|
+
return schema;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/** A SF API name is in Nimble scope iff it is a managed-package object or a standard AMS object. */
|
|
175
|
+
IsNimbleScopedObject(name) {
|
|
176
|
+
if (NIMBLE_STANDARD_OBJECTS.has(name))
|
|
177
|
+
return true;
|
|
178
|
+
return NIMBLE_NAMESPACES.some(ns => name.startsWith(ns));
|
|
206
179
|
}
|
|
207
180
|
MapSFType(sfType) {
|
|
208
181
|
const map = {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
182
|
+
id: 'string', string: 'string', textarea: 'string', url: 'string', email: 'string', phone: 'string',
|
|
183
|
+
boolean: 'boolean', int: 'number', double: 'decimal', currency: 'decimal', percent: 'decimal',
|
|
184
|
+
date: 'datetime', datetime: 'datetime', time: 'string', reference: 'string',
|
|
185
|
+
picklist: 'string', multipicklist: 'string', base64: 'string', anyType: 'string', address: 'string',
|
|
212
186
|
};
|
|
213
187
|
return map[sfType] ?? 'string';
|
|
214
188
|
}
|
|
215
|
-
//
|
|
189
|
+
// ── Auth (Salesforce OAuth2 bearer) ──────────────────────────────────────
|
|
216
190
|
async Authenticate(companyIntegration, contextUser) {
|
|
217
191
|
const config = await this.ParseConfig(companyIntegration, contextUser);
|
|
218
192
|
if (this.tokenCache && this.tokenCache.ExpiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS) {
|
|
219
|
-
return
|
|
193
|
+
return this.BuildAuthContext(config, this.tokenCache.AccessToken, this.tokenCache.InstanceURL);
|
|
220
194
|
}
|
|
221
195
|
const token = await this.ObtainToken(config);
|
|
222
196
|
this.tokenCache = token;
|
|
223
|
-
return
|
|
197
|
+
return this.BuildAuthContext(config, token.AccessToken, token.InstanceURL);
|
|
198
|
+
}
|
|
199
|
+
BuildAuthContext(config, accessToken, instanceURL) {
|
|
200
|
+
return {
|
|
201
|
+
Token: accessToken,
|
|
202
|
+
TokenType: 'Bearer',
|
|
203
|
+
Config: config,
|
|
204
|
+
InstanceURL: instanceURL,
|
|
205
|
+
ApiVersion: config.ApiVersion,
|
|
206
|
+
};
|
|
224
207
|
}
|
|
225
208
|
async ObtainToken(config) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
209
|
+
// Pre-provisioned access token (e.g. test/broker injection) — use as-is, no exchange.
|
|
210
|
+
if (config.AccessToken && config.InstanceURL) {
|
|
211
|
+
return { AccessToken: config.AccessToken, InstanceURL: config.InstanceURL, ExpiresAt: Date.now() + TOKEN_LIFETIME_MS };
|
|
212
|
+
}
|
|
213
|
+
const tokenURL = `${config.LoginURL.replace(/\/+$/, '')}/services/oauth2/token`;
|
|
214
|
+
const params = { client_id: config.ClientID };
|
|
215
|
+
if (config.RefreshToken) {
|
|
216
|
+
params['grant_type'] = 'refresh_token';
|
|
217
|
+
params['refresh_token'] = config.RefreshToken;
|
|
218
|
+
if (config.ClientSecret)
|
|
219
|
+
params['client_secret'] = config.ClientSecret;
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
if (!config.ClientSecret)
|
|
223
|
+
throw new Error('Nimble AMS auth requires either a refresh_token or a client_secret.');
|
|
224
|
+
params['grant_type'] = 'client_credentials';
|
|
225
|
+
params['client_secret'] = config.ClientSecret;
|
|
226
|
+
}
|
|
227
|
+
const body = new URLSearchParams(params);
|
|
228
|
+
const response = await fetch(tokenURL, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
231
|
+
body: body.toString(),
|
|
232
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
233
|
+
});
|
|
229
234
|
if (!response.ok)
|
|
230
|
-
throw new Error(`Nimble AMS auth failed: ${response.status}`);
|
|
235
|
+
throw new Error(`Nimble AMS auth failed: HTTP ${response.status}`);
|
|
231
236
|
const data = await response.json();
|
|
232
|
-
|
|
237
|
+
// Salesforce returns the org instance_url in the token response — it is the authoritative origin.
|
|
238
|
+
const instanceURL = data.instance_url ?? config.InstanceURL;
|
|
239
|
+
if (!instanceURL)
|
|
240
|
+
throw new Error('Nimble AMS auth response carried no instance_url and none was configured.');
|
|
241
|
+
return { AccessToken: data.access_token, InstanceURL: instanceURL, ExpiresAt: Date.now() + TOKEN_LIFETIME_MS };
|
|
233
242
|
}
|
|
234
243
|
async ParseConfig(companyIntegration, contextUser, provider) {
|
|
235
|
-
|
|
244
|
+
const fromConfigJSON = this.ParseConfigJSON(companyIntegration.Configuration);
|
|
236
245
|
const credentialID = companyIntegration.CredentialID;
|
|
237
246
|
if (credentialID) {
|
|
238
247
|
const md = provider ?? new Metadata();
|
|
@@ -240,112 +249,382 @@ let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationCon
|
|
|
240
249
|
const loaded = await credential.Load(credentialID);
|
|
241
250
|
if (loaded && credential.Values) {
|
|
242
251
|
const parsed = JSON.parse(credential.Values);
|
|
243
|
-
return
|
|
252
|
+
return this.BuildConfig(parsed, fromConfigJSON);
|
|
244
253
|
}
|
|
245
254
|
}
|
|
246
|
-
|
|
255
|
+
if (fromConfigJSON)
|
|
256
|
+
return this.BuildConfig(fromConfigJSON, null);
|
|
257
|
+
throw new Error('No Nimble AMS credentials found — set the Credential entity or Configuration JSON.');
|
|
258
|
+
}
|
|
259
|
+
ParseConfigJSON(json) {
|
|
260
|
+
if (!json)
|
|
261
|
+
return null;
|
|
262
|
+
try {
|
|
263
|
+
return JSON.parse(json);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
BuildConfig(creds, configJSON) {
|
|
270
|
+
const pick = (...keys) => {
|
|
271
|
+
for (const k of keys) {
|
|
272
|
+
if (creds[k])
|
|
273
|
+
return creds[k];
|
|
274
|
+
if (configJSON && configJSON[k])
|
|
275
|
+
return configJSON[k];
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
};
|
|
279
|
+
return {
|
|
280
|
+
InstanceURL: pick('InstanceURL', 'instanceUrl', 'instance_url') ?? '',
|
|
281
|
+
ClientID: pick('ClientID', 'clientId', 'client_id') ?? '',
|
|
282
|
+
ClientSecret: pick('ClientSecret', 'clientSecret', 'client_secret'),
|
|
283
|
+
AccessToken: pick('AccessToken', 'accessToken', 'access_token'),
|
|
284
|
+
RefreshToken: pick('RefreshToken', 'refreshToken', 'refresh_token'),
|
|
285
|
+
LoginURL: pick('LoginURL', 'loginUrl', 'login_url') ?? 'https://login.salesforce.com',
|
|
286
|
+
ApiVersion: this.NormalizeApiVersion(pick('SalesforceAPIVersion', 'ApiVersion', 'apiVersion')),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/** Accept "59.0" or "v59.0"; the SF data path embeds it as `v{version}`. Store the bare number. */
|
|
290
|
+
NormalizeApiVersion(raw) {
|
|
291
|
+
if (!raw)
|
|
292
|
+
return DEFAULT_SF_API_VERSION;
|
|
293
|
+
return raw.replace(/^v/i, '');
|
|
247
294
|
}
|
|
248
|
-
//
|
|
295
|
+
// ── TestConnection ───────────────────────────────────────────────────────
|
|
249
296
|
async TestConnection(companyIntegration, contextUser) {
|
|
250
297
|
try {
|
|
251
298
|
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
if (response.Status === 200)
|
|
299
|
+
const response = await this.MakeHTTPRequest(auth, `${this.SFDataBase(auth)}/sobjects/`, 'GET', this.BuildHeaders(auth));
|
|
300
|
+
if (response.Status >= 200 && response.Status < 300)
|
|
255
301
|
return { Success: true, Message: 'Connected to Nimble AMS (Salesforce)' };
|
|
256
|
-
return { Success: false, Message: `API returned ${response.Status}` };
|
|
302
|
+
return { Success: false, Message: `Nimble AMS API returned HTTP ${response.Status}` };
|
|
257
303
|
}
|
|
258
304
|
catch (err) {
|
|
259
305
|
return { Success: false, Message: err instanceof Error ? err.message : String(err) };
|
|
260
306
|
}
|
|
261
307
|
}
|
|
262
|
-
//
|
|
308
|
+
// ── URL / Response / Pagination ──────────────────────────────────────────
|
|
263
309
|
GetBaseURL(_ci, auth) {
|
|
264
|
-
|
|
265
|
-
return `${a.InstanceURL}/services/data/${a.ApiVersion}`;
|
|
310
|
+
return auth.InstanceURL;
|
|
266
311
|
}
|
|
267
|
-
|
|
268
|
-
|
|
312
|
+
/** The instance origin for all REST calls. Routes through GetBaseURL so a test harness can redirect. */
|
|
313
|
+
Origin(auth) {
|
|
314
|
+
return this.GetBaseURL(undefined, auth);
|
|
269
315
|
}
|
|
316
|
+
/** The `/services/data/v{version}` base for all Salesforce REST calls. */
|
|
317
|
+
SFDataBase(auth) {
|
|
318
|
+
return `${this.Origin(auth)}/services/data/v${auth.ApiVersion}`;
|
|
319
|
+
}
|
|
320
|
+
/** Strip the SF `attributes` envelope blob from every record (sanctioned, declared removal). */
|
|
321
|
+
TransformRecord(raw, _obj, _fields) {
|
|
322
|
+
if (!('attributes' in raw))
|
|
323
|
+
return raw;
|
|
324
|
+
const out = { ...raw };
|
|
325
|
+
delete out['attributes'];
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
ExcludedSourceKeys(_objectName) {
|
|
329
|
+
return ['attributes'];
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Strip the response envelope to expose individual records. Handles all three doors:
|
|
333
|
+
* - SF SOQL → `body.records`
|
|
334
|
+
* - Fuse out → `body.Records` (note: PascalCase; null fields omitted per record)
|
|
335
|
+
* - LMS REST → root array or single object
|
|
336
|
+
*/
|
|
270
337
|
NormalizeResponse(rawBody, _key) {
|
|
271
338
|
if (Array.isArray(rawBody))
|
|
272
339
|
return rawBody;
|
|
340
|
+
if (!rawBody || typeof rawBody !== 'object')
|
|
341
|
+
return [];
|
|
273
342
|
const body = rawBody;
|
|
274
343
|
if (Array.isArray(body['records']))
|
|
275
344
|
return body['records'];
|
|
276
|
-
if (
|
|
277
|
-
return [
|
|
278
|
-
|
|
345
|
+
if (Array.isArray(body['Records']))
|
|
346
|
+
return body['Records'];
|
|
347
|
+
// A single non-enveloped record (e.g. LMS get-one) — pass through as one record.
|
|
348
|
+
return [body];
|
|
279
349
|
}
|
|
280
350
|
ExtractPaginationInfo(rawBody, _pt, _cp, _co, _ps) {
|
|
281
351
|
const body = rawBody;
|
|
282
|
-
return { HasMore:
|
|
352
|
+
return { HasMore: body?.done === false, NextCursor: body?.nextRecordsUrl };
|
|
283
353
|
}
|
|
284
|
-
//
|
|
354
|
+
// ── FetchChanges (door-aware) ────────────────────────────────────────────
|
|
285
355
|
async FetchChanges(ctx) {
|
|
356
|
+
const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
|
|
357
|
+
const cfg = this.ReadAccessConfig(obj);
|
|
358
|
+
const family = cfg.Family ?? '';
|
|
359
|
+
if (family === 'FuseEndpoint') {
|
|
360
|
+
// The Fuse endpoint IO is a contract descriptor, not a queryable record type — nothing to pull.
|
|
361
|
+
return { Records: [], HasMore: false, Warnings: [{ Code: 'NON_QUERYABLE', Message: `"${ctx.ObjectName}" is the Fuse contract descriptor; it carries no syncable records.` }] };
|
|
362
|
+
}
|
|
363
|
+
if (family === 'LmsFamily') {
|
|
364
|
+
return this.FetchLMS(ctx, obj);
|
|
365
|
+
}
|
|
366
|
+
// Default door: Salesforce REST SOQL (also used by the Fuse outbound named-SOQL setting when one
|
|
367
|
+
// is configured — the SOQL path is the credential-free-verifiable common case).
|
|
368
|
+
return this.FetchSOQL(ctx, obj);
|
|
369
|
+
}
|
|
370
|
+
/** Salesforce REST SOQL fetch — cursor pagination via nextRecordsUrl + watermark-ordered incremental. */
|
|
371
|
+
async FetchSOQL(ctx, obj) {
|
|
286
372
|
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
287
373
|
const headers = this.BuildHeaders(auth);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
soql = `SELECT FIELDS(ALL) FROM ${ctx.ObjectName} WHERE LastModifiedDate >= ${ctx.WatermarkValue} ORDER BY LastModifiedDate ASC LIMIT ${SF_PAGE_SIZE}`;
|
|
291
|
-
}
|
|
374
|
+
const watermarkField = obj.IncrementalWatermarkField ?? 'LastModifiedDate';
|
|
375
|
+
// Follow the SF cursor when present; otherwise build the first SOQL page.
|
|
292
376
|
const url = ctx.CurrentCursor
|
|
293
|
-
? `${auth
|
|
294
|
-
: `${
|
|
377
|
+
? `${this.GetBaseURL(ctx.CompanyIntegration, auth)}${ctx.CurrentCursor}`
|
|
378
|
+
: `${this.SFDataBase(auth)}/query?q=${encodeURIComponent(this.BuildSOQL(ctx.ObjectName, watermarkField, ctx.WatermarkValue ?? null))}`;
|
|
295
379
|
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
296
|
-
if (response.Status < 200 || response.Status >= 300)
|
|
297
|
-
throw new Error(`Nimble AMS SOQL error: ${response.Status}`);
|
|
380
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
381
|
+
throw new Error(`Nimble AMS SOQL error for "${ctx.ObjectName}": HTTP ${response.Status} — ${this.SafeBody(response)}`);
|
|
382
|
+
}
|
|
298
383
|
const body = response.Body;
|
|
299
|
-
const
|
|
300
|
-
|
|
384
|
+
const raw = body.records ?? [];
|
|
385
|
+
// Defensive: the Fuse OUTBOUND door caps a single call at 50,000 records; SF native paging
|
|
386
|
+
// (≤2000/page) keeps SOQL pages well under it, so a page at/over the cap signals a misconfig.
|
|
387
|
+
const warnings = raw.length >= FUSE_MAX_RECORDS_PER_CALL
|
|
388
|
+
? [{ Code: 'PAGE_AT_RECORD_CAP', Message: `"${ctx.ObjectName}" returned ${raw.length} records in one page — at/over the ${FUSE_MAX_RECORDS_PER_CALL}-record Fuse per-call cap.` }]
|
|
389
|
+
: undefined;
|
|
390
|
+
const records = raw.map(r => this.RawToExternalRecord(r, obj, ctx.ObjectName, watermarkField, 'Id'));
|
|
391
|
+
// Watermark advances ONLY on the final page (full-batch success), and only to the max seen.
|
|
301
392
|
let newWatermark;
|
|
302
393
|
if (body.done) {
|
|
303
|
-
for (const r of
|
|
304
|
-
const mod = r[
|
|
305
|
-
if (mod && (!newWatermark || mod > newWatermark))
|
|
394
|
+
for (const r of raw) {
|
|
395
|
+
const mod = r[watermarkField];
|
|
396
|
+
if (typeof mod === 'string' && (!newWatermark || mod > newWatermark))
|
|
306
397
|
newWatermark = mod;
|
|
307
398
|
}
|
|
308
399
|
}
|
|
309
|
-
return {
|
|
400
|
+
return {
|
|
401
|
+
Records: records,
|
|
402
|
+
HasMore: body.done === false,
|
|
403
|
+
NextCursor: body.nextRecordsUrl,
|
|
404
|
+
NewWatermarkValue: body.done ? newWatermark : undefined,
|
|
405
|
+
Warnings: warnings,
|
|
406
|
+
};
|
|
310
407
|
}
|
|
311
|
-
|
|
312
|
-
|
|
408
|
+
/**
|
|
409
|
+
* Build the SOQL for the first page. CRITICAL: NO `LIMIT` — Salesforce natively paginates via
|
|
410
|
+
* done/nextRecordsUrl, so a SOQL LIMIT would cap the ENTIRE result set and SF would report
|
|
411
|
+
* done=true at that count, silently dropping every record past the limit AND advancing the
|
|
412
|
+
* watermark past them next sync. Use `>=` (not `>`) so records at the exact watermark instant
|
|
413
|
+
* aren't lost; engine dedupe absorbs the boundary re-fetch. ORDER BY the watermark for monotonicity.
|
|
414
|
+
*/
|
|
415
|
+
BuildSOQL(objectName, watermarkField, watermarkValue) {
|
|
416
|
+
let soql = `SELECT FIELDS(ALL) FROM ${objectName}`;
|
|
417
|
+
if (watermarkValue)
|
|
418
|
+
soql += ` WHERE ${watermarkField} >= ${this.FormatSOQLDateTime(watermarkValue)}`;
|
|
419
|
+
soql += ` ORDER BY ${watermarkField} ASC`;
|
|
420
|
+
return soql;
|
|
421
|
+
}
|
|
422
|
+
/** SF SOQL datetime literals are unquoted ISO-8601 (e.g. 2026-01-01T00:00:00Z). */
|
|
423
|
+
FormatSOQLDateTime(value) {
|
|
424
|
+
// Already an unquoted ISO literal → use verbatim; otherwise emit a UTC ISO string.
|
|
425
|
+
if (/^\d{4}-\d{2}-\d{2}T/.test(value))
|
|
426
|
+
return value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value) ? value : `${value}Z`;
|
|
427
|
+
const d = new Date(value);
|
|
428
|
+
return isNaN(d.getTime()) ? value : d.toISOString();
|
|
429
|
+
}
|
|
430
|
+
/** NAMS LMS REST fetch — GET with an optional `lastUpdated` incremental filter (no pagination). */
|
|
431
|
+
async FetchLMS(ctx, obj) {
|
|
313
432
|
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
433
|
+
const headers = this.BuildHeaders(auth);
|
|
434
|
+
const watermarkField = obj.IncrementalWatermarkField ?? 'lastUpdated';
|
|
435
|
+
const base = `${this.GetBaseURL(ctx.CompanyIntegration, auth)}${obj.APIPath}`;
|
|
436
|
+
const url = ctx.WatermarkValue
|
|
437
|
+
? `${base}?${encodeURIComponent(watermarkField)}=${encodeURIComponent(ctx.WatermarkValue)}`
|
|
438
|
+
: base;
|
|
439
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
440
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
441
|
+
throw new Error(`Nimble AMS LMS error for "${ctx.ObjectName}": HTTP ${response.Status} — ${this.SafeBody(response)}`);
|
|
442
|
+
}
|
|
443
|
+
const raw = this.NormalizeResponse(response.Body, null);
|
|
444
|
+
const records = raw.map(r => this.RawToExternalRecord(r, obj, ctx.ObjectName, watermarkField, 'id'));
|
|
445
|
+
let newWatermark;
|
|
446
|
+
for (const r of raw) {
|
|
447
|
+
const mod = r[watermarkField];
|
|
448
|
+
if (typeof mod === 'string' && (!newWatermark || mod > newWatermark))
|
|
449
|
+
newWatermark = mod;
|
|
450
|
+
}
|
|
451
|
+
// LMS REST returns the full set in one response (no cursor) → batch is always complete.
|
|
452
|
+
return { Records: records, HasMore: false, NewWatermarkValue: newWatermark };
|
|
453
|
+
}
|
|
454
|
+
// ── CRUD (door-aware: Fuse inbound upsert vs Salesforce REST sObject) ─────
|
|
455
|
+
async CreateRecord(ctx) {
|
|
456
|
+
const ci = ctx.CompanyIntegration;
|
|
457
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
458
|
+
if (this.IsFuseWritePath(obj)) {
|
|
459
|
+
return this.FuseUpsert(ctx.ContextUser, ci, obj, ctx.ObjectName, ctx.Attributes, /*forUpdate*/ false);
|
|
460
|
+
}
|
|
461
|
+
return this.SFRestCreate(ctx.ContextUser, ci, ctx.ObjectName, ctx.Attributes, obj);
|
|
462
|
+
}
|
|
463
|
+
async UpdateRecord(ctx) {
|
|
464
|
+
const ci = ctx.CompanyIntegration;
|
|
465
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
466
|
+
if (this.IsFuseWritePath(obj)) {
|
|
467
|
+
// Fuse is upsert-keyed by External ID — the External ID rides in the record body, not the path.
|
|
468
|
+
const result = await this.FuseUpsert(ctx.ContextUser, ci, obj, ctx.ObjectName, ctx.Attributes, /*forUpdate*/ true);
|
|
469
|
+
// For update we report the supplied ExternalID on success (Fuse returns the SalesforceId).
|
|
470
|
+
return result.Success ? { Success: true, StatusCode: result.StatusCode, ExternalID: result.ExternalID ?? ctx.ExternalID } : result;
|
|
471
|
+
}
|
|
472
|
+
return this.SFRestUpdate(ctx.ContextUser, ci, ctx.ObjectName, ctx.ExternalID, ctx.Attributes, obj);
|
|
473
|
+
}
|
|
474
|
+
async DeleteRecord(ctx) {
|
|
475
|
+
const ci = ctx.CompanyIntegration;
|
|
476
|
+
const obj = this.GetCachedObject(ci.IntegrationID, ctx.ObjectName);
|
|
477
|
+
if (!obj.DeleteAPIPath || !obj.DeleteMethod) {
|
|
478
|
+
return { Success: false, StatusCode: 0, ErrorMessage: `DeleteRecord not supported for "${ctx.ObjectName}": Fuse is upsert-only; hard delete requires Salesforce REST sObject DELETE, which this object does not declare.` };
|
|
479
|
+
}
|
|
480
|
+
const auth = await this.Authenticate(ci, ctx.ContextUser);
|
|
481
|
+
const url = this.ResolveSFRestPath(auth, obj.DeleteAPIPath, ctx.ObjectName, ctx.ExternalID);
|
|
482
|
+
const response = await this.MakeHTTPRequest(auth, url, obj.DeleteMethod, this.BuildHeaders(auth));
|
|
483
|
+
if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
|
|
484
|
+
return { Success: true, StatusCode: response.Status, ExternalID: ctx.ExternalID };
|
|
485
|
+
}
|
|
486
|
+
return { Success: false, StatusCode: response.Status, ExternalID: '', ErrorMessage: `DeleteRecord failed for "${ctx.ObjectName}": HTTP ${response.Status} — ${this.SafeBody(response)}` };
|
|
487
|
+
}
|
|
488
|
+
/** Salesforce REST sObject create — POST `/sobjects/{ObjectName}` flat body, ID in response `id`. */
|
|
489
|
+
async SFRestCreate(contextUser, ci, objectName, attributes, obj) {
|
|
490
|
+
const auth = await this.Authenticate(ci, contextUser);
|
|
491
|
+
const createPath = obj.CreateAPIPath ?? `/services/data/v{apiVersion}/sobjects/{ObjectName}/`;
|
|
492
|
+
const url = this.ResolveSFRestPath(auth, createPath, objectName, undefined);
|
|
314
493
|
const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
|
|
315
|
-
const
|
|
316
|
-
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, ctx.Attributes);
|
|
494
|
+
const response = await this.MakeHTTPRequest(auth, url, obj.CreateMethod ?? 'POST', headers, attributes);
|
|
317
495
|
if (response.Status >= 200 && response.Status < 300) {
|
|
318
496
|
const body = response.Body;
|
|
319
|
-
const newID = body['id'] == null ? undefined : String(body['id']);
|
|
320
|
-
return this.BuildCreatedResult(newID, response.Status,
|
|
497
|
+
const newID = body?.['id'] == null ? undefined : String(body['id']);
|
|
498
|
+
return this.BuildCreatedResult(newID, response.Status, objectName); // fail LOUDLY on empty id
|
|
321
499
|
}
|
|
322
|
-
return
|
|
500
|
+
return { Success: false, StatusCode: response.Status, ErrorMessage: `CreateRecord failed for "${objectName}": HTTP ${response.Status} — ${this.SafeBody(response)}` };
|
|
323
501
|
}
|
|
324
|
-
|
|
325
|
-
|
|
502
|
+
/** Salesforce REST sObject update — PATCH `/sobjects/{ObjectName}/{Id}` flat body. */
|
|
503
|
+
async SFRestUpdate(contextUser, ci, objectName, externalID, attributes, obj) {
|
|
504
|
+
const auth = await this.Authenticate(ci, contextUser);
|
|
505
|
+
const updatePath = obj.UpdateAPIPath ?? `/services/data/v{apiVersion}/sobjects/{ObjectName}/{Id}`;
|
|
506
|
+
const url = this.ResolveSFRestPath(auth, updatePath, objectName, externalID);
|
|
326
507
|
const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
return
|
|
508
|
+
const response = await this.MakeHTTPRequest(auth, url, obj.UpdateMethod ?? 'PATCH', headers, attributes);
|
|
509
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
510
|
+
return { Success: true, StatusCode: response.Status, ExternalID: externalID };
|
|
511
|
+
}
|
|
512
|
+
return { Success: false, StatusCode: response.Status, ExternalID: '', ErrorMessage: `UpdateRecord failed for "${objectName}": HTTP ${response.Status} — ${this.SafeBody(response)}` };
|
|
332
513
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
514
|
+
/**
|
|
515
|
+
* Nimble Fuse INBOUND upsert — POST `/services/apexrest/NUINT/NUIntegrationService` with the
|
|
516
|
+
* `{Request: {Name, "Authentication Key", InboundRecords:[{Fields:{...}}]}}` envelope. Parses the
|
|
517
|
+
* per-record `InboundResults` for partial success, routes the single-record create through
|
|
518
|
+
* BuildCreatedResult, and fails LOUDLY on an empty SalesforceId.
|
|
519
|
+
*/
|
|
520
|
+
async FuseUpsert(contextUser, ci, obj, objectName, attributes, forUpdate) {
|
|
521
|
+
const auth = await this.Authenticate(ci, contextUser);
|
|
522
|
+
const cfg = this.ReadAccessConfig(obj);
|
|
523
|
+
const settingName = cfg.InboundSettingName;
|
|
524
|
+
if (!settingName) {
|
|
525
|
+
return { Success: false, StatusCode: 0, ErrorMessage: `Fuse upsert for "${objectName}" requires Configuration.InboundSettingName (the admin-configured Integration-Setting name); none is set.` };
|
|
526
|
+
}
|
|
527
|
+
const authKey = (auth.Config.AccessToken ? undefined : auth.Config.ClientSecret) ?? auth.Token ?? '';
|
|
528
|
+
const envelope = {
|
|
529
|
+
Request: {
|
|
530
|
+
Name: settingName,
|
|
531
|
+
'Authentication Key': authKey,
|
|
532
|
+
InboundRecords: [{ Fields: attributes }],
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
const url = `${this.GetBaseURL(ci, auth)}${FUSE_PATH}`;
|
|
536
|
+
const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
|
|
537
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, envelope);
|
|
538
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
539
|
+
return { Success: false, StatusCode: response.Status, ErrorMessage: `Fuse upsert failed for "${objectName}": HTTP ${response.Status} — ${this.SafeBody(response)}` };
|
|
540
|
+
}
|
|
541
|
+
const body = response.Body;
|
|
542
|
+
const result = (body.InboundResults ?? [])[0];
|
|
543
|
+
const envelopeError = this.JoinErrors(body.ErrorMessages);
|
|
544
|
+
if (!result) {
|
|
545
|
+
return { Success: false, StatusCode: response.Status, ErrorMessage: `Fuse upsert for "${objectName}" returned no InboundResults${envelopeError ? ` — ${envelopeError}` : ''}.` };
|
|
546
|
+
}
|
|
547
|
+
const recordError = this.JoinErrors(result.ErrorMessages ?? result.ErrorMessage);
|
|
548
|
+
if (result.Success === false || recordError) {
|
|
549
|
+
return { Success: false, StatusCode: response.Status, ExternalID: '', ErrorMessage: `Fuse upsert for "${objectName}" failed for ExternalId="${result.ExternalId ?? ''}": ${recordError || envelopeError || body.Message || 'unknown error'}` };
|
|
550
|
+
}
|
|
551
|
+
// A 2xx with no SalesforceId is a silent loss — fail LOUDLY via BuildCreatedResult.
|
|
552
|
+
if (!forUpdate) {
|
|
553
|
+
return this.BuildCreatedResult(result.SalesforceId, response.Status, objectName);
|
|
554
|
+
}
|
|
555
|
+
const sfid = result.SalesforceId == null ? '' : String(result.SalesforceId).trim();
|
|
556
|
+
if (sfid.length === 0) {
|
|
557
|
+
return { Success: false, StatusCode: response.Status, ErrorMessage: `Fuse upsert (update) for "${objectName}" returned HTTP ${response.Status} but no SalesforceId — treating as a failure.` };
|
|
558
|
+
}
|
|
559
|
+
return { Success: true, StatusCode: response.Status, ExternalID: sfid };
|
|
560
|
+
}
|
|
561
|
+
JoinErrors(errs) {
|
|
562
|
+
if (!errs)
|
|
563
|
+
return '';
|
|
564
|
+
return Array.isArray(errs) ? errs.filter(Boolean).join('; ') : errs;
|
|
341
565
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
return
|
|
566
|
+
/** True when the object's write door is the Fuse inbound upsert (vs Salesforce REST sObject CRUD). */
|
|
567
|
+
IsFuseWritePath(obj) {
|
|
568
|
+
return (obj.CreateAPIPath ?? '').includes(FUSE_PATH) || (obj.UpdateAPIPath ?? '').includes(FUSE_PATH);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Resolve a Salesforce-REST path template, substituting `{apiVersion}` (bare version), `{ObjectName}`,
|
|
572
|
+
* and `{Id}`/`{ExternalID}`. The contract's SF-REST paths are root-relative (start at /services/...),
|
|
573
|
+
* so we prefix the instance origin only.
|
|
574
|
+
*/
|
|
575
|
+
ResolveSFRestPath(auth, pathTemplate, objectName, externalID) {
|
|
576
|
+
let path = pathTemplate
|
|
577
|
+
.replace(/\{apiVersion\}/g, auth.ApiVersion)
|
|
578
|
+
.replace(/\{ObjectName\}/g, encodeURIComponent(objectName));
|
|
579
|
+
if (externalID !== undefined) {
|
|
580
|
+
const enc = encodeURIComponent(externalID);
|
|
581
|
+
path = path.replace(/\{Id\}/g, enc).replace(/\{ID\}/g, enc).replace(/\{id\}/g, enc).replace(/\{ExternalID\}/g, enc);
|
|
582
|
+
}
|
|
583
|
+
return `${this.Origin(auth)}${path}`;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Build an ExternalRecord from a raw vendor record. The FULL record flows into `Fields` (custom-column
|
|
587
|
+
* pass-through contract M1–M4) — the ONLY removed key is the sanctioned SF `attributes` envelope blob
|
|
588
|
+
* declared in {@link ExcludedSourceKeys}. The PK field supplies ExternalID; the watermark field supplies
|
|
589
|
+
* ModifiedAt. (FetchSOQL/FetchLMS override the base fetch, so we hand-build records here.)
|
|
590
|
+
*/
|
|
591
|
+
RawToExternalRecord(raw, obj, objectType, watermarkField, pkField) {
|
|
592
|
+
const transformed = this.TransformRecord(raw, obj, this.GetCachedFields(obj.ID));
|
|
593
|
+
const excluded = new Set(this.ExcludedSourceKeys(objectType));
|
|
594
|
+
const fields = {};
|
|
595
|
+
for (const [key, value] of Object.entries(transformed)) {
|
|
596
|
+
if (excluded.has(key))
|
|
597
|
+
continue;
|
|
598
|
+
fields[key] = value;
|
|
599
|
+
}
|
|
600
|
+
const modRaw = raw[watermarkField];
|
|
601
|
+
return {
|
|
602
|
+
ExternalID: String(raw[pkField] ?? ''),
|
|
603
|
+
ObjectType: objectType,
|
|
604
|
+
Fields: fields,
|
|
605
|
+
ModifiedAt: typeof modRaw === 'string' ? new Date(modRaw) : undefined,
|
|
606
|
+
IsDeleted: raw['IsDeleted'] === true,
|
|
607
|
+
};
|
|
345
608
|
}
|
|
346
|
-
|
|
609
|
+
/** Parse the per-IO access Configuration (Family / SObjectType / AccessPath / Fuse setting names). */
|
|
610
|
+
ReadAccessConfig(obj) {
|
|
611
|
+
const raw = obj.Configuration;
|
|
612
|
+
if (!raw)
|
|
613
|
+
return {};
|
|
614
|
+
try {
|
|
615
|
+
return JSON.parse(raw);
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
return {};
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// ── Headers + HTTP transport ─────────────────────────────────────────────
|
|
347
622
|
BuildHeaders(auth) {
|
|
348
|
-
return {
|
|
623
|
+
return {
|
|
624
|
+
Authorization: `Bearer ${auth.Token}`,
|
|
625
|
+
Accept: 'application/json',
|
|
626
|
+
'User-Agent': 'MemberJunction-Integration/1.0',
|
|
627
|
+
};
|
|
349
628
|
}
|
|
350
629
|
async MakeHTTPRequest(_auth, url, method, headers, body) {
|
|
351
630
|
await this.Throttle();
|
|
@@ -356,17 +635,18 @@ let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationCon
|
|
|
356
635
|
this.tokenCache = null;
|
|
357
636
|
continue;
|
|
358
637
|
}
|
|
359
|
-
if (response.status === 429) {
|
|
360
|
-
const
|
|
361
|
-
|
|
638
|
+
if (response.status === 429 && attempt < MAX_RETRIES) {
|
|
639
|
+
const ra = Number(response.headers.get('retry-after'));
|
|
640
|
+
const wait = !isNaN(ra) && ra > 0 ? ra * 1000 : Math.min(1000 * Math.pow(2, attempt) + Math.random() * 500, 60_000);
|
|
641
|
+
await this.Sleep(wait);
|
|
362
642
|
continue;
|
|
363
643
|
}
|
|
364
644
|
if (response.status >= 500 && attempt < MAX_RETRIES) {
|
|
365
645
|
await this.Sleep(Math.min(1000 * Math.pow(2, attempt), 30_000));
|
|
366
646
|
continue;
|
|
367
647
|
}
|
|
368
|
-
const
|
|
369
|
-
return this.ToRESTResponse(response,
|
|
648
|
+
const parsed = await this.ParseBody(response);
|
|
649
|
+
return this.ToRESTResponse(response, parsed);
|
|
370
650
|
}
|
|
371
651
|
throw new Error(`Nimble AMS request failed after ${MAX_RETRIES} retries: ${url}`);
|
|
372
652
|
}
|
|
@@ -375,8 +655,14 @@ let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationCon
|
|
|
375
655
|
const tid = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
376
656
|
try {
|
|
377
657
|
const opts = { method, headers, signal: controller.signal };
|
|
378
|
-
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH'))
|
|
379
|
-
|
|
658
|
+
if (body !== undefined && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
|
659
|
+
const serialized = JSON.stringify(body);
|
|
660
|
+
// Fuse OUTBOUND enforces a 3MB-per-call ceiling — guard the request body up front.
|
|
661
|
+
if (url.includes(FUSE_PATH) && Buffer.byteLength(serialized, 'utf8') > FUSE_MAX_BYTES_PER_CALL) {
|
|
662
|
+
throw new Error(`Nimble Fuse request body exceeds the ${FUSE_MAX_BYTES_PER_CALL}-byte (3MB) per-call limit.`);
|
|
663
|
+
}
|
|
664
|
+
opts.body = serialized;
|
|
665
|
+
}
|
|
380
666
|
return await fetch(url, opts);
|
|
381
667
|
}
|
|
382
668
|
catch (err) {
|
|
@@ -388,22 +674,30 @@ let NimbleAMSConnector = class NimbleAMSConnector extends BaseRESTIntegrationCon
|
|
|
388
674
|
clearTimeout(tid);
|
|
389
675
|
}
|
|
390
676
|
}
|
|
391
|
-
async ParseBody(r) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
return [{ SourceFieldName: 'Id', DestinationFieldName: 'Id', IsKeyField: true }];
|
|
400
|
-
return obj.Fields.map(f => ({ SourceFieldName: f.Name, DestinationFieldName: f.Name, IsKeyField: f.IsPrimaryKey }));
|
|
677
|
+
async ParseBody(r) {
|
|
678
|
+
const ct = r.headers.get('content-type') ?? '';
|
|
679
|
+
return ct.includes('json') ? r.json().catch(() => null) : r.text();
|
|
680
|
+
}
|
|
681
|
+
ToRESTResponse(r, body) {
|
|
682
|
+
const h = {};
|
|
683
|
+
r.headers.forEach((v, k) => { h[k.toLowerCase()] = v; });
|
|
684
|
+
return { Status: r.status, Body: body, Headers: h };
|
|
401
685
|
}
|
|
686
|
+
SafeBody(r) {
|
|
687
|
+
const s = typeof r.Body === 'string' ? r.Body : JSON.stringify(r.Body);
|
|
688
|
+
return (s ?? '').substring(0, 300);
|
|
689
|
+
}
|
|
690
|
+
async Throttle() {
|
|
691
|
+
const elapsed = Date.now() - this.lastRequestTime;
|
|
692
|
+
if (elapsed < MIN_REQUEST_INTERVAL_MS)
|
|
693
|
+
await this.Sleep(MIN_REQUEST_INTERVAL_MS - elapsed);
|
|
694
|
+
}
|
|
695
|
+
Sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
|
|
402
696
|
};
|
|
403
697
|
NimbleAMSConnector = __decorate([
|
|
404
698
|
RegisterClass(BaseIntegrationConnector, 'NimbleAMSConnector')
|
|
405
699
|
], NimbleAMSConnector);
|
|
406
700
|
export { NimbleAMSConnector };
|
|
407
|
-
// Tree-shaking prevention — REQUIRED
|
|
701
|
+
// Tree-shaking prevention — REQUIRED so @RegisterClass survives bundling.
|
|
408
702
|
export function LoadNimbleAMSConnector() { }
|
|
409
703
|
//# sourceMappingURL=NimbleAMSConnector.js.map
|