@memberjunction/integration-engine 5.40.2 → 5.42.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.
Files changed (78) hide show
  1. package/README.md +34 -0
  2. package/dist/BaseIntegrationConnector.d.ts +109 -0
  3. package/dist/BaseIntegrationConnector.d.ts.map +1 -1
  4. package/dist/BaseIntegrationConnector.js +308 -93
  5. package/dist/BaseIntegrationConnector.js.map +1 -1
  6. package/dist/BaseRESTIntegrationConnector.d.ts +32 -0
  7. package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
  8. package/dist/BaseRESTIntegrationConnector.js +182 -28
  9. package/dist/BaseRESTIntegrationConnector.js.map +1 -1
  10. package/dist/ContentHash.d.ts +22 -0
  11. package/dist/ContentHash.d.ts.map +1 -1
  12. package/dist/ContentHash.js +35 -0
  13. package/dist/ContentHash.js.map +1 -1
  14. package/dist/CustomColumnPromotion.d.ts +108 -0
  15. package/dist/CustomColumnPromotion.d.ts.map +1 -0
  16. package/dist/CustomColumnPromotion.js +202 -0
  17. package/dist/CustomColumnPromotion.js.map +1 -0
  18. package/dist/CustomOverflow.d.ts +64 -0
  19. package/dist/CustomOverflow.d.ts.map +1 -0
  20. package/dist/CustomOverflow.js +74 -0
  21. package/dist/CustomOverflow.js.map +1 -0
  22. package/dist/FieldMappingEngine.d.ts.map +1 -1
  23. package/dist/FieldMappingEngine.js +22 -3
  24. package/dist/FieldMappingEngine.js.map +1 -1
  25. package/dist/IntegrationConnectorCreationPipeline.d.ts +33 -0
  26. package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
  27. package/dist/IntegrationConnectorCreationPipeline.js +157 -0
  28. package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
  29. package/dist/IntegrationEngine.d.ts +106 -6
  30. package/dist/IntegrationEngine.d.ts.map +1 -1
  31. package/dist/IntegrationEngine.js +874 -170
  32. package/dist/IntegrationEngine.js.map +1 -1
  33. package/dist/IntegrationSchemaSync.d.ts +84 -13
  34. package/dist/IntegrationSchemaSync.d.ts.map +1 -1
  35. package/dist/IntegrationSchemaSync.js +198 -36
  36. package/dist/IntegrationSchemaSync.js.map +1 -1
  37. package/dist/KeySerialization.d.ts +21 -0
  38. package/dist/KeySerialization.d.ts.map +1 -0
  39. package/dist/KeySerialization.js +29 -0
  40. package/dist/KeySerialization.js.map +1 -0
  41. package/dist/MatchEngine.d.ts.map +1 -1
  42. package/dist/MatchEngine.js +12 -4
  43. package/dist/MatchEngine.js.map +1 -1
  44. package/dist/RecordFlatten.d.ts +49 -0
  45. package/dist/RecordFlatten.d.ts.map +1 -0
  46. package/dist/RecordFlatten.js +50 -0
  47. package/dist/RecordFlatten.js.map +1 -0
  48. package/dist/StreamingDiscovery.d.ts +145 -0
  49. package/dist/StreamingDiscovery.d.ts.map +1 -0
  50. package/dist/StreamingDiscovery.js +306 -0
  51. package/dist/StreamingDiscovery.js.map +1 -0
  52. package/dist/SyncLogger.d.ts +1 -1
  53. package/dist/SyncLogger.d.ts.map +1 -1
  54. package/dist/SyncLogger.js +19 -1
  55. package/dist/SyncLogger.js.map +1 -1
  56. package/dist/auth-helpers/BasicAuthHeaderBuilder.d.ts +36 -0
  57. package/dist/auth-helpers/BasicAuthHeaderBuilder.d.ts.map +1 -0
  58. package/dist/auth-helpers/BasicAuthHeaderBuilder.js +38 -0
  59. package/dist/auth-helpers/BasicAuthHeaderBuilder.js.map +1 -0
  60. package/dist/auth-helpers/OAuth1aSigner.d.ts +56 -0
  61. package/dist/auth-helpers/OAuth1aSigner.d.ts.map +1 -0
  62. package/dist/auth-helpers/OAuth1aSigner.js +91 -0
  63. package/dist/auth-helpers/OAuth1aSigner.js.map +1 -0
  64. package/dist/auth-helpers/OAuth2TokenManager.d.ts +103 -0
  65. package/dist/auth-helpers/OAuth2TokenManager.d.ts.map +1 -0
  66. package/dist/auth-helpers/OAuth2TokenManager.js +143 -0
  67. package/dist/auth-helpers/OAuth2TokenManager.js.map +1 -0
  68. package/dist/auth-helpers/index.d.ts +11 -0
  69. package/dist/auth-helpers/index.d.ts.map +1 -0
  70. package/dist/auth-helpers/index.js +8 -0
  71. package/dist/auth-helpers/index.js.map +1 -0
  72. package/dist/index.d.ts +15 -1
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +9 -0
  75. package/dist/index.js.map +1 -1
  76. package/dist/types.d.ts +73 -0
  77. package/dist/types.d.ts.map +1 -1
  78. package/package.json +7 -7
@@ -1,13 +1,17 @@
1
- import { CompositeKey, Metadata, RunView } from '@memberjunction/core';
1
+ import { CompositeKey, LogStatusEx, Metadata, RunView } from '@memberjunction/core';
2
2
  import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
3
3
  import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
4
- import { ClassifyError } from './types.js';
4
+ import { ClassifyError, IsRetryableError } from './types.js';
5
+ import { WithRetry } from './RetryRunner.js';
6
+ import { WithTimeout, DEFAULT_OPERATION_TIMEOUTS } from './BaseIntegrationConnector.js';
5
7
  import { ConnectorFactory } from './ConnectorFactory.js';
6
8
  import { FieldMappingEngine } from './FieldMappingEngine.js';
7
9
  import { MatchEngine } from './MatchEngine.js';
8
10
  import { WatermarkService } from './WatermarkService.js';
9
11
  import { SyncLogger } from './SyncLogger.js';
10
- import { CONTENT_HASH_COLUMN, computeContentHash } from './ContentHash.js';
12
+ import { CONTENT_HASH_COLUMN, computeContentHashWithOverflow, contentHashBasis } from './ContentHash.js';
13
+ import { serializeKeyValue } from './KeySerialization.js';
14
+ import { CUSTOM_OVERFLOW_COLUMN, hasUnmappedFields } from './CustomOverflow.js';
11
15
  import { partitionRecords, partitionRollupHash, diffPartitions, partitionKeyForIdentity } from './HashDiff.js';
12
16
  import { RateLimiter } from './RateLimiter.js';
13
17
  import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrency.js';
@@ -15,6 +19,37 @@ import { mostRecentWinner } from './ConflictRecency.js';
15
19
  import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
16
20
  /** Default batch size for fetching records from external systems */
17
21
  const DEFAULT_BATCH_SIZE = 200;
22
+ /**
23
+ * Hard ceiling on records the opt-in partitionReconcile (Merkle) mode may accumulate in RAM
24
+ * before it fails loud rather than risking an OOM crash. That mode buffers the ENTIRE fetched
25
+ * set (see applyViaPartitionReconcile) — intended for watermark-less small/medium objects. This
26
+ * cap is generous (well above the "tens of thousands" the mode targets); crossing it means the
27
+ * object is too large for partitionReconcile and the operator should disable it (the default
28
+ * streaming path has no such limit). Turns a silent OOM into an actionable error.
29
+ */
30
+ const PARTITION_RECONCILE_MAX_ACCUMULATION = 500_000;
31
+ /**
32
+ * Ceiling on the in-memory set of fetched ExternalIDs used for full-sync orphan detection. Past this,
33
+ * we stop tracking + skip the orphan sweep for that entity-map (surfaced as a SyncWarning) rather than
34
+ * grow the set unbounded and risk OOM on a multi-million-row full sync. Incremental syncs don't build
35
+ * this set; only full-sync / partition-reconcile do. Generous on purpose — most objects never hit it.
36
+ */
37
+ const ORPHAN_DETECTION_MAX_IDS = 1_000_000;
38
+ /**
39
+ * Cap on the retained per-record error SAMPLE in the aggregate run result. RecordsErrored keeps the
40
+ * true count; only this many error objects are kept for diagnostics (FinalizeRun persists the first
41
+ * 100). Prevents a multi-million-row failing run from holding every error object in RAM.
42
+ */
43
+ const MAX_AGGREGATE_ERRORS = 200;
44
+ /**
45
+ * In-memory safety ceiling for a FULL push (every row of an MJ entity is loaded into RAM before
46
+ * pushing). Past this we fail loud + actionable instead of risking an OOM. Env-overridable for the
47
+ * rare legitimate large full push. The proper long-term fix is a streaming/keyset push.
48
+ */
49
+ const FULL_PUSH_MAX_RECORDS = (() => {
50
+ const override = Number(process.env.MJ_INTEGRATION_FULL_PUSH_MAX_RECORDS);
51
+ return Number.isFinite(override) && override > 0 ? Math.floor(override) : 1_000_000;
52
+ })();
18
53
  /**
19
54
  * Server-side Integration Engine.
20
55
  *
@@ -50,6 +85,50 @@ export class SchemaNotGeneratedError extends Error {
50
85
  this.StoredProcedureName = storedProcedureName;
51
86
  }
52
87
  }
88
+ /**
89
+ * §29 — base of the VALUE-FIT error family: a mapped value cannot fit/coerce into its destination
90
+ * column's type. Per the bounded-typing policy (small columns; large content is an explicit text/json
91
+ * modality), such a value is NEVER truncated/clamped and NEVER widens the column — the offending
92
+ * RECORD is skipped and surfaced as a structured SyncWarning so the gap is visible, not silent, and
93
+ * the rest of the batch still commits. Caught per-record in ApplySingleRecord (catches the base, so
94
+ * every family member is handled uniformly). Add a subclass for each new fit failure mode.
95
+ */
96
+ export class ValueFitError extends Error {
97
+ constructor(message, fieldName) {
98
+ super(message);
99
+ this.name = new.target.name;
100
+ this.FieldName = fieldName;
101
+ }
102
+ }
103
+ /** A string value wider than its bounded NVARCHAR column. */
104
+ export class StringOverflowError extends ValueFitError {
105
+ constructor(fieldName, valueLength, maxLength) {
106
+ super(`Value for '${fieldName}' is ${valueLength} chars, exceeding the column width ${maxLength}; record skipped (not truncated).`, fieldName);
107
+ this.WarningCode = 'STRING_OVERFLOW_SKIPPED';
108
+ this.ValueLength = valueLength;
109
+ this.MaxLength = maxLength;
110
+ }
111
+ Details() { return { valueLength: this.ValueLength, maxLength: this.MaxLength }; }
112
+ }
113
+ /** A numeric value outside its integer column's representable range (e.g. > INT max → would sink the batch at bind time). */
114
+ export class NumericOverflowError extends ValueFitError {
115
+ constructor(fieldName, value, sqlType) {
116
+ super(`Value for '${fieldName}' (${value}) is outside the range of its ${sqlType} column; record skipped (not clamped).`, fieldName);
117
+ this.WarningCode = 'NUMERIC_OVERFLOW_SKIPPED';
118
+ this.Value = value;
119
+ this.SqlType = sqlType;
120
+ }
121
+ Details() { return { value: this.Value, sqlType: this.SqlType }; }
122
+ }
123
+ /** §29 — integer SQL-type ranges (JS-number-comparable). BIGINT bounds exceed 2^53 so the check is a
124
+ * no-op there (any JS number already fits); the real overflow risk is INT/SMALLINT/TINYINT. */
125
+ const INTEGER_SQL_BOUNDS = {
126
+ tinyint: { min: 0, max: 255 },
127
+ smallint: { min: -32768, max: 32767 },
128
+ int: { min: -2147483648, max: 2147483647 },
129
+ integer: { min: -2147483648, max: 2147483647 },
130
+ bigint: { min: -9223372036854775808, max: 9223372036854775807 },
131
+ };
53
132
  /**
54
133
  * Returns a SchemaNotGeneratedError if the given Save() failure message matches the
55
134
  * "CRUD routine doesn't exist yet" pattern for either dialect, otherwise null. When
@@ -76,17 +155,38 @@ export class IntegrationEngine extends BaseSingleton {
76
155
  this.fieldMappingEngine = new FieldMappingEngine();
77
156
  this.matchEngine = new MatchEngine();
78
157
  this.watermarkService = new WatermarkService();
158
+ /**
159
+ * Per-engine async mutex serializing the DB-WRITE section across concurrently-synced streams.
160
+ * When a layer runs multiple entity maps in parallel (syncConcurrency > 1), they all share ONE
161
+ * provider connection whose transaction state is singular — so concurrent BeginTransaction /
162
+ * SavePoint / Commit calls corrupt each other ("Transaction has not begun", "Cannot roll back
163
+ * SavePoint"). The fetch phase stays parallel (the real throughput win — it's network-bound);
164
+ * only the per-batch write transaction is serialized through this lock. Keyed per engine
165
+ * instance, which owns the shared provider.
166
+ */
167
+ this._writeChain = Promise.resolve();
79
168
  /** Configurable maximum batch size. Connector batches exceeding this are truncated. */
