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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +180 -4
  2. package/dist/BaseIntegrationConnector.d.ts +115 -4
  3. package/dist/BaseIntegrationConnector.d.ts.map +1 -1
  4. package/dist/BaseIntegrationConnector.js +191 -24
  5. package/dist/BaseIntegrationConnector.js.map +1 -1
  6. package/dist/BaseRESTIntegrationConnector.d.ts +14 -1
  7. package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
  8. package/dist/BaseRESTIntegrationConnector.js +50 -3
  9. package/dist/BaseRESTIntegrationConnector.js.map +1 -1
  10. package/dist/BatchIdentity.d.ts +47 -0
  11. package/dist/BatchIdentity.d.ts.map +1 -0
  12. package/dist/BatchIdentity.js +78 -0
  13. package/dist/BatchIdentity.js.map +1 -0
  14. package/dist/DiscoveryWatchdog.d.ts +103 -0
  15. package/dist/DiscoveryWatchdog.d.ts.map +1 -0
  16. package/dist/DiscoveryWatchdog.js +166 -0
  17. package/dist/DiscoveryWatchdog.js.map +1 -0
  18. package/dist/FieldMapValidation.d.ts +40 -0
  19. package/dist/FieldMapValidation.d.ts.map +1 -0
  20. package/dist/FieldMapValidation.js +58 -0
  21. package/dist/FieldMapValidation.js.map +1 -0
  22. package/dist/FieldMappingEngine.d.ts +1 -13
  23. package/dist/FieldMappingEngine.d.ts.map +1 -1
  24. package/dist/FieldMappingEngine.js +18 -3
  25. package/dist/FieldMappingEngine.js.map +1 -1
  26. package/dist/IntegrationConnectorCreationPipeline.d.ts +92 -1
  27. package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
  28. package/dist/IntegrationConnectorCreationPipeline.js +186 -11
  29. package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
  30. package/dist/IntegrationEngine.d.ts +198 -21
  31. package/dist/IntegrationEngine.d.ts.map +1 -1
  32. package/dist/IntegrationEngine.js +1092 -165
  33. package/dist/IntegrationEngine.js.map +1 -1
  34. package/dist/IntegrationSchemaSync.d.ts +8 -0
  35. package/dist/IntegrationSchemaSync.d.ts.map +1 -1
  36. package/dist/IntegrationSchemaSync.js +39 -2
  37. package/dist/IntegrationSchemaSync.js.map +1 -1
  38. package/dist/KeylessRecordGuard.d.ts +56 -0
  39. package/dist/KeylessRecordGuard.d.ts.map +1 -0
  40. package/dist/KeylessRecordGuard.js +80 -0
  41. package/dist/KeylessRecordGuard.js.map +1 -0
  42. package/dist/ResumeConcurrency.d.ts +46 -0
  43. package/dist/ResumeConcurrency.d.ts.map +1 -0
  44. package/dist/ResumeConcurrency.js +74 -0
  45. package/dist/ResumeConcurrency.js.map +1 -0
  46. package/dist/RetryAfter.d.ts +50 -0
  47. package/dist/RetryAfter.d.ts.map +1 -0
  48. package/dist/RetryAfter.js +127 -0
  49. package/dist/RetryAfter.js.map +1 -0
  50. package/dist/RetryRunner.d.ts +26 -2
  51. package/dist/RetryRunner.d.ts.map +1 -1
  52. package/dist/RetryRunner.js +11 -3
  53. package/dist/RetryRunner.js.map +1 -1
  54. package/dist/RunOwnershipService.d.ts +177 -0
  55. package/dist/RunOwnershipService.d.ts.map +1 -0
  56. package/dist/RunOwnershipService.js +279 -0
  57. package/dist/RunOwnershipService.js.map +1 -0
  58. package/dist/SyncDirectives.d.ts +66 -0
  59. package/dist/SyncDirectives.d.ts.map +1 -0
  60. package/dist/SyncDirectives.js +123 -0
  61. package/dist/SyncDirectives.js.map +1 -0
  62. package/dist/SyncLogger.d.ts +1 -1
  63. package/dist/SyncLogger.d.ts.map +1 -1
  64. package/dist/SyncLogger.js.map +1 -1
  65. package/dist/WatermarkService.d.ts +8 -0
  66. package/dist/WatermarkService.d.ts.map +1 -1
  67. package/dist/WatermarkService.js +18 -0
  68. package/dist/WatermarkService.js.map +1 -1
  69. package/dist/index.d.ts +12 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +9 -1
  72. package/dist/index.js.map +1 -1
  73. package/dist/types.d.ts +28 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +66 -1
  76. package/dist/types.js.map +1 -1
  77. package/package.json +8 -8
@@ -1,9 +1,12 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import { CompositeKey, DatabaseProviderBase, LogStatusEx, Metadata, RunView } from '@memberjunction/core';
3
+ import { RunOwnershipLostError, RunOwnershipService } from './RunOwnershipService.js';
2
4
  import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
3
5
  import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
4
6
  import { ClassifyError, IsRetryableError } from './types.js';
5
7
  import { WithRetry } from './RetryRunner.js';
6
- import { WithTimeout, DEFAULT_OPERATION_TIMEOUTS } from './BaseIntegrationConnector.js';
8
+ import { DecideKeylessRefusal, DescribeKeylessRefusal, MissingKeyFieldNames } from './KeylessRecordGuard.js';
9
+ import { WithTimeout, OperationTimeoutError, DEFAULT_OPERATION_TIMEOUTS } from './BaseIntegrationConnector.js';
7
10
  import { ConnectorFactory } from './ConnectorFactory.js';
8
11
  import { FieldMappingEngine } from './FieldMappingEngine.js';
9
12
  import { MatchEngine } from './MatchEngine.js';
@@ -14,11 +17,15 @@ import { RecordMapBatch } from './RecordMapBatch.js';
14
17
  import { buildContentHashPrefetchFilter, quoteTextLiteral } from './prefetchFilter.js';
15
18
  import { serializeKeyValue } from './KeySerialization.js';
16
19
  import { CUSTOM_OVERFLOW_COLUMN, reconcileOverflowValue, foldCustomKeyStats } from './CustomOverflow.js';
20
+ import { ComputeExcludedSourceNames } from './SyncDirectives.js';
21
+ import { DescribeUnbindableFieldMaps, FindUnbindableFieldMaps } from './FieldMapValidation.js';
17
22
  import { partitionRecords, partitionRollupHash, diffPartitions, partitionKeyForIdentity } from './HashDiff.js';
18
23
  import { RateLimiter } from './RateLimiter.js';
19
24
  import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrency.js';
20
25
  import { mostRecentWinner } from './ConflictRecency.js';
21
26
  import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
27
+ import { CollapseDuplicateIdentities } from './BatchIdentity.js';
28
+ import { ResumeConcurrency, RunResumesBounded } from './ResumeConcurrency.js';
22
29
  /** Default batch size for fetching records from external systems */
23
30
  const DEFAULT_BATCH_SIZE = 200;
