@memberjunction/integration-engine 6.1.0-edge.3 → 6.1.0-edge.4

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.
Files changed (71) hide show
  1. package/dist/BaseIntegrationConnector.d.ts +19 -2
  2. package/dist/BaseIntegrationConnector.d.ts.map +1 -1
  3. package/dist/BaseIntegrationConnector.js +112 -21
  4. package/dist/BaseIntegrationConnector.js.map +1 -1
  5. package/dist/BaseRESTIntegrationConnector.d.ts +14 -1
  6. package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
  7. package/dist/BaseRESTIntegrationConnector.js +40 -3
  8. package/dist/BaseRESTIntegrationConnector.js.map +1 -1
  9. package/dist/BatchIdentity.d.ts +47 -0
  10. package/dist/BatchIdentity.d.ts.map +1 -0
  11. package/dist/BatchIdentity.js +78 -0
  12. package/dist/BatchIdentity.js.map +1 -0
  13. package/dist/DiscoveryWatchdog.d.ts +103 -0
  14. package/dist/DiscoveryWatchdog.d.ts.map +1 -0
  15. package/dist/DiscoveryWatchdog.js +166 -0
  16. package/dist/DiscoveryWatchdog.js.map +1 -0
  17. package/dist/FieldMapValidation.d.ts +40 -0
  18. package/dist/FieldMapValidation.d.ts.map +1 -0
  19. package/dist/FieldMapValidation.js +58 -0
  20. package/dist/FieldMapValidation.js.map +1 -0
  21. package/dist/FieldMappingEngine.d.ts +1 -13
  22. package/dist/FieldMappingEngine.d.ts.map +1 -1
  23. package/dist/FieldMappingEngine.js +18 -3
  24. package/dist/FieldMappingEngine.js.map +1 -1
  25. package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
  26. package/dist/IntegrationConnectorCreationPipeline.js +13 -0
  27. package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
  28. package/dist/IntegrationEngine.d.ts +37 -0
  29. package/dist/IntegrationEngine.d.ts.map +1 -1
  30. package/dist/IntegrationEngine.js +481 -177
  31. package/dist/IntegrationEngine.js.map +1 -1
  32. package/dist/IntegrationSchemaSync.d.ts +8 -0
  33. package/dist/IntegrationSchemaSync.d.ts.map +1 -1
  34. package/dist/IntegrationSchemaSync.js +39 -2
  35. package/dist/IntegrationSchemaSync.js.map +1 -1
  36. package/dist/KeylessRecordGuard.d.ts +56 -0
  37. package/dist/KeylessRecordGuard.d.ts.map +1 -0
  38. package/dist/KeylessRecordGuard.js +80 -0
  39. package/dist/KeylessRecordGuard.js.map +1 -0
  40. package/dist/ResumeConcurrency.d.ts +46 -0
  41. package/dist/ResumeConcurrency.d.ts.map +1 -0
  42. package/dist/ResumeConcurrency.js +74 -0
  43. package/dist/ResumeConcurrency.js.map +1 -0
  44. package/dist/RetryAfter.d.ts +50 -0
  45. package/dist/RetryAfter.d.ts.map +1 -0
  46. package/dist/RetryAfter.js +127 -0
  47. package/dist/RetryAfter.js.map +1 -0
  48. package/dist/RetryRunner.d.ts +26 -2
  49. package/dist/RetryRunner.d.ts.map +1 -1
  50. package/dist/RetryRunner.js +11 -3
  51. package/dist/RetryRunner.js.map +1 -1
  52. package/dist/SyncDirectives.d.ts +66 -0
  53. package/dist/SyncDirectives.d.ts.map +1 -0
  54. package/dist/SyncDirectives.js +123 -0
  55. package/dist/SyncDirectives.js.map +1 -0
  56. package/dist/SyncLogger.d.ts +1 -1
  57. package/dist/SyncLogger.d.ts.map +1 -1
  58. package/dist/SyncLogger.js.map +1 -1
  59. package/dist/WatermarkService.d.ts +8 -0
  60. package/dist/WatermarkService.d.ts.map +1 -1
  61. package/dist/WatermarkService.js +18 -0
  62. package/dist/WatermarkService.js.map +1 -1
  63. package/dist/index.d.ts +9 -0
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +7 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/types.d.ts +20 -0
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js +66 -1
  70. package/dist/types.js.map +1 -1
  71. package/package.json +7 -7
@@ -5,6 +5,7 @@ import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
5
5
  import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
6
6
  import { ClassifyError, IsRetryableError } from './types.js';
7
7
  import { WithRetry } from './RetryRunner.js';
8
+ import { DecideKeylessRefusal, DescribeKeylessRefusal, MissingKeyFieldNames } from './KeylessRecordGuard.js';
8
9
  import { WithTimeout, OperationTimeoutError, DEFAULT_OPERATION_TIMEOUTS } from './BaseIntegrationConnector.js';
9
10
  import { ConnectorFactory } from './ConnectorFactory.js';
10
11
  import { FieldMappingEngine } from './FieldMappingEngine.js';
@@ -16,11 +17,15 @@ import { RecordMapBatch } from './RecordMapBatch.js';
16
17
  import { buildContentHashPrefetchFilter, quoteTextLiteral } from './prefetchFilter.js';
17
18
  import { serializeKeyValue } from './KeySerialization.js';
18
19
  import { CUSTOM_OVERFLOW_COLUMN, reconcileOverflowValue, foldCustomKeyStats } from './CustomOverflow.js';
20
+ import { ComputeExcludedSourceNames } from './SyncDirectives.js';
21
+ import { DescribeUnbindableFieldMaps, FindUnbindableFieldMaps } from './FieldMapValidation.js';
19
22
  import { partitionRecords, partitionRollupHash, diffPartitions, partitionKeyForIdentity } from './HashDiff.js';
20
23
  import { RateLimiter } from './RateLimiter.js';
21
24
  import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrency.js';
22
25
  import { mostRecentWinner } from './ConflictRecency.js';
23
26
  import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
27
+ import { CollapseDuplicateIdentities } from './BatchIdentity.js';
28
+ import { ResumeConcurrency, RunResumesBounded } from './ResumeConcurrency.js';
24
29
  /** Default batch size for fetching records from external systems */
25
30
  const DEFAULT_BATCH_SIZE = 200;
