@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.
- package/dist/IntegrationEngine.d.ts +77 -0
- package/dist/IntegrationEngine.d.ts.map +1 -1
- package/dist/IntegrationEngine.js +247 -51
- package/dist/IntegrationEngine.js.map +1 -1
- package/dist/MatchEngine.d.ts +156 -14
- package/dist/MatchEngine.d.ts.map +1 -1
- package/dist/MatchEngine.js +438 -68
- package/dist/MatchEngine.js.map +1 -1
- package/dist/RecordMapBatch.d.ts +162 -0
- package/dist/RecordMapBatch.d.ts.map +1 -0
- package/dist/RecordMapBatch.js +283 -0
- package/dist/RecordMapBatch.js.map +1 -0
- package/dist/prefetchFilter.d.ts +18 -0
- package/dist/prefetchFilter.d.ts.map +1 -1
- package/dist/prefetchFilter.js +15 -10
- package/dist/prefetchFilter.js.map +1 -1
- 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';
|
|
@@ -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 {
|
|
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
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
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
|
-
|
|
2033
|
-
|
|
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
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
3010
|
-
//
|
|
3011
|
-
//
|
|
3012
|
-
//
|
|
3013
|
-
|
|
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.
|
|
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.
|
|
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
|
|
3621
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID=${quotedExternalID}`,
|
|
3426
3622
|
Fields: ['ID'],
|
|
3427
3623
|
MaxRows: 1,
|
|
3428
3624
|
ResultType: 'simple',
|