80
169
  this.MaxBatchSize = DEFAULT_BATCH_SIZE;
81
170
  /** Per-integration request-spacing chain for the rate limiter (keyed by IntegrationID → last scheduled time). */
82
171
  this._rateLimiters = new Map();
83
172
  }
173
+ /** Registers (or clears, with undefined) the post-sync custom-column promotion hook. */
174
+ SetPostSyncSchemaPromotionCallback(callback) {
175
+ this.postSyncSchemaPromotionCallback = callback;
176
+ }
84
177
  /** Returns the active provider — explicit override if set, otherwise the global default. */
85
178
  get ProviderToUse() {
86
179
  return this._provider ?? Metadata.Provider;
87
180
  }
88
181
  /** In-process lock map to prevent concurrent syncs for the same CompanyIntegration */
89
182
  static { this.activeSyncs = new Map(); }
183
+ runWriteExclusive(fn) {
184
+ // Run fn after the prior write completes (whether it resolved or rejected); keep the chain
185
+ // alive past failures so one errored batch never deadlocks subsequent writers.
186
+ const run = this._writeChain.then(() => fn(), () => fn());
187
+ this._writeChain = run.then(() => undefined, () => undefined);
188
+ return run;
189
+ }
90
190
  /** Abort controllers for cancelling running syncs */
91
191
  static { this._abortControllers = new Map(); }
92
192
  /** Live sync progress — updated on every batch for ALL syncs regardless of caller */
@@ -130,13 +230,28 @@ export class IntegrationEngine extends BaseSingleton {
130
230
  BypassCache: true, // resume must see the live in-progress runs, not a stale cache
131
231
  }, contextUser);
132
232
  if (!orphanedRuns.Success || orphanedRuns.Results.length === 0) {
133
- console.log('[IntegrationEngine] No orphaned syncs to resume');
233
+ // Nothing to resume is the steady-state at every boot — verbose-only so it doesn't clutter the
234
+ // startup log. Only an ACTUAL resume (the "Found N…" path below) is worth showing by default.
235
+ LogStatusEx({ message: '[IntegrationEngine] No orphaned syncs to resume', verboseOnly: true });
134
236
  return;
135
237
  }
136
238
  console.log(`[IntegrationEngine] Found ${orphanedRuns.Results.length} orphaned sync(s) to resume`);
137
239
  for (const run of orphanedRuns.Results) {
138
240
  const companyIntegrationID = run.CompanyIntegrationID;
139
241
  const runID = run.ID;
242
+ const lockKey = companyIntegrationID.toLowerCase();
243
+ // C1: respect the SAME in-process concurrency lock RunSync uses. If a live sync for this
244
+ // CompanyIntegration is already running (e.g. the scheduler fired during startup), skip the
245
+ // resume — double-running one CI on the shared provider connection corrupts its singular
246
+ // transaction state (exactly what runWriteExclusive guards against WITHIN a run). The
247
+ // get→set pair below has no await between them, so check-and-reserve is atomic on the loop.
248
+ if (IntegrationEngine.activeSyncs.get(lockKey)) {
249
+ console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — a live sync for ${lockKey} is already running`);
250
+ continue;
251
+ }
252
+ let resolveResumeLock;
253
+ let resumeResult;
254
+ IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
140
255
  try {
141
256
  // Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
142
257
  // by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
@@ -182,6 +297,7 @@ export class IntegrationEngine extends BaseSingleton {
182
297
  const result = await this.ExecuteEntityMaps(config, run, contextUser);
183
298
  result.RunID = runID;
184
299
  await this.FinalizeRun(run, result, contextUser);
300
+ resumeResult = result;
185
301
  console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
186
302
  `${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
187
303
  `${result.RecordsErrored} errored`);
@@ -195,6 +311,17 @@ export class IntegrationEngine extends BaseSingleton {
195
311
  run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
196
312
  await run.Save();
197
313
  }
314
+ finally {
315
+ // Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
316
+ // `existing`). Resolve with the real result when we have one, else a benign empty result so no
317
+ // waiter hangs. Promise resolve is idempotent and the early-exit `continue` also lands here.
318
+ IntegrationEngine.activeSyncs.delete(lockKey);
319
+ resolveResumeLock(resumeResult ?? {
320
+ Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
321
+ RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
322
+ RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
323
+ });
324
+ }
198
325
  }
199
326
  }
200
327
  /**
@@ -332,6 +459,28 @@ export class IntegrationEngine extends BaseSingleton {
332
459
  result.ErrorMessage = `Sync completed with ${result.RecordsErrored} error(s)`;
333
460
  }
334
461
  await this.FinalizeRun(run, result, contextUser, onNotification, abortSignal?.aborted);
462
+ // Post-sync custom-column promotion (gaps.md §2 / M2). Self-gated server-side: a
463
+ // customs-free sync does no work. Skipped for an aborted run. Never throws into the sync.
464
+ if (!abortSignal?.aborted) {
465
+ result.SchemaUpdate = await this.invokePostSyncPromotionSafe(companyIntegrationID, contextUser, result);
466
+ // Restart-signal (M3): when columns were promoted, surface it on the structured
467
+ // stream so a watching client (IntegrationTailRunEvents) knows an MJAPI restart is
468
+ // needed to expose the new columns over GraphQL — read as intentional, not a crash.
469
+ // No new columns ⇒ no event ⇒ no restart (the convergence/1× guarantee). The new
470
+ // columns are already usable by the NEXT sync without a restart (metadata refreshed).
471
+ if (result.SchemaUpdate?.SchemaUpdatePending) {
472
+ logger.emit('sync.schema_update', {
473
+ columnsAdded: result.SchemaUpdate.ColumnsAdded,
474
+ restartRequiredForGraphQL: true,
475
+ });
476
+ }
477
+ // Surface any non-fatal promotion problems on the structured stream so the operator
478
+ // sees what DIDN'T promote (RSU/DDL/save failure, per-pass cap deferral, missing map) —
479
+ // promotion never fails the sync, but a swallowed problem must not be invisible (§4).
480
+ for (const w of result.SchemaUpdate?.Warnings ?? []) {
481
+ logger.warning('schema-promotion', 'CUSTOM_COLUMN_PROMOTION_WARNING', w);
482
+ }
483
+ }
335
484
  const summary = this.buildSyncResultBody(config.companyIntegration.Integration, result);
336
485
  logger.emit('sync.run.complete', {
337
486
  success: result.Success && result.RecordsErrored === 0,
@@ -565,7 +714,7 @@ export class IntegrationEngine extends BaseSingleton {
565
714
  // single-threaded async, so concurrent maps in a layer are safe.
566
715
  const processOne = async (entityMap) => {
567
716
  if (abortSignal?.aborted)
568
- return true;
717
+ return { ok: true, throttled: false };
569
718
  const i = globalIndex++;
570
719
  const mapStartTime = Date.now();
571
720
  const direction = config.syncDirection ?? entityMap.SyncDirection ?? 'Pull';
@@ -595,7 +744,7 @@ export class IntegrationEngine extends BaseSingleton {
595
744
  recordsErrored: mapResult.RecordsErrored,
596
745
  });
597
746
  this.checkSecondLayerEmpty(entityMap, mapResult, depGraph, processedByIoId, ioNameById, ioCategoryById, logger);
598
- return mapResult.Success;
747
+ return { ok: mapResult.Success, throttled: mapResult.Throttled === true };
599
748
  }
600
749
  catch (err) {
601
750
  const objName = entityMap.ExternalObjectName ?? entityMap.ID;
@@ -631,7 +780,7 @@ export class IntegrationEngine extends BaseSingleton {
631
780
  RecordsSkipped: 0,
632
781
  Duration: Date.now() - mapStartTime,
633
782
  });
634
- return false;
783
+ return { ok: false, throttled: ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED' };
635
784
  }
636
785
  };
637
786
  // Group maps into dependency layers (parents before children) via the IntegrationObject FK
@@ -646,7 +795,8 @@ export class IntegrationEngine extends BaseSingleton {
646
795
  // MaxConcurrencyHint on clean maps, cut on map failure. With no hint and default
647
796
  // syncConcurrency=1, min=max=1 → strictly sequential (unchanged behavior). The per-request
648
797
  // RateLimiter is the backstop that keeps the source within its real rate as parallelism rises.
649
- const maxConcurrency = Math.max(concurrency, config.connector.MaxConcurrencyHint ?? concurrency);
798
+ // Configuration override (IntegrationSetSyncConfig) wins over the connector's MaxConcurrencyHint constant.
799
+ const maxConcurrency = Math.max(concurrency, this.getConfigOverrides(config).maxConcurrency ?? config.connector.MaxConcurrencyHint ?? concurrency);
650
800
  const concController = new AdaptiveConcurrencyController({ start: concurrency, min: 1, max: maxConcurrency });
651
801
  // Second-layer silent-empty detection state (see checkSecondLayerEmpty): a per-IO running
652
802
  // record count + the FK dependency graph, so an association/dependent object that fetches
@@ -663,6 +813,19 @@ export class IntegrationEngine extends BaseSingleton {
663
813
  ioCategoryById.set(io.ID.toUpperCase(), io.Category);
664
814
  }
665
815
  }
816
+ // §4 OPT-IN: cross-layer pipelining overlaps independent DAG branches (a child starts when ITS
817
+ // parents finish, not when the whole parent layer does). Default OFF → the strict layer-barrier
818
+ // loop below (unchanged). Falls back to the barrier if the per-map dependency graph can't build.
819
+ const mapDeps = this.getCrossLayerPipelineEnabled(config) ? this.buildMapDependencies(config) : null;
820
+ if (mapDeps) {
821
+ logger?.emit('sync.config.loaded', { crossLayerPipeline: true, totalMaps });
822
+ await this.runPipelinedDAG(config.entityMaps, mapDeps, concController, processOne, abortSignal);
823
+ if (abortSignal?.aborted) {
824
+ aggregate.Success = false;
825
+ aggregate.ErrorMessage = 'Sync cancelled by user';
826
+ }
827
+ return aggregate;
828
+ }
666
829
  for (const layer of layers) {
667
830
  if (abortSignal?.aborted) {
668
831
  console.log(`[IntegrationEngine] Sync cancelled (${globalIndex}/${totalMaps} maps processed)`);
@@ -673,12 +836,12 @@ export class IntegrationEngine extends BaseSingleton {
673
836
  await RunAdaptive(layer, async (m) => {
674
837
  if (abortSignal?.aborted)
675
838
  return { ok: true, throttled: false };
676
- const ok = await processOne(m);
677
- // A data failure (FK/validation/transform) must NOT cut the in-flight cap — only a
678
- // real source throttle should, and the per-request RateLimiter already backs off on
679
- // 429s. We have no clean per-map 429 signal here (fetch 429s break the loop), so we
680
- // never mark `throttled` from a plain failure; concurrency only ramps UP on success.
681
- return { ok, throttled: false };
839
+ const { ok, throttled } = await processOne(m);
840
+ // §5 Gap 2: a real source throttle (RATE_LIMIT_EXCEEDED on fetch) now also cuts the
841
+ // per-layer in-flight cap, not just the per-request token bucket. A plain data failure
842
+ // (FK/validation/transform) carries throttled=false, so concurrency only drops on an
843
+ // actual rate-limit signal — never on ordinary record errors.
844
+ return { ok, throttled };
682
845
  }, concController);
683
846
  }
684
847
  return aggregate;
@@ -753,8 +916,11 @@ export class IntegrationEngine extends BaseSingleton {
753
916
  if (!ios || ios.length === 0)
754
917
  return null;
755
918
  const ioByName = new Map(); // lower(IO.Name) → IO.ID (upper)
756
- for (const io of ios)
919
+ const ioById = new Map(); // IO.ID (upper) → IO
920
+ for (const io of ios) {
757
921
  ioByName.set(io.Name.toLowerCase(), io.ID.toUpperCase());
922
+ ioById.set(io.ID.toUpperCase(), io);
923
+ }
758
924
  const mapToIoId = new Map(); // entityMap.ID → IO.ID
759
925
  const selectedIoIds = new Set();
760
926
  for (const m of config.entityMaps) {
@@ -769,11 +935,30 @@ export class IntegrationEngine extends BaseSingleton {
769
935
  const parentsByIoId = new Map();
770
936
  for (const ioId of selectedIoIds) {
771
937
  const set = new Set();
938
+ // (1) hard FK pointer on a field (RelatedIntegrationObjectID).
772
939
  for (const f of this.GetIntegrationObjectFields(ioId)) {
773
940
  const parent = f.RelatedIntegrationObjectID?.toUpperCase();
774
941
  if (parent && parent !== ioId && selectedIoIds.has(parent))
775
942
  set.add(parent);
776
943
  }
944
+ // (2) SOFT-FK form (parent-iterated children): the parent is named in the IO's
945
+ // Configuration (parentObjectName / ReferencedType), NOT via RelatedIntegrationObjectID —
946
+ // which is null for soft-FK connectors. Without this, nulling the FK pointer collapses the
947
+ // dependency graph → children run in layer 0 alongside their door → ZERO_PARENTS on the
948
+ // first sync (door not yet populated). Resolve the parent name → its IO id so doors are
949
+ // ordered before their children in a SINGLE pass.
950
+ const cfgRaw = ioById.get(ioId)?.Configuration;
951
+ if (cfgRaw) {
952
+ try {
953
+ const cfg = JSON.parse(cfgRaw);
954
+ for (const name of [cfg.parentObjectName, cfg.ReferencedType]) {
955
+ const parent = name ? ioByName.get(name.toLowerCase()) : undefined;
956
+ if (parent && parent !== ioId && selectedIoIds.has(parent))
957
+ set.add(parent);
958
+ }
959
+ }
960
+ catch { /* non-JSON Configuration → no soft-FK parent to add */ }
961
+ }
777
962
  parentsByIoId.set(ioId, set);
