@memberjunction/integration-engine 5.49.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.
@@ -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';
@@ -10,7 +10,8 @@ import { MatchEngine } from './MatchEngine.js';
10
10
  import { WatermarkService } from './WatermarkService.js';
11
11
  import { SyncLogger } from './SyncLogger.js';
12
12
  import { CONTENT_HASH_COLUMN, computeContentHash } from './ContentHash.js';
13
- import { buildContentHashPrefetchFilter } from './prefetchFilter.js';
13
+ import { RecordMapBatch } from './RecordMapBatch.js';
14
+ import { buildContentHashPrefetchFilter, quoteTextLiteral } from './prefetchFilter.js';
14
15
  import { serializeKeyValue } from './KeySerialization.js';
15
16
  import { CUSTOM_OVERFLOW_COLUMN, reconcileOverflowValue, foldCustomKeyStats } from './CustomOverflow.js';
16
17
  import { partitionRecords, partitionRollupHash, diffPartitions, partitionKeyForIdentity } from './HashDiff.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;
@@ -2020,19 +2045,21 @@ export class IntegrationEngine extends BaseSingleton {
2020
2045
  `use incremental push (set a push watermark) or raise MJ_INTEGRATION_FULL_PUSH_MAX_RECORDS. ` +
2021
2046
  `Refusing to partial-push (that would advance the watermark past unsent rows and drop data).`);
2022
2047
  }
2023
- // Load existing record maps to know which records already exist externally
2024
- const mapResult = await rv.RunView({
2025
- EntityName: 'MJ: Company Integration Record Maps',
2026
- ExtraFilter: `CompanyIntegrationID='${companyIntegration.ID}' AND EntityID='${entityMap.EntityID}'`,
2027
- Fields: ['EntityRecordID', 'ExternalSystemRecordID'],
2028
- ResultType: 'simple',
2029
- BypassCache: true, // sync decisions must reflect committed record-map state, not a stale cache
2030
- }, 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
+ }
2031
2060
  const existingMaps = new Map();
2032
- if (mapResult.Success) {
2033
- for (const m of mapResult.Results) {
2034
- existingMaps.set(m.EntityRecordID, m.ExternalSystemRecordID);
2035
- }
2061
+ for (const m of allMaps.Rows) {
2062
+ existingMaps.set(m.EntityRecordID, m.ExternalSystemRecordID);
2036
2063
  }
2037
2064
  const md = this.ProviderToUse;
2038
2065
  const entityInfo = md.EntityByName(entityMap.Entity);
@@ -2318,18 +2345,20 @@ export class IntegrationEngine extends BaseSingleton {
2318
2345
  * These records were deleted externally and should be removed from MJ.
2319
2346
  */
2320
2347
  async DeleteOrphanedRecords(companyIntegration, entityMap, fetchedExternalIDs, result, contextUser, logger) {
2321
- const rv = new RunView();
2322
- const mapResult = await rv.RunView({
2323
- EntityName: 'MJ: Company Integration Record Maps',
2324
- ExtraFilter: `CompanyIntegrationID='${companyIntegration.ID}' ` +
2325
- `AND EntityID='${entityMap.EntityID}'`,
2326
- Fields: ['EntityRecordID', 'ExternalSystemRecordID'],
2327
- ResultType: 'simple',
2328
- BypassCache: true, // orphan-sweep compares against committed record-map state
2329
- }, contextUser);
2330
- 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 });
2331
2359
  return;
2332
- const orphans = mapResult.Results.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
2360
+ }
2361
+ const orphans = allMaps.Rows.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
2333
2362
  if (orphans.length === 0)
2334
2363
  return;
2335
2364
  console.log(`[IntegrationEngine] Orphan detection for ${entityMap.ExternalObjectName}: ${orphans.length} records in MJ not found in external system`);