26
31
  /**
@@ -510,171 +515,208 @@ export class IntegrationEngine extends BaseSingleton {
510
515
  return;
511
516
  }
512
517
  console.log(`[IntegrationEngine] Found ${orphanedRuns.Results.length} orphaned sync(s) to resume`);
513
- for (const run of orphanedRuns.Results) {
514
- const companyIntegrationID = run.CompanyIntegrationID;
515
- const runID = run.ID;
516
- const lockKey = companyIntegrationID.toLowerCase();
517
- // C1: respect the SAME in-process concurrency lock RunSync uses. If a live sync for this
518
- // CompanyIntegration is already running (e.g. the scheduler fired during startup), skip the
519
- // resume — double-running one CI on the shared provider connection corrupts its singular
520
- // transaction state (exactly what runWriteExclusive guards against WITHIN a run). The
521
- // get→set pair below has no await between them, so check-and-reserve is atomic on the loop.
522
- if (IntegrationEngine.activeSyncs.get(lockKey)) {
523
- console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} a live sync for ${lockKey} is already running`);
524
- continue;
518
+ // CONCURRENTLY, because that is how these runs were STARTED.
519
+ //
520
+ // This loop used to `await` each resume in turn, which quietly converted a parallel
521
+ // workload into a queue ordered by whatever RunView happened to return. The slowest
522
+ // connector became a head-of-line block for every other connector in the workspace and
523
+ // a connector that never finishes means the others never start at all.
524
+ //
525
+ // Observed live: a restart orphaned three syncs; one resumed and was still going five
526
+ // hours later, and the other two (99,463 and 13,238 rows) never began. Nothing in their
527
+ // logs said so, because nothing had failed — they had simply never been reached. From
528
+ // outside the process a queued run and a crashed one are identical: IsInFlight true,
529
+ // CompletedAt null, counters frozen at the instant of the restart. The absence of an
530
+ // error is the only tell.
531
+ //
532
+ // Note what is NOT being parallelised. The write section stays serialized by
533
+ // `runWriteExclusive`, because all maps share one provider connection with singular
534
+ // transaction state; that is deliberate and unchanged. Per-CompanyIntegration exclusion
535
+ // stays too, via the `activeSyncs` lock each resume takes. What overlaps here is what
536
+ // overlapped before the restart: different connectors waiting on different sources.
537
+ //
538
+ // Bounded rather than unbounded: a workspace is one Node process, so concurrency buys
539
+ // overlap on network waiting and not more CPU, and a boot that adopted fifty runs at once
540
+ // would trade one pathology for another.
541
+ await RunResumesBounded(orphanedRuns.Results, ResumeConcurrency(), run => this.ResumeOneOrphanedRun(run, prov, rv, contextUser));
542
+ }
543
+ /**
544
+ * Resume ONE orphaned run, end to end: reserve the per-CompanyIntegration lock, claim the run,
545
+ * work out which entity maps already finished, and execute the rest under a fresh run context.
546
+ *
547
+ * Extracted from {@link ResumeOrphanedSyncs}'s loop so several runs can be in flight at once.
548
+ * NEVER THROWS — every failure path is handled here and recorded on the run row. A resume that
549
+ * threw out of this method would take a pool slot with it and, worse, could abandon the runs
550
+ * queued behind it, which is the exact failure this parallelisation exists to remove.
551
+ *
552
+ * The check-and-reserve on `activeSyncs` still has no `await` in front of it, so it stays
553
+ * atomic with several of these in flight: an async function runs synchronously up to its first
554
+ * await, and the pool always starts one from a synchronous call site.
555
+ */
556
+ async ResumeOneOrphanedRun(run, prov, rv, contextUser) {
557
+ const companyIntegrationID = run.CompanyIntegrationID;
558
+ const runID = run.ID;
559
+ const lockKey = companyIntegrationID.toLowerCase();
560
+ // C1: respect the SAME in-process concurrency lock RunSync uses. If a live sync for this
561
+ // CompanyIntegration is already running (e.g. the scheduler fired during startup), skip the
562
+ // resume — double-running one CI on the shared provider connection corrupts its singular
563
+ // transaction state (exactly what runWriteExclusive guards against WITHIN a run). The
564
+ // get→set pair below has no await between them, so check-and-reserve is atomic on the loop.
565
+ if (IntegrationEngine.activeSyncs.get(lockKey)) {
566
+ console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — a live sync for ${lockKey} is already running`);
567
+ return;
568
+ }
569
+ let resolveResumeLock;
570
+ let resumeResult;
571
+ IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
572
+ const ownership = new RunOwnershipService(prov, runID, undefined, contextUser);
573
+ try {
574
+ // CLAIM BEFORE ADOPTING (PR 1 item 6): a single atomic UPDATE that succeeds only if the
575
+ // run is still unowned/lapsed. Zero rows = another worker adopted it between our RunView
576
+ // and now — skip, never double-run. A successful claim BUMPS the fence, so if the
577
+ // original owner is actually alive-but-slow it aborts at its next boundary check
578
+ // without writing: the sweep-reclaim is itself the abort signal for the abandoned owner.
579
+ const claimed = await ownership.Claim();
580
+ if (!claimed) {
581
+ console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — claim lost (another worker adopted it)`);
582
+ return;
525
583
  }