778
963
  }
779
964
  return { mapToIoId, parentsByIoId };
@@ -831,6 +1016,101 @@ export class IntegrationEngine extends BaseSingleton {
831
1016
  catch { /* fall through */ }
832
1017
  return 1;
833
1018
  }
1019
+ /**
1020
+ * §4 OPT-IN cross-layer pipelining (CompanyIntegration.Configuration {"crossLayerPipeline": true}).
1021
+ * Default OFF → strict layer-barrier execution (unchanged). When ON, a child map starts as soon as
1022
+ * ITS OWN parents finish, instead of waiting for the entire parent LAYER — overlapping independent
1023
+ * branches of the DAG. Correctness is preserved because a child still gates on parent COMPLETION
1024
+ * (the parent's row writes are committed and its record-count recorded before the child begins).
1025
+ * Off by default because the throughput win is only measurable against a live run.
1026
+ */
1027
+ getCrossLayerPipelineEnabled(config) {
1028
+ try {
1029
+ const raw = config.companyIntegration.Configuration;
1030
+ if (raw)
1031
+ return JSON.parse(raw).crossLayerPipeline === true;
1032
+ }
1033
+ catch { /* fall through */ }
1034
+ return false;
1035
+ }
1036
+ /**
1037
+ * Per-map parent dependencies (entityMap.ID → set of parent entityMap.IDs) from the FK graph.
1038
+ * Used by the §4 pipelined scheduler. Returns null when the graph can't be resolved (caller then
1039
+ * falls back to layer-barrier execution).
1040
+ */
1041
+ buildMapDependencies(config) {
1042
+ const graph = this.computeSelectedDependencyGraph(config);
1043
+ if (!graph)
1044
+ return null;
1045
+ const { mapToIoId, parentsByIoId } = graph;
1046
+ // Invert mapToIoId: IO.ID → the maps targeting it (an IO can back more than one map).
1047
+ const mapsByIoId = new Map();
1048
+ for (const [mapId, ioId] of mapToIoId) {
1049
+ if (!mapsByIoId.has(ioId))
1050
+ mapsByIoId.set(ioId, []);
1051
+ mapsByIoId.get(ioId).push(mapId);
1052
+ }
1053
+ const deps = new Map();
1054
+ for (const m of config.entityMaps) {
1055
+ const ioId = mapToIoId.get(m.ID);
1056
+ const parentMapIds = new Set();
1057
+ if (ioId) {
1058
+ for (const parentIoId of parentsByIoId.get(ioId) ?? []) {
1059
+ for (const pm of mapsByIoId.get(parentIoId) ?? []) {
1060
+ if (!UUIDsEqual(pm, m.ID))
1061
+ parentMapIds.add(pm);
1062
+ }
1063
+ }
1064
+ }
1065
+ deps.set(m.ID, parentMapIds);
1066
+ }
1067
+ return deps;
1068
+ }
1069
+ /**
1070
+ * §4 dependency-aware scheduler: runs every map concurrently, but each map first awaits its
1071
+ * parents' completion, THEN gates on the live AIMD cap before doing work. Deadlock-free even at
1072
+ * cap=1: a map awaiting parents does NOT hold a concurrency slot (the slot is acquired only AFTER
1073
+ * the parent-await resolves), so parents always get to run. The cap gate counts only ACTIVE work.
1074
+ */
1075
+ async runPipelinedDAG(maps, mapDeps, controller, process, abortSignal) {
1076
+ const resolvers = new Map();
1077
+ const donePromises = new Map();
1078
+ for (const m of maps)
1079
+ donePromises.set(m.ID, new Promise(r => resolvers.set(m.ID, r)));
1080
+ const inFlight = { count: 0 };
1081
+ const scheduleOne = async (m) => {
1082
+ try {
1083
+ const deps = mapDeps.get(m.ID);
1084
+ if (deps && deps.size > 0) {
1085
+ await Promise.all([...deps].map(d => donePromises.get(d)).filter((p) => !!p));
1086
+ }
1087
+ if (abortSignal?.aborted)
1088
+ return;
1089
+ // Gate on the LIVE cap (yield on a macrotask so in-flight I/O isn't starved — mirrors
1090
+ // adaptiveWorker). Only ACTIVE work counts toward the cap; waiting-on-parents does not.
1091
+ while (inFlight.count >= controller.Cap) {
1092
+ await new Promise(r => setTimeout(r, 0));
1093
+ if (abortSignal?.aborted)
1094
+ return;
1095
+ }
1096
+ inFlight.count++;
1097
+ try {
1098
+ const outcome = await process(m);
1099
+ if (outcome.throttled || !outcome.ok)
1100
+ controller.OnThrottleOrError();
1101
+ else
1102
+ controller.OnSuccess();
1103
+ }
1104
+ finally {
1105
+ inFlight.count--;
1106
+ }
1107
+ }
1108
+ finally {
1109
+ resolvers.get(m.ID)(); // unblock children whether we ran, aborted, or threw
1110
+ }
1111
+ };
1112
+ await Promise.all(maps.map(m => scheduleOne(m)));
1113
+ }
834
1114
  /** Runs `fn` over items with at most `cap` concurrent executions. cap<=1 → strictly sequential. */
