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

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 (50) hide show
  1. package/dist/BaseIntegrationConnector.d.ts +7 -0
  2. package/dist/BaseIntegrationConnector.d.ts.map +1 -1
  3. package/dist/BaseIntegrationConnector.js +33 -2
  4. package/dist/BaseIntegrationConnector.js.map +1 -1
  5. package/dist/BaseRESTIntegrationConnector.d.ts +48 -7
  6. package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
  7. package/dist/BaseRESTIntegrationConnector.js +160 -17
  8. package/dist/BaseRESTIntegrationConnector.js.map +1 -1
  9. package/dist/DeclaredSampleMerge.d.ts +74 -0
  10. package/dist/DeclaredSampleMerge.d.ts.map +1 -0
  11. package/dist/DeclaredSampleMerge.js +104 -0
  12. package/dist/DeclaredSampleMerge.js.map +1 -0
  13. package/dist/IntegrationConnectorCreationPipeline.d.ts +20 -0
  14. package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
  15. package/dist/IntegrationConnectorCreationPipeline.js +190 -30
  16. package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
  17. package/dist/IntegrationEngine.d.ts +173 -1
  18. package/dist/IntegrationEngine.d.ts.map +1 -1
  19. package/dist/IntegrationEngine.js +834 -152
  20. package/dist/IntegrationEngine.js.map +1 -1
  21. package/dist/IntegrationSchemaSync.d.ts +46 -2
  22. package/dist/IntegrationSchemaSync.d.ts.map +1 -1
  23. package/dist/IntegrationSchemaSync.js +83 -14
  24. package/dist/IntegrationSchemaSync.js.map +1 -1
  25. package/dist/MatchEngine.js +1 -1
  26. package/dist/MatchEngine.js.map +1 -1
  27. package/dist/StreamingDiscovery.d.ts +11 -0
  28. package/dist/StreamingDiscovery.d.ts.map +1 -1
  29. package/dist/StreamingDiscovery.js +13 -3
  30. package/dist/StreamingDiscovery.js.map +1 -1
  31. package/dist/SyncLogger.d.ts +2 -1
  32. package/dist/SyncLogger.d.ts.map +1 -1
  33. package/dist/SyncLogger.js +1 -0
  34. package/dist/SyncLogger.js.map +1 -1
  35. package/dist/WatermarkService.d.ts +7 -0
  36. package/dist/WatermarkService.d.ts.map +1 -1
  37. package/dist/WatermarkService.js +8 -0
  38. package/dist/WatermarkService.js.map +1 -1
  39. package/dist/WriteSerializer.d.ts +44 -0
  40. package/dist/WriteSerializer.d.ts.map +1 -0
  41. package/dist/WriteSerializer.js +70 -0
  42. package/dist/WriteSerializer.js.map +1 -0
  43. package/dist/index.d.ts +1 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/types.d.ts +25 -1
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/types.js.map +1 -1
  50. package/package.json +7 -7
@@ -1,5 +1,5 @@
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';
@@ -25,6 +25,7 @@ import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrenc
25
25
  import { mostRecentWinner } from './ConflictRecency.js';
26
26
  import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
27
27
  import { CollapseDuplicateIdentities } from './BatchIdentity.js';
28
+ import { WriteSerializer } from './WriteSerializer.js';
28
29
  import { ResumeConcurrency, RunResumesBounded } from './ResumeConcurrency.js';
29
30
  /** Default batch size for fetching records from external systems */
30
31
  const DEFAULT_BATCH_SIZE = 200;
@@ -94,6 +95,33 @@ export class SchemaNotGeneratedError extends Error {
94
95
  this.StoredProcedureName = storedProcedureName;
95
96
  }
96
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
+ }
97
125
  /**
98
126
  * §29 — base of the VALUE-FIT error family: a mapped value cannot fit/coerce into its destination
99
127
  * column's type. Per the bounded-typing policy (small columns; large content is an explicit text/json
@@ -149,6 +177,24 @@ const INTEGER_SQL_BOUNDS = {
149
177
  * functions are generated, so it must be classified too (else the run produces
150
178
  * per-record errors instead of one fail-fast SchemaNotGeneratedError).
151
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
+ }
152
198
  function detectSchemaNotGenerated(entityName, errorMessage) {
153
199
  const sqlServer = errorMessage.match(/Could not find stored procedure '([^']+)'/i);
154
200
  if (sqlServer)
@@ -176,6 +222,17 @@ function detectSchemaNotGenerated(entityName, errorMessage) {
176
222
  export function PositiveInt(v) {
177
223
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined;
178
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
+ }
179
236
  export class IntegrationEngine extends BaseSingleton {
180
237
  constructor() {
181
238
  super();
@@ -240,10 +297,133 @@ export class IntegrationEngine extends BaseSingleton {
240
297
  * calls without threading a parameter through ~20 private signatures.
241
298
  */
242
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
+ }
243
319
  /** The current run's context, when called from inside a sync run. */
244
320
  get currentRunContext() {
245
321
  return IntegrationEngine.runContext.getStore();
246
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
+ }
247
427
  /** Registers (or clears, with undefined) the post-sync custom-column promotion hook. */