@@ -2540,6 +2569,11 @@ export class IntegrationEngine extends BaseSingleton {
2540
2569
  // to 500 healthy records.
2541
2570
  const APPLY_BATCH_SIZE = 500;
2542
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));
2543
2577
  for (let i = 0; i < records.length; i += APPLY_BATCH_SIZE) {
2544
2578
  const batch = records.slice(i, i + APPLY_BATCH_SIZE);
2545
2579
  const batchStartProcessed = result.RecordsProcessed;
@@ -2566,7 +2600,7 @@ export class IntegrationEngine extends BaseSingleton {
2566
2600
  try {
2567
2601
  for (const record of batch) {
2568
2602
  result.RecordsProcessed++;
2569
- await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2603
+ await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
2570
2604
  }
2571
2605
  await provider.CommitTransaction();
2572
2606
  }
@@ -2575,6 +2609,9 @@ export class IntegrationEngine extends BaseSingleton {
2575
2609
  // The batch transaction rolled back; the skip-IDs collected during the failed attempt
2576
2610
  // never committed. Reset and let the per-record retry re-collect only what commits.
2577
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();
2578
2615
  // Roll back the in-memory counters that ApplySingleRecord bumped inside the failed batch
2579
2616
  result.RecordsProcessed = batchStartProcessed;
2580
2617
  result.RecordsCreated = batchStartCreated;
@@ -2590,7 +2627,7 @@ export class IntegrationEngine extends BaseSingleton {
2590
2627
  }
2591
2628
  // Degrade to per-record application so the failure isolates to the poison
2592
2629
  // record(s) and every good record in this batch still commits.
2593
- await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2630
+ await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
2594
2631
  }
2595
2632
  }
2596
2633
  else {
@@ -2605,7 +2642,7 @@ export class IntegrationEngine extends BaseSingleton {
2605
2642
  try {
2606
2643
  // §10 — bounded inline retry for provably-transient save failures (auto-commit per
2607
2644
  // record, so no transaction to manage); permanent errors throw straight to dead-letter.
2608
- 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', {
2609
2646
  phase: 'save',
2610
2647
  externalObjectName: entityMap.ExternalObjectName,
2611
2648
  externalId: record.ExternalRecord?.ExternalID ?? '',
@@ -2635,9 +2672,35 @@ export class IntegrationEngine extends BaseSingleton {
2635
2672
  if (reconciledSkipIds.length > 0) {
2636
2673
  await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
2637
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);
2638
2680
  });
2639
2681
  }
2640
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
+ }
2641
2704
  /**
2642
2705
  * Refreshes __mj_integration_LastReconciledAt = now for a set of records that the content-hash
2643
2706
  * fast path skipped (present + confirmed-unchanged on the source). Issues ONE set-based UPDATE
@@ -2697,7 +2760,7 @@ export class IntegrationEngine extends BaseSingleton {
2697
2760
  *
2698
2761
  * Begin/Commit/Rollback are always matched per record (no leaked open transaction).
2699
2762
  */
2700
- async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds) {
2763
+ async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps) {
2701
2764
  const provider = this.ProviderToUse;
2702
2765
  for (const record of batch) {
2703
2766
  result.RecordsProcessed++;
@@ -2710,7 +2773,7 @@ export class IntegrationEngine extends BaseSingleton {
2710
2773
  await WithRetry(async () => {
2711
2774
  await provider.BeginTransaction();
2712
2775
  try {
2713
- await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
2776
+ await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps);
2714
2777
  await provider.CommitTransaction();
2715
2778
  }
2716
2779
  catch (e) {
@@ -2765,7 +2828,7 @@ export class IntegrationEngine extends BaseSingleton {
2765
2828
  /**
2766
2829
  * Applies a single record change (Create, Update, Delete, or Skip).
2767
2830
  */
2768
- async ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds) {
2831
+ async ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds, recordMaps) {
2769
2832
  logger?.emit('sync.record.decision', {
2770
2833
  externalId: record.ExternalRecord.ExternalID,
2771
2834
  objectType: record.ExternalRecord.ObjectType,
@@ -2781,7 +2844,7 @@ export class IntegrationEngine extends BaseSingleton {
2781
2844
  try {
2782
2845
  switch (record.ChangeType) {
2783
2846
  case 'Create': {
2784
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
2847
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
2785
2848
  if (outcome === 'updated')
2786
2849
  result.RecordsUpdated++;
2787
2850
  else if (outcome === 'skipped')
@@ -2791,7 +2854,7 @@ export class IntegrationEngine extends BaseSingleton {
2791
2854
  break;
2792
2855
  }
2793
2856
  case 'Update':
2794
- await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds);
2857
+ await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps);
2795
2858
  break;
2796
2859
  case 'Delete': {
2797
2860
  const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
@@ -2851,7 +2914,7 @@ export class IntegrationEngine extends BaseSingleton {
2851
2914
  *
2852
2915
  * @returns true if an existing row was updated, false if a new row was inserted (so the caller counts correctly).
2853
2916
  */
2854
- async CreateRecord(record, companyIntegration, entityMap, contextUser) {
2917
+ async CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps) {
2855
2918
  const md = this.ProviderToUse;
2856
2919
  const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
2857
2920
  const entityInfo = md.EntityByName(record.MJEntityName);
@@ -2878,7 +2941,7 @@ export class IntegrationEngine extends BaseSingleton {
2878
2941
  const storedHash = entity.Get(CONTENT_HASH_COLUMN);
2879
2942
  if (typeof storedHash === 'string' && storedHash.length > 0
2880
2943
  && storedHash === computeContentHash(record.MappedFields ?? {})) {
2881
- await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
2944
+ await this.QueueRecordMap(recordMaps, companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
2882
2945
  return 'skipped';
2883
2946
  }
2884
2947
  }
@@ -2887,8 +2950,8 @@ export class IntegrationEngine extends BaseSingleton {
2887
2950
  // re-establish the possibly-cleared record map and SKIP the write — leaving __mj_UpdatedAt
2888
2951
  // and the integration LastSynced columns untouched, exactly like the content-hash skip path.
2889
2952
  this.SetEntityFields(entity, record.MappedFields);
2890
- if (!entity.Dirty) {
2891
- 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);
2892
2955
  return 'skipped';
2893
2956
  }
2894
2957
  }
@@ -2913,7 +2976,7 @@ export class IntegrationEngine extends BaseSingleton {
2913
2976
  // incremental sync. SaveRecordMap is an upsert keyed on (CompanyIntegration, Entity, ExternalID),
2914
2977
  // so this also re-establishes a map that was previously cleared.
2915
2978
  const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
2916
- 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);
2917
2980
  return existed ? 'updated' : 'created';
2918
2981
  }
2919
2982
  /**
@@ -2945,10 +3008,10 @@ export class IntegrationEngine extends BaseSingleton {
2945
3008
  * If the record cannot be loaded (e.g. it was deleted or never fully created),
2946
3009
  * falls back to CreateRecord (upsert behavior).
2947
3010
  */
2948
- async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds) {
3011
+ async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds, recordMaps) {
2949
3012
  if (!record.MatchedMJRecordID) {
2950
3013
  // No matched ID — upsert by PK (insert; or update/skip if the PK already exists)
2951
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
3014
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
2952
3015
  if (outcome === 'updated')
2953
3016
  result.RecordsUpdated++;
2954
3017
  else if (outcome === 'skipped')
@@ -2977,7 +3040,7 @@ export class IntegrationEngine extends BaseSingleton {
2977
3040
  // (CompanyIntegration, Entity, ExternalID) — idempotent for already-mapped records, and the
2978
3041
  // CreateRecord skip branches already do exactly this. MatchedMJRecordID IS the dest PK
2979
3042
  // (PrimaryKeys order, '|'-joined), which is the EntityRecordID the map stores.
2980
- 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);
2981
3044
  // The record IS still present and confirmed-unchanged on the source — but skipping
2982
3045
  // the write here means SetStandardIntegrationFields never runs, so __mj_integration_
2983
3046
  // LastReconciledAt would freeze at first-sync time. Record the PK so the batch can
@@ -2996,7 +3059,7 @@ export class IntegrationEngine extends BaseSingleton {
2996
3059
  const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
2997
3060
  if (!loaded) {
2998
3061
  // Matched-ID row vanished — fall back to upsert by PK (insert; or update/skip if PK exists)
2999
- const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
3062
+ const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser, recordMaps);
3000
3063
  if (outcome === 'updated')
3001
3064
  result.RecordsUpdated++;
3002
3065
  else if (outcome === 'skipped')
@@ -3005,21 +3068,34 @@ export class IntegrationEngine extends BaseSingleton {
3005
3068
  result.RecordsCreated++;
3006
3069
  return;
3007
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.
3008
3079
  this.SetEntityFields(entity, record.MappedFields);
3009
- this.SetStandardIntegrationFields(entity, record);
3010
- // Skip unchanged records if no field values actually changed after setting,
3011
- // don't write to DB. Uses MJ's built-in dirty tracking (zero custom comparison logic).
3012
- // Critical for connectors without server-side date filtering (e.g., YM) where every
3013
- // sync re-fetches all records. Without this, 50k+ records get re-written every run.
3014
- 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)) {
3015
3085
  result.RecordsSkipped++;
3016
3086
  // Re-establish the record map even when the write is skipped — see the content-hash skip
3017
3087
  // above for the full rationale (a key-field/PK match can land here with no map row, and
3018
3088
  // dropping the map silently breaks the 1:1 completeness invariant + orphan detection).
3019
3089
  // The entity is loaded here, so use its actual PK as the EntityRecordID. Idempotent upsert.
3020
- 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);
3021
3096
  return;
3022
3097
  }
3098
+ this.SetStandardIntegrationFields(entity, record);
3023
3099
  // A5: Pre-write validation
3024
3100
  this.validateEntity(entity, record.MJEntityName);
3025
3101
  const saved = await entity.Save();
@@ -3036,7 +3112,7 @@ export class IntegrationEngine extends BaseSingleton {
3036
3112
  // rows and orphan/delete detection silently degrades. SaveRecordMap is an upsert keyed on
3037
3113
  // (CompanyIntegration, Entity, ExternalID), so this is idempotent for already-mapped records.
3038
3114
  const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
3039
- 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);
3040
3116
  result.RecordsUpdated++;
3041
3117
  }
3042
3118
  /**
@@ -3328,6 +3404,32 @@ export class IntegrationEngine extends BaseSingleton {
3328
3404
  isDateSqlType(t) {
3329
3405
  return /date|time|timestamp/.test(t);
3330
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
+ }
3331
3433
  /**
3332
3434
  * Sets standard integration columns (__mj_integration_*) on target entities.
3333
3435
  * Silently skips if the entity doesn't have these columns (e.g., __mj targets).
@@ -3409,6 +3511,94 @@ export class IntegrationEngine extends BaseSingleton {
3409
3511
  entity.Set('__mj_integration_IsTombstoned', false);
3410
3512
  }
3411
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
+ }
3412
3602
  /**
3413
3603
  * Creates or updates a CompanyIntegrationRecordMap entry to track the external↔MJ mapping.
3414
3604
  */
@@ -3419,10 +3609,16 @@ export class IntegrationEngine extends BaseSingleton {
3419
3609
  // The prior always-NewRecord() behavior created a duplicate map row whenever a
3420
3610
  // record fell through to this path again (e.g. matching missed), which then made
3421
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, "''")}'`;
3422
3618
  const rv = new RunView();
3423
3619
  const existing = await rv.RunView({
3424
3620
  EntityName: 'MJ: Company Integration Record Maps',
3425
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID='${externalID.replace(/'/g, "''")}'`,
3621
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID=${quotedExternalID}`,
3426
3622
  Fields: ['ID'],
3427
3623
  MaxRows: 1,
3428
3624
  ResultType: 'simple',