526
- let resolveResumeLock;
527
- let resumeResult;
528
- IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
529
- const ownership = new RunOwnershipService(prov, runID, undefined, contextUser);
530
- try {
531
- // CLAIM BEFORE ADOPTING (PR 1 item 6): a single atomic UPDATE that succeeds only if the
532
- // run is still unowned/lapsed. Zero rows = another worker adopted it between our RunView
533
- // and now skip, never double-run. A successful claim BUMPS the fence, so if the
534
- // original owner is actually alive-but-slow it aborts at its next boundary check
535
- // without writing: the sweep-reclaim is itself the abort signal for the abandoned owner.
536
- const claimed = await ownership.Claim();
537
- if (!claimed) {
538
- console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — claim lost (another worker adopted it)`);
539
- continue;
540
- }
541
- // Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
542
- // by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
543
- // not EntityID two maps can target the same MJ Entity, so keying on EntityID
544
- // could skip a still-pending sibling map. We also require IsSuccess=1: a map that
545
- // completed WITH errors (RecordsErrored>0, no throw) must be re-attempted on resume,
546
- // otherwise its errored records are silently abandoned.
547
- const detailsResult = await rv.RunView({
548
- EntityName: 'MJ: Company Integration Run Details',
549
- ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
550
- Fields: ['RecordID', 'IsSuccess'],
551
- ResultType: 'simple',
552
- }, contextUser);
553
- const completedMapIDs = new Set();
554
- if (detailsResult.Success) {
555
- for (const d of detailsResult.Results) {
556
- if (!d.IsSuccess)
557
- continue; // completed-with-errors → re-attempt on resume
558
- const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
559
- // Parse-miss falls open (map treated as not-completed → re-runs): at worst a
560
- // redundant idempotent re-sync, never a silent skip.
561
- if (m)
562
- completedMapIDs.add(m[1].toLowerCase());
563
- }
564
- }
565
- console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
566
- `for ${companyIntegrationID.substring(0, 8)}... ` +
567
- `(${completedMapIDs.size} entity maps already completed)`);
568
- // Recover what this run was ASKED to do. Without this the resume rebuilds config from
569
- // the CompanyIntegration alone, so an adopted run silently loses its options — most
570
- // damagingly FullSync, which exists precisely to distrust the watermark. An adopted
571
- // full sync would resume incrementally, fetch nothing, and report Success.
572
- // Unparseable/absent ConfigData falls back to defaults rather than refusing to resume.
573
- let resumeOptions;
574
- let resumeTriggerType = 'Scheduled';
575
- try {
576
- const cfg = JSON.parse(run.ConfigData ?? '{}');
577
- resumeOptions = cfg.options ?? undefined;
578
- if (cfg.triggerType)
579
- resumeTriggerType = cfg.triggerType;
580
- }
581
- catch {
582
- console.warn(`[IntegrationEngine] Run ${runID.substring(0, 8)} has unparseable ConfigData; resuming with defaults`);
583
- }
584
- if (resumeOptions?.FullSync) {
585
- console.log(`[IntegrationEngine] Run ${runID.substring(0, 8)} was a FULL sync — resuming as full, not incremental`);
586
- }
587
- // Load config and filter to only remaining entity maps (by map ID)
588
- const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, resumeOptions);
589
- const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
590
- if (remainingMaps.length === 0) {
591
- console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
592
- run.EndedAt = new Date();
593
- run.Status = 'Success';
594
- ownership.SyncEntityOwnershipFields(run); // full-row save must not clobber the live claim
595
- await run.Save();
596
- await ownership.Release('Success');
597
- continue;
584
+ // Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
585
+ // by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
586
+ // not EntityID two maps can target the same MJ Entity, so keying on EntityID
587
+ // could skip a still-pending sibling map. We also require IsSuccess=1: a map that
588
+ // completed WITH errors (RecordsErrored>0, no throw) must be re-attempted on resume,
589
+ // otherwise its errored records are silently abandoned.
590
+ const detailsResult = await rv.RunView({
591
+ EntityName: 'MJ: Company Integration Run Details',
592
+ ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
593
+ Fields: ['RecordID', 'IsSuccess'],
594
+ ResultType: 'simple',
595
+ }, contextUser);
596
+ const completedMapIDs = new Set();
597
+ if (detailsResult.Success) {
598
+ for (const d of detailsResult.Results) {
599
+ if (!d.IsSuccess)
600
+ continue; // completed-with-errors re-attempt on resume
601
+ const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
602
+ // Parse-miss falls open (map treated as not-completed re-runs): at worst a
603
+ // redundant idempotent re-sync, never a silent skip.
604
+ if (m)
605
+ completedMapIDs.add(m[1].toLowerCase());
598
606
  }
599
- console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
600
- // Replace entityMaps with only the remaining ones
601
- config.entityMaps = remainingMaps;
602
- // Execute remaining maps inside a per-run context: the resume gets its own provider
603
- // binding, abort controller, and ownership — identical to a fresh RunSync — so the
604
- // heartbeat renews the lease, the batch boundaries fence-check, and FinalizeRun
605
- // syncs ownership fields + releases, all through the SAME code paths.
606
- const abortController = new AbortController();
607
- const progressSnapshot = {
608
- StartedAt: new Date(),
609
- CurrentEntity: '',
610
- EntityMapsTotal: remainingMaps.length,
611
- EntityMapsCompleted: 0,
612
- RecordsProcessed: 0,
613
- RecordsCreated: 0,
614
- RecordsUpdated: 0,
615
- RecordsErrored: 0,
616
- // The run's OWN trigger type, recovered above — not a hardcoded 'Scheduled'. This is
617
- // what IntegrationGetSyncProgress reports back ("Sync in progress (Manual)"), so a
618
- // hardcoded value mislabels every adopted run.
619
- TriggerType: resumeTriggerType,
620
- };
621
- const runCtx = {
622
- provider: prov,
623
- ownership,
624
- abortController,
625
- progressSnapshot,
626
- cancelRequested: false,
627
- ownershipLost: false,
628
- };
629
- ownership.StartHeartbeat({
630
- onLost: () => { runCtx.ownershipLost = true; abortController.abort(); },
631
- onCancelRequested: () => { runCtx.cancelRequested = true; abortController.abort(); },
632
- progressSupplier: () => JSON.stringify(progressSnapshot),
633
- });
634
- const result = await IntegrationEngine.runContext.run(runCtx, async () => {
635
- const r = await this.ExecuteEntityMaps(config, run, contextUser, undefined, abortController.signal);
636
- r.RunID = runID;
637
- await this.FinalizeRun(run, r, contextUser);
638
- return r;
639
- });
640
- resumeResult = result;
641
- console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
642
- `${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
643
- `${result.RecordsErrored} errored`);
644
607
  }
645
- catch (err) {
646
- const errMsg = err instanceof Error ? err.message : String(err);
647
- console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
648
- if (err instanceof RunOwnershipLostError) {
649
- // We were fenced out mid-resume the NEW owner now owns the run row.
650
- // Writing 'Failed' here would clobber the live holder's state.
651
- console.warn(`[IntegrationEngine] Resume of run ${runID.substring(0, 8)} lost ownership leaving the run row to its new owner`);
652
- }
653
- else {
654
- // Mark as failed so it doesn't get picked up again
655
- run.EndedAt = new Date();
656
- run.Status = 'Failed';
657
- run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
658
- ownership.SyncEntityOwnershipFields(run);
659
- await run.Save();
660
- try {
661
- await ownership.Release('Failed');
662
- }
663
- catch { /* lease will simply expire */ }
664
- }
608
+ console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
609
+ `for ${companyIntegrationID.substring(0, 8)}... ` +
610
+ `(${completedMapIDs.size} entity maps already completed)`);
611
+ // Recover what this run was ASKED to do. Without this the resume rebuilds config from
612
+ // the CompanyIntegration alone, so an adopted run silently loses its options most
613
+ // damagingly FullSync, which exists precisely to distrust the watermark. An adopted
614
+ // full sync would resume incrementally, fetch nothing, and report Success.
615
+ // Unparseable/absent ConfigData falls back to defaults rather than refusing to resume.
616
+ let resumeOptions;
617
+ let resumeTriggerType = 'Scheduled';
618
+ try {
619
+ const cfg = JSON.parse(run.ConfigData ?? '{}');
620
+ resumeOptions = cfg.options ?? undefined;
621
+ if (cfg.triggerType)
622
+ resumeTriggerType = cfg.triggerType;
665
623
  }
666
- finally {
667
- ownership.StopHeartbeat();
668
- // Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
669
- // `existing`). Resolve with the real result when we have one, else a benign empty result so no
670
- // waiter hangs. Promise resolve is idempotent and the early-exit `continue` also lands here.
671
- IntegrationEngine.activeSyncs.delete(lockKey);
672
- resolveResumeLock(resumeResult ?? {
673
- Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
674
- RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
675
- RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
676
- });
624
+ catch {
625
+ console.warn(`[IntegrationEngine] Run ${runID.substring(0, 8)} has unparseable ConfigData; resuming with defaults`);
626
+ }
627
+ if (resumeOptions?.FullSync) {
628
+ console.log(`[IntegrationEngine] Run ${runID.substring(0, 8)} was a FULL sync resuming as full, not incremental`);
677
629
  }
630
+ // Load config and filter to only remaining entity maps (by map ID)
631
+ const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, resumeOptions);
632
+ const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
633
+ if (remainingMaps.length === 0) {
634
+ console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
635
+ run.EndedAt = new Date();
636
+ run.Status = 'Success';
637
+ ownership.SyncEntityOwnershipFields(run); // full-row save must not clobber the live claim
638
+ await run.Save();
639
+ await ownership.Release('Success');
640
+ return;
641
+ }
642
+ console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
643
+ // Replace entityMaps with only the remaining ones
644
+ config.entityMaps = remainingMaps;
645
+ // Execute remaining maps inside a per-run context: the resume gets its own provider
646
+ // binding, abort controller, and ownership — identical to a fresh RunSync — so the
647
+ // heartbeat renews the lease, the batch boundaries fence-check, and FinalizeRun
648
+ // syncs ownership fields + releases, all through the SAME code paths.
649
+ const abortController = new AbortController();
650
+ const progressSnapshot = {
651
+ StartedAt: new Date(),
652
+ CurrentEntity: '',
653
+ EntityMapsTotal: remainingMaps.length,
654
+ EntityMapsCompleted: 0,
655
+ RecordsProcessed: 0,
656
+ RecordsCreated: 0,
657
+ RecordsUpdated: 0,
658
+ RecordsErrored: 0,
659
+ // The run's OWN trigger type, recovered above — not a hardcoded 'Scheduled'. This is
660
+ // what IntegrationGetSyncProgress reports back ("Sync in progress (Manual)"), so a
661
+ // hardcoded value mislabels every adopted run.
662
+ TriggerType: resumeTriggerType,
663
+ };
664
+ const runCtx = {
665
+ provider: prov,
666
+ ownership,
667
+ abortController,
668
+ progressSnapshot,
669
+ cancelRequested: false,
670
+ ownershipLost: false,
671
+ };
672
+ ownership.StartHeartbeat({
673
+ onLost: () => { runCtx.ownershipLost = true; abortController.abort(); },
674
+ onCancelRequested: () => { runCtx.cancelRequested = true; abortController.abort(); },
675
+ progressSupplier: () => JSON.stringify(progressSnapshot),
676
+ });
677
+ const result = await IntegrationEngine.runContext.run(runCtx, async () => {
678
+ const r = await this.ExecuteEntityMaps(config, run, contextUser, undefined, abortController.signal);
679
+ r.RunID = runID;
680
+ await this.FinalizeRun(run, r, contextUser);
681
+ return r;
682
+ });
683
+ resumeResult = result;
684
+ console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
685
+ `${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
686
+ `${result.RecordsErrored} errored`);
687
+ }
688
+ catch (err) {
689
+ const errMsg = err instanceof Error ? err.message : String(err);
690
+ console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
691
+ if (err instanceof RunOwnershipLostError) {
692
+ // We were fenced out mid-resume — the NEW owner now owns the run row.
693
+ // Writing 'Failed' here would clobber the live holder's state.
694
+ console.warn(`[IntegrationEngine] Resume of run ${runID.substring(0, 8)} lost ownership — leaving the run row to its new owner`);
695
+ }
696
+ else {
697
+ // Mark as failed so it doesn't get picked up again
698
+ run.EndedAt = new Date();
699
+ run.Status = 'Failed';
700
+ run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
701
+ ownership.SyncEntityOwnershipFields(run);
702
+ await run.Save();
703
+ try {
704
+ await ownership.Release('Failed');
705
+ }
706
+ catch { /* lease will simply expire */ }
707
+ }
708
+ }
709
+ finally {
710
+ ownership.StopHeartbeat();
711
+ // Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
712
+ // `existing`). Resolve with the real result when we have one, else a benign empty result so no
713
+ // waiter hangs. Promise resolve is idempotent and the early-exit `return`s also land here.
714
+ IntegrationEngine.activeSyncs.delete(lockKey);
715
+ resolveResumeLock(resumeResult ?? {
716
+ Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
717
+ RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
718
+ RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
719
+ });
678
720
  }
679
721
  }
680
722
  /**
@@ -861,6 +903,27 @@ export class IntegrationEngine extends BaseSingleton {
861
903
  entityMapIDsFilter: options?.EntityMapIDs ?? null,
862
904
  syncDirectionOverride: options?.SyncDirection ?? null,
863
905
  });
906
+ /**
907
+ * Re-read the IO/IOF catalog before the run starts (also covers resumed runs — both paths land here).
908
+ *
909
+ * Everything else a run reads is fresh per run (LoadRunConfiguration and LoadFieldMaps
910
+ * read CI/entity maps/field maps with BypassCache) — but IntegrationObject and
911
+ * IntegrationObjectField are served from this engine's BaseEngine arrays, loaded at
912
+ * process start and auto-refreshed only by IN-PROCESS BaseEntity saves. A catalog edit
913
+ * made by direct SQL, a sproc-based sync push, or another process is therefore invisible
914
+ * to syncs until the host restarts — an AccessPath or field-type correction keeps being
915
+ * ignored run after run with nothing in the log to say why. The apply/evolution
916
+ * resolvers already do an invalidate+reload for exactly this reason; the sync path
917
+ * never got the same treatment.
918
+ *
919
+ * Only the two catalog arrays are refreshed — not Config(true), which reloads all eight
920
+ * datasets unfiltered on every run. The loader is called directly with bypassCache=true
921
+ * because RefreshItem defaults to the local cache — which is the very thing that is
922
+ * stale. Replacing the arrays is also what invalidates this PR's memoised views: both
923
+ * the per-object field index and the per-record GetCachedFields memo key on ARRAY
924
+ * IDENTITY, so they rebuild lazily on first read after the swap.
925
+ */
926
+ await IntegrationEngineBase.Instance.RefreshCatalog(contextUser);
864
927
  const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, options);
865
928
  logger.attachIntegrationName(config.companyIntegration.Integration);
866
929
  logger.emit('sync.config.loaded', {
@@ -1817,6 +1880,16 @@ export class IntegrationEngine extends BaseSingleton {
1817
1880
  async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
1818
1881
  const entityMapID = entityMap.ID;
1819
1882
  const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
1883
+ // Field-level exclusions declared by the connector (SourceFieldInfo.SyncDirective
1884
+ // -> IntegrationObjectField.Configuration). Resolved once per map, applied to every
1885
+ // batch below. Empty set on any lookup miss - exclusion can only ever narrow.
1886
+ const excludedSourceNames = this.ResolveExcludedSourceNames(config.companyIntegration.IntegrationID, entityMap.ExternalObjectName);
1887
+ if (excludedSourceNames.size > 0) {
1888
+ logger?.emit('sync.entity-map.exclusions', {
1889
+ externalObjectName: entityMap.ExternalObjectName,
1890
+ excludedFields: Array.from(excludedSourceNames).sort(),
1891
+ });
1892
+ }
1820
1893
  const watermark = await this.runWriteExclusive(() => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
1821
1894
  logger?.emit('sync.entity-map.start', {
1822
1895
  phase: 'pull-detail',
@@ -1832,8 +1905,13 @@ export class IntegrationEngine extends BaseSingleton {
1832
1905
  watermarkType: watermark?.WatermarkType ?? null,
1833
1906
  fullSync: config.fullSync,
1834
1907
  });
1908
+ this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
1835
1909
  // A6: Validate watermark before using it — skip entirely when FullSync requested
1836
1910
  let initialWatermark = config.fullSync ? null : (watermark?.WatermarkValue ?? null);
1911
+ // The value the ROW held before this run touched it — the retract target if a mid-run
1912
+ // durability floor (§8a below) has to be undone after a page-skip gap. Distinct from
1913
+ // initialWatermark, which a fullSync nulls even though the row still holds a real value.
1914
+ const preRunWatermarkValue = watermark?.WatermarkValue ?? null;
1837
1915
  if (initialWatermark && watermark) {
1838
1916
  const watermarkType = (watermark.WatermarkType ?? 'Timestamp');
1839
1917
  if (!this.watermarkService.ValidateWatermark(initialWatermark, watermarkType)) {
@@ -1917,6 +1995,7 @@ export class IntegrationEngine extends BaseSingleton {
1917
1995
  let previousBatchFingerprint;
1918
1996
  let fetchCompletedCleanly = true; // flipped to false if fetch aborted or errored mid-way
1919
1997
  let hadFetchGap = false; // ≥1 page was skipped after a persistent fetch error (offset/page paging)
1998
+ let watermarkFloorSaved = null; // §8a durability floor last persisted mid-run (null = none)
1920
1999
  let fetchGapCount = 0; // CONSECUTIVE skipped pages (reset on any clean fetch)
1921
2000
  const MAX_FETCH_GAPS = 25; // give up + hold the watermark if this many pages fail in a row (API down)
1922
2001
  let consecutiveEmptyBatches = 0; // P3-D: detect a connector that pages empty-but-HasMore forever
@@ -1984,6 +2063,16 @@ export class IntegrationEngine extends BaseSingleton {
1984
2063
  });
1985
2064
  let batch;
1986
2065
  const fetchStart = Date.now();
2066
+ // ONE multiplicative decrease per throttle EPISODE, not one per rejected attempt.
2067
+ //
2068
+ // A 429 that survives three retries is three rejections but one congestion event — the
2069
+ // same distinction TCP draws when it halves the window once per loss event rather than
2070
+ // once per lost segment. Decreasing on each attempt compounds: at a 0.5 backoff factor
2071
+ // three attempts take the rate to an eighth, five take it to a thirtieth, so a single
2072
+ // throttled fetch could drive a connector to its floor purely as a function of how
2073
+ // generous its retry budget is. The freeze already covers the interval the source asked
2074
+ // for; the decrease is about the rate AFTER that, and one signal deserves one step.
2075
+ let throttleReported = false;
1987
2076
  try {
1988
2077
  await this.rateLimit(config);
1989
2078
  // Resilient fetch: bound each attempt with a timeout (a hung vendor API must not
@@ -2003,13 +2092,40 @@ export class IntegrationEngine extends BaseSingleton {
2003
2092
  // `econnreset` in with timeouts under `NETWORK_TIMEOUT`, and a reset socket IS worth
2004
2093
  // retrying — so excluding the whole code would lose real resilience. Only the error
2005
2094
  // WithTimeout itself minted is excluded.
2006
- (err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => logger?.emit('sync.fetch.retry', {
2007
- externalObjectName: entityMap.ExternalObjectName,
2008
- batchIndex: batchCount,
2009
- attempt,
2010
- delayMs,
2011
- error: err instanceof Error ? err.message : String(err),
2012
- }));
2095
+ (err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => {
2096
+ // Report a throttle NOW, not after the retries are spent. ReportThrottle
2097
+ // freezes the shared bucket for this CompanyIntegration, so every other
2098
+ // object fetching concurrently backs off too — reporting it only in the
2099
+ // catch below meant the rest of the connector kept hammering a source that
2100
+ // had already said stop.
2101
+ //
2102
+ // Once per episode: see `throttleReported` above. Later attempts still get
2103
+ // their own Retry-After honoured via DelayForError, which is what actually
2104
+ // paces this loop; what they must not do is halve the rate again.
2105
+ if (!throttleReported && ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED') {
2106
+ throttleReported = true;
2107
+ this.reportRateOutcome(config, err);
2108
+ }
2109
+ logger?.emit('sync.fetch.retry', {
2110
+ externalObjectName: entityMap.ExternalObjectName,
2111
+ batchIndex: batchCount,
2112
+ attempt,
2113
+ delayMs,
2114
+ error: err instanceof Error ? err.message : String(err),
2115
+ });
2116
+ }, {
2117
+ // Honour the source's own instruction. A 429 usually carries Retry-After;
2118
+ // blind exponential backoff ignored it and retried early, which is how a
2119
+ // soft throttle becomes a hard one. Falls back to backoff when the
2120
+ // connector cannot parse one.
2121
+ DelayForError: (err) => ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED'
2122
+ ? config.connector.ExtractRetryAfterMs(err)
2123
+ : undefined,
2124
+ // A retry must pass through the same gate the first attempt did. The token
2125
+ // was acquired once before WithRetry, so retries previously bypassed the
2126
+ // limiter entirely — including the freeze the line above just applied.
2127
+ BeforeRetry: () => this.rateLimit(config),
2128
+ });
2013
2129
  this.reportRateOutcome(config); // clean fetch → ramp the adaptive rate back up
2014
2130
  fetchGapCount = 0; // clean fetch → reset the consecutive fetch-gap counter
2015
2131
  // §10: connector type-driven post-processing hook (default no-op) — enforce/normalize
@@ -2024,7 +2140,10 @@ export class IntegrationEngine extends BaseSingleton {
2024
2140
  // other errors don't touch the rate. §5 Gap 2: also flag the map result so the per-layer
2025
2141
  // AIMD controller reduces in-flight concurrency, not just the per-request token bucket.
2026
2142
  if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED') {
2027
- this.reportRateOutcome(config, fetchErr);
2143
+ // Only if the retry hook did not already do it — a fetch that was retried has
2144
+ // already had its one decrease applied, at the first sign rather than here.
2145
+ if (!throttleReported)
2146
+ this.reportRateOutcome(config, fetchErr);
2028
2147
  result.Throttled = true;
2029
2148
  }
2030
2149
  console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
@@ -2044,6 +2163,15 @@ export class IntegrationEngine extends BaseSingleton {
2044
2163
  fetchGapCount++;
2045
2164
  hadFetchGap = true;
2046
2165
  fetchCompletedCleanly = false;
2166
+ // A durability floor written before this gap may sit PAST the hole (the skipped
2167
+ // page can hold records behind the max watermark seen). Put the row back to what
2168
+ // it held before this run, exactly what the post-loop hold does for the in-memory
2169
+ // value — a crash from here on resumes from the pre-run watermark and re-covers
2170
+ // the gap. Later checkpoints stop writing floors (gate above).
2171
+ if (watermarkFloorSaved !== null) {
2172
+ await this.runWriteExclusive(() => this.watermarkService.RestoreValue(entityMapID, preRunWatermarkValue, contextUser));
2173
+ watermarkFloorSaved = null;
2174
+ }
2047
2175
  logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_PAGE_SKIPPED', `Persistent fetch error at ${currentOffset != null ? 'offset ' + currentOffset : 'page ' + currentPage} for ` +
2048
2176
  `'${entityMap.ExternalObjectName}' (batch ${batchCount}); skipped this page and continued — the ` +
2049
2177
  `watermark is held so the window is re-fetched next run. Error: ${errMsg}`, { offset: currentOffset ?? null, page: currentPage ?? null, batchIndex: batchCount, error: errMsg });
@@ -2133,7 +2261,20 @@ export class IntegrationEngine extends BaseSingleton {
2133
2261
  fetchedExternalIDs.clear();
2134
2262
  }
2135
2263
  }
2136
- const mapped = this.fieldMappingEngine.Apply(batch.Records, fieldMaps, entityMap.Entity);
2264
+ // Within-batch identity, enforced before mapping: two records sharing an ExternalID are
2265
+ // two observations of ONE source record. The write path cannot catch this — it decides
2266
+ // insert-vs-update against the DATABASE, where a first-time identity is absent for both
2267
+ // copies, so both insert and the pair re-inserts every sync. The fingerprint guard above
2268
+ // only sees a batch repeated in FULL. Never silent: a connector emitting duplicate
2269
+ // identities is a defect worth fixing at its source.
2270
+ const identity = CollapseDuplicateIdentities(batch.Records);
2271
+ if (identity.Collapsed > 0) {
2272
+ logger?.warning(entityMap.ExternalObjectName ?? 'sync', 'DUPLICATE_IDENTITIES_IN_BATCH', `${entityMap.ExternalObjectName}: ${identity.Collapsed} record(s) repeated an ExternalID already `
2273
+ + `present in the same batch and were collapsed (last occurrence kept). Two records sharing an `
2274
+ + `identity are one source record observed twice; writing both would insert duplicate rows that `
2275
+ + `no later sync could reconcile. Sample: ${identity.SampleIDs.join(', ')}`, { object: entityMap.ExternalObjectName, collapsed: identity.Collapsed, sample: identity.SampleIDs });
2276
+ }
2277
+ const mapped = this.fieldMappingEngine.Apply(identity.Records, fieldMaps, entityMap.Entity, excludedSourceNames);
2137
2278
  // Custom-key stats: aggregate unmapped keys for EVERY mapped record here —
2138
2279
  // before any skip decision — so candidates + sizing stats exist even when the
2139
2280
  // content-hash fast path skips the row (the hash basis deliberately excludes them).
@@ -2238,6 +2379,25 @@ export class IntegrationEngine extends BaseSingleton {
2238
2379
  if (isKeysetConnector && currentAfterKey) {
2239
2380
  await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
2240
2381
  }
2382
+ // The WATERMARK twin of the keyset floor above. Without it, a watermark-based
2383
+ // connector had NO durable position at all until the run ended: a SIGKILL / OOM /
2384
+ // container recycle mid-object threw away hours of applied batches and the next run
2385
+ // re-fetched the entire window from the last completed run's watermark. Same safety
2386
+ // argument as the graceful early-exit save below — currentWatermark only ever
2387
+ // advances at the END of a fully-applied batch, so this floor can never point past a
2388
+ // record that was not written. Gated on !hadFetchGap because a skipped page may
2389
+ // contain records BEHIND the max watermark seen (fetch order is not watermark order
2390
+ // on every source), i.e. a hole behind the floor; once a gap exists the floor stops
2391
+ // moving, and the one already written is retracted at the gap site. Skipped for
2392
+ // keyset connectors (their position IS the seek key above) and partition-reconcile
2393
+ // maps (their watermark row stores the rollup snapshot, not a timestamp).
2394
+ if (!isKeysetConnector && !partitionReconcile && !hadFetchGap
2395
+ && currentWatermark && currentWatermark !== initialWatermark
2396
+ && currentWatermark !== watermarkFloorSaved) {
2397
+ const floor = currentWatermark;
2398
+ await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, floor, contextUser, 'Pull'));
2399
+ watermarkFloorSaved = floor;
2400
+ }
2241
2401
  }
2242
2402
  // P3-D: a connector returning empty pages with HasMore=true would otherwise spin silently
2243
2403
  // to MAX_BATCHES_PER_MAP. Surface a structured warning once the empty streak crosses the
@@ -2443,6 +2603,7 @@ export class IntegrationEngine extends BaseSingleton {
2443
2603
  async ProcessPushSync(config, entityMap, run, contextUser, _entityMapIndex, _totalEntityMaps, _onProgress, _abortSignal, logger) {
2444
2604
  const entityMapID = entityMap.ID;
2445
2605
  const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
2606
+ this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
2446
2607
  const pushWatermark = await this.watermarkService.Load(entityMapID, contextUser, 'Push');
2447
2608
  const lastPushAt = pushWatermark?.WatermarkValue ?? null;
2448
2609
  // Check connector write capability
@@ -2949,12 +3110,25 @@ export class IntegrationEngine extends BaseSingleton {
2949
3110
  const orphans = allMaps.Rows.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
2950
3111
  if (orphans.length === 0)
2951
3112
  return;
3113
+ // The sweep is a DELETE PATH and must answer to the same policy as every other delete.
3114
+ // It used to call entity.Delete() unconditionally — its own warning text promised
3115
+ // "archived/deleted" while the code only ever deleted, so a map configured SoftDelete
3116
+ // (or DoNothing) had its rows physically removed by full syncs. DoNothing short-circuits
3117
+ // the whole sweep: the policy says external deletions never touch MJ rows, and saying so
3118
+ // once beats detecting the same "orphans" forever.
3119
+ if (entityMap.DeleteBehavior === 'DoNothing') {
3120
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_POLICY_SKIPPED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this ` +
3121
+ `full sync. This map's DeleteBehavior is 'DoNothing', so none were touched.`, { orphanCount: orphans.length });
3122
+ return;
3123
+ }
2952
3124
  console.log(`[IntegrationEngine] Orphan detection for ${entityMap.ExternalObjectName}: ${orphans.length} records in MJ not found in external system`);