248
428
  SetPostSyncSchemaPromotionCallback(callback) {
249
429
  this.postSyncSchemaPromotionCallback = callback;
@@ -296,19 +476,34 @@ export class IntegrationEngine extends BaseSingleton {
296
476
  * phase stays parallel (the real throughput win — it's network-bound). WeakMap so a
297
477
  * retired provider's chain entry is collectable.
298
478
  */
299
- static { this.writeChains = new WeakMap(); }
300
- runWriteExclusive(fn) {
479
+ static { this.writeSerializers = new WeakMap(); }
480
+ /** The write lock for this engine's provider, created on first use. */
481
+ writeSerializer() {
301
482
  const provider = this.ProviderToUse;
302
- let holder = IntegrationEngine.writeChains.get(provider);
303
- if (!holder) {
304
- holder = { chain: Promise.resolve() };
305
- IntegrationEngine.writeChains.set(provider, holder);
306
- }
307
- // Run fn after the prior write completes (whether it resolved or rejected); keep the chain
308
- // alive past failures so one errored batch never deadlocks subsequent writers.
309
- const run = holder.chain.then(() => fn(), () => fn());
310
- holder.chain = run.then(() => undefined, () => undefined);
311
- 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);
312
507
  }
313
508
  /**
314
509
  * Fence check at a batch boundary, BEFORE any write (PR 1 item 3). One SELECT of the run's
@@ -1299,6 +1494,13 @@ export class IntegrationEngine extends BaseSingleton {
1299
1494
  * Processes all entity maps, aggregating results with progress tracking.
1300
1495
  */
1301
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
+ }
1302
1504
  const aggregate = {
1303
1505
  Success: true,
1304
1506
  RecordsProcessed: 0,
@@ -1748,6 +1950,107 @@ export class IntegrationEngine extends BaseSingleton {
1748
1950
  });
1749
1951
  await Promise.all(workers);
1750
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
+ }
1751
2054
  /** Minimum ms between outbound requests for this integration (Integration.BatchRequestWaitTime; 0 = disabled). */
1752
2055
  getRequestSpacingMs(config) {
1753
2056
  try {
@@ -1795,6 +2098,8 @@ export class IntegrationEngine extends BaseSingleton {
1795
2098
  rateLimitBurst: PositiveInt(p.rateLimitBurst),
1796
2099
  discoveryTimeBudgetMs: PositiveInt(p.discoveryTimeBudgetMs),
1797
2100
  fetchTimeoutMs: PositiveInt(p.fetchTimeoutMs),
2101
+ // Ceiling on SIMULTANEOUS vendor fetches for this connection (see getFetchGate).
2102
+ fetchConcurrency: PositiveInt(p.fetchConcurrency),
1798
2103
  };
1799
2104
  }
1800
2105
  catch {
@@ -1832,6 +2137,17 @@ export class IntegrationEngine extends BaseSingleton {
1832
2137
  */
1833
2138
  reportRateOutcome(config, throttledErr) {
1834
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
+ }
1835
2151
  const rl = this._rateLimiters.get(key);
1836
2152
  if (!rl)
1837
2153
  return;
@@ -1874,11 +2190,27 @@ export class IntegrationEngine extends BaseSingleton {
1874
2190
  this.MergeResult(pullResult, pushResult);
1875
2191
  return pullResult;
1876
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
+ }
1877
2205
  /**
1878
2206
  * Pull sync: fetch from external → map → match → validate → apply to MJ.
1879
2207
  */
1880
2208
  async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
1881
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.
1882
2214
  const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
1883
2215
  // Field-level exclusions declared by the connector (SourceFieldInfo.SyncDirective
1884
2216
  // -> IntegrationObjectField.Configuration). Resolved once per map, applied to every
@@ -1890,7 +2222,7 @@ export class IntegrationEngine extends BaseSingleton {
1890
2222
  excludedFields: Array.from(excludedSourceNames).sort(),
1891
2223
  });
1892
2224
  }
1893
- const watermark = await this.runWriteExclusive(() => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
2225
+ const watermark = await this.runWriteForMap(entityMapID, () => this.watermarkService.Load(entityMapID, contextUser, 'Pull'));
1894
2226
  logger?.emit('sync.entity-map.start', {
1895
2227
  phase: 'pull-detail',
1896
2228
  externalObjectName: entityMap.ExternalObjectName,
@@ -2021,6 +2353,11 @@ export class IntegrationEngine extends BaseSingleton {
2021
2353
  const fetchTimeoutMs = PositiveInt(this.getConfigOverrides(config).fetchTimeoutMs)
2022
2354
  ?? PositiveInt(config.connector.FetchChangesTimeoutMs)
2023
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;
2024
2361
  while (hasMore) {
2025
2362
  if (abortSignal?.aborted) {
2026
2363
  console.log(`[IntegrationEngine] Sync cancelled for ${entityMap.ExternalObjectName} after ${recordsInMap} records — saving watermark`);
@@ -2063,69 +2400,20 @@ export class IntegrationEngine extends BaseSingleton {
2063
2400
  });
2064
2401
  let batch;
2065
2402
  const fetchStart = Date.now();
2066
- // ONE multiplicative decrease per throttle EPISODE, not one per rejected attempt.
2067
- //
2068
- // A 429 that survives three retries is three rejections but one congestion event — the
2069
- // same distinction TCP draws when it halves the window once per loss event rather than
2070
- // once per lost segment. Decreasing on each attempt compounds: at a 0.5 backoff factor
2071
- // three attempts take the rate to an eighth, five take it to a thirtieth, so a single
2072
- // throttled fetch could drive a connector to its floor purely as a function of how
2073
- // generous its retry budget is. The freeze already covers the interval the source asked
2074
- // for; the decrease is about the rate AFTER that, and one signal deserves one step.
2075
- let throttleReported = false;
2076
2403
  try {
2077
- await this.rateLimit(config);
2078
- // Resilient fetch: bound each attempt with a timeout (a hung vendor API must not
2079
- // hold the sync lock forever) and retry only transient errors (network/throttle/DB).
2080
- // A non-retryable error (auth, 4xx, parse) throws immediately as before.
2081
- batch = await WithRetry(() => WithTimeout(config.connector.FetchChanges(ctx), fetchTimeoutMs, `FetchChanges(${entityMap.ExternalObjectName})`), undefined,
2082
- // OUR OWN timeout is terminal for this page; a transport error is not.
2083
- //
2084
- // `WithTimeout` is a `Promise.race` with no cancellation, so the abandoned attempt
2085
- // keeps running. Retrying meant a second full page of vendor requests overlapping
2086
- // the first, then a third — up to 3x the load on a source that was already too slow
2087
- // to finish once, which is a good way to earn a real 429 (and THAT does cut
2088
- // concurrency). And the retry could not succeed on its merits anyway: the same work
2089
- // under the same budget exceeds it again.
2090
- //
2091
- // Deliberately `instanceof` rather than the classified code. `ClassifyError` folds
2092
- // `econnreset` in with timeouts under `NETWORK_TIMEOUT`, and a reset socket IS worth
2093
- // retrying — so excluding the whole code would lose real resilience. Only the error
2094
- // WithTimeout itself minted is excluded.
2095
- (err) => !(err instanceof OperationTimeoutError) && IsRetryableError(ClassifyError(err).Code), (attempt, err, delayMs) => {
2096
- // Report a throttle NOW, not after the retries are spent. ReportThrottle
2097
- // freezes the shared bucket for this CompanyIntegration, so every other
2098
- // object fetching concurrently backs off too — reporting it only in the
2099
- // catch below meant the rest of the connector kept hammering a source that
2100
- // had already said stop.
2101
- //
2102
- // Once per episode: see `throttleReported` above. Later attempts still get
2103
- // their own Retry-After honoured via DelayForError, which is what actually
2104
- // paces this loop; what they must not do is halve the rate again.
2105
- if (!throttleReported && ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED') {
2106
- throttleReported = true;
2107
- this.reportRateOutcome(config, err);
2108
- }
2109
- logger?.emit('sync.fetch.retry', {
2110
- externalObjectName: entityMap.ExternalObjectName,
2111
- batchIndex: batchCount,
2112
- attempt,
2113
- delayMs,
2114
- error: err instanceof Error ? err.message : String(err),
2115
- });
2116
- }, {
2117
- // Honour the source's own instruction. A 429 usually carries Retry-After;
2118
- // blind exponential backoff ignored it and retried early, which is how a
2119
- // soft throttle becomes a hard one. Falls back to backoff when the
2120
- // connector cannot parse one.
2121
- DelayForError: (err) => ClassifyError(err).Code === 'RATE_LIMIT_EXCEEDED'
2122
- ? config.connector.ExtractRetryAfterMs(err)
2123
- : undefined,
2124
- // A retry must pass through the same gate the first attempt did. The token
2125
- // was acquired once before WithRetry, so retries previously bypassed the
2126
- // limiter entirely — including the freeze the line above just applied.
2127
- BeforeRetry: () => this.rateLimit(config),
2128
- });
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
+ }
2129
2417
  this.reportRateOutcome(config); // clean fetch → ramp the adaptive rate back up
2130
2418
  fetchGapCount = 0; // clean fetch → reset the consecutive fetch-gap counter
2131
2419
  // §10: connector type-driven post-processing hook (default no-op) — enforce/normalize
@@ -2133,17 +2421,68 @@ export class IntegrationEngine extends BaseSingleton {
2133
2421
  if (batch.Records.length > 0) {
2134
2422
  batch.Records = batch.Records.map(r => config.connector.PostProcessRecord(r));
2135
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
+ }
2136
2447
  }
2137
2448
  catch (fetchErr) {
2138
2449
  const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
2139
2450
  // A throttle (429 / rate-limit) backs the adaptive limiter off (honoring Retry-After);
2140
2451
  // other errors don't touch the rate. §5 Gap 2: also flag the map result so the per-layer
2141
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
+ }
2142
2482
  if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED') {
2143
- // Only if the retry hook did not already do it a fetch that was retried has
2144
- // already had its one decrease applied, at the first sign rather than here.
2145
- if (!throttleReported)
2146
- this.reportRateOutcome(config, fetchErr);
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.
2147
2486
  result.Throttled = true;
2148
2487
  }
2149
2488
  console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
@@ -2169,7 +2508,7 @@ export class IntegrationEngine extends BaseSingleton {
2169
2508
  // value — a crash from here on resumes from the pre-run watermark and re-covers
2170
2509
  // the gap. Later checkpoints stop writing floors (gate above).
2171
2510
  if (watermarkFloorSaved !== null) {
2172
- await this.runWriteExclusive(() => this.watermarkService.RestoreValue(entityMapID, preRunWatermarkValue, contextUser));
2511
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.RestoreValue(entityMapID, preRunWatermarkValue, contextUser));
2173
2512
  watermarkFloorSaved = null;
2174
2513
  }
2175
2514
  logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'FETCH_PAGE_SKIPPED', `Persistent fetch error at ${currentOffset != null ? 'offset ' + currentOffset : 'page ' + currentPage} for ` +
@@ -2303,11 +2642,11 @@ export class IntegrationEngine extends BaseSingleton {
2303
2642
  // not begun"). Holding the same write-lock for the read keeps the connection single-owner.
2304
2643
  const resolved = partitionReconcile
2305
2644
  ? []
2306
- : await this.runWriteExclusive(() => this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser));
2645
+ : await this.runWriteForMap(entityMap.ID, () => this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser));
2307
2646
  const beforeApply = result.RecordsCreated + result.RecordsUpdated + result.RecordsSkipped + result.RecordsErrored;
2308
2647
  try {
2309
2648
  if (!partitionReconcile)
2310
- 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));
2311
2650
  }
2312
2651
  catch (applyErr) {
2313
2652
  if (applyErr instanceof SchemaNotGeneratedError) {
@@ -2343,7 +2682,7 @@ export class IntegrationEngine extends BaseSingleton {
2343
2682
  (batch.HasMore ? ` | more batches pending` : ` | batch complete`));
2344
2683
  // Update progress on the watermark record so the DB reflects live sync state
2345
2684
  if (batch.HasMore) {
2346
- await this.runWriteExclusive(() => this.watermarkService.UpdateProgress(entityMapID, afterApply, contextUser));
2685
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.UpdateProgress(entityMapID, afterApply, contextUser));
2347
2686
  }
2348
2687
  }
2349
2688
  recordsInMap += batch.Records.length;
@@ -2377,7 +2716,7 @@ export class IntegrationEngine extends BaseSingleton {
2377
2716
  // post-loop save below handles graceful early-exits precisely; this covers a SIGKILL
2378
2717
  // between graceful checkpoints, costing at most ~25 batches of re-fetch on resume.
2379
2718
  if (isKeysetConnector && currentAfterKey) {
2380
- await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
2719
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
2381
2720
  }
2382
2721
  // The WATERMARK twin of the keyset floor above. Without it, a watermark-based
2383
2722
  // connector had NO durable position at all until the run ended: a SIGKILL / OOM /
@@ -2395,7 +2734,7 @@ export class IntegrationEngine extends BaseSingleton {
2395
2734
  && currentWatermark && currentWatermark !== initialWatermark
2396
2735
  && currentWatermark !== watermarkFloorSaved) {
2397
2736
  const floor = currentWatermark;
2398
- await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, floor, contextUser, 'Pull'));
2737
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, floor, contextUser, 'Pull'));
2399
2738
  watermarkFloorSaved = floor;
2400
2739
  }
