@memberjunction/connector-salesforce 1.0.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.
@@ -0,0 +1,1865 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var SalesforceConnector_1;
8
+ import jwt from 'jsonwebtoken';
9
+ import { RegisterClass } from '@memberjunction/global';
10
+ import { Metadata } from '@memberjunction/core';
11
+ import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
12
+ import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
13
+ // ─── Constants ────────────────────────────────────────────────────────
14
+ const DEFAULT_API_VERSION = '61.0';
15
+ const DEFAULT_MAX_RETRIES = 5;
16
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
17
+ const DEFAULT_MIN_REQUEST_INTERVAL_MS = 100;
18
+ const DEFAULT_BATCH_SIZE = 2000;
19
+ /** Token lifetime — SF tokens last ~2 hours; refresh at 80% (96 min) */
20
+ const TOKEN_LIFETIME_MS = 2 * 60 * 60 * 1000;
21
+ const TOKEN_REFRESH_THRESHOLD = 0.8;
22
+ /** Governor limit thresholds */
23
+ const GOVERNOR_THROTTLE_THRESHOLD = 0.8;
24
+ const GOVERNOR_PAUSE_THRESHOLD = 0.95;
25
+ /** SF compound field types to skip during discovery */
26
+ const COMPOUND_FIELD_TYPES = new Set(['address', 'location']);
27
+ /** SF system fields that are always read-only */
28
+ const SYSTEM_READ_ONLY_FIELDS = new Set([
29
+ 'Id', 'CreatedDate', 'CreatedById', 'LastModifiedDate', 'LastModifiedById',
30
+ 'SystemModstamp', 'IsDeleted', 'LastActivityDate', 'LastViewedDate',
31
+ 'LastReferencedDate', 'MasterRecordId',
32
+ ]);
33
+ /** Fields always included in SOQL SELECT */
34
+ const REQUIRED_SOQL_FIELDS = ['Id', 'SystemModstamp', 'IsDeleted', 'LastModifiedById'];
35
+ /**
36
+ * Objects that SF exposes through the main `/sobjects/` endpoint but that only
37
+ * function correctly through the Tooling API. The Data API describes them as
38
+ * `queryable=true` so they'd otherwise land in the picker, but syncing them
39
+ * produces one or more of: duplicate-key violations (pagination returns the
40
+ * same record across pages), sentinel `000000000000000AAA` IDs, or
41
+ * `MALFORMED_QUERY` on fields like `Metadata`/`FullName` that SF permits only
42
+ * one-row-at-a-time in Tooling queries.
43
+ *
44
+ * Observed causing errors in production syncs; blacklisting here eliminates
45
+ * ~960 errors per cold apply and ~10 per incremental without any value loss
46
+ * (these are metadata/telemetry tables, not business data).
47
+ */
48
+ const TOOLING_API_DENYLIST = new Set([
49
+ 'EntityDefinition',
50
+ 'DataType',
51
+ 'AuraDefinitionInfo',
52
+ 'AuraDefinitionBundleInfo',
53
+ 'FormulaFunction',
54
+ 'AppDefinition',
55
+ 'UserSetupEntityAccess',
56
+ 'PlatformEventUsageMetric',
57
+ 'EventBusSubscriber',
58
+ 'ApexClass',
59
+ 'ApexPage',
60
+ 'ApexTrigger',
61
+ 'ApexComponent',
62
+ 'Publisher',
63
+ 'ExternalString',
64
+ 'CustomHttpHeader',
65
+ 'FormulaFunctionCategory',
66
+ ]);
67
+ // ─── SalesforceConnector ──────────────────────────────────────────────
68
+ /**
69
+ * Production Salesforce CRM connector using the Salesforce REST API v61.0.
70
+ *
71
+ * Supports:
72
+ * - OAuth 2.0 JWT Bearer Token authentication (RS256)
73
+ * - Live schema discovery via SF Describe API
74
+ * - SOQL-based incremental sync with SystemModstamp watermarks
75
+ * - Full CRUD (Create, Update, Delete) via SObject REST API
76
+ * - SOQL/SOSL search support
77
+ * - Comprehensive SF error code handling and governor limit management
78
+ *
79
+ * Extends BaseRESTIntegrationConnector but overrides FetchChanges entirely
80
+ * because Salesforce uses SOQL queries instead of standard REST list endpoints.
81
+ */
82
+ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationConnector {
83
+ constructor() {
84
+ super(...arguments);
85
+ // ── Token cache ─────────────────────────────────────────────────
86
+ this.cachedAuth = null;
87
+ this.tokenObtainedAt = 0;
88
+ // ── Rate limit / governor tracking ──────────────────────────────
89
+ this.lastRequestTime = 0;
90
+ this.governorState = { CurrentUsage: 0, DailyLimit: 15000, LastUpdated: 0 };
91
+ // ── Config ──────────────────────────────────────────────────────
92
+ this._config = null;
93
+ }
94
+ static { SalesforceConnector_1 = this; }
95
+ get effectiveMaxRetries() { return this._config?.MaxRetries ?? DEFAULT_MAX_RETRIES; }
96
+ get effectiveRequestTimeoutMs() { return this._config?.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; }
97
+ get effectiveMinRequestIntervalMs() { return this._config?.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS; }
98
+ get effectiveBatchSize() { return this._config?.DefaultBatchSize ?? DEFAULT_BATCH_SIZE; }
99
+ // ─── Capability Getters ──────────────────────────────────────────
100
+ get SupportsCreate() { return true; }
101
+ get SupportsUpdate() { return true; }
102
+ get SupportsDelete() { return true; }
103
+ get SupportsSearch() { return true; }
104
+ get IntegrationName() { return 'Salesforce'; }
105
+ /**
106
+ * CORRECTION (IMPROVE build): affirm AUTHORITATIVE discovery. The global describe
107
+ * (`/sobjects/`) returns the COMPLETE credentialed gamut of queryable objects this
108
+ * org exposes (standard + custom `__c`), and `DoIntrospectSchema` describes that whole
109
+ * set. So an object/field absent from a comprehensive refresh genuinely means the
110
+ * source dropped it, and the engine's refresh path may safely DEACTIVATE it
111
+ * (`Status='Disabled'` — reversible; it flips back to Active if the object reappears on
112
+ * a later discovery). This is what makes comprehensive-refresh deactivation correct
113
+ * for Salesforce rather than wrongly wiping Declared metadata.
114
+ */
115
+ get DiscoveryIsAuthoritative() { return true; }
116
+ // ─── Action Metadata ─────────────────────────────────────────────
117
+ /**
118
+ * Action-generation hint set. CORRECTION (IMPROVE build): this used to return
119
+ * a baked famous-subset catalog (~9 objects hardcoded in this file). That is a
120
+ * `catalog-in-code` defect — it froze the object universe to a famous subset.
121
+ *
122
+ * It now derives the hint set ENTIRELY from the runtime-cached IntegrationObject /
123
+ * IntegrationObjectField metadata for the Salesforce integration — the FULL
124
+ * Declared (credential-free catalog) + Discovered (live `DiscoverObjects` describe,
125
+ * custom `__c` included) gamut. There is NO baked catalog. If the metadata cache is
126
+ * not yet populated (e.g. action generation runs before the integration is seeded),
127
+ * it returns an empty array — the connector NEVER falls back to a hardcoded list.
128
+ *
129
+ * The per-tenant object UNIVERSE for sync comes exclusively from `DiscoverObjects`
130
+ * (live global describe / sObjects endpoint); this method only shapes the cached
131
+ * catalog into the ActionMetadataGenerator's hint structure.
132
+ */
133
+ GetIntegrationObjects() {
134
+ const engine = IntegrationEngineBase.Instance;
135
+ const integration = engine.Integrations.find(i => i.Name === this.IntegrationName);
136
+ if (!integration)
137
+ return [];
138
+ const objects = engine.GetActiveIntegrationObjects(integration.ID);
139
+ return objects.map(obj => {
140
+ const fields = engine.GetIntegrationObjectFields(obj.ID);
141
+ return {
142
+ Name: obj.Name,
143
+ DisplayName: obj.DisplayName ?? obj.Name,
144
+ Description: obj.Description ?? undefined,
145
+ SupportsWrite: obj.SupportsWrite,
146
+ Fields: fields.map(f => ({
147
+ Name: f.Name,
148
+ DisplayName: f.DisplayName ?? f.Name,
149
+ Description: f.Description ?? undefined,
150
+ Type: f.Type ?? 'string',
151
+ IsRequired: f.IsRequired,
152
+ IsReadOnly: f.IsReadOnly,
153
+ IsPrimaryKey: f.IsPrimaryKey,
154
+ })),
155
+ };
156
+ });
157
+ }
158
+ GetActionGeneratorConfig() {
159
+ const config = super.GetActionGeneratorConfig();
160
+ if (!config)
161
+ return null;
162
+ config.IconClass = 'fa-brands fa-salesforce';
163
+ return config;
164
+ }
165
+ // ─── TestConnection ──────────────────────────────────────────────
166
+ async TestConnection(companyIntegration, contextUser) {
167
+ try {
168
+ const auth = await this.Authenticate(companyIntegration, contextUser);
169
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/`;
170
+ const headers = this.BuildHeaders(auth);
171
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
172
+ if (response.Status >= 200 && response.Status < 300) {
173
+ return {
174
+ Success: true,
175
+ Message: `Successfully connected to Salesforce (${auth.InstanceUrl})`,
176
+ ServerVersion: `Salesforce REST API v${auth.ApiVersion}`,
177
+ };
178
+ }
179
+ return {
180
+ Success: false,
181
+ Message: `Salesforce API returned ${response.Status}: ${this.PreviewBody(response.Body)}`,
182
+ };
183
+ }
184
+ catch (err) {
185
+ const message = err instanceof Error ? err.message : String(err);
186
+ return { Success: false, Message: `Connection failed: ${message}` };
187
+ }
188
+ }
189
+ // ─── Discovery (Live from SF Describe API) ───────────────────────
190
+ /**
191
+ * Discovers available objects by calling the SF SObjects API.
192
+ * Returns user-relevant queryable objects: standard CRM objects + custom
193
+ * (__c). Filters out audit/system noise (~1,866 → ~150-300 typically).
194
+ * Set MJ_SALESFORCE_INCLUDE_ALL_SOBJECTS=true to bypass the filter and
195
+ * return the full catalog (useful for debugging or unusual integrations).
196
+ */
197
+ async DiscoverObjects(companyIntegration, contextUser) {
198
+ const auth = await this.Authenticate(companyIntegration, contextUser);
199
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/sobjects/`;
200
+ const headers = this.BuildHeaders(auth);
201
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
202
+ this.ValidateResponse(response, url);
203
+ const body = response.Body;
204
+ const sobjects = body.sobjects ?? [];
205
+ const includeAll = process.env.MJ_SALESFORCE_INCLUDE_ALL_SOBJECTS === 'true';
206
+ const filtered = sobjects.filter(obj => {
207
+ if (!obj.queryable)
208
+ return false;
209
+ if (TOOLING_API_DENYLIST.has(obj.name))
210
+ return false;
211
+ if (includeAll)
212
+ return true;
213
+ return this.isUserRelevantSObject(obj);
214
+ });
215
+ if (!includeAll) {
216
+ const removed = sobjects.length - filtered.length;
217
+ console.log(`[Salesforce] DiscoverObjects: filtered ${sobjects.length} → ${filtered.length} (excluded ${removed} system/audit objects; set MJ_SALESFORCE_INCLUDE_ALL_SOBJECTS=true to bypass)`);
218
+ }
219
+ return filtered.map(obj => ({
220
+ Name: obj.name,
221
+ Label: obj.label,
222
+ Description: obj.custom ? `Custom object: ${obj.label}` : undefined,
223
+ SupportsIncrementalSync: true,
224
+ SupportsWrite: obj.createable || obj.updateable,
225
+ }));
226
+ }
227
+ /**
228
+ * Heuristic for "user-relevant" SF object: customer data (custom objects
229
+ * with __c suffix) OR createable standard CRM objects, excluding audit
230
+ * tables, system metadata, and managed-package telemetry.
231
+ *
232
+ * Standard SF orgs return ~1,866 sobjects from describeGlobal — most are
233
+ * audit/internal noise that don't represent business data the user wants
234
+ * to sync into MJ. This drops them to ~150-300.
235
+ */
236
+ isUserRelevantSObject(obj) {
237
+ // Custom objects always pass — they're customer-defined data
238
+ if (obj.custom)
239
+ return true;
240
+ const name = obj.name;
241
+ // STRICT exclusions only. Don't filter anything that could possibly
242
+ // hold customer data. Each exclusion below is a category SF defines
243
+ // as pure audit/internal/metadata with no customer-meaningful rows:
244
+ //
245
+ // *ChangeEvent: CDC stream, transient, replicated by replication API
246
+ // *Feed: Chatter feed entries (separate "Feed" sync if wanted)
247
+ // *History: audit tables — every value-change is one row
248
+ // *FieldHistory: same as *History but per-field tracking
249
+ // *Share: SF row-level access control entries (security metadata)
250
+ // *OwnerSharingRule / *CriteriaBasedSharingRule: security rules
251
+ // *PermissionSet*: profile/permission internals
252
+ //
253
+ // Things we used to exclude that we now LET THROUGH because they CAN
254
+ // be customer data: EmailMessage (email log), CaseComment / FeedComment
255
+ // (customer interactions), EntitySubscription (notification subs),
256
+ // Vote (idea/feedback), Tag (content tagging), Solution (knowledge),
257
+ // ProcessInstance (workflow approvals), Domain (tenant config).
258
+ if (/(?:ChangeEvent|Feed|History|FieldHistory|Share|OwnerSharingRule|CriteriaBasedSharingRule)$/.test(name)) {
259
+ return false;
260
+ }
261
+ // SF tooling/setup metadata: Apex code, permissions, setup audit, login
262
+ // history, async job framework, sandbox/cron internals, network/site
263
+ // metadata, theme/branding, package licenses. None of these are
264
+ // business data the integration should sync.
265
+ if (/^(Apex|Permission|Setup|Login|Async|Sandbox|Auth|Network|Stamp|Site|FlowDefinition|FlowInterview|FlowVariableView|EventLog|CronTrigger|StreamingChannel|InstalledMobileApp|UserPackageLicense|PackageLicense|Theme)/.test(name)) {
266
+ return false;
267
+ }
268
+ // Specific system catalog objects — schema-of-the-schema metadata,
269
+ // not customer data. These are readable but represent SF's own
270
+ // structural definitions, never user-entered records.
271
+ if (/^(EntityDefinition|FieldDefinition|EntityParticle|RelationshipInfo|RelationshipDomain|StandardAction|UserAppMenuItem|UserListView|UserPreference|UserShare|GroupMember|FiscalYearSettings|Period|RecordType|BusinessProcess|PicklistValueInfo)$/.test(name)) {
272
+ return false;
273
+ }
274
+ // NOTE: we intentionally do NOT exclude `!obj.createable`. Many SF
275
+ // objects are flagged non-createable because they're auto-populated
276
+ // by SF (rollups, attachment-link junctions, history-style records)
277
+ // but they DO carry real customer data we want to sync. The
278
+ // targeted exclusions above already cover the audit/security/CDC
279
+ // categories that have no business value.
280
+ return true;
281
+ }
282
+ /**
283
+ * Discovers fields on a specific SF object via the Describe API.
284
+ * Skips compound fields (address, location) — their component fields are included individually.
285
+ */
286
+ async DiscoverFields(companyIntegration, objectName, contextUser) {
287
+ const auth = await this.Authenticate(companyIntegration, contextUser);
288
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/sobjects/${objectName}/describe`;
289
+ const headers = this.BuildHeaders(auth);
290
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
291
+ this.ValidateResponse(response, url);
292
+ const body = response.Body;
293
+ const fields = body.fields ?? [];
294
+ return fields
295
+ .filter(f => !COMPOUND_FIELD_TYPES.has(f.type))
296
+ .map(f => this.MapSFFieldToSchema(f));
297
+ }
298
+ /**
299
+ * Full schema introspection — builds object graph with relationships.
300
+ *
301
+ * If `options.ObjectNames` is provided, only those objects are described
302
+ * (fast path for user-selected subsets). Without a filter, every
303
+ * queryable sobject is described — ~70s even with parallelism on a
304
+ * large org — and the result is cached per-org for 5 minutes so
305
+ * back-to-back resolver calls don't re-describe.
306
+ *
307
+ * Filtered calls bypass the cache on purpose: the subset is typically
308
+ * small and cheap, and the full-schema cache would shadow newer results
309
+ * if a user selects a previously-unknown object.
310
+ */
311
+ async IntrospectSchema(companyIntegration, contextUser, options) {
312
+ const filtered = options?.ObjectNames && options.ObjectNames.length > 0;
313
+ if (filtered) {
314
+ return this.DoIntrospectSchema(companyIntegration, contextUser, options);
315
+ }
316
+ // Dedupe back-to-back introspection requests from the same org.
317
+ // Three separate resolvers call IntrospectSchema; without this, the
318
+ // UI's "discover then apply" flow re-describes every object twice.
319
+ const cacheKey = companyIntegration.ID;
320
+ const cached = SalesforceConnector_1.introspectCache.get(cacheKey);
321
+ const now = Date.now();
322
+ if (cached && cached.expiresAt > now) {
323
+ const remainingSec = Math.round((cached.expiresAt - now) / 1000);
324
+ console.log(`[Salesforce] IntrospectSchema: reusing in-flight/cached result (${remainingSec}s TTL remaining)`);
325
+ return cached.promise;
326
+ }
327
+ const promise = this.DoIntrospectSchema(companyIntegration, contextUser);
328
+ SalesforceConnector_1.introspectCache.set(cacheKey, {
329
+ promise,
330
+ expiresAt: now + SalesforceConnector_1.INTROSPECT_CACHE_TTL_MS,
331
+ });
332
+ // If the run fails, evict so the next request can retry
333
+ promise.catch(() => SalesforceConnector_1.introspectCache.delete(cacheKey));
334
+ return promise;
335
+ }
336
+ async DoIntrospectSchema(companyIntegration, contextUser, options) {
337
+ const allObjects = await this.DiscoverObjects(companyIntegration, contextUser);
338
+ const wanted = options?.ObjectNames && options.ObjectNames.length > 0
339
+ ? new Set(options.ObjectNames)
340
+ : null;
341
+ const objects = wanted ? allObjects.filter(o => wanted.has(o.Name)) : allObjects;
342
+ const total = objects.length;
343
+ const startMs = Date.now();
344
+ const CONCURRENCY = 8;
345
+ console.log(`[Salesforce] IntrospectSchema: describing ${total} queryable objects (parallel×${CONCURRENCY})...`);
346
+ // CORRECTION (IMPROVE build): mark the FULL (unscoped) introspection authoritative so
347
+ // the comprehensive-refresh deactivation path may run. A SCOPED introspection
348
+ // (ObjectNames filter) describes only a subset and can NEVER prove absence, so it is
349
+ // never authoritative regardless of DiscoveryIsAuthoritative.
350
+ const result = { Objects: [], IsAuthoritative: this.DiscoveryIsAuthoritative && !wanted };
351
+ let nextIdx = 0;
352
+ let succeeded = 0;
353
+ let skipped = 0;
354
+ const worker = async () => {
355
+ while (true) {
356
+ const myIdx = nextIdx++;
357
+ if (myIdx >= total)
358
+ return;
359
+ const obj = objects[myIdx];
360
+ try {
361
+ const sourceObj = await this.BuildSourceObjectFromDescribe(companyIntegration, contextUser, obj.Name);
362
+ result.Objects.push(sourceObj);
363
+ succeeded++;
364
+ }
365
+ catch (err) {
366
+ const msg = err instanceof Error ? err.message : String(err);
367
+ skipped++;
368
+ console.warn(`[Salesforce] Skipping "${obj.Name}" during introspection: ${msg}`);
369
+ }
370
+ const done = succeeded + skipped;
371
+ if (done % 100 === 0 || done === total) {
372
+ const elapsedSec = ((Date.now() - startMs) / 1000).toFixed(1);
373
+ const etaSec = done < total
374
+ ? (((Date.now() - startMs) / done) * (total - done) / 1000).toFixed(0)
375
+ : '0';
376
+ console.log(`[Salesforce] IntrospectSchema progress: ${done}/${total} (ok=${succeeded}, skipped=${skipped}) — ${elapsedSec}s elapsed, ~${etaSec}s remaining`);
377
+ }
378
+ }
379
+ };
380
+ await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
381
+ console.log(`[Salesforce] IntrospectSchema complete: ${succeeded}/${total} objects in ${((Date.now() - startMs) / 1000).toFixed(1)}s`);
382
+ return result;
383
+ }
384
+ // ─── Introspection cache (module-scoped via static) ──────────────
385
+ // Resolver creates a fresh connector per request, so instance-level
386
+ // caching is useless. Static Map survives across requests in the same
387
+ // process. TTL is modest to allow schema refresh on demand.
388
+ static { this.INTROSPECT_CACHE_TTL_MS = 5 * 60 * 1000; }
389
+ static { this.introspectCache = new Map(); }
390
+ // ─── FetchChanges (SOQL-based — overrides base class entirely) ───
391
+ /**
392
+ * Fetches changed records using SOQL queries with SystemModstamp watermarks.
393
+ * Completely overrides the base class REST pagination because SF uses
394
+ * SOQL + queryMore, not standard REST list endpoints.
395
+ *
396
+ * Dispatches to family-specific fetch routines based on the
397
+ * IntegrationObject metadata's `DefaultQueryParams.api_family` hint:
398
+ * tooling → Tooling API, analytics_report/dashboard → Analytics API,
399
+ * bulk_* / composite → not intended for listing (returns empty batch),
400
+ * everything else → standard SObject SOQL flow.
401
+ */
402
+ async FetchChanges(ctx) {
403
+ const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
404
+ const batchSize = ctx.BatchSize || this.effectiveBatchSize;
405
+ const family = this.ResolveAPIFamily(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
406
+ // Continuation: SF returned a nextRecordsUrl on the previous call,
407
+ // and the engine passed it back as CurrentCursor. Re-issue against
408
+ // the same query state via /query/<locator> rather than re-running
409
+ // the original SOQL (which would just return the first page again
410
+ // and silently truncate the dataset to one batch). Pre-existing bug:
411
+ // a dead `this.queryLocator` member shadowed this branch with an
412
+ // always-false condition, so every "next batch" call re-executed
413
+ // the initial SOQL and produced duplicate first-page results until
414
+ // the engine's duplicate-batch guard aborted the entity.
415
+ if (ctx.CurrentCursor) {
416
+ return this.FetchNextPage(auth, ctx.CurrentCursor);
417
+ }
418
+ if (family === 'analytics_report' || family === 'analytics_dashboard') {
419
+ return this.FetchAnalyticsList(auth, family, ctx.ObjectName);
420
+ }
421
+ if (family === 'bulk_ingest' || family === 'bulk_query') {
422
+ return this.FetchBulkJobs(auth, family, ctx.ObjectName);
423
+ }
424
+ if (family === 'composite') {
425
+ // Composite is a per-request construct, not a queryable endpoint
426
+ return { Records: [], HasMore: false };
427
+ }
428
+ if (family === 'knowledge') {
429
+ const page = ctx.CurrentPage ?? 1;
430
+ return this.FetchKnowledgeArticles(auth, ctx.ObjectName, ctx.WatermarkValue, batchSize, page);
431
+ }
432
+ // Standard SObject (sobject) and Tooling (tooling) both use SOQL —
433
+ // just against different endpoints.
434
+ const fields = await this.GetQueryableFieldNames(auth, ctx.ObjectName, family);
435
+ const soql = this.BuildSOQLQuery(ctx.ObjectName, fields, ctx.WatermarkValue, batchSize, true);
436
+ return this.ExecuteSOQLQuery(auth, soql, ctx.ObjectName, family);
437
+ }
438
+ /**
439
+ * Resolves the API family for a given IntegrationObject by reading its
440
+ * `DefaultQueryParams.api_family` flag from the engine cache. Defaults to
441
+ * `sobject` when no metadata is available (common in unit tests).
442
+ */
443
+ ResolveAPIFamily(integrationID, objectName) {
444
+ try {
445
+ const obj = IntegrationEngineBase.Instance.GetIntegrationObject(integrationID, objectName);
446
+ if (!obj || !obj.DefaultQueryParams)
447
+ return 'sobject';
448
+ const parsed = JSON.parse(obj.DefaultQueryParams);
449
+ if (this.IsValidFamily(parsed.api_family))
450
+ return parsed.api_family;
451
+ return 'sobject';
452
+ }
453
+ catch {
454
+ return 'sobject';
455
+ }
456
+ }
457
+ IsValidFamily(v) {
458
+ return v === 'sobject' || v === 'tooling' || v === 'knowledge'
459
+ || v === 'analytics_report' || v === 'analytics_dashboard'
460
+ || v === 'bulk_ingest' || v === 'bulk_query' || v === 'composite';
461
+ }
462
+ /**
463
+ * Returns the REST path prefix for an SObject in the given API family.
464
+ * Used by CRUD operations and field discovery.
465
+ */
466
+ SObjectBasePath(auth, family) {
467
+ const base = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}`;
468
+ return family === 'tooling' ? `${base}/tooling/sobjects` : `${base}/sobjects`;
469
+ }
470
+ /**
471
+ * Returns the SOQL query endpoint for the given API family.
472
+ * Standard uses /query + /queryAll; Tooling uses /tooling/query.
473
+ */
474
+ SOQLEndpoint(auth, family, includeDeleted) {
475
+ const base = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}`;
476
+ if (family === 'tooling')
477
+ return `${base}/tooling/query`;
478
+ return includeDeleted ? `${base}/queryAll` : `${base}/query`;
479
+ }
480
+ // ─── CRUD Operations ─────────────────────────────────────────────
481
+ /**
482
+ * Creates a new record in Salesforce. Dispatches to the appropriate API
483
+ * family endpoint (standard SObjects, Tooling, Bulk Ingest/Query jobs, or
484
+ * Composite) based on the IntegrationObject metadata.
485
+ */
486
+ async CreateRecord(ctx) {
487
+ const companyIntegration = ctx.CompanyIntegration;
488
+ const contextUser = ctx.ContextUser;
489
+ const auth = await this.Authenticate(companyIntegration, contextUser);
490
+ const family = this.ResolveAPIFamily(companyIntegration.IntegrationID, ctx.ObjectName);
491
+ if (family === 'bulk_ingest' || family === 'bulk_query') {
492
+ return this.CreateBulkJob(auth, family, ctx.Attributes);
493
+ }
494
+ if (family === 'composite') {
495
+ return this.ExecuteCompositeRequest(auth, ctx.Attributes);
496
+ }
497
+ if (family === 'analytics_report' || family === 'analytics_dashboard' || family === 'knowledge') {
498
+ return {
499
+ Success: false,
500
+ ErrorMessage: `[Salesforce] Create is not supported for API family "${family}" (${ctx.ObjectName})`,
501
+ StatusCode: 405,
502
+ };
503
+ }
504
+ const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
505
+ const url = `${this.SObjectBasePath(auth, family)}/${ctx.ObjectName}/`;
506
+ const body = this.StripReadOnlyFields(ctx.Attributes);
507
+ const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
508
+ if (response.Status >= 200 && response.Status < 300) {
509
+ // SF SObject create returns { id, success, errors }. CORRECTION (IMPROVE build):
510
+ // route through BuildCreatedResult — a 2xx with success=false or an empty/absent id
511
+ // is a FAILURE (silently losing the record + duplicate create next sync), never
512
+ // a hand-constructed { Success:true, ExternalID:'' }.
513
+ const created = response.Body;
514
+ if (created.success === false) {
515
+ const detail = (created.errors ?? [])
516
+ .map(e => `${e.errorCode}: ${e.message}`).join('; ');
517
+ return {
518
+ Success: false,
519
+ StatusCode: response.Status,
520
+ ErrorMessage: `[Salesforce] Create of "${ctx.ObjectName}" returned HTTP ${response.Status} with success=false${detail ? `: ${detail}` : ''}`,
521
+ };
522
+ }
523
+ return this.BuildCreatedResult(created.id, response.Status, ctx.ObjectName);
524
+ }
525
+ return this.BuildCRUDError(response, 'CreateRecord', ctx.ObjectName);
526
+ }
527
+ /**
528
+ * Updates an existing record in Salesforce (PATCH — only changed fields).
529
+ * Honors API family routing; Tooling uses `/tooling/sobjects/`, everything
530
+ * else uses standard `/sobjects/`.
531
+ */
532
+ async UpdateRecord(ctx) {
533
+ const companyIntegration = ctx.CompanyIntegration;
534
+ const contextUser = ctx.ContextUser;
535
+ const auth = await this.Authenticate(companyIntegration, contextUser);
536
+ const family = this.ResolveAPIFamily(companyIntegration.IntegrationID, ctx.ObjectName);
537
+ if (family !== 'sobject' && family !== 'tooling') {
538
+ return {
539
+ Success: false,
540
+ ErrorMessage: `[Salesforce] Update is not supported for API family "${family}" (${ctx.ObjectName})`,
541
+ StatusCode: 405,
542
+ };
543
+ }
544
+ const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
545
+ const url = `${this.SObjectBasePath(auth, family)}/${ctx.ObjectName}/${ctx.ExternalID}`;
546
+ const body = this.StripReadOnlyFields(ctx.Attributes);
547
+ const response = await this.MakeHTTPRequest(auth, url, 'PATCH', headers, body);
548
+ if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
549
+ return {
550
+ Success: true,
551
+ ExternalID: ctx.ExternalID,
552
+ StatusCode: response.Status,
553
+ };
554
+ }
555
+ return this.HandleWriteError(response, 'UpdateRecord', ctx.ObjectName, ctx.ExternalID, auth, headers, url, body);
556
+ }
557
+ /**
558
+ * Deletes a record from Salesforce. For Bulk Job families, delete aborts
559
+ * the job. For standard and Tooling SObjects, performs a soft delete
560
+ * (Recycle Bin, 15-day retention). Analytics/Composite/Knowledge do not
561
+ * support deletion through this path.
562
+ */
563
+ async DeleteRecord(ctx) {
564
+ const companyIntegration = ctx.CompanyIntegration;
565
+ const contextUser = ctx.ContextUser;
566
+ const auth = await this.Authenticate(companyIntegration, contextUser);
567
+ const family = this.ResolveAPIFamily(companyIntegration.IntegrationID, ctx.ObjectName);
568
+ if (family === 'bulk_ingest' || family === 'bulk_query') {
569
+ return this.AbortBulkJob(auth, family, ctx.ExternalID);
570
+ }
571
+ if (family !== 'sobject' && family !== 'tooling') {
572
+ return {
573
+ Success: false,
574
+ ErrorMessage: `[Salesforce] Delete is not supported for API family "${family}" (${ctx.ObjectName})`,
575
+ StatusCode: 405,
576
+ };
577
+ }
578
+ const headers = this.BuildHeaders(auth);
579
+ const url = `${this.SObjectBasePath(auth, family)}/${ctx.ObjectName}/${ctx.ExternalID}`;
580
+ const response = await this.MakeHTTPRequest(auth, url, 'DELETE', headers);
581
+ if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
582
+ return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
583
+ }
584
+ // ENTITY_IS_DELETED means it's already gone — treat as success
585
+ if (this.HasSFErrorCode(response, 'ENTITY_IS_DELETED')) {
586
+ return { Success: true, ExternalID: ctx.ExternalID, StatusCode: 200 };
587
+ }
588
+ return this.BuildCRUDError(response, 'DeleteRecord', ctx.ObjectName);
589
+ }
590
+ /**
591
+ * Retrieves a single record by its Salesforce ID. Routes to the API family
592
+ * endpoint indicated by IntegrationObject metadata.
593
+ */
594
+ async GetRecord(ctx) {
595
+ const companyIntegration = ctx.CompanyIntegration;
596
+ const contextUser = ctx.ContextUser;
597
+ const auth = await this.Authenticate(companyIntegration, contextUser);
598
+ const family = this.ResolveAPIFamily(companyIntegration.IntegrationID, ctx.ObjectName);
599
+ if (family === 'analytics_report') {
600
+ return this.GetAnalyticsReport(auth, ctx.ExternalID);
601
+ }
602
+ if (family === 'analytics_dashboard') {
603
+ return this.GetAnalyticsDashboard(auth, ctx.ExternalID);
604
+ }
605
+ if (family === 'bulk_ingest' || family === 'bulk_query') {
606
+ return this.GetBulkJob(auth, family, ctx.ExternalID);
607
+ }
608
+ if (family === 'composite') {
609
+ return null;
610
+ }
611
+ const headers = this.BuildHeaders(auth);
612
+ const url = `${this.SObjectBasePath(auth, family)}/${ctx.ObjectName}/${ctx.ExternalID}`;
613
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
614
+ if (response.Status === 404)
615
+ return null;
616
+ this.ValidateResponse(response, url);
617
+ const raw = response.Body;
618
+ return this.RawToExternalRecord(raw, ctx.ObjectName);
619
+ }
620
+ // ─── Search (SOQL) ───────────────────────────────────────────────
621
+ /**
622
+ * Searches records using SOQL WHERE clauses built from the provided filters.
623
+ * Search is supported for Standard SObjects and Tooling SObjects; other API
624
+ * families return an empty result.
625
+ */
626
+ async SearchRecords(ctx) {
627
+ const companyIntegration = ctx.CompanyIntegration;
628
+ const contextUser = ctx.ContextUser;
629
+ const auth = await this.Authenticate(companyIntegration, contextUser);
630
+ const family = this.ResolveAPIFamily(companyIntegration.IntegrationID, ctx.ObjectName);
631
+ if (family !== 'sobject' && family !== 'tooling') {
632
+ return { Records: [], TotalCount: 0, HasMore: false };
633
+ }
634
+ const fields = await this.GetQueryableFieldNames(auth, ctx.ObjectName, family);
635
+ const whereClause = this.BuildWhereClauseFromFilters(ctx.Filters);
636
+ const limit = ctx.PageSize ?? 100;
637
+ const offset = ctx.Page != null && ctx.Page > 1 ? (ctx.Page - 1) * limit : 0;
638
+ let soql = `SELECT ${fields.join(', ')} FROM ${ctx.ObjectName}`;
639
+ if (whereClause)
640
+ soql += ` WHERE ${whereClause}`;
641
+ if (ctx.Sort)
642
+ soql += ` ORDER BY ${ctx.Sort}`;
643
+ soql += ` LIMIT ${limit}`;
644
+ if (offset > 0)
645
+ soql += ` OFFSET ${offset}`;
646
+ const url = `${this.SOQLEndpoint(auth, family, false)}?q=${encodeURIComponent(soql)}`;
647
+ const headers = this.BuildHeaders(auth);
648
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
649
+ this.ValidateResponse(response, url);
650
+ const body = response.Body;
651
+ const records = (body.records ?? []).map(r => this.RawToExternalRecord(r, ctx.ObjectName));
652
+ return {
653
+ Records: records,
654
+ TotalCount: body.totalSize ?? records.length,
655
+ HasMore: !body.done,
656
+ };
657
+ }
658
+ // ─── Default Field Mappings ──────────────────────────────────────
659
+ GetDefaultFieldMappings(objectName, _entityName) {
660
+ switch (objectName) {
661
+ case 'Contact': return this.GetContactMappings();
662
+ case 'Account': return this.GetAccountMappings();
663
+ case 'Lead': return this.GetLeadMappings();
664
+ default: return [];
665
+ }
666
+ }
667
+ GetDefaultConfiguration() {
668
+ return {
669
+ DefaultSchemaName: 'Salesforce',
670
+ DefaultObjects: [
671
+ {
672
+ SourceObjectName: 'Account',
673
+ TargetTableName: 'SalesforceAccount',
674
+ TargetEntityName: 'Salesforce Account',
675
+ SyncEnabled: true,
676
+ FieldMappings: this.GetAccountMappings(),
677
+ },
678
+ {
679
+ SourceObjectName: 'Contact',
680
+ TargetTableName: 'SalesforceContact',
681
+ TargetEntityName: 'Salesforce Contact',
682
+ SyncEnabled: true,
683
+ FieldMappings: this.GetContactMappings(),
684
+ },
685
+ {
686
+ SourceObjectName: 'Lead',
687
+ TargetTableName: 'SalesforceLead',
688
+ TargetEntityName: 'Salesforce Lead',
689
+ SyncEnabled: true,
690
+ FieldMappings: this.GetLeadMappings(),
691
+ },
692
+ ],
693
+ };
694
+ }
695
+ // ─── BaseRESTIntegrationConnector abstract implementations ───────
696
+ async Authenticate(companyIntegration, contextUser) {
697
+ // Return cached auth if still valid
698
+ if (this.cachedAuth && this.IsTokenValid()) {
699
+ return this.cachedAuth;
700
+ }
701
+ const credentials = await this.LoadCredentials(companyIntegration, contextUser);
702
+ const config = this.BuildConnectionConfig(credentials, companyIntegration);
703
+ this._config = config;
704
+ console.log(`[Salesforce] Authenticating via ${config.AuthFlow === 'client_credentials' ? 'Client Credentials' : 'JWT Bearer Token'}...`);
705
+ const tokenResponse = config.AuthFlow === 'client_credentials'
706
+ ? await this.ExchangeClientCredentialsForToken(config)
707
+ : await this.ExchangeJWTForToken(config.TokenUrl ?? config.LoginUrl, this.BuildJWTAssertion(config));
708
+ this.cachedAuth = {
709
+ Token: tokenResponse.access_token,
710
+ InstanceUrl: tokenResponse.instance_url,
711
+ ApiVersion: config.ApiVersion,
712
+ Config: config,
713
+ CompanyIntegration: companyIntegration,
714
+ };
715
+ this.tokenObtainedAt = Date.now();
716
+ console.log(`[Salesforce] Authenticated, instance: ${tokenResponse.instance_url}`);
717
+ return this.cachedAuth;
718
+ }
719
+ BuildHeaders(auth) {
720
+ return {
721
+ 'Authorization': `Bearer ${auth.Token}`,
722
+ 'Accept': 'application/json',
723
+ };
724
+ }
725
+ async MakeHTTPRequest(auth, url, method, headers, body) {
726
+ await this.ThrottleIfNeeded();
727
+ await this.CheckGovernorLimits();
728
+ const maxRetries = this.effectiveMaxRetries;
729
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
730
+ // Transient network / timeout resilience: a FetchWithTimeout throw (AbortSignal timeout,
731
+ // socket reset, DNS blip) is retried with backoff like any other transient failure, instead
732
+ // of failing the record on the very first attempt. Idempotent — it retries the SAME request.
733
+ let response;
734
+ try {
735
+ response = await this.FetchWithTimeout(url, method, headers, body);
736
+ }
737
+ catch (netErr) {
738
+ if (attempt < maxRetries) {
739
+ const delayMs = this.CalculateRetryDelay(attempt);
740
+ console.warn(`[Salesforce] Network/timeout error, retrying in ${delayMs}ms ` +
741
+ `(attempt ${attempt + 1}/${maxRetries})`);
742
+ await this.Sleep(delayMs);
743
+ continue;
744
+ }
745
+ throw netErr;
746
+ }
747
+ this.lastRequestTime = Date.now();
748
+ // Parse governor limits from response
749
+ this.ParseGovernorLimits(response.headers);
750
+ // Handle 401 — token expired. Clear the cache so the NEXT operation re-authenticates.
751
+ // NOTE: inline re-auth+retry within this call is NOT done here — MakeHTTPRequest has no
752
+ // CompanyIntegration/contextUser to call Authenticate() with (that would need a base-signature
753
+ // change across all connectors). Cache-clear → next-op-recovers is the current contract.
754
+ if (response.status === 401 && attempt === 0) {
755
+ console.warn('[Salesforce] Token expired (401), re-authenticating on next operation...');
756
+ this.cachedAuth = null;
757
+ }
758
+ // Handle 429 — rate limited. Honor the vendor's stated wait (Retry-After / Sforce-Limit-Info)
759
+ // when present; fall back to the AIMD-style exponential backoff otherwise.
760
+ if (response.status === 429) {
761
+ const delayMs = this.RetryAfterMs(response.headers) ?? this.CalculateRetryDelay(attempt);
762
+ console.warn(`[Salesforce] Rate limited (429), retrying in ${delayMs}ms ` +
763
+ `(attempt ${attempt + 1}/${maxRetries})`);
764
+ await this.Sleep(delayMs);
765
+ continue;
766
+ }
767
+ // Handle 500/502/503/504 — transient server-side errors. Per the Salesforce/Fonteva error
768
+ // contract these are retry-if-safe (backoff). Only retried while attempts remain; otherwise
769
+ // the non-2xx response is returned to the caller for normal error handling.
770
+ if ((response.status === 500 || response.status === 502 || response.status === 503 || response.status === 504)
771
+ && attempt < maxRetries) {
772
+ const delayMs = this.RetryAfterMs(response.headers) ?? this.CalculateRetryDelay(attempt);
773
+ console.warn(`[Salesforce] Server error (${response.status}), retrying in ${delayMs}ms ` +
774
+ `(attempt ${attempt + 1}/${maxRetries})`);
775
+ await this.Sleep(delayMs);
776
+ continue;
777
+ }
778
+ // Handle UNABLE_TO_LOCK_ROW — retry with backoff
779
+ if (response.status === 400) {
780
+ const responseBody = await this.SafeParseJSON(response);
781
+ if (this.IsLockRowError(responseBody) && attempt < maxRetries) {
782
+ const delayMs = this.CalculateRetryDelay(attempt);
783
+ console.warn(`[Salesforce] Row lock contention, retrying in ${delayMs}ms`);
784
+ await this.Sleep(delayMs);
785
+ continue;
786
+ }
787
+ return this.BuildRESTResponse(response, responseBody);
788
+ }
789
+ // Handle 204 No Content (successful DELETE/PATCH)
790
+ if (response.status === 204) {
791
+ return { Status: 204, Body: {}, Headers: this.ExtractHeaders(response) };
792
+ }
793
+ const responseBody = await this.SafeParseJSON(response);
794
+ return this.BuildRESTResponse(response, responseBody);
795
+ }
796
+ throw new Error(`Salesforce API request failed after ${maxRetries} retries: ${url}`);
797
+ }
798
+ NormalizeResponse(rawBody, _responseDataKey) {
799
+ // SF SOQL responses have records at body.records
800
+ const body = rawBody;
801
+ return (body.records ?? []);
802
+ }
803
+ /**
804
+ * CORRECTION (IMPROVE build): per-record transform that strips Salesforce's
805
+ * `attributes` metadata blob (e.g. `{ type, url }`) from every record. This is a
806
+ * SANCTIONED removal declared in {@link ExcludedSourceKeys}, NOT a silent drop — the
807
+ * `attributes` key is vendor envelope noise, not customer data, so dropping it can
808
+ * never lose a custom column. Every OTHER source field flows through untouched, so
809
+ * the full-record pass-through contract (custom-column capture) is preserved.
810
+ *
811
+ * Used by both the base-fetch path (GetRecord via applyTransformPreservingKeys) and
812
+ * the SOQL override path (RawToExternalRecord routes through StripVendorAttributes,
813
+ * which reuses ExcludedSourceKeys so the two paths agree on what is removed).
814
+ */
815
+ TransformRecord(raw, _obj, _fields) {
816
+ if (!('attributes' in raw))
817
+ return raw; // identity fast-path — nothing to strip
818
+ const out = { ...raw };
819
+ delete out['attributes'];
820
+ return out;
821
+ }
822
+ /**
823
+ * The Salesforce `attributes` blob is the ONLY key any object's TransformRecord
824
+ * removes. Declaring it here makes the removal auditable and excludes it from the
825
+ * base's re-add-dropped-keys safety net (and from change-detection).
826
+ */
827
+ ExcludedSourceKeys(_objectName) {
828
+ return ['attributes'];
829
+ }
830
+ ExtractPaginationInfo(rawBody, _paginationType, _currentPage, _currentOffset, _pageSize) {
831
+ // Not used — we override FetchChanges entirely for SOQL
832
+ const body = rawBody;
833
+ return {
834
+ HasMore: !body.done,
835
+ NextCursor: body.nextRecordsUrl ?? undefined,
836
+ };
837
+ }
838
+ GetBaseURL(_companyIntegration, auth) {
839
+ const sfAuth = auth;
840
+ return sfAuth.InstanceUrl;
841
+ }
842
+ /**
843
+ * The API origin for all REST calls. Routes through GetBaseURL() so a test harness can
844
+ * redirect the connector to a mock server (the base class hooks GetBaseURL); in production
845
+ * this returns the authenticated InstanceUrl unchanged. ALL URL construction must go through
846
+ * this rather than reading auth.InstanceUrl directly, or the connector is not mock-testable.
847
+ */
848
+ ApiBase(auth) {
849
+ return this.GetBaseURL(auth.CompanyIntegration, auth);
850
+ }
851
+ // ─── SOQL Query Engine ───────────────────────────────────────────
852
+ /**
853
+ * Builds a SOQL query string with optional watermark filter.
854
+ */
855
+ BuildSOQLQuery(objectName, fields, watermarkValue, batchSize, includeDeleted) {
856
+ // Many SF system/tooling/meta objects lack one or more of the "standard"
857
+ // audit fields (SystemModstamp, IsDeleted, LastModifiedById). Assuming
858
+ // they're always present produces `INVALID_FIELD` errors. Build the
859
+ // SELECT from what the object's describe actually exposes.
860
+ const available = new Set(fields);
861
+ const requiredPresent = REQUIRED_SOQL_FIELDS.filter(f => available.has(f));
862
+ const dedupedFields = [...new Set([...requiredPresent, ...fields])];
863
+ // Pick the best available watermark/ordering column.
864
+ // Preference: SystemModstamp > LastModifiedDate > CreatedDate > (none)
865
+ const watermarkCol = available.has('SystemModstamp')
866
+ ? 'SystemModstamp'
867
+ : available.has('LastModifiedDate')
868
+ ? 'LastModifiedDate'
869
+ : available.has('CreatedDate')
870
+ ? 'CreatedDate'
871
+ : null;
872
+ let soql = `SELECT ${dedupedFields.join(', ')} FROM ${objectName}`;
873
+ if (watermarkValue && watermarkCol) {
874
+ // `>=` not `>` — strict greater-than misses records modified
875
+ // at exactly the watermark instant. SF's SystemModstamp has
876
+ // millisecond precision but bulk updates can produce multiple
877
+ // records with the identical modstamp; only the last one shapes
878
+ // the saved watermark. Without `>=`, any record colliding on
879
+ // that exact ms after watermark save is permanently dropped
880
+ // (the watermark advances past it next sync). Engine dedupe
881
+ // handles the cheap cost of re-fetching boundary records.
882
+ soql += ` WHERE ${watermarkCol} >= ${this.FormatSOQLDateTime(watermarkValue)}`;
883
+ }
884
+ if (watermarkCol) {
885
+ soql += ` ORDER BY ${watermarkCol} ASC`;
886
+ }
887
+ // NO `LIMIT batchSize`. SF's REST API natively paginates the result
888
+ // via `done` / `nextRecordsUrl` — the engine loop drives subsequent
889
+ // pages. A SOQL LIMIT here would cap the ENTIRE result set at
890
+ // batchSize records and SF would (correctly) report done=true at
891
+ // that count, silently dropping every record past the limit. The
892
+ // worst part: incremental syncs advance the watermark past the
893
+ // dropped records, so they're never re-fetched on subsequent runs.
894
+ // Per-page batch size is controlled by the `Sforce-Query-Options`
895
+ // header (or SF default) and does not need a SOQL LIMIT.
896
+ // _ = batchSize // intentionally unused — preserved param for API stability
897
+ void batchSize;
898
+ return soql;
899
+ }
900
+ /**
901
+ * Executes a SOQL query and returns records as a FetchBatchResult.
902
+ * For the standard SObject family this uses `queryAll` so that soft-deleted
903
+ * records are returned (IsDeleted=true). For the Tooling family, the
904
+ * Tooling API's `/tooling/query` endpoint is used — `queryAll` is not
905
+ * supported there.
906
+ */
907
+ async ExecuteSOQLQuery(auth, soql, objectName, family = 'sobject') {
908
+ const includeDeleted = family === 'sobject';
909
+ const url = `${this.SOQLEndpoint(auth, family, includeDeleted)}?q=${encodeURIComponent(soql)}`;
910
+ const headers = this.BuildHeaders(auth);
911
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
912
+ this.ValidateResponse(response, url);
913
+ return this.ParseSOQLResponse(response.Body, objectName);
914
+ }
915
+ /**
916
+ * Fetches the next page of results using a queryMore locator.
917
+ */
918
+ async FetchNextPage(auth, queryLocator) {
919
+ // queryLocator is a relative URL like /services/data/v61.0/query/01gxx...
920
+ const url = `${this.ApiBase(auth)}${queryLocator}`;
921
+ const headers = this.BuildHeaders(auth);
922
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
923
+ this.ValidateResponse(response, url);
924
+ const body = response.Body;
925
+ // Extract object name from the first record's attributes
926
+ const firstRecord = body.records?.[0];
927
+ const attrs = firstRecord?.['attributes'];
928
+ const objectName = attrs?.type ?? 'Unknown';
929
+ return this.ParseSOQLResponse(body, objectName);
930
+ }
931
+ /**
932
+ * Parses a SOQL query response into a FetchBatchResult.
933
+ */
934
+ ParseSOQLResponse(body, objectName) {
935
+ const rawRecords = body.records ?? [];
936
+ // Some Salesforce system/metadata objects (e.g. TabDefinition,
937
+ // FormulaFunctionAllowedType) return many rows that all share the
938
+ // placeholder `Id = '000000000000000AAA'` — SF treats Id as
939
+ // non-unique on those objects, but MJ's auto-generated UQ_<table>_PK
940
+ // rejects the duplicates and produces one error per record after
941
+ // the first. Dedupe on Id within the batch (keep first occurrence)
942
+ // before returning to the engine.
943
+ const seenIds = new Set();
944
+ const dedupedRaw = [];
945
+ let duplicatesDropped = 0;
946
+ for (const r of rawRecords) {
947
+ const raw = r;
948
+ const id = raw['Id'];
949
+ if (id) {
950
+ if (seenIds.has(id)) {
951
+ duplicatesDropped++;
952
+ continue;
953
+ }
954
+ seenIds.add(id);
955
+ }
956
+ dedupedRaw.push(r);
957
+ }
958
+ if (duplicatesDropped > 0) {
959
+ console.warn(`[Salesforce] ${objectName}: dropped ${duplicatesDropped} record(s) with duplicate Id ` +
960
+ `(SF returned non-unique Ids for this object — typical for system/metadata sObjects ` +
961
+ `like TabDefinition, FormulaFunctionAllowedType where Id is a placeholder).`);
962
+ }
963
+ const records = dedupedRaw.map(r => {
964
+ const raw = r;
965
+ const record = this.RawToExternalRecord(raw, objectName);
966
+ record.IsDeleted = raw['IsDeleted'] === true;
967
+ record.ModifiedAt = raw['SystemModstamp']
968
+ ? new Date(raw['SystemModstamp'])
969
+ : undefined;
970
+ return record;
971
+ });
972
+ // Compute new watermark from the max SystemModstamp in this batch
973
+ const newWatermark = this.ExtractMaxWatermark(dedupedRaw);
974
+ return {
975
+ Records: records,
976
+ HasMore: !body.done,
977
+ NewWatermarkValue: newWatermark ?? undefined,
978
+ NextCursor: body.nextRecordsUrl ?? undefined,
979
+ };
980
+ }
981
+ /**
982
+ * Extracts the maximum SystemModstamp value from a batch of records and
983
+ * advances it by 1ms so the next sync's `WHERE SystemModstamp >= <wm>`
984
+ * filter excludes the boundary cluster.
985
+ *
986
+ * Why advance: the SOQL filter uses `>=` (not `>`) on purpose so we don't
987
+ * drop records that share the modstamp instant of the previous max. But
988
+ * if the saved watermark equals the new max, every subsequent incremental
989
+ * re-pulls the same boundary cluster forever and the watermark plateaus.
990
+ * Observed in the wild: 1,800 EmailMessage records bulk-imported with
991
+ * identical SystemModstamp re-fetched on every run with `Updated` count
992
+ * never decreasing.
993
+ *
994
+ * SF's SystemModstamp is millisecond-precision, so adding 1ms cannot
995
+ * skip a real record — there is nothing scheduled between `max` and
996
+ * `max + 1ms`. The connector returns the advanced value as
997
+ * `NewWatermarkValue`; the engine persists it; the next run picks up
998
+ * cleanly past the cluster.
999
+ */
1000
+ ExtractMaxWatermark(records) {
1001
+ let maxTimestamp = null;
1002
+ for (const record of records) {
1003
+ const raw = record;
1004
+ const stamp = raw['SystemModstamp'];
1005
+ if (stamp && (!maxTimestamp || stamp > maxTimestamp)) {
1006
+ maxTimestamp = stamp;
1007
+ }
1008
+ }
1009
+ if (!maxTimestamp)
1010
+ return null;
1011
+ const parsed = new Date(maxTimestamp);
1012
+ if (Number.isNaN(parsed.getTime()))
1013
+ return maxTimestamp;
1014
+ return new Date(parsed.getTime() + 1).toISOString();
1015
+ }
1016
+ /**
1017
+ * Gets queryable field names for a SF object via the Describe API. When
1018
+ * `family === 'tooling'`, calls the Tooling describe endpoint instead of
1019
+ * the standard one.
1020
+ */
1021
+ async GetQueryableFieldNames(auth, objectName, family = 'sobject') {
1022
+ const url = `${this.SObjectBasePath(auth, family)}/${objectName}/describe`;
1023
+ const headers = this.BuildHeaders(auth);
1024
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
1025
+ this.ValidateResponse(response, url);
1026
+ const body = response.Body;
1027
+ const fields = body.fields ?? [];
1028
+ return fields
1029
+ .filter(f => !COMPOUND_FIELD_TYPES.has(f.type))
1030
+ .map(f => f.name);
1031
+ }
1032
+ // ─── JWT Authentication ──────────────────────────────────────────
1033
+ /**
1034
+ * Builds a signed JWT assertion for the SF JWT Bearer Token flow.
1035
+ */
1036
+ BuildJWTAssertion(config) {
1037
+ const now = Math.floor(Date.now() / 1000);
1038
+ const payload = {
1039
+ iss: config.ClientId,
1040
+ sub: config.Username,
1041
+ aud: config.LoginUrl,
1042
+ exp: now + 300, // 5 minutes
1043
+ };
1044
+ if (!config.PrivateKey) {
1045
+ throw new Error('Salesforce JWT Bearer authentication requires a PrivateKey.');
1046
+ }
1047
+ const pem = this.NormalizePem(config.PrivateKey);
1048
+ return jwt.sign(payload, pem, { algorithm: 'RS256' });
1049
+ }
1050
+ /**
1051
+ * Normalize a PEM private key that may have had its newlines stripped or
1052
+ * replaced with spaces / literal "\n" sequences. Rebuilds the standard
1053
+ * BEGIN header / 64-char body / END footer layout that OpenSSL requires.
1054
+ */
1055
+ NormalizePem(key) {
1056
+ if (!key)
1057
+ return key;
1058
+ // First, normalize escaped newlines to real ones
1059
+ let normalized = key.includes('\\n') ? key.replace(/\\n/g, '\n') : key;
1060
+ // If real newlines exist already, trust them
1061
+ if (normalized.includes('\n'))
1062
+ return normalized;
1063
+ // Split out header / footer / body and rebuild with real newlines
1064
+ const headerMatch = normalized.match(/^(-----BEGIN [^-]+-----)/);
1065
+ const footerMatch = normalized.match(/(-----END [^-]+-----)\s*$/);
1066
+ if (!headerMatch || !footerMatch)
1067
+ return normalized;
1068
+ const header = headerMatch[1];
1069
+ const footer = footerMatch[1];
1070
+ const bodyRaw = normalized.slice(header.length, normalized.length - footer.length);
1071
+ const body = bodyRaw.replace(/\s+/g, '');
1072
+ const chunked = body.match(/.{1,64}/g)?.join('\n') ?? body;
1073
+ return `${header}\n${chunked}\n${footer}`;
1074
+ }
1075
+ /**
1076
+ * Exchanges a JWT assertion for an access token at the SF token endpoint.
1077
+ */
1078
+ async ExchangeJWTForToken(loginUrl, jwtAssertion) {
1079
+ const tokenUrl = `${loginUrl}/services/oauth2/token`;
1080
+ const body = `grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')}&assertion=${encodeURIComponent(jwtAssertion)}`;
1081
+ const response = await fetch(tokenUrl, {
1082
+ method: 'POST',
1083
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1084
+ body,
1085
+ signal: AbortSignal.timeout(this.effectiveRequestTimeoutMs),
1086
+ });
1087
+ if (!response.ok) {
1088
+ const errorBody = await response.text();
1089
+ throw new Error(`Salesforce JWT token exchange failed (HTTP ${response.status}): ${errorBody}`);
1090
+ }
1091
+ const result = await response.json();
1092
+ if (!result.access_token || !result.instance_url) {
1093
+ throw new Error('Salesforce token response missing access_token or instance_url');
1094
+ }
1095
+ return result;
1096
+ }
1097
+ /**
1098
+ * Exchanges Client Credentials for an access token.
1099
+ * Used with Connected Apps configured for the OAuth 2.0 Client Credentials flow.
1100
+ */
1101
+ async ExchangeClientCredentialsForToken(config) {
1102
+ if (!config.ClientSecret) {
1103
+ throw new Error('Client Credentials flow requires clientSecret');
1104
+ }
1105
+ const tokenUrl = `${config.LoginUrl}/services/oauth2/token`;
1106
+ const body = new URLSearchParams({
1107
+ grant_type: 'client_credentials',
1108
+ client_id: config.ClientId,
1109
+ client_secret: config.ClientSecret,
1110
+ }).toString();
1111
+ const response = await fetch(tokenUrl, {
1112
+ method: 'POST',
1113
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1114
+ body,
1115
+ signal: AbortSignal.timeout(this.effectiveRequestTimeoutMs),
1116
+ });
1117
+ if (!response.ok) {
1118
+ const errorBody = await response.text();
1119
+ throw new Error(`Salesforce Client Credentials token exchange failed (HTTP ${response.status}): ${errorBody}`);
1120
+ }
1121
+ const result = await response.json();
1122
+ if (!result.access_token || !result.instance_url) {
1123
+ throw new Error('Salesforce token response missing access_token or instance_url');
1124
+ }
1125
+ return result;
1126
+ }
1127
+ /**
1128
+ * Checks if the cached token is still valid (within 80% of lifetime).
1129
+ */
1130
+ IsTokenValid() {
1131
+ if (!this.cachedAuth || this.tokenObtainedAt === 0)
1132
+ return false;
1133
+ const elapsed = Date.now() - this.tokenObtainedAt;
1134
+ return elapsed < TOKEN_LIFETIME_MS * TOKEN_REFRESH_THRESHOLD;
1135
+ }
1136
+ // ─── Credential Loading ──────────────────────────────────────────
1137
+ /**
1138
+ * Loads credentials from the Credential entity or Configuration JSON.
1139
+ */
1140
+ async LoadCredentials(companyIntegration, contextUser) {
1141
+ // Try Credential entity first
1142
+ const credentialID = companyIntegration.CredentialID;
1143
+ if (credentialID) {
1144
+ const creds = await this.LoadFromCredentialEntity(credentialID, contextUser);
1145
+ if (creds)
1146
+ return creds;
1147
+ }
1148
+ // Fallback: Configuration JSON
1149
+ const configJson = companyIntegration.Configuration;
1150
+ if (configJson) {
1151
+ const creds = this.ParseCredentialJson(configJson);
1152
+ if (creds)
1153
+ return creds;
1154
+ }
1155
+ throw new Error('No Salesforce credentials found. Set Configuration JSON with either ' +
1156
+ '(a) clientId + clientSecret for Client Credentials flow, or ' +
1157
+ '(b) clientId + username + privateKey for JWT Bearer flow.');
1158
+ }
1159
+ async LoadFromCredentialEntity(credentialID, contextUser, provider) {
1160
+ const md = provider ?? new Metadata();
1161
+ const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
1162
+ const loaded = await credential.Load(credentialID);
1163
+ if (!loaded || !credential.Values)
1164
+ return null;
1165
+ return this.ParseCredentialJson(credential.Values);
1166
+ }
1167
+ ParseCredentialJson(json) {
1168
+ try {
1169
+ const parsed = JSON.parse(json);
1170
+ const clientId = parsed['clientId'] ?? parsed['ClientId'];
1171
+ if (!clientId)
1172
+ return null;
1173
+ const loginUrl = parsed['loginUrl'] ?? parsed['LoginUrl'] ?? 'https://login.salesforce.com';
1174
+ const apiVersion = parsed['apiVersion'] ?? parsed['ApiVersion'] ?? DEFAULT_API_VERSION;
1175
+ const tokenUrl = parsed['tokenUrl'] ?? parsed['TokenUrl'];
1176
+ // Detect auth flow: if clientSecret is present, use client_credentials;
1177
+ // otherwise require privateKey + username for JWT Bearer
1178
+ const clientSecret = parsed['clientSecret'] ?? parsed['ClientSecret'];
1179
+ const privateKey = parsed['privateKey'] ?? parsed['PrivateKey'];
1180
+ const username = parsed['username'] ?? parsed['Username'];
1181
+ const explicitFlow = parsed['authFlow'] ?? parsed['AuthFlow'];
1182
+ if (explicitFlow === 'client_credentials' || (clientSecret && !privateKey)) {
1183
+ if (!clientSecret)
1184
+ return null;
1185
+ return {
1186
+ AuthFlow: 'client_credentials',
1187
+ LoginUrl: loginUrl,
1188
+ ClientId: clientId,
1189
+ ClientSecret: clientSecret,
1190
+ ApiVersion: apiVersion,
1191
+ TokenUrl: tokenUrl,
1192
+ };
1193
+ }
1194
+ // JWT Bearer flow
1195
+ if (!privateKey || !username)
1196
+ return null;
1197
+ return {
1198
+ AuthFlow: 'jwt_bearer',
1199
+ LoginUrl: loginUrl,
1200
+ ClientId: clientId,
1201
+ Username: username,
1202
+ PrivateKey: privateKey,
1203
+ ApiVersion: apiVersion,
1204
+ TokenUrl: tokenUrl,
1205
+ };
1206
+ }
1207
+ catch {
1208
+ return null;
1209
+ }
1210
+ }
1211
+ BuildConnectionConfig(credentials, companyIntegration) {
1212
+ const config = {
1213
+ AuthFlow: credentials.AuthFlow,
1214
+ LoginUrl: credentials.LoginUrl,
1215
+ ClientId: credentials.ClientId,
1216
+ ApiVersion: credentials.ApiVersion,
1217
+ Username: credentials.Username,
1218
+ PrivateKey: credentials.PrivateKey,
1219
+ ClientSecret: credentials.ClientSecret,
1220
+ TokenUrl: credentials.TokenUrl,
1221
+ };
1222
+ const configJson = companyIntegration.Configuration;
1223
+ if (configJson) {
1224
+ this.ApplyConfigOverrides(config, configJson);
1225
+ }
1226
+ return config;
1227
+ }
1228
+ ApplyConfigOverrides(config, json) {
1229
+ try {
1230
+ const parsed = JSON.parse(json);
1231
+ const parseOptionalInt = (key) => {
1232
+ const v = parsed[key];
1233
+ if (v == null)
1234
+ return undefined;
1235
+ const n = Number(v);
1236
+ return isNaN(n) ? undefined : Math.floor(n);
1237
+ };
1238
+ config.MaxRetries = parseOptionalInt('MaxRetries');
1239
+ config.RequestTimeoutMs = parseOptionalInt('RequestTimeoutMs');
1240
+ config.MinRequestIntervalMs = parseOptionalInt('MinRequestIntervalMs');
1241
+ config.DefaultBatchSize = parseOptionalInt('DefaultBatchSize');
1242
+ }
1243
+ catch {
1244
+ // Configuration JSON may not be valid — ignore
1245
+ }
1246
+ }
1247
+ // ─── Rate Limit / Governor Limit Management ──────────────────────
1248
+ /**
1249
+ * Throttles requests to respect minimum interval between API calls.
1250
+ */
1251
+ async ThrottleIfNeeded() {
1252
+ const minInterval = this.effectiveMinRequestIntervalMs;
1253
+ const elapsed = Date.now() - this.lastRequestTime;
1254
+ if (elapsed < minInterval) {
1255
+ await this.Sleep(minInterval - elapsed);
1256
+ }
1257
+ }
1258
+ /**
1259
+ * Checks governor limits and pauses if approaching the daily API limit.
1260
+ */
1261
+ async CheckGovernorLimits() {
1262
+ const { CurrentUsage, DailyLimit } = this.governorState;
1263
+ if (DailyLimit === 0)
1264
+ return;
1265
+ const ratio = CurrentUsage / DailyLimit;
1266
+ if (ratio >= GOVERNOR_PAUSE_THRESHOLD) {
1267
+ throw new Error(`Salesforce governor limit critical: ${CurrentUsage}/${DailyLimit} API calls used ` +
1268
+ `(${Math.round(ratio * 100)}%). Sync paused to prevent REQUEST_LIMIT_EXCEEDED.`);
1269
+ }
1270
+ if (ratio >= GOVERNOR_THROTTLE_THRESHOLD) {
1271
+ console.warn(`[Salesforce] Governor limit warning: ${CurrentUsage}/${DailyLimit} ` +
1272
+ `(${Math.round(ratio * 100)}%). Adding throttle delay.`);
1273
+ await this.Sleep(500); // Extra delay when approaching limit
1274
+ }
1275
+ }
1276
+ /**
1277
+ * Parses the Sforce-Limit-Info header from every SF response.
1278
+ */
1279
+ ParseGovernorLimits(headers) {
1280
+ const limitInfo = headers.get('sforce-limit-info');
1281
+ if (!limitInfo)
1282
+ return;
1283
+ // Format: "api-usage=25/15000"
1284
+ const match = limitInfo.match(/api-usage=(\d+)\/(\d+)/);
1285
+ if (match) {
1286
+ this.governorState = {
1287
+ CurrentUsage: parseInt(match[1], 10),
1288
+ DailyLimit: parseInt(match[2], 10),
1289
+ LastUpdated: Date.now(),
1290
+ };
1291
+ }
1292
+ }
1293
+ // ─── HTTP Helpers ────────────────────────────────────────────────
1294
+ async FetchWithTimeout(url, method, headers, body) {
1295
+ const fetchOptions = {
1296
+ method,
1297
+ headers,
1298
+ signal: AbortSignal.timeout(this.effectiveRequestTimeoutMs),
1299
+ };
1300
+ if (body !== undefined && method !== 'GET' && method !== 'DELETE') {
1301
+ fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
1302
+ }
1303
+ return fetch(url, fetchOptions);
1304
+ }
1305
+ BuildRESTResponse(response, body) {
1306
+ return {
1307
+ Status: response.status,
1308
+ Body: body,
1309
+ Headers: this.ExtractHeaders(response),
1310
+ };
1311
+ }
1312
+ ExtractHeaders(response) {
1313
+ const headers = {};
1314
+ response.headers.forEach((value, key) => {
1315
+ headers[key.toLowerCase()] = value;
1316
+ });
1317
+ return headers;
1318
+ }
1319
+ async SafeParseJSON(response) {
1320
+ try {
1321
+ return await response.json();
1322
+ }
1323
+ catch {
1324
+ return {};
1325
+ }
1326
+ }
1327
+ CalculateRetryDelay(attempt) {
1328
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s (capped at 30s)
1329
+ return Math.min(1000 * Math.pow(2, attempt), 30000);
1330
+ }
1331
+ /**
1332
+ * Parses the vendor's stated retry wait from a 429/503 response into milliseconds, so the connector
1333
+ * honors `Retry-After` (delta-seconds OR an HTTP-date) and Salesforce's `Sforce-Limit-Info` rather
1334
+ * than blindly using exponential backoff. Returns null when no usable signal is present (caller falls
1335
+ * back to {@link CalculateRetryDelay}). Capped at 60s so a hostile/garbage header can't stall a sync.
1336
+ */
1337
+ RetryAfterMs(headers) {
1338
+ const raw = headers?.get?.('retry-after');
1339
+ if (raw) {
1340
+ const secs = Number(raw);
1341
+ if (Number.isFinite(secs) && secs >= 0)
1342
+ return Math.min(secs * 1000, 60000);
1343
+ const when = Date.parse(raw); // HTTP-date form
1344
+ if (!Number.isNaN(when))
1345
+ return Math.min(Math.max(when - Date.now(), 0), 60000);
1346
+ }
1347
+ return null;
1348
+ }
1349
+ Sleep(ms) {
1350
+ return new Promise(resolve => setTimeout(resolve, ms));
1351
+ }
1352
+ // ─── SF Type Mapping ─────────────────────────────────────────────
1353
+ /**
1354
+ * Maps a Salesforce field describe to an ExternalFieldSchema.
1355
+ * Custom fields can be detected by callers via the `__c` suffix in the
1356
+ * field name — Salesforce standardizes this convention for all custom
1357
+ * fields on both standard and custom SObjects.
1358
+ */
1359
+ MapSFFieldToSchema(f) {
1360
+ // Salesforce's `Id` is the universal PK on every SObject (standard + custom).
1361
+ // The /describe response sets type='id' on the PK field but doesn't carry
1362
+ // an explicit IsPrimaryKey signal, so we have to stamp it here. Without
1363
+ // this, UpsertField's new-field path would persist `Id` with no PK flag
1364
+ // and the downstream SoftPKClassifier becomes the only safety net.
1365
+ const isPK = f.name === 'Id' || f.type === 'id';
1366
+ return {
1367
+ Name: f.name,
1368
+ Label: f.label,
1369
+ Description: f.inlineHelpText ?? undefined,
1370
+ DataType: this.MapSalesforceType(f.type),
1371
+ IsRequired: !f.nillable && !f.defaultedOnCreate,
1372
+ IsPrimaryKey: isPK,
1373
+ IsUniqueKey: isPK || f.externalId,
1374
+ IsReadOnly: f.calculated || !f.updateable || SYSTEM_READ_ONLY_FIELDS.has(f.name),
1375
+ IsForeignKey: f.type === 'reference' && f.referenceTo.length > 0,
1376
+ ForeignKeyTarget: f.referenceTo.length > 0 ? f.referenceTo[0] : null,
1377
+ };
1378
+ }
1379
+ /**
1380
+ * Maps a Salesforce field type to a generic integration type.
1381
+ */
1382
+ MapSalesforceType(sfType) {
1383
+ switch (sfType) {
1384
+ case 'string':
1385
+ case 'textarea':
1386
+ case 'url':
1387
+ case 'email':
1388
+ case 'phone':
1389
+ case 'picklist':
1390
+ case 'multipicklist':
1391
+ case 'combobox':
1392
+ return 'string';
1393
+ case 'int':
1394
+ return 'integer';
1395
+ case 'double':
1396
+ case 'currency':
1397
+ case 'percent':
1398
+ return 'decimal';
1399
+ case 'boolean':
1400
+ return 'boolean';
1401
+ case 'date':
1402
+ return 'date';
1403
+ case 'datetime':
1404
+ return 'datetime';
1405
+ case 'time':
1406
+ return 'time';
1407
+ case 'id':
1408
+ case 'reference':
1409
+ return 'nvarchar(18)';
1410
+ case 'base64':
1411
+ case 'encryptedstring':
1412
+ return 'text';
1413
+ default:
1414
+ return 'string';
1415
+ }
1416
+ }
1417
+ // ─── SOQL Helpers ────────────────────────────────────────────────
1418
+ /**
1419
+ * Formats a watermark value as a SOQL datetime literal.
1420
+ */
1421
+ FormatSOQLDateTime(value) {
1422
+ // If it's already in ISO 8601 format, it works directly in SOQL
1423
+ // SOQL accepts: 2026-03-13T10:30:00.000Z or 2026-03-13T10:30:00.000+00:00
1424
+ if (value.includes('T'))
1425
+ return value;
1426
+ // If it's just a date, append time
1427
+ return `${value}T00:00:00.000Z`;
1428
+ }
1429
+ /**
1430
+ * Builds a SOQL WHERE clause from a Filters map.
1431
+ */
1432
+ BuildWhereClauseFromFilters(filters) {
1433
+ const conditions = Object.entries(filters).map(([field, value]) => {
1434
+ const escaped = value.replace(/'/g, "\\'");
1435
+ return `${field} = '${escaped}'`;
1436
+ });
1437
+ return conditions.length > 0 ? conditions.join(' AND ') : null;
1438
+ }
1439
+ // ─── Error Handling ──────────────────────────────────────────────
1440
+ /**
1441
+ * Checks if a response contains a specific SF error code.
1442
+ */
1443
+ HasSFErrorCode(response, errorCode) {
1444
+ const errors = this.ExtractSFErrors(response);
1445
+ return errors.some(e => e.errorCode === errorCode);
1446
+ }
1447
+ /**
1448
+ * Extracts SF error objects from a response.
1449
+ */
1450
+ ExtractSFErrors(response) {
1451
+ const body = response.Body;
1452
+ if (Array.isArray(body)) {
1453
+ return body;
1454
+ }
1455
+ if (body && typeof body === 'object' && 'message' in body) {
1456
+ return [body];
1457
+ }
1458
+ return [];
1459
+ }
1460
+ /**
1461
+ * Checks if a response body contains an UNABLE_TO_LOCK_ROW error.
1462
+ */
1463
+ IsLockRowError(body) {
1464
+ if (!Array.isArray(body))
1465
+ return false;
1466
+ return body.some((e) => e['errorCode'] === 'UNABLE_TO_LOCK_ROW');
1467
+ }
1468
+ /**
1469
+ * Builds a CRUDResult for error responses.
1470
+ */
1471
+ BuildCRUDError(response, operation, objectName) {
1472
+ const errors = this.ExtractSFErrors(response);
1473
+ const message = errors.length > 0
1474
+ ? errors.map(e => `${e.errorCode}: ${e.message}`).join('; ')
1475
+ : `[Salesforce] ${operation} on ${objectName} failed (HTTP ${response.Status})`;
1476
+ return {
1477
+ Success: false,
1478
+ ErrorMessage: message,
1479
+ StatusCode: response.Status,
1480
+ };
1481
+ }
1482
+ /**
1483
+ * Handles write errors with special retry logic for UNABLE_TO_LOCK_ROW.
1484
+ */
1485
+ async HandleWriteError(response, operation, objectName, externalID, auth, headers, url, body) {
1486
+ // UNABLE_TO_LOCK_ROW — retry once more
1487
+ if (this.HasSFErrorCode(response, 'UNABLE_TO_LOCK_ROW')) {
1488
+ console.warn(`[Salesforce] Row lock contention on ${objectName}/${externalID}, retrying...`);
1489
+ await this.Sleep(1000);
1490
+ const retry = await this.MakeHTTPRequest(auth, url, 'PATCH', headers, body);
1491
+ if (retry.Status === 204 || (retry.Status >= 200 && retry.Status < 300)) {
1492
+ return { Success: true, ExternalID: externalID, StatusCode: retry.Status };
1493
+ }
1494
+ }
1495
+ return this.BuildCRUDError(response, operation, objectName);
1496
+ }
1497
+ // ─── Record Conversion ───────────────────────────────────────────
1498
+ /**
1499
+ * Converts a raw SF API record to an ExternalRecord. The SOQL override path
1500
+ * hand-builds records, so it routes through the SAME sanctioned strip the base path
1501
+ * uses: every key flows through to `Fields` EXCEPT those declared in
1502
+ * {@link ExcludedSourceKeys} (just `attributes`). This preserves full-record
1503
+ * pass-through for custom-column capture while removing only the vendor envelope blob.
1504
+ */
1505
+ RawToExternalRecord(raw, objectType) {
1506
+ const excluded = new Set(this.ExcludedSourceKeys(objectType));
1507
+ const fields = {};
1508
+ for (const [key, value] of Object.entries(raw)) {
1509
+ if (excluded.has(key))
1510
+ continue; // sanctioned removal (SF metadata, not a data field)
1511
+ fields[key] = value;
1512
+ }
1513
+ return {
1514
+ ExternalID: String(raw['Id'] ?? ''),
1515
+ ObjectType: objectType,
1516
+ Fields: fields,
1517
+ ModifiedAt: raw['SystemModstamp']
1518
+ ? new Date(raw['SystemModstamp'])
1519
+ : undefined,
1520
+ };
1521
+ }
1522
+ /**
1523
+ * Strips read-only and system fields from an attributes map before write operations.
1524
+ */
1525
+ StripReadOnlyFields(attributes) {
1526
+ const result = {};
1527
+ for (const [key, value] of Object.entries(attributes)) {
1528
+ if (SYSTEM_READ_ONLY_FIELDS.has(key))
1529
+ continue;
1530
+ if (value === undefined)
1531
+ continue;
1532
+ result[key] = value;
1533
+ }
1534
+ return result;
1535
+ }
1536
+ // ─── Schema Building ─────────────────────────────────────────────
1537
+ async BuildSourceObjectFromDescribe(companyIntegration, contextUser, objectName) {
1538
+ const auth = await this.Authenticate(companyIntegration, contextUser);
1539
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/sobjects/${objectName}/describe`;
1540
+ const headers = this.BuildHeaders(auth);
1541
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
1542
+ this.ValidateResponse(response, url);
1543
+ const body = response.Body;
1544
+ const sfFields = (body.fields ?? []).filter(f => !COMPOUND_FIELD_TYPES.has(f.type));
1545
+ const fields = sfFields.map(f => ({
1546
+ Name: f.name,
1547
+ Label: f.label,
1548
+ Description: f.inlineHelpText ?? undefined,
1549
+ SourceType: this.MapSalesforceType(f.type),
1550
+ IsRequired: !f.nillable && !f.defaultedOnCreate,
1551
+ MaxLength: f.length > 0 ? f.length : null,
1552
+ Precision: f.precision > 0 ? f.precision : null,
1553
+ Scale: f.scale > 0 ? f.scale : null,
1554
+ DefaultValue: f.defaultValue != null ? String(f.defaultValue) : null,
1555
+ IsPrimaryKey: f.name === 'Id',
1556
+ IsForeignKey: f.type === 'reference' && f.referenceTo.length > 0,
1557
+ ForeignKeyTarget: f.referenceTo.length > 0 ? f.referenceTo[0] : null,
1558
+ }));
1559
+ const relationships = sfFields
1560
+ .filter(f => f.type === 'reference' && f.referenceTo.length > 0)
1561
+ .map(f => ({
1562
+ FieldName: f.name,
1563
+ TargetObject: f.referenceTo[0],
1564
+ TargetField: 'Id',
1565
+ }));
1566
+ return {
1567
+ ExternalName: objectName,
1568
+ ExternalLabel: body.label ?? objectName,
1569
+ Description: undefined,
1570
+ Fields: fields,
1571
+ PrimaryKeyFields: ['Id'],
1572
+ Relationships: relationships,
1573
+ };
1574
+ }
1575
+ // ─── Default Field Mappings ──────────────────────────────────────
1576
+ GetContactMappings() {
1577
+ return [
1578
+ { SourceFieldName: 'Email', DestinationFieldName: 'Email', IsKeyField: true },
1579
+ { SourceFieldName: 'FirstName', DestinationFieldName: 'FirstName' },
1580
+ { SourceFieldName: 'LastName', DestinationFieldName: 'LastName' },
1581
+ { SourceFieldName: 'Phone', DestinationFieldName: 'Phone' },
1582
+ { SourceFieldName: 'MobilePhone', DestinationFieldName: 'MobilePhone' },
1583
+ { SourceFieldName: 'Title', DestinationFieldName: 'Title' },
1584
+ { SourceFieldName: 'Department', DestinationFieldName: 'Department' },
1585
+ { SourceFieldName: 'MailingStreet', DestinationFieldName: 'Address1' },
1586
+ { SourceFieldName: 'MailingCity', DestinationFieldName: 'City' },
1587
+ { SourceFieldName: 'MailingState', DestinationFieldName: 'StateOrProvince' },
1588
+ { SourceFieldName: 'MailingPostalCode', DestinationFieldName: 'PostalCode' },
1589
+ { SourceFieldName: 'MailingCountry', DestinationFieldName: 'Country' },
1590
+ ];
1591
+ }
1592
+ GetAccountMappings() {
1593
+ return [
1594
+ { SourceFieldName: 'Name', DestinationFieldName: 'Name', IsKeyField: true },
1595
+ { SourceFieldName: 'BillingStreet', DestinationFieldName: 'Address1' },
1596
+ { SourceFieldName: 'BillingCity', DestinationFieldName: 'City' },
1597
+ { SourceFieldName: 'BillingState', DestinationFieldName: 'StateOrProvince' },
1598
+ { SourceFieldName: 'BillingPostalCode', DestinationFieldName: 'PostalCode' },
1599
+ { SourceFieldName: 'BillingCountry', DestinationFieldName: 'Country' },
1600
+ { SourceFieldName: 'Phone', DestinationFieldName: 'Phone' },
1601
+ { SourceFieldName: 'Website', DestinationFieldName: 'WebSite' },
1602
+ { SourceFieldName: 'Industry', DestinationFieldName: 'Industry' },
1603
+ { SourceFieldName: 'Description', DestinationFieldName: 'Description' },
1604
+ ];
1605
+ }
1606
+ GetLeadMappings() {
1607
+ return [
1608
+ { SourceFieldName: 'Email', DestinationFieldName: 'Email', IsKeyField: true },
1609
+ { SourceFieldName: 'FirstName', DestinationFieldName: 'FirstName' },
1610
+ { SourceFieldName: 'LastName', DestinationFieldName: 'LastName' },
1611
+ { SourceFieldName: 'Phone', DestinationFieldName: 'Phone' },
1612
+ { SourceFieldName: 'Company', DestinationFieldName: 'CompanyName' },
1613
+ { SourceFieldName: 'Title', DestinationFieldName: 'Title' },
1614
+ ];
1615
+ }
1616
+ // ─── Validation Helpers ──────────────────────────────────────────
1617
+ ValidateResponse(response, url) {
1618
+ if (response.Status < 200 || response.Status >= 300) {
1619
+ const preview = this.PreviewBody(response.Body);
1620
+ throw new Error(`HTTP ${response.Status} from ${url}: ${preview}`);
1621
+ }
1622
+ }
1623
+ PreviewBody(body) {
1624
+ if (typeof body === 'string')
1625
+ return body.slice(0, 500);
1626
+ return JSON.stringify(body).slice(0, 500);
1627
+ }
1628
+ // ─── Analytics API (Reports & Dashboards) ────────────────────────
1629
+ /**
1630
+ * Lists Analytics Reports or Dashboards via the Analytics API. Read-only;
1631
+ * no incremental-sync support at this endpoint.
1632
+ */
1633
+ async FetchAnalyticsList(auth, family, objectName) {
1634
+ const segment = family === 'analytics_report' ? 'reports' : 'dashboards';
1635
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/analytics/${segment}`;
1636
+ const headers = this.BuildHeaders(auth);
1637
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
1638
+ this.ValidateResponse(response, url);
1639
+ const body = response.Body;
1640
+ const list = body.reports ?? body.dashboards ?? [];
1641
+ const records = list.map(item => this.AnalyticsItemToRecord(item, objectName));
1642
+ return { Records: records, HasMore: false };
1643
+ }
1644
+ AnalyticsItemToRecord(item, objectType) {
1645
+ return {
1646
+ ExternalID: String(item.id ?? ''),
1647
+ ObjectType: objectType,
1648
+ Fields: { ...item },
1649
+ };
1650
+ }
1651
+ async GetAnalyticsReport(auth, id) {
1652
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/analytics/reports/${id}`;
1653
+ const headers = this.BuildHeaders(auth);
1654
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
1655
+ if (response.Status === 404)
1656
+ return null;
1657
+ this.ValidateResponse(response, url);
1658
+ return {
1659
+ ExternalID: id,
1660
+ ObjectType: 'Report',
1661
+ Fields: response.Body,
1662
+ };
1663
+ }
1664
+ async GetAnalyticsDashboard(auth, id) {
1665
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/analytics/dashboards/${id}`;
1666
+ const headers = this.BuildHeaders(auth);
1667
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
1668
+ if (response.Status === 404)
1669
+ return null;
1670
+ this.ValidateResponse(response, url);
1671
+ return {
1672
+ ExternalID: id,
1673
+ ObjectType: 'Dashboard',
1674
+ Fields: response.Body,
1675
+ };
1676
+ }
1677
+ // ─── Knowledge Articles ──────────────────────────────────────────
1678
+ /**
1679
+ * Fetches published Knowledge article versions. This endpoint is
1680
+ * paginated via `pageNumber`/`pageSize` (not cursor). The connector
1681
+ * returns a single batch; callers that need pagination can issue
1682
+ * additional requests via SearchRecords.
1683
+ */
1684
+ async FetchKnowledgeArticles(auth, objectName, watermarkValue, batchSize, page = 1) {
1685
+ const params = new URLSearchParams({ pageSize: String(batchSize), pageNumber: String(page) });
1686
+ if (watermarkValue) {
1687
+ params.set('publishStatus', 'Online');
1688
+ }
1689
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/support/knowledgeArticles?${params}`;
1690
+ const headers = this.BuildHeaders(auth);
1691
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
1692
+ this.ValidateResponse(response, url);
1693
+ const body = response.Body;
1694
+ const records = (body.articles ?? []).map(a => ({
1695
+ ExternalID: String(a.id ?? ''),
1696
+ ObjectType: objectName,
1697
+ Fields: a,
1698
+ }));
1699
+ // Knowledge Articles uses page-based pagination. The endpoint doesn't
1700
+ // return a documented total/has-more signal in its body, so treat a
1701
+ // full page as "potentially more" — same defensive pattern used for
1702
+ // SI and YM. Engine loop drives subsequent pages via NextPage; an
1703
+ // empty next-page response terminates naturally. Without this guard
1704
+ // sync silently caps at batchSize records per object — identical to
1705
+ // the SOQL LIMIT bug we already fixed in this session.
1706
+ const hasMore = records.length >= batchSize;
1707
+ return {
1708
+ Records: records,
1709
+ HasMore: hasMore,
1710
+ NextPage: hasMore ? page + 1 : undefined,
1711
+ };
1712
+ }
1713
+ // ─── Bulk API 2.0 (Ingest & Query Jobs) ──────────────────────────
1714
+ /**
1715
+ * Lists in-flight Bulk API 2.0 jobs. Useful for monitoring background
1716
+ * imports/queries the integration previously started.
1717
+ */
1718
+ async FetchBulkJobs(auth, family, objectName) {
1719
+ const segment = family === 'bulk_ingest' ? 'ingest' : 'query';
1720
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/jobs/${segment}`;
1721
+ const headers = this.BuildHeaders(auth);
1722
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
1723
+ this.ValidateResponse(response, url);
1724
+ const body = response.Body;
1725
+ const records = (body.records ?? []).map(r => ({
1726
+ ExternalID: String(r.id ?? ''),
1727
+ ObjectType: objectName,
1728
+ Fields: r,
1729
+ ModifiedAt: r.systemModstamp ? new Date(r.systemModstamp) : undefined,
1730
+ }));
1731
+ return {
1732
+ Records: records,
1733
+ HasMore: !body.done,
1734
+ NextCursor: body.nextRecordsUrl ?? undefined,
1735
+ };
1736
+ }
1737
+ /**
1738
+ * Creates (starts) a new Bulk API 2.0 ingest or query job using the
1739
+ * provided attributes. Required fields differ by operation; we pass
1740
+ * attributes straight through to Salesforce after stripping any readonly
1741
+ * fields. For ingest, CSV data is uploaded in a separate request (PUT) —
1742
+ * callers must issue that themselves via UploadBulkData once the job ID
1743
+ * is known.
1744
+ */
1745
+ async CreateBulkJob(auth, family, attrs) {
1746
+ const segment = family === 'bulk_ingest' ? 'ingest' : 'query';
1747
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/jobs/${segment}`;
1748
+ const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
1749
+ const body = this.StripReadOnlyFields(attrs);
1750
+ const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
1751
+ if (response.Status >= 200 && response.Status < 300) {
1752
+ const created = response.Body;
1753
+ return this.BuildCreatedResult(created.id, response.Status, `${family} job`);
1754
+ }
1755
+ return this.BuildCRUDError(response, 'CreateBulkJob', family);
1756
+ }
1757
+ /**
1758
+ * Aborts a running Bulk API 2.0 job by PATCHing state=Aborted.
1759
+ */
1760
+ async AbortBulkJob(auth, family, jobId) {
1761
+ const segment = family === 'bulk_ingest' ? 'ingest' : 'query';
1762
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/jobs/${segment}/${jobId}`;
1763
+ const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
1764
+ const response = await this.MakeHTTPRequest(auth, url, 'PATCH', headers, { state: 'Aborted' });
1765
+ if (response.Status >= 200 && response.Status < 300) {
1766
+ return { Success: true, ExternalID: jobId, StatusCode: response.Status };
1767
+ }
1768
+ return this.BuildCRUDError(response, 'AbortBulkJob', family);
1769
+ }
1770
+ async GetBulkJob(auth, family, jobId) {
1771
+ const segment = family === 'bulk_ingest' ? 'ingest' : 'query';
1772
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/jobs/${segment}/${jobId}`;
1773
+ const headers = this.BuildHeaders(auth);
1774
+ const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
1775
+ if (response.Status === 404)
1776
+ return null;
1777
+ this.ValidateResponse(response, url);
1778
+ return {
1779
+ ExternalID: jobId,
1780
+ ObjectType: family === 'bulk_ingest' ? 'BulkIngestJob' : 'BulkQueryJob',
1781
+ Fields: response.Body,
1782
+ };
1783
+ }
1784
+ // ─── Composite Requests ──────────────────────────────────────────
1785
+ /**
1786
+ * Executes a composite request — up to 25 sub-requests in one HTTP call.
1787
+ * The `attrs` payload is expected to be the full composite body or at
1788
+ * least a `compositeRequest` array. Returns the overall composite
1789
+ * response as the created record's ExternalID (Salesforce returns
1790
+ * per-sub-request results; callers should inspect Fields for details).
1791
+ *
1792
+ * Salesforce returns HTTP 200 for the OVERALL composite call even when
1793
+ * individual sub-requests fail (the composite envelope carries a
1794
+ * per-sub-request `httpStatusCode`). Blanket-returning Success:true on the
1795
+ * envelope status swallows those sub-request failures. We inspect the
1796
+ * `compositeResponse` array and fail loudly if any sub-request returned a
1797
+ * 4xx/5xx, summarizing the sub-request errors so the caller sees them.
1798
+ */
1799
+ async ExecuteCompositeRequest(auth, attrs) {
1800
+ const url = `${this.ApiBase(auth)}/services/data/v${auth.ApiVersion}/composite`;
1801
+ const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
1802
+ // Either pass the whole payload through or wrap a bare array
1803
+ const body = Array.isArray(attrs.compositeRequest)
1804
+ ? attrs
1805
+ : { allOrNone: true, compositeRequest: [attrs] };
1806
+ const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
1807
+ if (response.Status >= 200 && response.Status < 300) {
1808
+ const subErrors = this.CollectCompositeSubErrors(response.Body);
1809
+ if (subErrors.length > 0) {
1810
+ return {
1811
+ Success: false,
1812
+ StatusCode: response.Status,
1813
+ ErrorMessage: `[Salesforce] Composite request returned HTTP ${response.Status} but ${subErrors.length} sub-request(s) failed: ${subErrors.join('; ')}`,
1814
+ };
1815
+ }
1816
+ return { Success: true, ExternalID: 'composite', StatusCode: response.Status };
1817
+ }
1818
+ return this.BuildCRUDError(response, 'ExecuteCompositeRequest', 'CompositeRequest');
1819
+ }
1820
+ /**
1821
+ * Inspects a composite response body for per-sub-request failures. A
1822
+ * sub-request failed if its `httpStatusCode` is >= 400. Returns a
1823
+ * human-readable summary string per failed sub-request (empty array when
1824
+ * all sub-requests succeeded or the body has no compositeResponse array).
1825
+ */
1826
+ CollectCompositeSubErrors(responseBody) {
1827
+ const subResponses = responseBody?.compositeResponse;
1828
+ if (!Array.isArray(subResponses))
1829
+ return [];
1830
+ const failures = [];
1831
+ for (const sub of subResponses) {
1832
+ if (typeof sub?.httpStatusCode === 'number' && sub.httpStatusCode >= 400) {
1833
+ failures.push(this.SummarizeCompositeSubError(sub));
1834
+ }
1835
+ }
1836
+ return failures;
1837
+ }
1838
+ /** Formats a single failed composite sub-response into a readable error string. */
1839
+ SummarizeCompositeSubError(sub) {
1840
+ const ref = sub.referenceId ?? '(no referenceId)';
1841
+ const detail = this.ExtractCompositeSubErrorDetail(sub.body);
1842
+ return `${ref} (HTTP ${sub.httpStatusCode})${detail ? `: ${detail}` : ''}`;
1843
+ }
1844
+ /**
1845
+ * Salesforce sub-request error bodies are typically an array of
1846
+ * `{ errorCode, message }`. Pull a concise detail string out of that shape.
1847
+ */
1848
+ ExtractCompositeSubErrorDetail(body) {
1849
+ if (Array.isArray(body)) {
1850
+ return body
1851
+ .map(e => {
1852
+ const err = e;
1853
+ return [err.errorCode, err.message].filter(Boolean).join(': ');
1854
+ })
1855
+ .filter(Boolean)
1856
+ .join(', ');
1857
+ }
1858
+ return '';
1859
+ }
1860
+ };
1861
+ SalesforceConnector = SalesforceConnector_1 = __decorate([
1862
+ RegisterClass(BaseIntegrationConnector, '@memberjunction/connector-salesforce')
1863
+ ], SalesforceConnector);
1864
+ export { SalesforceConnector };
1865
+ //# sourceMappingURL=SalesforceConnector.js.map