24
31
  /**
@@ -151,22 +158,30 @@ function detectSchemaNotGenerated(entityName, errorMessage) {
151
158
  return new SchemaNotGeneratedError(entityName, postgres[1]);
152
159
  return null;
153
160
  }
161
+ /**
162
+ * Coerces an externally-supplied tuning value — a duration in ms, or a count — to a usable positive
163
+ * integer, or `undefined` when it is not one, so the caller's `??` chain falls through to the next
164
+ * source.
165
+ *
166
+ * Every source of these values is outside the engine's control: operator-authored JSON in
167
+ * `CompanyIntegration.Configuration`, and connector-authored properties like
168
+ * `BaseIntegrationConnector.FetchChangesTimeoutMs`, whose declared type (`number | null`) happily
169
+ * admits `0`, negatives and `NaN` — a connector computing one from an unset env var gets `NaN`
170
+ * without a type error. Handing any of those to `setTimeout` is silently catastrophic rather than
171
+ * loud: it coerces them to ~1ms, so every wrapped operation rejects immediately and the object
172
+ * syncs nothing. Guard BOTH sources, not just the JSON one.
173
+ *
174
+ * Exported so the guard itself is unit-testable without standing up a sync.
175
+ */
176
+ export function PositiveInt(v) {
177
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined;
178
+ }
154
179
  export class IntegrationEngine extends BaseSingleton {
155
180
  constructor() {
156
181
  super();
157
182
  this.fieldMappingEngine = new FieldMappingEngine();
158
183
  this.matchEngine = new MatchEngine();
159
184
  this.watermarkService = new WatermarkService();
160
- /**
161
- * Per-engine async mutex serializing the DB-WRITE section across concurrently-synced streams.
162
- * When a layer runs multiple entity maps in parallel (syncConcurrency > 1), they all share ONE
163
- * provider connection whose transaction state is singular — so concurrent BeginTransaction /
164
- * SavePoint / Commit calls corrupt each other ("Transaction has not begun", "Cannot roll back
165
- * SavePoint"). The fetch phase stays parallel (the real throughput win — it's network-bound);
166
- * only the per-batch write transaction is serialized through this lock. Keyed per engine
167
- * instance, which owns the shared provider.
168
- */
169
- this._writeChain = Promise.resolve();
170
185
  /** Configurable maximum batch size. Connector batches exceeding this are truncated. */
171
186
  this.MaxBatchSize = DEFAULT_BATCH_SIZE;
172
187
  /** Per-integration request-spacing chain for the rate limiter (keyed by IntegrationID → last scheduled time). */
@@ -216,13 +231,26 @@ export class IntegrationEngine extends BaseSingleton {
216
231
  }
217
232
  return raw;
218
233
  }
234
+ /**
235
+ * Per-run execution context (PR 1 item 7). Each run carries its OWN provider,
236
+ * ownership service and abort plumbing through the async call chain — there is
237
+ * deliberately NO shared `_provider` instance field, because a last-writer-wins
238
+ * provider was the reason concurrent runs could write through each other's
239
+ * connection. AsyncLocalStorage propagates the context to every helper the run
240
+ * calls without threading a parameter through ~20 private signatures.
241
+ */
242
+ static { this.runContext = new AsyncLocalStorage(); }
243
+ /** The current run's context, when called from inside a sync run. */
244
+ get currentRunContext() {
245
+ return IntegrationEngine.runContext.getStore();
246
+ }
219
247
  /** Registers (or clears, with undefined) the post-sync custom-column promotion hook. */
220
248
  SetPostSyncSchemaPromotionCallback(callback) {
221
249
  this.postSyncSchemaPromotionCallback = callback;
222
250
  }
223
- /** Returns the active provider — explicit override if set, otherwise the global default. */
251
+ /** Returns the active provider — the current run's own provider when inside a run, otherwise the global default. */
224
252
  get ProviderToUse() {
225
- return this._provider ?? Metadata.Provider;
253
+ return this.currentRunContext?.provider ?? Metadata.Provider;
226
254
  }
227
255
  /** In-process lock map to prevent concurrent syncs for the same CompanyIntegration */
228
256
  static { this.activeSyncs = new Map(); }
@@ -256,35 +284,195 @@ export class IntegrationEngine extends BaseSingleton {
256
284
  static GetMaintenanceLock(companyIntegrationID) {
257
285
  return IntegrationEngine.maintenanceLocks.get(companyIntegrationID.toLowerCase());
258
286
  }
287
+ /**
288
+ * Async mutexes serializing the DB-WRITE section, keyed PER PROVIDER (PR 1 item 7 —
289
+ * per-run write chains). A provider connection's transaction state is singular, so
290
+ * concurrent BeginTransaction / SavePoint / Commit calls through the SAME provider
291
+ * corrupt each other ("Transaction has not begun", "Cannot roll back SavePoint") —
292
+ * whether the writers are parallel streams within one run OR two different runs that
293
+ * happen to share a provider instance. Keying the mutex on the provider makes the
294
+ * serialization boundary exactly the hazard boundary: runs on their own providers
295
+ * write fully in parallel; anything sharing a connection is serialized. The fetch
296
+ * phase stays parallel (the real throughput win — it's network-bound). WeakMap so a
297
+ * retired provider's chain entry is collectable.
298
+ */
299
+ static { this.writeChains = new WeakMap(); }
259
300
  runWriteExclusive(fn) {
301
+ const provider = this.ProviderToUse;
302
+ let holder = IntegrationEngine.writeChains.get(provider);
303
+ if (!holder) {
304
+ holder = { chain: Promise.resolve() };
305
+ IntegrationEngine.writeChains.set(provider, holder);
306
+ }
260
307
  // Run fn after the prior write completes (whether it resolved or rejected); keep the chain
261
308
  // alive past failures so one errored batch never deadlocks subsequent writers.
262
- const run = this._writeChain.then(() => fn(), () => fn());
263
- this._writeChain = run.then(() => undefined, () => undefined);
309
+ const run = holder.chain.then(() => fn(), () => fn());
310
+ holder.chain = run.then(() => undefined, () => undefined);
264
311
  return run;
265
312
  }
266
- /** Abort controllers for cancelling running syncs */
267
- static { this._abortControllers = new Map(); }
268
- /** Live sync progress updated on every batch for ALL syncs regardless of caller */
269
- static { this._syncProgress = new Map(); }
270
- /** Read current sync progress for a connector. Returns undefined if no sync is running. */
271
- static GetSyncProgress(companyIntegrationID) {
272
- return IntegrationEngine._syncProgress.get(companyIntegrationID.toLowerCase());
313
+ /**
314
+ * Fence check at a batch boundary, BEFORE any write (PR 1 item 3). One SELECT of the run's
315
+ * ownership columns: if the fence has moved (the stale sweep or another worker reclaimed the
316
+ * run) throw RunOwnershipLostError so the loop aborts WITHOUT writing this batch; if a
317
+ * cross-process cancel was requested, trip the run's abort controller so the loop winds down
318
+ * through the normal cancel path. A caller with no ownership context (e.g. unit tests driving
319
+ * internals directly) is a no-op.
320
+ */
321
+ async assertOwnershipAtBoundary() {
322
+ const ctx = this.currentRunContext;
323
+ if (!ctx?.ownership)
324
+ return;
325
+ const check = await ctx.ownership.CheckBoundary();
326
+ if (!check.Owned) {
327
+ ctx.ownershipLost = true;
328
+ ctx.abortController.abort();
329
+ throw new RunOwnershipLostError(ctx.ownership.RunID, 'fence moved at batch boundary — run was reclaimed by another process');
330
+ }
331
+ if (check.CancelRequested && !ctx.cancelRequested) {
332
+ ctx.cancelRequested = true;
333
+ ctx.abortController.abort();
334
+ }
273
335
  }
274
- /** Cancel a running sync for a connector. Returns true if a sync was found and signalled. */
275
- static CancelSync(companyIntegrationID) {
276
- const key = companyIntegrationID.toLowerCase();
277
- const controller = IntegrationEngine._abortControllers.get(key);
278
- if (controller) {
279
- console.log(`[IntegrationEngine] Cancelling sync for ${companyIntegrationID}`);
280
- controller.abort();
281
- return true;
336
+ /**
337
+ * One-time-per-method deprecation notice. Repeating it on every call would flood the
338
+ * log of a caller that polls progress on a timer, which is the usual usage.
339
+ */
340
+ static { this._deprecationNoticesEmitted = new Set(); }
341
+ static noteDeprecated(oldName, replacement) {
342
+ if (IntegrationEngine._deprecationNoticesEmitted.has(oldName)) {
343
+ return;
282
344
  }
345
+ IntegrationEngine._deprecationNoticesEmitted.add(oldName);
346
+ console.warn(`IntegrationEngine.${oldName}() is deprecated and no longer functional: sync progress and ` +
347
+ `cancellation now live on the CompanyIntegrationRun row so they are visible across processes, ` +
348
+ `and the in-process map this method read no longer exists. Use ${replacement}() instead.`);
349
+ }
350
+ /**
351
+ * @deprecated Superseded by {@link IntegrationEngine.GetSyncProgressAsync}, which reads
352
+ * the run row and therefore sees runs owned by ANY process.
353
+ *
354
+ * Retained with its original signature so a published consumer does not break on a
355
+ * minor upgrade. It cannot be made to work: the static map it used to read was removed
356
+ * when progress moved to the database, and a synchronous method cannot query it. It
357
+ * returns `undefined` — the same value it returned when no run was in progress — and
358
+ * logs once explaining the replacement.
359
+ */
360
+ static GetSyncProgress(_companyIntegrationID) {
361
+ IntegrationEngine.noteDeprecated('GetSyncProgress', 'GetSyncProgressAsync');
362
+ return undefined;
363
+ }
364
+ /**
365
+ * @deprecated Superseded by {@link IntegrationEngine.CancelSyncAsync}, which records the
366
+ * cancel on the run row so the owning process observes it at its next batch boundary.
367
+ *
368
+ * Retained with its original signature for published consumers. Returns `false` —
369
+ * truthfully reporting that it cancelled nothing — rather than pretending to succeed,
370
+ * so a caller branching on the result is not silently misled.
371
+ */
372
+ static CancelSync(_companyIntegrationID) {
373
+ IntegrationEngine.noteDeprecated('CancelSync', 'CancelSyncAsync');
283
374
  return false;
284
375
  }
285
- /** Get all active sync progress entries */
376
+ /**
377
+ * @deprecated No direct replacement. Query `MJ: Company Integration Runs` for rows whose
378
+ * Status is `In Progress` or `Queued` and read their `ProgressJSON`, which is what
379
+ * {@link IntegrationEngine.GetSyncProgressAsync} does for a single connector.
380
+ *
381
+ * Retained with its original signature for published consumers; returns an empty map.
382
+ */
286
383
  static GetAllSyncProgress() {
287
- return new Map(IntegrationEngine._syncProgress);
384
+ IntegrationEngine.noteDeprecated('GetAllSyncProgress', 'GetSyncProgressAsync');
385
+ return new Map();
386
+ }
387
+ /**
388
+ * Read current sync progress for a connector FROM THE DATABASE (PR 1 item 4 —
389
+ * progress lives on the run row, so it is visible from ANY process, not just the
390
+ * one executing the sync). Returns undefined when no live run exists. A run is
391
+ * "live" when Status is In Progress/Queued AND its lease has not expired — an
392
+ * expired lease means the owner died and the snapshot is stale history, not
393
+ * progress.
394
+ */
395
+ static async GetSyncProgressAsync(companyIntegrationID, contextUser, provider) {
396
+ // Server-side providers are DatabaseProviderBase (which implements IRunViewProvider);
397
+ // same narrowing the engine uses for the ownership sprocs.
398
+ const rv = new RunView(provider);
399
+ const result = await rv.RunView({
400
+ EntityName: 'MJ: Company Integration Runs',
401
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID.replace(/'/g, "''")}' AND Status IN ('In Progress','Queued')`,
402
+ OrderBy: 'StartedAt DESC',
403
+ Fields: ['ProgressJSON', 'LeaseExpiresAt', 'StartedAt'],
404
+ MaxRows: 1,
405
+ ResultType: 'simple',
406
+ BypassCache: true, // live liveness/progress read — must see the current row
407
+ }, contextUser);
408
+ const row = result.Success ? result.Results?.[0] : undefined;
409
+ if (!row)
410
+ return undefined;
411
+ if (row.LeaseExpiresAt != null && new Date(row.LeaseExpiresAt).getTime() < Date.now()) {
412
+ return undefined; // owner's lease lapsed — not live progress
413
+ }
414
+ if (!row.ProgressJSON)
415
+ return undefined;
416
+ try {
417
+ const snapshot = JSON.parse(row.ProgressJSON);
418
+ return { ...snapshot, StartedAt: new Date(snapshot.StartedAt) };
419
+ }
420
+ catch {
421
+ return undefined; // corrupt snapshot — treat as no progress rather than throwing at a poller
422
+ }
423
+ }
424
+ /**
425
+ * Request cancellation of a running (or queued) sync by stamping
426
+ * CancelRequestedAt on the live run row (PR 1 item 4 — the DATABASE is the single
427
+ * source of cancellation truth, so a cancel issued in ANY process reaches the
428
+ * owner). The owner observes the stamp at its next batch boundary / lease renewal
429
+ * and stops after the current batch. Returns true when a live run row was stamped.
430
+ */
431
+ static async CancelSyncAsync(companyIntegrationID, contextUser, provider) {
432
+ const p = (provider ?? Metadata.Provider);
433
+ const d = p.Dialect;
434
+ const schema = d.QuoteIdentifier(p.MJCoreSchemaName);
435
+ const table = d.QuoteIdentifier('CompanyIntegrationRun');
436
+ const ciCol = d.QuoteIdentifier('CompanyIntegrationID');
437
+ const statusCol = d.QuoteIdentifier('Status');
438
+ const cancelCol = d.QuoteIdentifier('CancelRequestedAt');
439
+ const placeholder = p.BuildParameterPlaceholder(0);
440
+ // One UPDATE, no select-then-update: stamp every un-stamped live run for this CI.
441
+ const rows = await p.ExecuteSQL(`UPDATE ${schema}.${table} SET ${cancelCol} = ${d.CurrentTimestampUTC()} ` +
442
+ `WHERE ${ciCol} = ${placeholder} AND ${statusCol} IN ('In Progress','Queued') AND ${cancelCol} IS NULL`, [companyIntegrationID], { isMutation: true, description: 'CancelSync — stamp CancelRequestedAt' }, contextUser);
443
+ console.log(`[IntegrationEngine] Cancel requested for ${companyIntegrationID} (DB stamp)`);
444
+ // ExecuteSQL result shape for UPDATE differs per driver; a thrown error is the failure
445
+ // signal. Verify via a cheap read so the caller gets an honest "was anything live?".
446
+ void rows;
447
+ const check = await p.ExecuteSQL(`SELECT COUNT(*) AS N FROM ${schema}.${table} WHERE ${ciCol} = ${placeholder} ` +
448
+ `AND ${statusCol} IN ('In Progress','Queued') AND ${cancelCol} IS NOT NULL`, [companyIntegrationID], { isMutation: false, description: 'CancelSync — verify stamp' }, contextUser);
449
+ return Number(check?.[0]?.N ?? 0) > 0;
450
+ }
451
+ /**
452
+ * Worker-mode poll (PR 1 item 8): the oldest claimable `Queued` runs. A row is claimable
453
+ * when it is unowned or its lease has lapsed — a Queued row with a LIVE lease is being
454
+ * started by another worker right now and must not be returned. This is only a
455
+ * *candidate* list; {@link ExecuteQueuedRun}'s atomic claim is what actually grants
456
+ * exclusivity, so two workers polling simultaneously is safe by construction.
457
+ */
458
+ static async PollQueuedRuns(contextUser, maxRows, provider) {
459
+ const p = (provider ?? Metadata.Provider);
460
+ const now = p.Dialect.CurrentTimestampUTC();
461
+ const rv = new RunView(p);
462
+ const result = await rv.RunView({
463
+ EntityName: 'MJ: Company Integration Runs',
464
+ ExtraFilter: `Status='Queued' AND (OwnerToken IS NULL OR LeaseExpiresAt IS NULL OR LeaseExpiresAt < ${now})`,
465
+ OrderBy: 'StartedAt ASC',
466
+ Fields: ['ID', 'CompanyIntegrationID'],
467
+ MaxRows: maxRows,
468
+ ResultType: 'simple',
469
+ BypassCache: true, // queue read — must see the current rows
470
+ }, contextUser);
471
+ if (!result.Success) {
472
+ console.warn(`[IntegrationEngine] Queue poll failed: ${result.ErrorMessage}`);
473
+ return [];
474
+ }
475
+ return result.Results ?? [];
288
476
  }
289
477
  /**
290
478
  * U3 — pure, MONOTONIC progress fold: applies one per-map progress event to the live
@@ -306,13 +494,17 @@ export class IntegrationEngine extends BaseSingleton {
306
494
  * Call this once during MJAPI startup after metadata is loaded.
307
495
  */
308
496
  async ResumeOrphanedSyncs(contextUser, provider) {
309
- if (provider)
310
- this._provider = provider;
497
+ const prov = provider ?? Metadata.Provider;
311
498
  await IntegrationEngineBase.Instance.Config(false, contextUser, provider);
499
+ // Liveness pre-screen (PR 1 item 6): an 'In Progress' run with a LIVE lease belongs to a
500
+ // healthy process — possibly another worker — and must not be adopted. Only unowned runs or
501
+ // runs whose lease has lapsed are orphan CANDIDATES. The claim sproc below re-evaluates the
502
+ // same predicate atomically, so this filter is a cheap pre-screen, not the correctness gate.
503
+ const dialect = prov.Dialect;
312
504
  const rv = new RunView();
313
505
  const orphanedRuns = await rv.RunView({
314
506
  EntityName: 'MJ: Company Integration Runs',
315
- ExtraFilter: `Status='In Progress'`,
507
+ ExtraFilter: `Status='In Progress' AND (OwnerToken IS NULL OR LeaseExpiresAt IS NULL OR LeaseExpiresAt < ${dialect.CurrentTimestampUTC()})`,
316
508
  ResultType: 'entity_object',
317
509
  BypassCache: true, // resume must see the live in-progress runs, not a stale cache
318
510
  }, contextUser);
@@ -323,93 +515,209 @@ export class IntegrationEngine extends BaseSingleton {
323
515
  return;
324
516
  }
325
517
  console.log(`[IntegrationEngine] Found ${orphanedRuns.Results.length} orphaned sync(s) to resume`);
326
- for (const run of orphanedRuns.Results) {
327
- const companyIntegrationID = run.CompanyIntegrationID;
328
- const runID = run.ID;
329
- const lockKey = companyIntegrationID.toLowerCase();
330
- // C1: respect the SAME in-process concurrency lock RunSync uses. If a live sync for this
331
- // CompanyIntegration is already running (e.g. the scheduler fired during startup), skip the
332
- // resume — double-running one CI on the shared provider connection corrupts its singular
333
- // transaction state (exactly what runWriteExclusive guards against WITHIN a run). The
334
- // get→set pair below has no await between them, so check-and-reserve is atomic on the loop.
335
- if (IntegrationEngine.activeSyncs.get(lockKey)) {
336
- console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} a live sync for ${lockKey} is already running`);
337
- continue;
518
+ // CONCURRENTLY, because that is how these runs were STARTED.
519
+ //
520
+ // This loop used to `await` each resume in turn, which quietly converted a parallel
521
+ // workload into a queue ordered by whatever RunView happened to return. The slowest
522
+ // connector became a head-of-line block for every other connector in the workspace and
523
+ // a connector that never finishes means the others never start at all.
524
+ //
525
+ // Observed live: a restart orphaned three syncs; one resumed and was still going five
526
+ // hours later, and the other two (99,463 and 13,238 rows) never began. Nothing in their
527
+ // logs said so, because nothing had failed — they had simply never been reached. From
528
+ // outside the process a queued run and a crashed one are identical: IsInFlight true,
529
+ // CompletedAt null, counters frozen at the instant of the restart. The absence of an
530
+ // error is the only tell.
531
+ //
532
+ // Note what is NOT being parallelised. The write section stays serialized by
533
+ // `runWriteExclusive`, because all maps share one provider connection with singular
534
+ // transaction state; that is deliberate and unchanged. Per-CompanyIntegration exclusion
535
+ // stays too, via the `activeSyncs` lock each resume takes. What overlaps here is what
536
+ // overlapped before the restart: different connectors waiting on different sources.
537
+ //
538
+ // Bounded rather than unbounded: a workspace is one Node process, so concurrency buys
539
+ // overlap on network waiting and not more CPU, and a boot that adopted fifty runs at once
540
+ // would trade one pathology for another.
541
+ await RunResumesBounded(orphanedRuns.Results, ResumeConcurrency(), run => this.ResumeOneOrphanedRun(run, prov, rv, contextUser));
542
+ }
543
+ /**
544
+ * Resume ONE orphaned run, end to end: reserve the per-CompanyIntegration lock, claim the run,
545
+ * work out which entity maps already finished, and execute the rest under a fresh run context.
546
+ *
547
+ * Extracted from {@link ResumeOrphanedSyncs}'s loop so several runs can be in flight at once.
548
+ * NEVER THROWS — every failure path is handled here and recorded on the run row. A resume that
549
+ * threw out of this method would take a pool slot with it and, worse, could abandon the runs
550
+ * queued behind it, which is the exact failure this parallelisation exists to remove.
551
+ *
552
+ * The check-and-reserve on `activeSyncs` still has no `await` in front of it, so it stays
553
+ * atomic with several of these in flight: an async function runs synchronously up to its first
554
+ * await, and the pool always starts one from a synchronous call site.
555
+ */
556
+ async ResumeOneOrphanedRun(run, prov, rv, contextUser) {
557
+ const companyIntegrationID = run.CompanyIntegrationID;
558
+ const runID = run.ID;
559
+ const lockKey = companyIntegrationID.toLowerCase();
560
+ // C1: respect the SAME in-process concurrency lock RunSync uses. If a live sync for this
561
+ // CompanyIntegration is already running (e.g. the scheduler fired during startup), skip the
562
+ // resume — double-running one CI on the shared provider connection corrupts its singular
563
+ // transaction state (exactly what runWriteExclusive guards against WITHIN a run). The
564
+ // get→set pair below has no await between them, so check-and-reserve is atomic on the loop.
565
+ if (IntegrationEngine.activeSyncs.get(lockKey)) {
566
+ console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — a live sync for ${lockKey} is already running`);
567
+ return;
568
+ }
569
+ let resolveResumeLock;
570
+ let resumeResult;
571
+ IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
572
+ const ownership = new RunOwnershipService(prov, runID, undefined, contextUser);
573
+ try {
574
+ // CLAIM BEFORE ADOPTING (PR 1 item 6): a single atomic UPDATE that succeeds only if the
575
+ // run is still unowned/lapsed. Zero rows = another worker adopted it between our RunView
576
+ // and now — skip, never double-run. A successful claim BUMPS the fence, so if the
577
+ // original owner is actually alive-but-slow it aborts at its next boundary check
578
+ // without writing: the sweep-reclaim is itself the abort signal for the abandoned owner.
579
+ const claimed = await ownership.Claim();
580
+ if (!claimed) {
581
+ console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — claim lost (another worker adopted it)`);
582
+ return;
338
583
  }
339
- let resolveResumeLock;
340
- let resumeResult;
341
- IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
342
- try {
343
- // Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
344
- // by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
345
- // not EntityID two maps can target the same MJ Entity, so keying on EntityID
346
- // could skip a still-pending sibling map. We also require IsSuccess=1: a map that
347
- // completed WITH errors (RecordsErrored>0, no throw) must be re-attempted on resume,
348
- // otherwise its errored records are silently abandoned.
349
- const detailsResult = await rv.RunView({
350
- EntityName: 'MJ: Company Integration Run Details',
351
- ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
352
- Fields: ['RecordID', 'IsSuccess'],
353
- ResultType: 'simple',
354
- }, contextUser);
355
- const completedMapIDs = new Set();
356
- if (detailsResult.Success) {
357
- for (const d of detailsResult.Results) {
358
- if (!d.IsSuccess)
359
- continue; // completed-with-errors → re-attempt on resume
360
- const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
361
- // Parse-miss falls open (map treated as not-completed → re-runs): at worst a
362
- // redundant idempotent re-sync, never a silent skip.
363
- if (m)
364
- completedMapIDs.add(m[1].toLowerCase());
365
- }
366
- }
367
- console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
368
- `for ${companyIntegrationID.substring(0, 8)}... ` +
369
- `(${completedMapIDs.size} entity maps already completed)`);
370
- // Load config and filter to only remaining entity maps (by map ID)
371
- const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser);
372
- const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
373
- if (remainingMaps.length === 0) {
374
- console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
375
- run.EndedAt = new Date();
376
- run.Status = 'Success';
377
- await run.Save();
378
- continue;
584
+ // Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
585
+ // by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
586
+ // not EntityID two maps can target the same MJ Entity, so keying on EntityID
587
+ // could skip a still-pending sibling map. We also require IsSuccess=1: a map that
588
+ // completed WITH errors (RecordsErrored>0, no throw) must be re-attempted on resume,
589
+ // otherwise its errored records are silently abandoned.
590
+ const detailsResult = await rv.RunView({
591
+ EntityName: 'MJ: Company Integration Run Details',
592
+ ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
593
+ Fields: ['RecordID', 'IsSuccess'],
594
+ ResultType: 'simple',
595
+ }, contextUser);
596
+ const completedMapIDs = new Set();
597
+ if (detailsResult.Success) {
598
+ for (const d of detailsResult.Results) {
599
+ if (!d.IsSuccess)
600
+ continue; // completed-with-errors re-attempt on resume
601
+ const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
602
+ // Parse-miss falls open (map treated as not-completed → re-runs): at worst a
603
+ // redundant idempotent re-sync, never a silent skip.
604
+ if (m)
605
+ completedMapIDs.add(m[1].toLowerCase());
379
606
  }
380
- console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
381
- // Replace entityMaps with only the remaining ones
382
- config.entityMaps = remainingMaps;
383
- // Execute remaining maps using the existing run record
384
- const result = await this.ExecuteEntityMaps(config, run, contextUser);
385
- result.RunID = runID;
386
- await this.FinalizeRun(run, result, contextUser);
387
- resumeResult = result;
388
- console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
389
- `${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
390
- `${result.RecordsErrored} errored`);
391
607
  }
392
- catch (err) {
393
- const errMsg = err instanceof Error ? err.message : String(err);
394
- console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
608
+ console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
609
+ `for ${companyIntegrationID.substring(0, 8)}... ` +
610
+ `(${completedMapIDs.size} entity maps already completed)`);
611
+ // Recover what this run was ASKED to do. Without this the resume rebuilds config from
612
+ // the CompanyIntegration alone, so an adopted run silently loses its options — most
613
+ // damagingly FullSync, which exists precisely to distrust the watermark. An adopted
614
+ // full sync would resume incrementally, fetch nothing, and report Success.
615
+ // Unparseable/absent ConfigData falls back to defaults rather than refusing to resume.
616
+ let resumeOptions;
617
+ let resumeTriggerType = 'Scheduled';
618
+ try {
619
+ const cfg = JSON.parse(run.ConfigData ?? '{}');
620
+ resumeOptions = cfg.options ?? undefined;
621
+ if (cfg.triggerType)
622
+ resumeTriggerType = cfg.triggerType;
623
+ }
624
+ catch {
625
+ console.warn(`[IntegrationEngine] Run ${runID.substring(0, 8)} has unparseable ConfigData; resuming with defaults`);
626
+ }
627
+ if (resumeOptions?.FullSync) {
628
+ console.log(`[IntegrationEngine] Run ${runID.substring(0, 8)} was a FULL sync — resuming as full, not incremental`);
629
+ }
630
+ // Load config and filter to only remaining entity maps (by map ID)
631
+ const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, resumeOptions);
632
+ const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
633
+ if (remainingMaps.length === 0) {
634
+ console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
635
+ run.EndedAt = new Date();
636
+ run.Status = 'Success';
637
+ ownership.SyncEntityOwnershipFields(run); // full-row save must not clobber the live claim
638
+ await run.Save();
639
+ await ownership.Release('Success');
640
+ return;
641
+ }
642
+ console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
643
+ // Replace entityMaps with only the remaining ones
644
+ config.entityMaps = remainingMaps;
645
+ // Execute remaining maps inside a per-run context: the resume gets its own provider
646
+ // binding, abort controller, and ownership — identical to a fresh RunSync — so the
647
+ // heartbeat renews the lease, the batch boundaries fence-check, and FinalizeRun
648
+ // syncs ownership fields + releases, all through the SAME code paths.
649
+ const abortController = new AbortController();
650
+ const progressSnapshot = {
651
+ StartedAt: new Date(),
652
+ CurrentEntity: '',
653
+ EntityMapsTotal: remainingMaps.length,
654
+ EntityMapsCompleted: 0,
655
+ RecordsProcessed: 0,
656
+ RecordsCreated: 0,
657
+ RecordsUpdated: 0,
658
+ RecordsErrored: 0,
659
+ // The run's OWN trigger type, recovered above — not a hardcoded 'Scheduled'. This is
660
+ // what IntegrationGetSyncProgress reports back ("Sync in progress (Manual)"), so a
661
+ // hardcoded value mislabels every adopted run.
662
+ TriggerType: resumeTriggerType,
663
+ };
664
+ const runCtx = {
665
+ provider: prov,
666
+ ownership,
667
+ abortController,
668
+ progressSnapshot,
669
+ cancelRequested: false,
670
+ ownershipLost: false,
671
+ };
672
+ ownership.StartHeartbeat({
673
+ onLost: () => { runCtx.ownershipLost = true; abortController.abort(); },
674
+ onCancelRequested: () => { runCtx.cancelRequested = true; abortController.abort(); },
675
+ progressSupplier: () => JSON.stringify(progressSnapshot),
676
+ });
677
+ const result = await IntegrationEngine.runContext.run(runCtx, async () => {
678
+ const r = await this.ExecuteEntityMaps(config, run, contextUser, undefined, abortController.signal);
679
+ r.RunID = runID;
680
+ await this.FinalizeRun(run, r, contextUser);
681
+ return r;
682
+ });
683
+ resumeResult = result;
684
+ console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
685
+ `${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
686
+ `${result.RecordsErrored} errored`);
687
+ }
688
+ catch (err) {
689
+ const errMsg = err instanceof Error ? err.message : String(err);
690
+ console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
691
+ if (err instanceof RunOwnershipLostError) {
692
+ // We were fenced out mid-resume — the NEW owner now owns the run row.
693
+ // Writing 'Failed' here would clobber the live holder's state.
694
+ console.warn(`[IntegrationEngine] Resume of run ${runID.substring(0, 8)} lost ownership — leaving the run row to its new owner`);
695
+ }
696
+ else {
395
697
  // Mark as failed so it doesn't get picked up again
396
698
  run.EndedAt = new Date();
397
699
  run.Status = 'Failed';
398
700
  run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
701
+ ownership.SyncEntityOwnershipFields(run);
399
702
  await run.Save();
400
- }
401
- finally {
402
- // Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
403
- // `existing`). Resolve with the real result when we have one, else a benign empty result so no
404
- // waiter hangs. Promise resolve is idempotent and the early-exit `continue` also lands here.
405
- IntegrationEngine.activeSyncs.delete(lockKey);
406
- resolveResumeLock(resumeResult ?? {
407
- Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
408
- RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
409
- RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
410
- });
703
+ try {
704
+ await ownership.Release('Failed');
705
+ }
706
+ catch { /* lease will simply expire */ }
411
707
  }
412
708
  }
709
+ finally {
710
+ ownership.StopHeartbeat();
711
+ // Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
712
+ // `existing`). Resolve with the real result when we have one, else a benign empty result so no
713
+ // waiter hangs. Promise resolve is idempotent and the early-exit `return`s also land here.
714
+ IntegrationEngine.activeSyncs.delete(lockKey);
715
+ resolveResumeLock(resumeResult ?? {
716
+ Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
717
+ RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
718
+ RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
719
+ });
720
+ }
413
721
  }
414
722
  /**
415
723
  * Executes a full sync run for a company integration.
@@ -424,8 +732,98 @@ export class IntegrationEngine extends BaseSingleton {
424
732
  * @returns Aggregate sync result with record counts and errors
425
733
  */
426
734
  async RunSync(companyIntegrationID, contextUser, triggerType = 'Manual', onProgress, onNotification, options, provider) {
427
- if (provider)
428
- this._provider = provider;
735
+ return this.runWithOwnedContext(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, provider);
736
+ }
737
+ /**
738
+ * Worker-mode enqueue (PR 1 item 8): create the run row with `Status='Queued'` and
739
+ * return its ID immediately, without executing anything. A worker process picks it up
740
+ * via {@link ExecuteQueuedRun}; the claim sproc provides the mutual exclusion, so any
741
+ * number of workers can poll the same queue safely.
742
+ *
743
+ * The trigger type and sync options are persisted on the run's `ConfigData` so the
744
+ * worker executes exactly what the caller asked for, in a different process.
745
+ */
746
+ async EnqueueSync(companyIntegrationID, contextUser, triggerType = 'Manual', options, provider) {
747
+ const md = provider ?? Metadata.Provider;
748
+ const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
749
+ run.NewRecord();
750
+ run.CompanyIntegrationID = companyIntegrationID;
751
+ run.RunByUserID = contextUser.ID;
752
+ run.StartedAt = new Date();
753
+ run.Status = 'Queued';
754
+ run.TotalRecords = 0;
755
+ run.ConfigData = JSON.stringify({ triggerType, options: options ?? null });
756
+ if (options?.ScheduledJobRunID) {
757
+ run.Set('ScheduledJobRunID', options.ScheduledJobRunID);
758
+ }
759
+ if (!(await run.Save())) {
760
+ throw new Error(`Failed to enqueue sync run: ${run.LatestResult?.CompleteMessage ?? 'unknown error'}`);
761
+ }
762
+ console.log(`[IntegrationEngine] Enqueued run ${run.ID} for ${companyIntegrationID} (${triggerType})`);
763
+ return run.ID;
764
+ }
765
+ /**
766
+ * Worker-mode execution (PR 1 item 8): take a `Queued` run row, claim it, and execute
767
+ * it in THIS process. Returns a failed result (without side effects) when the run is
768
+ * no longer queued or when another worker won the claim — losing the race is normal
769
+ * and must never be treated as an error condition by the caller's poll loop.
770
+ */
771
+ async ExecuteQueuedRun(runID, contextUser, provider) {
772
+ const md = provider ?? Metadata.Provider;
773
+ const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
774
+ if (!(await run.Load(runID))) {
775
+ return this.emptyFailedResult(`Queued run ${runID} not found`);
776
+ }
777
+ if (run.Status !== 'Queued') {
778
+ return this.emptyFailedResult(`Run ${runID} is '${run.Status}', not 'Queued' — another worker already took it`);
779
+ }
780
+ // A cancel issued while the run was still QUEUED must stop it here. CancelSync stamps
781
+ // both 'In Progress' and 'Queued' rows, but the only consumer of the stamp is the
782
+ // running loop's batch-boundary / lease-renewal check — a queued run has no loop yet,
783
+ // so without this gate the worker claims the cancelled row moments later and executes
784
+ // it to completion. Verified live: run E3F51F9A was stamped CancelRequestedAt at
785
+ // 15:50:46.643 and still finished Status='Success' at 15:50:48.646.
786
+ // Finalize the same way an aborted in-flight run finalizes (FinalizeRun): 'Cancelled'
787
+ // with an explicit ErrorLog carrying the reason.
788
+ if (run.CancelRequestedAt != null) {
789
+ run.EndedAt = new Date();
790
+ run.Status = 'Cancelled';
791
+ run.ErrorLog = 'Sync cancelled by user before it started';
792
+ if (!(await run.Save())) {
793
+ console.warn(`[IntegrationEngine] Could not finalize cancelled queued run ${runID}: ${run.LatestResult?.CompleteMessage ?? 'unknown error'}`);
794
+ }
795
+ console.log(`[IntegrationEngine] Queued run ${runID} was cancelled before start — not executing`);
796
+ return this.emptyFailedResult('Sync cancelled by user before it started');
797
+ }
798
+ let triggerType = 'Scheduled';
799
+ let options;
800
+ try {
801
+ const config = JSON.parse(run.ConfigData ?? '{}');
802
+ if (config.triggerType)
803
+ triggerType = config.triggerType;
804
+ options = config.options ?? undefined;
805
+ }
806
+ catch {
807
+ // A run whose ConfigData we can't read still executes — with defaults, not silently skipped.
808
+ console.warn(`[IntegrationEngine] Run ${runID} has unparseable ConfigData; executing with defaults`);
809
+ }
810
+ return this.runWithOwnedContext(run.CompanyIntegrationID, contextUser, triggerType, undefined, undefined, options, provider, run);
811
+ }
812
+ /** A zero-work failed SyncResult — used for refusals that must not look like partial work. */
813
+ emptyFailedResult(message) {
814
+ return {
815
+ Success: false, ErrorMessage: message, RecordsProcessed: 0, RecordsCreated: 0,
816
+ RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0,
817
+ Errors: [], EntityMapResults: [], Duration: 0,
818
+ };
819
+ }
820
+ /**
821
+ * Shared body of {@link RunSync} and {@link ExecuteQueuedRun}: acquires the per-connection
822
+ * concurrency lock, establishes the per-run AsyncLocalStorage context (own provider, own
823
+ * abort controller, own progress snapshot) and executes. `existingRun` is supplied by the
824
+ * worker path so a queued row is executed rather than a new row created.
825
+ */
826
+ async runWithOwnedContext(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, provider, existingRun) {
429
827
  const lockKey = companyIntegrationID.toLowerCase();
430
828
  const existing = IntegrationEngine.activeSyncs.get(lockKey);
431
829
  if (existing) {
@@ -446,10 +844,12 @@ export class IntegrationEngine extends BaseSingleton {
446
844
  Errors: [], EntityMapResults: [], Duration: 0,
447
845
  };
448
846
  }
449
- // Initialize abort controller and progress tracking
847
+ // Per-run context (PR 1 item 7): the run's OWN provider (captured, never stored on
848
+ // the engine), a local abort controller (plumbing driven by the DB cancel/fence
849
+ // signals — never a cross-process source of truth), and a local progress snapshot
850
+ // that the ownership service persists to ProgressJSON on the run row.
450
851
  const abortController = new AbortController();
451
- IntegrationEngine._abortControllers.set(lockKey, abortController);
452
- IntegrationEngine._syncProgress.set(lockKey, {
852
+ const progressSnapshot = {
453
853
  StartedAt: new Date(),
454
854
  CurrentEntity: '',
455
855
  EntityMapsTotal: 0,
@@ -459,33 +859,41 @@ export class IntegrationEngine extends BaseSingleton {
459
859
  RecordsUpdated: 0,
460
860
  RecordsErrored: 0,
461
861
  TriggerType: triggerType,
462
- });
862
+ };
863
+ const runCtx = {
864
+ provider: provider ?? Metadata.Provider,
865
+ abortController,
866
+ progressSnapshot,
867
+ cancelRequested: false,
868
+ ownershipLost: false,
869
+ };
463
870
  // Wrap caller's onProgress with internal tracking. U3 — MONOTONIC: with
464
871
  // syncConcurrency > 1 the per-map events arrive out of order (map 3 can emit after
465
872
  // map 7), so raw assignment made the progress bar go BACKWARDS. The snapshot is a
466
- // high-water mark, so only ever ratchet the counters upward.
873
+ // high-water mark, so only ever ratchet the counters upward. The snapshot is
874
+ // persisted to the run row's ProgressJSON (throttled) so readers in ANY process see it.
467
875
  const wrappedProgress = (progress) => {
468
- const entry = IntegrationEngine._syncProgress.get(lockKey);
469
- if (entry)
470
- IntegrationEngine.RatchetProgressSnapshot(entry, progress);
876
+ IntegrationEngine.RatchetProgressSnapshot(progressSnapshot, progress);
877
+ void runCtx.ownership?.WriteProgress(JSON.stringify(progressSnapshot));
471
878
  if (onProgress)
472
879
  onProgress(progress);
473
880
  };
474
- const syncPromise = this.executeSyncInternal(companyIntegrationID, contextUser, triggerType, wrappedProgress, onNotification, options, abortController.signal);
881
+ // Enter the AsyncLocalStorage scope every helper the run calls resolves
882
+ // ProviderToUse / write chain / ownership from THIS context, isolated per run.
883
+ const syncPromise = IntegrationEngine.runContext.run(runCtx, () => this.executeSyncInternal(companyIntegrationID, contextUser, triggerType, wrappedProgress, onNotification, options, abortController.signal, existingRun));
475
884
  IntegrationEngine.activeSyncs.set(lockKey, syncPromise);
476
885
  try {
477
886
  return await syncPromise;
478
887
  }
479
888
  finally {
480
889
  IntegrationEngine.activeSyncs.delete(lockKey);
481
- IntegrationEngine._abortControllers.delete(lockKey);
482
- IntegrationEngine._syncProgress.delete(lockKey);
890
+ runCtx.ownership?.StopHeartbeat();
483
891
  }
484
892
  }
485
893
  /**
486
894
  * Internal sync execution method. Contains the full orchestration logic.
487
895
  */
488
- async executeSyncInternal(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, abortSignal) {
896
+ async executeSyncInternal(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, abortSignal, existingRun) {
489
897
  const startTime = Date.now();
490
898
  const logger = new SyncLogger({ ciId: companyIntegrationID, integration: null });
491
899
  logger.emit('sync.run.start', {
@@ -495,6 +903,27 @@ export class IntegrationEngine extends BaseSingleton {
495
903
  entityMapIDsFilter: options?.EntityMapIDs ?? null,
496
904
  syncDirectionOverride: options?.SyncDirection ?? null,
497
905
  });
906
+ /**
907
+ * Re-read the IO/IOF catalog before the run starts (also covers resumed runs — both paths land here).
908
+ *
909
+ * Everything else a run reads is fresh per run (LoadRunConfiguration and LoadFieldMaps
910
+ * read CI/entity maps/field maps with BypassCache) — but IntegrationObject and
911
+ * IntegrationObjectField are served from this engine's BaseEngine arrays, loaded at
912
+ * process start and auto-refreshed only by IN-PROCESS BaseEntity saves. A catalog edit
913
+ * made by direct SQL, a sproc-based sync push, or another process is therefore invisible
914
+ * to syncs until the host restarts — an AccessPath or field-type correction keeps being
915
+ * ignored run after run with nothing in the log to say why. The apply/evolution
916
+ * resolvers already do an invalidate+reload for exactly this reason; the sync path
917
+ * never got the same treatment.
918
+ *
919
+ * Only the two catalog arrays are refreshed — not Config(true), which reloads all eight
920
+ * datasets unfiltered on every run. The loader is called directly with bypassCache=true
921
+ * because RefreshItem defaults to the local cache — which is the very thing that is
922
+ * stale. Replacing the arrays is also what invalidates this PR's memoised views: both
923
+ * the per-object field index and the per-record GetCachedFields memo key on ARRAY
924
+ * IDENTITY, so they rebuild lazily on first read after the swap.
925
+ */
926
+ await IntegrationEngineBase.Instance.RefreshCatalog(contextUser);
498
927
  const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, options);
499
928
  logger.attachIntegrationName(config.companyIntegration.Integration);
500
929
  logger.emit('sync.config.loaded', {
@@ -523,6 +952,15 @@ export class IntegrationEngine extends BaseSingleton {
523
952
  if (config.companyIntegration.IsActive === false) {
524
953
  const message = 'Connector is deactivated (IsActive=false); sync not started';
525
954
  logger.emit('sync.warning', { reason: 'deactivated', message });
955
+ // A queued row must not sit in the queue forever being re-polled by every worker.
956
+ if (existingRun) {
957
+ existingRun.Status = 'Failed';
958
+ existingRun.ErrorLog = message;
959
+ existingRun.EndedAt = new Date();
960
+ if (!(await existingRun.Save())) {
961
+ console.warn(`[IntegrationEngine] Failed to fail-out deactivated queued run ${existingRun.ID}: ${existingRun.LatestResult?.CompleteMessage ?? 'unknown error'}`);
962
+ }
963
+ }
526
964
  return {
527
965
  Success: false,
528
966
  ErrorMessage: message,
@@ -537,8 +975,53 @@ export class IntegrationEngine extends BaseSingleton {
537
975
  Duration: Date.now() - startTime,
538
976
  };
539
977
  }
540
- const run = await this.CreateRunRecord(config.companyIntegration, triggerType, contextUser, options?.ScheduledJobRunID);
978
+ // Worker mode executes a row that already exists (Status='Queued'); the direct path creates one.
979
+ const run = existingRun ?? await this.CreateRunRecord(config.companyIntegration, triggerType, contextUser, options?.ScheduledJobRunID, options);
541
980
  logger.attachRunId(run.ID);
981
+ // ── Durable-run ownership (PR 1 item 3): claim before the first batch. ──
982
+ // The claim is ONE atomic UPDATE (unowned OR expired lease) that bumps FenceToken;
983
+ // zero rows back means another process owns this run and we must not proceed.
984
+ // Renewal runs from a TIMER at ~lease/3 (not the batch loop) so a long batch never
985
+ // looks dead; the lease is max(default, MaxRuntimeMinutes) so a configured long run
986
+ // only ever EXTENDS protection. The heartbeat's renewal result doubles as the
987
+ // cross-process cancel poll.
988
+ const runCtx = this.currentRunContext;
989
+ if (runCtx) {
990
+ const ownership = new RunOwnershipService(runCtx.provider, run.ID, options?.MaxRuntimeMinutes ?? undefined, contextUser);
991
+ const claimed = await ownership.Claim();
992
+ if (!claimed) {
993
+ const message = `Run ${run.ID} could not be claimed — another process holds a live lease. Not proceeding.`;
994
+ logger.emit('sync.warning', { reason: 'claim-lost', message });
995
+ return {
996
+ Success: false, ErrorMessage: message, RecordsProcessed: 0, RecordsCreated: 0,
997
+ RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0,
998
+ Errors: [], EntityMapResults: [], Duration: Date.now() - startTime, RunID: run.ID,
999
+ };
1000
+ }
1001
+ runCtx.ownership = ownership;
1002
+ // Worker mode: the claim is what promotes a Queued row to In Progress. Doing it
1003
+ // AFTER the claim (never before) means a worker that loses the race never touches
1004
+ // the row, so the winner's status is the only one written.
1005
+ if (existingRun && existingRun.Status === 'Queued') {
1006
+ existingRun.Status = 'In Progress';
1007
+ existingRun.StartedAt = new Date();
1008
+ ownership.SyncEntityOwnershipFields(existingRun);
1009
+ if (!(await existingRun.Save())) {
1010
+ console.warn(`[IntegrationEngine] Failed to mark claimed run ${existingRun.ID} In Progress: ${existingRun.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1011
+ }
1012
+ }
1013
+ ownership.StartHeartbeat({
1014
+ onLost: () => {
1015
+ runCtx.ownershipLost = true;
1016
+ runCtx.abortController.abort();
1017
+ },
1018
+ onCancelRequested: () => {
1019
+ runCtx.cancelRequested = true;
1020
+ runCtx.abortController.abort();
1021
+ },
1022
+ progressSupplier: () => JSON.stringify(runCtx.progressSnapshot),
1023
+ });
1024
+ }
542
1025
  // Durable, queryable, restart-surviving artifact stream for this sync. runID is
543
1026
  // the CompanyIntegrationRun.ID so the JSONL artifact cross-correlates with the run
544
1027
  // row. Exposed over GraphQL (IntegrationListRuns / IntegrationGetRun /
@@ -603,6 +1086,20 @@ export class IntegrationEngine extends BaseSingleton {
603
1086
  catch (err) {
604
1087
  const errMsg = err instanceof Error ? err.message : String(err);
605
1088
  logger.emit('sync.run.fail', { error: errMsg, durationMs: Date.now() - startTime });
1089
+ // Ownership lost (fence moved / lease reclaimed): the run row now belongs to
1090
+ // ANOTHER process — writing a terminal status to it here would clobber the new
1091
+ // owner's state. Stop everything locally and walk away without touching the row.
1092
+ const ctx = this.currentRunContext;
1093
+ if (err instanceof RunOwnershipLostError || ctx?.ownershipLost) {
1094
+ ctx?.ownership?.StopHeartbeat();
1095
+ await this.finalizeSyncProgress(progress, 'failed', errMsg);
1096
+ console.warn(`[IntegrationEngine] Run ${run.ID} ownership lost — aborted without writing the run row.`);
1097
+ return {
1098
+ Success: false, ErrorMessage: errMsg, RecordsProcessed: 0, RecordsCreated: 0,
1099
+ RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0,
1100
+ Errors: [], EntityMapResults: [], Duration: Date.now() - startTime, RunID: run.ID,
1101
+ };
1102
+ }
606
1103
  await this.finalizeSyncProgress(progress, 'failed', errMsg);
607
1104
  await this.FailRun(run, err, contextUser, onNotification);
608
1105
  throw err;
@@ -771,7 +1268,7 @@ export class IntegrationEngine extends BaseSingleton {
771
1268
  /**
772
1269
  * Creates a new CompanyIntegrationRun record to track this sync.
773
1270
  */
774
- async CreateRunRecord(companyIntegration, triggerType, contextUser, scheduledJobRunID) {
1271
+ async CreateRunRecord(companyIntegration, triggerType, contextUser, scheduledJobRunID, options) {
775
1272
  const md = this.ProviderToUse;
776
1273
  const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
777
1274
  run.NewRecord();
@@ -780,7 +1277,12 @@ export class IntegrationEngine extends BaseSingleton {
780
1277
  run.StartedAt = new Date();
781
1278
  run.Status = 'In Progress';
782
1279
  run.TotalRecords = 0;
783
- run.ConfigData = JSON.stringify({ triggerType });
1280
+ // Persist the OPTIONS alongside the trigger type, in the same shape EnqueueSync writes.
1281
+ // Without them a run that outlives its process loses what it was asked to do: ResumeOrphanedSyncs
1282
+ // rebuilds config from the CompanyIntegration alone, so an adopted `FullSync` run silently
1283
+ // resumed as an incremental one — re-fetching nothing and reporting Success, which is the
1284
+ // opposite of what a full sync is requested for (repairing drift, re-pulling after a remap).
1285
+ run.ConfigData = JSON.stringify({ triggerType, options: options ?? null });
784
1286
  // Link to scheduled job run if triggered by the scheduler.
785
1287
  // Use Set() because the ScheduledJobRunID column won't exist on the
786
1288
  // generated entity type until CodeGen runs after the migration.
@@ -848,6 +1350,11 @@ export class IntegrationEngine extends BaseSingleton {
848
1350
  return { ok: mapResult.Success, throttled: mapResult.Throttled === true };
849
1351
  }
850
1352
  catch (err) {
1353
+ // Ownership loss is NOT a per-map failure to record-and-continue: continuing to the
1354
+ // next map would keep writing after another process claimed the run — the exact
1355
+ // split-brain the fence prevents. Propagate so the whole sync aborts immediately.
1356
+ if (err instanceof RunOwnershipLostError)
1357
+ throw err;
851
1358
  const objName = entityMap.ExternalObjectName ?? entityMap.ID;
852
1359
  const errMsg = err instanceof Error ? err.message : String(err);
853
1360
  console.error(`[IntegrationEngine] Entity map '${objName}' failed: ${errMsg}`);
@@ -1282,12 +1789,12 @@ export class IntegrationEngine extends BaseSingleton {
1282
1789
  if (!raw)
1283
1790
  return {};
1284
1791
  const p = JSON.parse(raw);
1285
- const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined);
1286
1792
  return {
1287
- maxConcurrency: num(p.maxConcurrency),
1793
+ maxConcurrency: PositiveInt(p.maxConcurrency),
1288
1794
  rateLimitTokensPerSec: typeof p.rateLimitTokensPerSec === 'number' && p.rateLimitTokensPerSec > 0 ? p.rateLimitTokensPerSec : undefined,
1289
- rateLimitBurst: num(p.rateLimitBurst),
1290
- discoveryTimeBudgetMs: num(p.discoveryTimeBudgetMs),
1795
+ rateLimitBurst: PositiveInt(p.rateLimitBurst),
1796
+ discoveryTimeBudgetMs: PositiveInt(p.discoveryTimeBudgetMs),
1797
+ fetchTimeoutMs: PositiveInt(p.fetchTimeoutMs),
1291
1798
  };
1292
1799
  }
1293
1800
  catch {
@@ -1373,6 +1880,16 @@ export class IntegrationEngine extends BaseSingleton {
1373
1880
  async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
1374
1881
  const entityMapID = entityMap.ID;
1375
1882
  const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
1883
+ // Field-level exclusions declared by the connector (SourceFieldInfo.SyncDirective
1884
+ // -> IntegrationObjectField.Configuration). Resolved once per map, applied to every
1885
+ // batch below. Empty set on any lookup miss - exclusion can only ever narrow.
1886
+ const excludedSourceNames = this.ResolveExcludedSourceNames(config.companyIntegration.IntegrationID, entityMap.ExternalObjectName);
1887
+ if (excludedSourceNames.size > 0) {
1888
+ logger?.emit('sync.entity-map.exclusions', {
1889
+ externalObjectName: entityMap.ExternalObjectName,
1890
+ excludedFields: Array.from(excludedSourceNames).sort(),
1891
+ });
1892
+ }
1376
1893
  const watermark = await this.runWriteExclusive(() => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
1377
1894
  logger?.emit('sync.entity-map.start', {
1378
1895
  phase: 'pull-detail',
@@ -1388,8 +1905,13 @@ export class IntegrationEngine extends BaseSingleton {
1388
1905
  watermarkType: watermark?.WatermarkType ?? null,
1389
1906
  fullSync: config.fullSync,
1390
1907
  });
1908
+ this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
1391
1909
  // A6: Validate watermark before using it — skip entirely when FullSync requested
1392
1910
  let initialWatermark = config.fullSync ? null : (watermark?.WatermarkValue ?? null);
1911
+ // The value the ROW held before this run touched it — the retract target if a mid-run
1912
+ // durability floor (§8a below) has to be undone after a page-skip gap. Distinct from
1913
+ // initialWatermark, which a fullSync nulls even though the row still holds a real value.
1914
+ const preRunWatermarkValue = watermark?.WatermarkValue ?? null;
1393
1915
  if (initialWatermark && watermark) {
1394
1916
  const watermarkType = (watermark.WatermarkType ?? 'Timestamp');
1395
1917
  if (!this.watermarkService.ValidateWatermark(initialWatermark, watermarkType)) {
@@ -1473,9 +1995,11 @@ export class IntegrationEngine extends BaseSingleton {
1473
1995
  let previousBatchFingerprint;
1474
1996
  let fetchCompletedCleanly = true; // flipped to false if fetch aborted or errored mid-way
1475
1997
  let hadFetchGap = false; // ≥1 page was skipped after a persistent fetch error (offset/page paging)
1998
+ let watermarkFloorSaved = null; // §8a durability floor last persisted mid-run (null = none)
1476
1999
  let fetchGapCount = 0; // CONSECUTIVE skipped pages (reset on any clean fetch)
1477
2000
  const MAX_FETCH_GAPS = 25; // give up + hold the watermark if this many pages fail in a row (API down)
1478
2001
  let consecutiveEmptyBatches = 0; // P3-D: detect a connector that pages empty-but-HasMore forever
2002
+ let oversizeBatchWarned = false; // pagination rule: warn ONCE per object that the connector ignored BatchSize
1479
2003
  const MAX_BATCHES_PER_MAP = 5000;
1480
2004
  const EMPTY_BATCH_WARN_THRESHOLD = 5; // warn once after this many empty-but-HasMore batches in a row
1481
2005
  const fetchedExternalIDs = new Set(); // Track all IDs seen during this pull for orphan detection
@@ -1486,6 +2010,17 @@ export class IntegrationEngine extends BaseSingleton {
1486
2010
  // one entry per distinct key + a capped value sample. See CustomKeyStat.
1487
2011
  const customKeyAgg = new Map();
1488
2012
  let customKeyTotalRecords = 0;
2013
+ // Per-page fetch timeout, resolved ONCE per entity map. A connector that fans out one request
2014
+ // per parent does N requests inside a single FetchChanges call, so its page time scales with
2015
+ // BatchSize and with however much concurrency the adaptive controller currently allows — the
2016
+ // fixed 30s default punished exactly those connectors. Deployment config wins over the
2017
+ // connector's own declared default, which wins over the framework default. BOTH overrides go
2018
+ // through PositiveInt: `??` alone would only reject null/undefined, so a connector returning
2019
+ // 0 / -1 / NaN (all legal for its `number | null` type) would be applied verbatim and time
2020
+ // every page out at ~1ms.
2021
+ const fetchTimeoutMs = PositiveInt(this.getConfigOverrides(config).fetchTimeoutMs)
2022
+ ?? PositiveInt(config.connector.FetchChangesTimeoutMs)
2023
+ ?? DEFAULT_OPERATION_TIMEOUTS.FetchChangesMs;
1489
2024
  while (hasMore) {
1490
2025
  if (abortSignal?.aborted) {
1491
2026
  console.log(`[IntegrationEngine] Sync cancelled for ${entityMap.ExternalObjectName} after ${recordsInMap} records — saving watermark`);
@@ -1528,18 +2063,69 @@ export class IntegrationEngine extends BaseSingleton {
1528
2063
  });
1529
2064
  let batch;
1530
2065
  const fetchStart = Date.now();
2066
+ // ONE multiplicative decrease per throttle EPISODE, not one per rejected attempt.
2067
+ //
2068
+ // A 429 that survives three retries is three rejections but one congestion event — the
2069
+ // same distinction TCP draws when it halves the window once per loss event rather than
2070
+ // once per lost segment. Decreasing on each attempt compounds: at a 0.5 backoff factor
2071
+ // three attempts take the rate to an eighth, five take it to a thirtieth, so a single
2072
+ // throttled fetch could drive a connector to its floor purely as a function of how
2073
+ // generous its retry budget is. The freeze already covers the interval the source asked
2074
+ // for; the decrease is about the rate AFTER that, and one signal deserves one step.
2075
+ let throttleReported = false;
1531
2076
  try {
1532
2077
  await this.rateLimit(config);
1533
2078
  // Resilient fetch: bound each attempt with a timeout (a hung vendor API must not
1534
2079
  // hold the sync lock forever) and retry only transient errors (network/throttle/DB).
1535
2080
  // A non-retryable error (auth, 4xx, parse) throws immediately as before.
1536
- 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', {
1537
- externalObjectName: entityMap.ExternalObjectName,
1538
- batchIndex: batchCount,
1539
- attempt,
1540
- delayMs,
1541
- error: err instanceof Error ? err.message : String(err),
1542
- }));
2081
+ batch = await WithRetry(() => WithTimeout(config.connector.FetchChanges(ctx), fetchTimeoutMs, `FetchChanges(${entityMap.ExternalObjectName})`), undefined,
2082
+ // OUR OWN timeout is terminal for this page; a transport error is not.
2083
+ //
2084
+ // `WithTimeout` is a `Promise.race` with no cancellation, so the abandoned attempt
2085
+ // keeps running. Retrying meant a second full page of vendor requests overlapping
2086
+ // the first, then a third up to 3x the load on a source that was already too slow
2087
+ // to finish once, which is a good way to earn a real 429 (and THAT does cut
2088
+ // concurrency). And the retry could not succeed on its merits anyway: the same work
2089
+ // under the same budget exceeds it again.
2090
+ //
2091
+ // Deliberately `instanceof` rather than the classified code. `ClassifyError` folds
2092
+ // `econnreset` in with timeouts under `NETWORK_TIMEOUT`, and a reset socket IS worth
2093
+ // retrying — so excluding the whole code would lose real resilience. Only the error
2094
+ // WithTimeout itself minted is excluded.
2095
+ (err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => {
2096
+ // Report a throttle NOW, not after the retries are spent. ReportThrottle
2097
+ // freezes the shared bucket for this CompanyIntegration, so every other
2098
+ // object fetching concurrently backs off too — reporting it only in the
2099
+ // catch below meant the rest of the connector kept hammering a source that
2100
+ // had already said stop.
2101
+ //
2102
+ // Once per episode: see `throttleReported` above. Later attempts still get
2103
+ // their own Retry-After honoured via DelayForError, which is what actually
2104
+ // paces this loop; what they must not do is halve the rate again.
2105
+ if (!throttleReported && ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED') {
2106
+ throttleReported = true;
2107
+ this.reportRateOutcome(config, err);
2108
+ }
2109
+ logger?.emit('sync.fetch.retry', {
2110
+ externalObjectName: entityMap.ExternalObjectName,
2111
+ batchIndex: batchCount,
2112
+ attempt,
2113
+ delayMs,
2114
+ error: err instanceof Error ? err.message : String(err),
2115
+ });
2116
+ }, {
2117
+ // Honour the source's own instruction. A 429 usually carries Retry-After;
2118
+ // blind exponential backoff ignored it and retried early, which is how a
2119
+ // soft throttle becomes a hard one. Falls back to backoff when the
2120
+ // connector cannot parse one.
2121
+ DelayForError: (err) => ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED'
2122
+ ? config.connector.ExtractRetryAfterMs(err)
2123
+ : undefined,
2124
+ // A retry must pass through the same gate the first attempt did. The token
2125
+ // was acquired once before WithRetry, so retries previously bypassed the
2126
+ // limiter entirely — including the freeze the line above just applied.
2127
+ BeforeRetry: () => this.rateLimit(config),
2128
+ });
1543
2129
  this.reportRateOutcome(config); // clean fetch → ramp the adaptive rate back up
1544
2130
  fetchGapCount = 0; // clean fetch → reset the consecutive fetch-gap counter
1545
2131
  // §10: connector type-driven post-processing hook (default no-op) — enforce/normalize
@@ -1554,7 +2140,10 @@ export class IntegrationEngine extends BaseSingleton {
1554
2140
  // other errors don't touch the rate. §5 Gap 2: also flag the map result so the per-layer
1555
2141
  // AIMD controller reduces in-flight concurrency, not just the per-request token bucket.
1556
2142
  if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED') {
1557
- this.reportRateOutcome(config, fetchErr);
2143
+ // Only if the retry hook did not already do it — a fetch that was retried has
2144
+ // already had its one decrease applied, at the first sign rather than here.
2145
+ if (!throttleReported)
2146
+ this.reportRateOutcome(config, fetchErr);
1558
2147
  result.Throttled = true;
1559
2148
  }
1560
2149
  console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
@@ -1574,6 +2163,15 @@ export class IntegrationEngine extends BaseSingleton {
1574
2163
  fetchGapCount++;
1575
2164
  hadFetchGap = true;
1576
2165
  fetchCompletedCleanly = false;
2166
+ // A durability floor written before this gap may sit PAST the hole (the skipped
2167
+ // page can hold records behind the max watermark seen). Put the row back to what
2168
+ // it held before this run, exactly what the post-loop hold does for the in-memory
2169
+ // value — a crash from here on resumes from the pre-run watermark and re-covers
2170
+ // the gap. Later checkpoints stop writing floors (gate above).
2171
+ if (watermarkFloorSaved !== null) {
2172
+ await this.runWriteExclusive(() => this.watermarkService.RestoreValue(entityMapID, preRunWatermarkValue, contextUser));
2173
+ watermarkFloorSaved = null;
2174
+ }
1577
2175
  logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_PAGE_SKIPPED', `Persistent fetch error at ${currentOffset != null ? 'offset ' + currentOffset : 'page ' + currentPage} for ` +
1578
2176
  `'${entityMap.ExternalObjectName}' (batch ${batchCount}); skipped this page and continued — the ` +
1579
2177
  `watermark is held so the window is re-fetched next run. Error: ${errMsg}`, { offset: currentOffset ?? null, page: currentPage ?? null, batchIndex: batchCount, error: errMsg });
@@ -1583,7 +2181,33 @@ export class IntegrationEngine extends BaseSingleton {
1583
2181
  currentPage += 1;
1584
2182
  continue;
1585
2183
  }
2184
+ // Cannot skip past this page (cursor paging, or the gap budget is spent), so the object
2185
+ // stops here with an incomplete result set. The watermark is held below and the run
2186
+ // re-fetches next time — but WITHOUT this warning that outcome is invisible: the map
2187
+ // reports success with the records it did get, so an object whose very first page
2188
+ // failed reads as a clean "0 records, nothing changed" run. Verified live: a sync
2189
+ // whose only page timed out finished Status=Success, errorCount=0, empty ErrorLog.
1586
2190
  fetchCompletedCleanly = false;
2191
+ const abortMessage = `Fetch for '${entityMap.ExternalObjectName}' stopped at batch ${batchCount} after a persistent ` +
2192
+ `error and could not continue past it, so this object's result set is INCOMPLETE ` +
2193
+ `(${recordsInMap} record(s) fetched before the failure). The watermark is held, so the ` +
2194
+ `unfetched window is retried next run. Error: ${errMsg}`;
2195
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_ABORTED_INCOMPLETE', abortMessage, { batchIndex: batchCount, recordsFetchedBeforeFailure: recordsInMap, error: errMsg });
2196
+ // The structured warning above reaches the console and the per-run artifact — neither of
2197
+ // which is queryable run history. The DURABLE record is CompanyIntegrationRun, whose
2198
+ // Status is derived from RecordsErrored (unchanged here, correctly: no record failed) and
2199
+ // whose ErrorLog is written from result.Errors. Without an entry there, a nightly sync
2200
+ // that aborts on its first page every night reads as an unbroken run of clean Successes
2201
+ // with TotalRecords=0. Severity 'Warning' is what keeps Status='Success': MergeResult
2202
+ // only clears Success on RecordsErrored > 0, so this records the condition without
2203
+ // reclassifying a held-watermark retry as a failed run.
2204
+ result.Errors.push({
2205
+ ExternalID: '',
2206
+ ChangeType: 'Skip',
2207
+ ErrorMessage: abortMessage,
2208
+ ErrorCode: 'CONNECTOR_ERROR',
2209
+ Severity: 'Warning',
2210
+ });
1587
2211
  break;
1588
2212
  }
1589
2213
  logger?.emit('sync.fetch.batch.complete', {
@@ -1605,11 +2229,15 @@ export class IntegrationEngine extends BaseSingleton {
1605
2229
  logger?.warning(entityMap.ExternalObjectName ?? 'sync', w.Code, w.Message, w.Data);
1606
2230
  }
1607
2231
  }
1608
- // If the connector returned more records than MaxBatchSize, log it but never truncate —
1609
- // all records are written, just in sub-batches to keep DB transactions manageable.
1610
- if (batch.Records.length > this.MaxBatchSize) {
1611
- console.log(`[IntegrationEngine] ${entityMap.ExternalObjectName}: connector returned ` +
1612
- `${batch.Records.length} records (> MaxBatchSize ${this.MaxBatchSize}), writing in chunks.`);
2232
+ // Engine-side half of the pagination rule: a connector MUST honour ctx.BatchSize. We never
2233
+ // truncate every record is written, just in sub-batches to keep DB transactions manageable
2234
+ // but an over-size batch is a real connector defect and has to be visible on the structured
2235
+ // run-event stream, not buried in a console.log nobody reads. Warned ONCE per object (the
2236
+ // CONSECUTIVE_EMPTY_BATCHES pattern) so a paginating-but-over-size connector doesn't flood
2237
+ // the artifact with one warning per page.
2238
+ if (batch.Records.length > this.MaxBatchSize && !oversizeBatchWarned) {
2239
+ oversizeBatchWarned = true;
2240
+ this.warnOversizedBatch(entityMap, batch, batchCount, logger);
1613
2241
  }
1614
2242
  if (batch.Records.length > 0) {
1615
2243
  const fingerprint = batch.Records.map(r => r.ExternalID).join(',');
@@ -1633,7 +2261,20 @@ export class IntegrationEngine extends BaseSingleton {
1633
2261
  fetchedExternalIDs.clear();
1634
2262
  }
1635
2263
  }
1636
- const mapped = this.fieldMappingEngine.Apply(batch.Records, fieldMaps, entityMap.Entity);
2264
+ // Within-batch identity, enforced before mapping: two records sharing an ExternalID are
2265
+ // two observations of ONE source record. The write path cannot catch this — it decides
2266
+ // insert-vs-update against the DATABASE, where a first-time identity is absent for both
2267
+ // copies, so both insert and the pair re-inserts every sync. The fingerprint guard above
2268
+ // only sees a batch repeated in FULL. Never silent: a connector emitting duplicate
2269
+ // identities is a defect worth fixing at its source.
2270
+ const identity = CollapseDuplicateIdentities(batch.Records);
2271
+ if (identity.Collapsed > 0) {
2272
+ logger?.warning(entityMap.ExternalObjectName ?? 'sync', 'DUPLICATE_IDENTITIES_IN_BATCH', `${entityMap.ExternalObjectName}: ${identity.Collapsed} record(s) repeated an ExternalID already `
2273
+ + `present in the same batch and were collapsed (last occurrence kept). Two records sharing an `
2274
+ + `identity are one source record observed twice; writing both would insert duplicate rows that `
2275
+ + `no later sync could reconcile. Sample: ${identity.SampleIDs.join(', ')}`, { object: entityMap.ExternalObjectName, collapsed: identity.Collapsed, sample: identity.SampleIDs });
2276
+ }
2277
+ const mapped = this.fieldMappingEngine.Apply(identity.Records, fieldMaps, entityMap.Entity, excludedSourceNames);
1637
2278
  // Custom-key stats: aggregate unmapped keys for EVERY mapped record here —
1638
2279
  // before any skip decision — so candidates + sizing stats exist even when the
1639
2280
  // content-hash fast path skips the row (the hash basis deliberately excludes them).
@@ -1653,6 +2294,9 @@ export class IntegrationEngine extends BaseSingleton {
1653
2294
  `for this object to stream via per-record content-hash instead.`);
1654
2295
  }
1655
2296
  }
2297
+ // Batch boundary: verify we STILL own the run before this batch's writes begin.
2298
+ // Throws RunOwnershipLostError (aborting with nothing written) if the fence moved.
2299
+ await this.assertOwnershipAtBoundary();
1656
2300
  // Serialize the match READ too (record-map / PK lookups). On a shared provider connection
1657
2301
  // a read routes through whatever transaction is active, so a match read in this stream
1658
2302
  // collides with another concurrent stream's in-flight write transaction ("Transaction has
@@ -1735,6 +2379,25 @@ export class IntegrationEngine extends BaseSingleton {
1735
2379
  if (isKeysetConnector && currentAfterKey) {
1736
2380
  await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
1737
2381
  }
2382
+ // The WATERMARK twin of the keyset floor above. Without it, a watermark-based
2383
+ // connector had NO durable position at all until the run ended: a SIGKILL / OOM /
2384
+ // container recycle mid-object threw away hours of applied batches and the next run
2385
+ // re-fetched the entire window from the last completed run's watermark. Same safety
2386
+ // argument as the graceful early-exit save below — currentWatermark only ever
2387
+ // advances at the END of a fully-applied batch, so this floor can never point past a
2388
+ // record that was not written. Gated on !hadFetchGap because a skipped page may
2389
+ // contain records BEHIND the max watermark seen (fetch order is not watermark order
2390
+ // on every source), i.e. a hole behind the floor; once a gap exists the floor stops
2391
+ // moving, and the one already written is retracted at the gap site. Skipped for
2392
+ // keyset connectors (their position IS the seek key above) and partition-reconcile
2393
+ // maps (their watermark row stores the rollup snapshot, not a timestamp).
2394
+ if (!isKeysetConnector && !partitionReconcile && !hadFetchGap
2395
+ && currentWatermark && currentWatermark !== initialWatermark
2396
+ && currentWatermark !== watermarkFloorSaved) {
2397
+ const floor = currentWatermark;
2398
+ await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, floor, contextUser, 'Pull'));
2399
+ watermarkFloorSaved = floor;
2400
+ }
1738
2401
  }
1739
2402
  // P3-D: a connector returning empty pages with HasMore=true would otherwise spin silently
1740
2403
  // to MAX_BATCHES_PER_MAP. Surface a structured warning once the empty streak crosses the
@@ -1819,6 +2482,26 @@ export class IntegrationEngine extends BaseSingleton {
1819
2482
  await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
1820
2483
  result.WatermarkAfter = currentAfterKey;
1821
2484
  }
2485
+ else if (!hadFetchGap && currentWatermark && currentWatermark !== initialWatermark) {
2486
+ // A WATERMARK-based connector stopped early (cancel / safety limit / duplicate batch /
2487
+ // schema-not-generated / unskippable fetch error) but whole batches DID complete. Persist the
2488
+ // max watermark seen so the next run resumes from there instead of re-fetching everything
2489
+ // since the last clean run — the counterpart of the keyset branch above, which until now was
2490
+ // the ONLY early-stop that saved its position. The cancel log has always said "saving
2491
+ // watermark"; for a non-keyset connector it previously saved nothing.
2492
+ //
2493
+ // Safe because currentWatermark only ever advances at the END of a fully-applied batch
2494
+ // (§10, after ApplyRecords) — every early-exit `break` above happens before that, so this
2495
+ // value can never point past a record that wasn't written.
2496
+ //
2497
+ // Deliberately NOT wall-clock "now", even for a full sync: coverage is partial, so advancing
2498
+ // past the point actually reached would skip the (reached, now] window permanently. And
2499
+ // deliberately NOT when hadFetchGap — a skipped page leaves a HOLE behind this watermark,
2500
+ // which is why that path holds it for a full re-fetch next run.
2501
+ const partialWatermark = currentWatermark;
2502
+ await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, partialWatermark, contextUser, 'Pull'));
2503
+ result.WatermarkAfter = partialWatermark;
2504
+ }
1822
2505
  // Orphan detection: delete/tombstone MJ records whose external counterpart no longer exists.
1823
2506
  // Runs on a full sync OR a partition-reconcile (both fetch the COMPLETE set, so an MJ record
1824
2507
  // whose ExternalID isn't in fetchedExternalIDs is genuinely gone — even one inside an otherwise
@@ -1857,6 +2540,59 @@ export class IntegrationEngine extends BaseSingleton {
1857
2540
  await this.CreateRunDetail(run, entityMap, result, contextUser);
1858
2541
  return result;
1859
2542
  }
2543
+ /**
2544
+ * Classifies a fetched batch against the `ctx.BatchSize` the engine asked for. Returns null when
2545
+ * the batch is within contract, so the caller can treat "no verdict" as "nothing to warn about".
2546
+ *
2547
+ * Two severities, because they are different defects:
2548
+ * - `CONNECTOR_UNBOUNDED_BATCH` — the FIRST batch came back over-size AND `HasMore` is not true,
2549
+ * i.e. the connector ignored pagination entirely and pulled the whole object into memory in one
2550
+ * request. That is what OOMs a large tenant, and it grows silently with the customer's data.
2551
+ * - `CONNECTOR_IGNORED_BATCH_SIZE` — the connector IS paging but overshoots the requested size
2552
+ * (e.g. a hardcoded page size). Bounded memory, still a contract violation worth fixing.
2553
+ *
2554
+ * Public + static because the decision is the interesting part and deserves a unit test that
2555
+ * doesn't have to stand up a whole sync (same rationale as {@link RatchetProgressSnapshot}).
2556
+ */
2557
+ static ClassifyOversizedBatch(objectName, recordCount, requestedBatchSize, batchIndex, hasMore) {
2558
+ if (recordCount <= requestedBatchSize)
2559
+ return null;
2560
+ const unbounded = batchIndex === 1 && hasMore !== true;
2561
+ return unbounded
2562
+ ? {
2563
+ Code: 'CONNECTOR_UNBOUNDED_BATCH',
2564
+ Unbounded: true,
2565
+ Message: `'${objectName}': connector returned ALL ${recordCount} records in a single batch ` +
2566
+ `(requested ${requestedBatchSize}, HasMore=false) — pagination is not implemented for this ` +
2567
+ `object, so the entire object is held in memory. Every record was written, in chunks.`,
2568
+ }
2569
+ : {
2570
+ Code: 'CONNECTOR_IGNORED_BATCH_SIZE',
2571
+ Unbounded: false,
2572
+ Message: `'${objectName}': connector returned ${recordCount} records for batch ${batchIndex} ` +
2573
+ `(requested ${requestedBatchSize}) — ctx.BatchSize is not being honoured. Every record was ` +
2574
+ `written, in chunks. Warned once per object.`,
2575
+ };
2576
+ }
2577
+ /**
2578
+ * Surfaces an over-size batch onto the structured run-event stream (queryable via
2579
+ * IntegrationTailRunEvents) so the pagination-rule violation is visible instead of console-only.
2580
+ * Never truncates and never fails the sync — the over-size batch is written in chunks either way.
2581
+ */
2582
+ warnOversizedBatch(entityMap, batch, batchIndex, logger) {
2583
+ const objectName = entityMap.ExternalObjectName ?? entityMap.ID;
2584
+ const verdict = IntegrationEngine.ClassifyOversizedBatch(objectName, batch.Records.length, this.MaxBatchSize, batchIndex, batch.HasMore);
2585
+ if (!verdict)
2586
+ return;
2587
+ logger?.warning(objectName, verdict.Code, verdict.Message, {
2588
+ batchIndex,
2589
+ recordCount: batch.Records.length,
2590
+ requestedBatchSize: this.MaxBatchSize,
2591
+ hasMore: batch.HasMore ?? null,
2592
+ unbounded: verdict.Unbounded,
2593
+ });
2594
+ console.warn(`[IntegrationEngine] ${verdict.Message}`);
2595
+ }
1860
2596
  /**
1861
2597
  * Push sync: detect local MJ record changes → reverse-map fields → push to external system.
1862
2598
  *
@@ -1867,6 +2603,7 @@ export class IntegrationEngine extends BaseSingleton {
1867
2603
  async ProcessPushSync(config, entityMap, run, contextUser, _entityMapIndex, _totalEntityMaps, _onProgress, _abortSignal, logger) {
1868
2604
  const entityMapID = entityMap.ID;
1869
2605
  const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
2606
+ this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
1870
2607
  const pushWatermark = await this.watermarkService.Load(entityMapID, contextUser, 'Push');
1871
2608
  const lastPushAt = pushWatermark?.WatermarkValue ?? null;
1872
2609
  // Check connector write capability
@@ -1924,7 +2661,19 @@ export class IntegrationEngine extends BaseSingleton {
1924
2661
  // lexicographically, matching the SQL `ChangedAt > '...'` filter in LoadChangedMJRecords.
1925
2662
  let firstErrorChangeAt = null; // min ChangedAt among failed pushes
1926
2663
  const successfulChangeAts = [];
2664
+ let recordsSinceBoundary = 0;
1927
2665
  for (const change of changedRecords) {
2666
+ // Push has no natural batch, so impose a fence boundary every MaxBatchSize records:
2667
+ // a reclaimed run must stop writing to the VENDOR promptly, not at end-of-map. The
2668
+ // check sits OUTSIDE the per-record try so RunOwnershipLostError propagates (the
2669
+ // per-record catch must never swallow it). A cross-process cancel trips the abort
2670
+ // signal, honored at the top of each iteration.
2671
+ if (recordsSinceBoundary === 0)
2672
+ await this.assertOwnershipAtBoundary();
2673
+ if (++recordsSinceBoundary >= this.MaxBatchSize)
2674
+ recordsSinceBoundary = 0;
2675
+ if (_abortSignal?.aborted)
2676
+ break;
1928
2677
  result.RecordsProcessed++;
1929
2678
  try {
1930
2679
  await this.PushSingleRecord(change, config, entityMap, pushFieldMaps, result, contextUser, logger);
@@ -2361,12 +3110,25 @@ export class IntegrationEngine extends BaseSingleton {
2361
3110
  const orphans = allMaps.Rows.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
2362
3111
  if (orphans.length === 0)
2363
3112
  return;
3113
+ // The sweep is a DELETE PATH and must answer to the same policy as every other delete.
3114
+ // It used to call entity.Delete() unconditionally — its own warning text promised
3115
+ // "archived/deleted" while the code only ever deleted, so a map configured SoftDelete
3116
+ // (or DoNothing) had its rows physically removed by full syncs. DoNothing short-circuits
3117
+ // the whole sweep: the policy says external deletions never touch MJ rows, and saying so
3118
+ // once beats detecting the same "orphans" forever.
3119
+ if (entityMap.DeleteBehavior === 'DoNothing') {
3120
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_POLICY_SKIPPED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this ` +
3121
+ `full sync. This map's DeleteBehavior is 'DoNothing', so none were touched.`, { orphanCount: orphans.length });
3122
+ return;
3123
+ }
2364
3124
  console.log(`[IntegrationEngine] Orphan detection for ${entityMap.ExternalObjectName}: ${orphans.length} records in MJ not found in external system`);
2365
3125
  // Surface delete-detection in the structured stream (previously console-only). The orphan
2366
3126
  // COUNT is already in the run counts via RecordsDeleted, but a dedicated warning makes a
2367
3127
  // large/unexpected count visible over GraphQL — the early signal of an incomplete upstream
2368
3128
  // fetch silently archiving live records.
2369
- logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync — they will be archived/deleted (delete-detection). A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
3129
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync — ` +
3130
+ `they will be ${entityMap.DeleteBehavior === 'SoftDelete' ? 'archived (SoftDelete)' : 'deleted'} and their record-map rows pruned (delete-detection). ` +
3131
+ `A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
2370
3132
  const md = this.ProviderToUse;
2371
3133
  const entityInfo = md.EntityByName(entityMap.Entity);
2372
3134
  const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
@@ -2376,11 +3138,44 @@ export class IntegrationEngine extends BaseSingleton {
2376
3138
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(orphan.EntityRecordID, pkFields));
2377
3139
  if (!loaded) {
2378
3140
  console.log(`[IntegrationEngine] Orphan ${orphan.EntityRecordID} already deleted from MJ`);
3141
+ // The map row outlived its record. Nothing anywhere else deletes record-map
3142
+ // rows, so without this the same "orphan" is re-detected on EVERY subsequent
3143
+ // full sync and ORPHANS_DETECTED becomes a cumulative counter of history
3144
+ // rather than a signal about THIS run — observed live as a count that only
3145
+ // ever grew, sync after sync.
3146
+ await this.DeleteRecordMapRow(orphan.ID, contextUser);
3147
+ continue;
3148
+ }
3149
+ if (entityMap.DeleteBehavior === 'SoftDelete') {
3150
+ // Same archive shape as DeleteRecord's SoftDelete branch: the row stays,
3151
+ // marked Archived/tombstoned. The MAP row goes either way — the mapping's
3152
+ // job is done, and keeping it would re-detect this orphan forever.
3153
+ const fields = entity.Fields ?? [];
3154
+ const hasField = (n) => fields.some(f => f.Name === n);
3155
+ if (hasField('__mj_integration_SyncStatus'))
3156
+ entity.Set('__mj_integration_SyncStatus', 'Archived');
3157
+ if (hasField('__mj_integration_LastSyncedAt'))
3158
+ entity.Set('__mj_integration_LastSyncedAt', new Date().toISOString());
3159
+ if (hasField('__mj_integration_IsTombstoned'))
3160
+ entity.Set('__mj_integration_IsTombstoned', true);
3161
+ if (hasField('__mj_integration_DeletedDetectedAt'))
3162
+ entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
3163
+ const archived = await entity.Save();
3164
+ if (archived) {
3165
+ result.RecordsDeleted++;
3166
+ await this.DeleteRecordMapRow(orphan.ID, contextUser);
3167
+ console.log(`[IntegrationEngine] Archived orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
3168
+ }
3169
+ else {
3170
+ const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
3171
+ console.warn(`[IntegrationEngine] Orphan archive blocked for ${entityMap.Entity} ${orphan.EntityRecordID} — ${reason}`);
3172
+ }
2379
3173
  continue;
2380
3174
  }
2381
3175
  const deleted = await entity.Delete();
2382
3176
  if (deleted) {
2383
3177
  result.RecordsDeleted++;
3178
+ await this.DeleteRecordMapRow(orphan.ID, contextUser);
2384
3179
  console.log(`[IntegrationEngine] Deleted orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
2385
3180
  }
2386
3181
  else {
@@ -2394,6 +3189,28 @@ export class IntegrationEngine extends BaseSingleton {
2394
3189
  }
2395
3190
  }
2396
3191
  }
3192
+ /**
3193
+ * Removes one 'MJ: Company Integration Record Maps' row after delete-detection has handled
3194
+ * its orphan (deleted, archived, or found already gone). A failure here is logged and
3195
+ * swallowed: the orphan itself was handled, and the worst consequence of a surviving map
3196
+ * row is one redundant re-detection on the next full sync.
3197
+ */
3198
+ async DeleteRecordMapRow(mapRowID, contextUser) {
3199
+ try {
3200
+ const md = this.ProviderToUse;
3201
+ const mapRow = await md.GetEntityObject('MJ: Company Integration Record Maps', contextUser);
3202
+ const loaded = await mapRow.InnerLoad(CompositeKey.FromID(mapRowID));
3203
+ if (!loaded)
3204
+ return;
3205
+ const ok = await mapRow.Delete();
3206
+ if (!ok) {
3207
+ console.warn(`[IntegrationEngine] Record-map prune blocked for ${mapRowID} — ${mapRow.LatestResult?.CompleteMessage ?? 'unknown reason'}`);
3208
+ }
3209
+ }
3210
+ catch (err) {
3211
+ console.warn(`[IntegrationEngine] Record-map prune failed for ${mapRowID}: ${err instanceof Error ? err.message : String(err)}`);
3212
+ }
3213
+ }
2397
3214
  /**
2398
3215
  * Strips field-name prefixes from a RecordChange.RecordID to get the raw entity record ID.
2399
3216
  * "hs_object_id|465950833372" → "465950833372"
@@ -2515,6 +3332,8 @@ export class IntegrationEngine extends BaseSingleton {
2515
3332
  result.RecordsSkipped += recs.length;
2516
3333
  continue;
2517
3334
  }
3335
+ // Partition boundary = a batch boundary: fence-check before this partition's writes.
3336
+ await this.assertOwnershipAtBoundary();
2518
3337
  // D3: serialize the match READ through the same write-mutex the non-partition path uses
2519
3338
  // (~line 1644). matchEngine.Resolve reads existing MJ rows on the SHARED provider
2520
3339
  // connection, so when streams run in parallel (syncConcurrency>1) it must not interleave
@@ -2844,7 +3663,7 @@ export class IntegrationEngine extends BaseSingleton {
2844
3663
  try {
2845
3664
  switch (record.ChangeType) {
2846
3665
  case 'Create': {
2847
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
3666
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
2848
3667
  if (outcome === 'updated')
2849
3668
  result.RecordsUpdated++;
2850
3669
  else if (outcome === 'skipped')
@@ -2854,7 +3673,7 @@ export class IntegrationEngine extends BaseSingleton {
2854
3673
  break;
2855
3674
  }
2856
3675
  case 'Update':
2857
- await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps);
3676
+ await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps, logger);
2858
3677
  break;
2859
3678
  case 'Delete': {
2860
3679
  const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
@@ -2914,7 +3733,9 @@ export class IntegrationEngine extends BaseSingleton {
2914
3733
  *
2915
3734
  * @returns true if an existing row was updated, false if a new row was inserted (so the caller counts correctly).
2916
3735
  */
2917
- async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps) {
3736
+ async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps,
3737
+ /** Optional — lets the keyless-key guard below surface on the run's event stream. */
3738
+ keylessLogger) {
2918
3739
  const md = this.ProviderToUse;
2919
3740
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
2920
3741
  const entityInfo = md.EntityByName(record.MJEntityName);
@@ -2923,6 +3744,31 @@ export class IntegrationEngine extends BaseSingleton {
2923
3744
  // ID), check whether that row already exists before deciding INSERT vs UPDATE. A null mappedPK
2924
3745
  // (e.g. a server-assigned UUID PK not present in the mapped fields) means a genuinely new row.
2925
3746
  const mappedPK = this.extractMappedPrimaryKey(record, pkFields);
3747
+ // INVARIANT: a record destined for a SOFT-primary-key table must carry its key.
3748
+ //
3749
+ // A soft PK is INFERRED, not generated — it is the external system's own identifier, stored
3750
+ // as ordinary data with no PRIMARY KEY constraint, no identity, and no unique index (see
3751
+ // DDLGenerator: soft keys get a non-unique index only). So nothing at the database level
3752
+ // rejects a NULL key, and a row written without one can never be matched again: the next
3753
+ // sync's existence check misses it, and inserts another copy. Every pass therefore multiplies
3754
+ // the damage silently — the rows look fully populated, only the key column is empty.
3755
+ //
3756
+ // `mappedPK == null` is legitimate ONLY when the destination generates its own key (an
3757
+ // identity column or a server-assigned UUID), which is why the check is scoped to soft PKs
3758
+ // rather than applied to every table.
3759
+ const keyless = DecideKeylessRefusal(mappedPK, pkFields, MissingKeyFieldNames(record.MappedFields, pkFields, serializeKeyValue));
3760
+ if (keyless.Refuse) {
3761
+ const detail = DescribeKeylessRefusal(record.MJEntityName, keyless.KeyNames);
3762
+ keylessLogger?.emit('sync.record.error', {
3763
+ phase: 'write',
3764
+ externalObjectName: entityMap.ExternalObjectName,
3765
+ externalID: record.ExternalRecord?.ExternalID,
3766
+ error: `KEYLESS_RECORD_REFUSED: ${detail}`,
3767
+ });
3768
+ // Reported even without a logger — silence is the failure mode this guard exists to end.
3769
+ console.error(`[IntegrationEngine] KEYLESS_RECORD_REFUSED: ${detail}`);
3770
+ return 'skipped';
3771
+ }
2926
3772
  const existed = mappedPK != null
2927
3773
  ? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
2928
3774
  : false;
@@ -3008,10 +3854,12 @@ export class IntegrationEngine extends BaseSingleton {
3008
3854
  * If the record cannot be loaded (e.g. it was deleted or never fully created),
3009
3855
  * falls back to CreateRecord (upsert behavior).
3010
3856
  */
3011
- async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps) {
3857
+ async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps,
3858
+ /** Forwarded to CreateRecord's keyless-key guard on the upsert fallback paths. */
3859
+ logger) {
3012
3860
  if (!record.MatchedMJRecordID) {
3013
3861
  // No matched ID — upsert by PK (insert; or update/skip if the PK already exists)
3014
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
3862
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
3015
3863
  if (outcome === 'updated')
3016
3864
  result.RecordsUpdated++;
3017
3865
  else if (outcome === 'skipped')
@@ -3059,7 +3907,7 @@ export class IntegrationEngine extends BaseSingleton {
3059
3907
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
3060
3908
  if (!loaded) {
3061
3909
  // Matched-ID row vanished — fall back to upsert by PK (insert; or update/skip if PK exists)
3062
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
3910
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
3063
3911
  if (outcome === 'updated')
3064
3912
  result.RecordsUpdated++;
3065
3913
  else if (outcome === 'skipped')
@@ -3259,6 +4107,31 @@ export class IntegrationEngine extends BaseSingleton {
3259
4107
  }
3260
4108
  return key;
3261
4109
  }
4110
+ /**
4111
+ * Reports ACTIVE field maps whose MJ column does not exist, once per entity map per run.
4112
+ *
4113
+ * `BaseEntity.Set` no-ops on an unknown field — no throw, no log, no dirty flag — so a map
4114
+ * pointing at a column that was never applied (or was renamed) drops its value for every
4115
+ * record while the run reports those records as written. Checking it here costs one metadata
4116
+ * read and happens before the first fetch, so the warning arrives before the wasted work.
4117
+ */
4118
+ WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger) {
4119
+ const entityName = entityMap.Entity ?? '';
4120
+ // Diagnostics must never be able to fail a run: an unresolvable entity/provider is reported
4121
+ // by the paths that actually need it, and here it simply means there is nothing to check.
4122
+ let entityFieldNames = [];
4123
+ try {
4124
+ const entityInfo = entityName ? this.ProviderToUse?.EntityByName(entityName) : null;
4125
+ entityFieldNames = entityInfo?.Fields?.map(f => f.Name) ?? [];
4126
+ }
4127
+ catch {
4128
+ return;
4129
+ }
4130
+ const unbindable = FindUnbindableFieldMaps(fieldMaps, entityFieldNames);
4131
+ if (unbindable.length === 0)
4132
+ return;
4133
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FIELD_MAP_DESTINATION_MISSING', DescribeUnbindableFieldMaps(unbindable, entityMap.ExternalObjectName ?? entityMap.ID, entityName), { fieldMaps: unbindable });
4134
+ }
3262
4135
  /**
3263
4136
  * Sets fields on a BaseEntity instance from a field value map.
3264
4137
  */
@@ -3619,12 +4492,19 @@ export class IntegrationEngine extends BaseSingleton {
3619
4492
  const existing = await rv.RunView({
3620
4493
  EntityName: 'MJ: Company Integration Record Maps',
3621
4494
  ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID=${quotedExternalID}`,
3622
- Fields: ['ID'],
4495
+ Fields: ['ID', 'EntityRecordID'],
3623
4496
  MaxRows: 1,
3624
4497
  ResultType: 'simple',
3625
4498
  BypassCache: true, // upsert-by-identity: a stale miss here re-creates a duplicate record map
3626
4499
  }, contextUser);
3627
4500
  if (existing.Success && existing.Results.length > 0) {
4501
+ // The row already says exactly this. On an incremental sync that is nearly every record
4502
+ // — mappings are stable — and the Load + Save this path used to do anyway were two more
4503
+ // round trips to change nothing, plus a meaningless __mj_UpdatedAt bump on every synced
4504
+ // record's map row. The batched writer (RecordMapBatch.flushChunk) has always skipped
4505
+ // this case; the per-record fallback now agrees with it.
4506
+ if (existing.Results[0].EntityRecordID === entityRecordID)
4507
+ return;
3628
4508
  const loaded = await recordMap.Load(existing.Results[0].ID);
3629
4509
  if (!loaded)
3630
4510
  recordMap.NewRecord();
@@ -3748,23 +4628,36 @@ export class IntegrationEngine extends BaseSingleton {
3748
4628
  async FinalizeRun(run, result, contextUser, onNotification, aborted) {
3749
4629
  run.EndedAt = new Date();
3750
4630
  run.TotalRecords = result.RecordsProcessed;
3751
- // A user/system-cancelled run must NOT be recorded as 'Success' that hides the
3752
- // cancellation in run history (indistinguishable from a clean completion) and is wrong
3753
- // for any downstream cadence/health logic. Until a first-class 'Cancelled' status value
3754
- // exists on CompanyIntegrationRun (Status value list is Pending/In Progress/Success/Failed),
3755
- // finalize an aborted run as 'Failed' with an explicit ErrorLog. The durable progress
3756
- // artifact additionally carries exitReason='aborted' (see finalizeSyncProgress) so a stopped
3757
- // run stays distinguishable from a real failure over GraphQL.
4631
+ // A cancelled run is neither a success nor a failure, and 'Cancelled' is now a first-class
4632
+ // value in the Status list so record it as itself. Previously this had to be 'Failed' with
4633
+ // an explanatory ErrorLog, which meant every health/cadence consumer counted deliberate
4634
+ // cancellations as errors unless it string-matched that text. The ErrorLog is still written
4635
+ // (it carries the reason), and the durable progress artifact still carries
4636
+ // exitReason='aborted' (see finalizeSyncProgress).
4637
+ // Held in a local so the release below sends the SAME value. Deriving it there from
4638
+ // `run.Status` with a two-way test collapsed everything non-Success to 'Failed', which would
4639
+ // have overwritten 'Cancelled' in the release UPDATE — the sproc assigns Status = @FinalStatus,
4640
+ // so the row's carefully-set status would be undone one statement later.
4641
+ let terminalStatus;
3758
4642
  if (aborted) {
3759
- run.Status = 'Failed';
4643
+ terminalStatus = 'Cancelled';
3760
4644
  run.ErrorLog = result.ErrorMessage ?? 'Sync cancelled by user';
3761
4645
  }
3762
4646
  else {
3763
- run.Status = result.RecordsErrored > 0 ? 'Failed' : 'Success';
4647
+ terminalStatus = result.RecordsErrored > 0 ? 'Failed' : 'Success';
3764
4648
  if (result.Errors.length > 0) {
3765
4649
  run.ErrorLog = JSON.stringify(result.Errors.slice(0, 100));
3766
4650
  }
3767
4651
  }
4652
+ run.Status = terminalStatus;
4653
+ // The generated spUpdate writes EVERY column from the entity's in-memory state.
4654
+ // This run entity was loaded BEFORE the claim, so without a sync its ownership
4655
+ // columns (FenceToken 0, OwnerToken null, stale lease) would clobber the DB's live
4656
+ // values on save — silently un-fencing the run. Sync them to the service's
4657
+ // last-known-authoritative values first; Release() below then clears ownership
4658
+ // atomically (token-checked, so a stale holder's release no-ops).
4659
+ const ownership = this.currentRunContext?.ownership;
4660
+ ownership?.SyncEntityOwnershipFields(run);
3768
4661
  // Retry the finalize save: a failed save leaves the run 'In Progress', which ResumeOrphanedSyncs
3769
4662
  // re-queues on next startup → the whole sync re-runs (re-fetch + re-apply). Worth a few retries to
3770
4663
  // make the terminal status durable. Both a thrown infra error and a `false` logical-failure retry.
@@ -3781,6 +4674,16 @@ export class IntegrationEngine extends BaseSingleton {
3781
4674
  `${saveErr instanceof Error ? saveErr.message : String(saveErr)}. ` +
3782
4675
  `Run may remain 'In Progress' and be re-queued as orphaned on next startup.`);
3783
4676
  }
4677
+ // Terminal release: clear OwnerToken/LeaseExpiresAt and (re-)stamp the final status
4678
+ // in one token-checked statement, so the row is immediately claimable-clean.
4679
+ if (ownership) {
4680
+ try {
4681
+ await ownership.Release(terminalStatus);
4682
+ }
4683
+ catch (releaseErr) {
4684
+ console.warn(`[IntegrationEngine] Run ${run.ID} release failed (non-fatal — lease will simply expire): ${releaseErr instanceof Error ? releaseErr.message : String(releaseErr)}`);
4685
+ }
4686
+ }
3784
4687
  if (onNotification) {
3785
4688
  const notification = this.buildCompletionNotification(run, result);
3786
4689
  this.safeNotify(onNotification, notification);
@@ -3850,7 +4753,17 @@ export class IntegrationEngine extends BaseSingleton {
3850
4753
  run.EndedAt = new Date();
3851
4754
  run.Status = 'Failed';
3852
4755
  run.ErrorLog = err instanceof Error ? err.message : String(err);
4756
+ // Same full-row-save hazard as FinalizeRun: sync the in-memory ownership columns to the
4757
+ // claim's live values before Save, then release (token-checked — a fenced-out holder no-ops).
4758
+ const ownership = this.currentRunContext?.ownership;
4759
+ ownership?.SyncEntityOwnershipFields(run);
3853
4760
  await run.Save();
4761
+ if (ownership) {
4762
+ try {
4763
+ await ownership.Release('Failed');
4764
+ }
4765
+ catch { /* non-fatal — lease will expire */ }
4766
+ }
3854
4767
  if (onNotification) {
3855
4768
  const failResult = {
3856
4769
  Success: false,
@@ -3968,7 +4881,7 @@ export class IntegrationEngine extends BaseSingleton {
3968
4881
  CompanyIntegrationID: companyIntegrationID,
3969
4882
  ContextUser: contextUser,
3970
4883
  SyncedEntityNames: syncedEntityNames,
3971
- Provider: this._provider,
4884
+ Provider: this.currentRunContext?.provider,
3972
4885
  // The run's in-memory custom-key candidates — needed because the
3973
4886
  // overflow-column scan alone under-reports once the hash basis excludes
3974
4887
  // overflow (skipped rows never write their overflow JSON).
@@ -4054,6 +4967,20 @@ export class IntegrationEngine extends BaseSingleton {
4054
4967
  GetIntegrationObjectFields(objectID) {
4055
4968
  return this.Base.GetIntegrationObjectFields(objectID);
4056
4969
  }
4970
+ /**
4971
+ * Source field names the connector declared as SyncDirective 'Exclude' for one
4972
+ * integration object, read from IntegrationObjectField.Configuration. Empty set
4973
+ * on any lookup miss (unknown object, no fields, no integration id) - a failed
4974
+ * lookup must never widen or narrow the sync beyond its declared behaviour.
4975
+ */
4976
+ ResolveExcludedSourceNames(integrationID, externalObjectName) {
4977
+ if (!integrationID || !externalObjectName)
4978
+ return new Set();
4979
+ const obj = this.GetIntegrationObject(integrationID, externalObjectName);
4980
+ if (!obj)
4981
+ return new Set();
4982
+ return ComputeExcludedSourceNames(this.GetIntegrationObjectFields(obj.ID));
4983
+ }
4057
4984
  GetActiveIntegrationObjects(integrationID) {
4058
4985
  return this.Base.GetActiveIntegrationObjects(integrationID);
4059
4986
  }