2401
2740
  }
@@ -2432,7 +2771,7 @@ export class IntegrationEngine extends BaseSingleton {
2432
2771
  // NOTE: a connector that ALSO returns a monotonic watermark (MonotonicWatermark=true) skips
2433
2772
  // this branch and falls through to SAVE that watermark below, so its next incremental NARROWS
2434
2773
  // (microtime > watermark) instead of re-scanning the whole object every run.
2435
- await this.runWriteExclusive(() => this.watermarkService.ClearKeysetPosition(entityMapID, contextUser));
2774
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.ClearKeysetPosition(entityMapID, contextUser));
2436
2775
  result.WatermarkAfter = null;
2437
2776
  }
2438
2777
  else if (fetchCompletedCleanly) {
@@ -2473,13 +2812,13 @@ export class IntegrationEngine extends BaseSingleton {
2473
2812
  else {
2474
2813
  finalWatermark = new Date().toISOString();
2475
2814
  }
2476
- await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull'));
2815
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull'));
2477
2816
  result.WatermarkAfter = finalWatermark;
2478
2817
  }
2479
2818
  else if (isKeysetConnector && currentAfterKey) {
2480
2819
  // The keyset scan stopped early (cancel / fetch error / safety limit). Persist the precise
2481
2820
  // last ordering key so the next run resumes the seek from here instead of restarting.
2482
- await this.runWriteExclusive(() => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
2821
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser));
2483
2822
  result.WatermarkAfter = currentAfterKey;
2484
2823
  }
