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

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 (85) hide show
  1. package/dist/BaseIntegrationConnector.d.ts +26 -2
  2. package/dist/BaseIntegrationConnector.d.ts.map +1 -1
  3. package/dist/BaseIntegrationConnector.js +145 -23
  4. package/dist/BaseIntegrationConnector.js.map +1 -1
  5. package/dist/BaseRESTIntegrationConnector.d.ts +62 -8
  6. package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
  7. package/dist/BaseRESTIntegrationConnector.js +200 -20
  8. package/dist/BaseRESTIntegrationConnector.js.map +1 -1
  9. package/dist/BatchIdentity.d.ts +47 -0
  10. package/dist/BatchIdentity.d.ts.map +1 -0
  11. package/dist/BatchIdentity.js +78 -0
  12. package/dist/BatchIdentity.js.map +1 -0
  13. package/dist/DeclaredSampleMerge.d.ts +74 -0
  14. package/dist/DeclaredSampleMerge.d.ts.map +1 -0
  15. package/dist/DeclaredSampleMerge.js +104 -0
  16. package/dist/DeclaredSampleMerge.js.map +1 -0
  17. package/dist/DiscoveryWatchdog.d.ts +103 -0
  18. package/dist/DiscoveryWatchdog.d.ts.map +1 -0
  19. package/dist/DiscoveryWatchdog.js +166 -0
  20. package/dist/DiscoveryWatchdog.js.map +1 -0
  21. package/dist/FieldMapValidation.d.ts +40 -0
  22. package/dist/FieldMapValidation.d.ts.map +1 -0
  23. package/dist/FieldMapValidation.js +58 -0
  24. package/dist/FieldMapValidation.js.map +1 -0
  25. package/dist/FieldMappingEngine.d.ts +1 -13
  26. package/dist/FieldMappingEngine.d.ts.map +1 -1
  27. package/dist/FieldMappingEngine.js +18 -3
  28. package/dist/FieldMappingEngine.js.map +1 -1
  29. package/dist/IntegrationConnectorCreationPipeline.d.ts +20 -0
  30. package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
  31. package/dist/IntegrationConnectorCreationPipeline.js +203 -30
  32. package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
  33. package/dist/IntegrationEngine.d.ts +204 -1
  34. package/dist/IntegrationEngine.d.ts.map +1 -1
  35. package/dist/IntegrationEngine.js +1224 -262
  36. package/dist/IntegrationEngine.js.map +1 -1
  37. package/dist/IntegrationSchemaSync.d.ts +54 -2
  38. package/dist/IntegrationSchemaSync.d.ts.map +1 -1
  39. package/dist/IntegrationSchemaSync.js +122 -16
  40. package/dist/IntegrationSchemaSync.js.map +1 -1
  41. package/dist/KeylessRecordGuard.d.ts +56 -0
  42. package/dist/KeylessRecordGuard.d.ts.map +1 -0
  43. package/dist/KeylessRecordGuard.js +80 -0
  44. package/dist/KeylessRecordGuard.js.map +1 -0
  45. package/dist/ResumeConcurrency.d.ts +46 -0
  46. package/dist/ResumeConcurrency.d.ts.map +1 -0
  47. package/dist/ResumeConcurrency.js +74 -0
  48. package/dist/ResumeConcurrency.js.map +1 -0
  49. package/dist/RetryAfter.d.ts +50 -0
  50. package/dist/RetryAfter.d.ts.map +1 -0
  51. package/dist/RetryAfter.js +127 -0
  52. package/dist/RetryAfter.js.map +1 -0
  53. package/dist/RetryRunner.d.ts +26 -2
  54. package/dist/RetryRunner.d.ts.map +1 -1
  55. package/dist/RetryRunner.js +11 -3
  56. package/dist/RetryRunner.js.map +1 -1
  57. package/dist/StreamingDiscovery.d.ts +11 -0
  58. package/dist/StreamingDiscovery.d.ts.map +1 -1
  59. package/dist/StreamingDiscovery.js +13 -3
  60. package/dist/StreamingDiscovery.js.map +1 -1
  61. package/dist/SyncDirectives.d.ts +66 -0
  62. package/dist/SyncDirectives.d.ts.map +1 -0
  63. package/dist/SyncDirectives.js +123 -0
  64. package/dist/SyncDirectives.js.map +1 -0
  65. package/dist/SyncLogger.d.ts +2 -1
  66. package/dist/SyncLogger.d.ts.map +1 -1
  67. package/dist/SyncLogger.js +1 -0
  68. package/dist/SyncLogger.js.map +1 -1
  69. package/dist/WatermarkService.d.ts +15 -0
  70. package/dist/WatermarkService.d.ts.map +1 -1
  71. package/dist/WatermarkService.js +26 -0
  72. package/dist/WatermarkService.js.map +1 -1
  73. package/dist/WriteSerializer.d.ts +44 -0
  74. package/dist/WriteSerializer.d.ts.map +1 -0
  75. package/dist/WriteSerializer.js +70 -0
  76. package/dist/WriteSerializer.js.map +1 -0
  77. package/dist/index.d.ts +10 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +8 -1
  80. package/dist/index.js.map +1 -1
  81. package/dist/types.d.ts +45 -1
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/types.js +66 -1
  84. package/dist/types.js.map +1 -1
  85. package/package.json +7 -7
@@ -1,10 +1,11 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
- import { CompositeKey, DatabaseProviderBase, LogStatusEx, Metadata, RunView } from '@memberjunction/core';
2
+ import { CompositeKey, DatabaseProviderBase, LogStatusEx, Metadata, RunView, EntitySaveOptions, EntityDeleteOptions } from '@memberjunction/core';
3
3
  import { RunOwnershipLostError, RunOwnershipService } from './RunOwnershipService.js';
4
4
  import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
5
5
  import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
6
6
  import { ClassifyError, IsRetryableError } from './types.js';
7
7
  import { WithRetry } from './RetryRunner.js';
8
+ import { DecideKeylessRefusal, DescribeKeylessRefusal, MissingKeyFieldNames } from './KeylessRecordGuard.js';
8
9
  import { WithTimeout, OperationTimeoutError, DEFAULT_OPERATION_TIMEOUTS } from './BaseIntegrationConnector.js';
9
10
  import { ConnectorFactory } from './ConnectorFactory.js';
10
11
  import { FieldMappingEngine } from './FieldMappingEngine.js';
@@ -16,11 +17,16 @@ import { RecordMapBatch } from './RecordMapBatch.js';
16
17
  import { buildContentHashPrefetchFilter, quoteTextLiteral } from './prefetchFilter.js';
17
18
  import { serializeKeyValue } from './KeySerialization.js';
18
19
  import { CUSTOM_OVERFLOW_COLUMN, reconcileOverflowValue, foldCustomKeyStats } from './CustomOverflow.js';
20
+ import { ComputeExcludedSourceNames } from './SyncDirectives.js';
21
+ import { DescribeUnbindableFieldMaps, FindUnbindableFieldMaps } from './FieldMapValidation.js';
19
22
  import { partitionRecords, partitionRollupHash, diffPartitions, partitionKeyForIdentity } from './HashDiff.js';
20
23
  import { RateLimiter } from './RateLimiter.js';
21
24
  import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrency.js';
22
25
  import { mostRecentWinner } from './ConflictRecency.js';
23
26
  import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
27
+ import { CollapseDuplicateIdentities } from './BatchIdentity.js';
28
+ import { WriteSerializer } from './WriteSerializer.js';
29
+ import { ResumeConcurrency, RunResumesBounded } from './ResumeConcurrency.js';
24
30
  /** Default batch size for fetching records from external systems */
25
31
  const DEFAULT_BATCH_SIZE = 200;
26
32
  /**
@@ -89,6 +95,33 @@ export class SchemaNotGeneratedError extends Error {
89
95
  this.StoredProcedureName = storedProcedureName;
90
96
  }
91
97
  }
98
+ /**
99
+ * Thrown by a connector when the vendor says THIS account cannot serve an object it nonetheless
100
+ * lists in its catalog (e.g. a record type the account has not enabled). Distinct from a fetch
101
+ * failure: retrying cannot help WITHIN a run, so the map ends cleanly instead of climbing a retry
102
+ * ladder — one warning, watermark untouched.
103
+ *
104
+ * It is deliberately not remembered ACROSS runs. The next run simply asks again, which is what makes
105
+ * an account change self-healing with no marker, no recheck clock and no override to get right.
106
+ *
107
+ * Connectors are NOT required to import this class — the engine also recognises any error carrying
108
+ * `code === 'OBJECT_UNAVAILABLE'`, so a connector can classify one without a peer version bump.
109
+ */
110
+ export class ObjectUnavailableError extends Error {
111
+ constructor(objectName, vendorMessage) {
112
+ super(`The source cannot serve "${objectName}" for this account: ${vendorMessage}`);
113
+ this.code = 'OBJECT_UNAVAILABLE';
114
+ this.name = 'ObjectUnavailableError';
115
+ this.VendorMessage = vendorMessage;
116
+ }
117
+ }
118
+ /** Recognises an unavailability signal from a connector that imported the class OR just set the code. */
119
+ export function IsObjectUnavailable(err) {
120
+ if (err instanceof ObjectUnavailableError)
121
+ return true;
122
+ return typeof err === 'object' && err !== null
123
+ && err.code === 'OBJECT_UNAVAILABLE';
124
+ }
92
125
  /**
93
126
  * §29 — base of the VALUE-FIT error family: a mapped value cannot fit/coerce into its destination
94
127
  * column's type. Per the bounded-typing policy (small columns; large content is an explicit text/json
@@ -144,6 +177,24 @@ const INTEGER_SQL_BOUNDS = {
144
177
  * functions are generated, so it must be classified too (else the run produces
145
178
  * per-record errors instead of one fail-fast SchemaNotGeneratedError).
146
179
  */
180
+ /**
181
+ * The one primary-key shape a TransactionGroup cannot carry: a SINGLE auto-increment column.
182
+ *
183
+ * An enrolled `Save()` returns true immediately and the row lands at `Submit()`, so the caller
184
+ * reads `entity.PrimaryKey` while the write is still queued. Every other shape survives that —
185
+ * `NewRecord()` client-generates a `uniqueidentifier`, and a composite/soft key takes its values
186
+ * from the mapped fields before the save. A server-assigned identity does not exist until the
187
+ * insert executes, so the record map would be written with a blank EntityRecordID and every
188
+ * incremental sync would re-insert the row.
189
+ *
190
+ * Defined ONCE, module-scope, because it is asked at two different levels — per record at the
191
+ * enrolment seam, and per entity map before a group is created. Two copies of this rule drifting
192
+ * apart is how a map ends up batching records that individually refuse to enrol.
193
+ */
194
+ function IsIdentityOnlyPrimaryKey(primaryKeys) {
195
+ const pks = primaryKeys ?? [];
196
+ return pks.length === 1 && pks[0]?.AutoIncrement === true;
197
+ }
147
198
  function detectSchemaNotGenerated(entityName, errorMessage) {
148
199
  const sqlServer = errorMessage.match(/Could not find stored procedure '([^']+)'/i);
149
200
  if (sqlServer)
