@memberjunction/integration-engine 5.27.0 → 5.28.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.
@@ -1,5 +1,5 @@
1
1
  import { CompositeKey, Metadata, RunView } from '@memberjunction/core';
2
- import { BaseSingleton } from '@memberjunction/global';
2
+ import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
3
3
  import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
4
4
  import { ClassifyError } from './types.js';
5
5
  import { ConnectorFactory } from './ConnectorFactory.js';
@@ -54,6 +54,79 @@ export class IntegrationEngine extends BaseSingleton {
54
54
  static GetAllSyncProgress() {
55
55
  return new Map(IntegrationEngine._syncProgress);
56
56
  }
57
+ /**
58
+ * Resumes any syncs that were orphaned by a process restart.
59
+ * Finds all CompanyIntegrationRun records with Status='In Progress',
60
+ * determines which entity maps already completed (have run details),
61
+ * and resumes from the remaining entity maps using existing watermarks.
62
+ *
63
+ * Call this once during MJAPI startup after metadata is loaded.
64
+ */
65
+ async ResumeOrphanedSyncs(contextUser) {
66
+ await IntegrationEngineBase.Instance.Config(false, contextUser);
67
+ const rv = new RunView();
68
+ const orphanedRuns = await rv.RunView({
69
+ EntityName: 'MJ: Company Integration Runs',
70
+ ExtraFilter: `Status='In Progress'`,
71
+ ResultType: 'entity_object',
72
+ }, contextUser);
73
+ if (!orphanedRuns.Success || orphanedRuns.Results.length === 0) {
74
+ console.log('[IntegrationEngine] No orphaned syncs to resume');
75
+ return;
76
+ }
77
+ console.log(`[IntegrationEngine] Found ${orphanedRuns.Results.length} orphaned sync(s) to resume`);
78
+ for (const run of orphanedRuns.Results) {
79
+ const companyIntegrationID = run.CompanyIntegrationID;
80
+ const runID = run.ID;
81
+ try {
82
+ // Find which entity maps already completed in this run
83
+ const detailsResult = await rv.RunView({
84
+ EntityName: 'MJ: Company Integration Run Details',
85
+ ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
86
+ Fields: ['EntityID'],
87
+ ResultType: 'simple',
88
+ }, contextUser);
89
+ const completedEntityIDs = new Set();
90
+ if (detailsResult.Success) {
91
+ for (const d of detailsResult.Results) {
92
+ completedEntityIDs.add(d.EntityID.toLowerCase());
93
+ }
94
+ }
95
+ console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
96
+ `for ${companyIntegrationID.substring(0, 8)}... ` +
97
+ `(${completedEntityIDs.size} entity maps already completed)`);
98
+ // Load config and filter to only remaining entity maps
99
+ const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser);
100
+ const remainingMaps = config.entityMaps.filter(em => !completedEntityIDs.has((em.EntityID).toLowerCase()));
101
+ if (remainingMaps.length === 0) {
102
+ console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
103
+ run.EndedAt = new Date();
104
+ run.Status = 'Success';
105
+ await run.Save();
106
+ continue;
107
+ }
108
+ console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
109
+ // Replace entityMaps with only the remaining ones
110
+ config.entityMaps = remainingMaps;
111
+ // Execute remaining maps using the existing run record
112
+ const result = await this.ExecuteEntityMaps(config, run, contextUser);
113
+ result.RunID = runID;
114
+ await this.FinalizeRun(run, result, contextUser);
115
+ console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
116
+ `${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
117
+ `${result.RecordsErrored} errored`);
118
+ }
119
+ catch (err) {
120
+ const errMsg = err instanceof Error ? err.message : String(err);
121
+ console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
122
+ // Mark as failed so it doesn't get picked up again
123
+ run.EndedAt = new Date();
124
+ run.Status = 'Failed';
125
+ run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
126
+ await run.Save();
127
+ }
128
+ }
129
+ }
57
130
  /**
58
131
  * Executes a full sync run for a company integration.
59
132
  * If a sync is already running for the same integration, waits for it to finish
@@ -118,12 +191,14 @@ export class IntegrationEngine extends BaseSingleton {
118
191
  const run = await this.CreateRunRecord(config.companyIntegration, triggerType, contextUser, options?.ScheduledJobRunID);
119
192
  try {
120
193
  const result = await this.ExecuteEntityMaps(config, run, contextUser, onProgress, abortSignal);
121
- result.RunID = run.Get('ID');
194
+ result.RunID = run.ID;
122
195
  result.Duration = Date.now() - startTime;
123
196
  if (result.RecordsErrored > 0) {
124
197
  result.ErrorMessage = `Sync completed with ${result.RecordsErrored} error(s)`;
125
198
  }
126
199
  await this.FinalizeRun(run, result, contextUser, onNotification);
200
+ const summary = this.buildSyncResultBody(config.companyIntegration.Integration, result);
201
+ console.log(`[IntegrationEngine] Sync complete:\n${summary}`);
127
202
  return result;
128
203
  }
129
204
  catch (err) {
@@ -160,7 +235,7 @@ export class IntegrationEngine extends BaseSingleton {
160
235
  throw new Error(`CompanyIntegration not found: ${companyIntegrationID}`);
161
236
  }
162
237
  const integration = integrationsResult.Results
163
- .find(i => i.Get('ID') === companyIntegration.IntegrationID);
238
+ .find(i => UUIDsEqual(i.ID, companyIntegration.IntegrationID));
164
239
  if (!integration) {
165
240
  throw new Error(`Integration not found for CompanyIntegration: ${companyIntegrationID}`);
166
241
  }
@@ -169,7 +244,7 @@ export class IntegrationEngine extends BaseSingleton {
169
244
  // Filter to specific entity maps if requested
170
245
  if (options?.EntityMapIDs && options.EntityMapIDs.length > 0) {
171
246
  const requestedIDs = new Set(options.EntityMapIDs.map(id => id.toLowerCase()));
172
- entityMaps = entityMaps.filter(m => requestedIDs.has(m.Get('ID').toLowerCase()));
247
+ entityMaps = entityMaps.filter(m => requestedIDs.has(m.ID.toLowerCase()));
173
248
  }
174
249
  return {
175
250
  companyIntegration,
@@ -177,6 +252,7 @@ export class IntegrationEngine extends BaseSingleton {
177
252
  integration,
178
253
  connector,
179
254
  fullSync: options?.FullSync ?? false,
255
+ syncDirection: options?.SyncDirection,
180
256
  };
181
257
  }
182
258
  /**
@@ -186,7 +262,7 @@ export class IntegrationEngine extends BaseSingleton {
186
262
  const md = new Metadata();
187
263
  const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
188
264
  run.NewRecord();
189
- run.CompanyIntegrationID = companyIntegration.Get('ID');
265
+ run.CompanyIntegrationID = companyIntegration.ID;
190
266
  run.RunByUserID = contextUser.ID;
191
267
  run.StartedAt = new Date();
192
268
  run.Status = 'In Progress';
@@ -235,7 +311,7 @@ export class IntegrationEngine extends BaseSingleton {
235
311
  aggregate.EntityMapResults.push(this.buildEntityMapResult(entityMap, mapResult, Date.now() - mapStartTime));
236
312
  }
237
313
  catch (err) {
238
- const objName = entityMap.ExternalObjectName ?? String(entityMap.Get('ID'));
314
+ const objName = entityMap.ExternalObjectName ?? entityMap.ID;
239
315
  const errMsg = err instanceof Error ? err.message : String(err);
240
316
  console.error(`[IntegrationEngine] Entity map '${objName}' failed: ${errMsg}`);
241
317
  aggregate.RecordsErrored++;
@@ -248,7 +324,7 @@ export class IntegrationEngine extends BaseSingleton {
248
324
  ExternalRecord: { ExternalID: objName, ObjectType: objName, Fields: {} },
249
325
  });
250
326
  aggregate.EntityMapResults.push({
251
- EntityMapID: entityMap.Get('ID'),
327
+ EntityMapID: entityMap.ID,
252
328
  ExternalObjectName: objName,
253
329
  EntityName: entityMap.Entity ?? '',
254
330
  Success: false,
@@ -265,18 +341,38 @@ export class IntegrationEngine extends BaseSingleton {
265
341
  return aggregate;
266
342
  }
267
343
  /**
268
- * Processes a single entity map: fetch → map → match → validate → apply.
344
+ * Processes a single entity map based on SyncDirection:
345
+ * - Pull: fetch from external → map → match → apply to MJ
346
+ * - Push: detect MJ changes → map → push to external
347
+ * - Bidirectional: pull first, then push
269
348
  */
270
349
  async ProcessSingleEntityMap(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal) {
271
- const entityMapID = entityMap.Get('ID');
350
+ const direction = config.syncDirection ?? entityMap.SyncDirection ?? 'Pull';
351
+ if (direction === 'Pull') {
352
+ return this.ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
353
+ }
354
+ if (direction === 'Push') {
355
+ return this.ProcessPushSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
356
+ }
357
+ // Bidirectional: pull first, then push
358
+ const pullResult = await this.ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
359
+ const pushResult = await this.ProcessPushSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
360
+ this.MergeResult(pullResult, pushResult);
361
+ return pullResult;
362
+ }
363
+ /**
364
+ * Pull sync: fetch from external → map → match → validate → apply to MJ.
365
+ */
366
+ async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal) {
367
+ const entityMapID = entityMap.ID;
272
368
  const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
273
- const watermark = await this.watermarkService.Load(entityMapID, contextUser);
369
+ const watermark = await this.watermarkService.Load(entityMapID, contextUser, 'Pull');
274
370
  // A6: Validate watermark before using it — skip entirely when FullSync requested
275
371
  let initialWatermark = config.fullSync ? null : (watermark?.WatermarkValue ?? null);
276
372
  if (initialWatermark && watermark) {
277
373
  const watermarkType = (watermark.WatermarkType ?? 'Timestamp');
278
374
  if (!this.watermarkService.ValidateWatermark(initialWatermark, watermarkType)) {
279
- console.warn(`[IntegrationEngine] Invalid watermark '${initialWatermark}' for EntityMap ${entityMap.Get('ID')}, resetting to full fetch`);
375
+ console.warn(`[IntegrationEngine] Invalid watermark '${initialWatermark}' for EntityMap ${entityMap.ID}, resetting to full fetch`);
280
376
  initialWatermark = null;
281
377
  }
282
378
  }
@@ -298,16 +394,20 @@ export class IntegrationEngine extends BaseSingleton {
298
394
  let currentCursor;
299
395
  let batchCount = 0;
300
396
  let previousBatchFingerprint;
397
+ let fetchCompletedCleanly = true; // flipped to false if fetch aborted or errored mid-way
301
398
  const MAX_BATCHES_PER_MAP = 5000;
399
+ const fetchedExternalIDs = new Set(); // Track all IDs seen during this pull for orphan detection
302
400
  while (hasMore) {
303
401
  if (abortSignal?.aborted) {
304
402
  console.log(`[IntegrationEngine] Sync cancelled for ${entityMap.ExternalObjectName} after ${recordsInMap} records — saving watermark`);
403
+ fetchCompletedCleanly = false;
305
404
  break;
306
405
  }
307
406
  batchCount++;
308
407
  if (batchCount > MAX_BATCHES_PER_MAP) {
309
408
  console.error(`[IntegrationEngine] Safety limit reached: ${MAX_BATCHES_PER_MAP} batches for ` +
310
409
  `${entityMap.ExternalObjectName}. Stopping to prevent infinite loop.`);
410
+ fetchCompletedCleanly = false;
311
411
  break;
312
412
  }
313
413
  const ctx = {
@@ -320,23 +420,35 @@ export class IntegrationEngine extends BaseSingleton {
320
420
  CurrentOffset: currentOffset,
321
421
  CurrentCursor: currentCursor,
322
422
  };
323
- const batch = await config.connector.FetchChanges(ctx);
324
- // Enforce MaxBatchSize — connectors are asked to respect it but may overshoot
423
+ let batch;
424
+ try {
425
+ batch = await config.connector.FetchChanges(ctx);
426
+ }
427
+ catch (fetchErr) {
428
+ const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
429
+ console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
430
+ fetchCompletedCleanly = false;
431
+ break;
432
+ }
433
+ // If the connector returned more records than MaxBatchSize, log it but never truncate —
434
+ // all records are written, just in sub-batches to keep DB transactions manageable.
325
435
  if (batch.Records.length > this.MaxBatchSize) {
326
- console.warn(`[IntegrationEngine] ${entityMap.ExternalObjectName}: connector returned ` +
327
- `${batch.Records.length} records, exceeding MaxBatchSize of ${this.MaxBatchSize}. ` +
328
- `Truncating to ${this.MaxBatchSize}.`);
329
- batch.Records = batch.Records.slice(0, this.MaxBatchSize);
436
+ console.log(`[IntegrationEngine] ${entityMap.ExternalObjectName}: connector returned ` +
437
+ `${batch.Records.length} records (> MaxBatchSize ${this.MaxBatchSize}), writing in chunks.`);
330
438
  }
331
439
  if (batch.Records.length > 0) {
332
440
  const fingerprint = batch.Records.map(r => r.ExternalID).join(',');
333
441
  if (fingerprint === previousBatchFingerprint) {
334
442
  console.warn(`[IntegrationEngine] Duplicate batch detected for ${entityMap.ExternalObjectName} — ` +
335
443
  `connector returned same records twice. Stopping to prevent infinite loop.`);
444
+ fetchCompletedCleanly = false;
336
445
  break;
337
446
  }
338
447
  previousBatchFingerprint = fingerprint;
339
448
  }
449
+ for (const rec of batch.Records) {
450
+ fetchedExternalIDs.add(rec.ExternalID);
451
+ }
340
452
  const mapped = this.fieldMappingEngine.Apply(batch.Records, fieldMaps, entityMap.Entity);
341
453
  const resolved = await this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser);
342
454
  const beforeApply = result.RecordsCreated + result.RecordsUpdated + result.RecordsSkipped + result.RecordsErrored;
@@ -368,12 +480,332 @@ export class IntegrationEngine extends BaseSingleton {
368
480
  hasMore = batch.HasMore === true; // Explicit boolean check — prevents truthy undefined from looping
369
481
  }
370
482
  if (currentWatermark) {
371
- await this.watermarkService.Update(entityMapID, currentWatermark, contextUser);
372
- result.WatermarkAfter = currentWatermark;
483
+ // After a clean full sync, advance the watermark to now rather than the max
484
+ // modification date found in the last batch. HubSpot's list API returns records
485
+ // in creation order, so the last batch can contain records with very old
486
+ // hs_lastmodifieddate values. A stale watermark causes the next incremental to
487
+ // re-fetch the entire change history from that old date.
488
+ const finalWatermark = (config.fullSync && fetchCompletedCleanly)
489
+ ? new Date().toISOString()
490
+ : currentWatermark;
491
+ await this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull');
492
+ result.WatermarkAfter = finalWatermark;
493
+ }
494
+ // Orphan detection: on full sync, delete MJ records whose external counterpart no longer exists.
495
+ // Only runs if the fetch completed cleanly — a partial fetch (aborted, errored, safety-limited)
496
+ // means fetchedExternalIDs is incomplete; running deletion on it would be catastrophic.
497
+ if (config.fullSync && fetchedExternalIDs.size > 0 && fetchCompletedCleanly) {
498
+ await this.DeleteOrphanedRecords(config.companyIntegration, entityMap, fetchedExternalIDs, result, contextUser);
373
499
  }
374
500
  await this.CreateRunDetail(run, entityMap, result, contextUser);
375
501
  return result;
376
502
  }
503
+ /**
504
+ * Push sync: detect local MJ record changes → reverse-map fields → push to external system.
505
+ *
506
+ * Uses MJ's Record Changes entity to find records modified since the last push watermark.
507
+ * Filters out changes made by the integration engine itself to prevent echo loops.
508
+ * For each changed record, calls the connector's CreateRecord/UpdateRecord/DeleteRecord.
509
+ */
510
+ async ProcessPushSync(config, entityMap, run, contextUser, _entityMapIndex, _totalEntityMaps, _onProgress, _abortSignal) {
511
+ const entityMapID = entityMap.ID;
512
+ const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
513
+ const pushWatermark = await this.watermarkService.Load(entityMapID, contextUser, 'Push');
514
+ const lastPushAt = pushWatermark?.WatermarkValue ?? null;
515
+ // Check connector write capability
516
+ if (!config.connector.SupportsCreate && !config.connector.SupportsUpdate) {
517
+ console.log(`[IntegrationEngine] Push skipped for ${entityMap.ExternalObjectName}: connector does not support writes`);
518
+ return this.EmptyResult();
519
+ }
520
+ // Full push: load ALL records from the MJ entity. Incremental push: only changed records.
521
+ const changedRecords = config.fullSync
522
+ ? await this.LoadAllMJRecords(entityMap, config.companyIntegration, contextUser)
523
+ : await this.LoadChangedMJRecords(entityMap, lastPushAt, contextUser);
524
+ if (changedRecords.length === 0) {
525
+ console.log(`[IntegrationEngine] Push: no changes for ${entityMap.ExternalObjectName} since ${lastPushAt ?? 'beginning'}`);
526
+ await this.CreateRunDetail(run, entityMap, this.EmptyResult(), contextUser);
527
+ return this.EmptyResult();
528
+ }
529
+ console.log(`[IntegrationEngine] Push: ${changedRecords.length} changed records for ${entityMap.ExternalObjectName}`);
530
+ const result = {
531
+ Success: true, RecordsProcessed: 0, RecordsCreated: 0, RecordsUpdated: 0,
532
+ RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0, Errors: [],
533
+ };
534
+ // Get push-direction field maps (DestToSource or Both)
535
+ const pushFieldMaps = fieldMaps.filter(fm => fm.Direction === 'DestToSource' || fm.Direction === 'Both');
536
+ // If no field maps are configured for push, skip this entity entirely
537
+ if (pushFieldMaps.length === 0) {
538
+ console.log(`[IntegrationEngine] Push skipped for ${entityMap.ExternalObjectName}: no field maps with push direction`);
539
+ return this.EmptyResult();
540
+ }
541
+ let latestChangeAt = null;
542
+ for (const change of changedRecords) {
543
+ result.RecordsProcessed++;
544
+ try {
545
+ await this.PushSingleRecord(change, config, entityMap, pushFieldMaps, result, contextUser);
546
+ if (change.ChangedAt && (!latestChangeAt || change.ChangedAt > latestChangeAt)) {
547
+ latestChangeAt = change.ChangedAt;
548
+ }
549
+ }
550
+ catch (err) {
551
+ const errMsg = err instanceof Error ? err.message : String(err);
552
+ result.RecordsErrored++;
553
+ result.Errors.push({
554
+ ExternalID: change.RecordID,
555
+ ChangeType: change.Type,
556
+ ErrorMessage: errMsg,
557
+ ErrorCode: 'CONNECTOR_ERROR',
558
+ Severity: 'Critical',
559
+ ExternalRecord: { ExternalID: change.RecordID, ObjectType: entityMap.ExternalObjectName, Fields: {} },
560
+ });
561
+ }
562
+ }
563
+ // Update push watermark
564
+ if (latestChangeAt) {
565
+ await this.watermarkService.Update(entityMapID, latestChangeAt, contextUser, 'Push');
566
+ }
567
+ console.log(`[IntegrationEngine] Push complete for ${entityMap.ExternalObjectName}: ` +
568
+ `${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
569
+ `${result.RecordsDeleted} deleted, ${result.RecordsErrored} errored`);
570
+ await this.CreateRunDetail(run, entityMap, result, contextUser);
571
+ return result;
572
+ }
573
+ /** Loads MJ records changed since the last push watermark, excluding integration-engine changes. */
574
+ async LoadChangedMJRecords(entityMap, lastPushAt, contextUser) {
575
+ const rv = new RunView();
576
+ // Query Record Changes for this entity since the last push
577
+ let filter = `EntityID='${entityMap.EntityID}'`;
578
+ if (lastPushAt) {
579
+ filter += ` AND ChangedAt > '${lastPushAt}'`;
580
+ }
581
+ // Exclude changes made by the integration engine (System user or specific source)
582
+ filter += ` AND UserID != 'ECAFCCEC-6A37-EF11-86D4-000D3A4E707E'`; // System user
583
+ filter += ` AND Type IN ('Create', 'Update', 'Delete')`;
584
+ const result = await rv.RunView({
585
+ EntityName: 'Record Changes',
586
+ ExtraFilter: filter,
587
+ OrderBy: 'ChangedAt ASC',
588
+ Fields: ['RecordID', 'Type', 'ChangedAt'],
589
+ ResultType: 'simple',
590
+ }, contextUser);
591
+ if (!result.Success)
592
+ return [];
593
+ // Dedupe: keep only the latest change per RecordID.
594
+ // RecordChange.RecordID format is "fieldName|value" or "f1|v1||f2|v2" for composites.
595
+ // Normalize to just the value(s) so it matches CompanyIntegrationRecordMap.EntityRecordID.
596
+ const latestByRecord = new Map();
597
+ for (const r of result.Results) {
598
+ const normalizedID = this.NormalizeRecordChangeID(r.RecordID);
599
+ latestByRecord.set(normalizedID, { ...r, RecordID: normalizedID, Fields: {} });
600
+ }
601
+ // Load current field values for each changed record
602
+ const md = new Metadata();
603
+ const entityInfo = md.Entities.find(e => e.Name === entityMap.Entity);
604
+ const pkFieldName = entityInfo?.FirstPrimaryKey?.Name ?? 'ID';
605
+ for (const [recordID, change] of latestByRecord) {
606
+ if (change.Type === 'Delete')
607
+ continue; // No fields to load for deletes
608
+ try {
609
+ const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
610
+ const loaded = await entity.InnerLoad(new CompositeKey([{ FieldName: pkFieldName, Value: recordID }]));
611
+ if (loaded) {
612
+ change.Fields = entity.GetAll();
613
+ }
614
+ }
615
+ catch {
616
+ // Record may have been deleted between change detection and load
617
+ }
618
+ }
619
+ return [...latestByRecord.values()];
620
+ }
621
+ /**
622
+ * Loads ALL records from an MJ entity for full push sync.
623
+ * For each record, checks the record map to determine if it's a Create (no external ID)
624
+ * or Update (has external ID) in the external system.
625
+ */
626
+ async LoadAllMJRecords(entityMap, companyIntegration, contextUser) {
627
+ const rv = new RunView();
628
+ // Load all records from the MJ entity
629
+ const allResult = await rv.RunView({
630
+ EntityName: entityMap.Entity,
631
+ ResultType: 'simple',
632
+ }, contextUser);
633
+ if (!allResult.Success || allResult.Results.length === 0)
634
+ return [];
635
+ // Load existing record maps to know which records already exist externally
636
+ const mapResult = await rv.RunView({
637
+ EntityName: 'MJ: Company Integration Record Maps',
638
+ ExtraFilter: `CompanyIntegrationID='${companyIntegration.ID}' AND EntityID='${entityMap.EntityID}'`,
639
+ Fields: ['EntityRecordID', 'ExternalSystemRecordID'],
640
+ ResultType: 'simple',
641
+ }, contextUser);
642
+ const existingMaps = new Map();
643
+ if (mapResult.Success) {
644
+ for (const m of mapResult.Results) {
645
+ existingMaps.set(m.EntityRecordID, m.ExternalSystemRecordID);
646
+ }
647
+ }
648
+ const md = new Metadata();
649
+ const entityInfo = md.Entities.find(e => e.Name === entityMap.Entity);
650
+ const pkFieldName = entityInfo?.FirstPrimaryKey?.Name ?? 'ID';
651
+ const now = new Date().toISOString();
652
+ return allResult.Results.map(record => {
653
+ const recordID = String(record[pkFieldName] ?? '');
654
+ return {
655
+ RecordID: recordID,
656
+ Type: existingMaps.has(recordID) ? 'Update' : 'Create',
657
+ ChangedAt: now,
658
+ Fields: record,
659
+ };
660
+ });
661
+ }
662
+ /** Pushes a single changed MJ record to the external system. */
663
+ async PushSingleRecord(change, config, entityMap, pushFieldMaps, result, contextUser) {
664
+ // Reverse-map MJ fields to external fields
665
+ const externalAttributes = {};
666
+ for (const fm of pushFieldMaps) {
667
+ const mjValue = change.Fields[fm.DestinationFieldName];
668
+ if (mjValue !== undefined && mjValue !== null) {
669
+ externalAttributes[fm.SourceFieldName] = mjValue;
670
+ }
671
+ }
672
+ // Nothing to push — all field values were null/undefined. Skip instead of erroring.
673
+ if (change.Type !== 'Delete' && Object.keys(externalAttributes).length === 0) {
674
+ result.RecordsSkipped++;
675
+ return;
676
+ }
677
+ // Look up the ExternalID from the record map
678
+ const rv = new RunView();
679
+ const mapResult = await rv.RunView({
680
+ EntityName: 'MJ: Company Integration Record Maps',
681
+ ExtraFilter: `EntityRecordID='${change.RecordID}' AND CompanyIntegrationID='${config.companyIntegration.ID}'`,
682
+ Fields: ['ExternalSystemRecordID'],
683
+ MaxRows: 1,
684
+ ResultType: 'simple',
685
+ }, contextUser);
686
+ const externalID = mapResult.Success && mapResult.Results.length > 0
687
+ ? mapResult.Results[0].ExternalSystemRecordID
688
+ : null;
689
+ const crudBase = {
690
+ CompanyIntegration: config.companyIntegration,
691
+ ObjectName: entityMap.ExternalObjectName,
692
+ ContextUser: contextUser,
693
+ };
694
+ if (change.Type === 'Delete' && externalID && config.connector.SupportsDelete) {
695
+ const delResult = await config.connector.DeleteRecord({ ...crudBase, ExternalID: externalID });
696
+ if (!delResult.Success) {
697
+ if (delResult.StatusCode === 403) {
698
+ console.warn(`[IntegrationEngine] Skipping delete — ${delResult.ErrorMessage}`);
699
+ result.RecordsSkipped++;
700
+ return;
701
+ }
702
+ throw new Error(delResult.ErrorMessage ?? 'Delete failed');
703
+ }
704
+ result.RecordsDeleted++;
705
+ }
706
+ else if (externalID) {
707
+ // Update existing external record
708
+ const updResult = await config.connector.UpdateRecord({
709
+ ...crudBase, ExternalID: externalID, Attributes: externalAttributes,
710
+ });
711
+ if (!updResult.Success) {
712
+ if (updResult.StatusCode === 403) {
713
+ console.warn(`[IntegrationEngine] Skipping update — ${updResult.ErrorMessage}`);
714
+ result.RecordsSkipped++;
715
+ return;
716
+ }
717
+ throw new Error(updResult.ErrorMessage ?? 'Update failed');
718
+ }
719
+ result.RecordsUpdated++;
720
+ }
721
+ else if (config.connector.SupportsCreate) {
722
+ // Create new external record
723
+ const createResult = await config.connector.CreateRecord({
724
+ ...crudBase, Attributes: externalAttributes,
725
+ });
726
+ if (!createResult.Success) {
727
+ if (createResult.StatusCode === 403) {
728
+ console.warn(`[IntegrationEngine] Skipping create — ${createResult.ErrorMessage}`);
729
+ result.RecordsSkipped++;
730
+ return;
731
+ }
732
+ throw new Error(createResult.ErrorMessage ?? 'Create failed');
733
+ }
734
+ // Persist the new external ID so future syncs update instead of re-creating
735
+ if (createResult.ExternalID) {
736
+ await this.SaveRecordMap(config.companyIntegration.ID, createResult.ExternalID, entityMap.EntityID, change.RecordID, contextUser);
737
+ }
738
+ result.RecordsCreated++;
739
+ }
740
+ else {
741
+ result.RecordsSkipped++;
742
+ }
743
+ }
744
+ /**
745
+ * Full-sync orphan detection: finds MJ records that have a record map entry
746
+ * but were NOT returned by the external system during this full pull.
747
+ * These records were deleted externally and should be removed from MJ.
748
+ */
749
+ async DeleteOrphanedRecords(companyIntegration, entityMap, fetchedExternalIDs, result, contextUser) {
750
+ const rv = new RunView();
751
+ const mapResult = await rv.RunView({
752
+ EntityName: 'MJ: Company Integration Record Maps',
753
+ ExtraFilter: `CompanyIntegrationID='${companyIntegration.ID}' ` +
754
+ `AND EntityID='${entityMap.EntityID}'`,
755
+ Fields: ['EntityRecordID', 'ExternalSystemRecordID'],
756
+ ResultType: 'simple',
757
+ }, contextUser);
758
+ if (!mapResult.Success)
759
+ return;
760
+ const orphans = mapResult.Results.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
761
+ if (orphans.length === 0)
762
+ return;
763
+ console.log(`[IntegrationEngine] Orphan detection for ${entityMap.ExternalObjectName}: ${orphans.length} records in MJ not found in external system`);
764
+ const md = new Metadata();
765
+ const entityInfo = md.Entities.find(e => e.Name === entityMap.Entity);
766
+ const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
767
+ for (const orphan of orphans) {
768
+ try {
769
+ const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
770
+ const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(orphan.EntityRecordID, pkFields));
771
+ if (!loaded) {
772
+ console.log(`[IntegrationEngine] Orphan ${orphan.EntityRecordID} already deleted from MJ`);
773
+ continue;
774
+ }
775
+ const deleted = await entity.Delete();
776
+ if (deleted) {
777
+ result.RecordsDeleted++;
778
+ console.log(`[IntegrationEngine] Deleted orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
779
+ }
780
+ else {
781
+ const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
782
+ console.warn(`[IntegrationEngine] Orphan delete blocked for ${entityMap.Entity} ${orphan.EntityRecordID} — ${reason}`);
783
+ }
784
+ }
785
+ catch (err) {
786
+ const msg = err instanceof Error ? err.message : String(err);
787
+ console.warn(`[IntegrationEngine] Orphan delete failed for ${entityMap.Entity} ${orphan.EntityRecordID}: ${msg}`);
788
+ }
789
+ }
790
+ }
791
+ /**
792
+ * Strips field-name prefixes from a RecordChange.RecordID to get the raw entity record ID.
793
+ * "hs_object_id|465950833372" → "465950833372"
794
+ * "contact_id|123||deal_id|456" → "123|456"
795
+ */
796
+ NormalizeRecordChangeID(recordID) {
797
+ return recordID
798
+ .split('||')
799
+ .map(part => { const i = part.indexOf('|'); return i >= 0 ? part.substring(i + 1) : part; })
800
+ .join('|');
801
+ }
802
+ /** Returns an empty SyncResult. */
803
+ EmptyResult() {
804
+ return {
805
+ Success: true, RecordsProcessed: 0, RecordsCreated: 0, RecordsUpdated: 0,
806
+ RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0, Errors: [],
807
+ };
808
+ }
377
809
  /**
378
810
  * Emits a progress update via the callback.
379
811
  */
@@ -436,13 +868,16 @@ export class IntegrationEngine extends BaseSingleton {
436
868
  result.RecordsCreated++;
437
869
  break;
438
870
  case 'Update':
439
- await this.UpdateRecord(record, contextUser);
440
- result.RecordsUpdated++;
871
+ await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser);
441
872
  break;
442
- case 'Delete':
443
- await this.DeleteRecord(record, entityMap, contextUser);
444
- result.RecordsDeleted++;
873
+ case 'Delete': {
874
+ const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
875
+ if (didDelete)
876
+ result.RecordsDeleted++;
877
+ else
878
+ result.RecordsErrored++;
445
879
  break;
880
+ }
446
881
  case 'Skip':
447
882
  result.RecordsSkipped++;
448
883
  break;
@@ -463,15 +898,25 @@ export class IntegrationEngine extends BaseSingleton {
463
898
  if (!saved) {
464
899
  throw new Error(`Failed to create ${record.MJEntityName} record`);
465
900
  }
466
- // Use the external ID as the entity record identifier in the record map
467
- await this.SaveRecordMap(companyIntegration.Get('ID'), record.ExternalRecord.ExternalID, entityMap.EntityID, record.ExternalRecord.ExternalID, contextUser);
901
+ // Use the entity's actual PK (e.g. the UUID assigned by the DB) as the
902
+ // EntityRecordID in the record map, NOT the external ID. Storing the
903
+ // external ID as EntityRecordID caused UpdateRecord to fail to load the
904
+ // entity (UUID lookup with a HubSpot numeric ID) and fall back to
905
+ // CreateRecord, producing duplicates on every incremental sync.
906
+ const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
907
+ await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
468
908
  }
469
909
  /**
470
910
  * Updates an existing MJ record with pre-write validation.
911
+ * If the record cannot be loaded (e.g. it was deleted or never fully created),
912
+ * falls back to CreateRecord (upsert behavior).
471
913
  */
472
- async UpdateRecord(record, contextUser) {
914
+ async UpdateRecord(record, companyIntegration, entityMap, result, contextUser) {
473
915
  if (!record.MatchedMJRecordID) {
474
- throw new Error('Cannot update record without MatchedMJRecordID');
916
+ // No matched ID — treat as a new record
917
+ await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
918
+ result.RecordsCreated++;
919
+ return;
475
920
  }
476
921
  const md = new Metadata();
477
922
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
@@ -479,16 +924,28 @@ export class IntegrationEngine extends BaseSingleton {
479
924
  const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
480
925
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
481
926
  if (!loaded) {
482
- throw new Error(`Failed to load ${record.MJEntityName} record ${record.MatchedMJRecordID}`);
927
+ // Record doesn't exist in DB — fall back to INSERT (upsert)
928
+ await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
929
+ result.RecordsCreated++;
930
+ return;
483
931
  }
484
932
  this.SetEntityFields(entity, record.MappedFields);
485
933
  this.SetStandardIntegrationFields(entity, record);
934
+ // Skip unchanged records — if no field values actually changed after setting,
935
+ // don't write to DB. Uses MJ's built-in dirty tracking (zero custom comparison logic).
936
+ // Critical for connectors without server-side date filtering (e.g., YM) where every
937
+ // sync re-fetches all records. Without this, 50k+ records get re-written every run.
938
+ if (!entity.Dirty) {
939
+ result.RecordsSkipped++;
940
+ return;
941
+ }
486
942
  // A5: Pre-write validation
487
943
  this.validateEntity(entity, record.MJEntityName);
488
944
  const saved = await entity.Save();
489
945
  if (!saved) {
490
946
  throw new Error(`Failed to update ${record.MJEntityName} record ${record.MatchedMJRecordID}`);
491
947
  }
948
+ result.RecordsUpdated++;
492
949
  }
493
950
  /**
494
951
  * Runs Validate() on an entity if the method exists.
@@ -508,22 +965,26 @@ export class IntegrationEngine extends BaseSingleton {
508
965
  */
509
966
  async DeleteRecord(record, entityMap, contextUser) {
510
967
  if (!record.MatchedMJRecordID) {
511
- throw new Error('Cannot delete record without MatchedMJRecordID');
968
+ console.log(`[IntegrationEngine] Skipping delete for ${record.ExternalRecord.ObjectType} ExternalID=${record.ExternalRecord.ExternalID} — no MJ record map entry (record was never synced)`);
969
+ return false;
512
970
  }
513
971
  if (entityMap.DeleteBehavior === 'DoNothing')
514
- return;
972
+ return false;
515
973
  const md = new Metadata();
516
974
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
517
975
  const entityInfo = md.Entities.find(e => e.Name === record.MJEntityName);
518
976
  const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
519
977
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
520
978
  if (!loaded) {
521
- throw new Error(`Failed to load ${record.MJEntityName} for deletion: ${record.MatchedMJRecordID}`);
979
+ console.log(`[IntegrationEngine] Skipping delete for ${record.MJEntityName} ${record.MatchedMJRecordID} — record not found in MJ DB (may have been deleted already)`);
980
+ return false;
522
981
  }
523
982
  const deleted = await entity.Delete();
524
983
  if (!deleted) {
525
- throw new Error(`Failed to delete ${record.MJEntityName} record ${record.MatchedMJRecordID}`);
984
+ const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
985
+ console.warn(`[IntegrationEngine] Delete blocked for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
526
986
  }
987
+ return deleted;
527
988
  }
528
989
  /**
529
990
  * Builds a CompositeKey for a record lookup using the entity's PK field definitions.
@@ -589,7 +1050,7 @@ export class IntegrationEngine extends BaseSingleton {
589
1050
  const md = new Metadata();
590
1051
  const detail = await md.GetEntityObject('MJ: Company Integration Run Details', contextUser);
591
1052
  detail.NewRecord();
592
- detail.CompanyIntegrationRunID = run.Get('ID');
1053
+ detail.CompanyIntegrationRunID = run.ID;
593
1054
  detail.EntityID = entityMap.EntityID;
594
1055
  detail.RecordID = `Processed:${result.RecordsProcessed}`;
595
1056
  detail.Action = result.RecordsCreated > 0 ? 'INSERT' : 'UPDATE';
@@ -616,7 +1077,7 @@ export class IntegrationEngine extends BaseSingleton {
616
1077
  */
617
1078
  buildEntityMapResult(entityMap, mapResult, duration) {
618
1079
  return {
619
- EntityMapID: entityMap.Get('ID'),
1080
+ EntityMapID: entityMap.ID,
620
1081
  ExternalObjectName: entityMap.ExternalObjectName ?? '',
621
1082
  EntityName: entityMap.Entity ?? '',
622
1083
  Success: mapResult.RecordsErrored === 0,
@@ -684,7 +1145,7 @@ export class IntegrationEngine extends BaseSingleton {
684
1145
  Event: event,
685
1146
  Severity: severity,
686
1147
  CompanyIntegrationID: run.CompanyIntegrationID,
687
- RunID: run.Get('ID'),
1148
+ RunID: run.ID,
688
1149
  Subject: subject,
689
1150
  Body: body,
690
1151
  Result: result,
@@ -708,7 +1169,7 @@ export class IntegrationEngine extends BaseSingleton {
708
1169
  Event: 'SyncFailed',
709
1170
  Severity: 'Error',
710
1171
  CompanyIntegrationID: run.CompanyIntegrationID,
711
- RunID: run.Get('ID'),
1172
+ RunID: run.ID,
712
1173
  Subject: subject,
713
1174
  Body: body,
714
1175
  Result: result,