2485
2824
  else if (!hadFetchGap && currentWatermark && currentWatermark !== initialWatermark) {
@@ -2499,7 +2838,7 @@ export class IntegrationEngine extends BaseSingleton {
2499
2838
  // deliberately NOT when hadFetchGap — a skipped page leaves a HOLE behind this watermark,
2500
2839
  // which is why that path holds it for a full re-fetch next run.
2501
2840
  const partialWatermark = currentWatermark;
2502
- await this.runWriteExclusive(() => this.watermarkService.Update(entityMapID, partialWatermark, contextUser, 'Pull'));
2841
+ await this.runWriteForMap(entityMapID, () => this.watermarkService.Update(entityMapID, partialWatermark, contextUser, 'Pull'));
2503
2842
  result.WatermarkAfter = partialWatermark;
2504
2843
  }
2505
2844
  // Orphan detection: delete/tombstone MJ records whose external counterpart no longer exists.
@@ -2750,13 +3089,19 @@ export class IntegrationEngine extends BaseSingleton {
2750
3089
  // Load current field values for each changed record
2751
3090
  const md = this.ProviderToUse;
2752
3091
  const entityInfo = md.EntityByName(entityMap.Entity);
2753
- const pkFieldName = entityInfo?.FirstPrimaryKey?.Name ?? 'ID';
3092
+ if (!entityInfo) {
3093
+ throw new Error(`Cannot push ${entityMap.Entity}: entity not found in metadata.`);
3094
+ }
3095
+ // The normalized RecordID is the '|'-joined value(s) of EVERY key column — the shape the record
3096
+ // map stores — so it is parsed against all of the entity's PrimaryKeys. Pinning it to the first
3097
+ // key column (or a made-up `ID`) handed a composite key to one column as "v1|v2" and failed the load.
3098
+ const pkFields = entityInfo.PrimaryKeys;
2754
3099
  for (const [recordID, change] of latestByRecord) {
2755
3100
  if (change.Type === 'Delete')
2756
3101
  continue; // No fields to load for deletes
2757
3102
  try {
2758
3103
  const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
2759
- const loaded = await entity.InnerLoad(new CompositeKey([{ FieldName: pkFieldName, Value: recordID }]));
3104
+ const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(recordID, pkFields));
2760
3105
  if (loaded) {
2761
3106
  change.Fields = entity.GetAll();
2762
3107
  }
@@ -2812,10 +3157,15 @@ export class IntegrationEngine extends BaseSingleton {
2812
3157
  }
2813
3158
  const md = this.ProviderToUse;
2814
3159
  const entityInfo = md.EntityByName(entityMap.Entity);
2815
- const pkFieldName = entityInfo?.FirstPrimaryKey?.Name ?? 'ID';
3160
+ if (!entityInfo) {
3161
+ throw new Error(`Cannot push ${entityMap.Entity}: entity not found in metadata.`);
3162
+ }
3163
+ const pkFields = entityInfo.PrimaryKeys;
2816
3164
  const now = new Date().toISOString();
2817
3165
  return allResult.Results.map(record => {
2818
- const recordID = String(record[pkFieldName] ?? '');
3166
+ // '|'-joined across EVERY key column — the shape EntityRecordID is stored in — so the
3167
+ // existingMaps lookup (Create vs Update) matches a composite key, not just its first column.
3168
+ const recordID = this.ComposeEntityRecordID(record, pkFields);
2819
3169
  return {
2820
3170
  RecordID: recordID,
2821
3171
  Type: existingMaps.has(recordID) ? 'Update' : 'Create',
@@ -3066,7 +3416,7 @@ export class IntegrationEngine extends BaseSingleton {
3066
3416
  const md = this.ProviderToUse;
3067
3417
  const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
3068
3418
  const entityInfo = md.EntityByName(entityMap.Entity);
3069
- const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
3419
+ const pkFields = entityInfo?.PrimaryKeys ?? [];
3070
3420
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(mjRecordID, pkFields));
3071
3421
  if (!loaded)
3072
3422
  return;
@@ -3079,7 +3429,7 @@ export class IntegrationEngine extends BaseSingleton {
3079
3429
  }
3080
3430
  // Surface a failed conflict-mark: the engine thinks the row is quarantined, but without the
3081
3431
  // marker the operator has no signal. A silent failure here leaves the record in limbo.
3082
- const ok = await entity.Save();
3432
+ const ok = await entity.Save(this.syncSaveOptions);
3083
3433
  if (!ok) {
3084
3434
  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 });
3085
3435
  }
@@ -3131,7 +3481,7 @@ export class IntegrationEngine extends BaseSingleton {
3131
3481
  `A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
3132
3482
  const md = this.ProviderToUse;
3133
3483
  const entityInfo = md.EntityByName(entityMap.Entity);
3134
- const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
3484
+ const pkFields = entityInfo?.PrimaryKeys ?? [];
3135
3485
  for (const orphan of orphans) {
3136
3486
  try {
3137
3487
  const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
@@ -3160,7 +3510,7 @@ export class IntegrationEngine extends BaseSingleton {
3160
3510
  entity.Set('__mj_integration_IsTombstoned', true);
3161
3511
  if (hasField('__mj_integration_DeletedDetectedAt'))
3162
3512
  entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
3163
- const archived = await entity.Save();
3513
+ const archived = await entity.Save(this.syncSaveOptions);
3164
3514
  if (archived) {
3165
3515
  result.RecordsDeleted++;
3166
3516
  await this.DeleteRecordMapRow(orphan.ID, contextUser);
@@ -3172,7 +3522,7 @@ export class IntegrationEngine extends BaseSingleton {
3172
3522
  }
3173
3523
  continue;
3174
3524
  }
3175
- const deleted = await entity.Delete();
3525
+ const deleted = await entity.Delete(this.syncDeleteOptions);
3176
3526
  if (deleted) {
3177
3527
  result.RecordsDeleted++;
3178
3528
  await this.DeleteRecordMapRow(orphan.ID, contextUser);
@@ -3338,8 +3688,8 @@ export class IntegrationEngine extends BaseSingleton {
3338
3688
  // (~line 1644). matchEngine.Resolve reads existing MJ rows on the SHARED provider
3339
3689
  // connection, so when streams run in parallel (syncConcurrency>1) it must not interleave
3340
3690
  // with another stream's open write transaction (else "Transaction in progress" / dirty read).
3341
- const resolved = await this.runWriteExclusive(() => this.matchEngine.Resolve(recs, entityMap, fieldMaps, contextUser));
3342
- await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1);
3691
+ const resolved = await this.runWriteForMap(entityMap.ID, () => this.matchEngine.Resolve(recs, entityMap, fieldMaps, contextUser));
3692
+ await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger, this.getSyncConcurrency(config) <= 1, this.getSyncConcurrency(config));
3343
3693
  appliedRecords += recs.length;
3344
3694
  }
3345
3695
  }
@@ -3377,7 +3727,14 @@ export class IntegrationEngine extends BaseSingleton {
3377
3727
  // lost batch atomicity is absorbed by the engine's idempotency (upsert-by-identity + content
3378
3728
  // hash) and the safe-floor watermark (advances only on a clean batch). Default true = the
3379
3729
  // proven atomic serial path, unchanged.
3380
- useTransaction = true) {
3730
+ useTransaction = true,
3731
+ /**
3732
+ * Requested apply concurrency. Only consulted on the transaction-free path (the one
3733
+ * `useTransaction: false` selects), where records auto-commit independently and can
3734
+ * therefore overlap. Defaults to 1, so a caller that does not pass it keeps the serial
3735
+ * behaviour exactly.
3736
+ */
3737
+ concurrency = 1) {
3381
3738
  // Batched application with per-record failure isolation (the "grace gap" fix).
3382
3739
  // Happy path: each batch of up to APPLY_BATCH_SIZE records commits as a single
3383
3740
  // transaction — small enough to avoid SQL Server lock escalation (~5000 rows) while
@@ -3404,27 +3761,174 @@ export class IntegrationEngine extends BaseSingleton {
3404
3761
  // otherwise load one-by-one. For a watermark-less re-sync where nothing changed,
3405
3762
  // this lets UpdateRecord skip every per-record load. Best-effort: undefined → the
3406
3763
  // existing dirty-flag path runs unchanged.
3407
- // Serialize the per-batch DB-write across concurrently-synced streams (shared provider
3408
- // connection ⇒ one transaction at a time). Fetch already happened in parallel upstream;
3409
- // only this write section is mutually exclusive. A throw inside (e.g. SchemaNotGenerated)
3410
- // propagates out to fail-stop this entity map, exactly as before.
3411
- await this.runWriteExclusive(async () => {
3764
+ // WHAT ACTUALLY NEEDS THE MUTEX.
3765
+ //
3766
+ // The shared provider connection holds one transaction at a time, so the section that
3767
+ // OWNS a transaction must be mutually exclusive. `BeginTransaction` + per-record
3768
+ // `Save()` owns one for the whole batch, and stays serialized exactly as before.
3769
+ //
3770
+ // A batched batch does not. A `TransactionGroup` is an in-memory list until `Submit()`:
3771
+ // enrolling an entity validates, checks row scope and renders the CRUD procedure call,
3772
+ // then parks it — no statement travels, no transaction is open. Only `Submit` touches
3773
+ // the connection. Holding the mutex across the whole apply block was therefore
3774
+ // serializing work that never needed it, and that is what made batching and concurrency
3775
+ // mutually exclusive: maps could not overlap on the part where the time actually goes
3776
+ // (fetch, paging, transform, enrolment) because they were queued behind each other's
3777
+ // writes.
3778
+ //
3779
+ // So the batched path takes the mutex only around the writes themselves. One
3780
+ // transaction is still in flight at a time — the invariant is unchanged — but maps
3781
+ // overlap everywhere else, and each keeps its OWN group, so a poison record fails the
3782
+ // map that owns it instead of every map that happened to be batching alongside it.
3783
+ // Deliberately NOT gated on `useTransaction`. That gate is
3784
+ // `getSyncConcurrency(config) <= 1`, so keeping it here would mean batching only ever
3785
+ // happened at concurrency 1 — the exact tradeoff this change exists to remove.
3786
+ // Batching is a property of how the writes TRAVEL; concurrency is a property of how
3787
+ // many maps fetch at once. They are independent.
3788
+ // Batching is a property of the ENTITY MAP, not only of the connection: a map whose
3789
+ // target's whole identity is server-assigned can never enrol a record, so batching it
3790
+ // would produce an empty group and a non-atomic batch reporting success. See
3791
+ // entityMapHasIdentityOnlyPK.
3792
+ const batchedWrites = this.ReadWriteMode(companyIntegration) === 'batched'
3793
+ && !this.entityMapHasIdentityOnlyPK(entityMap);
3794
+ // NEVER nest `runWriteExclusive`: the inner call waits on a chain that already contains
3795
+ // the outer one, which deadlocks. Under the outer mutex the writes are already
3796
+ // serialized, so they run inline; without it they take the mutex individually.
3797
+ const serializeWrite = batchedWrites
3798
+ ? (fn) => this.runWriteForMap(entityMap.ID, fn)
3799
+ : (fn) => fn();
3800
+ const applyOneBatch = async () => {
3412
3801
  const precheckHashes = await this.PrefetchContentHashes(batch, contextUser);
3413
3802
  // PKs of records the content-hash fast path skipped this batch — still present and
3414
3803
  // confirmed-unchanged on the source. Collected so we can refresh LastReconciledAt for
3415
3804
  // all of them in ONE set-based touch after the batch (instead of a frozen-forever stamp).
3416
3805
  let reconciledSkipIds = [];
3417
- if (useTransaction) {
3418
- await provider.BeginTransaction();
3419
- try {
3806
+ // Batched writes make a batch atomic by construction — the group is one
3807
+ // transaction — so this branch is entered for them regardless of concurrency. At
3808
+ // concurrency > 1 the atomicity is per entity map, and a group failure still
3809
+ // degrades to the record-by-record retry below.
3810
+ if (useTransaction || batchedWrites) {
3811
+ // Two ways to make this batch atomic, and they differ ONLY in how the writes
3812
+ // travel. `BeginTransaction` + per-record `Save()` sends one statement per
3813
+ // record; a TransactionGroup defers each `Save()` to `Submit()`, which sends
3814
+ // them together. Everything `Save()` does either way — validation, row scope,
3815
+ // the generated CRUD procedure, Record Changes, `OnAfterSaveExecute` — is
3816
+ // identical, because the group defers the WRITE and nothing else.
3817
+ //
3818
+ // Opt-in per connection, and it fails closed: an absent, unparseable or
3819
+ // unrecognised `writeMode` keeps the proven path, so the default never changes
3820
+ // underneath an existing tenant.
3821
+ const writeGroup = batchedWrites ? await provider.CreateTransactionGroup() : null;
3822
+ if (writeGroup) {
3823
+ // The batching itself (MJ#4087). Without this the group is atomic but still
3824
+ // SERIAL — one round trip per item inside the transaction, which is exactly
3825
+ // today's behaviour and why this PR was safe to land before the providers
3826
+ // could honour it. With it, the whole group travels as one statement.
3827
+ //
3828
+ // Set here rather than at construction because it is a property of how THIS
3829
+ // engine wants its batch submitted, not of the provider: any other caller
3830
+ // creating a group still gets the sequential default.
3831
+ writeGroup.BatchedSubmit = true;
3832
+ }
3833
+ const runCtx = this.currentRunContext;
3834
+ // A batched batch REQUIRES a run context: the group is handed to
3835
+ // ApplySingleRecord's frames through a nested scope of it, so without one
3836
+ // every record would silently save unenrolled and Submit would commit an
3837
+ // empty group — which returns true, so the batch would report success while
3838
+ // having quietly run the per-record path. Every sync path enters a run
3839
+ // context (RunSync and the adopted-run path both wrap their work in one), so
3840
+ // this cannot happen today; stating it makes that a requirement rather than
3841
+ // an accident, and turns a future regression into an error instead of a
3842
+ // silent loss of the feature.
3843
+ if (writeGroup && !runCtx) {
3844
+ throw new Error('batched writes were requested but no run context is active — the write group cannot reach the record ' +
3845
+ 'frames, and an unenrolled batch would report success while saving per-record. This is a wiring bug.');
3846
+ }
3847
+ if (!writeGroup)
3848
+ await provider.BeginTransaction();
3849
+ // EACH BATCH GETS ITS OWN GROUP, IN ITS OWN CONTEXT SCOPE.
3850
+ //
3851
+ // Assigning onto the shared run context would be a single slot: the moment two
3852
+ // maps overlap — which narrowing the mutex now allows — the second would
3853
+ // overwrite the first's group and enrol its records into the wrong batch.
3854
+ // Entering a nested AsyncLocalStorage scope instead gives every concurrent
3855
+ // batch its own `writeGroup`, inherited by the ApplySingleRecord frames below
3856
+ // that actually construct the entities, and torn down with the scope.
3857
+ //
3858
+ // Per-batch groups are also what keeps failures isolated: a poison record fails
3859
+ // the group its own map owns, and every other map in flight is untouched.
3860
+ // OPT-IN ceiling on how many writes may sit deferred in one group.
3861
+ //
3862
+ // A group holds every enrolled record's rendered SQL and parameters until Submit,
3863
+ // so peak memory for a batched apply is roughly (maps in flight x group size x row
3864
+ // size). With wide rows that is the largest allocation a sync makes, and a box that
3865
+ // has run out of heap has no way to trade a little throughput for headroom.
3866
+ //
3867
+ // Unset (the default) means NO mid-batch flush: the batch stays exactly one group
3868
+ // and one transaction, as today. Setting it below the batch size splits the batch
3869
+ // into several transactions, which is a real trade — an earlier flush stays
3870
+ // committed if a later one fails — and is why it is off unless asked for. The
3871
+ // per-record fallback that follows a failed batch is idempotent, so the split is
3872
+ // recoverable; it is simply no longer all-or-nothing.
3873
+ const flushAt = ReadFlushCeiling(process.env);
3874
+ const runBatch = async () => {
3875
+ let enrolledSinceFlush = 0;
3420
3876
  for (const record of batch) {
3421
3877
  result.RecordsProcessed++;
3422
3878
  await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
3879
+ if (flushAt === undefined)
3880
+ continue;
3881
+ if (++enrolledSinceFlush < flushAt)
3882
+ continue;
3883
+ enrolledSinceFlush = 0;
3884
+ // Mutating the context object is what makes the swap visible: every frame
3885
+ // below shares this same object through AsyncLocalStorage, so replacing the
3886
+ // group here is what the next record's enrolment sees.
3887
+ const ctx = this.currentRunContext;
3888
+ const full = ctx?.writeGroup;
3889
+ if (!full)
3890
+ continue;
3891
+ const submitted = await serializeWrite(() => full.Submit());
3892
+ if (!submitted)
3893
+ throw new Error('Batched write group did not commit');
3894
+ const fresh = await provider.CreateTransactionGroup();
3895
+ fresh.BatchedSubmit = true;
3896
+ ctx.writeGroup = fresh;
3897
+ }
3898
+ };
3899
+ try {
3900
+ if (writeGroup) {
3901
+ // Guarded above: a batched batch always has a run context to nest.
3902
+ await IntegrationEngine.runContext.run({ ...runCtx, writeGroup }, runBatch);
3903
+ }
3904
+ else {
3905
+ await runBatch();
3906
+ }
3907
+ if (writeGroup) {
3908
+ // The group holds every deferred write; Submit is where they land, in
3909
+ // one transaction. A false return means the group failed as a whole —
3910
+ // routed into the same catch, so the fallback below is reached by both
3911
+ // shapes rather than only by a throw.
3912
+ //
3913
+ // This is the ONLY part of a batched batch that touches the connection,
3914
+ // so it is the only part that takes the write mutex.
3915
+ const submitted = await serializeWrite(() => writeGroup.Submit());
3916
+ if (!submitted)
3917
+ throw new Error('Batched write group did not commit');
3918
+ }
3919
+ else {
3920
+ await provider.CommitTransaction();
3423
3921
  }
3424
- await provider.CommitTransaction();
3425
3922
  }
3426
3923
  catch (err) {
3427
- await provider.RollbackTransaction();
3924
+ // No shared slot to clear: the group lived in the batch's own context
3925
+ // scope, which has already unwound.
3926
+ //
3927
+ // A group that failed has already rolled itself back and there is no
3928
+ // provider-level transaction open to roll back — calling it would throw
3929
+ // over the real error.
3930
+ if (!batchedWrites)
3931
+ await provider.RollbackTransaction();
3428
3932
  // The batch transaction rolled back; the skip-IDs collected during the failed attempt
3429
3933
  // never committed. Reset and let the per-record retry re-collect only what commits.
3430
3934
  reconciledSkipIds = [];
@@ -3446,7 +3950,11 @@ export class IntegrationEngine extends BaseSingleton {
3446
3950
  }
3447
3951
  // Degrade to per-record application so the failure isolates to the poison
3448
3952
  // record(s) and every good record in this batch still commits.
3449
- await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
3953
+ // A batched batch runs concurrently with other entity maps, so the
3954
+ // per-record fallback must NOT open a provider transaction — that state
3955
+ // is global to the shared provider. Auto-commit instead; see the
3956
+ // `useProviderTransaction` doc on applyRecordsIndividually.
3957
+ await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps, !batchedWrites);
3450
3958
  }
3451
3959
  }
3452
3960
  else {
@@ -3456,7 +3964,23 @@ export class IntegrationEngine extends BaseSingleton {
3456
3964
  // connection. Per-record error isolation: a poison record is logged + counted; the
3457
3965
  // rest still commit; the idempotent re-sync + safe-floor watermark reconcile any
3458
3966
  // partial batch (the atomicity the transactional path provides is not needed here).
3459
- for (const record of batch) {
3967
+ // The records are independent and each auto-commits on its own pooled
3968
+ // connection, so this is the one place in the apply path where the requested
3969
+ // concurrency can actually be spent. Running them one at a time made
3970
+ // syncConcurrency a fetch-only setting: the caller opted into concurrency, paid
3971
+ // for it by giving up batch atomicity, and then still wrote serially.
3972
+ //
3973
+ // A fixed pool of workers pulling from a shared cursor, rather than
3974
+ // Promise.all over the batch: 500 simultaneous saves would swamp the connection
3975
+ // pool. The cap is the same knob the fetch side uses, clamped to a sane ceiling.
3976
+ const applyLimit = Math.max(1, Math.min(16, Math.floor(concurrency) || 1));
3977
+ let cursor = 0;
3978
+ // Set by whichever worker sees it; every worker stops at the next pull and the
3979
+ // error is rethrown after they settle. SchemaNotGeneratedError means the whole
3980
+ // map cannot proceed, so finishing the remaining records would be wasted work
3981
+ // against a table that does not exist.
3982
+ let fatal;
3983
+ const applyOne = async (record) => {
3460
3984
  result.RecordsProcessed++;
3461
3985
  try {
3462
3986
  // §10 — bounded inline retry for provably-transient save failures (auto-commit per
@@ -3470,7 +3994,8 @@ export class IntegrationEngine extends BaseSingleton {
3470
3994
  }
3471
3995
  catch (err) {
3472
3996
  if (err instanceof SchemaNotGeneratedError) {
3473
- throw err;
3997
+ fatal ??= err;
3998
+ return;
3474
3999
  }
3475
4000
  // §10 — permanent / retry-exhausted → dead-letter (count + log), move on; watermark advances regardless.
3476
4001
  result.RecordsErrored++;
@@ -3483,20 +4008,40 @@ export class IntegrationEngine extends BaseSingleton {
3483
4008
  Severity: classified.Severity,
3484
4009
  });
3485
4010
  }
3486
- }
4011
+ };
4012
+ // allSettled, not all: a worker must never reject, and the counters/dead-letter
4013
+ // list are only coherent once every worker has stopped touching them.
4014
+ await Promise.allSettled(Array.from({ length: Math.min(applyLimit, batch.length) }, async () => {
4015
+ for (;;) {
4016
+ if (fatal !== undefined)
4017
+ return;
4018
+ const next = cursor++;
4019
+ if (next >= batch.length)
4020
+ return;
4021
+ await applyOne(batch[next]);
4022
+ }
4023
+ }));
4024
+ if (fatal !== undefined)
4025
+ throw fatal;
3487
4026
  }
3488
4027
  // After the batch settles (committed, or per-record retried), refresh
3489
4028
  // LastReconciledAt for every content-hash-skipped row in ONE set-based touch.
3490
4029
  // Best-effort — a touch failure must never break the sync.
3491
4030
  if (reconciledSkipIds.length > 0) {
3492
- await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
4031
+ await serializeWrite(() => this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger));
3493
4032
  }
3494
4033
  // Write the batch's record maps set-based, now that the records they point at are
3495
4034
  // committed. Deliberately AFTER the transaction rather than inside it: the mapping
3496
4035
  // is derived data that the next sync can re-establish by primary key, and keeping
3497
4036
  // it out of the write transaction keeps that transaction as short as possible.
3498
- await this.FlushRecordMaps(recordMaps, entityMap, logger);
3499
- });
4037
+ await serializeWrite(() => this.FlushRecordMaps(recordMaps, entityMap, logger));
4038
+ };
4039
+ // Batched: overlap freely, serializing only the writes above. Otherwise: the whole
4040
+ // block stays under the mutex, because it owns a provider transaction throughout.
4041
+ if (batchedWrites)
4042
+ await applyOneBatch();
4043
+ else
4044
+ await this.runWriteExclusive(applyOneBatch);
3500
4045
  }
3501
4046
  }
3502
4047
  /**
@@ -3579,17 +4124,45 @@ export class IntegrationEngine extends BaseSingleton {
3579
4124
  *
3580
4125
  * Begin/Commit/Rollback are always matched per record (no leaked open transaction).
3581
4126
  */
3582
- async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps) {
4127
+ async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps,
4128
+ // When false, each record is applied WITHOUT a provider transaction (auto-commit).
4129
+ //
4130
+ // The provider holds its transaction state — `_transactionDepth`, the active
4131
+ // `Transaction`, the savepoint counter — as SINGLE FIELDS on the one shared provider
4132
+ // instance. `BeginTransaction` from two concurrently-syncing entity maps therefore
4133
+ // races that counter: the second caller sees depth 2, issues `SAVE TRANSACTION` against
4134
+ // a transaction the first caller may have already committed, and the leaked depth then
4135
+ // fails every subsequent query on the connection with "Transaction has not begun".
4136
+ //
4137
+ // That is safe ONLY while the engine owns the provider exclusively, i.e. sequential
4138
+ // sync (`useTransaction === true`). The batched path is not sequential, and it does not
4139
+ // need a transaction here either: `ApplySingleRecord` performs exactly ONE write
4140
+ // (Create | Update | Delete — record-maps are queued into `RecordMapBatch` and flushed
4141
+ // set-based later), so there is nothing for a transaction to make atomic. A single
4142
+ // statement either commits or it does not, and `WithRetry`'s next attempt starts clean
4143
+ // without a rollback of a transaction that never held anything.
4144
+ //
4145
+ // This mirrors exactly what the concurrent non-batched path already does: it applies
4146
+ // records through the SAME `ApplySingleRecord` with no transaction at all, and has run
4147
+ // at concurrency > 1 in production.
4148
+ useProviderTransaction = true) {
3583
4149
  const provider = this.ProviderToUse;
3584
4150
  for (const record of batch) {
3585
4151
  result.RecordsProcessed++;
3586
4152
  try {
3587
- // §10 — apply in its own transaction, with bounded inline retry for PROVABLY-TRANSIENT
3588
- // save failures (NETWORK_TIMEOUT / RATE_LIMIT_EXCEEDED / DATABASE_ERROR per IsRetryableError).
3589
- // Each attempt rolls back on throw so the next starts clean; a deadlock/momentary timeout
3590
- // self-heals here. A PERMANENT error (validation/FK/duplicate/config) is NOT retried — it
3591
- // throws straight out to the dead-letter path below.
4153
+ // §10 — bounded inline retry for PROVABLY-TRANSIENT save failures
4154
+ // (NETWORK_TIMEOUT / RATE_LIMIT_EXCEEDED / DATABASE_ERROR per IsRetryableError).
4155
+ // A PERMANENT error (validation/FK/duplicate/config) is NOT retried it throws
4156
+ // straight out to the dead-letter path below.
3592
4157
  await WithRetry(async () => {
4158
+ if (!useProviderTransaction) {
4159
+ // Auto-commit: never touches shared provider transaction state, so
4160
+ // concurrent entity maps cannot corrupt each other. See the parameter doc.
4161
+ await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
4162
+ return;
4163
+ }
4164
+ // Sequential path, unchanged: apply in its own transaction so a deadlock or
4165
+ // momentary timeout rolls back and the next attempt starts clean.
3593
4166
  await provider.BeginTransaction();
3594
4167
  try {
3595
4168
  await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
@@ -3663,7 +4236,7 @@ export class IntegrationEngine extends BaseSingleton {
3663
4236
  try {
3664
4237
  switch (record.ChangeType) {
3665
4238
  case 'Create': {
3666
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
4239
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
3667
4240
  if (outcome === 'updated')
3668
4241
  result.RecordsUpdated++;
3669
4242
  else if (outcome === 'skipped')
@@ -3735,11 +4308,14 @@ export class IntegrationEngine extends BaseSingleton {
3735
4308
  */
3736
4309
  async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps,
3737
4310
  /** Optional — lets the keyless-key guard below surface on the run's event stream. */
3738
- keylessLogger) {
4311
+ keylessLogger,
4312
+ /** What the batch already proved about its destination rows, if anything. */
4313
+ precheck) {
3739
4314
  const md = this.ProviderToUse;
3740
4315
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
4316
+ this.enrolInWriteGroup(entity);
3741
4317
  const entityInfo = md.EntityByName(record.MJEntityName);
3742
- const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
4318
+ const pkFields = entityInfo?.PrimaryKeys ?? [];
3743
4319
  // Upsert-safe: if the record's mapped fields carry a PK (soft-PK dest tables key on the external
3744
4320
  // ID), check whether that row already exists before deciding INSERT vs UPDATE. A null mappedPK
3745
4321
  // (e.g. a server-assigned UUID PK not present in the mapped fields) means a genuinely new row.
@@ -3769,7 +4345,16 @@ export class IntegrationEngine extends BaseSingleton {
3769
4345
  console.error(`[IntegrationEngine] KEYLESS_RECORD_REFUSED: ${detail}`);
3770
4346
  return 'skipped';
3771
4347
  }
3772
- const existed = mappedPK != null
4348
+ // The batch's prefetch may already have proved this row absent. When it did, the load below
4349
+ // is a SELECT * — every column including any NVARCHAR(MAX) — issued once per record, to
4350
+ // learn something the batch established in a single query. Skipping it is the whole point
4351
+ // of widening that prefetch to the create path.
4352
+ //
4353
+ // Only ever used to skip work when absence is PROVEN: the prefetch must have covered every
4354
+ // record in the batch, and this key must be missing from it. Anything less falls through to
4355
+ // the load, because a wrong "absent" turns an update into a duplicate insert.
4356
+ const provablyAbsent = this.isProvablyAbsent(mappedPK, precheck);
4357
+ const existed = mappedPK != null && !provablyAbsent
3773
4358
  ? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
3774
4359
  : false;
3775
4360
  if (existed) {
@@ -3796,7 +4381,7 @@ export class IntegrationEngine extends BaseSingleton {
3796
4381
  // re-establish the possibly-cleared record map and SKIP the write — leaving __mj_UpdatedAt
3797
4382
  // and the integration LastSynced columns untouched, exactly like the content-hash skip path.
3798
4383
  this.SetEntityFields(entity, record.MappedFields);
3799
- if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
4384
+ if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo, record)) {
3800
4385
  await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
3801
4386
  return 'skipped';
3802
4387
  }
@@ -3808,7 +4393,7 @@ export class IntegrationEngine extends BaseSingleton {
3808
4393
  this.SetStandardIntegrationFields(entity, record);
3809
4394
  // A5: Pre-write validation
3810
4395
  this.validateEntity(entity, record.MJEntityName);
3811
- const saved = await entity.Save();
4396
+ const saved = await entity.Save(this.syncSaveOptions);
3812
4397
  if (!saved) {
3813
4398
  const errMsg = entity.LatestResult?.CompleteMessage ?? 'unknown error';
3814
4399
  const schemaErr = detectSchemaNotGenerated(record.MJEntityName, errMsg);
@@ -3822,6 +4407,17 @@ export class IntegrationEngine extends BaseSingleton {
3822
4407
  // incremental sync. SaveRecordMap is an upsert keyed on (CompanyIntegration, Entity, ExternalID),
3823
4408
  // so this also re-establishes a map that was previously cleared.
3824
4409
  const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
4410
+ // The prefetch's absence proof is only true until this process inserts the row. A mid-batch
4411
+ // flush (MJ_INTEGRATION_BATCH_FLUSH_AT) COMMITS part of a batch; if a later record then fails,
4412
+ // the per-record fallback re-applies the whole batch against the SAME precheck — and the
4413
+ // committed rows' keys, honestly absent at prefetch time, would still "prove" absent and
4414
+ // insert again. Recording the key the moment we create keeps the proof truthful for any
4415
+ // replay in this run. Deliberately unconditional on commit outcome: if the group later rolls
4416
+ // back, an over-included key merely costs that record one existence load on retry — while an
4417
+ // under-included key costs a duplicate row. Only ever err toward the load.
4418
+ if (!existed && mappedPK != null && precheck) {
4419
+ precheck.Present.add(mappedPK);
4420
+ }
3825
4421
  await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
3826
4422
  return existed ? 'updated' : 'created';
3827
4423
  }
@@ -3849,6 +4445,23 @@ export class IntegrationEngine extends BaseSingleton {
3849
4445
  }
3850
4446
  return values.join('|');
3851
4447
  }
4448
+ /**
4449
+ * True ONLY when the batch prefetch PROVED this record's row does not exist: the prefetch covered
4450
+ * every record in the batch AND this key is missing from the rows it found. Anything less is
4451
+ * "unknown", and unknown must load — a wrong "absent" turns an update into a duplicate INSERT.
4452
+ *
4453
+ * `mappedPK` must be the '|'-joined key {@link extractMappedPrimaryKey} returns — the SAME shape
4454
+ * {@link PrefetchContentHashes} keys `Present` with (`pkNames.map(n => row[n] ?? '').join('|')`).
4455
+ * The first version of this check re-derived a key by indexing that string with PK field names,
4456
+ * which evaluates to `''` for every record — so "provably absent" was unconditionally true and
4457
+ * every upsert of an existing row became a blind duplicate INSERT. Kept as its own method so the
4458
+ * decision is testable against the real extractor's output rather than a re-implementation.
4459
+ */
4460
+ isProvablyAbsent(mappedPK, precheck) {
4461
+ return mappedPK != null
4462
+ && precheck?.CoversWholeBatch === true
4463
+ && !precheck.Present.has(mappedPK);
4464
+ }
3852
4465
  /**
3853
4466
  * Updates an existing MJ record with pre-write validation.
3854
4467
  * If the record cannot be loaded (e.g. it was deleted or never fully created),
@@ -3859,7 +4472,7 @@ export class IntegrationEngine extends BaseSingleton {
3859
4472
  logger) {
3860
4473
  if (!record.MatchedMJRecordID) {
3861
4474
  // No matched ID — upsert by PK (insert; or update/skip if the PK already exists)
3862
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
4475
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
3863
4476
  if (outcome === 'updated')
3864
4477
  result.RecordsUpdated++;
3865
4478
  else if (outcome === 'skipped')
@@ -3874,7 +4487,7 @@ export class IntegrationEngine extends BaseSingleton {
3874
4487
  // unchanged — skip the per-record DB load AND the write. The dirty-flag check
3875
4488
  // below is the fallback for entities without the hash column.
3876
4489
  if (precheckHashes) {
3877
- const stored = precheckHashes.get(record.MatchedMJRecordID);
4490
+ const stored = precheckHashes.Hashes.get(record.MatchedMJRecordID);
3878
4491
  if (stored && stored === computeContentHash(record.MappedFields ?? {})) {
3879
4492
  result.RecordsSkipped++;
3880
4493
  // Re-establish the external↔MJ record map even on the content-hash skip. A record can
@@ -3902,12 +4515,13 @@ export class IntegrationEngine extends BaseSingleton {
3902
4515
  }
3903
4516
  const md = this.ProviderToUse;
3904
4517
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
4518
+ this.enrolInWriteGroup(entity);
3905
4519
  const entityInfo = md.EntityByName(record.MJEntityName);
3906
- const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
4520
+ const pkFields = entityInfo?.PrimaryKeys ?? [];
3907
4521
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
3908
4522
  if (!loaded) {
3909
4523
  // Matched-ID row vanished — fall back to upsert by PK (insert; or update/skip if PK exists)
3910
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger);
4524
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps, logger, precheckHashes);
3911
4525
  if (outcome === 'updated')
3912
4526
  result.RecordsUpdated++;
3913
4527
  else if (outcome === 'skipped')
@@ -3929,7 +4543,7 @@ export class IntegrationEngine extends BaseSingleton {
3929
4543
  // Uses MJ's built-in dirty tracking (zero custom comparison logic). Critical for
3930
4544
  // connectors without server-side date filtering (e.g., YM) where every sync re-fetches
3931
4545
  // all records. Without this, 50k+ records get re-written every run.
3932
- if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
4546
+ if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo, record)) {
3933
4547
  result.RecordsSkipped++;
3934
4548
  // Re-establish the record map even when the write is skipped — see the content-hash skip
3935
4549
  // above for the full rationale (a key-field/PK match can land here with no map row, and
@@ -3946,7 +4560,7 @@ export class IntegrationEngine extends BaseSingleton {
3946
4560
  this.SetStandardIntegrationFields(entity, record);
3947
4561
  // A5: Pre-write validation
3948
4562
  this.validateEntity(entity, record.MJEntityName);
3949
- const saved = await entity.Save();
4563
+ const saved = await entity.Save(this.syncSaveOptions);
3950
4564
  if (!saved) {
3951
4565
  const errMsg = entity.LatestResult?.CompleteMessage ?? 'unknown error';
3952
4566
  const schemaErr = detectSchemaNotGenerated(record.MJEntityName, errMsg);
@@ -3973,19 +4587,39 @@ export class IntegrationEngine extends BaseSingleton {
3973
4587
  * - the read fails (best-effort — a logging/optimization read must never break a sync).
3974
4588
  */
3975
4589
  async PrefetchContentHashes(batch, contextUser) {
3976
- const ids = Array.from(new Set(batch.filter(r => r.ChangeType === 'Update' && r.MatchedMJRecordID)
3977
- .map(r => r.MatchedMJRecordID)));
3978
- if (ids.length === 0)
3979
- return undefined;
3980
4590
  const entityName = batch[0].MJEntityName;
3981
4591
  const entityInfo = this.ProviderToUse.EntityByName(entityName);
3982
4592
  if (!entityInfo)
3983
4593
  return undefined;
3984
- if (!entityInfo.Fields.some(f => f.Name === CONTENT_HASH_COLUMN))
4594
+ if (!entityInfo.Fields?.some(f => f.Name === CONTENT_HASH_COLUMN))
3985
4595
  return undefined;
3986
4596
  const pkFields = entityInfo.PrimaryKeys ?? [];
3987
4597
  if (pkFields.length === 0)
3988
4598
  return undefined;
4599
+ // Matched rows contribute the key the matcher already resolved. Unmatched rows contribute
4600
+ // the key their MAPPED FIELDS carry (soft-PK tables key on the external id), which is the
4601
+ // same key CreateRecord is about to probe for one at a time. Asking for all of them in the
4602
+ // one query we are already issuing is what lets that per-record probe be skipped.
4603
+ const wanted = new Set();
4604
+ let everyRecordCovered = true;
4605
+ for (const r of batch) {
4606
+ if (r.ChangeType === 'Update' && r.MatchedMJRecordID) {
4607
+ wanted.add(r.MatchedMJRecordID);
4608
+ continue;
4609
+ }
4610
+ const mappedPK = this.extractMappedPrimaryKey(r, pkFields);
4611
+ if (mappedPK == null) {
4612
+ // A destination-generated key (identity / server-assigned UUID) cannot be known
4613
+ // before the insert, so this record's existence is genuinely unknowable here.
4614
+ everyRecordCovered = false;
4615
+ continue;
4616
+ }
4617
+ // Already the '|'-joined key in pkFields order — add it as-is (see extractMappedPrimaryKey).
4618
+ wanted.add(mappedPK);
4619
+ }
4620
+ const ids = Array.from(wanted);
4621
+ if (ids.length === 0)
4622
+ return undefined;
3989
4623
  // Map keys must match `record.MatchedMJRecordID`, which is the PK value(s) joined by '|' in
3990
4624
  // PrimaryKeys order (single value for single-PK, "v1|v2" for composite — see MatchEngine).
3991
4625
  const pkNames = pkFields.map(f => f.Name);
@@ -4003,19 +4637,32 @@ export class IntegrationEngine extends BaseSingleton {
4003
4637
  Fields: [...pkNames, CONTENT_HASH_COLUMN],
4004
4638
  ExtraFilter: extraFilter,
4005
4639
  ResultType: 'simple',
4640
+ // A plain RunView is NOT unbounded — it falls back to the entity's UserViewMaxRows
4641
+ // (default 1000). This result is what `CoversWholeBatch` absence proofs are judged
4642
+ // against, and coverage is computed from the REQUEST side, never reconciled with
4643
+ // res.Results.length: a silently truncated response would mark every existing row
4644
+ // beyond the cap "provably absent" and re-INSERT it as a duplicate on every sync.
4645
+ // Today the apply batch (500) sits under the default cap, so nothing fires — but a
4646
+ // 2x margin defended by nothing is not a guard. Same reasoning as baseEngine's own
4647
+ // IgnoreMaxRows use, and this file documents the identical trap on the push side.
4648
+ IgnoreMaxRows: true,
4006
4649
  }, contextUser);
4007
4650
  if (!res.Success)
4008
4651
  return undefined;
4009
- const map = new Map();
4652
+ const Hashes = new Map();
4653
+ const Present = new Set();
4010
4654
  for (const row of res.Results) {
4011
4655
  // Re-key by the same '|'-join the matcher produced, so the lookup in ApplySingleRecord hits.
4012
4656
  const key = pkNames.map(n => row[n] ?? '').join('|');
4657
+ // Presence is recorded for EVERY returned row. A row whose hash is NULL still exists,
4658
+ // and treating "no hash" as "no row" would turn an update into a duplicate insert.
4659
+ Present.add(key);
4013
4660
  const hash = row[CONTENT_HASH_COLUMN];
4014
4661
  if (typeof hash === 'string' && hash.length > 0) {
4015
- map.set(key, hash);
4662
+ Hashes.set(key, hash);
4016
4663
  }
4017
4664
  }
4018
- return map;
4665
+ return { Hashes, Present, CoversWholeBatch: everyRecordCovered };
4019
4666
  }
4020
4667
  catch (err) {
4021
4668
  // MJ#3047 lesson: this best-effort catch was SILENT, so a failing prefetch (e.g. a reserved-word
@@ -4051,8 +4698,9 @@ export class IntegrationEngine extends BaseSingleton {
4051
4698
  return false;
4052
4699
  const md = this.ProviderToUse;
4053
4700
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
4701
+ this.enrolInWriteGroup(entity);
4054
4702
  const entityInfo = md.EntityByName(record.MJEntityName);
4055
- const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
4703
+ const pkFields = entityInfo?.PrimaryKeys ?? [];
4056
4704
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
4057
4705
  if (!loaded) {
4058
4706
  console.log(`[IntegrationEngine] Skipping delete for ${record.MJEntityName} ${record.MatchedMJRecordID} — record not found in MJ DB (may have been deleted already)`);
@@ -4074,14 +4722,14 @@ export class IntegrationEngine extends BaseSingleton {
4074
4722
  entity.Set('__mj_integration_IsTombstoned', true);
4075
4723
  if (hasField('__mj_integration_DeletedDetectedAt'))
4076
4724
  entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
4077
- const archived = await entity.Save();
4725
+ const archived = await entity.Save(this.syncSaveOptions);
4078
4726
  if (!archived) {
4079
4727
  const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
4080
4728
  console.warn(`[IntegrationEngine] Soft-delete (archive) failed for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
4081
4729
  }
4082
4730
  return archived;
4083
4731
  }
4084
- const deleted = await entity.Delete();
4732
+ const deleted = await entity.Delete(this.syncDeleteOptions);
4085
4733
  if (!deleted) {
4086
4734
  const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
4087
4735
  console.warn(`[IntegrationEngine] Delete blocked for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
@@ -4096,8 +4744,13 @@ export class IntegrationEngine extends BaseSingleton {
4096
4744
  */
4097
4745
  BuildEntityPrimaryKey(recordID, pkFields) {
4098
4746
  const key = new CompositeKey();
4099
- if (pkFields.length <= 1) {
4100
- key.KeyValuePairs.push({ FieldName: pkFields[0]?.Name ?? 'ID', Value: recordID });
4747
+ if (pkFields.length === 0) {
4748
+ // Never invent an `ID` column: MJ keys can have any name, and a load against a made-up
4749
+ // field fails with "Primary key ID not found in entity ..." — surface the real cause instead.
4750
+ throw new Error(`Cannot build a primary key for record '${recordID}': the entity has no primary key fields in metadata.`);
4751
+ }
4752
+ if (pkFields.length === 1) {
4753
+ key.KeyValuePairs.push({ FieldName: pkFields[0].Name, Value: recordID });
4101
4754
  }
4102
4755
  else {
4103
4756
  const parts = recordID.split('|');
@@ -4107,6 +4760,14 @@ export class IntegrationEngine extends BaseSingleton {
4107
4760
  }
4108
4761
  return key;
4109
4762
  }
4763
+ /**
4764
+ * The exact inverse of {@link BuildEntityPrimaryKey}: the '|'-joined value(s) of a data row's
4765
+ * primary-key column(s), in PK-field order — the shape CompanyIntegrationRecordMap.EntityRecordID
4766
+ * and a normalized RecordChange.RecordID carry. A single-column key is just its value.
4767
+ */
4768
+ ComposeEntityRecordID(row, pkFields) {
4769
+ return pkFields.map(pk => serializeKeyValue(row[pk.Name])).join('|');
4770
+ }
4110
4771
  /**
4111
4772
  * Reports ACTIVE field maps whose MJ column does not exist, once per entity map per run.
4112
4773
  *
@@ -4291,7 +4952,7 @@ export class IntegrationEngine extends BaseSingleton {
4291
4952
  * runtime-created target tables have no generated entity type, which is why the engine
4292
4953
  * already `.Set()`s the very same columns in SetStandardIntegrationFields.
4293
4954
  */
4294
- needsSyncStateRepair(entity, entityInfo) {
4955
+ needsSyncStateRepair(entity, entityInfo, record) {
4295
4956
  if (!entityInfo)
4296
4957
  return false;
4297
4958
  const has = (name) => entityInfo.Fields.some(f => f.Name === name);
@@ -4301,6 +4962,27 @@ export class IntegrationEngine extends BaseSingleton {
4301
4962
  return true;
4302
4963
  if (has('__mj_integration_SyncStatus') && entity.Get('__mj_integration_SyncStatus') !== 'Active')
4303
4964
  return true;
4965
+ // A STALE CONTENT HASH is repair-worthy for the same reason: skipping the write freezes it.
4966
+ //
4967
+ // The case that produces one is a source that stops sending a column. The mapper OMITS an
4968
+ // absent key rather than mapping it to null (a missing value is not a null value), so the
4969
+ // recomputed hash differs — but SetEntityFields never touches that column either, so the
4970
+ // entity is NOT dirty and the skip above fires. The stored hash is therefore never refreshed
4971
+ // and the mismatch is permanent: that row loses the content-hash fast path FOREVER, paying a
4972
+ // full load and field-by-field compare on every sync until some other field happens to
4973
+ // change. One repair write here re-converges it, and every later sync skips it cheaply.
4974
+ //
4975
+ // Deliberately NOT treated as "the column is gone" — absence in the data is not evidence of
4976
+ // absence in the schema (§ the same rule the field-level deactivation follows). The value is
4977
+ // left exactly as it is; only the hash is brought back in line with what we are actually
4978
+ // mapping.
4979
+ if (record && has(CONTENT_HASH_COLUMN)) {
4980
+ const storedHash = entity.Get(CONTENT_HASH_COLUMN);
4981
+ if (typeof storedHash === 'string' && storedHash.length > 0
4982
+ && storedHash !== computeContentHash(record.MappedFields ?? {})) {
4983
+ return true;
4984
+ }
4985
+ }
4304
4986
  return false;
4305
4987
  }
4306
4988
  /**
@@ -4731,7 +5413,7 @@ export class IntegrationEngine extends BaseSingleton {
4731
5413
  const d = provider.Dialect;
4732
5414
  const runTable = `${d.QuoteIdentifier(runInfo.SchemaName)}.${d.QuoteIdentifier(runInfo.BaseTable)}`;
4733
5415
  const detailTable = `${d.QuoteIdentifier(detailInfo.SchemaName)}.${d.QuoteIdentifier(detailInfo.BaseTable)}`;
4734
- const runPk = d.QuoteIdentifier(runInfo.PrimaryKeys[0].Name);
5416
+ const runPk = d.QuoteIdentifier(runInfo.FirstPrimaryKey.Name); // first-pk-ok: runInfo is MJ: Company Integration Runs (core entity, single ID key), the FK target of Run Details.CompanyIntegrationRunID
4735
5417
  const ciCol = d.QuoteIdentifier('CompanyIntegrationID');
4736
5418
  const startedCol = d.QuoteIdentifier('StartedAt');
4737
5419
  const detailFk = d.QuoteIdentifier('CompanyIntegrationRunID');