@memberjunction/integration-connectors 5.29.0 → 5.30.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/SageIntacctConnector.d.ts +162 -4
- package/dist/SageIntacctConnector.d.ts.map +1 -1
- package/dist/SageIntacctConnector.js +1139 -66
- package/dist/SageIntacctConnector.js.map +1 -1
- package/dist/SalesforceConnector.d.ts +141 -10
- package/dist/SalesforceConnector.d.ts.map +1 -1
- package/dist/SalesforceConnector.js +649 -48
- package/dist/SalesforceConnector.js.map +1 -1
- package/package.json +6 -5
|
@@ -4,9 +4,11 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
4
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
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
|
+
var SalesforceConnector_1;
|
|
7
8
|
import jwt from 'jsonwebtoken';
|
|
8
9
|
import { RegisterClass } from '@memberjunction/global';
|
|
9
10
|
import { Metadata } from '@memberjunction/core';
|
|
11
|
+
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
10
12
|
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
|
|
11
13
|
// ─── Constants ────────────────────────────────────────────────────────
|
|
12
14
|
const DEFAULT_API_VERSION = '61.0';
|
|
@@ -30,6 +32,38 @@ const SYSTEM_READ_ONLY_FIELDS = new Set([
|
|
|
30
32
|
]);
|
|
31
33
|
/** Fields always included in SOQL SELECT */
|
|
32
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
|
+
]);
|
|
33
67
|
// ─── Salesforce Object Metadata for Action Generation ─────────────────
|
|
34
68
|
const SALESFORCE_OBJECTS = [
|
|
35
69
|
{
|
|
@@ -183,11 +217,10 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
183
217
|
// ── Rate limit / governor tracking ──────────────────────────────
|
|
184
218
|
this.lastRequestTime = 0;
|
|
185
219
|
this.governorState = { CurrentUsage: 0, DailyLimit: 15000, LastUpdated: 0 };
|
|
186
|
-
// ── Pagination state for SOQL queryMore ─────────────────────────
|
|
187
|
-
this.queryLocator = null;
|
|
188
220
|
// ── Config ──────────────────────────────────────────────────────
|
|
189
221
|
this._config = null;
|
|
190
222
|
}
|
|
223
|
+
static { SalesforceConnector_1 = this; }
|
|
191
224
|
get effectiveMaxRetries() { return this._config?.MaxRetries ?? DEFAULT_MAX_RETRIES; }
|
|
192
225
|
get effectiveRequestTimeoutMs() { return this._config?.RequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; }
|
|
193
226
|
get effectiveMinRequestIntervalMs() { return this._config?.MinRequestIntervalMs ?? DEFAULT_MIN_REQUEST_INTERVAL_MS; }
|
|
@@ -236,7 +269,10 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
236
269
|
// ─── Discovery (Live from SF Describe API) ───────────────────────
|
|
237
270
|
/**
|
|
238
271
|
* Discovers available objects by calling the SF SObjects API.
|
|
239
|
-
* Returns
|
|
272
|
+
* Returns user-relevant queryable objects: standard CRM objects + custom
|
|
273
|
+
* (__c). Filters out audit/system noise (~1,866 → ~150-300 typically).
|
|
274
|
+
* Set MJ_SALESFORCE_INCLUDE_ALL_SOBJECTS=true to bypass the filter and
|
|
275
|
+
* return the full catalog (useful for debugging or unusual integrations).
|
|
240
276
|
*/
|
|
241
277
|
async DiscoverObjects(companyIntegration, contextUser) {
|
|
242
278
|
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
@@ -246,9 +282,21 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
246
282
|
this.ValidateResponse(response, url);
|
|
247
283
|
const body = response.Body;
|
|
248
284
|
const sobjects = body.sobjects ?? [];
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
285
|
+
const includeAll = process.env.MJ_SALESFORCE_INCLUDE_ALL_SOBJECTS === 'true';
|
|
286
|
+
const filtered = sobjects.filter(obj => {
|
|
287
|
+
if (!obj.queryable)
|
|
288
|
+
return false;
|
|
289
|
+
if (TOOLING_API_DENYLIST.has(obj.name))
|
|
290
|
+
return false;
|
|
291
|
+
if (includeAll)
|
|
292
|
+
return true;
|
|
293
|
+
return this.isUserRelevantSObject(obj);
|
|
294
|
+
});
|
|
295
|
+
if (!includeAll) {
|
|
296
|
+
const removed = sobjects.length - filtered.length;
|
|
297
|
+
console.log(`[Salesforce] DiscoverObjects: filtered ${sobjects.length} → ${filtered.length} (excluded ${removed} system/audit objects; set MJ_SALESFORCE_INCLUDE_ALL_SOBJECTS=true to bypass)`);
|
|
298
|
+
}
|
|
299
|
+
return filtered.map(obj => ({
|
|
252
300
|
Name: obj.name,
|
|
253
301
|
Label: obj.label,
|
|
254
302
|
Description: obj.custom ? `Custom object: ${obj.label}` : undefined,
|
|
@@ -256,6 +304,61 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
256
304
|
SupportsWrite: obj.createable || obj.updateable,
|
|
257
305
|
}));
|
|
258
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Heuristic for "user-relevant" SF object: customer data (custom objects
|
|
309
|
+
* with __c suffix) OR createable standard CRM objects, excluding audit
|
|
310
|
+
* tables, system metadata, and managed-package telemetry.
|
|
311
|
+
*
|
|
312
|
+
* Standard SF orgs return ~1,866 sobjects from describeGlobal — most are
|
|
313
|
+
* audit/internal noise that don't represent business data the user wants
|
|
314
|
+
* to sync into MJ. This drops them to ~150-300.
|
|
315
|
+
*/
|
|
316
|
+
isUserRelevantSObject(obj) {
|
|
317
|
+
// Custom objects always pass — they're customer-defined data
|
|
318
|
+
if (obj.custom)
|
|
319
|
+
return true;
|
|
320
|
+
const name = obj.name;
|
|
321
|
+
// STRICT exclusions only. Don't filter anything that could possibly
|
|
322
|
+
// hold customer data. Each exclusion below is a category SF defines
|
|
323
|
+
// as pure audit/internal/metadata with no customer-meaningful rows:
|
|
324
|
+
//
|
|
325
|
+
// *ChangeEvent: CDC stream, transient, replicated by replication API
|
|
326
|
+
// *Feed: Chatter feed entries (separate "Feed" sync if wanted)
|
|
327
|
+
// *History: audit tables — every value-change is one row
|
|
328
|
+
// *FieldHistory: same as *History but per-field tracking
|
|
329
|
+
// *Share: SF row-level access control entries (security metadata)
|
|
330
|
+
// *OwnerSharingRule / *CriteriaBasedSharingRule: security rules
|
|
331
|
+
// *PermissionSet*: profile/permission internals
|
|
332
|
+
//
|
|
333
|
+
// Things we used to exclude that we now LET THROUGH because they CAN
|
|
334
|
+
// be customer data: EmailMessage (email log), CaseComment / FeedComment
|
|
335
|
+
// (customer interactions), EntitySubscription (notification subs),
|
|
336
|
+
// Vote (idea/feedback), Tag (content tagging), Solution (knowledge),
|
|
337
|
+
// ProcessInstance (workflow approvals), Domain (tenant config).
|
|
338
|
+
if (/(?:ChangeEvent|Feed|History|FieldHistory|Share|OwnerSharingRule|CriteriaBasedSharingRule)$/.test(name)) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
// SF tooling/setup metadata: Apex code, permissions, setup audit, login
|
|
342
|
+
// history, async job framework, sandbox/cron internals, network/site
|
|
343
|
+
// metadata, theme/branding, package licenses. None of these are
|
|
344
|
+
// business data the integration should sync.
|
|
345
|
+
if (/^(Apex|Permission|Setup|Login|Async|Sandbox|Auth|Network|Stamp|Site|FlowDefinition|FlowInterview|FlowVariableView|EventLog|CronTrigger|StreamingChannel|InstalledMobileApp|UserPackageLicense|PackageLicense|Theme)/.test(name)) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
// Specific system catalog objects — schema-of-the-schema metadata,
|
|
349
|
+
// not customer data. These are readable but represent SF's own
|
|
350
|
+
// structural definitions, never user-entered records.
|
|
351
|
+
if (/^(EntityDefinition|FieldDefinition|EntityParticle|RelationshipInfo|RelationshipDomain|StandardAction|UserAppMenuItem|UserListView|UserPreference|UserShare|GroupMember|FiscalYearSettings|Period|RecordType|BusinessProcess|PicklistValueInfo)$/.test(name)) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
// NOTE: we intentionally do NOT exclude `!obj.createable`. Many SF
|
|
355
|
+
// objects are flagged non-createable because they're auto-populated
|
|
356
|
+
// by SF (rollups, attachment-link junctions, history-style records)
|
|
357
|
+
// but they DO carry real customer data we want to sync. The
|
|
358
|
+
// targeted exclusions above already cover the audit/security/CDC
|
|
359
|
+
// categories that have no business value.
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
259
362
|
/**
|
|
260
363
|
* Discovers fields on a specific SF object via the Describe API.
|
|
261
364
|
* Skips compound fields (address, location) — their component fields are included individually.
|
|
@@ -274,50 +377,208 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
274
377
|
}
|
|
275
378
|
/**
|
|
276
379
|
* Full schema introspection — builds object graph with relationships.
|
|
380
|
+
*
|
|
381
|
+
* If `options.ObjectNames` is provided, only those objects are described
|
|
382
|
+
* (fast path for user-selected subsets). Without a filter, every
|
|
383
|
+
* queryable sobject is described — ~70s even with parallelism on a
|
|
384
|
+
* large org — and the result is cached per-org for 5 minutes so
|
|
385
|
+
* back-to-back resolver calls don't re-describe.
|
|
386
|
+
*
|
|
387
|
+
* Filtered calls bypass the cache on purpose: the subset is typically
|
|
388
|
+
* small and cheap, and the full-schema cache would shadow newer results
|
|
389
|
+
* if a user selects a previously-unknown object.
|
|
277
390
|
*/
|
|
278
|
-
async IntrospectSchema(companyIntegration, contextUser) {
|
|
279
|
-
const
|
|
391
|
+
async IntrospectSchema(companyIntegration, contextUser, options) {
|
|
392
|
+
const filtered = options?.ObjectNames && options.ObjectNames.length > 0;
|
|
393
|
+
if (filtered) {
|
|
394
|
+
return this.DoIntrospectSchema(companyIntegration, contextUser, options);
|
|
395
|
+
}
|
|
396
|
+
// Dedupe back-to-back introspection requests from the same org.
|
|
397
|
+
// Three separate resolvers call IntrospectSchema; without this, the
|
|
398
|
+
// UI's "discover then apply" flow re-describes every object twice.
|
|
399
|
+
const cacheKey = companyIntegration.ID;
|
|
400
|
+
const cached = SalesforceConnector_1.introspectCache.get(cacheKey);
|
|
401
|
+
const now = Date.now();
|
|
402
|
+
if (cached && cached.expiresAt > now) {
|
|
403
|
+
const remainingSec = Math.round((cached.expiresAt - now) / 1000);
|
|
404
|
+
console.log(`[Salesforce] IntrospectSchema: reusing in-flight/cached result (${remainingSec}s TTL remaining)`);
|
|
405
|
+
return cached.promise;
|
|
406
|
+
}
|
|
407
|
+
const promise = this.DoIntrospectSchema(companyIntegration, contextUser);
|
|
408
|
+
SalesforceConnector_1.introspectCache.set(cacheKey, {
|
|
409
|
+
promise,
|
|
410
|
+
expiresAt: now + SalesforceConnector_1.INTROSPECT_CACHE_TTL_MS,
|
|
411
|
+
});
|
|
412
|
+
// If the run fails, evict so the next request can retry
|
|
413
|
+
promise.catch(() => SalesforceConnector_1.introspectCache.delete(cacheKey));
|
|
414
|
+
return promise;
|
|
415
|
+
}
|
|
416
|
+
async DoIntrospectSchema(companyIntegration, contextUser, options) {
|
|
417
|
+
const allObjects = await this.DiscoverObjects(companyIntegration, contextUser);
|
|
418
|
+
const wanted = options?.ObjectNames && options.ObjectNames.length > 0
|
|
419
|
+
? new Set(options.ObjectNames)
|
|
420
|
+
: null;
|
|
421
|
+
const objects = wanted ? allObjects.filter(o => wanted.has(o.Name)) : allObjects;
|
|
422
|
+
const total = objects.length;
|
|
423
|
+
const startMs = Date.now();
|
|
424
|
+
const CONCURRENCY = 8;
|
|
425
|
+
console.log(`[Salesforce] IntrospectSchema: describing ${total} queryable objects (parallel×${CONCURRENCY})...`);
|
|
280
426
|
const result = { Objects: [] };
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
427
|
+
let nextIdx = 0;
|
|
428
|
+
let succeeded = 0;
|
|
429
|
+
let skipped = 0;
|
|
430
|
+
const worker = async () => {
|
|
431
|
+
while (true) {
|
|
432
|
+
const myIdx = nextIdx++;
|
|
433
|
+
if (myIdx >= total)
|
|
434
|
+
return;
|
|
435
|
+
const obj = objects[myIdx];
|
|
436
|
+
try {
|
|
437
|
+
const sourceObj = await this.BuildSourceObjectFromDescribe(companyIntegration, contextUser, obj.Name);
|
|
438
|
+
result.Objects.push(sourceObj);
|
|
439
|
+
succeeded++;
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
443
|
+
skipped++;
|
|
444
|
+
console.warn(`[Salesforce] Skipping "${obj.Name}" during introspection: ${msg}`);
|
|
445
|
+
}
|
|
446
|
+
const done = succeeded + skipped;
|
|
447
|
+
if (done % 100 === 0 || done === total) {
|
|
448
|
+
const elapsedSec = ((Date.now() - startMs) / 1000).toFixed(1);
|
|
449
|
+
const etaSec = done < total
|
|
450
|
+
? (((Date.now() - startMs) / done) * (total - done) / 1000).toFixed(0)
|
|
451
|
+
: '0';
|
|
452
|
+
console.log(`[Salesforce] IntrospectSchema progress: ${done}/${total} (ok=${succeeded}, skipped=${skipped}) — ${elapsedSec}s elapsed, ~${etaSec}s remaining`);
|
|
453
|
+
}
|
|
289
454
|
}
|
|
290
|
-
}
|
|
455
|
+
};
|
|
456
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
|
457
|
+
console.log(`[Salesforce] IntrospectSchema complete: ${succeeded}/${total} objects in ${((Date.now() - startMs) / 1000).toFixed(1)}s`);
|
|
291
458
|
return result;
|
|
292
459
|
}
|
|
460
|
+
// ─── Introspection cache (module-scoped via static) ──────────────
|
|
461
|
+
// Resolver creates a fresh connector per request, so instance-level
|
|
462
|
+
// caching is useless. Static Map survives across requests in the same
|
|
463
|
+
// process. TTL is modest to allow schema refresh on demand.
|
|
464
|
+
static { this.INTROSPECT_CACHE_TTL_MS = 5 * 60 * 1000; }
|
|
465
|
+
static { this.introspectCache = new Map(); }
|
|
293
466
|
// ─── FetchChanges (SOQL-based — overrides base class entirely) ───
|
|
294
467
|
/**
|
|
295
468
|
* Fetches changed records using SOQL queries with SystemModstamp watermarks.
|
|
296
469
|
* Completely overrides the base class REST pagination because SF uses
|
|
297
470
|
* SOQL + queryMore, not standard REST list endpoints.
|
|
471
|
+
*
|
|
472
|
+
* Dispatches to family-specific fetch routines based on the
|
|
473
|
+
* IntegrationObject metadata's `DefaultQueryParams.api_family` hint:
|
|
474
|
+
* tooling → Tooling API, analytics_report/dashboard → Analytics API,
|
|
475
|
+
* bulk_* / composite → not intended for listing (returns empty batch),
|
|
476
|
+
* everything else → standard SObject SOQL flow.
|
|
298
477
|
*/
|
|
299
478
|
async FetchChanges(ctx) {
|
|
300
479
|
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
301
480
|
const batchSize = ctx.BatchSize || this.effectiveBatchSize;
|
|
302
|
-
|
|
303
|
-
|
|
481
|
+
const family = this.ResolveAPIFamily(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
|
|
482
|
+
// Continuation: SF returned a nextRecordsUrl on the previous call,
|
|
483
|
+
// and the engine passed it back as CurrentCursor. Re-issue against
|
|
484
|
+
// the same query state via /query/<locator> rather than re-running
|
|
485
|
+
// the original SOQL (which would just return the first page again
|
|
486
|
+
// and silently truncate the dataset to one batch). Pre-existing bug:
|
|
487
|
+
// a dead `this.queryLocator` member shadowed this branch with an
|
|
488
|
+
// always-false condition, so every "next batch" call re-executed
|
|
489
|
+
// the initial SOQL and produced duplicate first-page results until
|
|
490
|
+
// the engine's duplicate-batch guard aborted the entity.
|
|
491
|
+
if (ctx.CurrentCursor) {
|
|
304
492
|
return this.FetchNextPage(auth, ctx.CurrentCursor);
|
|
305
493
|
}
|
|
306
|
-
|
|
307
|
-
|
|
494
|
+
if (family === 'analytics_report' || family === 'analytics_dashboard') {
|
|
495
|
+
return this.FetchAnalyticsList(auth, family, ctx.ObjectName);
|
|
496
|
+
}
|
|
497
|
+
if (family === 'bulk_ingest' || family === 'bulk_query') {
|
|
498
|
+
return this.FetchBulkJobs(auth, family, ctx.ObjectName);
|
|
499
|
+
}
|
|
500
|
+
if (family === 'composite') {
|
|
501
|
+
// Composite is a per-request construct, not a queryable endpoint
|
|
502
|
+
return { Records: [], HasMore: false };
|
|
503
|
+
}
|
|
504
|
+
if (family === 'knowledge') {
|
|
505
|
+
const page = ctx.CurrentPage ?? 1;
|
|
506
|
+
return this.FetchKnowledgeArticles(auth, ctx.ObjectName, ctx.WatermarkValue, batchSize, page);
|
|
507
|
+
}
|
|
508
|
+
// Standard SObject (sobject) and Tooling (tooling) both use SOQL —
|
|
509
|
+
// just against different endpoints.
|
|
510
|
+
const fields = await this.GetQueryableFieldNames(auth, ctx.ObjectName, family);
|
|
308
511
|
const soql = this.BuildSOQLQuery(ctx.ObjectName, fields, ctx.WatermarkValue, batchSize, true);
|
|
309
|
-
return this.ExecuteSOQLQuery(auth, soql, ctx.ObjectName);
|
|
512
|
+
return this.ExecuteSOQLQuery(auth, soql, ctx.ObjectName, family);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Resolves the API family for a given IntegrationObject by reading its
|
|
516
|
+
* `DefaultQueryParams.api_family` flag from the engine cache. Defaults to
|
|
517
|
+
* `sobject` when no metadata is available (common in unit tests).
|
|
518
|
+
*/
|
|
519
|
+
ResolveAPIFamily(integrationID, objectName) {
|
|
520
|
+
try {
|
|
521
|
+
const obj = IntegrationEngineBase.Instance.GetIntegrationObject(integrationID, objectName);
|
|
522
|
+
if (!obj || !obj.DefaultQueryParams)
|
|
523
|
+
return 'sobject';
|
|
524
|
+
const parsed = JSON.parse(obj.DefaultQueryParams);
|
|
525
|
+
if (this.IsValidFamily(parsed.api_family))
|
|
526
|
+
return parsed.api_family;
|
|
527
|
+
return 'sobject';
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return 'sobject';
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
IsValidFamily(v) {
|
|
534
|
+
return v === 'sobject' || v === 'tooling' || v === 'knowledge'
|
|
535
|
+
|| v === 'analytics_report' || v === 'analytics_dashboard'
|
|
536
|
+
|| v === 'bulk_ingest' || v === 'bulk_query' || v === 'composite';
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Returns the REST path prefix for an SObject in the given API family.
|
|
540
|
+
* Used by CRUD operations and field discovery.
|
|
541
|
+
*/
|
|
542
|
+
SObjectBasePath(auth, family) {
|
|
543
|
+
const base = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}`;
|
|
544
|
+
return family === 'tooling' ? `${base}/tooling/sobjects` : `${base}/sobjects`;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Returns the SOQL query endpoint for the given API family.
|
|
548
|
+
* Standard uses /query + /queryAll; Tooling uses /tooling/query.
|
|
549
|
+
*/
|
|
550
|
+
SOQLEndpoint(auth, family, includeDeleted) {
|
|
551
|
+
const base = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}`;
|
|
552
|
+
if (family === 'tooling')
|
|
553
|
+
return `${base}/tooling/query`;
|
|
554
|
+
return includeDeleted ? `${base}/queryAll` : `${base}/query`;
|
|
310
555
|
}
|
|
311
556
|
// ─── CRUD Operations ─────────────────────────────────────────────
|
|
312
557
|
/**
|
|
313
|
-
* Creates a new record in Salesforce.
|
|
558
|
+
* Creates a new record in Salesforce. Dispatches to the appropriate API
|
|
559
|
+
* family endpoint (standard SObjects, Tooling, Bulk Ingest/Query jobs, or
|
|
560
|
+
* Composite) based on the IntegrationObject metadata.
|
|
314
561
|
*/
|
|
315
562
|
async CreateRecord(ctx) {
|
|
316
563
|
const companyIntegration = ctx.CompanyIntegration;
|
|
317
564
|
const contextUser = ctx.ContextUser;
|
|
318
565
|
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
566
|
+
const family = this.ResolveAPIFamily(companyIntegration.IntegrationID, ctx.ObjectName);
|
|
567
|
+
if (family === 'bulk_ingest' || family === 'bulk_query') {
|
|
568
|
+
return this.CreateBulkJob(auth, family, ctx.Attributes);
|
|
569
|
+
}
|
|
570
|
+
if (family === 'composite') {
|
|
571
|
+
return this.ExecuteCompositeRequest(auth, ctx.Attributes);
|
|
572
|
+
}
|
|
573
|
+
if (family === 'analytics_report' || family === 'analytics_dashboard' || family === 'knowledge') {
|
|
574
|
+
return {
|
|
575
|
+
Success: false,
|
|
576
|
+
ErrorMessage: `[Salesforce] Create is not supported for API family "${family}" (${ctx.ObjectName})`,
|
|
577
|
+
StatusCode: 405,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
319
580
|
const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
|
|
320
|
-
const url = `${
|
|
581
|
+
const url = `${this.SObjectBasePath(auth, family)}/${ctx.ObjectName}/`;
|
|
321
582
|
const body = this.StripReadOnlyFields(ctx.Attributes);
|
|
322
583
|
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
323
584
|
if (response.Status === 201 || (response.Status >= 200 && response.Status < 300)) {
|
|
@@ -332,13 +593,23 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
332
593
|
}
|
|
333
594
|
/**
|
|
334
595
|
* Updates an existing record in Salesforce (PATCH — only changed fields).
|
|
596
|
+
* Honors API family routing; Tooling uses `/tooling/sobjects/`, everything
|
|
597
|
+
* else uses standard `/sobjects/`.
|
|
335
598
|
*/
|
|
336
599
|
async UpdateRecord(ctx) {
|
|
337
600
|
const companyIntegration = ctx.CompanyIntegration;
|
|
338
601
|
const contextUser = ctx.ContextUser;
|
|
339
602
|
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
603
|
+
const family = this.ResolveAPIFamily(companyIntegration.IntegrationID, ctx.ObjectName);
|
|
604
|
+
if (family !== 'sobject' && family !== 'tooling') {
|
|
605
|
+
return {
|
|
606
|
+
Success: false,
|
|
607
|
+
ErrorMessage: `[Salesforce] Update is not supported for API family "${family}" (${ctx.ObjectName})`,
|
|
608
|
+
StatusCode: 405,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
340
611
|
const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
|
|
341
|
-
const url = `${
|
|
612
|
+
const url = `${this.SObjectBasePath(auth, family)}/${ctx.ObjectName}/${ctx.ExternalID}`;
|
|
342
613
|
const body = this.StripReadOnlyFields(ctx.Attributes);
|
|
343
614
|
const response = await this.MakeHTTPRequest(auth, url, 'PATCH', headers, body);
|
|
344
615
|
if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
|
|
@@ -351,14 +622,28 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
351
622
|
return this.HandleWriteError(response, 'UpdateRecord', ctx.ObjectName, ctx.ExternalID, auth, headers, url, body);
|
|
352
623
|
}
|
|
353
624
|
/**
|
|
354
|
-
* Deletes a record from Salesforce.
|
|
625
|
+
* Deletes a record from Salesforce. For Bulk Job families, delete aborts
|
|
626
|
+
* the job. For standard and Tooling SObjects, performs a soft delete
|
|
627
|
+
* (Recycle Bin, 15-day retention). Analytics/Composite/Knowledge do not
|
|
628
|
+
* support deletion through this path.
|
|
355
629
|
*/
|
|
356
630
|
async DeleteRecord(ctx) {
|
|
357
631
|
const companyIntegration = ctx.CompanyIntegration;
|
|
358
632
|
const contextUser = ctx.ContextUser;
|
|
359
633
|
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
634
|
+
const family = this.ResolveAPIFamily(companyIntegration.IntegrationID, ctx.ObjectName);
|
|
635
|
+
if (family === 'bulk_ingest' || family === 'bulk_query') {
|
|
636
|
+
return this.AbortBulkJob(auth, family, ctx.ExternalID);
|
|
637
|
+
}
|
|
638
|
+
if (family !== 'sobject' && family !== 'tooling') {
|
|
639
|
+
return {
|
|
640
|
+
Success: false,
|
|
641
|
+
ErrorMessage: `[Salesforce] Delete is not supported for API family "${family}" (${ctx.ObjectName})`,
|
|
642
|
+
StatusCode: 405,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
360
645
|
const headers = this.BuildHeaders(auth);
|
|
361
|
-
const url = `${
|
|
646
|
+
const url = `${this.SObjectBasePath(auth, family)}/${ctx.ObjectName}/${ctx.ExternalID}`;
|
|
362
647
|
const response = await this.MakeHTTPRequest(auth, url, 'DELETE', headers);
|
|
363
648
|
if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
|
|
364
649
|
return { Success: true, ExternalID: ctx.ExternalID, StatusCode: response.Status };
|
|
@@ -370,14 +655,28 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
370
655
|
return this.BuildCRUDError(response, 'DeleteRecord', ctx.ObjectName);
|
|
371
656
|
}
|
|
372
657
|
/**
|
|
373
|
-
* Retrieves a single record by its Salesforce ID.
|
|
658
|
+
* Retrieves a single record by its Salesforce ID. Routes to the API family
|
|
659
|
+
* endpoint indicated by IntegrationObject metadata.
|
|
374
660
|
*/
|
|
375
661
|
async GetRecord(ctx) {
|
|
376
662
|
const companyIntegration = ctx.CompanyIntegration;
|
|
377
663
|
const contextUser = ctx.ContextUser;
|
|
378
664
|
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
665
|
+
const family = this.ResolveAPIFamily(companyIntegration.IntegrationID, ctx.ObjectName);
|
|
666
|
+
if (family === 'analytics_report') {
|
|
667
|
+
return this.GetAnalyticsReport(auth, ctx.ExternalID);
|
|
668
|
+
}
|
|
669
|
+
if (family === 'analytics_dashboard') {
|
|
670
|
+
return this.GetAnalyticsDashboard(auth, ctx.ExternalID);
|
|
671
|
+
}
|
|
672
|
+
if (family === 'bulk_ingest' || family === 'bulk_query') {
|
|
673
|
+
return this.GetBulkJob(auth, family, ctx.ExternalID);
|
|
674
|
+
}
|
|
675
|
+
if (family === 'composite') {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
379
678
|
const headers = this.BuildHeaders(auth);
|
|
380
|
-
const url = `${
|
|
679
|
+
const url = `${this.SObjectBasePath(auth, family)}/${ctx.ObjectName}/${ctx.ExternalID}`;
|
|
381
680
|
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
382
681
|
if (response.Status === 404)
|
|
383
682
|
return null;
|
|
@@ -388,12 +687,18 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
388
687
|
// ─── Search (SOQL) ───────────────────────────────────────────────
|
|
389
688
|
/**
|
|
390
689
|
* Searches records using SOQL WHERE clauses built from the provided filters.
|
|
690
|
+
* Search is supported for Standard SObjects and Tooling SObjects; other API
|
|
691
|
+
* families return an empty result.
|
|
391
692
|
*/
|
|
392
693
|
async SearchRecords(ctx) {
|
|
393
694
|
const companyIntegration = ctx.CompanyIntegration;
|
|
394
695
|
const contextUser = ctx.ContextUser;
|
|
395
696
|
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
396
|
-
const
|
|
697
|
+
const family = this.ResolveAPIFamily(companyIntegration.IntegrationID, ctx.ObjectName);
|
|
698
|
+
if (family !== 'sobject' && family !== 'tooling') {
|
|
699
|
+
return { Records: [], TotalCount: 0, HasMore: false };
|
|
700
|
+
}
|
|
701
|
+
const fields = await this.GetQueryableFieldNames(auth, ctx.ObjectName, family);
|
|
397
702
|
const whereClause = this.BuildWhereClauseFromFilters(ctx.Filters);
|
|
398
703
|
const limit = ctx.PageSize ?? 100;
|
|
399
704
|
const offset = ctx.Page != null && ctx.Page > 1 ? (ctx.Page - 1) * limit : 0;
|
|
@@ -405,7 +710,7 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
405
710
|
soql += ` LIMIT ${limit}`;
|
|
406
711
|
if (offset > 0)
|
|
407
712
|
soql += ` OFFSET ${offset}`;
|
|
408
|
-
const url = `${
|
|
713
|
+
const url = `${this.SOQLEndpoint(auth, family, false)}?q=${encodeURIComponent(soql)}`;
|
|
409
714
|
const headers = this.BuildHeaders(auth);
|
|
410
715
|
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
411
716
|
this.ValidateResponse(response, url);
|
|
@@ -548,21 +853,60 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
548
853
|
* Builds a SOQL query string with optional watermark filter.
|
|
549
854
|
*/
|
|
550
855
|
BuildSOQLQuery(objectName, fields, watermarkValue, batchSize, includeDeleted) {
|
|
551
|
-
|
|
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;
|
|
552
872
|
let soql = `SELECT ${dedupedFields.join(', ')} FROM ${objectName}`;
|
|
553
|
-
if (watermarkValue) {
|
|
554
|
-
|
|
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`;
|
|
555
886
|
}
|
|
556
|
-
|
|
557
|
-
|
|
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;
|
|
558
898
|
return soql;
|
|
559
899
|
}
|
|
560
900
|
/**
|
|
561
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.
|
|
562
906
|
*/
|
|
563
|
-
async ExecuteSOQLQuery(auth, soql, objectName) {
|
|
564
|
-
|
|
565
|
-
const url = `${
|
|
907
|
+
async ExecuteSOQLQuery(auth, soql, objectName, family = 'sobject') {
|
|
908
|
+
const includeDeleted = family === 'sobject';
|
|
909
|
+
const url = `${this.SOQLEndpoint(auth, family, includeDeleted)}?q=${encodeURIComponent(soql)}`;
|
|
566
910
|
const headers = this.BuildHeaders(auth);
|
|
567
911
|
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
568
912
|
this.ValidateResponse(response, url);
|
|
@@ -588,7 +932,35 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
588
932
|
* Parses a SOQL query response into a FetchBatchResult.
|
|
589
933
|
*/
|
|
590
934
|
ParseSOQLResponse(body, objectName) {
|
|
591
|
-
const
|
|
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 => {
|
|
592
964
|
const raw = r;
|
|
593
965
|
const record = this.RawToExternalRecord(raw, objectName);
|
|
594
966
|
record.IsDeleted = raw['IsDeleted'] === true;
|
|
@@ -598,7 +970,7 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
598
970
|
return record;
|
|
599
971
|
});
|
|
600
972
|
// Compute new watermark from the max SystemModstamp in this batch
|
|
601
|
-
const newWatermark = this.ExtractMaxWatermark(
|
|
973
|
+
const newWatermark = this.ExtractMaxWatermark(dedupedRaw);
|
|
602
974
|
return {
|
|
603
975
|
Records: records,
|
|
604
976
|
HasMore: !body.done,
|
|
@@ -607,7 +979,23 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
607
979
|
};
|
|
608
980
|
}
|
|
609
981
|
/**
|
|
610
|
-
* Extracts the maximum SystemModstamp value from a batch of records
|
|
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.
|
|
611
999
|
*/
|
|
612
1000
|
ExtractMaxWatermark(records) {
|
|
613
1001
|
let maxTimestamp = null;
|
|
@@ -618,13 +1006,20 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
618
1006
|
maxTimestamp = stamp;
|
|
619
1007
|
}
|
|
620
1008
|
}
|
|
621
|
-
|
|
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();
|
|
622
1015
|
}
|
|
623
1016
|
/**
|
|
624
|
-
* Gets queryable field names for a SF object via the Describe API.
|
|
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.
|
|
625
1020
|
*/
|
|
626
|
-
async GetQueryableFieldNames(auth, objectName) {
|
|
627
|
-
const url = `${
|
|
1021
|
+
async GetQueryableFieldNames(auth, objectName, family = 'sobject') {
|
|
1022
|
+
const url = `${this.SObjectBasePath(auth, family)}/${objectName}/describe`;
|
|
628
1023
|
const headers = this.BuildHeaders(auth);
|
|
629
1024
|
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
630
1025
|
this.ValidateResponse(response, url);
|
|
@@ -646,7 +1041,33 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
646
1041
|
aud: config.LoginUrl,
|
|
647
1042
|
exp: now + 300, // 5 minutes
|
|
648
1043
|
};
|
|
649
|
-
|
|
1044
|
+
const pem = this.NormalizePem(config.PrivateKey);
|
|
1045
|
+
return jwt.sign(payload, pem, { algorithm: 'RS256' });
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Normalize a PEM private key that may have had its newlines stripped or
|
|
1049
|
+
* replaced with spaces / literal "\n" sequences. Rebuilds the standard
|
|
1050
|
+
* BEGIN header / 64-char body / END footer layout that OpenSSL requires.
|
|
1051
|
+
*/
|
|
1052
|
+
NormalizePem(key) {
|
|
1053
|
+
if (!key)
|
|
1054
|
+
return key;
|
|
1055
|
+
// First, normalize escaped newlines to real ones
|
|
1056
|
+
let normalized = key.includes('\\n') ? key.replace(/\\n/g, '\n') : key;
|
|
1057
|
+
// If real newlines exist already, trust them
|
|
1058
|
+
if (normalized.includes('\n'))
|
|
1059
|
+
return normalized;
|
|
1060
|
+
// Split out header / footer / body and rebuild with real newlines
|
|
1061
|
+
const headerMatch = normalized.match(/^(-----BEGIN [^-]+-----)/);
|
|
1062
|
+
const footerMatch = normalized.match(/(-----END [^-]+-----)\s*$/);
|
|
1063
|
+
if (!headerMatch || !footerMatch)
|
|
1064
|
+
return normalized;
|
|
1065
|
+
const header = headerMatch[1];
|
|
1066
|
+
const footer = footerMatch[1];
|
|
1067
|
+
const bodyRaw = normalized.slice(header.length, normalized.length - footer.length);
|
|
1068
|
+
const body = bodyRaw.replace(/\s+/g, '');
|
|
1069
|
+
const chunked = body.match(/.{1,64}/g)?.join('\n') ?? body;
|
|
1070
|
+
return `${header}\n${chunked}\n${footer}`;
|
|
650
1071
|
}
|
|
651
1072
|
/**
|
|
652
1073
|
* Exchanges a JWT assertion for an access token at the SF token endpoint.
|
|
@@ -910,6 +1331,9 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
910
1331
|
// ─── SF Type Mapping ─────────────────────────────────────────────
|
|
911
1332
|
/**
|
|
912
1333
|
* Maps a Salesforce field describe to an ExternalFieldSchema.
|
|
1334
|
+
* Custom fields can be detected by callers via the `__c` suffix in the
|
|
1335
|
+
* field name — Salesforce standardizes this convention for all custom
|
|
1336
|
+
* fields on both standard and custom SObjects.
|
|
913
1337
|
*/
|
|
914
1338
|
MapSFFieldToSchema(f) {
|
|
915
1339
|
return {
|
|
@@ -1169,8 +1593,185 @@ let SalesforceConnector = class SalesforceConnector extends BaseRESTIntegrationC
|
|
|
1169
1593
|
return body.slice(0, 500);
|
|
1170
1594
|
return JSON.stringify(body).slice(0, 500);
|
|
1171
1595
|
}
|
|
1596
|
+
// ─── Analytics API (Reports & Dashboards) ────────────────────────
|
|
1597
|
+
/**
|
|
1598
|
+
* Lists Analytics Reports or Dashboards via the Analytics API. Read-only;
|
|
1599
|
+
* no incremental-sync support at this endpoint.
|
|
1600
|
+
*/
|
|
1601
|
+
async FetchAnalyticsList(auth, family, objectName) {
|
|
1602
|
+
const segment = family === 'analytics_report' ? 'reports' : 'dashboards';
|
|
1603
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/analytics/${segment}`;
|
|
1604
|
+
const headers = this.BuildHeaders(auth);
|
|
1605
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1606
|
+
this.ValidateResponse(response, url);
|
|
1607
|
+
const body = response.Body;
|
|
1608
|
+
const list = body.reports ?? body.dashboards ?? [];
|
|
1609
|
+
const records = list.map(item => this.AnalyticsItemToRecord(item, objectName));
|
|
1610
|
+
return { Records: records, HasMore: false };
|
|
1611
|
+
}
|
|
1612
|
+
AnalyticsItemToRecord(item, objectType) {
|
|
1613
|
+
return {
|
|
1614
|
+
ExternalID: String(item.id ?? ''),
|
|
1615
|
+
ObjectType: objectType,
|
|
1616
|
+
Fields: { ...item },
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
async GetAnalyticsReport(auth, id) {
|
|
1620
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/analytics/reports/${id}`;
|
|
1621
|
+
const headers = this.BuildHeaders(auth);
|
|
1622
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1623
|
+
if (response.Status === 404)
|
|
1624
|
+
return null;
|
|
1625
|
+
this.ValidateResponse(response, url);
|
|
1626
|
+
return {
|
|
1627
|
+
ExternalID: id,
|
|
1628
|
+
ObjectType: 'Report',
|
|
1629
|
+
Fields: response.Body,
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
async GetAnalyticsDashboard(auth, id) {
|
|
1633
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/analytics/dashboards/${id}`;
|
|
1634
|
+
const headers = this.BuildHeaders(auth);
|
|
1635
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1636
|
+
if (response.Status === 404)
|
|
1637
|
+
return null;
|
|
1638
|
+
this.ValidateResponse(response, url);
|
|
1639
|
+
return {
|
|
1640
|
+
ExternalID: id,
|
|
1641
|
+
ObjectType: 'Dashboard',
|
|
1642
|
+
Fields: response.Body,
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
// ─── Knowledge Articles ──────────────────────────────────────────
|
|
1646
|
+
/**
|
|
1647
|
+
* Fetches published Knowledge article versions. This endpoint is
|
|
1648
|
+
* paginated via `pageNumber`/`pageSize` (not cursor). The connector
|
|
1649
|
+
* returns a single batch; callers that need pagination can issue
|
|
1650
|
+
* additional requests via SearchRecords.
|
|
1651
|
+
*/
|
|
1652
|
+
async FetchKnowledgeArticles(auth, objectName, watermarkValue, batchSize, page = 1) {
|
|
1653
|
+
const params = new URLSearchParams({ pageSize: String(batchSize), pageNumber: String(page) });
|
|
1654
|
+
if (watermarkValue) {
|
|
1655
|
+
params.set('publishStatus', 'Online');
|
|
1656
|
+
}
|
|
1657
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/support/knowledgeArticles?${params}`;
|
|
1658
|
+
const headers = this.BuildHeaders(auth);
|
|
1659
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1660
|
+
this.ValidateResponse(response, url);
|
|
1661
|
+
const body = response.Body;
|
|
1662
|
+
const records = (body.articles ?? []).map(a => ({
|
|
1663
|
+
ExternalID: String(a.id ?? ''),
|
|
1664
|
+
ObjectType: objectName,
|
|
1665
|
+
Fields: a,
|
|
1666
|
+
}));
|
|
1667
|
+
// Knowledge Articles uses page-based pagination. The endpoint doesn't
|
|
1668
|
+
// return a documented total/has-more signal in its body, so treat a
|
|
1669
|
+
// full page as "potentially more" — same defensive pattern used for
|
|
1670
|
+
// SI and YM. Engine loop drives subsequent pages via NextPage; an
|
|
1671
|
+
// empty next-page response terminates naturally. Without this guard
|
|
1672
|
+
// sync silently caps at batchSize records per object — identical to
|
|
1673
|
+
// the SOQL LIMIT bug we already fixed in this session.
|
|
1674
|
+
const hasMore = records.length >= batchSize;
|
|
1675
|
+
return {
|
|
1676
|
+
Records: records,
|
|
1677
|
+
HasMore: hasMore,
|
|
1678
|
+
NextPage: hasMore ? page + 1 : undefined,
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
// ─── Bulk API 2.0 (Ingest & Query Jobs) ──────────────────────────
|
|
1682
|
+
/**
|
|
1683
|
+
* Lists in-flight Bulk API 2.0 jobs. Useful for monitoring background
|
|
1684
|
+
* imports/queries the integration previously started.
|
|
1685
|
+
*/
|
|
1686
|
+
async FetchBulkJobs(auth, family, objectName) {
|
|
1687
|
+
const segment = family === 'bulk_ingest' ? 'ingest' : 'query';
|
|
1688
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/jobs/${segment}`;
|
|
1689
|
+
const headers = this.BuildHeaders(auth);
|
|
1690
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1691
|
+
this.ValidateResponse(response, url);
|
|
1692
|
+
const body = response.Body;
|
|
1693
|
+
const records = (body.records ?? []).map(r => ({
|
|
1694
|
+
ExternalID: String(r.id ?? ''),
|
|
1695
|
+
ObjectType: objectName,
|
|
1696
|
+
Fields: r,
|
|
1697
|
+
ModifiedAt: r.systemModstamp ? new Date(r.systemModstamp) : undefined,
|
|
1698
|
+
}));
|
|
1699
|
+
return {
|
|
1700
|
+
Records: records,
|
|
1701
|
+
HasMore: !body.done,
|
|
1702
|
+
NextCursor: body.nextRecordsUrl ?? undefined,
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Creates (starts) a new Bulk API 2.0 ingest or query job using the
|
|
1707
|
+
* provided attributes. Required fields differ by operation; we pass
|
|
1708
|
+
* attributes straight through to Salesforce after stripping any readonly
|
|
1709
|
+
* fields. For ingest, CSV data is uploaded in a separate request (PUT) —
|
|
1710
|
+
* callers must issue that themselves via UploadBulkData once the job ID
|
|
1711
|
+
* is known.
|
|
1712
|
+
*/
|
|
1713
|
+
async CreateBulkJob(auth, family, attrs) {
|
|
1714
|
+
const segment = family === 'bulk_ingest' ? 'ingest' : 'query';
|
|
1715
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/jobs/${segment}`;
|
|
1716
|
+
const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
|
|
1717
|
+
const body = this.StripReadOnlyFields(attrs);
|
|
1718
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
1719
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
1720
|
+
const created = response.Body;
|
|
1721
|
+
return { Success: true, ExternalID: created.id ?? '', StatusCode: response.Status };
|
|
1722
|
+
}
|
|
1723
|
+
return this.BuildCRUDError(response, 'CreateBulkJob', family);
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* Aborts a running Bulk API 2.0 job by PATCHing state=Aborted.
|
|
1727
|
+
*/
|
|
1728
|
+
async AbortBulkJob(auth, family, jobId) {
|
|
1729
|
+
const segment = family === 'bulk_ingest' ? 'ingest' : 'query';
|
|
1730
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/jobs/${segment}/${jobId}`;
|
|
1731
|
+
const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
|
|
1732
|
+
const response = await this.MakeHTTPRequest(auth, url, 'PATCH', headers, { state: 'Aborted' });
|
|
1733
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
1734
|
+
return { Success: true, ExternalID: jobId, StatusCode: response.Status };
|
|
1735
|
+
}
|
|
1736
|
+
return this.BuildCRUDError(response, 'AbortBulkJob', family);
|
|
1737
|
+
}
|
|
1738
|
+
async GetBulkJob(auth, family, jobId) {
|
|
1739
|
+
const segment = family === 'bulk_ingest' ? 'ingest' : 'query';
|
|
1740
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/jobs/${segment}/${jobId}`;
|
|
1741
|
+
const headers = this.BuildHeaders(auth);
|
|
1742
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1743
|
+
if (response.Status === 404)
|
|
1744
|
+
return null;
|
|
1745
|
+
this.ValidateResponse(response, url);
|
|
1746
|
+
return {
|
|
1747
|
+
ExternalID: jobId,
|
|
1748
|
+
ObjectType: family === 'bulk_ingest' ? 'BulkIngestJob' : 'BulkQueryJob',
|
|
1749
|
+
Fields: response.Body,
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
// ─── Composite Requests ──────────────────────────────────────────
|
|
1753
|
+
/**
|
|
1754
|
+
* Executes a composite request — up to 25 sub-requests in one HTTP call.
|
|
1755
|
+
* The `attrs` payload is expected to be the full composite body or at
|
|
1756
|
+
* least a `compositeRequest` array. Returns the overall composite
|
|
1757
|
+
* response as the created record's ExternalID (Salesforce returns
|
|
1758
|
+
* per-sub-request results; callers should inspect Fields for details).
|
|
1759
|
+
*/
|
|
1760
|
+
async ExecuteCompositeRequest(auth, attrs) {
|
|
1761
|
+
const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/composite`;
|
|
1762
|
+
const headers = { ...this.BuildHeaders(auth), 'Content-Type': 'application/json' };
|
|
1763
|
+
// Either pass the whole payload through or wrap a bare array
|
|
1764
|
+
const body = Array.isArray(attrs.compositeRequest)
|
|
1765
|
+
? attrs
|
|
1766
|
+
: { allOrNone: true, compositeRequest: [attrs] };
|
|
1767
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
1768
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
1769
|
+
return { Success: true, ExternalID: 'composite', StatusCode: response.Status };
|
|
1770
|
+
}
|
|
1771
|
+
return this.BuildCRUDError(response, 'ExecuteCompositeRequest', 'CompositeRequest');
|
|
1772
|
+
}
|
|
1172
1773
|
};
|
|
1173
|
-
SalesforceConnector = __decorate([
|
|
1774
|
+
SalesforceConnector = SalesforceConnector_1 = __decorate([
|
|
1174
1775
|
RegisterClass(BaseIntegrationConnector, 'SalesforceConnector')
|
|
1175
1776
|
], SalesforceConnector);
|
|
1176
1777
|
export { SalesforceConnector };
|