@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.
@@ -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 all queryable objects including custom objects (__c).
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
- return sobjects
250
- .filter(obj => obj.queryable)
251
- .map(obj => ({
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 objects = await this.DiscoverObjects(companyIntegration, contextUser);
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
- for (const obj of objects) {
282
- try {
283
- const sourceObj = await this.BuildSourceObjectFromDescribe(companyIntegration, contextUser, obj.Name);
284
- result.Objects.push(sourceObj);
285
- }
286
- catch (err) {
287
- const msg = err instanceof Error ? err.message : String(err);
288
- console.warn(`[Salesforce] Skipping "${obj.Name}" during introspection: ${msg}`);
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
- // If we have a queryLocator from a previous call, use queryMore
303
- if (this.queryLocator && ctx.CurrentCursor) {
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
- // Build and execute SOQL query
307
- const fields = await this.GetQueryableFieldNames(auth, ctx.ObjectName);
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 = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${ctx.ObjectName}/`;
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 = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${ctx.ObjectName}/${ctx.ExternalID}`;
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 = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${ctx.ObjectName}/${ctx.ExternalID}`;
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 = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${ctx.ObjectName}/${ctx.ExternalID}`;
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 fields = await this.GetQueryableFieldNames(auth, ctx.ObjectName);
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 = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/query?q=${encodeURIComponent(soql)}`;
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
- const dedupedFields = [...new Set([...REQUIRED_SOQL_FIELDS, ...fields])];
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
- soql += ` WHERE SystemModstamp > ${this.FormatSOQLDateTime(watermarkValue)}`;
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
- soql += ' ORDER BY SystemModstamp ASC';
557
- soql += ` LIMIT ${batchSize}`;
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
- // Use queryAll to include deleted records (IsDeleted=true)
565
- const url = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/queryAll?q=${encodeURIComponent(soql)}`;
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 records = (body.records ?? []).map(r => {
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(body.records ?? []);
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
- return maxTimestamp;
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 = `${auth.InstanceUrl}/services/data/v${auth.ApiVersion}/sobjects/${objectName}/describe`;
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
- return jwt.sign(payload, config.PrivateKey, { algorithm: 'RS256' });
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 };