@memberjunction/integration-engine 5.48.0 → 5.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/BaseIntegrationConnector.d.ts.map +1 -1
  2. package/dist/BaseIntegrationConnector.js +15 -2
  3. package/dist/BaseIntegrationConnector.js.map +1 -1
  4. package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
  5. package/dist/BaseRESTIntegrationConnector.js +16 -1
  6. package/dist/BaseRESTIntegrationConnector.js.map +1 -1
  7. package/dist/ContentHash.d.ts +21 -15
  8. package/dist/ContentHash.d.ts.map +1 -1
  9. package/dist/ContentHash.js +21 -15
  10. package/dist/ContentHash.js.map +1 -1
  11. package/dist/CustomColumnPromotion.d.ts +7 -0
  12. package/dist/CustomColumnPromotion.d.ts.map +1 -1
  13. package/dist/CustomColumnPromotion.js +17 -0
  14. package/dist/CustomColumnPromotion.js.map +1 -1
  15. package/dist/CustomOverflow.d.ts +18 -0
  16. package/dist/CustomOverflow.d.ts.map +1 -1
  17. package/dist/CustomOverflow.js +34 -0
  18. package/dist/CustomOverflow.js.map +1 -1
  19. package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
  20. package/dist/IntegrationConnectorCreationPipeline.js +18 -5
  21. package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
  22. package/dist/IntegrationEngine.d.ts +106 -1
  23. package/dist/IntegrationEngine.d.ts.map +1 -1
  24. package/dist/IntegrationEngine.js +390 -65
  25. package/dist/IntegrationEngine.js.map +1 -1
  26. package/dist/IntegrationSchemaSync.d.ts +42 -0
  27. package/dist/IntegrationSchemaSync.d.ts.map +1 -1
  28. package/dist/IntegrationSchemaSync.js +126 -28
  29. package/dist/IntegrationSchemaSync.js.map +1 -1
  30. package/dist/MatchEngine.d.ts +156 -14
  31. package/dist/MatchEngine.d.ts.map +1 -1
  32. package/dist/MatchEngine.js +438 -68
  33. package/dist/MatchEngine.js.map +1 -1
  34. package/dist/RecordMapBatch.d.ts +162 -0
  35. package/dist/RecordMapBatch.d.ts.map +1 -0
  36. package/dist/RecordMapBatch.js +283 -0
  37. package/dist/RecordMapBatch.js.map +1 -0
  38. package/dist/StreamingDiscovery.d.ts +7 -1
  39. package/dist/StreamingDiscovery.d.ts.map +1 -1
  40. package/dist/StreamingDiscovery.js +10 -3
  41. package/dist/StreamingDiscovery.js.map +1 -1
  42. package/dist/index.d.ts +4 -3
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +2 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/prefetchFilter.d.ts +18 -0
  47. package/dist/prefetchFilter.d.ts.map +1 -1
  48. package/dist/prefetchFilter.js +15 -10
  49. package/dist/prefetchFilter.js.map +1 -1
  50. package/dist/types.d.ts +58 -5
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js +18 -1
  53. package/dist/types.js.map +1 -1
  54. package/package.json +7 -7
@@ -1,4 +1,4 @@
1
- import { CompositeKey, LogStatusEx, Metadata, RunView } from '@memberjunction/core';
1
+ import { CompositeKey, DatabaseProviderBase, LogStatusEx, Metadata, RunView } from '@memberjunction/core';
2
2
  import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
3
3
  import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
4
4
  import { ClassifyError, IsRetryableError } from './types.js';
@@ -9,10 +9,11 @@ import { FieldMappingEngine } from './FieldMappingEngine.js';
9
9
  import { MatchEngine } from './MatchEngine.js';
10
10
  import { WatermarkService } from './WatermarkService.js';
11
11
  import { SyncLogger } from './SyncLogger.js';
12
- import { CONTENT_HASH_COLUMN, computeContentHashWithOverflow, contentHashBasis } from './ContentHash.js';
13
- import { buildContentHashPrefetchFilter } from './prefetchFilter.js';
12
+ import { CONTENT_HASH_COLUMN, computeContentHash } from './ContentHash.js';
13
+ import { RecordMapBatch } from './RecordMapBatch.js';
14
+ import { buildContentHashPrefetchFilter, quoteTextLiteral } from './prefetchFilter.js';
14
15
  import { serializeKeyValue } from './KeySerialization.js';
15
- import { CUSTOM_OVERFLOW_COLUMN, reconcileOverflowValue } from './CustomOverflow.js';
16
+ import { CUSTOM_OVERFLOW_COLUMN, reconcileOverflowValue, foldCustomKeyStats } from './CustomOverflow.js';
16
17
  import { partitionRecords, partitionRollupHash, diffPartitions, partitionKeyForIdentity } from './HashDiff.js';
17
18
  import { RateLimiter } from './RateLimiter.js';
18
19
  import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrency.js';
@@ -191,6 +192,30 @@ export class IntegrationEngine extends BaseSingleton {
191
192
  }
192
193
  return ceiling;
193
194
  }
195
+ /**
196
+ * Hard ceiling on the record-map keyset page size. Each page is one round trip that materializes
197
+ * this many rows in memory; past 50k the per-page cost stops amortizing and starts risking the
198
+ * response-size limits of the transport.
199
+ */
200
+ static { this.RECORD_MAP_PAGE_SIZE_CEILING = 50_000; }
201
+ /**
202
+ * Page size for the keyset walk over MJ: Company Integration Record Maps (see LoadAllRecordMaps).
203
+ * Read from MJ_INTEGRATION_RECORD_MAP_PAGE_SIZE at class-init and clamped to
204
+ * [1, RECORD_MAP_PAGE_SIZE_CEILING]; defaults to 10,000.
205
+ */
206
+ static { this.RecordMapPageSize = IntegrationEngine.computeRecordMapPageSize(); }
207
+ /** Reads + clamps the record-map page size from env; logs once when a configured value is clamped. */
208
+ static computeRecordMapPageSize() {
209
+ const ceiling = IntegrationEngine.RECORD_MAP_PAGE_SIZE_CEILING;
210
+ const raw = parseInt(process.env.MJ_INTEGRATION_RECORD_MAP_PAGE_SIZE ?? '', 10);
211
+ if (!Number.isFinite(raw) || raw <= 0)
212
+ return 10_000;
213
+ if (raw > ceiling) {
214
+ LogStatusEx({ message: `[IntegrationEngine] MJ_INTEGRATION_RECORD_MAP_PAGE_SIZE=${raw} exceeds the maximum ${ceiling} — clamped to ${ceiling}.` });
215
+ return ceiling;
216
+ }
217
+ return raw;
218
+ }
194
219
  /** Registers (or clears, with undefined) the post-sync custom-column promotion hook. */