@@ -171,6 +222,17 @@ function detectSchemaNotGenerated(entityName, errorMessage) {
171
222
  export function PositiveInt(v) {
172
223
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined;
173
224
  }
225
+ /**
226
+ * How many deferred writes may accumulate in one batched group before it is submitted and replaced.
227
+ *
228
+ * `undefined` (unset, or any unusable value) means no mid-batch flush at all — the batch is one
229
+ * group and one transaction, which is the default and the behaviour that shipped. An explicit value
230
+ * trades that atomicity for bounded memory on a heap-constrained box.
231
+ */
232
+ export function ReadFlushCeiling(env) {
233
+ const raw = parseInt(env.MJ_INTEGRATION_BATCH_FLUSH_AT ?? '', 10);
234
+ return Number.isFinite(raw) && raw >= 1 ? raw : undefined;
235
+ }
174
236
  export class IntegrationEngine extends BaseSingleton {
175
237
  constructor() {
176
238
  super();
@@ -235,10 +297,133 @@ export class IntegrationEngine extends BaseSingleton {
235
297
  * calls without threading a parameter through ~20 private signatures.
236
298
  */
237
299
  static { this.runContext = new AsyncLocalStorage(); }
300
+ /**
301
+ * The connection's opt-in write mode, from `CompanyIntegration.Configuration`.
302
+ *
303
+ * Returns `''` for absent, unparseable or wrongly-typed configuration. Every failure mode
304
+ * therefore lands on the proven per-record path: a connection has to ASK for batched writes,
305
+ * and a malformed request is not an ask.
306
+ */
307
+ ReadWriteMode(companyIntegration) {
308
+ try {
309
+ const raw = companyIntegration.Configuration;
310
+ if (!raw)
311
+ return '';
312
+ const parsed = JSON.parse(raw);
313
+ return typeof parsed.writeMode === 'string' ? parsed.writeMode : '';
314
+ }
315
+ catch {
316
+ return '';
317
+ }
318
+ }
238
319
  /** The current run's context, when called from inside a sync run. */
239
320
  get currentRunContext() {
240
321
  return IntegrationEngine.runContext.getStore();
241
322
  }
323
+ /**
324
+ * Defers this entity's write into the batch's group, when the connection asked for that.
325
+ *
326
+ * A no-op when no group is active, which is every existing caller — an entity constructed
327
+ * outside a batched apply saves immediately, exactly as before. What this does NOT do is skip
328
+ * any part of the save: `Save()` still validates, still checks row scope, still renders the
329
+ * generated procedure call, and still fires its post-save hook. Only the moment the SQL travels
330
+ * changes, and the group is what makes N of them travel together.
331
+ */
332
+ enrolInWriteGroup(entity) {
333
+ const group = this.currentRunContext?.writeGroup;
334
+ if (!group)
335
+ return;
336
+ // BATCHING REQUIRES A PK THAT EXISTS BEFORE SUBMIT.
337
+ //
338
+ // An enrolled `Save()` returns true immediately and the row lands at `Submit()`, so the
339
+ // caller reads `entity.PrimaryKey` while the write is still queued. That is fine for the
340
+ // shapes sync actually produces — `NewRecord()` client-generates the UUID for a single
341
+ // `uniqueidentifier` PK, and a composite/soft PK takes its values from the mapped fields
342
+ // before the save. It is NOT fine for a single AUTO-INCREMENT PK, whose value only exists
343
+ // after the insert executes: the record map would be written with a blank EntityRecordID,
344
+ // which is precisely the "duplicates on every incremental sync" failure the record-map
345
+ // comment in CreateRecord documents.
346
+ //
347
+ // So such an entity is left OUT of the group and saves immediately — correct, one round
348
+ // trip slower for that entity, and impossible to get silently wrong.
349
+ if (this.hasIdentityOnlyPrimaryKey(entity))
350
+ return;
351
+ entity.TransactionGroup = group;
352
+ }
353
+ /**
354
+ * True when the entity's identity is a SINGLE auto-increment column — the one primary-key
355
+ * shape whose value cannot be known until the insert has executed. See {@link enrolInWriteGroup}.
356
+ */
357
+ hasIdentityOnlyPrimaryKey(entity) {
358
+ return IsIdentityOnlyPrimaryKey(entity.EntityInfo?.PrimaryKeys);
359
+ }
360
+ /**
361
+ * The MAP-level twin of {@link hasIdentityOnlyPrimaryKey}, asked BEFORE a write group is
362
+ * created. The entity-level guard refuses enrolment per record, which is total at the seam —
363
+ * but for a map whose target is that shape it refuses EVERY record, and the branch around it
364
+ * has already committed to being atomic by construction: it skips `BeginTransaction` because
365
+ * a group exists. The result is a group that stays empty, no transaction, records saving
366
+ * one-by-one on their own, and an empty `Submit()` returning true — a NON-ATOMIC batch
367
+ * reporting success as an atomic one.
368
+ *
369
+ * Worse on the failure path: a mid-batch throw correctly skips the rollback (there is no
370
+ * transaction), then the fallback re-applies the WHOLE batch including rows that already
371
+ * committed. With a server-assigned identity there is nothing to recognise the first copy
372
+ * by, so those become duplicates — the very failure the enrolment guard exists to prevent,
373
+ * reached from the other side.
374
+ *
375
+ * So the decision is made here, at the map, and such a map falls to the `useTransaction`
376
+ * path and gets REAL atomicity instead of an empty group.
377
+ *
378
+ * Deliberately not asserted at `Submit()` instead: an empty group is a legitimate state —
379
+ * when the content-hash fast path skips every record in a batch, nothing enrols and the
380
+ * group is correctly empty. At submit time the harmful and benign cases are
381
+ * indistinguishable, which is exactly why this has to be decided up front.
382
+ */
383
+ entityMapHasIdentityOnlyPK(entityMap) {
384
+ if (!entityMap?.Entity)
385
+ return false;
386
+ const info = this.ProviderToUse?.EntityByName(entityMap.Entity);
387
+ return IsIdentityOnlyPrimaryKey(info?.PrimaryKeys);
388
+ }
389
+ /**
390
+ * The connection's write-side-effect mode from `CompanyIntegration.Configuration`. Returns ''
391
+ * for absent, unparseable or wrongly-typed configuration — every failure mode keeps the side
392
+ * effects ON. A connection has to ask, and a malformed request is not an ask.
393
+ */
394
+ ReadWriteSideEffects(companyIntegration) {
395
+ try {
396
+ const raw = companyIntegration.Get('Configuration');
397
+ if (!raw)
398
+ return '';
399
+ const parsed = JSON.parse(raw);
400
+ return typeof parsed.writeSideEffects === 'string' ? parsed.writeSideEffects : '';
401
+ }
402
+ catch {
403
+ return '';
404
+ }
405
+ }
406
+ /**
407
+ * Save options for THIS run's machine writes: suppress the audit row + geocode lookup when
408
+ * the connection asked for it, undefined otherwise (identical to the pre-feature call).
409
+ * Scoped to the sync's own saves — never to any other writer of the same entities.
410
+ */
411
+ get syncSaveOptions() {
412
+ if (!this.currentRunContext?.suppressWriteSideEffects)
413
+ return undefined;
414
+ const opts = new EntitySaveOptions();
415
+ opts.SkipRecordChanges = true;
416
+ opts.SkipGeoCoding = true;
417
+ return opts;
418
+ }
419
+ /** Delete twin of {@link syncSaveOptions}. */
420
+ get syncDeleteOptions() {
421
+ if (!this.currentRunContext?.suppressWriteSideEffects)
422
+ return undefined;
423
+ const opts = new EntityDeleteOptions();
424
+ opts.SkipRecordChanges = true;
425
+ return opts;
426
+ }
242
427
  /** Registers (or clears, with undefined) the post-sync custom-column promotion hook. */
243
428
  SetPostSyncSchemaPromotionCallback(callback) {
244
429
  this.postSyncSchemaPromotionCallback = callback;
@@ -291,19 +476,34 @@ export class IntegrationEngine extends BaseSingleton {
291
476
  * phase stays parallel (the real throughput win — it's network-bound). WeakMap so a
292
477
  * retired provider's chain entry is collectable.
293
478
  */
294
- static { this.writeChains = new WeakMap(); }
295
- runWriteExclusive(fn) {
479
+ static { this.writeSerializers = new WeakMap(); }
480
+ /** The write lock for this engine's provider, created on first use. */
481
+ writeSerializer() {
296
482
  const provider = this.ProviderToUse;
297
- let holder = IntegrationEngine.writeChains.get(provider);
298
- if (!holder) {
299
- holder = { chain: Promise.resolve() };
300
- IntegrationEngine.writeChains.set(provider, holder);
301
- }
302
- // Run fn after the prior write completes (whether it resolved or rejected); keep the chain
303
- // alive past failures so one errored batch never deadlocks subsequent writers.
304
- const run = holder.chain.then(() => fn(), () => fn());
305
- holder.chain = run.then(() => undefined, () => undefined);
306
- return run;
483
+ let s = IntegrationEngine.writeSerializers.get(provider);
484
+ if (!s) {
485
+ s = new WriteSerializer();
486
+ IntegrationEngine.writeSerializers.set(provider, s);
487
+ }
488
+ return s;
489
+ }
490
+ /**
491
+ * Runs `fn` with NO other write in flight against the provider. Required for work that opens
492
+ * the provider's single global transaction — while it is open, any other write would join it.
493
+ */
494
+ runWriteExclusive(fn) {
495
+ return this.writeSerializer().RunExclusive(fn);
496
+ }
497
+ /**
498
+ * Runs `fn` ordered against other writes for the SAME entity map, concurrently with other maps.
499
+ *
500
+ * Only for work that opens no provider transaction — watermark bookkeeping, match resolution,
501
+ * and the post-batch flushes of a batched (TransactionGroup-carrying) apply. Those were queued
502
+ * behind every other map's writes purely because the lock could not tell them apart from a
503
+ * transaction-holding section.
504
+ */
505
+ runWriteForMap(entityMapID, fn) {
506
+ return this.writeSerializer().RunKeyed(entityMapID, fn);
307
507
  }
308
508
  /**
309
509
  * Fence check at a batch boundary, BEFORE any write (PR 1 item 3). One SELECT of the run's
@@ -510,172 +710,209 @@ export class IntegrationEngine extends BaseSingleton {
510
710
  return;
511
711
  }
512
712
  console.log(`[IntegrationEngine] Found ${orphanedRuns.Results.length} orphaned sync(s) to resume`);
513
- for (const run of orphanedRuns.Results) {
514
- const companyIntegrationID = run.CompanyIntegrationID;
515
- const runID = run.ID;
516
- const lockKey = companyIntegrationID.toLowerCase();
517
- // C1: respect the SAME in-process concurrency lock RunSync uses. If a live sync for this
518
- // CompanyIntegration is already running (e.g. the scheduler fired during startup), skip the
519
- // resume — double-running one CI on the shared provider connection corrupts its singular
520
- // transaction state (exactly what runWriteExclusive guards against WITHIN a run). The
521
- // get→set pair below has no await between them, so check-and-reserve is atomic on the loop.
522
- if (IntegrationEngine.activeSyncs.get(lockKey)) {
523
- console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} a live sync for ${lockKey} is already running`);
524
- continue;
713
+ // CONCURRENTLY, because that is how these runs were STARTED.
714
+ //
715
+ // This loop used to `await` each resume in turn, which quietly converted a parallel
716
+ // workload into a queue ordered by whatever RunView happened to return. The slowest
717
+ // connector became a head-of-line block for every other connector in the workspace and
718
+ // a connector that never finishes means the others never start at all.
719
+ //
720
+ // Observed live: a restart orphaned three syncs; one resumed and was still going five
721
+ // hours later, and the other two (99,463 and 13,238 rows) never began. Nothing in their
722
+ // logs said so, because nothing had failed — they had simply never been reached. From
723
+ // outside the process a queued run and a crashed one are identical: IsInFlight true,
724
+ // CompletedAt null, counters frozen at the instant of the restart. The absence of an
725
+ // error is the only tell.
726
+ //
727
+ // Note what is NOT being parallelised. The write section stays serialized by
728
+ // `runWriteExclusive`, because all maps share one provider connection with singular
729
+ // transaction state; that is deliberate and unchanged. Per-CompanyIntegration exclusion
730
+ // stays too, via the `activeSyncs` lock each resume takes. What overlaps here is what
731
+ // overlapped before the restart: different connectors waiting on different sources.
732
+ //
733
+ // Bounded rather than unbounded: a workspace is one Node process, so concurrency buys
734
+ // overlap on network waiting and not more CPU, and a boot that adopted fifty runs at once
735
+ // would trade one pathology for another.
736
+ await RunResumesBounded(orphanedRuns.Results, ResumeConcurrency(), run => this.ResumeOneOrphanedRun(run, prov, rv, contextUser));
737
+ }
738
+ /**
739
+ * Resume ONE orphaned run, end to end: reserve the per-CompanyIntegration lock, claim the run,
740
+ * work out which entity maps already finished, and execute the rest under a fresh run context.
741
+ *
742
+ * Extracted from {@link ResumeOrphanedSyncs}'s loop so several runs can be in flight at once.
743
+ * NEVER THROWS — every failure path is handled here and recorded on the run row. A resume that
744
+ * threw out of this method would take a pool slot with it and, worse, could abandon the runs
745
+ * queued behind it, which is the exact failure this parallelisation exists to remove.
746
+ *
747
+ * The check-and-reserve on `activeSyncs` still has no `await` in front of it, so it stays
748
+ * atomic with several of these in flight: an async function runs synchronously up to its first
749
+ * await, and the pool always starts one from a synchronous call site.
750
+ */
751
+ async ResumeOneOrphanedRun(run, prov, rv, contextUser) {
752
+ const companyIntegrationID = run.CompanyIntegrationID;
753
+ const runID = run.ID;
754
+ const lockKey = companyIntegrationID.toLowerCase();
755
+ // C1: respect the SAME in-process concurrency lock RunSync uses. If a live sync for this
756
+ // CompanyIntegration is already running (e.g. the scheduler fired during startup), skip the
757
+ // resume — double-running one CI on the shared provider connection corrupts its singular
758
+ // transaction state (exactly what runWriteExclusive guards against WITHIN a run). The
759
+ // get→set pair below has no await between them, so check-and-reserve is atomic on the loop.
760
+ if (IntegrationEngine.activeSyncs.get(lockKey)) {
761
+ console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — a live sync for ${lockKey} is already running`);
762
+ return;
763
+ }
764
+ let resolveResumeLock;
765
+ let resumeResult;
766
+ IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
767
+ const ownership = new RunOwnershipService(prov, runID, undefined, contextUser);
768
+ try {
769
+ // CLAIM BEFORE ADOPTING (PR 1 item 6): a single atomic UPDATE that succeeds only if the
770
+ // run is still unowned/lapsed. Zero rows = another worker adopted it between our RunView
771
+ // and now — skip, never double-run. A successful claim BUMPS the fence, so if the
772
+ // original owner is actually alive-but-slow it aborts at its next boundary check
773
+ // without writing: the sweep-reclaim is itself the abort signal for the abandoned owner.
774
+ const claimed = await ownership.Claim();
775
+ if (!claimed) {
776
+ console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — claim lost (another worker adopted it)`);
777
+ return;
525
778
  }
526
- let resolveResumeLock;
527
- let resumeResult;
528
- IntegrationEngine.activeSyncs.set(lockKey, new Promise(res => { resolveResumeLock = res; }));
529
- const ownership = new RunOwnershipService(prov, runID, undefined, contextUser);
530
- try {
531
- // CLAIM BEFORE ADOPTING (PR 1 item 6): a single atomic UPDATE that succeeds only if the
532
- // run is still unowned/lapsed. Zero rows = another worker adopted it between our RunView
533
- // and now skip, never double-run. A successful claim BUMPS the fence, so if the
534
- // original owner is actually alive-but-slow it aborts at its next boundary check
535
- // without writing: the sweep-reclaim is itself the abort signal for the abandoned owner.
536
- const claimed = await ownership.Claim();
537
- if (!claimed) {
538
- console.log(`[IntegrationEngine] Skipping resume of run ${runID.substring(0, 8)} — claim lost (another worker adopted it)`);
539
- continue;
540
- }
541
- // Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
542
- // by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
543
- // not EntityID two maps can target the same MJ Entity, so keying on EntityID
544
- // could skip a still-pending sibling map. We also require IsSuccess=1: a map that
545
- // completed WITH errors (RecordsErrored>0, no throw) must be re-attempted on resume,
546
- // otherwise its errored records are silently abandoned.
547
- const detailsResult = await rv.RunView({
548
- EntityName: 'MJ: Company Integration Run Details',
549
- ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
550
- Fields: ['RecordID', 'IsSuccess'],
551
- ResultType: 'simple',
552
- }, contextUser);
553
- const completedMapIDs = new Set();
554
- if (detailsResult.Success) {
555
- for (const d of detailsResult.Results) {
556
- if (!d.IsSuccess)
557
- continue; // completed-with-errors → re-attempt on resume
558
- const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
559
- // Parse-miss falls open (map treated as not-completed → re-runs): at worst a
560
- // redundant idempotent re-sync, never a silent skip.
561
- if (m)
562
- completedMapIDs.add(m[1].toLowerCase());
563
- }
564
- }
565
- console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
566
- `for ${companyIntegrationID.substring(0, 8)}... ` +
567
- `(${completedMapIDs.size} entity maps already completed)`);
568
- // Recover what this run was ASKED to do. Without this the resume rebuilds config from
569
- // the CompanyIntegration alone, so an adopted run silently loses its options — most
570
- // damagingly FullSync, which exists precisely to distrust the watermark. An adopted
571
- // full sync would resume incrementally, fetch nothing, and report Success.
572
- // Unparseable/absent ConfigData falls back to defaults rather than refusing to resume.
573
- let resumeOptions;
574
- let resumeTriggerType = 'Scheduled';
575
- try {
576
- const cfg = JSON.parse(run.ConfigData ?? '{}');
577
- resumeOptions = cfg.options ?? undefined;
578
- if (cfg.triggerType)
579
- resumeTriggerType = cfg.triggerType;
580
- }
581
- catch {
582
- console.warn(`[IntegrationEngine] Run ${runID.substring(0, 8)} has unparseable ConfigData; resuming with defaults`);
583
- }
584
- if (resumeOptions?.FullSync) {
585
- console.log(`[IntegrationEngine] Run ${runID.substring(0, 8)} was a FULL sync — resuming as full, not incremental`);
586
- }
587
- // Load config and filter to only remaining entity maps (by map ID)
588
- const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, resumeOptions);
589
- const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
590
- if (remainingMaps.length === 0) {
591
- console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
592
- run.EndedAt = new Date();
593
- run.Status = 'Success';
594
- ownership.SyncEntityOwnershipFields(run); // full-row save must not clobber the live claim
595
- await run.Save();
596
- await ownership.Release('Success');
597
- continue;
779
+ // Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
780
+ // by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
781
+ // not EntityID two maps can target the same MJ Entity, so keying on EntityID
782
+ // could skip a still-pending sibling map. We also require IsSuccess=1: a map that
783
+ // completed WITH errors (RecordsErrored>0, no throw) must be re-attempted on resume,
784
+ // otherwise its errored records are silently abandoned.
785
+ const detailsResult = await rv.RunView({
786
+ EntityName: 'MJ: Company Integration Run Details',
787
+ ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
788
+ Fields: ['RecordID', 'IsSuccess'],
789
+ ResultType: 'simple',
790
+ }, contextUser);
791
+ const completedMapIDs = new Set();
792
+ if (detailsResult.Success) {
793
+ for (const d of detailsResult.Results) {
794
+ if (!d.IsSuccess)
795
+ continue; // completed-with-errors re-attempt on resume
796
+ const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
797
+ // Parse-miss falls open (map treated as not-completed re-runs): at worst a
798
+ // redundant idempotent re-sync, never a silent skip.
799
+ if (m)
800
+ completedMapIDs.add(m[1].toLowerCase());
598
801
  }
599
- console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
600
- // Replace entityMaps with only the remaining ones
601
- config.entityMaps = remainingMaps;
602
- // Execute remaining maps inside a per-run context: the resume gets its own provider
603
- // binding, abort controller, and ownership — identical to a fresh RunSync — so the
604
- // heartbeat renews the lease, the batch boundaries fence-check, and FinalizeRun
605
- // syncs ownership fields + releases, all through the SAME code paths.
606
- const abortController = new AbortController();
607
- const progressSnapshot = {
608
- StartedAt: new Date(),
609
- CurrentEntity: '',
610
- EntityMapsTotal: remainingMaps.length,
611
- EntityMapsCompleted: 0,
612
- RecordsProcessed: 0,
613
- RecordsCreated: 0,
614
- RecordsUpdated: 0,
615
- RecordsErrored: 0,
616
- // The run's OWN trigger type, recovered above — not a hardcoded 'Scheduled'. This is
617
- // what IntegrationGetSyncProgress reports back ("Sync in progress (Manual)"), so a
618
- // hardcoded value mislabels every adopted run.
619
- TriggerType: resumeTriggerType,
620
- };
621
- const runCtx = {
622
- provider: prov,
623
- ownership,
624
- abortController,
625
- progressSnapshot,
626
- cancelRequested: false,
627
- ownershipLost: false,
628
- };
629
- ownership.StartHeartbeat({
630
- onLost: () => { runCtx.ownershipLost = true; abortController.abort(); },
631
- onCancelRequested: () => { runCtx.cancelRequested = true; abortController.abort(); },
632
- progressSupplier: () => JSON.stringify(progressSnapshot),
633
- });
634
- const result = await IntegrationEngine.runContext.run(runCtx, async () => {
635
- const r = await this.ExecuteEntityMaps(config, run, contextUser, undefined, abortController.signal);
636
- r.RunID = runID;
637
- await this.FinalizeRun(run, r, contextUser);
638
- return r;
639
- });
640
- resumeResult = result;
641
- console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
642
- `${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
643
- `${result.RecordsErrored} errored`);
644
802
  }
645
- catch (err) {
646
- const errMsg = err instanceof Error ? err.message : String(err);
647
- console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
648
- if (err instanceof RunOwnershipLostError) {
649
- // We were fenced out mid-resume the NEW owner now owns the run row.
650
- // Writing 'Failed' here would clobber the live holder's state.
651
- console.warn(`[IntegrationEngine] Resume of run ${runID.substring(0, 8)} lost ownership leaving the run row to its new owner`);
652
- }
653
- else {
654
- // Mark as failed so it doesn't get picked up again
655
- run.EndedAt = new Date();
656
- run.Status = 'Failed';
657
- run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
658
- ownership.SyncEntityOwnershipFields(run);
659
- await run.Save();
660
- try {
661
- await ownership.Release('Failed');
662
- }
663
- catch { /* lease will simply expire */ }
664
- }
803
+ console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
804
+ `for ${companyIntegrationID.substring(0, 8)}... ` +
805
+ `(${completedMapIDs.size} entity maps already completed)`);
806
+ // Recover what this run was ASKED to do. Without this the resume rebuilds config from
807
+ // the CompanyIntegration alone, so an adopted run silently loses its options most
808
+ // damagingly FullSync, which exists precisely to distrust the watermark. An adopted
809
+ // full sync would resume incrementally, fetch nothing, and report Success.
810
+ // Unparseable/absent ConfigData falls back to defaults rather than refusing to resume.
811
+ let resumeOptions;
812
+ let resumeTriggerType = 'Scheduled';
813
+ try {
814
+ const cfg = JSON.parse(run.ConfigData ?? '{}');
815
+ resumeOptions = cfg.options ?? undefined;
816
+ if (cfg.triggerType)
817
+ resumeTriggerType = cfg.triggerType;
665
818
  }
666
- finally {
667
- ownership.StopHeartbeat();
668
- // Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
669
- // `existing`). Resolve with the real result when we have one, else a benign empty result so no
670
- // waiter hangs. Promise resolve is idempotent and the early-exit `continue` also lands here.
671
- IntegrationEngine.activeSyncs.delete(lockKey);
672
- resolveResumeLock(resumeResult ?? {
673
- Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
674
- RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
675
- RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
676
- });
819
+ catch {
820
+ console.warn(`[IntegrationEngine] Run ${runID.substring(0, 8)} has unparseable ConfigData; resuming with defaults`);
821
+ }
822
+ if (resumeOptions?.FullSync) {
823
+ console.log(`[IntegrationEngine] Run ${runID.substring(0, 8)} was a FULL sync resuming as full, not incremental`);
824
+ }
825
+ // Load config and filter to only remaining entity maps (by map ID)
826
+ const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, resumeOptions);
827
+ const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
828
+ if (remainingMaps.length === 0) {
829
+ console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
830
+ run.EndedAt = new Date();
831
+ run.Status = 'Success';
832
+ ownership.SyncEntityOwnershipFields(run); // full-row save must not clobber the live claim
833
+ await run.Save();
834
+ await ownership.Release('Success');
835
+ return;
836
+ }
837
+ console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
838
+ // Replace entityMaps with only the remaining ones
839
+ config.entityMaps = remainingMaps;
840
+ // Execute remaining maps inside a per-run context: the resume gets its own provider
841
+ // binding, abort controller, and ownership — identical to a fresh RunSync — so the
842
+ // heartbeat renews the lease, the batch boundaries fence-check, and FinalizeRun
843
+ // syncs ownership fields + releases, all through the SAME code paths.
844
+ const abortController = new AbortController();
845
+ const progressSnapshot = {
846
+ StartedAt: new Date(),
847
+ CurrentEntity: '',
848
+ EntityMapsTotal: remainingMaps.length,
849
+ EntityMapsCompleted: 0,
850
+ RecordsProcessed: 0,
851
+ RecordsCreated: 0,
852
+ RecordsUpdated: 0,
853
+ RecordsErrored: 0,
854
+ // The run's OWN trigger type, recovered above — not a hardcoded 'Scheduled'. This is
855
+ // what IntegrationGetSyncProgress reports back ("Sync in progress (Manual)"), so a
856
+ // hardcoded value mislabels every adopted run.
857
+ TriggerType: resumeTriggerType,
858
+ };
859
+ const runCtx = {
860
+ provider: prov,
861
+ ownership,
862
+ abortController,
863
+ progressSnapshot,
864
+ cancelRequested: false,
865
+ ownershipLost: false,
866
+ };
867
+ ownership.StartHeartbeat({
868
+ onLost: () => { runCtx.ownershipLost = true; abortController.abort(); },
869
+ onCancelRequested: () => { runCtx.cancelRequested = true; abortController.abort(); },
870
+ progressSupplier: () => JSON.stringify(progressSnapshot),
871
+ });
872
+ const result = await IntegrationEngine.runContext.run(runCtx, async () => {
873
+ const r = await this.ExecuteEntityMaps(config, run, contextUser, undefined, abortController.signal);
874
+ r.RunID = runID;
875
+ await this.FinalizeRun(run, r, contextUser);
876
+ return r;
877
+ });
878
+ resumeResult = result;
879
+ console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
880
+ `${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
881
+ `${result.RecordsErrored} errored`);
882
+ }
883
+ catch (err) {
884
+ const errMsg = err instanceof Error ? err.message : String(err);
885
+ console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
886
+ if (err instanceof RunOwnershipLostError) {
887
+ // We were fenced out mid-resume — the NEW owner now owns the run row.
888
+ // Writing 'Failed' here would clobber the live holder's state.
889
+ console.warn(`[IntegrationEngine] Resume of run ${runID.substring(0, 8)} lost ownership — leaving the run row to its new owner`);
890
+ }
891
+ else {
892
+ // Mark as failed so it doesn't get picked up again
893
+ run.EndedAt = new Date();
894
+ run.Status = 'Failed';
895
+ run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
896
+ ownership.SyncEntityOwnershipFields(run);
897
+ await run.Save();
898
+ try {
899
+ await ownership.Release('Failed');
900
+ }
901
+ catch { /* lease will simply expire */ }
677
902
  }
678
903
  }
904
+ finally {
905
+ ownership.StopHeartbeat();
906
+ // Release the C1 lock + unblock any RunSync that began awaiting this resume (RunSync returns
907
+ // `existing`). Resolve with the real result when we have one, else a benign empty result so no
908
+ // waiter hangs. Promise resolve is idempotent and the early-exit `return`s also land here.
909
+ IntegrationEngine.activeSyncs.delete(lockKey);
910
+ resolveResumeLock(resumeResult ?? {
911
+ Success: false, ErrorMessage: 'Resume produced no result', RecordsProcessed: 0,
912
+ RecordsCreated: 0, RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0,
913
+ RecordsSkipped: 0, Errors: [], EntityMapResults: [], Duration: 0,
914
+ });
915
+ }
679
916
  }
680
917
  /**
681
918
  * Executes a full sync run for a company integration.
@@ -861,6 +1098,27 @@ export class IntegrationEngine extends BaseSingleton {
861
1098
  entityMapIDsFilter: options?.EntityMapIDs ?? null,
862
1099
  syncDirectionOverride: options?.SyncDirection ?? null,
863
1100
  });
1101
+ /**
1102
+ * Re-read the IO/IOF catalog before the run starts (also covers resumed runs — both paths land here).
1103
+ *
1104
+ * Everything else a run reads is fresh per run (LoadRunConfiguration and LoadFieldMaps
1105
+ * read CI/entity maps/field maps with BypassCache) — but IntegrationObject and
1106
+ * IntegrationObjectField are served from this engine's BaseEngine arrays, loaded at
1107
+ * process start and auto-refreshed only by IN-PROCESS BaseEntity saves. A catalog edit
1108
+ * made by direct SQL, a sproc-based sync push, or another process is therefore invisible
1109
+ * to syncs until the host restarts — an AccessPath or field-type correction keeps being
1110
+ * ignored run after run with nothing in the log to say why. The apply/evolution
1111
+ * resolvers already do an invalidate+reload for exactly this reason; the sync path
1112
+ * never got the same treatment.
1113
+ *
1114
+ * Only the two catalog arrays are refreshed — not Config(true), which reloads all eight
1115
+ * datasets unfiltered on every run. The loader is called directly with bypassCache=true
1116
+ * because RefreshItem defaults to the local cache — which is the very thing that is
1117
+ * stale. Replacing the arrays is also what invalidates this PR's memoised views: both
1118
+ * the per-object field index and the per-record GetCachedFields memo key on ARRAY
1119
+ * IDENTITY, so they rebuild lazily on first read after the swap.
1120
+ */
1121
+ await IntegrationEngineBase.Instance.RefreshCatalog(contextUser);
864
1122
  const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, options);
865
1123
  logger.attachIntegrationName(config.companyIntegration.Integration);
866
1124
  logger.emit('sync.config.loaded', {
@@ -1236,6 +1494,13 @@ export class IntegrationEngine extends BaseSingleton {
1236
1494
  * Processes all entity maps, aggregating results with progress tracking.
1237
1495
  */
1238
1496
  async ExecuteEntityMaps(config, run, contextUser, onProgress, abortSignal, logger) {
1497
+ // Resolve the per-connection side-effect suppression ONCE for the run and stamp it on the
1498
+ // run context, where CreateRecord/UpdateRecord/DeleteRecord (several frames below, no
1499
+ // config parameter) read it back. Both run paths — direct and adopted — come through here.
1500
+ const runCtxForFlags = this.currentRunContext;
1501
+ if (runCtxForFlags) {
1502
+ runCtxForFlags.suppressWriteSideEffects = this.ReadWriteSideEffects(config.companyIntegration) === 'suppressed';
1503
+ }
1239
1504
  const aggregate = {
1240
1505
  Success: true,
1241
1506
  RecordsProcessed: 0,
@@ -1685,6 +1950,107 @@ export class IntegrationEngine extends BaseSingleton {
1685
1950
  });
1686
1951
  await Promise.all(workers);
1687
1952
  }
1953
+ getFetchGate(config) {
1954
+ // OPT-IN: the gate only exists when someone declared a ceiling — a per-connection
1955
+ // `fetchConcurrency` override or the connector's own MaxConcurrencyHint. A connector that
1956
+ // declares neither has never exhibited concurrency-governed throttling, and imposing a
1957
+ // default cap on it would newly constrain fan-outs (lanes × prefetch) that run fine
1958
+ // ungated today. No declaration → no gate → behavior identical to before this feature.
1959
+ const declared = this.getConfigOverrides(config).fetchConcurrency
1960
+ ?? config.connector.MaxConcurrencyHint;
1961
+ if (declared == null)
1962
+ return undefined;
1963
+ this._fetchGates ??= new Map();
1964
+ const key = config.companyIntegration.ID;
1965
+ const ceiling = Math.max(1, declared);
1966
+ let gate = this._fetchGates.get(key);
1967
+ if (!gate || gate.ceiling !== ceiling) {
1968
+ gate = { ceiling, controller: new AdaptiveConcurrencyController({ start: ceiling, min: 1, max: ceiling }), inFlight: 0, waiters: [] };
1969
+ this._fetchGates.set(key, gate);
1970
+ }
1971
+ return gate;
1972
+ }
1973
+ /**
1974
+ * Runs `fn` holding one slot of the connection's fetch gate. A whole retry episode holds a
1975
+ * single slot — retries of a throttled call must not add pressure to the account that just
1976
+ * throttled us. FIFO: waiters resolve in arrival order as slots free or the cap grows.
1977
+ */
1978
+ async withFetchGate(config, fn) {
1979
+ const gate = this.getFetchGate(config);
1980
+ if (!gate)
1981
+ return fn(); // no declared ceiling → ungated, exactly as before
1982
+ while (gate.inFlight >= gate.controller.Cap) {
1983
+ await new Promise((resolve) => gate.waiters.push(resolve));
1984
+ }
1985
+ gate.inFlight++;
1986
+ try {
1987
+ return await fn();
1988
+ }
1989
+ finally {
1990
+ gate.inFlight--;
1991
+ // Wake as many waiters as the CURRENT cap allows — it may have grown (or shrunk)
1992
+ // while they slept; each woken waiter re-checks the cap before taking a slot.
1993
+ while (gate.waiters.length > 0 && gate.inFlight < gate.controller.Cap) {
1994
+ const next = gate.waiters.shift();
1995
+ if (next)
1996
+ next();
1997
+ else
1998
+ break;
1999
+ }
2000
+ }
2001
+ }
2002
+ /**
2003
+ * One governed vendor fetch: rate-limit token, adaptive fetch gate, per-attempt timeout,
2004
+ * transient-only retry with Retry-After pacing, and ONE multiplicative decrease per throttle
2005
+ * EPISODE (not per rejected attempt — a 429 that survives three retries is three rejections
2006
+ * but one congestion event, the same distinction TCP draws when it halves the window once per
2007
+ * loss event). Extracted so the loop-top fetch and the pipelined prefetch (which starts the
2008
+ * next page while the current one is processed) share EXACTLY the same pacing and error
2009
+ * semantics — a prefetched page must be indistinguishable from a loop-top fetch to the vendor.
2010
+ *
2011
+ * Retry predicate: our OWN timeout is terminal for this page (WithTimeout is a Promise.race
2012
+ * with no cancellation, so the abandoned attempt keeps running — retrying stacks a second full
2013
+ * page of vendor requests on a source already too slow to finish one); a transport error is
2014
+ * not (a reset socket IS worth retrying). A throttle honors the source's Retry-After via
2015
+ * DelayForError, and every retry re-passes the rate limiter via BeforeRetry so it cannot
2016
+ * bypass a freeze the throttle just applied.
2017
+ */
2018
+ async governedFetch(config, ctx, objectName, fetchTimeoutMs, batchIndex, logger) {
2019
+ let throttleReported = false;
2020
+ try {
2021
+ await this.rateLimit(config);
2022
+ return await this.withFetchGate(config, () => WithRetry(() => WithTimeout(config.connector.FetchChanges(ctx), fetchTimeoutMs, `FetchChanges(${objectName})`), undefined, (err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => {
2023
+ // Report a throttle NOW, not after the retries are spent. ReportThrottle
2024
+ // freezes the shared bucket for this CompanyIntegration, so every other
2025
+ // object fetching concurrently backs off too. Once per episode; later
2026
+ // attempts still get their own Retry-After honoured via DelayForError.
2027
+ if (!throttleReported && ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED') {
2028
+ throttleReported = true;
2029
+ this.reportRateOutcome(config, err);
2030
+ }
2031
+ logger?.emit('sync.fetch.retry', {
2032
+ externalObjectName: objectName,
2033
+ batchIndex,
2034
+ attempt,
2035
+ delayMs,
2036
+ error: err instanceof Error ? err.message : String(err),
2037
+ });
2038
+ }, {
2039
+ DelayForError: (err) => ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED'
2040
+ ? config.connector.ExtractRetryAfterMs(err)
2041
+ : undefined,
2042
+ BeforeRetry: () => this.rateLimit(config),
2043
+ }));
2044
+ }
2045
+ catch (err) {
2046
+ // Retries spent (or none applicable). If the terminal error is itself the throttle
2047
+ // and the retry hook never saw one, apply the episode's one decrease here.
2048
+ if (!throttleReported && ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED') {
2049
+ this.reportRateOutcome(config, err);
2050
+ }
2051
+ throw err;
2052
+ }
2053
+ }
1688
2054
  /** Minimum ms between outbound requests for this integration (Integration.BatchRequestWaitTime; 0 = disabled). */
1689
2055
  getRequestSpacingMs(config) {
1690
2056
  try {
@@ -1732,6 +2098,8 @@ export class IntegrationEngine extends BaseSingleton {
1732
2098
  rateLimitBurst: PositiveInt(p.rateLimitBurst),
1733
2099
  discoveryTimeBudgetMs: PositiveInt(p.discoveryTimeBudgetMs),
1734
2100
  fetchTimeoutMs: PositiveInt(p.fetchTimeoutMs),
2101
+ // Ceiling on SIMULTANEOUS vendor fetches for this connection (see getFetchGate).
2102
+ fetchConcurrency: PositiveInt(p.fetchConcurrency),
1735
2103
  };
1736
2104
  }
1737
2105
  catch {
@@ -1769,6 +2137,17 @@ export class IntegrationEngine extends BaseSingleton {
1769
2137
  */
1770
2138
  reportRateOutcome(config, throttledErr) {
1771
2139
  const key = config.companyIntegration.ID;
2140
+ // The adaptive fetch gate learns from the SAME signal: a clean outcome creeps the
2141
+ // in-flight cap up by 1; a throttle halves it. This is what makes the gate find the
2142
+ // account's real concurrency grant with zero configuration — including throttles the
2143
+ // connector absorbed inside its own retry and surfaced via ctx.RateLimitReport.
2144
+ const gate = this._fetchGates?.get(key);
2145
+ if (gate) {
2146
+ if (throttledErr === undefined)
2147
+ gate.controller.OnSuccess();
2148
+ else
2149
+ gate.controller.OnThrottleOrError();
2150
+ }
1772
2151
  const rl = this._rateLimiters.get(key);
1773
2152
  if (!rl)
1774
2153
  return;
@@ -1811,13 +2190,39 @@ export class IntegrationEngine extends BaseSingleton {
1811
2190
  this.MergeResult(pullResult, pushResult);
1812
2191
  return pullResult;
1813
2192
  }
2193
+ /** Loads the map fresh and writes its Configuration through Save(), like every other engine write. */
2194
+ async SaveEntityMapConfiguration(entityMap, configurationJSON, contextUser) {
2195
+ const md = new Metadata(); // global-provider-ok: entity-map bookkeeping — single-provider context
2196
+ const row = await md.GetEntityObject('MJ: Company Integration Entity Maps', contextUser);
2197
+ if (!(await row.Load(entityMap.ID)))
2198
+ return;
2199
+ row.Configuration = configurationJSON;
2200
+ await this.runWriteExclusive(() => row.Save());
2201
+ // Keep the in-memory map coherent with what was just persisted, so a later decision in this
2202
+ // same run reads the value that is actually stored.
2203
+ entityMap.Configuration = configurationJSON;
2204
+ }
1814
2205
  /**
1815
2206
  * Pull sync: fetch from external → map → match → validate → apply to MJ.
1816
2207
  */
1817
2208
  async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
1818
2209
  const entityMapID = entityMap.ID;
2210
+ // An object this ACCOUNT cannot serve costs a request, an error event and a retry ladder
2211
+ // every run, forever, and says nothing new after the first time. While the marker is fresh
2212
+ // we spend nothing on it; once it ages out the next attempt IS the recheck, so an object
2213
+ // the account later enables heals itself with no operator action.
1819
2214
  const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
1820
- const watermark = await this.runWriteExclusive(() => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
2215
+ // Field-level exclusions declared by the connector (SourceFieldInfo.SyncDirective
2216
+ // -> IntegrationObjectField.Configuration). Resolved once per map, applied to every
2217
+ // batch below. Empty set on any lookup miss - exclusion can only ever narrow.
2218
+ const excludedSourceNames = this.ResolveExcludedSourceNames(config.companyIntegration.IntegrationID, entityMap.ExternalObjectName);
2219
+ if (excludedSourceNames.size > 0) {
2220
+ logger?.emit('sync.entity-map.exclusions', {
2221
+ externalObjectName: entityMap.ExternalObjectName,
2222
+ excludedFields: Array.from(excludedSourceNames).sort(),
2223
+ });
2224
+ }
2225
+ const watermark = await this.runWriteForMap(entityMapID, () => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
1821
2226
  logger?.emit('sync.entity-map.start', {
1822
2227
  phase: 'pull-detail',
1823
2228
  externalObjectName: entityMap.ExternalObjectName,
@@ -1832,8 +2237,13 @@ export class IntegrationEngine extends BaseSingleton {
1832
2237
  watermarkType: watermark?.WatermarkType ?? null,
1833
2238
  fullSync: config.fullSync,
1834
2239
  });
2240
+ this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
1835
2241
  // A6: Validate watermark before using it — skip entirely when FullSync requested
1836
2242
  let initialWatermark = config.fullSync ? null : (watermark?.WatermarkValue ?? null);
2243
+ // The value the ROW held before this run touched it — the retract target if a mid-run
2244
+ // durability floor (§8a below) has to be undone after a page-skip gap. Distinct from
2245
+ // initialWatermark, which a fullSync nulls even though the row still holds a real value.
2246
+ const preRunWatermarkValue = watermark?.WatermarkValue ?? null;
1837
2247
  if (initialWatermark && watermark) {
1838
2248
  const watermarkType = (watermark.WatermarkType ?? 'Timestamp');
1839
2249
  if (!this.watermarkService.ValidateWatermark(initialWatermark, watermarkType)) {
@@ -1917,6 +2327,7 @@ export class IntegrationEngine extends BaseSingleton {
1917
2327
  let previousBatchFingerprint;
1918
2328
  let fetchCompletedCleanly = true; // flipped to false if fetch aborted or errored mid-way
1919
2329
  let hadFetchGap = false; // ≥1 page was skipped after a persistent fetch error (offset/page paging)
2330
+ let watermarkFloorSaved = null; // §8a durability floor last persisted mid-run (null = none)
1920
2331
  let fetchGapCount = 0; // CONSECUTIVE skipped pages (reset on any clean fetch)
1921
2332
  const MAX_FETCH_GAPS = 25; // give up + hold the watermark if this many pages fail in a row (API down)
1922
2333
  let consecutiveEmptyBatches = 0; // P3-D: detect a connector that pages empty-but-HasMore forever
@@ -1942,6 +2353,11 @@ export class IntegrationEngine extends BaseSingleton {
1942
2353
  const fetchTimeoutMs = PositiveInt(this.getConfigOverrides(config).fetchTimeoutMs)
1943
2354
  ?? PositiveInt(config.connector.FetchChangesTimeoutMs)
1944
2355
  ?? DEFAULT_OPERATION_TIMEOUTS.FetchChangesMs;
2356
+ // Pipelined prefetch state: at most ONE page in flight ahead of processing, keyed by the
2357
+ // cursor it was built from so a drifted position (gap-skip/reset) discards it instead of
2358
+ // consuming the wrong page. If the loop exits with a prefetch still in flight, the promise
2359
+ // settles in the background and its result is discarded (its .catch keeps that silent).
2360
+ let prefetchedNext = null;
1945
2361
  while (hasMore) {
1946
2362
  if (abortSignal?.aborted) {
1947
2363
  console.log(`[IntegrationEngine] Sync cancelled for ${entityMap.ExternalObjectName} after ${recordsInMap} records — saving watermark`);
@@ -1985,31 +2401,19 @@ export class IntegrationEngine extends BaseSingleton {
1985
2401
  let batch;
1986
2402
  const fetchStart = Date.now();
1987
2403
  try {
1988
- await this.rateLimit(config);
1989
- // Resilient fetch: bound each attempt with a timeout (a hung vendor API must not
1990
- // hold the sync lock forever) and retry only transient errors (network/throttle/DB).
1991
- // A non-retryable error (auth, 4xx, parse) throws immediately as before.
1992
- batch = await WithRetry(() => WithTimeout(config.connector.FetchChanges(ctx), fetchTimeoutMs, `FetchChanges(${entityMap.ExternalObjectName})`), undefined,
1993
- // OUR OWN timeout is terminal for this page; a transport error is not.
1994
- //
1995
- // `WithTimeout` is a `Promise.race` with no cancellation, so the abandoned attempt
1996
- // keeps running. Retrying meant a second full page of vendor requests overlapping
1997
- // the first, then a third — up to 3x the load on a source that was already too slow
1998
- // to finish once, which is a good way to earn a real 429 (and THAT does cut
1999
- // concurrency). And the retry could not succeed on its merits anyway: the same work
2000
- // under the same budget exceeds it again.
2001
- //
2002
- // Deliberately `instanceof` rather than the classified code. `ClassifyError` folds
2003
- // `econnreset` in with timeouts under `NETWORK_TIMEOUT`, and a reset socket IS worth
2004
- // retrying — so excluding the whole code would lose real resilience. Only the error
2005
- // WithTimeout itself minted is excluded.
2006
- (err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => logger?.emit('sync.fetch.retry', {
2007
- externalObjectName: entityMap.ExternalObjectName,
2008
- batchIndex: batchCount,
2009
- attempt,
2010
- delayMs,
2011
- error: err instanceof Error ? err.message : String(err),
2012
- }));
2404
+ if (prefetchedNext && prefetchedNext.key === (currentCursor ?? '')) {
2405
+ // The page already in flight IS this page consume it. The rate limiter, the
2406
+ // fetch gate, the timeout/retry envelope, and once-per-episode throttle
2407
+ // reporting all ran inside governedFetch when the prefetch was launched, so
2408
+ // consuming it here adds no vendor pressure and loses no error semantics.
2409
+ const inFlight = prefetchedNext;
2410
+ prefetchedNext = null;
2411
+ batch = await inFlight.promise;
2412
+ }
2413
+ else {
2414
+ prefetchedNext = null; // position drifted (gap-skip/reset) discard the stale prefetch
2415
+ batch = await this.governedFetch(config, ctx, entityMap.ExternalObjectName, fetchTimeoutMs, batchCount, logger);
2416
+ }
2013
2417
  this.reportRateOutcome(config); // clean fetch → ramp the adaptive rate back up
2014
2418
  fetchGapCount = 0; // clean fetch → reset the consecutive fetch-gap counter
2015
2419
  // §10: connector type-driven post-processing hook (default no-op) — enforce/normalize
@@ -2017,14 +2421,68 @@ export class IntegrationEngine extends BaseSingleton {
2017
2421
  if (batch.Records.length > 0) {
2018
2422
  batch.Records = batch.Records.map(r => config.connector.PostProcessRecord(r));
2019
2423
  }
2424
+ // Pipelined prefetch (cursor-paged connectors): the next cursor is known the
2425
+ // moment a page arrives, so start downloading page N+1 while page N is mapped and
2426
+ // written — the shorter leg hides under the longer (~20-30% cycle reduction
2427
+ // measured at a ~6s fetch / ~1-2s process split). Cursor mode ONLY: offset/page
2428
+ // modes interact with the gap-skip resume logic and stay serial. Kill switch:
2429
+ // MJ_INTEGRATION_PREFETCH=off.
2430
+ if ((process.env.MJ_INTEGRATION_PREFETCH ?? 'on') !== 'off' && batch.HasMore === true && batch.NextCursor) {
2431
+ // Built from the ADVANCED position, exactly as the loop-top rebuild does.
2432
+ // Spreading ctx with only CurrentCursor once left AfterKeyValue (and
2433
+ // CurrentOffset) stale, so a keyset connector's "next" page re-ran the
2434
+ // previous seek: page N+1 was page N again, the duplicate-batch fingerprint
2435
+ // killed the walk, and every keyset object stopped at exactly two server pages.
2436
+ const nextCtx = {
2437
+ ...ctx,
2438
+ CurrentPage: batch.NextPage,
2439
+ CurrentOffset: batch.NextOffset,
2440
+ CurrentCursor: batch.NextCursor,
2441
+ AfterKeyValue: batch.NextAfterKeyValue ?? ctx.AfterKeyValue,
2442
+ };
2443
+ const nextPage = this.governedFetch(config, nextCtx, entityMap.ExternalObjectName, fetchTimeoutMs, batchCount + 1, logger);
2444
+ nextPage.catch(() => { });
2445
+ prefetchedNext = { key: batch.NextCursor, promise: nextPage };
2446
+ }
2020
2447
  }
2021
2448
  catch (fetchErr) {
2022
2449
  const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
2023
2450
  // A throttle (429 / rate-limit) backs the adaptive limiter off (honoring Retry-After);
2024
2451
  // other errors don't touch the rate. §5 Gap 2: also flag the map result so the per-layer
2025
2452
  // AIMD controller reduces in-flight concurrency, not just the per-request token bucket.
2453
+ if (IsObjectUnavailable(fetchErr)) {
2454
+ // Not a failure to retry: the vendor is telling us this account does not serve
2455
+ // this object. Warn once and end the map cleanly — no retry ladder, no
2456
+ // FETCH_INCOMPLETE.
2457
+ //
2458
+ // Deliberately NOT remembered between runs. Persisting it would buy one probe
2459
+ // per object per run, and the object count in any real system is small enough
2460
+ // that this is not worth a stored marker, a recheck clock, and the staleness
2461
+ // both bring: a remembered skip is wrong from the moment the account changes,
2462
+ // and every scheme for noticing that is another thing to get right. Re-asking
2463
+ // every run is self-healing by construction and has no configuration.
2464
+ //
2465
+ // But the map fetched NOTHING, so it is NOT a clean fetch, and every consequence
2466
+ // of "we saw the complete set" must be withheld. Breaking out with the flag still
2467
+ // true fell through to the clean-fetch branch and:
2468
+ // - minted a wall-clock Timestamp watermark for an object that returned zero
2469
+ // records. When the account later enables the object, the next incremental
2470
+ // filters `modified > <that stamp>` and permanently misses every record that
2471
+ // already existed — destroying the self-healing described above. (An
2472
+ // incremental over an EXISTING watermark merely rewrote the same value; the
2473
+ // damage lands on a full sync and on the first encounter, where no watermark
2474
+ // row exists yet and one is created at "now".)
2475
+ // - ran orphan detection. An empty fetch is not evidence that MJ's rows are gone.
2476
+ // - overwrote the partition rollup snapshot with an empty map, forcing a full
2477
+ // re-diff next run.
2478
+ fetchCompletedCleanly = false;
2479
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'OBJECT_UNAVAILABLE', `"${entityMap.ExternalObjectName}" is not available to this account; skipping it until the source starts serving it: ${errMsg}`, { externalObjectName: entityMap.ExternalObjectName });
2480
+ break;
2481
+ }
2026
2482
  if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED') {
2027
- this.reportRateOutcome(config, fetchErr);
2483
+ // The adaptive decrease already happened inside governedFetch (once per
2484
+ // throttle episode); here we only flag the map result so the per-layer AIMD
2485
+ // controller reduces in-flight concurrency too.
2028
2486
  result.Throttled = true;
2029
2487
  }
2030
2488
  console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
@@ -2044,6 +2502,15 @@ export class IntegrationEngine extends BaseSingleton {
2044
2502
  fetchGapCount++;
2045
2503
  hadFetchGap = true;
2046
2504
  fetchCompletedCleanly = false;
2505
+ // A durability floor written before this gap may sit PAST the hole (the skipped
2506
+ // page can hold records behind the max watermark seen). Put the row back to what
2507
+ // it held before this run, exactly what the post-loop hold does for the in-memory
2508
+ // value — a crash from here on resumes from the pre-run watermark and re-covers
2509
+ // the gap. Later checkpoints stop writing floors (gate above).
2510
+ if (watermarkFloorSaved !== null) {
2511
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.RestoreValue(entityMapID, preRunWatermarkValue, contextUser));
2512
+ watermarkFloorSaved = null;
2513
+ }
2047
2514
  logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_PAGE_SKIPPED', `Persistent fetch error at ${currentOffset != null ? 'offset ' + currentOffset : 'page ' + currentPage} for ` +
2048
2515
  `'${entityMap.ExternalObjectName}' (batch ${batchCount}); skipped this page and continued — the ` +
2049
2516
  `watermark is held so the window is re-fetched next run. Error: ${errMsg}`, { offset: currentOffset ?? null, page: currentPage ?? null, batchIndex: batchCount, error: errMsg });
@@ -2133,7 +2600,20 @@ export class IntegrationEngine extends BaseSingleton {
2133
2600
  fetchedExternalIDs.clear();
2134
2601
  }
2135
2602
  }
2136
- const mapped = this.fieldMappingEngine.Apply(batch.Records, fieldMaps, entityMap.Entity);
2603
+ // Within-batch identity, enforced before mapping: two records sharing an ExternalID are
2604
+ // two observations of ONE source record. The write path cannot catch this — it decides
2605
+ // insert-vs-update against the DATABASE, where a first-time identity is absent for both
2606
+ // copies, so both insert and the pair re-inserts every sync. The fingerprint guard above
2607
+ // only sees a batch repeated in FULL. Never silent: a connector emitting duplicate
2608
+ // identities is a defect worth fixing at its source.
2609
+ const identity = CollapseDuplicateIdentities(batch.Records);
2610
+ if (identity.Collapsed > 0) {
2611
+ logger?.warning(entityMap.ExternalObjectName ?? 'sync', 'DUPLICATE_IDENTITIES_IN_BATCH', `${entityMap.ExternalObjectName}: ${identity.Collapsed} record(s) repeated an ExternalID already `
2612
+ + `present in the same batch and were collapsed (last occurrence kept). Two records sharing an `
2613
+ + `identity are one source record observed twice; writing both would insert duplicate rows that `
2614
+ + `no later sync could reconcile. Sample: ${identity.SampleIDs.join(', ')}`, { object: entityMap.ExternalObjectName, collapsed: identity.Collapsed, sample: identity.SampleIDs });
2615
+ }
2616
+ const mapped = this.fieldMappingEngine.Apply(identity.Records, fieldMaps, entityMap.Entity, excludedSourceNames);
2137
2617
  // Custom-key stats: aggregate unmapped keys for EVERY mapped record here —
2138
2618
  // before any skip decision — so candidates + sizing stats exist even when the
2139
2619
  // content-hash fast path skips the row (the hash basis deliberately excludes them).
@@ -2162,11 +2642,11 @@ export class IntegrationEngine extends BaseSingleton {
2162
2642
  // not begun"). Holding the same write-lock for the read keeps the connection single-owner.
2163
2643
  const resolved = partitionReconcile
2164
2644
  ? []
2165
- : await this.runWriteExclusive(() => this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser));
2645
+ : await this.runWriteForMap(entityMap.ID, () => this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser));
2166
2646
  const beforeApply = result.RecordsCreated + result.RecordsUpdated + result.RecordsSkipped + result.RecordsErrored;
2167
2647
  try {
2168
2648
  if (!partitionReconcile)
2169
- await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1);
2649
+ await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1, this.getSyncConcurrency(config));
2170
2650
  }
2171
2651
  catch (applyErr) {
2172
2652
  if (applyErr instanceof SchemaNotGeneratedError) {
@@ -2202,7 +2682,7 @@ export class IntegrationEngine extends BaseSingleton {
2202
2682
  (batch.HasMore ? ` | more batches pending` : ` | batch complete`));
2203
2683
  // Update progress on the watermark record so the DB reflects live sync state
2204
2684
  if (batch.HasMore) {
2205
- await this.runWriteExclusive(() => this.watermarkService.UpdateProgress(entityMapID, afterApply, contextUser));
2685
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.UpdateProgress(entityMapID, afterApply, contextUser));
2206
2686
  }
2207
2687
  }
2208
2688
  recordsInMap += batch.Records.length;
@@ -2236,7 +2716,26 @@ export class IntegrationEngine extends BaseSingleton {
2236
2716
  // post-loop save below handles graceful early-exits precisely; this covers a SIGKILL
2237
2717
  // between graceful checkpoints, costing at most ~25 batches of re-fetch on resume.
2238
2718
  if (isKeysetConnector && currentAfterKey) {
2239
- await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
2719
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
2720
+ }
2721
+ // The WATERMARK twin of the keyset floor above. Without it, a watermark-based
2722
+ // connector had NO durable position at all until the run ended: a SIGKILL / OOM /
2723
+ // container recycle mid-object threw away hours of applied batches and the next run
2724
+ // re-fetched the entire window from the last completed run's watermark. Same safety
2725
+ // argument as the graceful early-exit save below — currentWatermark only ever
2726
+ // advances at the END of a fully-applied batch, so this floor can never point past a
2727
+ // record that was not written. Gated on !hadFetchGap because a skipped page may
2728
+ // contain records BEHIND the max watermark seen (fetch order is not watermark order
2729
+ // on every source), i.e. a hole behind the floor; once a gap exists the floor stops
2730
+ // moving, and the one already written is retracted at the gap site. Skipped for
2731
+ // keyset connectors (their position IS the seek key above) and partition-reconcile
2732
+ // maps (their watermark row stores the rollup snapshot, not a timestamp).
2733
+ if (!isKeysetConnector && !partitionReconcile && !hadFetchGap
2734
+ && currentWatermark && currentWatermark !== initialWatermark
2735
+ && currentWatermark !== watermarkFloorSaved) {
2736
+ const floor = currentWatermark;
2737
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, floor, contextUser, 'Pull'));
2738
+ watermarkFloorSaved = floor;
2240
2739
  }
2241
2740
  }
2242
2741
  // P3-D: a connector returning empty pages with HasMore=true would otherwise spin silently
@@ -2272,7 +2771,7 @@ export class IntegrationEngine extends BaseSingleton {
2272
2771
  // NOTE: a connector that ALSO returns a monotonic watermark (MonotonicWatermark=true) skips
2273
2772
  // this branch and falls through to SAVE that watermark below, so its next incremental NARROWS
2274
2773
  // (microtime > watermark) instead of re-scanning the whole object every run.
2275
- await this.runWriteExclusive(() => this.watermarkService.ClearKeysetPosition(entityMapID, contextUser));
2774
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.ClearKeysetPosition(entityMapID, contextUser));
2276
2775
  result.WatermarkAfter = null;
2277
2776
  }
2278
2777
  else if (fetchCompletedCleanly) {
@@ -2313,13 +2812,13 @@ export class IntegrationEngine extends BaseSingleton {
2313
2812
  else {
2314
2813
  finalWatermark = new Date().toISOString();
2315
2814
  }
2316
- await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull'));
2815
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull'));
2317
2816
  result.WatermarkAfter = finalWatermark;
2318
2817
  }
2319
2818
  else if (isKeysetConnector && currentAfterKey) {
2320
2819
  // The keyset scan stopped early (cancel / fetch error / safety limit). Persist the precise
2321
2820
  // last ordering key so the next run resumes the seek from here instead of restarting.
2322
- await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
2821
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
2323
2822
  result.WatermarkAfter = currentAfterKey;
2324
2823
  }
2325
2824
  else if (!hadFetchGap && currentWatermark && currentWatermark !== initialWatermark) {
@@ -2339,7 +2838,7 @@ export class IntegrationEngine extends BaseSingleton {
2339
2838
  // deliberately NOT when hadFetchGap — a skipped page leaves a HOLE behind this watermark,
2340
2839
  // which is why that path holds it for a full re-fetch next run.
2341
2840
  const partialWatermark = currentWatermark;
2342
- await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, partialWatermark, contextUser, 'Pull'));
2841
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, partialWatermark, contextUser, 'Pull'));
2343
2842
  result.WatermarkAfter = partialWatermark;
2344
2843
  }
2345
2844
  // Orphan detection: delete/tombstone MJ records whose external counterpart no longer exists.
@@ -2443,6 +2942,7 @@ export class IntegrationEngine extends BaseSingleton {
2443
2942
  async ProcessPushSync(config, entityMap, run, contextUser, _entityMapIndex, _totalEntityMaps, _onProgress, _abortSignal, logger) {
2444
2943
  const entityMapID = entityMap.ID;
2445
2944
  const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
2945
+ this.WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger);
2446
2946
  const pushWatermark = await this.watermarkService.Load(entityMapID, contextUser, 'Push');
2447
2947
  const lastPushAt = pushWatermark?.WatermarkValue ?? null;
2448
2948
  // Check connector write capability
@@ -2918,7 +3418,7 @@ export class IntegrationEngine extends BaseSingleton {
2918
3418
  }
2919
3419
  // Surface a failed conflict-mark: the engine thinks the row is quarantined, but without the
2920
3420
  // marker the operator has no signal. A silent failure here leaves the record in limbo.
2921
- const ok = await entity.Save();
3421
+ const ok = await entity.Save(this.syncSaveOptions);
2922
3422
  if (!ok) {
2923
3423
  logger?.warning(entityMap.ExternalObjectName ?? entityMap.Entity ?? entityMap.ID, 'CONFLICT_MARK_FAILED', `Could not mark MJ record ${mjRecordID} in-conflict: ${entity.LatestResult?.CompleteMessage ?? 'Save() returned false'}`, { mjRecordID, conflictFields });
2924
3424
  }
@@ -2949,12 +3449,25 @@ export class IntegrationEngine extends BaseSingleton {
2949
3449
  const orphans = allMaps.Rows.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
2950
3450
  if (orphans.length === 0)
2951
3451
  return;
3452
+ // The sweep is a DELETE PATH and must answer to the same policy as every other delete.
3453
+ // It used to call entity.Delete() unconditionally — its own warning text promised
3454
+ // "archived/deleted" while the code only ever deleted, so a map configured SoftDelete
3455
+ // (or DoNothing) had its rows physically removed by full syncs. DoNothing short-circuits
3456
+ // the whole sweep: the policy says external deletions never touch MJ rows, and saying so
3457
+ // once beats detecting the same "orphans" forever.
3458
+ if (entityMap.DeleteBehavior === 'DoNothing') {
3459
+ 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 ` +
3460
+ `full sync. This map's DeleteBehavior is 'DoNothing', so none were touched.`, { orphanCount: orphans.length });
3461
+ return;
3462
+ }
2952
3463
  console.log(`[IntegrationEngine] Orphan detection for ${entityMap.ExternalObjectName}: ${orphans.length} records in MJ not found in external system`);
2953
3464
  // Surface delete-detection in the structured stream (previously console-only). The orphan
2954
3465
  // COUNT is already in the run counts via RecordsDeleted, but a dedicated warning makes a
2955
3466
  // large/unexpected count visible over GraphQL — the early signal of an incomplete upstream
2956
3467
  // fetch silently archiving live records.
2957
- logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync — they will be archived/deleted (delete-detection). A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
3468
+ 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 — ` +
3469
+ `they will be ${entityMap.DeleteBehavior === 'SoftDelete' ? 'archived (SoftDelete)' : 'deleted'} and their record-map rows pruned (delete-detection). ` +
3470
+ `A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
2958
3471
  const md = this.ProviderToUse;
2959
3472
  const entityInfo = md.EntityByName(entityMap.Entity);
2960
3473
  const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
@@ -2964,11 +3477,44 @@ export class IntegrationEngine extends BaseSingleton {
2964
3477
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(orphan.EntityRecordID, pkFields));
2965
3478
  if (!loaded) {
2966
3479
  console.log(`[IntegrationEngine] Orphan ${orphan.EntityRecordID} already deleted from MJ`);
3480
+ // The map row outlived its record. Nothing anywhere else deletes record-map
3481
+ // rows, so without this the same "orphan" is re-detected on EVERY subsequent
3482
+ // full sync and ORPHANS_DETECTED becomes a cumulative counter of history
3483
+ // rather than a signal about THIS run — observed live as a count that only
3484
+ // ever grew, sync after sync.
3485
+ await this.DeleteRecordMapRow(orphan.ID, contextUser);
2967
3486
  continue;
2968
3487
  }
2969
- const deleted = await entity.Delete();
3488
+ if (entityMap.DeleteBehavior === 'SoftDelete') {
3489
+ // Same archive shape as DeleteRecord's SoftDelete branch: the row stays,
3490
+ // marked Archived/tombstoned. The MAP row goes either way — the mapping's
3491
+ // job is done, and keeping it would re-detect this orphan forever.
3492
+ const fields = entity.Fields ?? [];
3493
+ const hasField = (n) => fields.some(f => f.Name === n);
3494
+ if (hasField('__mj_integration_SyncStatus'))
3495
+ entity.Set('__mj_integration_SyncStatus', 'Archived');
3496
+ if (hasField('__mj_integration_LastSyncedAt'))
3497
+ entity.Set('__mj_integration_LastSyncedAt', new Date().toISOString());
3498
+ if (hasField('__mj_integration_IsTombstoned'))
3499
+ entity.Set('__mj_integration_IsTombstoned', true);
3500
+ if (hasField('__mj_integration_DeletedDetectedAt'))
3501
+ entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
3502
+ const archived = await entity.Save(this.syncSaveOptions);
3503
+ if (archived) {
3504
+ result.RecordsDeleted++;
3505
+ await this.DeleteRecordMapRow(orphan.ID, contextUser);
3506
+ console.log(`[IntegrationEngine] Archived orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
3507
+ }
3508
+ else {
3509
+ const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
3510
+ console.warn(`[IntegrationEngine] Orphan archive blocked for ${entityMap.Entity} ${orphan.EntityRecordID} — ${reason}`);
3511
+ }
3512
+ continue;
3513
+ }
3514
+ const deleted = await entity.Delete(this.syncDeleteOptions);
2970
3515
  if (deleted) {
2971
3516
  result.RecordsDeleted++;
3517
+ await this.DeleteRecordMapRow(orphan.ID, contextUser);
2972
3518
  console.log(`[IntegrationEngine] Deleted orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
2973
3519
  }
2974
3520
  else {
@@ -2982,6 +3528,28 @@ export class IntegrationEngine extends BaseSingleton {
2982
3528
  }
2983
3529
  }
2984
3530
  }
3531
+ /**
3532
+ * Removes one 'MJ: Company Integration Record Maps' row after delete-detection has handled
3533
+ * its orphan (deleted, archived, or found already gone). A failure here is logged and
3534
+ * swallowed: the orphan itself was handled, and the worst consequence of a surviving map
3535
+ * row is one redundant re-detection on the next full sync.
3536
+ */
3537
+ async DeleteRecordMapRow(mapRowID, contextUser) {
3538
+ try {
3539
+ const md = this.ProviderToUse;
3540
+ const mapRow = await md.GetEntityObject('MJ: Company Integration Record Maps', contextUser);
3541
+ const loaded = await mapRow.InnerLoad(CompositeKey.FromID(mapRowID));
3542
+ if (!loaded)
3543
+ return;
3544
+ const ok = await mapRow.Delete();
3545
+ if (!ok) {
3546
+ console.warn(`[IntegrationEngine] Record-map prune blocked for ${mapRowID} — ${mapRow.LatestResult?.CompleteMessage ?? 'unknown reason'}`);
3547
+ }
3548
+ }
3549
+ catch (err) {
3550
+ console.warn(`[IntegrationEngine] Record-map prune failed for ${mapRowID}: ${err instanceof Error ? err.message : String(err)}`);
3551
+ }
3552
+ }
2985
3553
  /**
2986
3554
  * Strips field-name prefixes from a RecordChange.RecordID to get the raw entity record ID.
2987
3555
  * "hs_object_id|465950833372" → "465950833372"
@@ -3109,8 +3677,8 @@ export class IntegrationEngine extends BaseSingleton {
3109
3677
  // (~line 1644). matchEngine.Resolve reads existing MJ rows on the SHARED provider
3110
3678
  // connection, so when streams run in parallel (syncConcurrency>1) it must not interleave
3111
3679
  // with another stream's open write transaction (else "Transaction in progress" / dirty read).
3112
- const resolved = await this.runWriteExclusive(() => this.matchEngine.Resolve(recs, entityMap, fieldMaps, contextUser));
3113
- await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1);
3680
+ const resolved = await this.runWriteForMap(entityMap.ID, () => this.matchEngine.Resolve(recs, entityMap, fieldMaps, contextUser));
3681
+ await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1, this.getSyncConcurrency(config));
3114
3682
  appliedRecords += recs.length;
3115
3683
  }
3116
3684
  }
@@ -3148,7 +3716,14 @@ export class IntegrationEngine extends BaseSingleton {
3148
3716
  // lost batch atomicity is absorbed by the engine's idempotency (upsert-by-identity + content
3149
3717
  // hash) and the safe-floor watermark (advances only on a clean batch). Default true = the
3150
3718
  // proven atomic serial path, unchanged.
3151
- useTransaction = true) {
3719
+ useTransaction = true,
3720
+ /**
3721
+ * Requested apply concurrency. Only consulted on the transaction-free path (the one
3722
+ * `useTransaction: false` selects), where records auto-commit independently and can
3723
+ * therefore overlap. Defaults to 1, so a caller that does not pass it keeps the serial
3724
+ * behaviour exactly.
3725
+ */
3726
+ concurrency = 1) {
3152
3727
  // Batched application with per-record failure isolation (the "grace gap" fix).
3153
3728
  // Happy path: each batch of up to APPLY_BATCH_SIZE records commits as a single
3154
3729
  // transaction — small enough to avoid SQL Server lock escalation (~5000 rows) while
@@ -3175,27 +3750,174 @@ export class IntegrationEngine extends BaseSingleton {
3175
3750
  // otherwise load one-by-one. For a watermark-less re-sync where nothing changed,
3176
3751
  // this lets UpdateRecord skip every per-record load. Best-effort: undefined → the
3177
3752
  // existing dirty-flag path runs unchanged.
3178
- // Serialize the per-batch DB-write across concurrently-synced streams (shared provider
3179
- // connection ⇒ one transaction at a time). Fetch already happened in parallel upstream;
3180
- // only this write section is mutually exclusive. A throw inside (e.g. SchemaNotGenerated)
3181
- // propagates out to fail-stop this entity map, exactly as before.
3182
- await this.runWriteExclusive(async () => {
3753
+ // WHAT ACTUALLY NEEDS THE MUTEX.
3754
+ //
3755
+ // The shared provider connection holds one transaction at a time, so the section that
3756
+ // OWNS a transaction must be mutually exclusive. `BeginTransaction` + per-record
3757
+ // `Save()` owns one for the whole batch, and stays serialized exactly as before.
3758
+ //
3759
+ // A batched batch does not. A `TransactionGroup` is an in-memory list until `Submit()`:
3760
+ // enrolling an entity validates, checks row scope and renders the CRUD procedure call,
3761
+ // then parks it — no statement travels, no transaction is open. Only `Submit` touches
3762
+ // the connection. Holding the mutex across the whole apply block was therefore
3763
+ // serializing work that never needed it, and that is what made batching and concurrency
3764
+ // mutually exclusive: maps could not overlap on the part where the time actually goes
3765
+ // (fetch, paging, transform, enrolment) because they were queued behind each other's
3766
+ // writes.
3767
+ //
3768
+ // So the batched path takes the mutex only around the writes themselves. One
3769
+ // transaction is still in flight at a time — the invariant is unchanged — but maps
3770
+ // overlap everywhere else, and each keeps its OWN group, so a poison record fails the
3771
+ // map that owns it instead of every map that happened to be batching alongside it.
3772
+ // Deliberately NOT gated on `useTransaction`. That gate is
3773
+ // `getSyncConcurrency(config) <= 1`, so keeping it here would mean batching only ever
3774
+ // happened at concurrency 1 — the exact tradeoff this change exists to remove.
3775
+ // Batching is a property of how the writes TRAVEL; concurrency is a property of how
3776
+ // many maps fetch at once. They are independent.
3777
+ // Batching is a property of the ENTITY MAP, not only of the connection: a map whose
3778
+ // target's whole identity is server-assigned can never enrol a record, so batching it
3779
+ // would produce an empty group and a non-atomic batch reporting success. See
3780
+ // entityMapHasIdentityOnlyPK.
3781
+ const batchedWrites = this.ReadWriteMode(companyIntegration) === 'batched'
3782
+ && !this.entityMapHasIdentityOnlyPK(entityMap);
3783
+ // NEVER nest `runWriteExclusive`: the inner call waits on a chain that already contains
3784
+ // the outer one, which deadlocks. Under the outer mutex the writes are already
3785
+ // serialized, so they run inline; without it they take the mutex individually.
3786
+ const serializeWrite = batchedWrites
3787
+ ? (fn) => this.runWriteForMap(entityMap.ID, fn)
3788
+ : (fn) => fn();
3789
+ const applyOneBatch = async () => {
3183
3790
  const precheckHashes = await this.PrefetchContentHashes(batch, contextUser);
3184
3791
  // PKs of records the content-hash fast path skipped this batch — still present and
3185
3792
  // confirmed-unchanged on the source. Collected so we can refresh LastReconciledAt for
3186
3793
  // all of them in ONE set-based touch after the batch (instead of a frozen-forever stamp).
3187
3794
  let reconciledSkipIds = [];
3188
- if (useTransaction) {
3189
- await provider.BeginTransaction();
3190
- try {
3795
+ // Batched writes make a batch atomic by construction — the group is one
3796
+ // transaction — so this branch is entered for them regardless of concurrency. At
3797
+ // concurrency > 1 the atomicity is per entity map, and a group failure still
3798
+ // degrades to the record-by-record retry below.
3799
+ if (useTransaction || batchedWrites) {
3800
+ // Two ways to make this batch atomic, and they differ ONLY in how the writes
3801
+ // travel. `BeginTransaction` + per-record `Save()` sends one statement per
3802
+ // record; a TransactionGroup defers each `Save()` to `Submit()`, which sends
3803
+ // them together. Everything `Save()` does either way — validation, row scope,
3804
+ // the generated CRUD procedure, Record Changes, `OnAfterSaveExecute` — is
3805
+ // identical, because the group defers the WRITE and nothing else.
3806
+ //
3807
+ // Opt-in per connection, and it fails closed: an absent, unparseable or
3808
+ // unrecognised `writeMode` keeps the proven path, so the default never changes
3809
+ // underneath an existing tenant.
3810
+ const writeGroup = batchedWrites ? await provider.CreateTransactionGroup() : null;
3811
+ if (writeGroup) {
3812
+ // The batching itself (MJ#4087). Without this the group is atomic but still
3813
+ // SERIAL — one round trip per item inside the transaction, which is exactly
3814
+ // today's behaviour and why this PR was safe to land before the providers
3815
+ // could honour it. With it, the whole group travels as one statement.
3816
+ //
3817
+ // Set here rather than at construction because it is a property of how THIS
3818
+ // engine wants its batch submitted, not of the provider: any other caller
3819
+ // creating a group still gets the sequential default.
3820
+ writeGroup.BatchedSubmit = true;
3821
+ }
3822
+ const runCtx = this.currentRunContext;
3823
+ // A batched batch REQUIRES a run context: the group is handed to
3824
+ // ApplySingleRecord's frames through a nested scope of it, so without one
3825
+ // every record would silently save unenrolled and Submit would commit an
3826
+ // empty group — which returns true, so the batch would report success while
3827
+ // having quietly run the per-record path. Every sync path enters a run
3828
+ // context (RunSync and the adopted-run path both wrap their work in one), so
3829
+ // this cannot happen today; stating it makes that a requirement rather than
3830
+ // an accident, and turns a future regression into an error instead of a
3831
+ // silent loss of the feature.
3832
+ if (writeGroup && !runCtx) {
3833
+ throw new Error('batched writes were requested but no run context is active — the write group cannot reach the record ' +
3834
+ 'frames, and an unenrolled batch would report success while saving per-record. This is a wiring bug.');
3835
+ }
3836
+ if (!writeGroup)
3837
+ await provider.BeginTransaction();
3838
+ // EACH BATCH GETS ITS OWN GROUP, IN ITS OWN CONTEXT SCOPE.
3839
+ //
3840
+ // Assigning onto the shared run context would be a single slot: the moment two
3841
+ // maps overlap — which narrowing the mutex now allows — the second would
3842
+ // overwrite the first's group and enrol its records into the wrong batch.
3843
+ // Entering a nested AsyncLocalStorage scope instead gives every concurrent
3844
+ // batch its own `writeGroup`, inherited by the ApplySingleRecord frames below
3845
+ // that actually construct the entities, and torn down with the scope.
3846
+ //
3847
+ // Per-batch groups are also what keeps failures isolated: a poison record fails
3848
+ // the group its own map owns, and every other map in flight is untouched.
3849
+ // OPT-IN ceiling on how many writes may sit deferred in one group.
3850
+ //
3851
+ // A group holds every enrolled record's rendered SQL and parameters until Submit,
3852
+ // so peak memory for a batched apply is roughly (maps in flight x group size x row
3853
+ // size). With wide rows that is the largest allocation a sync makes, and a box that
3854
+ // has run out of heap has no way to trade a little throughput for headroom.
3855
+ //
3856
+ // Unset (the default) means NO mid-batch flush: the batch stays exactly one group
3857
+ // and one transaction, as today. Setting it below the batch size splits the batch
3858
+ // into several transactions, which is a real trade — an earlier flush stays
3859
+ // committed if a later one fails — and is why it is off unless asked for. The
3860
+ // per-record fallback that follows a failed batch is idempotent, so the split is
3861
+ // recoverable; it is simply no longer all-or-nothing.
3862
+ const flushAt = ReadFlushCeiling(process.env);
3863
+ const runBatch = async () => {
3864
+ let enrolledSinceFlush = 0;
3191
3865
  for (const record of batch) {
3192
3866
  result.RecordsProcessed++;
3193
3867
  await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
3868
+ if (flushAt === undefined)
3869
+ continue;
3870
+ if (++enrolledSinceFlush < flushAt)
3871
+ continue;
3872
+ enrolledSinceFlush = 0;
3873
+ // Mutating the context object is what makes the swap visible: every frame
3874
+ // below shares this same object through AsyncLocalStorage, so replacing the
3875
+ // group here is what the next record's enrolment sees.
3876
+ const ctx = this.currentRunContext;
3877
+ const full = ctx?.writeGroup;
3878
+ if (!full)
3879
+ continue;
3880
+ const submitted = await serializeWrite(() => full.Submit());
3881
+ if (!submitted)
3882
+ throw new Error('Batched write group did not commit');
3883
+ const fresh = await provider.CreateTransactionGroup();
3884
+ fresh.BatchedSubmit = true;
3885
+ ctx.writeGroup = fresh;
3886
+ }
3887
+ };
3888
+ try {
3889
+ if (writeGroup) {
3890
+ // Guarded above: a batched batch always has a run context to nest.
3891
+ await IntegrationEngine.runContext.run({ ...runCtx, writeGroup }, runBatch);
3892
+ }
3893
+ else {
3894
+ await runBatch();
3895
+ }
3896
+ if (writeGroup) {
3897
+ // The group holds every deferred write; Submit is where they land, in
3898
+ // one transaction. A false return means the group failed as a whole —
3899
+ // routed into the same catch, so the fallback below is reached by both
3900
+ // shapes rather than only by a throw.
3901
+ //
3902
+ // This is the ONLY part of a batched batch that touches the connection,
3903
+ // so it is the only part that takes the write mutex.
3904
+ const submitted = await serializeWrite(() => writeGroup.Submit());
3905
+ if (!submitted)
3906
+ throw new Error('Batched write group did not commit');
3907
+ }
3908
+ else {
3909
+ await provider.CommitTransaction();
3194
3910
  }
3195
- await provider.CommitTransaction();
3196
3911
  }
3197
3912
  catch (err) {
3198
- await provider.RollbackTransaction();
3913
+ // No shared slot to clear: the group lived in the batch's own context
3914
+ // scope, which has already unwound.
3915
+ //
3916
+ // A group that failed has already rolled itself back and there is no
3917
+ // provider-level transaction open to roll back — calling it would throw
3918
+ // over the real error.
3919
+ if (!batchedWrites)
3920
+ await provider.RollbackTransaction();
3199
3921
  // The batch transaction rolled back; the skip-IDs collected during the failed attempt
3200
3922
  // never committed. Reset and let the per-record retry re-collect only what commits.
3201
3923
  reconciledSkipIds = [];
@@ -3217,7 +3939,11 @@ export class IntegrationEngine extends BaseSingleton {
3217
3939
  }
3218
3940
  // Degrade to per-record application so the failure isolates to the poison
3219
3941
  // record(s) and every good record in this batch still commits.
3220
- await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
3942
+ // A batched batch runs concurrently with other entity maps, so the
3943
+ // per-record fallback must NOT open a provider transaction — that state
3944
+ // is global to the shared provider. Auto-commit instead; see the
3945
+ // `useProviderTransaction` doc on applyRecordsIndividually.
3946
+ await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps, !batchedWrites);
3221
3947
  }
3222
3948
  }
3223
3949
  else {
@@ -3227,7 +3953,23 @@ export class IntegrationEngine extends BaseSingleton {
3227
3953
  // connection. Per-record error isolation: a poison record is logged + counted; the
3228
3954
  // rest still commit; the idempotent re-sync + safe-floor watermark reconcile any
3229
3955
  // partial batch (the atomicity the transactional path provides is not needed here).
3230
- for (const record of batch) {
3956
+ // The records are independent and each auto-commits on its own pooled
3957
+ // connection, so this is the one place in the apply path where the requested
3958
+ // concurrency can actually be spent. Running them one at a time made
3959
+ // syncConcurrency a fetch-only setting: the caller opted into concurrency, paid
3960
+ // for it by giving up batch atomicity, and then still wrote serially.
3961
+ //
3962
+ // A fixed pool of workers pulling from a shared cursor, rather than
3963
+ // Promise.all over the batch: 500 simultaneous saves would swamp the connection
3964
+ // pool. The cap is the same knob the fetch side uses, clamped to a sane ceiling.
3965
+ const applyLimit = Math.max(1, Math.min(16, Math.floor(concurrency) || 1));
3966
+ let cursor = 0;
3967
+ // Set by whichever worker sees it; every worker stops at the next pull and the
3968
+ // error is rethrown after they settle. SchemaNotGeneratedError means the whole
3969
+ // map cannot proceed, so finishing the remaining records would be wasted work
3970
+ // against a table that does not exist.
3971
+ let fatal;
3972
+ const applyOne = async (record) => {
3231
3973
  result.RecordsProcessed++;
3232
3974
  try {
3233
3975
  // §10 — bounded inline retry for provably-transient save failures (auto-commit per
@@ -3241,7 +3983,8 @@ export class IntegrationEngine extends BaseSingleton {
3241
3983
  }
3242
3984
  catch (err) {
3243
3985
  if (err instanceof SchemaNotGeneratedError) {
3244
- throw err;
3986
+ fatal ??= err;
3987
+ return;
3245
3988
  }
3246
3989
  // §10 — permanent / retry-exhausted → dead-letter (count + log), move on; watermark advances regardless.
3247
3990
  result.RecordsErrored++;
@@ -3254,20 +3997,40 @@ export class IntegrationEngine extends BaseSingleton {
3254
3997
  Severity: classified.Severity,
3255
3998
  });
3256
3999
  }
3257
- }
4000
+ };
4001
+ // allSettled, not all: a worker must never reject, and the counters/dead-letter
4002
+ // list are only coherent once every worker has stopped touching them.
4003
+ await Promise.allSettled(Array.from({ length: Math.min(applyLimit, batch.length) }, async () => {
4004
+ for (;;) {
4005
+ if (fatal !== undefined)
4006
+ return;
4007
+ const next = cursor++;
4008
+ if (next >= batch.length)
4009
+ return;
4010
+ await applyOne(batch[next]);
4011
+ }
4012
+ }));
4013
+ if (fatal !== undefined)
4014
+ throw fatal;
3258
4015
  }
3259
4016
  // After the batch settles (committed, or per-record retried), refresh
3260
4017
  // LastReconciledAt for every content-hash-skipped row in ONE set-based touch.
3261
4018
  // Best-effort — a touch failure must never break the sync.
3262
4019
  if (reconciledSkipIds.length > 0) {
3263
- await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
4020
+ await serializeWrite(() => this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger));
3264
4021
  }
3265
4022
  // Write the batch's record maps set-based, now that the records they point at are
3266
4023
  // committed. Deliberately AFTER the transaction rather than inside it: the mapping
3267
4024
  // is derived data that the next sync can re-establish by primary key, and keeping
3268
4025
  // it out of the write transaction keeps that transaction as short as possible.
3269
- await this.FlushRecordMaps(recordMaps, entityMap, logger);
3270
- });
4026
+ await serializeWrite(() => this.FlushRecordMaps(recordMaps, entityMap, logger));
4027
+ };
4028
+ // Batched: overlap freely, serializing only the writes above. Otherwise: the whole
4029
+ // block stays under the mutex, because it owns a provider transaction throughout.
4030
+ if (batchedWrites)
4031
+ await applyOneBatch();
4032
+ else
4033
+ await this.runWriteExclusive(applyOneBatch);
3271
4034
  }
3272
4035
  }
3273
4036
  /**
@@ -3350,17 +4113,45 @@ export class IntegrationEngine extends BaseSingleton {
3350
4113
  *
3351
4114
  * Begin/Commit/Rollback are always matched per record (no leaked open transaction).
3352
4115
  */
3353
- async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps) {
4116
+ async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps,
4117
+ // When false, each record is applied WITHOUT a provider transaction (auto-commit).
4118
+ //
4119
+ // The provider holds its transaction state — `_transactionDepth`, the active
4120
+ // `Transaction`, the savepoint counter — as SINGLE FIELDS on the one shared provider
4121
+ // instance. `BeginTransaction` from two concurrently-syncing entity maps therefore
4122
+ // races that counter: the second caller sees depth 2, issues `SAVE TRANSACTION` against
4123
+ // a transaction the first caller may have already committed, and the leaked depth then
4124
+ // fails every subsequent query on the connection with "Transaction has not begun".
4125
+ //
4126
+ // That is safe ONLY while the engine owns the provider exclusively, i.e. sequential
4127
+ // sync (`useTransaction === true`). The batched path is not sequential, and it does not
4128
+ // need a transaction here either: `ApplySingleRecord` performs exactly ONE write
4129
+ // (Create | Update | Delete — record-maps are queued into `RecordMapBatch` and flushed
4130
+ // set-based later), so there is nothing for a transaction to make atomic. A single
4131
+ // statement either commits or it does not, and `WithRetry`'s next attempt starts clean
4132
+ // without a rollback of a transaction that never held anything.
4133
+ //
4134
+ // This mirrors exactly what the concurrent non-batched path already does: it applies
4135
+ // records through the SAME `ApplySingleRecord` with no transaction at all, and has run
4136
+ // at concurrency > 1 in production.
4137
+ useProviderTransaction = true) {
3354
4138
  const provider = this.ProviderToUse;
3355
4139
  for (const record of batch) {
3356
4140
  result.RecordsProcessed++;
3357
4141
  try {
3358
- // §10 — apply in its own transaction, with bounded inline retry for PROVABLY-TRANSIENT
3359
- // save failures (NETWORK_TIMEOUT / RATE_LIMIT_EXCEEDED / DATABASE_ERROR per IsRetryableError).
3360
- // Each attempt rolls back on throw so the next starts clean; a deadlock/momentary timeout
3361
- // self-heals here. A PERMANENT error (validation/FK/duplicate/config) is NOT retried — it
3362
- // throws straight out to the dead-letter path below.
4142
+ // §10 — bounded inline retry for PROVABLY-TRANSIENT save failures
4143
+ // (NETWORK_TIMEOUT / RATE_LIMIT_EXCEEDED / DATABASE_ERROR per IsRetryableError).
4144
+ // A PERMANENT error (validation/FK/duplicate/config) is NOT retried it throws
4145
+ // straight out to the dead-letter path below.
3363
4146
  await WithRetry(async () => {
4147
+ if (!useProviderTransaction) {
4148
+ // Auto-commit: never touches shared provider transaction state, so
4149
+ // concurrent entity maps cannot corrupt each other. See the parameter doc.
4150
+ await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
4151
+ return;
4152
+ }
4153
+ // Sequential path, unchanged: apply in its own transaction so a deadlock or
4154
+ // momentary timeout rolls back and the next attempt starts clean.
3364
4155
  await provider.BeginTransaction();
3365
4156
  try {
3366
4157
  await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
@@ -3434,7 +4225,7 @@ export class IntegrationEngine extends BaseSingleton {
3434
4225
  try {
3435
4226
  switch (record.ChangeType) {
3436
4227
  case 'Create': {
3437
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
4228
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
3438
4229
  if (outcome === 'updated')
3439
4230
  result.RecordsUpdated++;
3440
4231
  else if (outcome === 'skipped')
@@ -3444,7 +4235,7 @@ export class IntegrationEngine extends BaseSingleton {
3444
4235
  break;
3445
4236
  }
3446
4237
  case 'Update':
3447
- await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps);
4238
+ await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps, logger);
3448
4239
  break;
3449
4240
  case 'Delete': {
3450
4241
  const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
@@ -3504,16 +4295,55 @@ export class IntegrationEngine extends BaseSingleton {
3504
4295
  *
3505
4296
  * @returns true if an existing row was updated, false if a new row was inserted (so the caller counts correctly).
3506
4297
  */
3507
- async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps) {
4298
+ async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps,
4299
+ /** Optional — lets the keyless-key guard below surface on the run's event stream. */
4300
+ keylessLogger,
4301
+ /** What the batch already proved about its destination rows, if anything. */
4302
+ precheck) {
3508
4303
  const md = this.ProviderToUse;
3509
4304
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
4305
+ this.enrolInWriteGroup(entity);
3510
4306
  const entityInfo = md.EntityByName(record.MJEntityName);
3511
4307
  const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
3512
4308
  // Upsert-safe: if the record's mapped fields carry a PK (soft-PK dest tables key on the external
3513
4309
  // ID), check whether that row already exists before deciding INSERT vs UPDATE. A null mappedPK
3514
4310
  // (e.g. a server-assigned UUID PK not present in the mapped fields) means a genuinely new row.
3515
4311
  const mappedPK = this.extractMappedPrimaryKey(record, pkFields);
3516
- const existed = mappedPK != null
4312
+ // INVARIANT: a record destined for a SOFT-primary-key table must carry its key.
4313
+ //
4314
+ // A soft PK is INFERRED, not generated — it is the external system's own identifier, stored
4315
+ // as ordinary data with no PRIMARY KEY constraint, no identity, and no unique index (see
4316
+ // DDLGenerator: soft keys get a non-unique index only). So nothing at the database level
4317
+ // rejects a NULL key, and a row written without one can never be matched again: the next
4318
+ // sync's existence check misses it, and inserts another copy. Every pass therefore multiplies
4319
+ // the damage silently — the rows look fully populated, only the key column is empty.
4320
+ //
4321
+ // `mappedPK == null` is legitimate ONLY when the destination generates its own key (an
4322
+ // identity column or a server-assigned UUID), which is why the check is scoped to soft PKs
4323
+ // rather than applied to every table.
4324
+ const keyless = DecideKeylessRefusal(mappedPK, pkFields, MissingKeyFieldNames(record.MappedFields, pkFields, serializeKeyValue));
4325
+ if (keyless.Refuse) {
4326
+ const detail = DescribeKeylessRefusal(record.MJEntityName, keyless.KeyNames);
4327
+ keylessLogger?.emit('sync.record.error', {
4328
+ phase: 'write',
4329
+ externalObjectName: entityMap.ExternalObjectName,
4330
+ externalID: record.ExternalRecord?.ExternalID,
4331
+ error: `KEYLESS_RECORD_REFUSED: ${detail}`,
4332
+ });
4333
+ // Reported even without a logger — silence is the failure mode this guard exists to end.
4334
+ console.error(`[IntegrationEngine] KEYLESS_RECORD_REFUSED: ${detail}`);
4335
+ return 'skipped';
4336
+ }
4337
+ // The batch's prefetch may already have proved this row absent. When it did, the load below
4338
+ // is a SELECT * — every column including any NVARCHAR(MAX) — issued once per record, to
4339
+ // learn something the batch established in a single query. Skipping it is the whole point
4340
+ // of widening that prefetch to the create path.
4341
+ //
4342
+ // Only ever used to skip work when absence is PROVEN: the prefetch must have covered every
4343
+ // record in the batch, and this key must be missing from it. Anything less falls through to
4344
+ // the load, because a wrong "absent" turns an update into a duplicate insert.
4345
+ const provablyAbsent = this.isProvablyAbsent(mappedPK, precheck);
4346
+ const existed = mappedPK != null && !provablyAbsent
3517
4347
  ? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
3518
4348
  : false;
3519
4349
  if (existed) {
@@ -3540,7 +4370,7 @@ export class IntegrationEngine extends BaseSingleton {
3540
4370
  // re-establish the possibly-cleared record map and SKIP the write — leaving __mj_UpdatedAt
3541
4371
  // and the integration LastSynced columns untouched, exactly like the content-hash skip path.
3542
4372
  this.SetEntityFields(entity, record.MappedFields);
3543
- if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
4373
+ if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo, record)) {
3544
4374
  await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
3545
4375
  return 'skipped';
3546
4376
  }
@@ -3552,7 +4382,7 @@ export class IntegrationEngine extends BaseSingleton {
3552
4382
  this.SetStandardIntegrationFields(entity, record);
3553
4383
  // A5: Pre-write validation
3554
4384
  this.validateEntity(entity, record.MJEntityName);
3555
- const saved = await entity.Save();
4385
+ const saved = await entity.Save(this.syncSaveOptions);
3556
4386
  if (!saved) {
3557
4387
  const errMsg = entity.LatestResult?.CompleteMessage ?? 'unknown error';
3558
4388
  const schemaErr = detectSchemaNotGenerated(record.MJEntityName, errMsg);
@@ -3566,6 +4396,17 @@ export class IntegrationEngine extends BaseSingleton {
3566
4396
  // incremental sync. SaveRecordMap is an upsert keyed on (CompanyIntegration, Entity, ExternalID),
3567
4397
  // so this also re-establishes a map that was previously cleared.
3568
4398
  const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
4399
+ // The prefetch's absence proof is only true until this process inserts the row. A mid-batch
4400
+ // flush (MJ_INTEGRATION_BATCH_FLUSH_AT) COMMITS part of a batch; if a later record then fails,
4401
+ // the per-record fallback re-applies the whole batch against the SAME precheck — and the
4402
+ // committed rows' keys, honestly absent at prefetch time, would still "prove" absent and
4403
+ // insert again. Recording the key the moment we create keeps the proof truthful for any
4404
+ // replay in this run. Deliberately unconditional on commit outcome: if the group later rolls
4405
+ // back, an over-included key merely costs that record one existence load on retry — while an
4406
+ // under-included key costs a duplicate row. Only ever err toward the load.
4407
+ if (!existed && mappedPK != null && precheck) {
4408
+ precheck.Present.add(mappedPK);
4409
+ }
3569
4410
  await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
3570
4411
  return existed ? 'updated' : 'created';
3571
4412
  }
@@ -3593,15 +4434,34 @@ export class IntegrationEngine extends BaseSingleton {
3593
4434
  }
3594
4435
  return values.join('|');
3595
4436
  }
4437
+ /**
4438
+ * True ONLY when the batch prefetch PROVED this record's row does not exist: the prefetch covered
4439
+ * every record in the batch AND this key is missing from the rows it found. Anything less is
4440
+ * "unknown", and unknown must load — a wrong "absent" turns an update into a duplicate INSERT.
4441
+ *
4442
+ * `mappedPK` must be the '|'-joined key {@link extractMappedPrimaryKey} returns — the SAME shape
4443
+ * {@link PrefetchContentHashes} keys `Present` with (`pkNames.map(n => row[n] ?? '').join('|')`).
4444
+ * The first version of this check re-derived a key by indexing that string with PK field names,
4445
+ * which evaluates to `''` for every record — so "provably absent" was unconditionally true and
4446
+ * every upsert of an existing row became a blind duplicate INSERT. Kept as its own method so the
4447
+ * decision is testable against the real extractor's output rather than a re-implementation.
4448
+ */
4449
+ isProvablyAbsent(mappedPK, precheck) {
4450
+ return mappedPK != null
4451
+ && precheck?.CoversWholeBatch === true
4452
+ && !precheck.Present.has(mappedPK);
4453
+ }
3596
4454
  /**
3597
4455
  * Updates an existing MJ record with pre-write validation.
3598
4456
  * If the record cannot be loaded (e.g. it was deleted or never fully created),
3599
4457
  * falls back to CreateRecord (upsert behavior).
3600
4458
  */
3601
- async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps) {
4459
+ async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps,
4460
+ /** Forwarded to CreateRecord's keyless-key guard on the upsert fallback paths. */
4461
+ logger) {
3602
4462
  if (!record.MatchedMJRecordID) {
3603
4463
  // No matched ID — upsert by PK (insert; or update/skip if the PK already exists)
3604
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
4464
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
3605
4465
  if (outcome === 'updated')
3606
4466
  result.RecordsUpdated++;
3607
4467
  else if (outcome === 'skipped')
@@ -3616,7 +4476,7 @@ export class IntegrationEngine extends BaseSingleton {
3616
4476
  // unchanged — skip the per-record DB load AND the write. The dirty-flag check
3617
4477
  // below is the fallback for entities without the hash column.
3618
4478
  if (precheckHashes) {
3619
- const stored = precheckHashes.get(record.MatchedMJRecordID);
4479
+ const stored = precheckHashes.Hashes.get(record.MatchedMJRecordID);
3620
4480
  if (stored && stored === computeContentHash(record.MappedFields ?? {})) {
3621
4481
  result.RecordsSkipped++;
3622
4482
  // Re-establish the external↔MJ record map even on the content-hash skip. A record can
@@ -3644,12 +4504,13 @@ export class IntegrationEngine extends BaseSingleton {
3644
4504
  }
3645
4505
  const md = this.ProviderToUse;
3646
4506
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
4507
+ this.enrolInWriteGroup(entity);
3647
4508
  const entityInfo = md.EntityByName(record.MJEntityName);
3648
4509
  const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
3649
4510
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
3650
4511
  if (!loaded) {
3651
4512
  // Matched-ID row vanished — fall back to upsert by PK (insert; or update/skip if PK exists)
3652
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
4513
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
3653
4514
  if (outcome === 'updated')
3654
4515
  result.RecordsUpdated++;
3655
4516
  else if (outcome === 'skipped')
@@ -3671,7 +4532,7 @@ export class IntegrationEngine extends BaseSingleton {
3671
4532
  // Uses MJ's built-in dirty tracking (zero custom comparison logic). Critical for
3672
4533
  // connectors without server-side date filtering (e.g., YM) where every sync re-fetches
3673
4534
  // all records. Without this, 50k+ records get re-written every run.
3674
- if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
4535
+ if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo, record)) {
3675
4536
  result.RecordsSkipped++;
3676
4537
  // Re-establish the record map even when the write is skipped — see the content-hash skip
3677
4538
  // above for the full rationale (a key-field/PK match can land here with no map row, and
@@ -3688,7 +4549,7 @@ export class IntegrationEngine extends BaseSingleton {
3688
4549
  this.SetStandardIntegrationFields(entity, record);
3689
4550
  // A5: Pre-write validation
3690
4551
  this.validateEntity(entity, record.MJEntityName);
3691
- const saved = await entity.Save();
4552
+ const saved = await entity.Save(this.syncSaveOptions);
3692
4553
  if (!saved) {
3693
4554
  const errMsg = entity.LatestResult?.CompleteMessage ?? 'unknown error';
3694
4555
  const schemaErr = detectSchemaNotGenerated(record.MJEntityName, errMsg);
@@ -3715,19 +4576,39 @@ export class IntegrationEngine extends BaseSingleton {
3715
4576
  * - the read fails (best-effort — a logging/optimization read must never break a sync).
3716
4577
  */
3717
4578
  async PrefetchContentHashes(batch, contextUser) {
3718
- const ids = Array.from(new Set(batch.filter(r => r.ChangeType === 'Update' && r.MatchedMJRecordID)
3719
- .map(r => r.MatchedMJRecordID)));
3720
- if (ids.length === 0)
3721
- return undefined;
3722
4579
  const entityName = batch[0].MJEntityName;
3723
4580
  const entityInfo = this.ProviderToUse.EntityByName(entityName);
3724
4581
  if (!entityInfo)
3725
4582
  return undefined;
3726
- if (!entityInfo.Fields.some(f => f.Name === CONTENT_HASH_COLUMN))
4583
+ if (!entityInfo.Fields?.some(f => f.Name === CONTENT_HASH_COLUMN))
3727
4584
  return undefined;
3728
4585
  const pkFields = entityInfo.PrimaryKeys ?? [];
3729
4586
  if (pkFields.length === 0)
3730
4587
  return undefined;
4588
+ // Matched rows contribute the key the matcher already resolved. Unmatched rows contribute
4589
+ // the key their MAPPED FIELDS carry (soft-PK tables key on the external id), which is the
4590
+ // same key CreateRecord is about to probe for one at a time. Asking for all of them in the
4591
+ // one query we are already issuing is what lets that per-record probe be skipped.
4592
+ const wanted = new Set();
4593
+ let everyRecordCovered = true;
4594
+ for (const r of batch) {
4595
+ if (r.ChangeType === 'Update' && r.MatchedMJRecordID) {
4596
+ wanted.add(r.MatchedMJRecordID);
4597
+ continue;
4598
+ }
4599
+ const mappedPK = this.extractMappedPrimaryKey(r, pkFields);
4600
+ if (mappedPK == null) {
4601
+ // A destination-generated key (identity / server-assigned UUID) cannot be known
4602
+ // before the insert, so this record's existence is genuinely unknowable here.
4603
+ everyRecordCovered = false;
4604
+ continue;
4605
+ }
4606
+ // Already the '|'-joined key in pkFields order — add it as-is (see extractMappedPrimaryKey).
4607
+ wanted.add(mappedPK);
4608
+ }
4609
+ const ids = Array.from(wanted);
4610
+ if (ids.length === 0)
4611
+ return undefined;
3731
4612
  // Map keys must match `record.MatchedMJRecordID`, which is the PK value(s) joined by '|' in
3732
4613
  // PrimaryKeys order (single value for single-PK, "v1|v2" for composite — see MatchEngine).
3733
4614
  const pkNames = pkFields.map(f => f.Name);
@@ -3745,19 +4626,32 @@ export class IntegrationEngine extends BaseSingleton {
3745
4626
  Fields: [...pkNames, CONTENT_HASH_COLUMN],
3746
4627
  ExtraFilter: extraFilter,
3747
4628
  ResultType: 'simple',
4629
+ // A plain RunView is NOT unbounded — it falls back to the entity's UserViewMaxRows
4630
+ // (default 1000). This result is what `CoversWholeBatch` absence proofs are judged
4631
+ // against, and coverage is computed from the REQUEST side, never reconciled with
4632
+ // res.Results.length: a silently truncated response would mark every existing row
4633
+ // beyond the cap "provably absent" and re-INSERT it as a duplicate on every sync.
4634
+ // Today the apply batch (500) sits under the default cap, so nothing fires — but a
4635
+ // 2x margin defended by nothing is not a guard. Same reasoning as baseEngine's own
4636
+ // IgnoreMaxRows use, and this file documents the identical trap on the push side.
4637
+ IgnoreMaxRows: true,
3748
4638
  }, contextUser);
3749
4639
  if (!res.Success)
3750
4640
  return undefined;
3751
- const map = new Map();
4641
+ const Hashes = new Map();
4642
+ const Present = new Set();
3752
4643
  for (const row of res.Results) {
3753
4644
  // Re-key by the same '|'-join the matcher produced, so the lookup in ApplySingleRecord hits.
3754
4645
  const key = pkNames.map(n => row[n] ?? '').join('|');
4646
+ // Presence is recorded for EVERY returned row. A row whose hash is NULL still exists,
4647
+ // and treating "no hash" as "no row" would turn an update into a duplicate insert.
4648
+ Present.add(key);
3755
4649
  const hash = row[CONTENT_HASH_COLUMN];
3756
4650
  if (typeof hash === 'string' && hash.length > 0) {
3757
- map.set(key, hash);
4651
+ Hashes.set(key, hash);
3758
4652
  }
3759
4653
  }
3760
- return map;
4654
+ return { Hashes, Present, CoversWholeBatch: everyRecordCovered };
3761
4655
  }
3762
4656
  catch (err) {
3763
4657
  // MJ#3047 lesson: this best-effort catch was SILENT, so a failing prefetch (e.g. a reserved-word
@@ -3793,6 +4687,7 @@ export class IntegrationEngine extends BaseSingleton {
3793
4687
  return false;
3794
4688
  const md = this.ProviderToUse;
3795
4689
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
4690
+ this.enrolInWriteGroup(entity);
3796
4691
  const entityInfo = md.EntityByName(record.MJEntityName);
3797
4692
  const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
3798
4693
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
@@ -3816,14 +4711,14 @@ export class IntegrationEngine extends BaseSingleton {
3816
4711
  entity.Set('__mj_integration_IsTombstoned', true);
3817
4712
  if (hasField('__mj_integration_DeletedDetectedAt'))
3818
4713
  entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
3819
- const archived = await entity.Save();
4714
+ const archived = await entity.Save(this.syncSaveOptions);
3820
4715
  if (!archived) {
3821
4716
  const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
3822
4717
  console.warn(`[IntegrationEngine] Soft-delete (archive) failed for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
3823
4718
  }
3824
4719
  return archived;
3825
4720
  }
3826
- const deleted = await entity.Delete();
4721
+ const deleted = await entity.Delete(this.syncDeleteOptions);
3827
4722
  if (!deleted) {
3828
4723
  const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
3829
4724
  console.warn(`[IntegrationEngine] Delete blocked for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
@@ -3849,6 +4744,31 @@ export class IntegrationEngine extends BaseSingleton {
3849
4744
  }
3850
4745
  return key;
3851
4746
  }
4747
+ /**
4748
+ * Reports ACTIVE field maps whose MJ column does not exist, once per entity map per run.
4749
+ *
4750
+ * `BaseEntity.Set` no-ops on an unknown field — no throw, no log, no dirty flag — so a map
4751
+ * pointing at a column that was never applied (or was renamed) drops its value for every
4752
+ * record while the run reports those records as written. Checking it here costs one metadata
4753
+ * read and happens before the first fetch, so the warning arrives before the wasted work.
4754
+ */
4755
+ WarnOnUnbindableFieldMaps(entityMap, fieldMaps, logger) {
4756
+ const entityName = entityMap.Entity ?? '';
4757
+ // Diagnostics must never be able to fail a run: an unresolvable entity/provider is reported
4758
+ // by the paths that actually need it, and here it simply means there is nothing to check.
4759
+ let entityFieldNames = [];
4760
+ try {
4761
+ const entityInfo = entityName ? this.ProviderToUse?.EntityByName(entityName) : null;
4762
+ entityFieldNames = entityInfo?.Fields?.map(f => f.Name) ?? [];
4763
+ }
4764
+ catch {
4765
+ return;
4766
+ }
4767
+ const unbindable = FindUnbindableFieldMaps(fieldMaps, entityFieldNames);
4768
+ if (unbindable.length === 0)
4769
+ return;
4770
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FIELD_MAP_DESTINATION_MISSING', DescribeUnbindableFieldMaps(unbindable, entityMap.ExternalObjectName ?? entityMap.ID, entityName), { fieldMaps: unbindable });
4771
+ }
3852
4772
  /**
3853
4773
  * Sets fields on a BaseEntity instance from a field value map.
3854
4774
  */
@@ -4008,7 +4928,7 @@ export class IntegrationEngine extends BaseSingleton {
4008
4928
  * runtime-created target tables have no generated entity type, which is why the engine
4009
4929
  * already `.Set()`s the very same columns in SetStandardIntegrationFields.
4010
4930
  */
4011
- needsSyncStateRepair(entity, entityInfo) {
4931
+ needsSyncStateRepair(entity, entityInfo, record) {
4012
4932
  if (!entityInfo)
4013
4933
  return false;
4014
4934
  const has = (name) => entityInfo.Fields.some(f => f.Name === name);
@@ -4018,6 +4938,27 @@ export class IntegrationEngine extends BaseSingleton {
4018
4938
  return true;
4019
4939
  if (has('__mj_integration_SyncStatus') && entity.Get('__mj_integration_SyncStatus') !== 'Active')
4020
4940
  return true;
4941
+ // A STALE CONTENT HASH is repair-worthy for the same reason: skipping the write freezes it.
4942
+ //
4943
+ // The case that produces one is a source that stops sending a column. The mapper OMITS an
4944
+ // absent key rather than mapping it to null (a missing value is not a null value), so the
4945
+ // recomputed hash differs — but SetEntityFields never touches that column either, so the
4946
+ // entity is NOT dirty and the skip above fires. The stored hash is therefore never refreshed
4947
+ // and the mismatch is permanent: that row loses the content-hash fast path FOREVER, paying a
4948
+ // full load and field-by-field compare on every sync until some other field happens to
4949
+ // change. One repair write here re-converges it, and every later sync skips it cheaply.
4950
+ //
4951
+ // Deliberately NOT treated as "the column is gone" — absence in the data is not evidence of
4952
+ // absence in the schema (§ the same rule the field-level deactivation follows). The value is
4953
+ // left exactly as it is; only the hash is brought back in line with what we are actually
4954
+ // mapping.
4955
+ if (record && has(CONTENT_HASH_COLUMN)) {
4956
+ const storedHash = entity.Get(CONTENT_HASH_COLUMN);
4957
+ if (typeof storedHash === 'string' && storedHash.length > 0
4958
+ && storedHash !== computeContentHash(record.MappedFields ?? {})) {
4959
+ return true;
4960
+ }
4961
+ }
4021
4962
  return false;
4022
4963
  }
4023
4964
  /**
@@ -4209,12 +5150,19 @@ export class IntegrationEngine extends BaseSingleton {
4209
5150
  const existing = await rv.RunView({
4210
5151
  EntityName: 'MJ: Company Integration Record Maps',
4211
5152
  ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID=${quotedExternalID}`,
4212
- Fields: ['ID'],
5153
+ Fields: ['ID', 'EntityRecordID'],
4213
5154
  MaxRows: 1,
4214
5155
  ResultType: 'simple',
4215
5156
  BypassCache: true, // upsert-by-identity: a stale miss here re-creates a duplicate record map
4216
5157
  }, contextUser);
4217
5158
  if (existing.Success && existing.Results.length > 0) {
5159
+ // The row already says exactly this. On an incremental sync that is nearly every record
5160
+ // — mappings are stable — and the Load + Save this path used to do anyway were two more
5161
+ // round trips to change nothing, plus a meaningless __mj_UpdatedAt bump on every synced
5162
+ // record's map row. The batched writer (RecordMapBatch.flushChunk) has always skipped
5163
+ // this case; the per-record fallback now agrees with it.
5164
+ if (existing.Results[0].EntityRecordID === entityRecordID)
5165
+ return;
4218
5166
  const loaded = await recordMap.Load(existing.Results[0].ID);
4219
5167
  if (!loaded)
4220
5168
  recordMap.NewRecord();
@@ -4677,6 +5625,20 @@ export class IntegrationEngine extends BaseSingleton {
4677
5625
  GetIntegrationObjectFields(objectID) {
4678
5626
  return this.Base.GetIntegrationObjectFields(objectID);
4679
5627
  }
5628
+ /**
5629
+ * Source field names the connector declared as SyncDirective 'Exclude' for one
5630
+ * integration object, read from IntegrationObjectField.Configuration. Empty set
5631
+ * on any lookup miss (unknown object, no fields, no integration id) - a failed
5632
+ * lookup must never widen or narrow the sync beyond its declared behaviour.
5633
+ */
5634
+ ResolveExcludedSourceNames(integrationID, externalObjectName) {
5635
+ if (!integrationID || !externalObjectName)
5636
+ return new Set();
5637
+ const obj = this.GetIntegrationObject(integrationID, externalObjectName);
5638
+ if (!obj)
5639
+ return new Set();
5640
+ return ComputeExcludedSourceNames(this.GetIntegrationObjectFields(obj.ID));
5641
+ }
4680
5642
  GetActiveIntegrationObjects(integrationID) {
4681
5643
  return this.Base.GetActiveIntegrationObjects(integrationID);
4682
5644
  }