835
1115
  async runBounded(items, cap, fn) {
836
1116
  if (cap <= 1) {
@@ -878,19 +1158,49 @@ export class IntegrationEngine extends BaseSingleton {
878
1158
  * the same vendor have independent budgets and must not share one bucket. {@link reportRateOutcome}
879
1159
  * feeds 429s/successes back so the rate auto-tunes (AIMD).
880
1160
  */
1161
+ /**
1162
+ * Per-connection numeric tuning overrides from CompanyIntegration.Configuration — the typed
1163
+ * fields the IntegrationSetSyncConfig GraphQL mutation writes. These let an operator override
1164
+ * the connector's code-constant rate limit / concurrency / discovery budget per connection,
1165
+ * via the API, instead of editing code. Only positive finite values are honored.
1166
+ */
1167
+ getConfigOverrides(config) {
1168
+ try {
1169
+ const raw = config.companyIntegration.Configuration;
1170
+ if (!raw)
1171
+ return {};
1172
+ const p = JSON.parse(raw);
1173
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined);
1174
+ return {
1175
+ maxConcurrency: num(p.maxConcurrency),
1176
+ rateLimitTokensPerSec: typeof p.rateLimitTokensPerSec === 'number' && p.rateLimitTokensPerSec > 0 ? p.rateLimitTokensPerSec : undefined,
1177
+ rateLimitBurst: num(p.rateLimitBurst),
1178
+ discoveryTimeBudgetMs: num(p.discoveryTimeBudgetMs),
1179
+ };
1180
+ }
1181
+ catch {
1182
+ return {};
1183
+ }
1184
+ }
881
1185
  getRateLimiter(config) {
882
1186
  const key = config.companyIntegration.ID;
883
1187
  let rl = this._rateLimiters.get(key);
884
1188
  if (!rl) {
885
1189
  const policy = config.connector.RateLimitPolicy;
1190
+ const overrides = this.getConfigOverrides(config);
886
1191
  const spacingMs = this.getRequestSpacingMs(config);
887
- const tokensPerSec = policy?.TokensPerSec ?? (spacingMs > 0 ? 1000 / spacingMs : 10);
1192
+ // Configuration override wins over the connector's code constant (the "not just constants" goal).
1193
+ const tokensPerSec = overrides.rateLimitTokensPerSec ?? policy?.TokensPerSec ?? (spacingMs > 0 ? 1000 / spacingMs : 10);
888
1194
  rl = new RateLimiter({
889
1195
  TokensPerSec: tokensPerSec,
890
1196
  // Floor Burst at 1 so a slow-spacing integration (fractional tokens/sec) still gets
891
1197
  // one immediate token instead of stalling ~1s on the very first request.
892
- Burst: Math.max(1, policy?.Burst ?? Math.ceil(tokensPerSec)),
1198
+ Burst: Math.max(1, overrides.rateLimitBurst ?? policy?.Burst ?? Math.ceil(tokensPerSec)),
893
1199
  ThrottleBackoffFactor: policy?.ThrottleBackoffFactor,
1200
+ // Thread the connector's recovery tuning through; RateLimiter applies its own sane
1201
+ // defaults (ramp = rate/10, floor = rate/20) when these are omitted.
1202
+ SuccessRampPerCall: policy?.SuccessRampPerCall,
1203
+ MinTokensPerSec: policy?.MinTokensPerSec,
894
1204
  });
895
1205
  this._rateLimiters.set(key, rl);
896
1206
  }
@@ -951,7 +1261,7 @@ export class IntegrationEngine extends BaseSingleton {
951
1261
  async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
952
1262
  const entityMapID = entityMap.ID;
953
1263
  const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
954
- const watermark = await this.watermarkService.Load(entityMapID, contextUser, 'Pull');
1264
+ const watermark = await this.runWriteExclusive(() => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
955
1265
  logger?.emit('sync.entity-map.start', {
956
1266
  phase: 'pull-detail',
957
1267
  externalObjectName: entityMap.ExternalObjectName,
@@ -1034,12 +1344,14 @@ export class IntegrationEngine extends BaseSingleton {
1034
1344
  };
1035
1345
  let hasMore = true;
1036
1346
  let currentWatermark = initialWatermark;
1037
- // Safe-floor watermark for data-loss prevention: the high-water mark of the last FULLY-CLEAN batch
1038
- // (zero errored records). If any batch rolls back, we persist THIS instead of currentWatermark so the
1039
- // next incremental re-fetches the window covering the failed records (the idempotent upsert + content-
1040
- // hash skip then reconcile them). Without this, errored-then-rolled-back records fall below the saved
1041
- // watermark and are never re-fetched — silent permanent data loss.
1042
- let lastCleanWatermark = initialWatermark;
1347
+ // §10 — the watermark ALWAYS advances to the max value seen on a clean fetch; it is NEVER held back
1348
+ // by a per-record failure. A failed record is classified: a PROVABLY-TRANSIENT save error is retried
1349
+ // inline within the batch (ApplyRecords); a PERMANENT (or retry-exhausted) failure is dead-lettered —
1350
+ // counted errored, logged to result.Errors (queryable over GraphQL), and the sync moves on. Holding
1351
+ // the watermark at the last-clean batch was the old safe-floor; it poison-pilled the stream when an
1352
+ // EARLY record failed permanently (the whole window re-fetched + re-failed every run, watermark frozen
1353
+ // at the start). Recovery for dead-lettered records is an operator-triggered full sync (which ignores
1354
+ // the watermark). The run still reports Status='Failed' whenever ANY record errored.
1043
1355
  let recordsInMap = 0;
1044
1356
  let currentPage;
1045
1357
  let currentOffset;
@@ -1048,8 +1360,14 @@ export class IntegrationEngine extends BaseSingleton {
1048
1360
  let batchCount = 0;
1049
1361
  let previousBatchFingerprint;
1050
1362
  let fetchCompletedCleanly = true; // flipped to false if fetch aborted or errored mid-way
1363
+ let hadFetchGap = false; // ≥1 page was skipped after a persistent fetch error (offset/page paging)
1364
+ let fetchGapCount = 0; // CONSECUTIVE skipped pages (reset on any clean fetch)
1365
+ const MAX_FETCH_GAPS = 25; // give up + hold the watermark if this many pages fail in a row (API down)
1366
+ let consecutiveEmptyBatches = 0; // P3-D: detect a connector that pages empty-but-HasMore forever
1051
1367
  const MAX_BATCHES_PER_MAP = 5000;
1368
+ const EMPTY_BATCH_WARN_THRESHOLD = 5; // warn once after this many empty-but-HasMore batches in a row
1052
1369
  const fetchedExternalIDs = new Set(); // Track all IDs seen during this pull for orphan detection
1370
+ let orphanTrackingOverflowed = false; // set if the ID set exceeds ORPHAN_DETECTION_MAX_IDS → skip the sweep, don't OOM
1053
1371
  const accumulatedMapped = []; // partition-reconcile mode: collect mapped records, apply post-loop
1054
1372
  while (hasMore) {
1055
1373
  if (abortSignal?.aborted) {
@@ -1074,6 +1392,13 @@ export class IntegrationEngine extends BaseSingleton {
1074
1392
  CurrentOffset: currentOffset,
1075
1393
  CurrentCursor: currentCursor,
1076
1394
  AfterKeyValue: currentAfterKey ?? null, // §7 keyset/seek resume (connector opt-in)
1395
+ // §7: expose the per-credential adaptive AIMD bucket + concurrency cap so a connector's
1396
+ // INNER request loop (second-layer / parent-iterated objects) is governed by the SAME
1397
+ // adaptive rate as the object level, instead of a fixed self-throttle that defeats
1398
+ // concurrency and ignores 429 back-off. Back-compat: connectors that ignore these are unchanged.
1399
+ RateLimitAcquire: () => this.rateLimit(config),
1400
+ RateLimitReport: (throttledErr) => this.reportRateOutcome(config, throttledErr),
1401
+ MaxConcurrency: Math.max(1, this.getConfigOverrides(config).maxConcurrency ?? config.connector.MaxConcurrencyHint ?? 4),
1077
1402
  };
1078
1403
  logger?.emit('sync.fetch.batch.start', {
1079
1404
  externalObjectName: entityMap.ExternalObjectName,
@@ -1088,8 +1413,18 @@ export class IntegrationEngine extends BaseSingleton {
1088
1413
  const fetchStart = Date.now();
1089
1414
  try {
1090
1415
  await this.rateLimit(config);
1091
- batch = await config.connector.FetchChanges(ctx);
1416
+ // Resilient fetch: bound each attempt with a timeout (a hung vendor API must not
1417
+ // hold the sync lock forever) and retry only transient errors (network/throttle/DB).
1418
+ // A non-retryable error (auth, 4xx, parse) throws immediately as before.
1419
+ batch = await WithRetry(() => WithTimeout(config.connector.FetchChanges(ctx), DEFAULT_OPERATION_TIMEOUTS.FetchChangesMs, `FetchChanges(${entityMap.ExternalObjectName})`), undefined, (err) => IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => logger?.emit('sync.fetch.retry', {
1420
+ externalObjectName: entityMap.ExternalObjectName,
1421
+ batchIndex: batchCount,
1422
+ attempt,
1423
+ delayMs,
1424
+ error: err instanceof Error ? err.message : String(err),
1425
+ }));
1092
1426
  this.reportRateOutcome(config); // clean fetch → ramp the adaptive rate back up
1427
+ fetchGapCount = 0; // clean fetch → reset the consecutive fetch-gap counter
1093
1428
  // §10: connector type-driven post-processing hook (default no-op) — enforce/normalize
1094
1429
  // record values to their resolved formats before mapping + write.
1095
1430
  if (batch.Records.length > 0) {
@@ -1099,9 +1434,12 @@ export class IntegrationEngine extends BaseSingleton {
1099
1434
  catch (fetchErr) {
1100
1435
  const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
1101
1436
  // A throttle (429 / rate-limit) backs the adaptive limiter off (honoring Retry-After);
1102
- // other errors don't touch the rate.
1103
- if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED')
1437
+ // other errors don't touch the rate. §5 Gap 2: also flag the map result so the per-layer
1438
+ // AIMD controller reduces in-flight concurrency, not just the per-request token bucket.
1439
+ if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED') {
1104
1440
  this.reportRateOutcome(config, fetchErr);
1441
+ result.Throttled = true;
1442
+ }
1105
1443
  console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
1106
1444
  logger?.emit('sync.record.error', {
1107
1445
  phase: 'fetch',
@@ -1109,6 +1447,25 @@ export class IntegrationEngine extends BaseSingleton {
1109
1447
  batchIndex: batchCount,
1110
1448
  error: errMsg,
1111
1449
  });
1450
+ // Resilience: a persistent fetch failure on ONE page shouldn't abandon the whole object.
1451
+ // POSITION-based paging (offset/page) can step past the failed page and keep going; we mark
1452
+ // the fetch incomplete (so the watermark is HELD below + the orphan/partition sweep is skipped)
1453
+ // — the skipped window is re-fetched next run (idempotent upsert + content-hash skip reconcile
1454
+ // it), no data lost. CURSOR paging CAN'T continue (the next cursor lives in the failed response).
1455
+ const canSkipPage = currentOffset != null || currentPage != null;
1456
+ if (canSkipPage && fetchGapCount < MAX_FETCH_GAPS) {
1457
+ fetchGapCount++;
1458
+ hadFetchGap = true;
1459
+ fetchCompletedCleanly = false;
1460
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_PAGE_SKIPPED', `Persistent fetch error at ${currentOffset != null ? 'offset ' + currentOffset : 'page ' + currentPage} for ` +
1461
+ `'${entityMap.ExternalObjectName}' (batch ${batchCount}); skipped this page and continued — the ` +
1462
+ `watermark is held so the window is re-fetched next run. Error: ${errMsg}`, { offset: currentOffset ?? null, page: currentPage ?? null, batchIndex: batchCount, error: errMsg });
1463
+ if (currentOffset != null)
1464
+ currentOffset += this.MaxBatchSize;
1465
+ else if (currentPage != null)
1466
+ currentPage += 1;
1467
+ continue;
1468
+ }
1112
1469
  fetchCompletedCleanly = false;
1113
1470
  break;
1114
1471
  }
@@ -1147,22 +1504,44 @@ export class IntegrationEngine extends BaseSingleton {
1147
1504
  }
1148
1505
  previousBatchFingerprint = fingerprint;
1149
1506
  }
1150
- for (const rec of batch.Records) {
1151
- fetchedExternalIDs.add(rec.ExternalID);
1507
+ if (!orphanTrackingOverflowed) {
1508
+ for (const rec of batch.Records) {
1509
+ fetchedExternalIDs.add(rec.ExternalID);
1510
+ }
1511
+ // OOM guard: a multi-million-row full sync would grow this set without bound. Past the
1512
+ // ceiling, drop it + flag so orphan detection is skipped (a SyncWarning is emitted below)
1513
+ // rather than risk crashing the run.
1514
+ if (fetchedExternalIDs.size > ORPHAN_DETECTION_MAX_IDS) {
1515
+ orphanTrackingOverflowed = true;
1516
+ fetchedExternalIDs.clear();
1517
+ }
1152
1518
  }
1153
1519
  const mapped = this.fieldMappingEngine.Apply(batch.Records, fieldMaps, entityMap.Entity);
1154
1520
  // Partition (Merkle) reconcile defers match + apply: accumulate mapped records now; the
1155
1521
  // partition-diff + selective apply runs once after the full fetch (applyViaPartitionReconcile).
1156
- if (partitionReconcile)
1522
+ if (partitionReconcile) {
1157
1523
  accumulatedMapped.push(...mapped);
1524
+ // OOM guard: this mode buffers the ENTIRE fetched set in RAM. Past a generous ceiling,
1525
+ // fail loud (caught by the per-map error path → other maps continue) instead of crashing
1526
+ // the whole run with an OOM. The default streaming path has no such limit.
1527
+ if (accumulatedMapped.length > PARTITION_RECONCILE_MAX_ACCUMULATION) {
1528
+ throw new Error(`partitionReconcile accumulated ${accumulatedMapped.length} records for ` +
1529
+ `${entityMap.ExternalObjectName} (cap ${PARTITION_RECONCILE_MAX_ACCUMULATION}). This mode ` +
1530
+ `buffers the whole set in RAM and is unsafe at this size — disable Configuration.partitionReconcile ` +
1531
+ `for this object to stream via per-record content-hash instead.`);
1532
+ }
1533
+ }
1534
+ // Serialize the match READ too (record-map / PK lookups). On a shared provider connection
1535
+ // a read routes through whatever transaction is active, so a match read in this stream
1536
+ // collides with another concurrent stream's in-flight write transaction ("Transaction has
1537
+ // not begun"). Holding the same write-lock for the read keeps the connection single-owner.
1158
1538
  const resolved = partitionReconcile
1159
1539
  ? []
1160
- : await this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser);
1540
+ : await this.runWriteExclusive(() => this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser));
1161
1541
  const beforeApply = result.RecordsCreated + result.RecordsUpdated + result.RecordsSkipped + result.RecordsErrored;
1162
- const erroredBeforeApply = result.RecordsErrored;
1163
1542
  try {
1164
1543
  if (!partitionReconcile)
1165
- await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger);
1544
+ await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1);
1166
1545
  }
1167
1546
  catch (applyErr) {
1168
1547
  if (applyErr instanceof SchemaNotGeneratedError) {
@@ -1198,7 +1577,7 @@ export class IntegrationEngine extends BaseSingleton {
1198
1577
  (batch.HasMore ? ` | more batches pending` : ` | batch complete`));
1199
1578
  // Update progress on the watermark record so the DB reflects live sync state
1200
1579
  if (batch.HasMore) {
1201
- await this.watermarkService.UpdateProgress(entityMapID, afterApply, contextUser);
1580
+ await this.runWriteExclusive(() => this.watermarkService.UpdateProgress(entityMapID, afterApply, contextUser));
1202
1581
  }
1203
1582
  }
1204
1583
  recordsInMap += batch.Records.length;
@@ -1207,11 +1586,7 @@ export class IntegrationEngine extends BaseSingleton {
1207
1586
  this.emitProgress(onProgress, entityMapIndex, totalEntityMaps, recordsInMap, recordsInMap);
1208
1587
  }
1209
1588
  if (batch.NewWatermarkValue) {
1210
- currentWatermark = batch.NewWatermarkValue;
1211
- // Only raise the safe floor when this batch applied with ZERO errors. A batch that rolled back
1212
- // (RecordsErrored increased) must NOT advance the floor, so its records stay re-fetchable next run.
1213
- if (result.RecordsErrored === erroredBeforeApply)
1214
- lastCleanWatermark = currentWatermark;
1589
+ currentWatermark = batch.NewWatermarkValue; // §10 — track the max seen; failures never hold it back
1215
1590
  }
1216
1591
  currentPage = batch.NextPage;
1217
1592
  currentOffset = batch.NextOffset;
@@ -1236,9 +1611,22 @@ export class IntegrationEngine extends BaseSingleton {
1236
1611
  // post-loop save below handles graceful early-exits precisely; this covers a SIGKILL
1237
1612
  // between graceful checkpoints, costing at most ~25 batches of re-fetch on resume.
1238
1613
  if (isKeysetConnector && currentAfterKey) {
1239
- await this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser);
1614
+ await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
1240
1615
  }
1241
1616
  }
1617
+ // P3-D: a connector returning empty pages with HasMore=true would otherwise spin silently
1618
+ // to MAX_BATCHES_PER_MAP. Surface a structured warning once the empty streak crosses the
1619
+ // threshold so the connector bug is visible, not buried.
1620
+ if (batch.Records.length === 0 && batch.HasMore === true) {
1621
+ consecutiveEmptyBatches++;
1622
+ if (consecutiveEmptyBatches === EMPTY_BATCH_WARN_THRESHOLD) {
1623
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'CONSECUTIVE_EMPTY_BATCHES', `'${entityMap.ExternalObjectName}' returned ${EMPTY_BATCH_WARN_THRESHOLD} empty batches in a row while still reporting HasMore=true ` +
1624
+ `(batch ${batchCount}/${MAX_BATCHES_PER_MAP}). Likely a connector pagination bug (cursor not advancing / HasMore stuck true).`, { batchCount, consecutiveEmptyBatches });
1625
+ }
1626
+ }
1627
+ else {
1628
+ consecutiveEmptyBatches = 0;
1629
+ }
1242
1630
  hasMore = batch.HasMore === true; // Explicit boolean check — prevents truthy undefined from looping
1243
1631
  }
1244
1632
  // Partition (Merkle) reconcile: the full set is now accumulated — diff it against last sync's
@@ -1251,12 +1639,15 @@ export class IntegrationEngine extends BaseSingleton {
1251
1639
  // The rollup snapshot (not a timestamp) was saved by applyViaPartitionReconcile above.
1252
1640
  result.WatermarkAfter = null;
1253
1641
  }
1254
- else if (fetchCompletedCleanly && isKeysetConnector) {
1255
- // A clean keyset scan covered the whole ordering range. These connectors have no
1256
- // timestamp filter — the next scheduled sync re-seeks from the start (content-hash keeps
1257
- // unchanged rows write-free) — so clear the resume marker rather than writing a timestamp
1258
- // into it, which the restore logic would otherwise mis-read as a seek key.
1259
- await this.watermarkService.ClearKeysetPosition(entityMapID, contextUser);
1642
+ else if (fetchCompletedCleanly && isKeysetConnector && !config.connector.MonotonicWatermark) {
1643
+ // A clean keyset scan covered the whole ordering range. A PURE-keyset connector (no reliable
1644
+ // watermark) has no timestamp filter — the next scheduled sync re-seeks from the start
1645
+ // (content-hash keeps unchanged rows write-free) — so clear the resume marker rather than
1646
+ // writing a timestamp into it, which the restore logic would otherwise mis-read as a seek key.
1647
+ // NOTE: a connector that ALSO returns a monotonic watermark (MonotonicWatermark=true) skips
1648
+ // this branch and falls through to SAVE that watermark below, so its next incremental NARROWS
1649
+ // (microtime > watermark) instead of re-scanning the whole object every run.
1650
+ await this.runWriteExclusive(() => this.watermarkService.ClearKeysetPosition(entityMapID, contextUser));
1260
1651
  result.WatermarkAfter = null;
1261
1652
  }
1262
1653
  else if (fetchCompletedCleanly) {
@@ -1279,22 +1670,31 @@ export class IntegrationEngine extends BaseSingleton {
1279
1670
  // but at least a watermark row exists for bookkeeping.
1280
1671
  let finalWatermark;
1281
1672
  if (currentWatermark) {
1282
- // On an incremental where records errored, hold the watermark to the last fully-clean batch's
1283
- // value (the safe floor) so the next run re-fetches the failed window; the idempotent upsert +
1284
- // content-hash skip reconcile it. A full sync always advances to "now".
1285
- const incrementalWatermark = result.RecordsErrored > 0 ? (lastCleanWatermark ?? currentWatermark) : currentWatermark;
1286
- finalWatermark = config.fullSync ? new Date().toISOString() : incrementalWatermark;
1673
+ // §10 — advance to the max watermark seen, ALWAYS. Errored records are dead-lettered (logged +
1674
+ // counted), never held against the watermark — a permanently-failing record must not freeze the
1675
+ // stream. A full sync advances to wall-clock "now" — EXCEPT a connector whose watermark is a
1676
+ // reliable monotonic max (MonotonicWatermark=true): for it, "now" in the source's OWN watermark
1677
+ // format IS currentWatermark (the max seen), so advancing to that — not an ISO timestamp the
1678
+ // connector can't compare against — lets the next incremental narrow.
1679
+ const incrementalWatermark = currentWatermark;
1680
+ // D2 clock-skew safety net: a clean full sync advances to wall-clock "now", but if the source's
1681
+ // own max watermark is AHEAD of our clock, advancing to "now" would skip the (now, sourceMax]
1682
+ // window on the next incremental — so never advance the watermark below the max value seen.
1683
+ let fullSyncWatermark = new Date().toISOString();
1684
+ if (currentWatermark > fullSyncWatermark)
1685
+ fullSyncWatermark = currentWatermark;
1686
+ finalWatermark = config.fullSync && !config.connector.MonotonicWatermark ? fullSyncWatermark : incrementalWatermark;
1287
1687
  }
1288
1688
  else {
1289
1689
  finalWatermark = new Date().toISOString();
1290
1690
  }
1291
- await this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull');
1691
+ await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull'));
1292
1692
  result.WatermarkAfter = finalWatermark;
1293
1693
  }
1294
1694
  else if (isKeysetConnector && currentAfterKey) {
1295
1695
  // The keyset scan stopped early (cancel / fetch error / safety limit). Persist the precise
1296
1696
  // last ordering key so the next run resumes the seek from here instead of restarting.
1297
- await this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser);
1697
+ await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
1298
1698
  result.WatermarkAfter = currentAfterKey;
1299
1699
  }
1300
1700
  // Orphan detection: delete/tombstone MJ records whose external counterpart no longer exists.
@@ -1304,6 +1704,21 @@ export class IntegrationEngine extends BaseSingleton {
1304
1704
  if ((config.fullSync || partitionReconcile) && fetchedExternalIDs.size > 0 && fetchCompletedCleanly) {
1305
1705
  await this.DeleteOrphanedRecords(config.companyIntegration, entityMap, fetchedExternalIDs, result, contextUser, logger);
1306
1706
  }
1707
+ else if (orphanTrackingOverflowed && fetchCompletedCleanly) {
1708
+ // We deliberately stopped tracking IDs to avoid OOM — orphan/delete detection can't run
1709
+ // safely this pass (a partial set would delete live records). Surface it, don't hide it.
1710
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHAN_DETECTION_SKIPPED_TOO_LARGE', `'${entityMap.ExternalObjectName}' returned more than ${ORPHAN_DETECTION_MAX_IDS.toLocaleString()} records, ` +
1711
+ `so orphan/delete detection was skipped this run to avoid excessive memory use. Records were synced normally; ` +
1712
+ `deletions in the source will not be reflected until a smaller/incremental run or a raised cap.`, { cap: ORPHAN_DETECTION_MAX_IDS });
1713
+ }
1714
+ // If any page was skipped after a persistent fetch error, surface ONE summary warning so the
1715
+ // operator knows this object's data is INCOMPLETE this run (the per-page warnings carry the
1716
+ // offsets). The watermark was held above, so the skipped window is re-fetched on the next run.
1717
+ if (hadFetchGap) {
1718
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_INCOMPLETE_PAGES_SKIPPED', `'${entityMap.ExternalObjectName}' finished with one or more pages skipped after persistent fetch errors — ` +
1719
+ `the result set is INCOMPLETE. The watermark was held, so the skipped window is re-fetched next run; ` +
1720
+ `records on the reachable pages were synced normally.`, { skipped: true });
1721
+ }
1307
1722
  await this.CreateRunDetail(run, entityMap, result, contextUser);
1308
1723
  return result;
1309
1724
  }
@@ -1475,13 +1890,26 @@ export class IntegrationEngine extends BaseSingleton {
1475
1890
  */
1476
1891
  async LoadAllMJRecords(entityMap, companyIntegration, contextUser) {
1477
1892
  const rv = new RunView();
1478
- // Load all records from the MJ entity
1893
+ // Load all records from the MJ entity. OOM SAFETY VALVE: a full push materializes every row of
1894
+ // the MJ entity in RAM. Past a generous ceiling, fail LOUD + actionable (caught by the per-map
1895
+ // error path → other maps continue) rather than risk an OOM that kills the whole run. A genuine
1896
+ // full push of a multi-million-row entity should use incremental push or raise the limit; we do
1897
+ // NOT silently partial-push (that would advance the watermark past unsent rows → dropped data).
1898
+ // NOTE: a true streaming/keyset push refactor is the proper long-term fix; this guard makes the
1899
+ // failure mode safe in the meantime without touching the delicate push-watermark clamp logic.
1479
1900
  const allResult = await rv.RunView({
1480
1901
  EntityName: entityMap.Entity,
1481
1902
  ResultType: 'simple',
1903
+ MaxRows: FULL_PUSH_MAX_RECORDS + 1, // +1 so we can detect "exceeded" vs "exactly at cap"
1482
1904
  }, contextUser);
1483
1905
  if (!allResult.Success || allResult.Results.length === 0)
1484
1906
  return [];
1907
+ if (allResult.Results.length > FULL_PUSH_MAX_RECORDS) {
1908
+ throw new Error(`Full push of '${entityMap.Entity}' exceeds the in-memory safety limit of ` +
1909
+ `${FULL_PUSH_MAX_RECORDS.toLocaleString()} records. A full push loads every row into memory; ` +
1910
+ `use incremental push (set a push watermark) or raise MJ_INTEGRATION_FULL_PUSH_MAX_RECORDS. ` +
1911
+ `Refusing to partial-push (that would advance the watermark past unsent rows and drop data).`);
1912
+ }
1485
1913
  // Load existing record maps to know which records already exist externally
1486
1914
  const mapResult = await rv.RunView({
1487
1915
  EntityName: 'MJ: Company Integration Record Maps',
@@ -1720,7 +2148,7 @@ export class IntegrationEngine extends BaseSingleton {
1720
2148
  resolution,
1721
2149
  });
1722
2150
  if (manualConflict) {
1723
- await this.markConflictOnMJRecord(change.RecordID, entityMap, conflictFields, contextUser);
2151
+ await this.markConflictOnMJRecord(change.RecordID, entityMap, conflictFields, contextUser, logger);
1724
2152
  return { action: 'skip', attributes: {} };
1725
2153
  }
1726
2154
  }
@@ -1747,7 +2175,7 @@ export class IntegrationEngine extends BaseSingleton {
1747
2175
  return mostRecentWinner(mjFields['__mj_UpdatedAt'], ext.ModifiedAt);
1748
2176
  }
1749
2177
  /** Marks an MJ mirror record in-conflict (Manual resolution) via its standard sync columns. Best-effort. */
1750
- async markConflictOnMJRecord(mjRecordID, entityMap, conflictFields, contextUser) {
2178
+ async markConflictOnMJRecord(mjRecordID, entityMap, conflictFields, contextUser, logger) {
1751
2179
  try {
1752
2180
  const md = this.ProviderToUse;
1753
2181
  const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
@@ -1763,10 +2191,15 @@ export class IntegrationEngine extends BaseSingleton {
1763
2191
  if (hasField('__mj_integration_SyncMessage')) {
1764
2192
  entity.Set('__mj_integration_SyncMessage', `Bidirectional conflict: external changed ${conflictFields.join(', ')} since last sync; awaiting manual resolution.`);
1765
2193
  }
1766
- await entity.Save();
2194
+ // Surface a failed conflict-mark: the engine thinks the row is quarantined, but without the
2195
+ // marker the operator has no signal. A silent failure here leaves the record in limbo.
2196
+ const ok = await entity.Save();
2197
+ if (!ok) {
2198
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.Entity ?? entityMap.ID, 'CONFLICT_MARK_FAILED', `Could not mark MJ record ${mjRecordID} in-conflict: ${entity.LatestResult?.CompleteMessage ?? 'Save() returned false'}`, { mjRecordID, conflictFields });
2199
+ }
1767
2200
  }
1768
- catch {
1769
- // best-effort
2201
+ catch (markErr) {
2202
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.Entity ?? entityMap.ID, 'CONFLICT_MARK_FAILED', `Could not mark MJ record ${mjRecordID} in-conflict: ${markErr instanceof Error ? markErr.message : String(markErr)}`, { mjRecordID, conflictFields });
1770
2203
  }
1771
2204
  }
1772
2205
  /**
@@ -1914,7 +2347,7 @@ export class IntegrationEngine extends BaseSingleton {
1914
2347
  const buckets = partitionRecords(mappedRecords, idOf, partitionOf);
1915
2348
  const newRollups = new Map();
1916
2349
  for (const [partition, recs] of buckets) {
1917
- newRollups.set(partition, partitionRollupHash(recs, r => r.MappedFields));
2350
+ newRollups.set(partition, partitionRollupHash(recs, r => contentHashBasis(r.MappedFields, r.UnmappedFields)));
1918
2351
  }
1919
2352
  // Diff against last sync's snapshot; only changed/added partitions need a deep apply. On a FORCED
1920
2353
  // FULL SYNC, treat the snapshot as empty so EVERY partition is re-applied: fullSync is the operator's
@@ -1941,8 +2374,12 @@ export class IntegrationEngine extends BaseSingleton {
1941
2374
  result.RecordsSkipped += recs.length;
1942
2375
  continue;
1943
2376
  }
1944
- const resolved = await this.matchEngine.Resolve(recs, entityMap, fieldMaps, contextUser);
1945
- await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger);
2377
+ // D3: serialize the match READ through the same write-mutex the non-partition path uses
2378
+ // (~line 1644). matchEngine.Resolve reads existing MJ rows on the SHARED provider
2379
+ // connection, so when streams run in parallel (syncConcurrency>1) it must not interleave
2380
+ // with another stream's open write transaction (else "Transaction in progress" / dirty read).
2381
+ const resolved = await this.runWriteExclusive(() => this.matchEngine.Resolve(recs, entityMap, fieldMaps, contextUser));
2382
+ await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1);
1946
2383
  appliedRecords += recs.length;
1947
2384
  }
1948
2385
  }
@@ -1971,7 +2408,16 @@ export class IntegrationEngine extends BaseSingleton {
1971
2408
  /**
1972
2409
  * Applies resolved records to MJ, handling each individually for error isolation.
1973
2410
  */
1974
- async ApplyRecords(records, companyIntegration, entityMap, result, contextUser, logger) {
2411
+ async ApplyRecords(records, companyIntegration, entityMap, result, contextUser, logger,
2412
+ // OPT-IN concurrency (syncConcurrency>1): when false, the batch is applied WITHOUT a provider
2413
+ // transaction (per-record auto-commit on pooled connections). Rationale: the provider holds ONE
2414
+ // global transaction bound to one connection, so a held transaction makes every concurrent
2415
+ // stream's fetch-phase reads (credentials, etc.) collide on that connection. Running the
2416
+ // concurrent write transaction-free keeps the global transaction null → zero collisions. The
2417
+ // lost batch atomicity is absorbed by the engine's idempotency (upsert-by-identity + content
2418
+ // hash) and the safe-floor watermark (advances only on a clean batch). Default true = the
2419
+ // proven atomic serial path, unchanged.
2420
+ useTransaction = true) {
1975
2421
  // Batched application with per-record failure isolation (the "grace gap" fix).
1976
2422
  // Happy path: each batch of up to APPLY_BATCH_SIZE records commits as a single
1977
2423
  // transaction — small enough to avoid SQL Server lock escalation (~5000 rows) while
@@ -1993,47 +2439,91 @@ export class IntegrationEngine extends BaseSingleton {
1993
2439
  // otherwise load one-by-one. For a watermark-less re-sync where nothing changed,
1994
2440
  // this lets UpdateRecord skip every per-record load. Best-effort: undefined → the
1995
2441
  // existing dirty-flag path runs unchanged.
1996
- const precheckHashes = await this.PrefetchContentHashes(batch, contextUser);
1997
- // PKs of records the content-hash fast path skipped this batch — still present and
1998
- // confirmed-unchanged on the source. Collected so we can refresh LastReconciledAt for
1999
- // all of them in ONE set-based touch after the batch (instead of a frozen-forever stamp).
2000
- let reconciledSkipIds = [];
2001
- await provider.BeginTransaction();
2002
- try {
2003
- for (const record of batch) {
2004
- result.RecordsProcessed++;
2005
- await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2442
+ // Serialize the per-batch DB-write across concurrently-synced streams (shared provider
2443
+ // connection ⇒ one transaction at a time). Fetch already happened in parallel upstream;
2444
+ // only this write section is mutually exclusive. A throw inside (e.g. SchemaNotGenerated)
2445
+ // propagates out to fail-stop this entity map, exactly as before.
2446
+ await this.runWriteExclusive(async () => {
2447
+ const precheckHashes = await this.PrefetchContentHashes(batch, contextUser);
2448
+ // PKs of records the content-hash fast path skipped this batch — still present and
2449
+ // confirmed-unchanged on the source. Collected so we can refresh LastReconciledAt for
2450
+ // all of them in ONE set-based touch after the batch (instead of a frozen-forever stamp).
2451
+ let reconciledSkipIds = [];
2452
+ if (useTransaction) {
2453
+ await provider.BeginTransaction();
2454
+ try {
2455
+ for (const record of batch) {
2456
+ result.RecordsProcessed++;
2457
+ await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2458
+ }
2459
+ await provider.CommitTransaction();
2460
+ }
2461
+ catch (err) {
2462
+ await provider.RollbackTransaction();
2463
+ // The batch transaction rolled back; the skip-IDs collected during the failed attempt
2464
+ // never committed. Reset and let the per-record retry re-collect only what commits.
2465
+ reconciledSkipIds = [];
2466
+ // Roll back the in-memory counters that ApplySingleRecord bumped inside the failed batch
2467
+ result.RecordsProcessed = batchStartProcessed;
2468
+ result.RecordsCreated = batchStartCreated;
2469
+ result.RecordsUpdated = batchStartUpdated;
2470
+ result.RecordsDeleted = batchStartDeleted;
2471
+ result.RecordsSkipped = batchStartSkipped;
2472
+ // SchemaNotGeneratedError is per-entity-deterministic — every record in
2473
+ // this object will fail the same way. Bubble it up so ProcessPullSync
2474
+ // can fail-stop the entityMap with one log line instead of producing
2475
+ // per-record duplicates. Rollback + counter restore above already ran.
2476
+ if (err instanceof SchemaNotGeneratedError) {
2477
+ throw err;
2478
+ }
2479
+ // Degrade to per-record application so the failure isolates to the poison
2480
+ // record(s) and every good record in this batch still commits.
2481
+ await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2482
+ }
2006
2483
  }
2007
- await provider.CommitTransaction();
2008
- }
2009
- catch (err) {
2010
- await provider.RollbackTransaction();
2011
- // The batch transaction rolled back; the skip-IDs collected during the failed attempt
2012
- // never committed. Reset and let the per-record retry re-collect only what commits.
2013
- reconciledSkipIds = [];
2014
- // Roll back the in-memory counters that ApplySingleRecord bumped inside the failed batch
2015
- result.RecordsProcessed = batchStartProcessed;
2016
- result.RecordsCreated = batchStartCreated;
2017
- result.RecordsUpdated = batchStartUpdated;
2018
- result.RecordsDeleted = batchStartDeleted;
2019
- result.RecordsSkipped = batchStartSkipped;
2020
- // SchemaNotGeneratedError is per-entity-deterministic — every record in
2021
- // this object will fail the same way. Bubble it up so ProcessPullSync
2022
- // can fail-stop the entityMap with one log line instead of producing
2023
- // per-record duplicates. Rollback + counter restore above already ran.
2024
- if (err instanceof SchemaNotGeneratedError) {
2025
- throw err;
2484
+ else {
2485
+ // OPT-IN concurrent path (syncConcurrency>1): NO batch transaction. Each record
2486
+ // auto-commits on its own pooled connection, so the global transaction is never
2487
+ // held and concurrent streams' fetch-phase reads can't collide on the shared
2488
+ // connection. Per-record error isolation: a poison record is logged + counted; the
2489
+ // rest still commit; the idempotent re-sync + safe-floor watermark reconcile any
2490
+ // partial batch (the atomicity the transactional path provides is not needed here).
2491
+ for (const record of batch) {
2492
+ result.RecordsProcessed++;
2493
+ try {
2494
+ // §10 — bounded inline retry for provably-transient save failures (auto-commit per
2495
+ // record, so no transaction to manage); permanent errors throw straight to dead-letter.
2496
+ await WithRetry(() => this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds), undefined, (e) => !(e instanceof SchemaNotGeneratedError) && IsRetryableError(ClassifyError(e).Code), (attempt, e, delayMs) => logger?.emit('sync.record.retry', {
2497
+ phase: 'save',
2498
+ externalObjectName: entityMap.ExternalObjectName,
2499
+ externalId: record.ExternalRecord?.ExternalID ?? '',
2500
+ attempt, delayMs, errorCode: ClassifyError(e).Code,
2501
+ }));
2502
+ }
2503
+ catch (err) {
2504
+ if (err instanceof SchemaNotGeneratedError) {
2505
+ throw err;
2506
+ }
2507
+ // §10 — permanent / retry-exhausted → dead-letter (count + log), move on; watermark advances regardless.
2508
+ result.RecordsErrored++;
2509
+ const classified = ClassifyError(err);
2510
+ result.Errors.push({
2511
+ ExternalID: record.ExternalRecord?.ExternalID ?? '',
2512
+ ChangeType: record.ChangeType ?? 'Create',
2513
+ ErrorMessage: err instanceof Error ? err.message : String(err),
2514
+ ErrorCode: classified.Code,
2515
+ Severity: classified.Severity,
2516
+ });
2517
+ }
2518
+ }
2026
2519
  }
2027
- // Degrade to per-record application so the failure isolates to the poison
2028
- // record(s) and every good record in this batch still commits.
2029
- await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2030
- }
2031
- // After the batch settles (committed, or per-record retried), refresh
2032
- // LastReconciledAt for every content-hash-skipped row in ONE set-based touch.
2033
- // Best-effort — a touch failure must never break the sync.
2034
- if (reconciledSkipIds.length > 0) {
2035
- await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
2036
- }
2520
+ // After the batch settles (committed, or per-record retried), refresh
2521
+ // LastReconciledAt for every content-hash-skipped row in ONE set-based touch.
2522
+ // Best-effort — a touch failure must never break the sync.
2523
+ if (reconciledSkipIds.length > 0) {
2524
+ await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
2525
+ }
2526
+ });
2037
2527
  }
2038
2528
  }
2039
2529
  /**
@@ -2098,20 +2588,44 @@ export class IntegrationEngine extends BaseSingleton {
2098
2588
  async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds) {
2099
2589
  const provider = this.ProviderToUse;
2100
2590
  for (const record of batch) {
2101
- await provider.BeginTransaction();
2591
+ result.RecordsProcessed++;
2102
2592
  try {
2103
- result.RecordsProcessed++;
2104
- await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2105
- await provider.CommitTransaction();
2593
+ // §10 — apply in its own transaction, with bounded inline retry for PROVABLY-TRANSIENT
2594
+ // save failures (NETWORK_TIMEOUT / RATE_LIMIT_EXCEEDED / DATABASE_ERROR per IsRetryableError).
2595
+ // Each attempt rolls back on throw so the next starts clean; a deadlock/momentary timeout
2596
+ // self-heals here. A PERMANENT error (validation/FK/duplicate/config) is NOT retried — it
2597
+ // throws straight out to the dead-letter path below.
2598
+ await WithRetry(async () => {
2599
+ await provider.BeginTransaction();
2600
+ try {
2601
+ await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2602
+ await provider.CommitTransaction();
2603
+ }
2604
+ catch (e) {
2605
+ await provider.RollbackTransaction();
2606
+ throw e;
2607
+ }
2608
+ }, undefined, (err) => !(err instanceof SchemaNotGeneratedError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => logger?.emit('sync.record.retry', {
2609
+ phase: 'save',
2610
+ externalObjectName: entityMap.ExternalObjectName,
2611
+ externalId: record.ExternalRecord.ExternalID,
2612
+ changeType: record.ChangeType,
2613
+ attempt,
2614
+ delayMs,
2615
+ errorCode: ClassifyError(err).Code,
2616
+ }));
2106
2617
  }
2107
2618
  catch (recErr) {
2108
- await provider.RollbackTransaction();
2109
2619
  // A schema-not-generated failure on one record means EVERY record in this
2110
2620
  // object will fail identically — bubble it up so the entityMap fail-stops
2111
2621
  // once rather than emitting per-record duplicates for the whole batch.
2112
2622
  if (recErr instanceof SchemaNotGeneratedError) {
2113
2623
  throw recErr;
2114
2624
  }
2625
+ // §10 — permanent (or retry-exhausted transient) failure → DEAD-LETTER: count it errored,
2626
+ // log it (queryable over GraphQL), and move on. The watermark is NOT held back (it advances
2627
+ // to max-seen regardless), so a permanently-bad record never poison-pills the stream; recovery
2628
+ // is an operator-triggered full sync. The run still reports Status='Failed' (RecordsErrored>0).
2115
2629
  const classified = ClassifyError(recErr);
2116
2630
  const msg = recErr instanceof Error ? recErr.message : String(recErr);
2117
2631
  result.RecordsErrored++;
@@ -2152,31 +2666,47 @@ export class IntegrationEngine extends BaseSingleton {
2152
2666
  c: result.RecordsCreated, u: result.RecordsUpdated,
2153
2667
  d: result.RecordsDeleted, s: result.RecordsSkipped,
2154
2668
  };
2155
- switch (record.ChangeType) {
2156
- case 'Create': {
2157
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
2158
- if (outcome === 'updated')
2159
- result.RecordsUpdated++;
2160
- else if (outcome === 'skipped')
2669
+ try {
2670
+ switch (record.ChangeType) {
2671
+ case 'Create': {
2672
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
2673
+ if (outcome === 'updated')
2674
+ result.RecordsUpdated++;
2675
+ else if (outcome === 'skipped')
2676
+ result.RecordsSkipped++;
2677
+ else
2678
+ result.RecordsCreated++;
2679
+ break;
2680
+ }
2681
+ case 'Update':
2682
+ await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds);
2683
+ break;
2684
+ case 'Delete': {
2685
+ const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
2686
+ if (didDelete)
2687
+ result.RecordsDeleted++;
2688
+ else
2689
+ result.RecordsErrored++;
2690
+ break;
2691
+ }
2692
+ case 'Skip':
2161
2693
  result.RecordsSkipped++;
2162
- else
2163
- result.RecordsCreated++;
2164
- break;
2165
- }
2166
- case 'Update':
2167
- await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds);
2168
- break;
2169
- case 'Delete': {
2170
- const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
2171
- if (didDelete)
2172
- result.RecordsDeleted++;
2173
- else
2174
- result.RecordsErrored++;
2175
- break;
2694
+ break;
2176
2695
  }
2177
- case 'Skip':
2696
+ }
2697
+ catch (err) {
2698
+ // §29 — a value that doesn't fit its column type (string too wide, integer out of range) is a
2699
+ // per-RECORD skip, not a batch failure and not truncation/clamping. Surface it as a structured
2700
+ // SyncWarning (visible over GraphQL) and move on so the rest of the batch still commits. The
2701
+ // base ValueFitError is caught, so every family member is handled uniformly. Any other error
2702
+ // propagates (batch isolation handles it).
2703
+ if (err instanceof ValueFitError) {
2178
2704
  result.RecordsSkipped++;
2179
- break;
2705
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.Entity ?? entityMap.ID, err.WarningCode, `Record skipped — ${err.message}`, { externalId: record.ExternalRecord.ExternalID, field: err.FieldName, ...err.Details() });
2706
+ }
2707
+ else {
2708
+ throw err;
2709
+ }
2180
2710
  }
2181
2711
  if (logger) {
2182
2712
  const outcome = result.RecordsCreated > before.c ? 'created' :
@@ -2222,6 +2752,24 @@ export class IntegrationEngine extends BaseSingleton {
2222
2752
  ? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
2223
2753
  : false;
2224
2754
  if (existed) {
2755
+ // Content-hash fast path (the upsert-path complement to UpdateRecord's matched-path precheck):
2756
+ // if the existing row's STORED hash equals the recomputed hash of the incoming mapped fields,
2757
+ // the record is provably unchanged — re-establish the (possibly-cleared) record map and SKIP
2758
+ // the write. Without this, object-valued mapped fields (persisted as JSON strings) make MJ's
2759
+ // field-level dirty tracking fire spuriously on every re-sync, re-writing unchanged rows — the
2760
+ // PropFuel `opens` class of redundant write that UpdateRecord's content-hash skip already avoids
2761
+ // on the MATCHED path but the upsert (unmatched / soft-PK) path did not. .Get on the dynamic
2762
+ // integration column is the sanctioned access here — these runtime-created tables have no
2763
+ // generated entity type (the engine already .Set()s the same __mj_integration_* columns).
2764
+ const hasHashColumn = entityInfo?.Fields.some(f => f.Name === CONTENT_HASH_COLUMN) ?? false;
2765
+ if (hasHashColumn) {
2766
+ const storedHash = entity.Get(CONTENT_HASH_COLUMN);
2767
+ if (typeof storedHash === 'string' && storedHash.length > 0
2768
+ && storedHash === computeContentHashWithOverflow(record.MappedFields ?? {}, record.UnmappedFields)) {
2769
+ await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
2770
+ return 'skipped';
2771
+ }
2772
+ }
2225
2773
  // Footprint-clean upsert: set only the BUSINESS fields first; if nothing actually changed
2226
2774
  // (dirty tracking after SetEntityFields, BEFORE the always-changing integration metadata),
2227
2775
  // re-establish the possibly-cleared record map and SKIP the write — leaving __mj_UpdatedAt
@@ -2271,9 +2819,12 @@ export class IntegrationEngine extends BaseSingleton {
2271
2819
  const values = [];
2272
2820
  for (const pk of pkFields) {
2273
2821
  const v = (pk.Name in fields) ? fields[pk.Name] : lower.get(pk.Name.toLowerCase());
2274
- if (v == null || String(v) === '')
2822
+ // serializeKeyValue mirrors the write-side coercion (objects → JSON, not "[object Object]")
2823
+ // so the load key equals the value stored in the column for object-valued PKs.
2824
+ const s = serializeKeyValue(v);
2825
+ if (s === '')
2275
2826
  return null;
2276
- values.push(String(v));
2827
+ values.push(s);
2277
2828
  }
2278
2829
  return values.join('|');
2279
2830
  }
@@ -2301,8 +2852,20 @@ export class IntegrationEngine extends BaseSingleton {
2301
2852
  // below is the fallback for entities without the hash column.
2302
2853
  if (precheckHashes) {
2303
2854
  const stored = precheckHashes.get(record.MatchedMJRecordID);
2304
- if (stored && stored === computeContentHash(record.MappedFields ?? {})) {
2855
+ if (stored && stored === computeContentHashWithOverflow(record.MappedFields ?? {}, record.UnmappedFields)) {
2305
2856
  result.RecordsSkipped++;
2857
+ // Re-establish the external↔MJ record map even on the content-hash skip. A record can
2858
+ // reach UpdateRecord matched by KEY FIELDS / PK (MatchEngine.FindByKeyFields queries the
2859
+ // dest table directly, NOT the RecordMap) with NO map row pointing at it — e.g. after the
2860
+ // entity maps (and their cascaded record maps) were deleted while the dest rows persisted
2861
+ // (a maps delete+re-add, partial cleanup, or a fresh CompanyIntegration over pre-existing
2862
+ // rows). Skipping the write here without writing the map leaves the RecordMap empty for
2863
+ // every unchanged-but-matched record, so orphan/delete detection and the 1:1 completeness
2864
+ // invariant silently degrade. SaveRecordMap is an upsert keyed on
2865
+ // (CompanyIntegration, Entity, ExternalID) — idempotent for already-mapped records, and the
2866
+ // CreateRecord skip branches already do exactly this. MatchedMJRecordID IS the dest PK
2867
+ // (PrimaryKeys order, '|'-joined), which is the EntityRecordID the map stores.
2868
+ await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, record.MatchedMJRecordID, contextUser);
2306
2869
  // The record IS still present and confirmed-unchanged on the source — but skipping
2307
2870
  // the write here means SetStandardIntegrationFields never runs, so __mj_integration_
2308
2871
  // LastReconciledAt would freeze at first-sync time. Record the PK so the batch can
@@ -2338,6 +2901,11 @@ export class IntegrationEngine extends BaseSingleton {
2338
2901
  // sync re-fetches all records. Without this, 50k+ records get re-written every run.
2339
2902
  if (!entity.Dirty) {
2340
2903
  result.RecordsSkipped++;
2904
+ // Re-establish the record map even when the write is skipped — see the content-hash skip
2905
+ // above for the full rationale (a key-field/PK match can land here with no map row, and
2906
+ // dropping the map silently breaks the 1:1 completeness invariant + orphan detection).
2907
+ // The entity is loaded here, so use its actual PK as the EntityRecordID. Idempotent upsert.
2908
+ await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
2341
2909
  return;
2342
2910
  }
2343
2911
  // A5: Pre-write validation
@@ -2380,26 +2948,43 @@ export class IntegrationEngine extends BaseSingleton {
2380
2948
  if (!entityInfo.Fields.some(f => f.Name === CONTENT_HASH_COLUMN))
2381
2949
  return undefined;
2382
2950
  const pkFields = entityInfo.PrimaryKeys ?? [];
2383
- if (pkFields.length !== 1)
2384
- return undefined; // single-PK fast path only
2385
- const pk = pkFields[0].Name;
2951
+ if (pkFields.length === 0)
2952
+ return undefined;
2953
+ // Map keys must match `record.MatchedMJRecordID`, which is the PK value(s) joined by '|' in
2954
+ // PrimaryKeys order (single value for single-PK, "v1|v2" for composite — see MatchEngine).
2955
+ const pkNames = pkFields.map(f => f.Name);
2386
2956
  try {
2387
- const escaped = ids.map(id => `'${String(id).replace(/'/g, "''")}'`).join(',');
2388
2957
  const rv = new RunView();
2958
+ let extraFilter;
2959
+ if (pkNames.length === 1) {
2960
+ // Single-PK fast path: WHERE pk IN (...).
2961
+ const escaped = ids.map(id => `'${String(id).replace(/'/g, "''")}'`).join(',');
2962
+ extraFilter = `${pkNames[0]} IN (${escaped})`;
2963
+ }
2964
+ else {
2965
+ // Composite-PK: each MatchedMJRecordID is "v1|v2|..." in PrimaryKeys order. Build
2966
+ // an OR of per-record (pk1='v1' AND pk2='v2') clauses — bounded by batch size.
2967
+ // Plain (unbracketed) identifiers → dialect-agnostic (SS brackets break Postgres).
2968
+ extraFilter = ids.map(mid => {
2969
+ const parts = String(mid).split('|');
2970
+ return '(' + pkNames.map((name, i) => `${name} = '${String(parts[i] ?? '').replace(/'/g, "''")}'`).join(' AND ') + ')';
2971
+ }).join(' OR ');
2972
+ }
2389
2973
  const res = await rv.RunView({
2390
2974
  EntityName: entityName,
2391
- Fields: [pk, CONTENT_HASH_COLUMN],
2392
- ExtraFilter: `${pk} IN (${escaped})`,
2975
+ Fields: [...pkNames, CONTENT_HASH_COLUMN],
2976
+ ExtraFilter: extraFilter,
2393
2977
  ResultType: 'simple',
2394
2978
  }, contextUser);
2395
2979
  if (!res.Success)
2396
2980
  return undefined;
2397
2981
  const map = new Map();
2398
2982
  for (const row of res.Results) {
2399
- const id = row[pk];
2983
+ // Re-key by the same '|'-join the matcher produced, so the lookup in ApplySingleRecord hits.
2984
+ const key = pkNames.map(n => row[n] ?? '').join('|');
2400
2985
  const hash = row[CONTENT_HASH_COLUMN];
2401
- if (id != null && typeof hash === 'string' && hash.length > 0) {
2402
- map.set(String(id), hash);
2986
+ if (typeof hash === 'string' && hash.length > 0) {
2987
+ map.set(key, hash);
2403
2988
  }
2404
2989
  }
2405
2990
  return map;
@@ -2512,27 +3097,29 @@ export class IntegrationEngine extends BaseSingleton {
2512
3097
  for (const [fieldName, value] of Object.entries(fields)) {
2513
3098
  const key = fieldName.toLowerCase();
2514
3099
  const coerced = this.coerceIncomingValue(value, typeLookup.get(key));
2515
- entity.Set(fieldName, this.enforceMaxLength(coerced, maxLenLookup.get(key), fieldName));
3100
+ entity.Set(fieldName, this.enforceValueFit(coerced, typeLookup.get(key), maxLenLookup.get(key), fieldName));
2516
3101
  }
2517
3102
  }
2518
3103
  /**
2519
- * §5/§10 type-driven enforcement: clamp an over-length string to the target column's MaxLength
2520
- * (a character count — SQLMaxLength already converts nvarchar bytes→chars) so a source value
2521
- * wider than the resolved column is truncated instead of failing the whole row on "value too
2522
- * long". Non-strings / values that fit / unlimited columns (MaxLength 0) pass through unchanged.
3104
+ * §5/§10/§29 value-fit enforcement: a value that cannot fit its bounded destination column is NOT
3105
+ * truncated/clamped (silent corruption) and does NOT widen the column (space is the priority) —
3106
+ * instead it raises a {@link ValueFitError}, which ApplySingleRecord catches to SKIP that one record
3107
+ * and surface a structured SyncWarning. Two fit failures are enforced: a string wider than its column
3108
+ * ({@link StringOverflowError}) and an integer outside its column's range ({@link NumericOverflowError},
3109
+ * which would otherwise sink the whole batch at SQL bind time). Values that fit / unlimited columns /
3110
+ * non-enforced types pass through unchanged.
2523
3111
  */
2524
- enforceMaxLength(value, maxLength, fieldName) {
2525
- if (typeof value !== 'string' || maxLength === undefined || value.length <= maxLength)
2526
- return value;
2527
- // NVARCHAR width is in UTF-16 code units, so we cut at `maxLength` code units — but never
2528
- // mid surrogate pair (slicing between a high+low surrogate yields an invalid string). If the
2529
- // last kept unit is a high surrogate, drop it (lose one emoji rather than corrupt the value).
2530
- let cut = maxLength;
2531
- const lastUnit = value.charCodeAt(cut - 1);
2532
- if (lastUnit >= 0xD800 && lastUnit <= 0xDBFF)
2533
- cut -= 1;
2534
- console.warn(`[IntegrationEngine] Truncated '${fieldName}' ${value.length}→${cut} code units (exceeds column width).`);
2535
- return value.slice(0, cut);
3112
+ enforceValueFit(value, targetType, maxLength, fieldName) {
3113
+ if (typeof value === 'string' && maxLength !== undefined && value.length > maxLength) {
3114
+ throw new StringOverflowError(fieldName, value.length, maxLength);
3115
+ }
3116
+ if (typeof value === 'number' && Number.isFinite(value) && targetType) {
3117
+ const bound = INTEGER_SQL_BOUNDS[targetType.toLowerCase()];
3118
+ if (bound && (value < bound.min || value > bound.max)) {
3119
+ throw new NumericOverflowError(fieldName, value, targetType);
3120
+ }
3121
+ }
3122
+ return value;
2536
3123
  }
2537
3124
  /**
2538
3125
  * Coerce external values to something MJ's SQL provider can bind safely.
@@ -2661,7 +3248,15 @@ export class IntegrationEngine extends BaseSingleton {
2661
3248
  // hash equals the stored hash can be skipped without loading it (see
2662
3249
  // PrefetchContentHashes / UpdateRecord). No-op on tables predating the column.
2663
3250
  if (hasField(CONTENT_HASH_COLUMN)) {
2664
- entity.Set(CONTENT_HASH_COLUMN, computeContentHash(record.MappedFields ?? {}));
3251
+ entity.Set(CONTENT_HASH_COLUMN, computeContentHashWithOverflow(record.MappedFields ?? {}, record.UnmappedFields));
3252
+ }
3253
+ // Custom-overflow capture (gaps.md §2): park any source keys with no field map as JSON,
3254
+ // in THIS same row write (no extra round-trip → a customs-free sync stays byte-identical).
3255
+ // Only written when there ARE extras; when empty, this is the signal that no post-sync RSU
3256
+ // promotion is needed for this row. Backend staging only — never user-facing metadata until
3257
+ // a key is promoted to a real column. No-op on tables predating the column. See CustomOverflow.
3258
+ if (hasField(CUSTOM_OVERFLOW_COLUMN) && hasUnmappedFields(record.UnmappedFields)) {
3259
+ entity.Set(CUSTOM_OVERFLOW_COLUMN, JSON.stringify(record.UnmappedFields));
2665
3260
  }
2666
3261
  // ── Per-record sync ledger (plan §2.5) ───────────────────────────────────────
2667
3262
  // The external system's version token for optimistic-concurrency on bidirectional
@@ -2748,10 +3343,20 @@ export class IntegrationEngine extends BaseSingleton {
2748
3343
  detail.RecordID = `EntityMap:${entityMap.ID}|Processed:${result.RecordsProcessed}`;
2749
3344
  detail.Action = result.RecordsCreated > 0 ? 'INSERT' : 'UPDATE';
2750
3345
  detail.IsSuccess = result.RecordsErrored === 0;
2751
- const detailSaved = await detail.Save();
2752
- if (!detailSaved) {
2753
- console.warn(`[IntegrationEngine] Failed to save run detail for entity map ${entityMap.ID}: ` +
2754
- `${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`);
3346
+ // Retry: this row IS the per-entity-map audit trail the GraphQL GetRun API reads. A lost save
3347
+ // makes a working sync look like it processed zero maps. Retry transient failures before giving up.
3348
+ try {
3349
+ await WithRetry(async () => {
3350
+ const ok = await detail.Save();
3351
+ if (!ok)
3352
+ throw new Error(detail.LatestResult?.CompleteMessage ?? 'detail.Save() returned false');
3353
+ return true;
3354
+ }, { MaxAttempts: 3, InitialBackoffMs: 500, MaxBackoffMs: 5000, JitterFraction: 0.1 });
3355
+ }
3356
+ catch (detailErr) {
3357
+ console.error(`[IntegrationEngine] Failed to save run detail for entity map ${entityMap.ID} after retries: ` +
3358
+ `${detailErr instanceof Error ? detailErr.message : String(detailErr)}. ` +
3359
+ `The run's per-map audit row is missing; GetRun will under-report processed maps.`);
2755
3360
  }
2756
3361
  }
2757
3362
  /**
@@ -2762,9 +3367,15 @@ export class IntegrationEngine extends BaseSingleton {
2762
3367
  aggregate.RecordsCreated += mapResult.RecordsCreated;
2763
3368
  aggregate.RecordsUpdated += mapResult.RecordsUpdated;
2764
3369
  aggregate.RecordsDeleted += mapResult.RecordsDeleted;
2765
- aggregate.RecordsErrored += mapResult.RecordsErrored;
3370
+ aggregate.RecordsErrored += mapResult.RecordsErrored; // true count, always exact
2766
3371
  aggregate.RecordsSkipped += mapResult.RecordsSkipped;
2767
- aggregate.Errors.push(...mapResult.Errors);
3372
+ // Bound the retained error SAMPLE: only the first MAX_AGGREGATE_ERRORS are ever persisted
3373
+ // (FinalizeRun slices to 100). Accumulating every per-record error across a multi-million-row
3374
+ // failing run would hold the whole set in RAM for no gain — RecordsErrored already has the count.
3375
+ if (aggregate.Errors.length < MAX_AGGREGATE_ERRORS && mapResult.Errors.length > 0) {
3376
+ const room = MAX_AGGREGATE_ERRORS - aggregate.Errors.length;
3377
+ aggregate.Errors.push(...mapResult.Errors.slice(0, room));
3378
+ }
2768
3379
  if (mapResult.RecordsErrored > 0) {
2769
3380
  aggregate.Success = false;
2770
3381
  }
@@ -2790,7 +3401,7 @@ export class IntegrationEngine extends BaseSingleton {
2790
3401
  /**
2791
3402
  * Finalizes a successful run with aggregate totals and emits a completion notification.
2792
3403
  */
2793
- async FinalizeRun(run, result, _contextUser, onNotification, aborted) {
3404
+ async FinalizeRun(run, result, contextUser, onNotification, aborted) {
2794
3405
  run.EndedAt = new Date();
2795
3406
  run.TotalRecords = result.RecordsProcessed;
2796
3407
  // A user/system-cancelled run must NOT be recorded as 'Success' — that hides the
@@ -2810,15 +3421,83 @@ export class IntegrationEngine extends BaseSingleton {
2810
3421
  run.ErrorLog = JSON.stringify(result.Errors.slice(0, 100));
2811
3422
  }
2812
3423
  }
2813
- const saved = await run.Save();
2814
- if (!saved) {
2815
- console.warn(`[IntegrationEngine] Failed to finalize run ${run.ID}: ` +
2816
- `${run.LatestResult?.CompleteMessage ?? 'unknown error'}`);
3424
+ // Retry the finalize save: a failed save leaves the run 'In Progress', which ResumeOrphanedSyncs
3425
+ // re-queues on next startup → the whole sync re-runs (re-fetch + re-apply). Worth a few retries to
3426
+ // make the terminal status durable. Both a thrown infra error and a `false` logical-failure retry.
3427
+ try {
3428
+ await WithRetry(async () => {
3429
+ const ok = await run.Save();
3430
+ if (!ok)
3431
+ throw new Error(run.LatestResult?.CompleteMessage ?? 'run.Save() returned false');
3432
+ return true;
3433
+ }, { MaxAttempts: 3, InitialBackoffMs: 500, MaxBackoffMs: 5000, JitterFraction: 0.1 });
3434
+ }
3435
+ catch (saveErr) {
3436
+ console.error(`[IntegrationEngine] Failed to finalize run ${run.ID} after retries: ` +
3437
+ `${saveErr instanceof Error ? saveErr.message : String(saveErr)}. ` +
3438
+ `Run may remain 'In Progress' and be re-queued as orphaned on next startup.`);
2817
3439
  }
2818
3440
  if (onNotification) {
2819
3441
  const notification = this.buildCompletionNotification(run, result);
2820
3442
  this.safeNotify(onNotification, notification);
2821
3443
  }
3444
+ // Retention: keep the run-history audit tables bounded (best-effort; never fails the sync).
3445
+ await this.pruneOldRunHistory(run, contextUser);
3446
+ }
3447
+ /**
3448
+ * Retention: prune old CompanyIntegrationRun + RunDetail rows for THIS connection so the audit
3449
+ * tables don't grow without bound (a row accumulates per sync forever otherwise — the unbounded-log
3450
+ * gap). Keeps the `MJ_INTEGRATION_MAX_RUNS_PER_CI` most-recent runs (default 100; <=0 disables).
3451
+ * Uses dialect-safe BULK DELETEs — NOT per-row BaseEntity.Delete, which would just shift the
3452
+ * unbounded growth into RecordChanges. Best-effort: a prune failure must NEVER fail a sync that
3453
+ * already landed data, and one bulk statement drains a large backlog in a single round-trip.
3454
+ */
3455
+ async pruneOldRunHistory(run, contextUser) {
3456
+ const keep = parseInt(process.env.MJ_INTEGRATION_MAX_RUNS_PER_CI ?? '100', 10);
3457
+ if (!Number.isFinite(keep) || keep <= 0)
3458
+ return; // retention disabled
3459
+ try {
3460
+ const md = this.ProviderToUse;
3461
+ const runInfo = run.EntityInfo;
3462
+ const detailInfo = md.EntityByName('MJ: Company Integration Run Details');
3463
+ const ciId = run.CompanyIntegrationID;
3464
+ if (!runInfo?.SchemaName || !runInfo.BaseTable || !runInfo.PrimaryKeys?.length)
3465
+ return;
3466
+ if (!detailInfo?.SchemaName || !detailInfo.BaseTable || !ciId)
3467
+ return;
3468
+ // Cutoff = StartedAt of the Nth-most-recent run for this CI. If there aren't MORE than
3469
+ // `keep` runs, there's nothing to prune.
3470
+ const recent = await new RunView().RunView({
3471
+ EntityName: runInfo.Name,
3472
+ ExtraFilter: `CompanyIntegrationID='${String(ciId).replace(/'/g, "''")}'`,
3473
+ OrderBy: 'StartedAt DESC',
3474
+ Fields: ['StartedAt'],
3475
+ MaxRows: keep,
3476
+ ResultType: 'simple',
3477
+ }, contextUser);
3478
+ if (!recent.Success || (recent.TotalRowCount ?? 0) <= keep)
3479
+ return;
3480
+ const cutoffRaw = recent.Results?.[recent.Results.length - 1]?.StartedAt;
3481
+ if (!cutoffRaw)
3482
+ return;
3483
+ const provider = md;
3484
+ const d = provider.Dialect;
3485
+ const runTable = `${d.QuoteIdentifier(runInfo.SchemaName)}.${d.QuoteIdentifier(runInfo.BaseTable)}`;
3486
+ const detailTable = `${d.QuoteIdentifier(detailInfo.SchemaName)}.${d.QuoteIdentifier(detailInfo.BaseTable)}`;
3487
+ const runPk = d.QuoteIdentifier(runInfo.PrimaryKeys[0].Name);
3488
+ const ciCol = d.QuoteIdentifier('CompanyIntegrationID');
3489
+ const startedCol = d.QuoteIdentifier('StartedAt');
3490
+ const detailFk = d.QuoteIdentifier('CompanyIntegrationRunID');
3491
+ const ci = d.QuoteStringLiteral(String(ciId));
3492
+ const cut = d.QuoteStringLiteral(new Date(cutoffRaw).toISOString());
3493
+ const oldRuns = `SELECT ${runPk} FROM ${runTable} WHERE ${ciCol}=${ci} AND ${startedCol} < ${cut}`;
3494
+ // Details first (FK → run; these entities don't cascade-delete), then the runs.
3495
+ await provider.ExecuteSQL(`DELETE FROM ${detailTable} WHERE ${detailFk} IN (${oldRuns})`, undefined, undefined, contextUser);
3496
+ await provider.ExecuteSQL(`DELETE FROM ${runTable} WHERE ${ciCol}=${ci} AND ${startedCol} < ${cut}`, undefined, undefined, contextUser);
3497
+ }
3498
+ catch (err) {
3499
+ console.warn(`[IntegrationEngine] Run-history retention prune skipped: ${err instanceof Error ? err.message : String(err)}`);
3500
+ }
2822
3501
  }
2823
3502
  /**
2824
3503
  * Marks a run as failed after an unrecoverable error and emits a failure notification.
@@ -2928,6 +3607,31 @@ export class IntegrationEngine extends BaseSingleton {
2928
3607
  console.warn('[IntegrationEngine] Notification callback threw:', notifyErr);
2929
3608
  }
2930
3609
  }
3610
+ /**
3611
+ * Invokes the post-sync custom-column promotion hook (gaps.md §2 / M2) without ever
3612
+ * propagating an exception into the sync — a promotion failure must never fail a sync that
3613
+ * already landed its data. Returns undefined when no hook is registered (a no-op host) or
3614
+ * the hook throws. The hook is self-gated: a customs-free sync does no work.
3615
+ */
3616
+ async invokePostSyncPromotionSafe(companyIntegrationID, contextUser, result) {
3617
+ if (!this.postSyncSchemaPromotionCallback)
3618
+ return undefined;
3619
+ try {
3620
+ const syncedEntityNames = [
3621
+ ...new Set((result.EntityMapResults ?? []).map(r => r.EntityName).filter(Boolean)),
3622
+ ];
3623
+ return await this.postSyncSchemaPromotionCallback({
3624
+ CompanyIntegrationID: companyIntegrationID,
3625
+ ContextUser: contextUser,
3626
+ SyncedEntityNames: syncedEntityNames,
3627
+ Provider: this._provider,
3628
+ });
3629
+ }
3630
+ catch (promoteErr) {
3631
+ console.warn('[IntegrationEngine] Post-sync schema promotion callback threw:', promoteErr);
3632
+ return undefined;
3633
+ }
3634
+ }
2931
3635
  // ── Composition: delegate metadata to IntegrationEngineBase ───────
2932
3636
  get Base() {
2933
3637
  return IntegrationEngineBase.Instance;