195
220
  SetPostSyncSchemaPromotionCallback(callback) {
196
221
  this.postSyncSchemaPromotionCallback = callback;
@@ -201,6 +226,36 @@ export class IntegrationEngine extends BaseSingleton {
201
226
  }
202
227
  /** In-process lock map to prevent concurrent syncs for the same CompanyIntegration */
203
228
  static { this.activeSyncs = new Map(); }
229
+ /**
230
+ * Maintenance locks: while a metadata refresh / schema evolution / RSU pipeline is
231
+ * running for a CompanyIntegration, data syncs MUST NOT start ("locks of sync and scheduled
232
+ * sync must occur" — the refresh is rewriting the very metadata, field maps and DDL the sync
233
+ * would read). Held per-CI; RunSync refuses with a clear error while held, and the scheduled
234
+ * drivers skip with a logged reason. Keyed lowercase like activeSyncs.
235
+ */
236
+ static { this.maintenanceLocks = new Map(); }
237
+ /**
238
+ * Acquires the maintenance lock for a CompanyIntegration. Returns false (does NOT wait) when
239
+ * a data sync is currently running or another maintenance operation already holds the lock —
240
+ * the caller decides whether to wait for `GetSyncProgress` to clear or surface the conflict.
241
+ */
242
+ static AcquireMaintenanceLock(companyIntegrationID, reason) {
243
+ const key = companyIntegrationID.toLowerCase();
244
+ if (IntegrationEngine.activeSyncs.has(key))
245
+ return false; // a sync is mid-flight
246
+ if (IntegrationEngine.maintenanceLocks.has(key))
247
+ return false; // refresh already running
248
+ IntegrationEngine.maintenanceLocks.set(key, { Reason: reason, AcquiredAt: new Date() });
249
+ return true;
250
+ }
251
+ /** Releases the maintenance lock (idempotent — safe in a finally). */
252
+ static ReleaseMaintenanceLock(companyIntegrationID) {
253
+ IntegrationEngine.maintenanceLocks.delete(companyIntegrationID.toLowerCase());
254
+ }
255
+ /** Current maintenance lock for a CompanyIntegration, or undefined when none is held. */
256
+ static GetMaintenanceLock(companyIntegrationID) {
257
+ return IntegrationEngine.maintenanceLocks.get(companyIntegrationID.toLowerCase());
258
+ }
204
259
  runWriteExclusive(fn) {
205
260
  // Run fn after the prior write completes (whether it resolved or rejected); keep the chain
206
261
  // alive past failures so one errored batch never deadlocks subsequent writers.
@@ -231,6 +286,17 @@ export class IntegrationEngine extends BaseSingleton {
231
286
  static GetAllSyncProgress() {
232
287
  return new Map(IntegrationEngine._syncProgress);
233
288
  }
289
+ /**
290
+ * U3 — pure, MONOTONIC progress fold: applies one per-map progress event to the live
291
+ * snapshot as a high-water mark. Under syncConcurrency > 1 events arrive out of order,
292
+ * so counters only ever ratchet UP — a progress bar must never go backwards. Totals are
293
+ * assigned (they're authoritative per event); completed/processed take max().
294
+ */
295
+ static RatchetProgressSnapshot(entry, progress) {
296
+ entry.EntityMapsTotal = progress.TotalEntityMaps;
297
+ entry.EntityMapsCompleted = Math.max(entry.EntityMapsCompleted, progress.EntityMapIndex);
298
+ entry.RecordsProcessed = Math.max(entry.RecordsProcessed, progress.RecordsProcessedInCurrentMap);
299
+ }
234
300
  /**
235
301
  * Resumes any syncs that were orphaned by a process restart.
236
302
  * Finds all CompanyIntegrationRun records with Status='In Progress',
@@ -366,6 +432,20 @@ export class IntegrationEngine extends BaseSingleton {
366
432
  console.warn(`[IntegrationEngine] Sync already running for ${lockKey}, waiting...`);
367
433
  return existing;
368
434
  }
435
+ // A metadata refresh / schema evolution / RSU pipeline holds the maintenance
436
+ // lock — a sync starting mid-refresh would read half-rewritten metadata/field maps/DDL.
437
+ // Refuse loudly (no queueing: the refresh may restart the process; the caller/schedule
438
+ // simply retries after it completes).
439
+ const maintenance = IntegrationEngine.maintenanceLocks.get(lockKey);
440
+ if (maintenance) {
441
+ const message = `Sync refused: ${maintenance.Reason} is in progress for this connection (since ${maintenance.AcquiredAt.toISOString()}). Retry after it completes.`;
442
+ console.warn(`[IntegrationEngine] ${message}`);
443
+ return {
444
+ Success: false, ErrorMessage: message, RecordsProcessed: 0, RecordsCreated: 0,
445
+ RecordsUpdated: 0, RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0,
446
+ Errors: [], EntityMapResults: [], Duration: 0,
447
+ };
448
+ }
369
449
  // Initialize abort controller and progress tracking
370
450
  const abortController = new AbortController();
371
451
  IntegrationEngine._abortControllers.set(lockKey, abortController);
@@ -380,14 +460,14 @@ export class IntegrationEngine extends BaseSingleton {
380
460
  RecordsErrored: 0,
381
461
  TriggerType: triggerType,
382
462
  });
383
- // Wrap caller's onProgress with internal tracking
463
+ // Wrap caller's onProgress with internal tracking. U3 — MONOTONIC: with
464
+ // syncConcurrency > 1 the per-map events arrive out of order (map 3 can emit after
465
+ // map 7), so raw assignment made the progress bar go BACKWARDS. The snapshot is a
466
+ // high-water mark, so only ever ratchet the counters upward.
384
467
  const wrappedProgress = (progress) => {
385
468
  const entry = IntegrationEngine._syncProgress.get(lockKey);
386
- if (entry) {
387
- entry.EntityMapsTotal = progress.TotalEntityMaps;
388
- entry.EntityMapsCompleted = progress.EntityMapIndex;
389
- entry.RecordsProcessed = progress.RecordsProcessedInCurrentMap;
390
- }
469
+ if (entry)
470
+ IntegrationEngine.RatchetProgressSnapshot(entry, progress);
391
471
  if (onProgress)
392
472
  onProgress(progress);
393
473
  };
@@ -972,7 +1052,18 @@ export class IntegrationEngine extends BaseSingleton {
972
1052
  if (cfgRaw) {
973
1053
  try {
974
1054
  const cfg = JSON.parse(cfgRaw);
975
- for (const name of [cfg.parentObjectName, cfg.ReferencedType]) {
1055
+ const softParentNames = [cfg.parentObjectName, cfg.ReferencedType];
1056
+ // MULTI-LEVEL template-var children declare a per-var parent MAP
1057
+ // (Configuration.parentObjectNames = {"<var>":"<SiblingObject>"}) instead of the
1058
+ // single parentObjectName. Include those parents too, or a `/a/{x}/b/{y}/c` child
1059
+ // orders after only ONE of its parents — the sync DAG must gate it behind ALL of
1060
+ // them (parents-populated-before-child, the DAG ordering). Matches what the wizard's
1061
+ // DependsOn exposes, so UI hint and sync ordering agree. Additive edges only
1062
+ // (cycle-guarded downstream), so this can only make ordering MORE correct.
1063
+ if (cfg.parentObjectNames && typeof cfg.parentObjectNames === 'object' && !Array.isArray(cfg.parentObjectNames)) {
1064
+ softParentNames.push(...Object.values(cfg.parentObjectNames).filter((v) => typeof v === 'string'));
1065
+ }
1066
+ for (const name of softParentNames) {
976
1067
  const parent = name ? ioByName.get(name.toLowerCase()) : undefined;
977
1068
  if (parent && parent !== ioId && selectedIoIds.has(parent))
978
1069
  set.add(parent);
@@ -1390,6 +1481,11 @@ export class IntegrationEngine extends BaseSingleton {
1390
1481
  const fetchedExternalIDs = new Set(); // Track all IDs seen during this pull for orphan detection
1391
1482
  let orphanTrackingOverflowed = false; // set if the ID set exceeds ORPHAN_DETECTION_MAX_IDS → skip the sweep, don't OOM
1392
1483
  const accumulatedMapped = []; // partition-reconcile mode: collect mapped records, apply post-loop
1484
+ // Custom-key stats: in-memory aggregation of every UNMAPPED source key seen this
1485
+ // run, independent of whether the row is written or content-hash-skipped. Bounded memory:
1486
+ // one entry per distinct key + a capped value sample. See CustomKeyStat.
1487
+ const customKeyAgg = new Map();
1488
+ let customKeyTotalRecords = 0;
1393
1489
  while (hasMore) {
1394
1490
  if (abortSignal?.aborted) {
1395
1491
  console.log(`[IntegrationEngine] Sync cancelled for ${entityMap.ExternalObjectName} after ${recordsInMap} records — saving watermark`);
@@ -1538,6 +1634,11 @@ export class IntegrationEngine extends BaseSingleton {
1538
1634
  }
1539
1635
  }
1540
1636
  const mapped = this.fieldMappingEngine.Apply(batch.Records, fieldMaps, entityMap.Entity);
1637
+ // Custom-key stats: aggregate unmapped keys for EVERY mapped record here —
1638
+ // before any skip decision — so candidates + sizing stats exist even when the
1639
+ // content-hash fast path skips the row (the hash basis deliberately excludes them).
1640
+ foldCustomKeyStats(mapped.map(r => r.UnmappedFields), customKeyAgg);
1641
+ customKeyTotalRecords += mapped.length;
1541
1642
  // Partition (Merkle) reconcile defers match + apply: accumulate mapped records now; the
1542
1643
  // partition-diff + selective apply runs once after the full fetch (applyViaPartitionReconcile).
1543
1644
  if (partitionReconcile) {
@@ -1740,6 +1841,19 @@ export class IntegrationEngine extends BaseSingleton {
1740
1841
  `the result set is INCOMPLETE. The watermark was held, so the skipped window is re-fetched next run; ` +
1741
1842
  `records on the reachable pages were synced normally.`, { skipped: true });
1742
1843
  }
1844
+ // Surface the run's custom-key statistics (out-of-band candidates). Keyed by the
1845
+ // target MJ entity so the post-sync promotion callback can line them up with its scan.
1846
+ if (customKeyAgg.size > 0) {
1847
+ result.CustomKeyStats = {
1848
+ [entityMap.Entity]: [...customKeyAgg.entries()].map(([key, s]) => ({
1849
+ Key: key,
1850
+ Occurrences: s.occurrences,
1851
+ TotalRecords: customKeyTotalRecords,
1852
+ MaxLength: s.maxLength,
1853
+ SampleValues: s.samples,
1854
+ })).sort((a, b) => a.Key.localeCompare(b.Key)),
1855
+ };
1856
+ }
1743
1857
  await this.CreateRunDetail(run, entityMap, result, contextUser);
1744
1858
  return result;
1745
1859
  }
@@ -1931,19 +2045,21 @@ export class IntegrationEngine extends BaseSingleton {
1931
2045
  `use incremental push (set a push watermark) or raise MJ_INTEGRATION_FULL_PUSH_MAX_RECORDS. ` +
1932
2046
  `Refusing to partial-push (that would advance the watermark past unsent rows and drop data).`);
1933
2047
  }
1934
- // Load existing record maps to know which records already exist externally
1935
- const mapResult = await rv.RunView({
1936
- EntityName: 'MJ: Company Integration Record Maps',
1937
- ExtraFilter: `CompanyIntegrationID='${companyIntegration.ID}' AND EntityID='${entityMap.EntityID}'`,
1938
- Fields: ['EntityRecordID', 'ExternalSystemRecordID'],
1939
- ResultType: 'simple',
1940
- BypassCache: true, // sync decisions must reflect committed record-map state, not a stale cache
1941
- }, contextUser);
2048
+ // Load existing record maps to know which records already exist externally.
2049
+ // Paged: an unpaged read is silently capped at the entity's UserViewMaxRows (1000 by
2050
+ // default), and a truncated map here makes already-synced records look brand new — so
2051
+ // the push would re-CREATE them externally as duplicates.
2052
+ const allMaps = await this.LoadAllRecordMaps(companyIntegration.ID, entityMap.EntityID, contextUser);
2053
+ if (!allMaps.Complete) {
2054
+ // Refuse rather than push a partial picture: with an incomplete map, every unmapped
2055
+ // record reads as "not yet in the external system" and gets created a second time.
2056
+ throw new Error(`Cannot push ${entityMap.Entity}: failed to load the existing record map ` +
2057
+ `(${allMaps.Error ?? 'unknown error'}). Refusing to push — an incomplete map would ` +
2058
+ `re-create already-synced records as duplicates in the external system.`);
2059
+ }
1942
2060
  const existingMaps = new Map();
1943
- if (mapResult.Success) {
1944
- for (const m of mapResult.Results) {
1945
- existingMaps.set(m.EntityRecordID, m.ExternalSystemRecordID);
1946
- }
2061
+ for (const m of allMaps.Rows) {
2062
+ existingMaps.set(m.EntityRecordID, m.ExternalSystemRecordID);
1947
2063
  }
1948
2064
  const md = this.ProviderToUse;
1949
2065
  const entityInfo = md.EntityByName(entityMap.Entity);
@@ -2229,18 +2345,20 @@ export class IntegrationEngine extends BaseSingleton {
2229
2345
  * These records were deleted externally and should be removed from MJ.
2230
2346
  */
2231
2347
  async DeleteOrphanedRecords(companyIntegration, entityMap, fetchedExternalIDs, result, contextUser, logger) {
2232
- const rv = new RunView();
2233
- const mapResult = await rv.RunView({
2234
- EntityName: 'MJ: Company Integration Record Maps',
2235
- ExtraFilter: `CompanyIntegrationID='${companyIntegration.ID}' ` +
2236
- `AND EntityID='${entityMap.EntityID}'`,
2237
- Fields: ['EntityRecordID', 'ExternalSystemRecordID'],
2238
- ResultType: 'simple',
2239
- BypassCache: true, // orphan-sweep compares against committed record-map state
2240
- }, contextUser);
2241
- if (!mapResult.Success)
2348
+ // Paged: an unpaged read is silently capped at the entity's UserViewMaxRows (1000 by
2349
+ // default). A tenant with 5,000 orphans would clear 1,000 per run and the operator would
2350
+ // see a clean run every time — the truncation was invisible, which is the actual bug.
2351
+ const allMaps = await this.LoadAllRecordMaps(companyIntegration.ID, entityMap.EntityID, contextUser);
2352
+ if (!allMaps.Complete) {
2353
+ // Deleting against a partial map is the dangerous direction: rows we simply failed to
2354
+ // read are indistinguishable from rows the external system dropped. Skip the sweep and
2355
+ // say so, rather than archiving live records on incomplete evidence.
2356
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHAN_SWEEP_SKIPPED', `Delete-detection was skipped for ${entityMap.Entity}: the record map could not be read completely ` +
2357
+ `(${allMaps.Error ?? 'unknown error'}) after ${allMaps.Rows.length} row(s). No records were deleted. ` +
2358
+ `Deleting on a partial map would archive live records.`, { rowsRead: allMaps.Rows.length });
2242
2359
  return;
2243
- const orphans = mapResult.Results.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
2360
+ }
2361
+ const orphans = allMaps.Rows.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
2244
2362
  if (orphans.length === 0)
2245
2363
  return;
2246
2364
  console.log(`[IntegrationEngine] Orphan detection for ${entityMap.ExternalObjectName}: ${orphans.length} records in MJ not found in external system`);
@@ -2368,7 +2486,9 @@ export class IntegrationEngine extends BaseSingleton {
2368
2486
  const buckets = partitionRecords(mappedRecords, idOf, partitionOf);
2369
2487
  const newRollups = new Map();
2370
2488
  for (const [partition, recs] of buckets) {
2371
- newRollups.set(partition, partitionRollupHash(recs, r => contentHashBasis(r.MappedFields, r.UnmappedFields)));
2489
+ // Content-hash basis: MAPPED fields only — an unmapped/custom key must never move a
2490
+ // partition rollup (its capture + promotion is handled out-of-band via CustomKeyStats).
2491
+ newRollups.set(partition, partitionRollupHash(recs, r => r.MappedFields));
2372
2492
  }
2373
2493
  // Diff against last sync's snapshot; only changed/added partitions need a deep apply. On a FORCED
2374
2494
  // FULL SYNC, treat the snapshot as empty so EVERY partition is re-applied: fullSync is the operator's
@@ -2449,6 +2569,11 @@ export class IntegrationEngine extends BaseSingleton {
2449
2569
  // to 500 healthy records.
2450
2570
  const APPLY_BATCH_SIZE = 500;
2451
2571
  const provider = this.ProviderToUse;
2572
+ // One batched record-map writer for this entity map's whole apply pass. Every path that
2573
+ // used to spend three round trips per record establishing a mapping now queues into this
2574
+ // and the queue is flushed set-based once the batch has settled — the single largest cost
2575
+ // in a no-change sync.
2576
+ const recordMaps = new RecordMapBatch(this.ProviderToUse, companyIntegration.ID, contextUser, (ciID, extID, entID, recID, user) => this.SaveRecordMap(ciID, extID, entID, recID, user));
2452
2577
  for (let i = 0; i < records.length; i += APPLY_BATCH_SIZE) {
2453
2578
  const batch = records.slice(i, i + APPLY_BATCH_SIZE);
2454
2579
  const batchStartProcessed = result.RecordsProcessed;
@@ -2475,7 +2600,7 @@ export class IntegrationEngine extends BaseSingleton {
2475
2600
  try {
2476
2601
  for (const record of batch) {
2477
2602
  result.RecordsProcessed++;
2478
- await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2603
+ await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
2479
2604
  }
2480
2605
  await provider.CommitTransaction();
2481
2606
  }
@@ -2484,6 +2609,9 @@ export class IntegrationEngine extends BaseSingleton {
2484
2609
  // The batch transaction rolled back; the skip-IDs collected during the failed attempt
2485
2610
  // never committed. Reset and let the per-record retry re-collect only what commits.
2486
2611
  reconciledSkipIds = [];
2612
+ // Same reasoning for the queued mappings — the records they point at were rolled
2613
+ // back, so writing them would leave the map referencing rows that don't exist.
2614
+ recordMaps.Discard();
2487
2615
  // Roll back the in-memory counters that ApplySingleRecord bumped inside the failed batch
2488
2616
  result.RecordsProcessed = batchStartProcessed;
2489
2617
  result.RecordsCreated = batchStartCreated;
@@ -2499,7 +2627,7 @@ export class IntegrationEngine extends BaseSingleton {
2499
2627
  }
2500
2628
  // Degrade to per-record application so the failure isolates to the poison
2501
2629
  // record(s) and every good record in this batch still commits.
2502
- await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2630
+ await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
2503
2631
  }
2504
2632
  }
2505
2633
  else {
@@ -2514,7 +2642,7 @@ export class IntegrationEngine extends BaseSingleton {
2514
2642
  try {
2515
2643
  // §10 — bounded inline retry for provably-transient save failures (auto-commit per
2516
2644
  // record, so no transaction to manage); permanent errors throw straight to dead-letter.
2517
- await WithRetry(() => this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds), undefined, (e) => !(e instanceof SchemaNotGeneratedError) && IsRetryableError(ClassifyError(e).Code), (attempt, e, delayMs) => logger?.emit('sync.record.retry', {
2645
+ await WithRetry(() => this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps), undefined, (e) => !(e instanceof SchemaNotGeneratedError) && IsRetryableError(ClassifyError(e).Code), (attempt, e, delayMs) => logger?.emit('sync.record.retry', {
2518
2646
  phase: 'save',
2519
2647
  externalObjectName: entityMap.ExternalObjectName,
2520
2648
  externalId: record.ExternalRecord?.ExternalID ?? '',
@@ -2544,9 +2672,35 @@ export class IntegrationEngine extends BaseSingleton {
2544
2672
  if (reconciledSkipIds.length > 0) {
2545
2673
  await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
2546
2674
  }
2675
+ // Write the batch's record maps set-based, now that the records they point at are
2676
+ // committed. Deliberately AFTER the transaction rather than inside it: the mapping
2677
+ // is derived data that the next sync can re-establish by primary key, and keeping
2678
+ // it out of the write transaction keeps that transaction as short as possible.
2679
+ await this.FlushRecordMaps(recordMaps, entityMap, logger);
2547
2680
  });
2548
2681
  }
2549
2682
  }
2683
+ /**
2684
+ * Flushes the queued record maps and reports any that failed, per external ID.
2685
+ *
2686
+ * Failures here are warnings, not record errors: the record itself saved. A missing mapping
2687
+ * degrades orphan detection until the next sync re-establishes it by primary key, which is
2688
+ * worth telling the operator about but is not a reason to mark the record — or the run —
2689
+ * failed. Flushing itself never throws for the same reason.
2690
+ */
2691
+ async FlushRecordMaps(recordMaps, entityMap, logger) {
2692
+ try {
2693
+ await recordMaps.Flush();
2694
+ }
2695
+ catch (err) {
2696
+ const msg = err instanceof Error ? err.message : String(err);
2697
+ console.warn(`[IntegrationEngine] Record-map flush failed for ${entityMap.ExternalObjectName}: ${msg}`);
2698
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.Entity ?? entityMap.ID, 'RECORD_MAP_FLUSH_FAILED', msg, {});
2699
+ }
2700
+ for (const failure of recordMaps.TakeFailures()) {
2701
+ logger?.warning(entityMap.ExternalObjectName ?? entityMap.Entity ?? entityMap.ID, 'RECORD_MAP_WRITE_FAILED', `Record map not written for external ID ${failure.ExternalID}: ${failure.ErrorMessage}`, { externalId: failure.ExternalID, entityId: failure.EntityID });
2702
+ }
2703
+ }
2550
2704
  /**
2551
2705
  * Refreshes __mj_integration_LastReconciledAt = now for a set of records that the content-hash
2552
2706
  * fast path skipped (present + confirmed-unchanged on the source). Issues ONE set-based UPDATE
@@ -2606,7 +2760,7 @@ export class IntegrationEngine extends BaseSingleton {
2606
2760
  *
2607
2761
  * Begin/Commit/Rollback are always matched per record (no leaked open transaction).
2608
2762
  */
2609
- async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds) {
2763
+ async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps) {
2610
2764
  const provider = this.ProviderToUse;
2611
2765
  for (const record of batch) {
2612
2766
  result.RecordsProcessed++;
@@ -2619,7 +2773,7 @@ export class IntegrationEngine extends BaseSingleton {
2619
2773
  await WithRetry(async () => {
2620
2774
  await provider.BeginTransaction();
2621
2775
  try {
2622
- await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2776
+ await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
2623
2777
  await provider.CommitTransaction();
2624
2778
  }
2625
2779
  catch (e) {
@@ -2674,7 +2828,7 @@ export class IntegrationEngine extends BaseSingleton {
2674
2828
  /**
2675
2829
  * Applies a single record change (Create, Update, Delete, or Skip).
2676
2830
  */
2677
- async ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds) {
2831
+ async ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps) {
2678
2832
  logger?.emit('sync.record.decision', {
2679
2833
  externalId: record.ExternalRecord.ExternalID,
2680
2834
  objectType: record.ExternalRecord.ObjectType,
@@ -2690,7 +2844,7 @@ export class IntegrationEngine extends BaseSingleton {
2690
2844
  try {
2691
2845
  switch (record.ChangeType) {
2692
2846
  case 'Create': {
2693
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
2847
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
2694
2848
  if (outcome === 'updated')
2695
2849
  result.RecordsUpdated++;
2696
2850
  else if (outcome === 'skipped')
@@ -2700,7 +2854,7 @@ export class IntegrationEngine extends BaseSingleton {
2700
2854
  break;
2701
2855
  }
2702
2856
  case 'Update':
2703
- await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds);
2857
+ await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps);
2704
2858
  break;
2705
2859
  case 'Delete': {
2706
2860
  const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
@@ -2760,7 +2914,7 @@ export class IntegrationEngine extends BaseSingleton {
2760
2914
  *
2761
2915
  * @returns true if an existing row was updated, false if a new row was inserted (so the caller counts correctly).
2762
2916
  */
2763
- async CreateRecord(record, companyIntegration, entityMap, contextUser) {
2917
+ async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps) {
2764
2918
  const md = this.ProviderToUse;
2765
2919
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
2766
2920
  const entityInfo = md.EntityByName(record.MJEntityName);
@@ -2786,8 +2940,8 @@ export class IntegrationEngine extends BaseSingleton {
2786
2940
  if (hasHashColumn) {
2787
2941
  const storedHash = entity.Get(CONTENT_HASH_COLUMN);
2788
2942
  if (typeof storedHash === 'string' && storedHash.length > 0
2789
- && storedHash === computeContentHashWithOverflow(record.MappedFields ?? {}, record.UnmappedFields)) {
2790
- await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
2943
+ && storedHash === computeContentHash(record.MappedFields ?? {})) {
2944
+ await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
2791
2945
  return 'skipped';
2792
2946
  }
2793
2947
  }
@@ -2796,8 +2950,8 @@ export class IntegrationEngine extends BaseSingleton {
2796
2950
  // re-establish the possibly-cleared record map and SKIP the write — leaving __mj_UpdatedAt
2797
2951
  // and the integration LastSynced columns untouched, exactly like the content-hash skip path.
2798
2952
  this.SetEntityFields(entity, record.MappedFields);
2799
- if (!entity.Dirty) {
2800
- await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
2953
+ if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
2954
+ await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
2801
2955
  return 'skipped';
2802
2956
  }
2803
2957
  }
@@ -2822,7 +2976,7 @@ export class IntegrationEngine extends BaseSingleton {
2822
2976
  // incremental sync. SaveRecordMap is an upsert keyed on (CompanyIntegration, Entity, ExternalID),
2823
2977
  // so this also re-establishes a map that was previously cleared.
2824
2978
  const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
2825
- await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
2979
+ await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
2826
2980
  return existed ? 'updated' : 'created';
2827
2981
  }
2828
2982
  /**
@@ -2854,10 +3008,10 @@ export class IntegrationEngine extends BaseSingleton {
2854
3008
  * If the record cannot be loaded (e.g. it was deleted or never fully created),
2855
3009
  * falls back to CreateRecord (upsert behavior).
2856
3010
  */
2857
- async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds) {
3011
+ async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps) {
2858
3012
  if (!record.MatchedMJRecordID) {
2859
3013
  // No matched ID — upsert by PK (insert; or update/skip if the PK already exists)
2860
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
3014
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
2861
3015
  if (outcome === 'updated')
2862
3016
  result.RecordsUpdated++;
2863
3017
  else if (outcome === 'skipped')
@@ -2873,7 +3027,7 @@ export class IntegrationEngine extends BaseSingleton {
2873
3027
  // below is the fallback for entities without the hash column.
2874
3028
  if (precheckHashes) {
2875
3029
  const stored = precheckHashes.get(record.MatchedMJRecordID);
2876
- if (stored && stored === computeContentHashWithOverflow(record.MappedFields ?? {}, record.UnmappedFields)) {
3030
+ if (stored && stored === computeContentHash(record.MappedFields ?? {})) {
2877
3031
  result.RecordsSkipped++;
2878
3032
  // Re-establish the external↔MJ record map even on the content-hash skip. A record can
2879
3033
  // reach UpdateRecord matched by KEY FIELDS / PK (MatchEngine.FindByKeyFields queries the
@@ -2886,7 +3040,7 @@ export class IntegrationEngine extends BaseSingleton {
2886
3040
  // (CompanyIntegration, Entity, ExternalID) — idempotent for already-mapped records, and the
2887
3041
  // CreateRecord skip branches already do exactly this. MatchedMJRecordID IS the dest PK
2888
3042
  // (PrimaryKeys order, '|'-joined), which is the EntityRecordID the map stores.
2889
- await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, record.MatchedMJRecordID, contextUser);
3043
+ await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, record.MatchedMJRecordID, contextUser);
2890
3044
  // The record IS still present and confirmed-unchanged on the source — but skipping
2891
3045
  // the write here means SetStandardIntegrationFields never runs, so __mj_integration_
2892
3046
  // LastReconciledAt would freeze at first-sync time. Record the PK so the batch can
@@ -2905,7 +3059,7 @@ export class IntegrationEngine extends BaseSingleton {
2905
3059
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
2906
3060
  if (!loaded) {
2907
3061
  // Matched-ID row vanished — fall back to upsert by PK (insert; or update/skip if PK exists)
2908
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
3062
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
2909
3063
  if (outcome === 'updated')
2910
3064
  result.RecordsUpdated++;
2911
3065
  else if (outcome === 'skipped')
@@ -2914,21 +3068,34 @@ export class IntegrationEngine extends BaseSingleton {
2914
3068
  result.RecordsCreated++;
2915
3069
  return;
2916
3070
  }
3071
+ // Set only the BUSINESS fields first, and evaluate dirtiness BEFORE the integration
3072
+ // bookkeeping columns are stamped. This ordering is the whole point: several
3073
+ // __mj_integration_* columns (LastSyncedAt, LastReconciledAt) are set to `new Date()`
3074
+ // on every record, so stamping them first made `entity.Dirty` unconditionally true and
3075
+ // the check below dead — every matched record rewrote every column on every sync,
3076
+ // clobbering concurrent edits to untouched columns and fabricating a record-change row
3077
+ // each time. CreateRecord's upsert branch already orders it this way; this is the same
3078
+ // footprint-clean rule applied to the matched path.
2917
3079
  this.SetEntityFields(entity, record.MappedFields);
2918
- this.SetStandardIntegrationFields(entity, record);
2919
- // Skip unchanged records if no field values actually changed after setting,
2920
- // don't write to DB. Uses MJ's built-in dirty tracking (zero custom comparison logic).
2921
- // Critical for connectors without server-side date filtering (e.g., YM) where every
2922
- // sync re-fetches all records. Without this, 50k+ records get re-written every run.
2923
- if (!entity.Dirty) {
3080
+ // Skip unchanged records — if no business field values actually changed, don't write.
3081
+ // Uses MJ's built-in dirty tracking (zero custom comparison logic). Critical for
3082
+ // connectors without server-side date filtering (e.g., YM) where every sync re-fetches
3083
+ // all records. Without this, 50k+ records get re-written every run.
3084
+ if (!entity.Dirty && !this.needsSyncStateRepair(entity, entityInfo)) {
2924
3085
  result.RecordsSkipped++;
2925
3086
  // Re-establish the record map even when the write is skipped — see the content-hash skip
2926
3087
  // above for the full rationale (a key-field/PK match can land here with no map row, and
2927
3088
  // dropping the map silently breaks the 1:1 completeness invariant + orphan detection).
2928
3089
  // The entity is loaded here, so use its actual PK as the EntityRecordID. Idempotent upsert.
2929
- await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
3090
+ await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
3091
+ // Same reasoning as the content-hash skip: the record IS confirmed present on the
3092
+ // source, so let the batch refresh LastReconciledAt in one set-based touch rather
3093
+ // than rewriting the row for a timestamp.
3094
+ if (reconciledSkipIds)
3095
+ reconciledSkipIds.push(record.MatchedMJRecordID);
2930
3096
  return;
2931
3097
  }
3098
+ this.SetStandardIntegrationFields(entity, record);
2932
3099
  // A5: Pre-write validation
2933
3100
  this.validateEntity(entity, record.MJEntityName);
2934
3101
  const saved = await entity.Save();
@@ -2945,7 +3112,7 @@ export class IntegrationEngine extends BaseSingleton {
2945
3112
  // rows and orphan/delete detection silently degrades. SaveRecordMap is an upsert keyed on
2946
3113
  // (CompanyIntegration, Entity, ExternalID), so this is idempotent for already-mapped records.
2947
3114
  const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
2948
- await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
3115
+ await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
2949
3116
  result.RecordsUpdated++;
2950
3117
  }
2951
3118
  /**
@@ -3237,6 +3404,32 @@ export class IntegrationEngine extends BaseSingleton {
3237
3404
  isDateSqlType(t) {
3238
3405
  return /date|time|timestamp/.test(t);
3239
3406
  }
3407
+ /**
3408
+ * True when a row whose BUSINESS fields are unchanged still has to be written because its
3409
+ * integration sync state is stale — it is tombstoned (the record reappeared on the source),
3410
+ * carries a prior error/conflict note, or is not marked Active.
3411
+ *
3412
+ * Without this, restoring UpdateRecord's dirty check would introduce a regression: before
3413
+ * the fix, an unchanged row was rewritten every sync (expensively) and so its tombstone /
3414
+ * error state was cleared as a side effect. Skipping the write must not freeze that state.
3415
+ * The cost is nil — the entity is already loaded at every call site.
3416
+ *
3417
+ * `.Get` on the dynamic `__mj_integration_*` columns is the sanctioned access here: these
3418
+ * runtime-created target tables have no generated entity type, which is why the engine
3419
+ * already `.Set()`s the very same columns in SetStandardIntegrationFields.
3420
+ */
3421
+ needsSyncStateRepair(entity, entityInfo) {
3422
+ if (!entityInfo)
3423
+ return false;
3424
+ const has = (name) => entityInfo.Fields.some(f => f.Name === name);
3425
+ if (has('__mj_integration_IsTombstoned') && entity.Get('__mj_integration_IsTombstoned') === true)
3426
+ return true;
3427
+ if (has('__mj_integration_SyncMessage') && entity.Get('__mj_integration_SyncMessage') != null)
3428
+ return true;
3429
+ if (has('__mj_integration_SyncStatus') && entity.Get('__mj_integration_SyncStatus') !== 'Active')
3430
+ return true;
3431
+ return false;
3432
+ }
3240
3433
  /**
3241
3434
  * Sets standard integration columns (__mj_integration_*) on target entities.
3242
3435
  * Silently skips if the entity doesn't have these columns (e.g., __mj targets).
@@ -3261,12 +3454,17 @@ export class IntegrationEngine extends BaseSingleton {
3261
3454
  if (hasField('__mj_integration_SyncMessage')) {
3262
3455
  entity.Set('__mj_integration_SyncMessage', null);
3263
3456
  }
3264
- // Content hash of the mapped values — the cheap change-detection key for
3457
+ // Content hash of the MAPPED values only — the cheap change-detection key for
3265
3458
  // watermark-less sources. On the next sync, a record whose freshly-computed
3266
3459
  // hash equals the stored hash can be skipped without loading it (see
3267
3460
  // PrefetchContentHashes / UpdateRecord). No-op on tables predating the column.
3461
+ // Content-hash basis: unmapped/custom keys are EXCLUDED — a newly-appearing custom
3462
+ // column must not break the row match (its capture + stats ride CustomKeyStats;
3463
+ // promotion + the schema-change watermark reset backfill it properly). Rows whose
3464
+ // stored hash predates this basis (overflow folded in) mismatch ONCE, rewrite, and
3465
+ // converge on the new basis.
3268
3466
  if (hasField(CONTENT_HASH_COLUMN)) {
3269
- entity.Set(CONTENT_HASH_COLUMN, computeContentHashWithOverflow(record.MappedFields ?? {}, record.UnmappedFields));
3467
+ entity.Set(CONTENT_HASH_COLUMN, computeContentHash(record.MappedFields ?? {}));
3270
3468
  }
3271
3469
  // Custom-overflow capture (gaps.md §2): park any source keys with no field map as JSON,
3272
3470
  // in THIS same row write (no extra round-trip → a customs-free sync stays byte-identical).
@@ -3313,6 +3511,94 @@ export class IntegrationEngine extends BaseSingleton {
3313
3511
  entity.Set('__mj_integration_IsTombstoned', false);
3314
3512
  }
3315
3513
  }
3514
+ /**
3515
+ * Reads EVERY record-map row for a (CompanyIntegration, Entity) pair, in pages.
3516
+ *
3517
+ * A plain `RunView` with no `MaxRows` is NOT unbounded: it falls back to the entity's
3518
+ * `UserViewMaxRows`, which defaults to 1000. Both callers of this helper (full-push
3519
+ * "what already exists externally" and full-sync orphan detection) treat the result as
3520
+ * the complete picture, so a silent truncation at 1000 was a data bug in both directions —
3521
+ * duplicate creates on push, and an orphan sweep that cleared only the first 1000 while
3522
+ * reporting a clean run.
3523
+ *
3524
+ * `Complete` is the honest half: paging can still fail part-way, and callers must decide
3525
+ * what a partial read means for them rather than acting on it as if it were whole.
3526
+ *
3527
+ * **Keyset, not OFFSET.** `AfterKey` is used rather than `StartRow` for two independent
3528
+ * reasons, both of which bite precisely at the volumes this helper exists for:
3529
+ *
3530
+ * 1. `StartRow` + `MaxRows` sets the provider's `usingPagination` flag, which forces a
3531
+ * `SELECT COUNT(*)` over the whole record-map view *for every page*. Paging 100k
3532
+ * mappings would issue 20 full counts nobody reads — reintroducing, twenty-fold, the
3533
+ * speculative-count cost removed elsewhere in this change. Keyset queries deliberately
3534
+ * do not set that flag.
3535
+ * 2. OFFSET paging assumes the row set does not shift underneath the cursor. This table is
3536
+ * written by the same sync (`RecordMapBatch`), and on Postgres `ID` defaults to a RANDOM
3537
+ * uuid, so a concurrent insert can land *before* the cursor and shift every later page —
3538
+ * silently skipping rows while still reporting `Complete: true`. That is exactly the
3539
+ * truncation this helper exists to prevent, and the orphan sweep would archive live
3540
+ * records on the strength of it. Keyset seeks on the last ID seen and cannot shift.
3541
+ */
3542
+ async LoadAllRecordMaps(companyIntegrationID, entityID, contextUser) {
3543
+ const PAGE_SIZE = IntegrationEngine.RecordMapPageSize;
3544
+ // Backstop only: at the default page size this is 50M mappings for one
3545
+ // (CompanyIntegration, Entity) pair. It exists so a provider that ignores the seek key
3546
+ // cannot spin forever, not as a real limit anyone should reach.
3547
+ const MAX_PAGES = 5000;
3548
+ const filter = `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}'`;
3549
+ const rv = new RunView();
3550
+ const rows = [];
3551
+ let afterID;
3552
+ for (let pageNo = 0; pageNo < MAX_PAGES; pageNo++) {
3553
+ const page = await rv.RunView({
3554
+ EntityName: 'MJ: Company Integration Record Maps',
3555
+ ExtraFilter: filter,
3556
+ Fields: ['ID', 'EntityRecordID', 'ExternalSystemRecordID'],
3557
+ // Keyset requires the sort to be on the PK alone; it is also what makes the seek
3558
+ // below well-defined.
3559
+ OrderBy: 'ID ASC',
3560
+ AfterKey: afterID ? CompositeKey.FromID(afterID) : undefined,
3561
+ IgnoreMaxRows: true, // the entity-level cap is exactly what we're defeating here
3562
+ MaxRows: PAGE_SIZE,
3563
+ ResultType: 'simple',
3564
+ BypassCache: true, // sync decisions must reflect committed record-map state
3565
+ }, contextUser);
3566
+ if (!page.Success) {
3567
+ return { Rows: rows, Complete: false, Error: page.ErrorMessage ?? 'RunView failed' };
3568
+ }
3569
+ rows.push(...page.Results);
3570
+ if (page.Results.length < PAGE_SIZE)
3571
+ return { Rows: rows, Complete: true };
3572
+ afterID = page.Results[page.Results.length - 1].ID;
3573
+ }
3574
+ // Fell out of the loop with full pages still coming: either a genuinely enormous map or a
3575
+ // provider that ignored the seek key. Either way we do NOT have the complete picture, and
3576
+ // saying so is the whole point of the Complete flag.
3577
+ return {
3578
+ Rows: rows,
3579
+ Complete: false,
3580
+ Error: `Record-map paging exceeded ${MAX_PAGES} pages (${rows.length} rows read) without reaching the end.`,
3581
+ };
3582
+ }
3583
+ /**
3584
+ * Records an external↔MJ mapping — batched when the apply pass supplied a writer, direct
3585
+ * otherwise.
3586
+ *
3587
+ * The batched form exists because the per-record `SaveRecordMap`
3588
+ * below costs three round trips, paid for every record the sync touches including the ones it
3589
+ * decides not to change. The `recordMaps` parameter is optional so that callers outside the
3590
+ * batched apply pass (and every existing test) keep the original immediate-write behaviour.
3591
+ */
3592
+ async QueueRecordMap(recordMaps, companyIntegrationID, externalID, entityID, entityRecordID, contextUser) {
3593
+ if (recordMaps) {
3594
+ // Queue only — the apply loop flushes after the batch transaction commits. Writing here
3595
+ // (or auto-flushing on a full chunk) would put map rows inside a transaction that
3596
+ // Discard() can no longer take back on rollback.
3597
+ recordMaps.Queue({ EntityID: entityID, ExternalID: externalID, EntityRecordID: entityRecordID });
3598
+ return;
3599
+ }
3600
+ await this.SaveRecordMap(companyIntegrationID, externalID, entityID, entityRecordID, contextUser);
3601
+ }
3316
3602
  /**
3317
3603
  * Creates or updates a CompanyIntegrationRecordMap entry to track the external↔MJ mapping.
3318
3604
  */
@@ -3323,10 +3609,16 @@ export class IntegrationEngine extends BaseSingleton {
3323
3609
  // The prior always-NewRecord() behavior created a duplicate map row whenever a
3324
3610
  // record fell through to this path again (e.g. matching missed), which then made
3325
3611
  // every by-external-ID lookup ambiguous. Look up an existing mapping first.
3612
+ // Quoted the same way the batched read quotes it — this is `RecordMapBatch`'s fallback path,
3613
+ // so a value the batch would have found here must be found here too, or the fallback
3614
+ // re-creates the very duplicate the upsert exists to prevent.
3615
+ const quotedExternalID = md instanceof DatabaseProviderBase
3616
+ ? quoteTextLiteral(externalID, md.Dialect)
3617
+ : `'${externalID.replace(/'/g, "''")}'`;
3326
3618
  const rv = new RunView();
3327
3619
  const existing = await rv.RunView({
3328
3620
  EntityName: 'MJ: Company Integration Record Maps',
3329
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID='${externalID.replace(/'/g, "''")}'`,
3621
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID=${quotedExternalID}`,
3330
3622
  Fields: ['ID'],
3331
3623
  MaxRows: 1,
3332
3624
  ResultType: 'simple',
@@ -3402,6 +3694,35 @@ export class IntegrationEngine extends BaseSingleton {
3402
3694
  if (mapResult.RecordsErrored > 0) {
3403
3695
  aggregate.Success = false;
3404
3696
  }
3697
+ // Merge custom-key stats (out-of-band candidates) by entity name. Two maps
3698
+ // targeting the SAME entity merge per-key: occurrences/totals sum, max length wins,
3699
+ // samples concat under the same bounded cap.
3700
+ if (mapResult.CustomKeyStats) {
3701
+ aggregate.CustomKeyStats ??= {};
3702
+ for (const [entityName, stats] of Object.entries(mapResult.CustomKeyStats)) {
3703
+ const existing = aggregate.CustomKeyStats[entityName];
3704
+ if (!existing) {
3705
+ aggregate.CustomKeyStats[entityName] = stats;
3706
+ continue;
3707
+ }
3708
+ const byKey = new Map(existing.map(s => [s.Key, s]));
3709
+ for (const s of stats) {
3710
+ const prior = byKey.get(s.Key);
3711
+ if (!prior) {
3712
+ byKey.set(s.Key, s);
3713
+ }
3714
+ else {
3715
+ prior.Occurrences += s.Occurrences;
3716
+ prior.TotalRecords += s.TotalRecords;
3717
+ prior.MaxLength = Math.max(prior.MaxLength, s.MaxLength);
3718
+ const room = 20 - prior.SampleValues.length;
3719
+ if (room > 0)
3720
+ prior.SampleValues.push(...s.SampleValues.slice(0, room));
3721
+ }
3722
+ }
3723
+ aggregate.CustomKeyStats[entityName] = [...byKey.values()].sort((a, b) => a.Key.localeCompare(b.Key));
3724
+ }
3725
+ }
3405
3726
  }
3406
3727
  /**
3407
3728
  * Builds a per-entity-map result summary from the entity map and its sync result.
@@ -3648,6 +3969,10 @@ export class IntegrationEngine extends BaseSingleton {
3648
3969
  ContextUser: contextUser,
3649
3970
  SyncedEntityNames: syncedEntityNames,
3650
3971
  Provider: this._provider,
3972
+ // The run's in-memory custom-key candidates — needed because the
3973
+ // overflow-column scan alone under-reports once the hash basis excludes
3974
+ // overflow (skipped rows never write their overflow JSON).
3975
+ CustomKeyStats: result.CustomKeyStats,
3651
3976
  });
3652
3977
  }
3653
3978
  catch (promoteErr) {