2953
3125
  // Surface delete-detection in the structured stream (previously console-only). The orphan
2954
3126
  // COUNT is already in the run counts via RecordsDeleted, but a dedicated warning makes a
2955
3127
  // large/unexpected count visible over GraphQL — the early signal of an incomplete upstream
2956
3128
  // fetch silently archiving live records.
2957
- logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync — they will be archived/deleted (delete-detection). A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
3129
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync — ` +
3130
+ `they will be ${entityMap.DeleteBehavior === 'SoftDelete' ? 'archived (SoftDelete)' : 'deleted'} and their record-map rows pruned (delete-detection). ` +
3131
+ `A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
2958
3132
  const md = this.ProviderToUse;
2959
3133
  const entityInfo = md.EntityByName(entityMap.Entity);
2960
3134
  const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
@@ -2964,11 +3138,44 @@ export class IntegrationEngine extends BaseSingleton {
2964
3138
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(orphan.EntityRecordID, pkFields));
2965
3139
  if (!loaded) {
2966
3140
  console.log(`[IntegrationEngine] Orphan ${orphan.EntityRecordID} already deleted from MJ`);
3141
+ // The map row outlived its record. Nothing anywhere else deletes record-map
3142
+ // rows, so without this the same "orphan" is re-detected on EVERY subsequent
3143
+ // full sync and ORPHANS_DETECTED becomes a cumulative counter of history
3144
+ // rather than a signal about THIS run — observed live as a count that only
3145
+ // ever grew, sync after sync.
3146
+ await this.DeleteRecordMapRow(orphan.ID, contextUser);
3147
+ continue;
3148
+ }
3149
+ if (entityMap.DeleteBehavior === 'SoftDelete') {
3150
+ // Same archive shape as DeleteRecord's SoftDelete branch: the row stays,
3151
+ // marked Archived/tombstoned. The MAP row goes either way — the mapping's
3152
+ // job is done, and keeping it would re-detect this orphan forever.
3153
+ const fields = entity.Fields ?? [];
3154
+ const hasField = (n) => fields.some(f => f.Name === n);
3155
+ if (hasField('__mj_integration_SyncStatus'))
3156
+ entity.Set('__mj_integration_SyncStatus', 'Archived');
3157
+ if (hasField('__mj_integration_LastSyncedAt'))
3158
+ entity.Set('__mj_integration_LastSyncedAt', new Date().toISOString());
3159
+ if (hasField('__mj_integration_IsTombstoned'))
3160
+ entity.Set('__mj_integration_IsTombstoned', true);
3161
+ if (hasField('__mj_integration_DeletedDetectedAt'))
3162
+ entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
3163
+ const archived = await entity.Save();
3164
+ if (archived) {
3165
+ result.RecordsDeleted++;
3166
+ await this.DeleteRecordMapRow(orphan.ID, contextUser);
3167
+ console.log(`[IntegrationEngine] Archived orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
3168
+ }
3169
+ else {
3170
+ const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
3171
+ console.warn(`[IntegrationEngine] Orphan archive blocked for ${entityMap.Entity} ${orphan.EntityRecordID} — ${reason}`);
3172
+ }
2967
3173
  continue;
2968
3174
  }
2969
3175
  const deleted = await entity.Delete();
2970
3176
  if (deleted) {
2971
3177
  result.RecordsDeleted++;
3178
+ await this.DeleteRecordMapRow(orphan.ID, contextUser);
2972
3179
  console.log(`[IntegrationEngine] Deleted orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
2973
3180
  }
2974
3181
  else {
@@ -2982,6 +3189,28 @@ export class IntegrationEngine extends BaseSingleton {
2982
3189
  }
2983
3190
  }
2984
3191
  }
3192
+ /**
3193
+ * Removes one 'MJ: Company Integration Record Maps' row after delete-detection has handled
3194
+ * its orphan (deleted, archived, or found already gone). A failure here is logged and
3195
+ * swallowed: the orphan itself was handled, and the worst consequence of a surviving map
3196
+ * row is one redundant re-detection on the next full sync.
3197
+ */
3198
+ async DeleteRecordMapRow(mapRowID, contextUser) {
3199
+ try {
3200
+ const md = this.ProviderToUse;
3201
+ const mapRow = await md.GetEntityObject('MJ: Company Integration Record Maps', contextUser);
3202
+ const loaded = await mapRow.InnerLoad(CompositeKey.FromID(mapRowID));
3203
+ if (!loaded)
3204
+ return;
3205
+ const ok = await mapRow.Delete();
3206
+ if (!ok) {
3207
+ console.warn(`[IntegrationEngine] Record-map prune blocked for ${mapRowID} — ${mapRow.LatestResult?.CompleteMessage ?? 'unknown reason'}`);
3208
+ }
3209
+ }
3210
+ catch (err) {
3211
+ console.warn(`[IntegrationEngine] Record-map prune failed for ${mapRowID}: ${err instanceof Error ? err.message : String(err)}`);
3212
+ }
3213
+ }
2985
3214
  /**
2986
3215
  * Strips field-name prefixes from a RecordChange.RecordID to get the raw entity record ID.
2987
3216
  * "hs_object_id|465950833372" → "465950833372"
@@ -3434,7 +3663,7 @@ export class IntegrationEngine extends BaseSingleton {
3434
3663
  try {
3435
3664
  switch (record.ChangeType) {
3436
3665
  case 'Create': {
3437
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
3666
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
3438
3667
  if (outcome === 'updated')
3439
3668
  result.RecordsUpdated++;
3440
3669
  else if (outcome === 'skipped')
@@ -3444,7 +3673,7 @@ export class IntegrationEngine extends BaseSingleton {
3444
3673
  break;
3445
3674
  }
3446
3675
  case 'Update':
3447
- await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps);
3676
+ await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps, logger);
3448
3677
  break;
3449
3678
  case 'Delete': {
3450
3679
  const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
@@ -3504,7 +3733,9 @@ export class IntegrationEngine extends BaseSingleton {
3504
3733
  *
3505
3734
  * @returns true if an existing row was updated, false if a new row was inserted (so the caller counts correctly).
3506
3735
  */
3507
- async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps) {
3736
+ async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps,
3737
+ /** Optional — lets the keyless-key guard below surface on the run's event stream. */
3738
+ keylessLogger) {
3508
3739
  const md = this.ProviderToUse;
3509
3740
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
3510
3741
  const entityInfo = md.EntityByName(record.MJEntityName);
@@ -3513,6 +3744,31 @@ export class IntegrationEngine extends BaseSingleton {
3513
3744
  // ID), check whether that row already exists before deciding INSERT vs UPDATE. A null mappedPK
3514
3745
  // (e.g. a server-assigned UUID PK not present in the mapped fields) means a genuinely new row.
3515
3746
  const mappedPK = this.extractMappedPrimaryKey(record, pkFields);
3747
+ // INVARIANT: a record destined for a SOFT-primary-key table must carry its key.
3748
+ //
3749
+ // A soft PK is INFERRED, not generated — it is the external system's own identifier, stored
3750
+ // as ordinary data with no PRIMARY KEY constraint, no identity, and no unique index (see
3751
+ // DDLGenerator: soft keys get a non-unique index only). So nothing at the database level
3752
+ // rejects a NULL key, and a row written without one can never be matched again: the next
3753
+ // sync's existence check misses it, and inserts another copy. Every pass therefore multiplies
3754
+ // the damage silently — the rows look fully populated, only the key column is empty.
3755
+ //
3756
+ // `mappedPK == null` is legitimate ONLY when the destination generates its own key (an
3757
+ // identity column or a server-assigned UUID), which is why the check is scoped to soft PKs
3758
+ // rather than applied to every table.
3759
+ const keyless = DecideKeylessRefusal(mappedPK, pkFields, MissingKeyFieldNames(record.MappedFields, pkFields, serializeKeyValue));
3760
+ if (keyless.Refuse) {
3761
+ const detail = DescribeKeylessRefusal(record.MJEntityName, keyless.KeyNames);
3762
+ keylessLogger?.emit('sync.record.error', {
3763
+ phase: 'write',
3764
+ externalObjectName: entityMap.ExternalObjectName,
3765
+ externalID: record.ExternalRecord?.ExternalID,
3766
+ error: `KEYLESS_RECORD_REFUSED: ${detail}`,
3767
+ });
3768
+ // Reported even without a logger — silence is the failure mode this guard exists to end.
3769
+ console.error(`[IntegrationEngine] KEYLESS_RECORD_REFUSED: ${detail}`);
3770
+ return 'skipped';
3771
+ }
3516
3772
  const existed = mappedPK != null
3517
3773
  ? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
3518
3774
  : false;
@@ -3598,10 +3854,12 @@ export class IntegrationEngine extends BaseSingleton {
3598
3854
  * If the record cannot be loaded (e.g. it was deleted or never fully created),
3599
3855
  * falls back to CreateRecord (upsert behavior).
3600
3856
  */
3601
- async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps) {
3857
+ async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps,
3858
+ /** Forwarded to CreateRecord's keyless-key guard on the upsert fallback paths. */
3859
+ logger) {
3602
3860
  if (!record.MatchedMJRecordID) {
3603
3861
  // No matched ID — upsert by PK (insert; or update/skip if the PK already exists)
3604
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
3862
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
3605
3863
  if (outcome === 'updated')
3606
3864
  result.RecordsUpdated++;
3607
3865
  else if (outcome === 'skipped')
@@ -3649,7 +3907,7 @@ export class IntegrationEngine extends BaseSingleton {
3649
3907
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
3650
3908
  if (!loaded) {
3651
3909
  // Matched-ID row vanished — fall back to upsert by PK (insert; or update/skip if PK exists)
3652
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
3910
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
3653
3911
  if (outcome === 'updated')
3654
3912
  result.RecordsUpdated++;
3655
3913
  else if (outcome === 'skipped')
@@ -3849,6 +4107,31 @@ export class IntegrationEngine extends BaseSingleton {
3849
4107
  }
3850
4108
  return key;
3851
4109
  }
4110
+ /**
4111
+ * Reports ACTIVE field maps whose MJ column does not exist, once per entity map per run.
4112
+ *
4113
+ * `BaseEntity.Set` no-ops on an unknown field — no throw, no log, no dirty flag — so a map
4114
+ * pointing at a column that was never applied (or was renamed) drops its value for every
4115
+ * record while the run reports those records as written. Checking it here costs one metadata
4116
+ * read and happens before the first fetch, so the warning arrives before the wasted work.
4117
+ */
4118
+ WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger) {
4119
+ const entityName = entityMap.Entity ?? '';
4120
+ // Diagnostics must never be able to fail a run: an unresolvable entity/provider is reported
4121
+ // by the paths that actually need it, and here it simply means there is nothing to check.
4122
+ let entityFieldNames = [];
4123
+ try {
4124
+ const entityInfo = entityName ? this.ProviderToUse?.EntityByName(entityName) : null;
4125
+ entityFieldNames = entityInfo?.Fields?.map(f => f.Name) ?? [];
4126
+ }
4127
+ catch {
4128
+ return;
4129
+ }
4130
+ const unbindable = FindUnbindableFieldMaps(fieldMaps, entityFieldNames);
4131
+ if (unbindable.length === 0)
4132
+ return;
4133
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FIELD_MAP_DESTINATION_MISSING', DescribeUnbindableFieldMaps(unbindable, entityMap.ExternalObjectName ?? entityMap.ID, entityName), { fieldMaps: unbindable });
4134
+ }
3852
4135
  /**
3853
4136
  * Sets fields on a BaseEntity instance from a field value map.
3854
4137
  */
@@ -4209,12 +4492,19 @@ export class IntegrationEngine extends BaseSingleton {
4209
4492
  const existing = await rv.RunView({
4210
4493
  EntityName: 'MJ: Company Integration Record Maps',
4211
4494
  ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID=${quotedExternalID}`,
4212
- Fields: ['ID'],
4495
+ Fields: ['ID', 'EntityRecordID'],
4213
4496
  MaxRows: 1,
4214
4497
  ResultType: 'simple',
4215
4498
  BypassCache: true, // upsert-by-identity: a stale miss here re-creates a duplicate record map
4216
4499
  }, contextUser);
4217
4500
  if (existing.Success && existing.Results.length > 0) {
4501
+ // The row already says exactly this. On an incremental sync that is nearly every record
4502
+ // — mappings are stable — and the Load + Save this path used to do anyway were two more
4503
+ // round trips to change nothing, plus a meaningless __mj_UpdatedAt bump on every synced
4504
+ // record's map row. The batched writer (RecordMapBatch.flushChunk) has always skipped
4505
+ // this case; the per-record fallback now agrees with it.
4506
+ if (existing.Results[0].EntityRecordID === entityRecordID)
4507
+ return;
4218
4508
  const loaded = await recordMap.Load(existing.Results[0].ID);
4219
4509
  if (!loaded)
4220
4510
  recordMap.NewRecord();
@@ -4677,6 +4967,20 @@ export class IntegrationEngine extends BaseSingleton {
4677
4967
  GetIntegrationObjectFields(objectID) {
4678
4968
  return this.Base.GetIntegrationObjectFields(objectID);
4679
4969
  }
4970
+ /**
4971
+ * Source field names the connector declared as SyncDirective 'Exclude' for one
4972
+ * integration object, read from IntegrationObjectField.Configuration. Empty set
4973
+ * on any lookup miss (unknown object, no fields, no integration id) - a failed
4974
+ * lookup must never widen or narrow the sync beyond its declared behaviour.
4975
+ */
4976
+ ResolveExcludedSourceNames(integrationID, externalObjectName) {
4977
+ if (!integrationID || !externalObjectName)
4978
+ return new Set();
4979
+ const obj = this.GetIntegrationObject(integrationID, externalObjectName);
4980
+ if (!obj)
4981
+ return new Set();
4982
+ return ComputeExcludedSourceNames(this.GetIntegrationObjectFields(obj.ID));
4983
+ }
4680
4984
  GetActiveIntegrationObjects(integrationID) {
4681
4985
  return this.Base.GetActiveIntegrationObjects(integrationID);
4682
4986
  }