@memberjunction/integration-engine 5.27.0 → 5.28.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/BaseIntegrationConnector.d.ts +2 -0
- package/dist/BaseIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseIntegrationConnector.js.map +1 -1
- package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseRESTIntegrationConnector.js +13 -1
- package/dist/BaseRESTIntegrationConnector.js.map +1 -1
- package/dist/IntegrationEngine.d.ts +51 -1
- package/dist/IntegrationEngine.d.ts.map +1 -1
- package/dist/IntegrationEngine.js +498 -37
- package/dist/IntegrationEngine.js.map +1 -1
- package/dist/IntegrationSchemaSync.d.ts +65 -0
- package/dist/IntegrationSchemaSync.d.ts.map +1 -0
- package/dist/IntegrationSchemaSync.js +297 -0
- package/dist/IntegrationSchemaSync.js.map +1 -0
- package/dist/WatermarkService.d.ts +2 -2
- package/dist/WatermarkService.d.ts.map +1 -1
- package/dist/WatermarkService.js +7 -7
- package/dist/WatermarkService.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CompositeKey, Metadata, RunView } from '@memberjunction/core';
|
|
2
|
-
import { BaseSingleton } from '@memberjunction/global';
|
|
2
|
+
import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
|
|
3
3
|
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
4
4
|
import { ClassifyError } from './types.js';
|
|
5
5
|
import { ConnectorFactory } from './ConnectorFactory.js';
|
|
@@ -54,6 +54,79 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
54
54
|
static GetAllSyncProgress() {
|
|
55
55
|
return new Map(IntegrationEngine._syncProgress);
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Resumes any syncs that were orphaned by a process restart.
|
|
59
|
+
* Finds all CompanyIntegrationRun records with Status='In Progress',
|
|
60
|
+
* determines which entity maps already completed (have run details),
|
|
61
|
+
* and resumes from the remaining entity maps using existing watermarks.
|
|
62
|
+
*
|
|
63
|
+
* Call this once during MJAPI startup after metadata is loaded.
|
|
64
|
+
*/
|
|
65
|
+
async ResumeOrphanedSyncs(contextUser) {
|
|
66
|
+
await IntegrationEngineBase.Instance.Config(false, contextUser);
|
|
67
|
+
const rv = new RunView();
|
|
68
|
+
const orphanedRuns = await rv.RunView({
|
|
69
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
70
|
+
ExtraFilter: `Status='In Progress'`,
|
|
71
|
+
ResultType: 'entity_object',
|
|
72
|
+
}, contextUser);
|
|
73
|
+
if (!orphanedRuns.Success || orphanedRuns.Results.length === 0) {
|
|
74
|
+
console.log('[IntegrationEngine] No orphaned syncs to resume');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
console.log(`[IntegrationEngine] Found ${orphanedRuns.Results.length} orphaned sync(s) to resume`);
|
|
78
|
+
for (const run of orphanedRuns.Results) {
|
|
79
|
+
const companyIntegrationID = run.CompanyIntegrationID;
|
|
80
|
+
const runID = run.ID;
|
|
81
|
+
try {
|
|
82
|
+
// Find which entity maps already completed in this run
|
|
83
|
+
const detailsResult = await rv.RunView({
|
|
84
|
+
EntityName: 'MJ: Company Integration Run Details',
|
|
85
|
+
ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
|
|
86
|
+
Fields: ['EntityID'],
|
|
87
|
+
ResultType: 'simple',
|
|
88
|
+
}, contextUser);
|
|
89
|
+
const completedEntityIDs = new Set();
|
|
90
|
+
if (detailsResult.Success) {
|
|
91
|
+
for (const d of detailsResult.Results) {
|
|
92
|
+
completedEntityIDs.add(d.EntityID.toLowerCase());
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
|
|
96
|
+
`for ${companyIntegrationID.substring(0, 8)}... ` +
|
|
97
|
+
`(${completedEntityIDs.size} entity maps already completed)`);
|
|
98
|
+
// Load config and filter to only remaining entity maps
|
|
99
|
+
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser);
|
|
100
|
+
const remainingMaps = config.entityMaps.filter(em => !completedEntityIDs.has((em.EntityID).toLowerCase()));
|
|
101
|
+
if (remainingMaps.length === 0) {
|
|
102
|
+
console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
|
|
103
|
+
run.EndedAt = new Date();
|
|
104
|
+
run.Status = 'Success';
|
|
105
|
+
await run.Save();
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
console.log(`[IntegrationEngine] Resuming ${remainingMaps.length} remaining entity maps (of ${config.entityMaps.length} total)`);
|
|
109
|
+
// Replace entityMaps with only the remaining ones
|
|
110
|
+
config.entityMaps = remainingMaps;
|
|
111
|
+
// Execute remaining maps using the existing run record
|
|
112
|
+
const result = await this.ExecuteEntityMaps(config, run, contextUser);
|
|
113
|
+
result.RunID = runID;
|
|
114
|
+
await this.FinalizeRun(run, result, contextUser);
|
|
115
|
+
console.log(`[IntegrationEngine] Resume complete for ${runID.substring(0, 8)}: ` +
|
|
116
|
+
`${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
|
|
117
|
+
`${result.RecordsErrored} errored`);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
121
|
+
console.error(`[IntegrationEngine] Failed to resume run ${runID.substring(0, 8)}: ${errMsg}`);
|
|
122
|
+
// Mark as failed so it doesn't get picked up again
|
|
123
|
+
run.EndedAt = new Date();
|
|
124
|
+
run.Status = 'Failed';
|
|
125
|
+
run.ErrorLog = JSON.stringify([{ ErrorMessage: `Resume failed: ${errMsg}` }]);
|
|
126
|
+
await run.Save();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
57
130
|
/**
|
|
58
131
|
* Executes a full sync run for a company integration.
|
|
59
132
|
* If a sync is already running for the same integration, waits for it to finish
|
|
@@ -118,12 +191,14 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
118
191
|
const run = await this.CreateRunRecord(config.companyIntegration, triggerType, contextUser, options?.ScheduledJobRunID);
|
|
119
192
|
try {
|
|
120
193
|
const result = await this.ExecuteEntityMaps(config, run, contextUser, onProgress, abortSignal);
|
|
121
|
-
result.RunID = run.
|
|
194
|
+
result.RunID = run.ID;
|
|
122
195
|
result.Duration = Date.now() - startTime;
|
|
123
196
|
if (result.RecordsErrored > 0) {
|
|
124
197
|
result.ErrorMessage = `Sync completed with ${result.RecordsErrored} error(s)`;
|
|
125
198
|
}
|
|
126
199
|
await this.FinalizeRun(run, result, contextUser, onNotification);
|
|
200
|
+
const summary = this.buildSyncResultBody(config.companyIntegration.Integration, result);
|
|
201
|
+
console.log(`[IntegrationEngine] Sync complete:\n${summary}`);
|
|
127
202
|
return result;
|
|
128
203
|
}
|
|
129
204
|
catch (err) {
|
|
@@ -160,7 +235,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
160
235
|
throw new Error(`CompanyIntegration not found: ${companyIntegrationID}`);
|
|
161
236
|
}
|
|
162
237
|
const integration = integrationsResult.Results
|
|
163
|
-
.find(i => i.
|
|
238
|
+
.find(i => UUIDsEqual(i.ID, companyIntegration.IntegrationID));
|
|
164
239
|
if (!integration) {
|
|
165
240
|
throw new Error(`Integration not found for CompanyIntegration: ${companyIntegrationID}`);
|
|
166
241
|
}
|
|
@@ -169,7 +244,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
169
244
|
// Filter to specific entity maps if requested
|
|
170
245
|
if (options?.EntityMapIDs && options.EntityMapIDs.length > 0) {
|
|
171
246
|
const requestedIDs = new Set(options.EntityMapIDs.map(id => id.toLowerCase()));
|
|
172
|
-
entityMaps = entityMaps.filter(m => requestedIDs.has(m.
|
|
247
|
+
entityMaps = entityMaps.filter(m => requestedIDs.has(m.ID.toLowerCase()));
|
|
173
248
|
}
|
|
174
249
|
return {
|
|
175
250
|
companyIntegration,
|
|
@@ -177,6 +252,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
177
252
|
integration,
|
|
178
253
|
connector,
|
|
179
254
|
fullSync: options?.FullSync ?? false,
|
|
255
|
+
syncDirection: options?.SyncDirection,
|
|
180
256
|
};
|
|
181
257
|
}
|
|
182
258
|
/**
|
|
@@ -186,7 +262,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
186
262
|
const md = new Metadata();
|
|
187
263
|
const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
|
|
188
264
|
run.NewRecord();
|
|
189
|
-
run.CompanyIntegrationID = companyIntegration.
|
|
265
|
+
run.CompanyIntegrationID = companyIntegration.ID;
|
|
190
266
|
run.RunByUserID = contextUser.ID;
|
|
191
267
|
run.StartedAt = new Date();
|
|
192
268
|
run.Status = 'In Progress';
|
|
@@ -235,7 +311,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
235
311
|
aggregate.EntityMapResults.push(this.buildEntityMapResult(entityMap, mapResult, Date.now() - mapStartTime));
|
|
236
312
|
}
|
|
237
313
|
catch (err) {
|
|
238
|
-
const objName = entityMap.ExternalObjectName ??
|
|
314
|
+
const objName = entityMap.ExternalObjectName ?? entityMap.ID;
|
|
239
315
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
240
316
|
console.error(`[IntegrationEngine] Entity map '${objName}' failed: ${errMsg}`);
|
|
241
317
|
aggregate.RecordsErrored++;
|
|
@@ -248,7 +324,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
248
324
|
ExternalRecord: { ExternalID: objName, ObjectType: objName, Fields: {} },
|
|
249
325
|
});
|
|
250
326
|
aggregate.EntityMapResults.push({
|
|
251
|
-
EntityMapID: entityMap.
|
|
327
|
+
EntityMapID: entityMap.ID,
|
|
252
328
|
ExternalObjectName: objName,
|
|
253
329
|
EntityName: entityMap.Entity ?? '',
|
|
254
330
|
Success: false,
|
|
@@ -265,18 +341,38 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
265
341
|
return aggregate;
|
|
266
342
|
}
|
|
267
343
|
/**
|
|
268
|
-
* Processes a single entity map
|
|
344
|
+
* Processes a single entity map based on SyncDirection:
|
|
345
|
+
* - Pull: fetch from external → map → match → apply to MJ
|
|
346
|
+
* - Push: detect MJ changes → map → push to external
|
|
347
|
+
* - Bidirectional: pull first, then push
|
|
269
348
|
*/
|
|
270
349
|
async ProcessSingleEntityMap(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal) {
|
|
271
|
-
const
|
|
350
|
+
const direction = config.syncDirection ?? entityMap.SyncDirection ?? 'Pull';
|
|
351
|
+
if (direction === 'Pull') {
|
|
352
|
+
return this.ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
|
|
353
|
+
}
|
|
354
|
+
if (direction === 'Push') {
|
|
355
|
+
return this.ProcessPushSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
|
|
356
|
+
}
|
|
357
|
+
// Bidirectional: pull first, then push
|
|
358
|
+
const pullResult = await this.ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
|
|
359
|
+
const pushResult = await this.ProcessPushSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
|
|
360
|
+
this.MergeResult(pullResult, pushResult);
|
|
361
|
+
return pullResult;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Pull sync: fetch from external → map → match → validate → apply to MJ.
|
|
365
|
+
*/
|
|
366
|
+
async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal) {
|
|
367
|
+
const entityMapID = entityMap.ID;
|
|
272
368
|
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
273
|
-
const watermark = await this.watermarkService.Load(entityMapID, contextUser);
|
|
369
|
+
const watermark = await this.watermarkService.Load(entityMapID, contextUser, 'Pull');
|
|
274
370
|
// A6: Validate watermark before using it — skip entirely when FullSync requested
|
|
275
371
|
let initialWatermark = config.fullSync ? null : (watermark?.WatermarkValue ?? null);
|
|
276
372
|
if (initialWatermark && watermark) {
|
|
277
373
|
const watermarkType = (watermark.WatermarkType ?? 'Timestamp');
|
|
278
374
|
if (!this.watermarkService.ValidateWatermark(initialWatermark, watermarkType)) {
|
|
279
|
-
console.warn(`[IntegrationEngine] Invalid watermark '${initialWatermark}' for EntityMap ${entityMap.
|
|
375
|
+
console.warn(`[IntegrationEngine] Invalid watermark '${initialWatermark}' for EntityMap ${entityMap.ID}, resetting to full fetch`);
|
|
280
376
|
initialWatermark = null;
|
|
281
377
|
}
|
|
282
378
|
}
|
|
@@ -298,16 +394,20 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
298
394
|
let currentCursor;
|
|
299
395
|
let batchCount = 0;
|
|
300
396
|
let previousBatchFingerprint;
|
|
397
|
+
let fetchCompletedCleanly = true; // flipped to false if fetch aborted or errored mid-way
|
|
301
398
|
const MAX_BATCHES_PER_MAP = 5000;
|
|
399
|
+
const fetchedExternalIDs = new Set(); // Track all IDs seen during this pull for orphan detection
|
|
302
400
|
while (hasMore) {
|
|
303
401
|
if (abortSignal?.aborted) {
|
|
304
402
|
console.log(`[IntegrationEngine] Sync cancelled for ${entityMap.ExternalObjectName} after ${recordsInMap} records — saving watermark`);
|
|
403
|
+
fetchCompletedCleanly = false;
|
|
305
404
|
break;
|
|
306
405
|
}
|
|
307
406
|
batchCount++;
|
|
308
407
|
if (batchCount > MAX_BATCHES_PER_MAP) {
|
|
309
408
|
console.error(`[IntegrationEngine] Safety limit reached: ${MAX_BATCHES_PER_MAP} batches for ` +
|
|
310
409
|
`${entityMap.ExternalObjectName}. Stopping to prevent infinite loop.`);
|
|
410
|
+
fetchCompletedCleanly = false;
|
|
311
411
|
break;
|
|
312
412
|
}
|
|
313
413
|
const ctx = {
|
|
@@ -320,23 +420,35 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
320
420
|
CurrentOffset: currentOffset,
|
|
321
421
|
CurrentCursor: currentCursor,
|
|
322
422
|
};
|
|
323
|
-
|
|
324
|
-
|
|
423
|
+
let batch;
|
|
424
|
+
try {
|
|
425
|
+
batch = await config.connector.FetchChanges(ctx);
|
|
426
|
+
}
|
|
427
|
+
catch (fetchErr) {
|
|
428
|
+
const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
|
429
|
+
console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
|
|
430
|
+
fetchCompletedCleanly = false;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
// If the connector returned more records than MaxBatchSize, log it but never truncate —
|
|
434
|
+
// all records are written, just in sub-batches to keep DB transactions manageable.
|
|
325
435
|
if (batch.Records.length > this.MaxBatchSize) {
|
|
326
|
-
console.
|
|
327
|
-
`${batch.Records.length} records
|
|
328
|
-
`Truncating to ${this.MaxBatchSize}.`);
|
|
329
|
-
batch.Records = batch.Records.slice(0, this.MaxBatchSize);
|
|
436
|
+
console.log(`[IntegrationEngine] ${entityMap.ExternalObjectName}: connector returned ` +
|
|
437
|
+
`${batch.Records.length} records (> MaxBatchSize ${this.MaxBatchSize}), writing in chunks.`);
|
|
330
438
|
}
|
|
331
439
|
if (batch.Records.length > 0) {
|
|
332
440
|
const fingerprint = batch.Records.map(r => r.ExternalID).join(',');
|
|
333
441
|
if (fingerprint === previousBatchFingerprint) {
|
|
334
442
|
console.warn(`[IntegrationEngine] Duplicate batch detected for ${entityMap.ExternalObjectName} — ` +
|
|
335
443
|
`connector returned same records twice. Stopping to prevent infinite loop.`);
|
|
444
|
+
fetchCompletedCleanly = false;
|
|
336
445
|
break;
|
|
337
446
|
}
|
|
338
447
|
previousBatchFingerprint = fingerprint;
|
|
339
448
|
}
|
|
449
|
+
for (const rec of batch.Records) {
|
|
450
|
+
fetchedExternalIDs.add(rec.ExternalID);
|
|
451
|
+
}
|
|
340
452
|
const mapped = this.fieldMappingEngine.Apply(batch.Records, fieldMaps, entityMap.Entity);
|
|
341
453
|
const resolved = await this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser);
|
|
342
454
|
const beforeApply = result.RecordsCreated + result.RecordsUpdated + result.RecordsSkipped + result.RecordsErrored;
|
|
@@ -368,12 +480,332 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
368
480
|
hasMore = batch.HasMore === true; // Explicit boolean check — prevents truthy undefined from looping
|
|
369
481
|
}
|
|
370
482
|
if (currentWatermark) {
|
|
371
|
-
|
|
372
|
-
|
|
483
|
+
// After a clean full sync, advance the watermark to now rather than the max
|
|
484
|
+
// modification date found in the last batch. HubSpot's list API returns records
|
|
485
|
+
// in creation order, so the last batch can contain records with very old
|
|
486
|
+
// hs_lastmodifieddate values. A stale watermark causes the next incremental to
|
|
487
|
+
// re-fetch the entire change history from that old date.
|
|
488
|
+
const finalWatermark = (config.fullSync && fetchCompletedCleanly)
|
|
489
|
+
? new Date().toISOString()
|
|
490
|
+
: currentWatermark;
|
|
491
|
+
await this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull');
|
|
492
|
+
result.WatermarkAfter = finalWatermark;
|
|
493
|
+
}
|
|
494
|
+
// Orphan detection: on full sync, delete MJ records whose external counterpart no longer exists.
|
|
495
|
+
// Only runs if the fetch completed cleanly — a partial fetch (aborted, errored, safety-limited)
|
|
496
|
+
// means fetchedExternalIDs is incomplete; running deletion on it would be catastrophic.
|
|
497
|
+
if (config.fullSync && fetchedExternalIDs.size > 0 && fetchCompletedCleanly) {
|
|
498
|
+
await this.DeleteOrphanedRecords(config.companyIntegration, entityMap, fetchedExternalIDs, result, contextUser);
|
|
373
499
|
}
|
|
374
500
|
await this.CreateRunDetail(run, entityMap, result, contextUser);
|
|
375
501
|
return result;
|
|
376
502
|
}
|
|
503
|
+
/**
|
|
504
|
+
* Push sync: detect local MJ record changes → reverse-map fields → push to external system.
|
|
505
|
+
*
|
|
506
|
+
* Uses MJ's Record Changes entity to find records modified since the last push watermark.
|
|
507
|
+
* Filters out changes made by the integration engine itself to prevent echo loops.
|
|
508
|
+
* For each changed record, calls the connector's CreateRecord/UpdateRecord/DeleteRecord.
|
|
509
|
+
*/
|
|
510
|
+
async ProcessPushSync(config, entityMap, run, contextUser, _entityMapIndex, _totalEntityMaps, _onProgress, _abortSignal) {
|
|
511
|
+
const entityMapID = entityMap.ID;
|
|
512
|
+
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
513
|
+
const pushWatermark = await this.watermarkService.Load(entityMapID, contextUser, 'Push');
|
|
514
|
+
const lastPushAt = pushWatermark?.WatermarkValue ?? null;
|
|
515
|
+
// Check connector write capability
|
|
516
|
+
if (!config.connector.SupportsCreate && !config.connector.SupportsUpdate) {
|
|
517
|
+
console.log(`[IntegrationEngine] Push skipped for ${entityMap.ExternalObjectName}: connector does not support writes`);
|
|
518
|
+
return this.EmptyResult();
|
|
519
|
+
}
|
|
520
|
+
// Full push: load ALL records from the MJ entity. Incremental push: only changed records.
|
|
521
|
+
const changedRecords = config.fullSync
|
|
522
|
+
? await this.LoadAllMJRecords(entityMap, config.companyIntegration, contextUser)
|
|
523
|
+
: await this.LoadChangedMJRecords(entityMap, lastPushAt, contextUser);
|
|
524
|
+
if (changedRecords.length === 0) {
|
|
525
|
+
console.log(`[IntegrationEngine] Push: no changes for ${entityMap.ExternalObjectName} since ${lastPushAt ?? 'beginning'}`);
|
|
526
|
+
await this.CreateRunDetail(run, entityMap, this.EmptyResult(), contextUser);
|
|
527
|
+
return this.EmptyResult();
|
|
528
|
+
}
|
|
529
|
+
console.log(`[IntegrationEngine] Push: ${changedRecords.length} changed records for ${entityMap.ExternalObjectName}`);
|
|
530
|
+
const result = {
|
|
531
|
+
Success: true, RecordsProcessed: 0, RecordsCreated: 0, RecordsUpdated: 0,
|
|
532
|
+
RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0, Errors: [],
|
|
533
|
+
};
|
|
534
|
+
// Get push-direction field maps (DestToSource or Both)
|
|
535
|
+
const pushFieldMaps = fieldMaps.filter(fm => fm.Direction === 'DestToSource' || fm.Direction === 'Both');
|
|
536
|
+
// If no field maps are configured for push, skip this entity entirely
|
|
537
|
+
if (pushFieldMaps.length === 0) {
|
|
538
|
+
console.log(`[IntegrationEngine] Push skipped for ${entityMap.ExternalObjectName}: no field maps with push direction`);
|
|
539
|
+
return this.EmptyResult();
|
|
540
|
+
}
|
|
541
|
+
let latestChangeAt = null;
|
|
542
|
+
for (const change of changedRecords) {
|
|
543
|
+
result.RecordsProcessed++;
|
|
544
|
+
try {
|
|
545
|
+
await this.PushSingleRecord(change, config, entityMap, pushFieldMaps, result, contextUser);
|
|
546
|
+
if (change.ChangedAt && (!latestChangeAt || change.ChangedAt > latestChangeAt)) {
|
|
547
|
+
latestChangeAt = change.ChangedAt;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
552
|
+
result.RecordsErrored++;
|
|
553
|
+
result.Errors.push({
|
|
554
|
+
ExternalID: change.RecordID,
|
|
555
|
+
ChangeType: change.Type,
|
|
556
|
+
ErrorMessage: errMsg,
|
|
557
|
+
ErrorCode: 'CONNECTOR_ERROR',
|
|
558
|
+
Severity: 'Critical',
|
|
559
|
+
ExternalRecord: { ExternalID: change.RecordID, ObjectType: entityMap.ExternalObjectName, Fields: {} },
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// Update push watermark
|
|
564
|
+
if (latestChangeAt) {
|
|
565
|
+
await this.watermarkService.Update(entityMapID, latestChangeAt, contextUser, 'Push');
|
|
566
|
+
}
|
|
567
|
+
console.log(`[IntegrationEngine] Push complete for ${entityMap.ExternalObjectName}: ` +
|
|
568
|
+
`${result.RecordsCreated} created, ${result.RecordsUpdated} updated, ` +
|
|
569
|
+
`${result.RecordsDeleted} deleted, ${result.RecordsErrored} errored`);
|
|
570
|
+
await this.CreateRunDetail(run, entityMap, result, contextUser);
|
|
571
|
+
return result;
|
|
572
|
+
}
|
|
573
|
+
/** Loads MJ records changed since the last push watermark, excluding integration-engine changes. */
|
|
574
|
+
async LoadChangedMJRecords(entityMap, lastPushAt, contextUser) {
|
|
575
|
+
const rv = new RunView();
|
|
576
|
+
// Query Record Changes for this entity since the last push
|
|
577
|
+
let filter = `EntityID='${entityMap.EntityID}'`;
|
|
578
|
+
if (lastPushAt) {
|
|
579
|
+
filter += ` AND ChangedAt > '${lastPushAt}'`;
|
|
580
|
+
}
|
|
581
|
+
// Exclude changes made by the integration engine (System user or specific source)
|
|
582
|
+
filter += ` AND UserID != 'ECAFCCEC-6A37-EF11-86D4-000D3A4E707E'`; // System user
|
|
583
|
+
filter += ` AND Type IN ('Create', 'Update', 'Delete')`;
|
|
584
|
+
const result = await rv.RunView({
|
|
585
|
+
EntityName: 'Record Changes',
|
|
586
|
+
ExtraFilter: filter,
|
|
587
|
+
OrderBy: 'ChangedAt ASC',
|
|
588
|
+
Fields: ['RecordID', 'Type', 'ChangedAt'],
|
|
589
|
+
ResultType: 'simple',
|
|
590
|
+
}, contextUser);
|
|
591
|
+
if (!result.Success)
|
|
592
|
+
return [];
|
|
593
|
+
// Dedupe: keep only the latest change per RecordID.
|
|
594
|
+
// RecordChange.RecordID format is "fieldName|value" or "f1|v1||f2|v2" for composites.
|
|
595
|
+
// Normalize to just the value(s) so it matches CompanyIntegrationRecordMap.EntityRecordID.
|
|
596
|
+
const latestByRecord = new Map();
|
|
597
|
+
for (const r of result.Results) {
|
|
598
|
+
const normalizedID = this.NormalizeRecordChangeID(r.RecordID);
|
|
599
|
+
latestByRecord.set(normalizedID, { ...r, RecordID: normalizedID, Fields: {} });
|
|
600
|
+
}
|
|
601
|
+
// Load current field values for each changed record
|
|
602
|
+
const md = new Metadata();
|
|
603
|
+
const entityInfo = md.Entities.find(e => e.Name === entityMap.Entity);
|
|
604
|
+
const pkFieldName = entityInfo?.FirstPrimaryKey?.Name ?? 'ID';
|
|
605
|
+
for (const [recordID, change] of latestByRecord) {
|
|
606
|
+
if (change.Type === 'Delete')
|
|
607
|
+
continue; // No fields to load for deletes
|
|
608
|
+
try {
|
|
609
|
+
const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
|
|
610
|
+
const loaded = await entity.InnerLoad(new CompositeKey([{ FieldName: pkFieldName, Value: recordID }]));
|
|
611
|
+
if (loaded) {
|
|
612
|
+
change.Fields = entity.GetAll();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
// Record may have been deleted between change detection and load
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return [...latestByRecord.values()];
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Loads ALL records from an MJ entity for full push sync.
|
|
623
|
+
* For each record, checks the record map to determine if it's a Create (no external ID)
|
|
624
|
+
* or Update (has external ID) in the external system.
|
|
625
|
+
*/
|
|
626
|
+
async LoadAllMJRecords(entityMap, companyIntegration, contextUser) {
|
|
627
|
+
const rv = new RunView();
|
|
628
|
+
// Load all records from the MJ entity
|
|
629
|
+
const allResult = await rv.RunView({
|
|
630
|
+
EntityName: entityMap.Entity,
|
|
631
|
+
ResultType: 'simple',
|
|
632
|
+
}, contextUser);
|
|
633
|
+
if (!allResult.Success || allResult.Results.length === 0)
|
|
634
|
+
return [];
|
|
635
|
+
// Load existing record maps to know which records already exist externally
|
|
636
|
+
const mapResult = await rv.RunView({
|
|
637
|
+
EntityName: 'MJ: Company Integration Record Maps',
|
|
638
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegration.ID}' AND EntityID='${entityMap.EntityID}'`,
|
|
639
|
+
Fields: ['EntityRecordID', 'ExternalSystemRecordID'],
|
|
640
|
+
ResultType: 'simple',
|
|
641
|
+
}, contextUser);
|
|
642
|
+
const existingMaps = new Map();
|
|
643
|
+
if (mapResult.Success) {
|
|
644
|
+
for (const m of mapResult.Results) {
|
|
645
|
+
existingMaps.set(m.EntityRecordID, m.ExternalSystemRecordID);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const md = new Metadata();
|
|
649
|
+
const entityInfo = md.Entities.find(e => e.Name === entityMap.Entity);
|
|
650
|
+
const pkFieldName = entityInfo?.FirstPrimaryKey?.Name ?? 'ID';
|
|
651
|
+
const now = new Date().toISOString();
|
|
652
|
+
return allResult.Results.map(record => {
|
|
653
|
+
const recordID = String(record[pkFieldName] ?? '');
|
|
654
|
+
return {
|
|
655
|
+
RecordID: recordID,
|
|
656
|
+
Type: existingMaps.has(recordID) ? 'Update' : 'Create',
|
|
657
|
+
ChangedAt: now,
|
|
658
|
+
Fields: record,
|
|
659
|
+
};
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
/** Pushes a single changed MJ record to the external system. */
|
|
663
|
+
async PushSingleRecord(change, config, entityMap, pushFieldMaps, result, contextUser) {
|
|
664
|
+
// Reverse-map MJ fields to external fields
|
|
665
|
+
const externalAttributes = {};
|
|
666
|
+
for (const fm of pushFieldMaps) {
|
|
667
|
+
const mjValue = change.Fields[fm.DestinationFieldName];
|
|
668
|
+
if (mjValue !== undefined && mjValue !== null) {
|
|
669
|
+
externalAttributes[fm.SourceFieldName] = mjValue;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Nothing to push — all field values were null/undefined. Skip instead of erroring.
|
|
673
|
+
if (change.Type !== 'Delete' && Object.keys(externalAttributes).length === 0) {
|
|
674
|
+
result.RecordsSkipped++;
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
// Look up the ExternalID from the record map
|
|
678
|
+
const rv = new RunView();
|
|
679
|
+
const mapResult = await rv.RunView({
|
|
680
|
+
EntityName: 'MJ: Company Integration Record Maps',
|
|
681
|
+
ExtraFilter: `EntityRecordID='${change.RecordID}' AND CompanyIntegrationID='${config.companyIntegration.ID}'`,
|
|
682
|
+
Fields: ['ExternalSystemRecordID'],
|
|
683
|
+
MaxRows: 1,
|
|
684
|
+
ResultType: 'simple',
|
|
685
|
+
}, contextUser);
|
|
686
|
+
const externalID = mapResult.Success && mapResult.Results.length > 0
|
|
687
|
+
? mapResult.Results[0].ExternalSystemRecordID
|
|
688
|
+
: null;
|
|
689
|
+
const crudBase = {
|
|
690
|
+
CompanyIntegration: config.companyIntegration,
|
|
691
|
+
ObjectName: entityMap.ExternalObjectName,
|
|
692
|
+
ContextUser: contextUser,
|
|
693
|
+
};
|
|
694
|
+
if (change.Type === 'Delete' && externalID && config.connector.SupportsDelete) {
|
|
695
|
+
const delResult = await config.connector.DeleteRecord({ ...crudBase, ExternalID: externalID });
|
|
696
|
+
if (!delResult.Success) {
|
|
697
|
+
if (delResult.StatusCode === 403) {
|
|
698
|
+
console.warn(`[IntegrationEngine] Skipping delete — ${delResult.ErrorMessage}`);
|
|
699
|
+
result.RecordsSkipped++;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
throw new Error(delResult.ErrorMessage ?? 'Delete failed');
|
|
703
|
+
}
|
|
704
|
+
result.RecordsDeleted++;
|
|
705
|
+
}
|
|
706
|
+
else if (externalID) {
|
|
707
|
+
// Update existing external record
|
|
708
|
+
const updResult = await config.connector.UpdateRecord({
|
|
709
|
+
...crudBase, ExternalID: externalID, Attributes: externalAttributes,
|
|
710
|
+
});
|
|
711
|
+
if (!updResult.Success) {
|
|
712
|
+
if (updResult.StatusCode === 403) {
|
|
713
|
+
console.warn(`[IntegrationEngine] Skipping update — ${updResult.ErrorMessage}`);
|
|
714
|
+
result.RecordsSkipped++;
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
throw new Error(updResult.ErrorMessage ?? 'Update failed');
|
|
718
|
+
}
|
|
719
|
+
result.RecordsUpdated++;
|
|
720
|
+
}
|
|
721
|
+
else if (config.connector.SupportsCreate) {
|
|
722
|
+
// Create new external record
|
|
723
|
+
const createResult = await config.connector.CreateRecord({
|
|
724
|
+
...crudBase, Attributes: externalAttributes,
|
|
725
|
+
});
|
|
726
|
+
if (!createResult.Success) {
|
|
727
|
+
if (createResult.StatusCode === 403) {
|
|
728
|
+
console.warn(`[IntegrationEngine] Skipping create — ${createResult.ErrorMessage}`);
|
|
729
|
+
result.RecordsSkipped++;
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
throw new Error(createResult.ErrorMessage ?? 'Create failed');
|
|
733
|
+
}
|
|
734
|
+
// Persist the new external ID so future syncs update instead of re-creating
|
|
735
|
+
if (createResult.ExternalID) {
|
|
736
|
+
await this.SaveRecordMap(config.companyIntegration.ID, createResult.ExternalID, entityMap.EntityID, change.RecordID, contextUser);
|
|
737
|
+
}
|
|
738
|
+
result.RecordsCreated++;
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
result.RecordsSkipped++;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Full-sync orphan detection: finds MJ records that have a record map entry
|
|
746
|
+
* but were NOT returned by the external system during this full pull.
|
|
747
|
+
* These records were deleted externally and should be removed from MJ.
|
|
748
|
+
*/
|
|
749
|
+
async DeleteOrphanedRecords(companyIntegration, entityMap, fetchedExternalIDs, result, contextUser) {
|
|
750
|
+
const rv = new RunView();
|
|
751
|
+
const mapResult = await rv.RunView({
|
|
752
|
+
EntityName: 'MJ: Company Integration Record Maps',
|
|
753
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegration.ID}' ` +
|
|
754
|
+
`AND EntityID='${entityMap.EntityID}'`,
|
|
755
|
+
Fields: ['EntityRecordID', 'ExternalSystemRecordID'],
|
|
756
|
+
ResultType: 'simple',
|
|
757
|
+
}, contextUser);
|
|
758
|
+
if (!mapResult.Success)
|
|
759
|
+
return;
|
|
760
|
+
const orphans = mapResult.Results.filter(m => !fetchedExternalIDs.has(m.ExternalSystemRecordID));
|
|
761
|
+
if (orphans.length === 0)
|
|
762
|
+
return;
|
|
763
|
+
console.log(`[IntegrationEngine] Orphan detection for ${entityMap.ExternalObjectName}: ${orphans.length} records in MJ not found in external system`);
|
|
764
|
+
const md = new Metadata();
|
|
765
|
+
const entityInfo = md.Entities.find(e => e.Name === entityMap.Entity);
|
|
766
|
+
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
767
|
+
for (const orphan of orphans) {
|
|
768
|
+
try {
|
|
769
|
+
const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
|
|
770
|
+
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(orphan.EntityRecordID, pkFields));
|
|
771
|
+
if (!loaded) {
|
|
772
|
+
console.log(`[IntegrationEngine] Orphan ${orphan.EntityRecordID} already deleted from MJ`);
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
const deleted = await entity.Delete();
|
|
776
|
+
if (deleted) {
|
|
777
|
+
result.RecordsDeleted++;
|
|
778
|
+
console.log(`[IntegrationEngine] Deleted orphan ${entityMap.Entity} ${orphan.EntityRecordID} (external ${orphan.ExternalSystemRecordID} no longer exists)`);
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
782
|
+
console.warn(`[IntegrationEngine] Orphan delete blocked for ${entityMap.Entity} ${orphan.EntityRecordID} — ${reason}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
catch (err) {
|
|
786
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
787
|
+
console.warn(`[IntegrationEngine] Orphan delete failed for ${entityMap.Entity} ${orphan.EntityRecordID}: ${msg}`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Strips field-name prefixes from a RecordChange.RecordID to get the raw entity record ID.
|
|
793
|
+
* "hs_object_id|465950833372" → "465950833372"
|
|
794
|
+
* "contact_id|123||deal_id|456" → "123|456"
|
|
795
|
+
*/
|
|
796
|
+
NormalizeRecordChangeID(recordID) {
|
|
797
|
+
return recordID
|
|
798
|
+
.split('||')
|
|
799
|
+
.map(part => { const i = part.indexOf('|'); return i >= 0 ? part.substring(i + 1) : part; })
|
|
800
|
+
.join('|');
|
|
801
|
+
}
|
|
802
|
+
/** Returns an empty SyncResult. */
|
|
803
|
+
EmptyResult() {
|
|
804
|
+
return {
|
|
805
|
+
Success: true, RecordsProcessed: 0, RecordsCreated: 0, RecordsUpdated: 0,
|
|
806
|
+
RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0, Errors: [],
|
|
807
|
+
};
|
|
808
|
+
}
|
|
377
809
|
/**
|
|
378
810
|
* Emits a progress update via the callback.
|
|
379
811
|
*/
|
|
@@ -436,13 +868,16 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
436
868
|
result.RecordsCreated++;
|
|
437
869
|
break;
|
|
438
870
|
case 'Update':
|
|
439
|
-
await this.UpdateRecord(record, contextUser);
|
|
440
|
-
result.RecordsUpdated++;
|
|
871
|
+
await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser);
|
|
441
872
|
break;
|
|
442
|
-
case 'Delete':
|
|
443
|
-
await this.DeleteRecord(record, entityMap, contextUser);
|
|
444
|
-
|
|
873
|
+
case 'Delete': {
|
|
874
|
+
const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
|
|
875
|
+
if (didDelete)
|
|
876
|
+
result.RecordsDeleted++;
|
|
877
|
+
else
|
|
878
|
+
result.RecordsErrored++;
|
|
445
879
|
break;
|
|
880
|
+
}
|
|
446
881
|
case 'Skip':
|
|
447
882
|
result.RecordsSkipped++;
|
|
448
883
|
break;
|
|
@@ -463,15 +898,25 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
463
898
|
if (!saved) {
|
|
464
899
|
throw new Error(`Failed to create ${record.MJEntityName} record`);
|
|
465
900
|
}
|
|
466
|
-
// Use the
|
|
467
|
-
|
|
901
|
+
// Use the entity's actual PK (e.g. the UUID assigned by the DB) as the
|
|
902
|
+
// EntityRecordID in the record map, NOT the external ID. Storing the
|
|
903
|
+
// external ID as EntityRecordID caused UpdateRecord to fail to load the
|
|
904
|
+
// entity (UUID lookup with a HubSpot numeric ID) and fall back to
|
|
905
|
+
// CreateRecord, producing duplicates on every incremental sync.
|
|
906
|
+
const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
|
|
907
|
+
await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
|
|
468
908
|
}
|
|
469
909
|
/**
|
|
470
910
|
* Updates an existing MJ record with pre-write validation.
|
|
911
|
+
* If the record cannot be loaded (e.g. it was deleted or never fully created),
|
|
912
|
+
* falls back to CreateRecord (upsert behavior).
|
|
471
913
|
*/
|
|
472
|
-
async UpdateRecord(record, contextUser) {
|
|
914
|
+
async UpdateRecord(record, companyIntegration, entityMap, result, contextUser) {
|
|
473
915
|
if (!record.MatchedMJRecordID) {
|
|
474
|
-
|
|
916
|
+
// No matched ID — treat as a new record
|
|
917
|
+
await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
|
|
918
|
+
result.RecordsCreated++;
|
|
919
|
+
return;
|
|
475
920
|
}
|
|
476
921
|
const md = new Metadata();
|
|
477
922
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
@@ -479,16 +924,28 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
479
924
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
480
925
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
|
|
481
926
|
if (!loaded) {
|
|
482
|
-
|
|
927
|
+
// Record doesn't exist in DB — fall back to INSERT (upsert)
|
|
928
|
+
await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
|
|
929
|
+
result.RecordsCreated++;
|
|
930
|
+
return;
|
|
483
931
|
}
|
|
484
932
|
this.SetEntityFields(entity, record.MappedFields);
|
|
485
933
|
this.SetStandardIntegrationFields(entity, record);
|
|
934
|
+
// Skip unchanged records — if no field values actually changed after setting,
|
|
935
|
+
// don't write to DB. Uses MJ's built-in dirty tracking (zero custom comparison logic).
|
|
936
|
+
// Critical for connectors without server-side date filtering (e.g., YM) where every
|
|
937
|
+
// sync re-fetches all records. Without this, 50k+ records get re-written every run.
|
|
938
|
+
if (!entity.Dirty) {
|
|
939
|
+
result.RecordsSkipped++;
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
486
942
|
// A5: Pre-write validation
|
|
487
943
|
this.validateEntity(entity, record.MJEntityName);
|
|
488
944
|
const saved = await entity.Save();
|
|
489
945
|
if (!saved) {
|
|
490
946
|
throw new Error(`Failed to update ${record.MJEntityName} record ${record.MatchedMJRecordID}`);
|
|
491
947
|
}
|
|
948
|
+
result.RecordsUpdated++;
|
|
492
949
|
}
|
|
493
950
|
/**
|
|
494
951
|
* Runs Validate() on an entity if the method exists.
|
|
@@ -508,22 +965,26 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
508
965
|
*/
|
|
509
966
|
async DeleteRecord(record, entityMap, contextUser) {
|
|
510
967
|
if (!record.MatchedMJRecordID) {
|
|
511
|
-
|
|
968
|
+
console.log(`[IntegrationEngine] Skipping delete for ${record.ExternalRecord.ObjectType} ExternalID=${record.ExternalRecord.ExternalID} — no MJ record map entry (record was never synced)`);
|
|
969
|
+
return false;
|
|
512
970
|
}
|
|
513
971
|
if (entityMap.DeleteBehavior === 'DoNothing')
|
|
514
|
-
return;
|
|
972
|
+
return false;
|
|
515
973
|
const md = new Metadata();
|
|
516
974
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
517
975
|
const entityInfo = md.Entities.find(e => e.Name === record.MJEntityName);
|
|
518
976
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
519
977
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
|
|
520
978
|
if (!loaded) {
|
|
521
|
-
|
|
979
|
+
console.log(`[IntegrationEngine] Skipping delete for ${record.MJEntityName} ${record.MatchedMJRecordID} — record not found in MJ DB (may have been deleted already)`);
|
|
980
|
+
return false;
|
|
522
981
|
}
|
|
523
982
|
const deleted = await entity.Delete();
|
|
524
983
|
if (!deleted) {
|
|
525
|
-
|
|
984
|
+
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
985
|
+
console.warn(`[IntegrationEngine] Delete blocked for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
|
|
526
986
|
}
|
|
987
|
+
return deleted;
|
|
527
988
|
}
|
|
528
989
|
/**
|
|
529
990
|
* Builds a CompositeKey for a record lookup using the entity's PK field definitions.
|
|
@@ -589,7 +1050,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
589
1050
|
const md = new Metadata();
|
|
590
1051
|
const detail = await md.GetEntityObject('MJ: Company Integration Run Details', contextUser);
|
|
591
1052
|
detail.NewRecord();
|
|
592
|
-
detail.CompanyIntegrationRunID = run.
|
|
1053
|
+
detail.CompanyIntegrationRunID = run.ID;
|
|
593
1054
|
detail.EntityID = entityMap.EntityID;
|
|
594
1055
|
detail.RecordID = `Processed:${result.RecordsProcessed}`;
|
|
595
1056
|
detail.Action = result.RecordsCreated > 0 ? 'INSERT' : 'UPDATE';
|
|
@@ -616,7 +1077,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
616
1077
|
*/
|
|
617
1078
|
buildEntityMapResult(entityMap, mapResult, duration) {
|
|
618
1079
|
return {
|
|
619
|
-
EntityMapID: entityMap.
|
|
1080
|
+
EntityMapID: entityMap.ID,
|
|
620
1081
|
ExternalObjectName: entityMap.ExternalObjectName ?? '',
|
|
621
1082
|
EntityName: entityMap.Entity ?? '',
|
|
622
1083
|
Success: mapResult.RecordsErrored === 0,
|
|
@@ -684,7 +1145,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
684
1145
|
Event: event,
|
|
685
1146
|
Severity: severity,
|
|
686
1147
|
CompanyIntegrationID: run.CompanyIntegrationID,
|
|
687
|
-
RunID: run.
|
|
1148
|
+
RunID: run.ID,
|
|
688
1149
|
Subject: subject,
|
|
689
1150
|
Body: body,
|
|
690
1151
|
Result: result,
|
|
@@ -708,7 +1169,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
708
1169
|
Event: 'SyncFailed',
|
|
709
1170
|
Severity: 'Error',
|
|
710
1171
|
CompanyIntegrationID: run.CompanyIntegrationID,
|
|
711
|
-
RunID: run.
|
|
1172
|
+
RunID: run.ID,
|
|
712
1173
|
Subject: subject,
|
|
713
1174
|
Body: body,
|
|
714
1175
|
Result: result,
|