@memberjunction/integration-engine 5.37.0 → 5.39.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/ActionMetadataGenerator.d.ts +8 -1
- package/dist/ActionMetadataGenerator.d.ts.map +1 -1
- package/dist/ActionMetadataGenerator.js +22 -3
- package/dist/ActionMetadataGenerator.js.map +1 -1
- package/dist/AdaptiveConcurrency.d.ts +85 -0
- package/dist/AdaptiveConcurrency.d.ts.map +1 -0
- package/dist/AdaptiveConcurrency.js +148 -0
- package/dist/AdaptiveConcurrency.js.map +1 -0
- package/dist/BaseIntegrationConnector.d.ts +127 -3
- package/dist/BaseIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseIntegrationConnector.js +126 -11
- package/dist/BaseIntegrationConnector.js.map +1 -1
- package/dist/BaseRESTIntegrationConnector.d.ts +80 -15
- package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseRESTIntegrationConnector.js +314 -64
- package/dist/BaseRESTIntegrationConnector.js.map +1 -1
- package/dist/ConflictRecency.d.ts +24 -0
- package/dist/ConflictRecency.d.ts.map +1 -0
- package/dist/ConflictRecency.js +25 -0
- package/dist/ConflictRecency.js.map +1 -0
- package/dist/ContentHash.d.ts +28 -0
- package/dist/ContentHash.d.ts.map +1 -0
- package/dist/ContentHash.js +54 -0
- package/dist/ContentHash.js.map +1 -0
- package/dist/EnrichSchemaConstraints.d.ts +59 -0
- package/dist/EnrichSchemaConstraints.d.ts.map +1 -0
- package/dist/EnrichSchemaConstraints.js +168 -0
- package/dist/EnrichSchemaConstraints.js.map +1 -0
- package/dist/FieldMappingEngine.d.ts +22 -0
- package/dist/FieldMappingEngine.d.ts.map +1 -1
- package/dist/FieldMappingEngine.js +66 -5
- package/dist/FieldMappingEngine.js.map +1 -1
- package/dist/HashDiff.d.ts +68 -0
- package/dist/HashDiff.d.ts.map +1 -0
- package/dist/HashDiff.js +108 -0
- package/dist/HashDiff.js.map +1 -0
- package/dist/IntegrationActionGenerator.d.ts +93 -0
- package/dist/IntegrationActionGenerator.d.ts.map +1 -0
- package/dist/IntegrationActionGenerator.js +313 -0
- package/dist/IntegrationActionGenerator.js.map +1 -0
- package/dist/IntegrationConnectorCreationPipeline.d.ts +86 -0
- package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -0
- package/dist/IntegrationConnectorCreationPipeline.js +226 -0
- package/dist/IntegrationConnectorCreationPipeline.js.map +1 -0
- package/dist/IntegrationEngine.d.ts +199 -1
- package/dist/IntegrationEngine.d.ts.map +1 -1
- package/dist/IntegrationEngine.js +1592 -109
- package/dist/IntegrationEngine.js.map +1 -1
- package/dist/IntegrationSchemaSync.d.ts +82 -0
- package/dist/IntegrationSchemaSync.d.ts.map +1 -1
- package/dist/IntegrationSchemaSync.js +289 -42
- package/dist/IntegrationSchemaSync.js.map +1 -1
- package/dist/MatchEngine.d.ts.map +1 -1
- package/dist/MatchEngine.js +4 -0
- package/dist/MatchEngine.js.map +1 -1
- package/dist/RateLimiter.d.ts +117 -0
- package/dist/RateLimiter.d.ts.map +1 -0
- package/dist/RateLimiter.js +159 -0
- package/dist/RateLimiter.js.map +1 -0
- package/dist/SyncLogger.d.ts +106 -0
- package/dist/SyncLogger.d.ts.map +1 -0
- package/dist/SyncLogger.js +176 -0
- package/dist/SyncLogger.js.map +1 -0
- package/dist/WatermarkService.d.ts +36 -1
- package/dist/WatermarkService.d.ts.map +1 -1
- package/dist/WatermarkService.js +113 -3
- package/dist/WatermarkService.js.map +1 -1
- package/dist/index.d.ts +23 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +52 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -6
|
@@ -6,6 +6,13 @@ import { ConnectorFactory } from './ConnectorFactory.js';
|
|
|
6
6
|
import { FieldMappingEngine } from './FieldMappingEngine.js';
|
|
7
7
|
import { MatchEngine } from './MatchEngine.js';
|
|
8
8
|
import { WatermarkService } from './WatermarkService.js';
|
|
9
|
+
import { SyncLogger } from './SyncLogger.js';
|
|
10
|
+
import { CONTENT_HASH_COLUMN, computeContentHash } from './ContentHash.js';
|
|
11
|
+
import { partitionRecords, partitionRollupHash, diffPartitions, partitionKeyForIdentity } from './HashDiff.js';
|
|
12
|
+
import { RateLimiter } from './RateLimiter.js';
|
|
13
|
+
import { AdaptiveConcurrencyController, RunAdaptive } from './AdaptiveConcurrency.js';
|
|
14
|
+
import { mostRecentWinner } from './ConflictRecency.js';
|
|
15
|
+
import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
|
|
9
16
|
/** Default batch size for fetching records from external systems */
|
|
10
17
|
const DEFAULT_BATCH_SIZE = 200;
|
|
11
18
|
/**
|
|
@@ -44,18 +51,24 @@ export class SchemaNotGeneratedError extends Error {
|
|
|
44
51
|
}
|
|
45
52
|
}
|
|
46
53
|
/**
|
|
47
|
-
* Returns a SchemaNotGeneratedError if the given Save() failure message
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
54
|
+
* Returns a SchemaNotGeneratedError if the given Save() failure message matches the
|
|
55
|
+
* "CRUD routine doesn't exist yet" pattern for either dialect, otherwise null. When
|
|
56
|
+
* CodeGen hasn't created the spCreate/spUpdate/spDelete for an entity, BaseEntity.Save()
|
|
57
|
+
* returns false and the routine-not-found error lands in LatestResult.CompleteMessage:
|
|
58
|
+
* - SQL Server: `Could not find stored procedure '<schema>.<name>'`
|
|
59
|
+
* - PostgreSQL: `function <schema>.<name>(<args>) does not exist` (SQLSTATE 42883)
|
|
60
|
+
* The PG form is what a connector entity in a custom schema hits before its CRUD
|
|
61
|
+
* functions are generated, so it must be classified too (else the run produces
|
|
62
|
+
* per-record errors instead of one fail-fast SchemaNotGeneratedError).
|
|
53
63
|
*/
|
|
54
64
|
function detectSchemaNotGenerated(entityName, errorMessage) {
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
return
|
|
58
|
-
|
|
65
|
+
const sqlServer = errorMessage.match(/Could not find stored procedure '([^']+)'/i);
|
|
66
|
+
if (sqlServer)
|
|
67
|
+
return new SchemaNotGeneratedError(entityName, sqlServer[1]);
|
|
68
|
+
const postgres = errorMessage.match(/function\s+([^(\s]+)\s*\([^)]*\)\s+does not exist/i);
|
|
69
|
+
if (postgres)
|
|
70
|
+
return new SchemaNotGeneratedError(entityName, postgres[1]);
|
|
71
|
+
return null;
|
|
59
72
|
}
|
|
60
73
|
export class IntegrationEngine extends BaseSingleton {
|
|
61
74
|
constructor() {
|
|
@@ -65,6 +78,8 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
65
78
|
this.watermarkService = new WatermarkService();
|
|
66
79
|
/** Configurable maximum batch size. Connector batches exceeding this are truncated. */
|
|
67
80
|
this.MaxBatchSize = DEFAULT_BATCH_SIZE;
|
|
81
|
+
/** Per-integration request-spacing chain for the rate limiter (keyed by IntegrationID → last scheduled time). */
|
|
82
|
+
this._rateLimiters = new Map();
|
|
68
83
|
}
|
|
69
84
|
/** Returns the active provider — explicit override if set, otherwise the global default. */
|
|
70
85
|
get ProviderToUse() {
|
|
@@ -112,6 +127,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
112
127
|
EntityName: 'MJ: Company Integration Runs',
|
|
113
128
|
ExtraFilter: `Status='In Progress'`,
|
|
114
129
|
ResultType: 'entity_object',
|
|
130
|
+
BypassCache: true, // resume must see the live in-progress runs, not a stale cache
|
|
115
131
|
}, contextUser);
|
|
116
132
|
if (!orphanedRuns.Success || orphanedRuns.Results.length === 0) {
|
|
117
133
|
console.log('[IntegrationEngine] No orphaned syncs to resume');
|
|
@@ -122,25 +138,36 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
122
138
|
const companyIntegrationID = run.CompanyIntegrationID;
|
|
123
139
|
const runID = run.ID;
|
|
124
140
|
try {
|
|
125
|
-
// Find which entity
|
|
141
|
+
// Find which entity MAPS already completed SUCCESSFULLY in this run. We correlate
|
|
142
|
+
// by EntityMapID (parsed from the detail's RecordID, stamped by CreateRunDetail),
|
|
143
|
+
// not EntityID — two maps can target the same MJ Entity, so keying on EntityID
|
|
144
|
+
// could skip a still-pending sibling map. We also require IsSuccess=1: a map that
|
|
145
|
+
// completed WITH errors (RecordsErrored>0, no throw) must be re-attempted on resume,
|
|
146
|
+
// otherwise its errored records are silently abandoned.
|
|
126
147
|
const detailsResult = await rv.RunView({
|
|
127
148
|
EntityName: 'MJ: Company Integration Run Details',
|
|
128
149
|
ExtraFilter: `CompanyIntegrationRunID='${runID}'`,
|
|
129
|
-
Fields: ['
|
|
150
|
+
Fields: ['RecordID', 'IsSuccess'],
|
|
130
151
|
ResultType: 'simple',
|
|
131
152
|
}, contextUser);
|
|
132
|
-
const
|
|
153
|
+
const completedMapIDs = new Set();
|
|
133
154
|
if (detailsResult.Success) {
|
|
134
155
|
for (const d of detailsResult.Results) {
|
|
135
|
-
|
|
156
|
+
if (!d.IsSuccess)
|
|
157
|
+
continue; // completed-with-errors → re-attempt on resume
|
|
158
|
+
const m = /^EntityMap:([0-9a-fA-F-]+)\|/.exec(d.RecordID ?? '');
|
|
159
|
+
// Parse-miss falls open (map treated as not-completed → re-runs): at worst a
|
|
160
|
+
// redundant idempotent re-sync, never a silent skip.
|
|
161
|
+
if (m)
|
|
162
|
+
completedMapIDs.add(m[1].toLowerCase());
|
|
136
163
|
}
|
|
137
164
|
}
|
|
138
165
|
console.log(`[IntegrationEngine] Resuming run ${runID.substring(0, 8)}... ` +
|
|
139
166
|
`for ${companyIntegrationID.substring(0, 8)}... ` +
|
|
140
|
-
`(${
|
|
141
|
-
// Load config and filter to only remaining entity maps
|
|
167
|
+
`(${completedMapIDs.size} entity maps already completed)`);
|
|
168
|
+
// Load config and filter to only remaining entity maps (by map ID)
|
|
142
169
|
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser);
|
|
143
|
-
const remainingMaps = config.entityMaps.filter(em => !
|
|
170
|
+
const remainingMaps = config.entityMaps.filter(em => !completedMapIDs.has(em.ID.toLowerCase()));
|
|
144
171
|
if (remainingMaps.length === 0) {
|
|
145
172
|
console.log(`[IntegrationEngine] All entity maps completed for run ${runID.substring(0, 8)}, marking as Success`);
|
|
146
173
|
run.EndedAt = new Date();
|
|
@@ -232,47 +259,197 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
232
259
|
*/
|
|
233
260
|
async executeSyncInternal(companyIntegrationID, contextUser, triggerType, onProgress, onNotification, options, abortSignal) {
|
|
234
261
|
const startTime = Date.now();
|
|
262
|
+
const logger = new SyncLogger({ ciId: companyIntegrationID, integration: null });
|
|
263
|
+
logger.emit('sync.run.start', {
|
|
264
|
+
triggerType,
|
|
265
|
+
fullSync: options?.FullSync ?? false,
|
|
266
|
+
scheduledJobRunID: options?.ScheduledJobRunID ?? null,
|
|
267
|
+
entityMapIDsFilter: options?.EntityMapIDs ?? null,
|
|
268
|
+
syncDirectionOverride: options?.SyncDirection ?? null,
|
|
269
|
+
});
|
|
235
270
|
const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser, options);
|
|
271
|
+
logger.attachIntegrationName(config.companyIntegration.Integration);
|
|
272
|
+
logger.emit('sync.config.loaded', {
|
|
273
|
+
integration: config.companyIntegration.Integration,
|
|
274
|
+
integrationID: config.companyIntegration.IntegrationID,
|
|
275
|
+
entityMapsCount: config.entityMaps.length,
|
|
276
|
+
entityMaps: config.entityMaps.map(em => ({
|
|
277
|
+
ExternalObjectName: em.ExternalObjectName,
|
|
278
|
+
Entity: em.Entity,
|
|
279
|
+
SyncDirection: em.SyncDirection,
|
|
280
|
+
IsActive: em.SyncEnabled === true,
|
|
281
|
+
Priority: em.Priority ?? null,
|
|
282
|
+
})),
|
|
283
|
+
maxBatchSize: this.MaxBatchSize,
|
|
284
|
+
});
|
|
285
|
+
logger.emit('sync.connector.built', {
|
|
286
|
+
connectorClass: config.connector?.constructor?.name ?? null,
|
|
287
|
+
});
|
|
288
|
+
// IsActive gate (single authoritative engine-level enforcement). A deactivated
|
|
289
|
+
// CompanyIntegration must not sync regardless of which path triggered it — the GQL
|
|
290
|
+
// StartSync mutation, the scheduled-job driver, or any future caller all funnel
|
|
291
|
+
// through here. Gating BEFORE CreateRunRecord guarantees no orphan 'In Progress'
|
|
292
|
+
// run row is produced for a deactivated connector. IsActive is boolean | null;
|
|
293
|
+
// only an explicit false aborts (null/undefined = not gated, preserving behavior
|
|
294
|
+
// for connections predating the flag).
|
|
295
|
+
if (config.companyIntegration.IsActive === false) {
|
|
296
|
+
const message = 'Connector is deactivated (IsActive=false); sync not started';
|
|
297
|
+
logger.emit('sync.warning', { reason: 'deactivated', message });
|
|
298
|
+
return {
|
|
299
|
+
Success: false,
|
|
300
|
+
ErrorMessage: message,
|
|
301
|
+
RecordsProcessed: 0,
|
|
302
|
+
RecordsCreated: 0,
|
|
303
|
+
RecordsUpdated: 0,
|
|
304
|
+
RecordsDeleted: 0,
|
|
305
|
+
RecordsErrored: 0,
|
|
306
|
+
RecordsSkipped: 0,
|
|
307
|
+
Errors: [],
|
|
308
|
+
EntityMapResults: [],
|
|
309
|
+
Duration: Date.now() - startTime,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
236
312
|
const run = await this.CreateRunRecord(config.companyIntegration, triggerType, contextUser, options?.ScheduledJobRunID);
|
|
313
|
+
logger.attachRunId(run.ID);
|
|
314
|
+
// Durable, queryable, restart-surviving artifact stream for this sync. runID is
|
|
315
|
+
// the CompanyIntegrationRun.ID so the JSONL artifact cross-correlates with the run
|
|
316
|
+
// row. Exposed over GraphQL (IntegrationListRuns / IntegrationGetRun /
|
|
317
|
+
// IntegrationTailRunEvents). Construction is best-effort — a logging-dir problem
|
|
318
|
+
// must never block a sync.
|
|
319
|
+
const progress = this.createSyncProgressEmitter(run.ID, companyIntegrationID, config, triggerType, options, startTime);
|
|
320
|
+
if (progress) {
|
|
321
|
+
logger.attachEmitter(progress);
|
|
322
|
+
try {
|
|
323
|
+
progress.runStart('Sync run started');
|
|
324
|
+
}
|
|
325
|
+
catch { /* best-effort */ }
|
|
326
|
+
}
|
|
237
327
|
try {
|
|
238
|
-
const result = await this.ExecuteEntityMaps(config, run, contextUser, onProgress, abortSignal);
|
|
328
|
+
const result = await this.ExecuteEntityMaps(config, run, contextUser, onProgress, abortSignal, logger);
|
|
239
329
|
result.RunID = run.ID;
|
|
240
330
|
result.Duration = Date.now() - startTime;
|
|
241
331
|
if (result.RecordsErrored > 0) {
|
|
242
332
|
result.ErrorMessage = `Sync completed with ${result.RecordsErrored} error(s)`;
|
|
243
333
|
}
|
|
244
|
-
await this.FinalizeRun(run, result, contextUser, onNotification);
|
|
334
|
+
await this.FinalizeRun(run, result, contextUser, onNotification, abortSignal?.aborted);
|
|
245
335
|
const summary = this.buildSyncResultBody(config.companyIntegration.Integration, result);
|
|
336
|
+
logger.emit('sync.run.complete', {
|
|
337
|
+
success: result.Success && result.RecordsErrored === 0,
|
|
338
|
+
durationMs: result.Duration,
|
|
339
|
+
recordsProcessed: result.RecordsProcessed,
|
|
340
|
+
recordsCreated: result.RecordsCreated,
|
|
341
|
+
recordsUpdated: result.RecordsUpdated,
|
|
342
|
+
recordsDeleted: result.RecordsDeleted,
|
|
343
|
+
recordsSkipped: result.RecordsSkipped,
|
|
344
|
+
recordsErrored: result.RecordsErrored,
|
|
345
|
+
errorCount: result.Errors?.length ?? 0,
|
|
346
|
+
});
|
|
347
|
+
// A cancelled run returns normally (no throw) with abortSignal.aborted set — finalize it as
|
|
348
|
+
// 'cancelled' (exitReason='aborted'), NOT 'completed', so a stopped run is distinguishable.
|
|
349
|
+
await this.finalizeSyncProgress(progress, abortSignal?.aborted ? 'cancelled' : 'completed', result.ErrorMessage);
|
|
246
350
|
console.log(`[IntegrationEngine] Sync complete:\n${summary}`);
|
|
247
351
|
return result;
|
|
248
352
|
}
|
|
249
353
|
catch (err) {
|
|
354
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
355
|
+
logger.emit('sync.run.fail', { error: errMsg, durationMs: Date.now() - startTime });
|
|
356
|
+
await this.finalizeSyncProgress(progress, 'failed', errMsg);
|
|
250
357
|
await this.FailRun(run, err, contextUser, onNotification);
|
|
251
358
|
throw err;
|
|
252
359
|
}
|
|
253
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Builds the durable progress emitter for a sync run. Best-effort: returns
|
|
363
|
+
* undefined (and never throws) if the artifact store can't be initialized, so
|
|
364
|
+
* structured logging can never block a sync.
|
|
365
|
+
*/
|
|
366
|
+
createSyncProgressEmitter(runID, companyIntegrationID, config, triggerType, options, startTimeMs) {
|
|
367
|
+
try {
|
|
368
|
+
return new IntegrationProgressEmitter({
|
|
369
|
+
runID,
|
|
370
|
+
runKind: 'SyncRun',
|
|
371
|
+
integrationID: config.companyIntegration.IntegrationID ?? undefined,
|
|
372
|
+
companyIntegrationID,
|
|
373
|
+
triggerType: this.mapTriggerTypeForManifest(triggerType),
|
|
374
|
+
startedAt: new Date(startTimeMs).toISOString(),
|
|
375
|
+
context: {
|
|
376
|
+
integration: config.companyIntegration.Integration ?? undefined,
|
|
377
|
+
fullSync: options?.FullSync ?? false,
|
|
378
|
+
entityMapCount: config.entityMaps.length,
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** Maps the engine SyncTriggerType to the manifest's triggerType vocabulary. */
|
|
387
|
+
mapTriggerTypeForManifest(t) {
|
|
388
|
+
switch (t) {
|
|
389
|
+
case 'Scheduled': return 'Scheduled';
|
|
390
|
+
case 'Webhook': return 'Webhook';
|
|
391
|
+
default: return 'Manual';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/** Writes the terminal artifact result + flushes. Best-effort — never throws. */
|
|
395
|
+
async finalizeSyncProgress(progress, outcome, message) {
|
|
396
|
+
if (!progress)
|
|
397
|
+
return;
|
|
398
|
+
try {
|
|
399
|
+
switch (outcome) {
|
|
400
|
+
case 'completed':
|
|
401
|
+
await progress.complete(message ?? 'Sync run complete');
|
|
402
|
+
break;
|
|
403
|
+
case 'cancelled':
|
|
404
|
+
// A user/system abort stopped the run mid-flight. The persisted
|
|
405
|
+
// CompanyIntegrationRun has no 'Cancelled' status, so exitReason='aborted'
|
|
406
|
+
// on the artifact is the GQL-visible signal that distinguishes a stopped
|
|
407
|
+
// run from one that completed (partial state is still durable).
|
|
408
|
+
await progress.cancel(message ?? 'Sync cancelled by user');
|
|
409
|
+
break;
|
|
410
|
+
case 'failed':
|
|
411
|
+
await progress.fail(message ?? 'Sync run failed');
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
await progress.flush();
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
/* best-effort terminal write */
|
|
418
|
+
}
|
|
419
|
+
}
|
|
254
420
|
/**
|
|
255
421
|
* Loads all configuration needed for a sync run.
|
|
256
422
|
*/
|
|
257
423
|
async LoadRunConfiguration(companyIntegrationID, contextUser, options) {
|
|
258
424
|
const rv = new RunView();
|
|
425
|
+
// BypassCache on ALL three: this loads the live configuration a sync is about to ACT on — the
|
|
426
|
+
// CompanyIntegration toggles, the per-entity-map Configuration (partitionReconcile/Merkle, sync
|
|
427
|
+
// direction, priority), and the connector mapping. A sync MUST decide from committed state, never a
|
|
428
|
+
// stale filtered cache. Without this, a config the caller just wrote (e.g. enabling partition
|
|
429
|
+
// reconcile) is invisible to the very next run on a dialect whose filtered-cache invalidation lags
|
|
430
|
+
// (observed: PG read the pre-toggle entity map → fell to the Timestamp path → never wrote the
|
|
431
|
+
// ChangeToken rollup snapshot, while SQL Server saw the fresh config). Same committed-state rule as
|
|
432
|
+
// the match/record-map/idempotency reads.
|
|
259
433
|
const [ciResult, entityMapsResult, integrationsResult] = await rv.RunViews([
|
|
260
434
|
{
|
|
261
435
|
EntityName: 'MJ: Company Integrations',
|
|
262
436
|
ExtraFilter: `ID='${companyIntegrationID}'`,
|
|
263
437
|
MaxRows: 1,
|
|
264
438
|
ResultType: 'entity_object',
|
|
439
|
+
BypassCache: true,
|
|
265
440
|
},
|
|
266
441
|
{
|
|
267
442
|
EntityName: 'MJ: Company Integration Entity Maps',
|
|
268
443
|
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND SyncEnabled=1 AND Status='Active'`,
|
|
269
444
|
OrderBy: 'Priority ASC',
|
|
270
445
|
ResultType: 'entity_object',
|
|
446
|
+
BypassCache: true,
|
|
271
447
|
},
|
|
272
448
|
{
|
|
273
449
|
EntityName: 'MJ: Integrations',
|
|
274
450
|
ExtraFilter: '',
|
|
275
451
|
ResultType: 'entity_object',
|
|
452
|
+
BypassCache: true,
|
|
276
453
|
},
|
|
277
454
|
], contextUser);
|
|
278
455
|
const companyIntegration = ciResult.Results[0];
|
|
@@ -296,10 +473,51 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
296
473
|
entityMaps,
|
|
297
474
|
integration,
|
|
298
475
|
connector,
|
|
299
|
-
|
|
476
|
+
// Explicit caller request wins; otherwise honor the integration's periodic-reconcile cadence.
|
|
477
|
+
fullSync: options?.FullSync ?? await this.resolveScheduledFullSync(companyIntegration, contextUser),
|
|
300
478
|
syncDirection: options?.SyncDirection,
|
|
301
479
|
};
|
|
302
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* Periodic full-reconcile cadence (plan §C5: "periodic full reconcile for hard deletes"). When the
|
|
483
|
+
* caller did NOT explicitly request FullSync, an integration can opt into automatic periodic full
|
|
484
|
+
* reconciles via CompanyIntegration.Configuration {"fullSyncEvery": N}: every Nth completed run
|
|
485
|
+
* (and the first) does a full fetch + orphan/delete-detection instead of a watermark-incremental
|
|
486
|
+
* pull, so hard-deletes upstream are reclaimed on a schedule without the caller tracking cadence.
|
|
487
|
+
* Zero cost when unset (no DB read); returns false on N<=1 / unset / any error (incremental — no
|
|
488
|
+
* behavior change).
|
|
489
|
+
*/
|
|
490
|
+
async resolveScheduledFullSync(companyIntegration, contextUser) {
|
|
491
|
+
try {
|
|
492
|
+
const raw = companyIntegration.Configuration;
|
|
493
|
+
if (!raw)
|
|
494
|
+
return false;
|
|
495
|
+
const parsed = JSON.parse(raw);
|
|
496
|
+
const every = Number(parsed.fullSyncEvery);
|
|
497
|
+
if (!Number.isFinite(every) || every <= 1)
|
|
498
|
+
return false;
|
|
499
|
+
const rv = new RunView();
|
|
500
|
+
const runs = await rv.RunView({
|
|
501
|
+
EntityName: 'MJ: Company Integration Runs',
|
|
502
|
+
// 'Success' is the only terminal "this run actually completed a reconcile" state the
|
|
503
|
+
// engine ever writes (FinalizeRun / the resume path). The CompanyIntegrationRun Status
|
|
504
|
+
// value list is Pending/In Progress/Success/Failed (no 'Completed'), so filtering on a
|
|
505
|
+
// value the engine never writes made this count ALWAYS 0 → every scheduled run forced
|
|
506
|
+
// to a full sync (0 % every === 0). 'Success' restores the intended 1-in-N cadence.
|
|
507
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegration.ID}' AND Status='Success'`,
|
|
508
|
+
Fields: ['ID'],
|
|
509
|
+
ResultType: 'simple',
|
|
510
|
+
BypassCache: true, // full-vs-incremental decision needs the true completed-run count
|
|
511
|
+
}, contextUser);
|
|
512
|
+
if (!runs.Success)
|
|
513
|
+
return false;
|
|
514
|
+
// Every Nth completed run (and the first, when the count is 0) is a full reconcile.
|
|
515
|
+
return (runs.Results.length % Math.floor(every)) === 0;
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
303
521
|
/**
|
|
304
522
|
* Creates a new CompanyIntegrationRun record to track this sync.
|
|
305
523
|
*/
|
|
@@ -328,7 +546,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
328
546
|
/**
|
|
329
547
|
* Processes all entity maps, aggregating results with progress tracking.
|
|
330
548
|
*/
|
|
331
|
-
async ExecuteEntityMaps(config, run, contextUser, onProgress, abortSignal) {
|
|
549
|
+
async ExecuteEntityMaps(config, run, contextUser, onProgress, abortSignal, logger) {
|
|
332
550
|
const aggregate = {
|
|
333
551
|
Success: true,
|
|
334
552
|
RecordsProcessed: 0,
|
|
@@ -341,24 +559,56 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
341
559
|
EntityMapResults: [],
|
|
342
560
|
};
|
|
343
561
|
const totalMaps = config.entityMaps.length;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
562
|
+
let globalIndex = 0;
|
|
563
|
+
// Per-map processing. Extracted so it can run sequentially OR concurrently within a
|
|
564
|
+
// dependency layer. Aggregate mutations run when each promise resolves — atomic under
|
|
565
|
+
// single-threaded async, so concurrent maps in a layer are safe.
|
|
566
|
+
const processOne = async (entityMap) => {
|
|
567
|
+
if (abortSignal?.aborted)
|
|
568
|
+
return true;
|
|
569
|
+
const i = globalIndex++;
|
|
352
570
|
const mapStartTime = Date.now();
|
|
571
|
+
const direction = config.syncDirection ?? entityMap.SyncDirection ?? 'Pull';
|
|
572
|
+
logger?.emit('sync.entity-map.start', {
|
|
573
|
+
index: i,
|
|
574
|
+
total: totalMaps,
|
|
575
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
576
|
+
mjEntity: entityMap.Entity,
|
|
577
|
+
direction,
|
|
578
|
+
priority: entityMap.Priority ?? null,
|
|
579
|
+
});
|
|
353
580
|
try {
|
|
354
|
-
const mapResult = await this.ProcessSingleEntityMap(config, entityMap, run, contextUser, i, totalMaps, onProgress, abortSignal);
|
|
581
|
+
const mapResult = await this.ProcessSingleEntityMap(config, entityMap, run, contextUser, i, totalMaps, onProgress, abortSignal, logger);
|
|
355
582
|
this.MergeResult(aggregate, mapResult);
|
|
356
583
|
aggregate.EntityMapResults.push(this.buildEntityMapResult(entityMap, mapResult, Date.now() - mapStartTime));
|
|
584
|
+
logger?.emit('sync.entity-map.complete', {
|
|
585
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
586
|
+
mjEntity: entityMap.Entity,
|
|
587
|
+
direction,
|
|
588
|
+
success: mapResult.Success,
|
|
589
|
+
durationMs: Date.now() - mapStartTime,
|
|
590
|
+
recordsProcessed: mapResult.RecordsProcessed,
|
|
591
|
+
recordsCreated: mapResult.RecordsCreated,
|
|
592
|
+
recordsUpdated: mapResult.RecordsUpdated,
|
|
593
|
+
recordsDeleted: mapResult.RecordsDeleted,
|
|
594
|
+
recordsSkipped: mapResult.RecordsSkipped,
|
|
595
|
+
recordsErrored: mapResult.RecordsErrored,
|
|
596
|
+
});
|
|
597
|
+
this.checkSecondLayerEmpty(entityMap, mapResult, depGraph, processedByIoId, ioNameById, ioCategoryById, logger);
|
|
598
|
+
return mapResult.Success;
|
|
357
599
|
}
|
|
358
600
|
catch (err) {
|
|
359
601
|
const objName = entityMap.ExternalObjectName ?? entityMap.ID;
|
|
360
602
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
361
603
|
console.error(`[IntegrationEngine] Entity map '${objName}' failed: ${errMsg}`);
|
|
604
|
+
logger?.emit('sync.entity-map.complete', {
|
|
605
|
+
externalObjectName: objName,
|
|
606
|
+
mjEntity: entityMap.Entity,
|
|
607
|
+
direction,
|
|
608
|
+
success: false,
|
|
609
|
+
durationMs: Date.now() - mapStartTime,
|
|
610
|
+
error: errMsg,
|
|
611
|
+
});
|
|
362
612
|
aggregate.RecordsErrored++;
|
|
363
613
|
aggregate.Errors.push({
|
|
364
614
|
ExternalID: objName,
|
|
@@ -381,37 +631,341 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
381
631
|
RecordsSkipped: 0,
|
|
382
632
|
Duration: Date.now() - mapStartTime,
|
|
383
633
|
});
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
// Group maps into dependency layers (parents before children) via the IntegrationObject FK
|
|
638
|
+
// graph, then process layers in order. Layers run sequentially — a child never syncs before
|
|
639
|
+
// its parent. Within a layer the maps are mutually independent and run up to `concurrency`
|
|
640
|
+
// at a time. Default concurrency is 1 (sequential — unchanged behavior); opt in to
|
|
641
|
+
// parallelism via CompanyIntegration.Configuration {"syncConcurrency": N}.
|
|
642
|
+
const layers = this.buildEntityMapDependencyLayers(config, logger);
|
|
643
|
+
const concurrency = this.getSyncConcurrency(config);
|
|
644
|
+
// §7 smart-but-careful peak parallelization: an AIMD controller governs the in-flight cap
|
|
645
|
+
// PER LAYER — start at the configured syncConcurrency, ramp UP toward the connector's
|
|
646
|
+
// MaxConcurrencyHint on clean maps, cut on map failure. With no hint and default
|
|
647
|
+
// syncConcurrency=1, min=max=1 → strictly sequential (unchanged behavior). The per-request
|
|
648
|
+
// RateLimiter is the backstop that keeps the source within its real rate as parallelism rises.
|
|
649
|
+
const maxConcurrency = Math.max(concurrency, config.connector.MaxConcurrencyHint ?? concurrency);
|
|
650
|
+
const concController = new AdaptiveConcurrencyController({ start: concurrency, min: 1, max: maxConcurrency });
|
|
651
|
+
// Second-layer silent-empty detection state (see checkSecondLayerEmpty): a per-IO running
|
|
652
|
+
// record count + the FK dependency graph, so an association/dependent object that fetches
|
|
653
|
+
// ZERO records while it HAS parents is surfaced as a structured SyncWarning. Layers run
|
|
654
|
+
// parents-first, so a child's parents' counts are already recorded by the time it completes.
|
|
655
|
+
const depGraph = this.computeSelectedDependencyGraph(config);
|
|
656
|
+
const processedByIoId = new Map();
|
|
657
|
+
const ioNameById = new Map();
|
|
658
|
+
const ioCategoryById = new Map(); // for Category='Association' objects the FK graph can't see
|
|
659
|
+
if (depGraph) {
|
|
660
|
+
for (const io of this.GetIntegrationObjectsByIntegrationID(config.companyIntegration.IntegrationID) ?? []) {
|
|
661
|
+
ioNameById.set(io.ID.toUpperCase(), io.Name);
|
|
662
|
+
if (io.Category)
|
|
663
|
+
ioCategoryById.set(io.ID.toUpperCase(), io.Category);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
for (const layer of layers) {
|
|
667
|
+
if (abortSignal?.aborted) {
|
|
668
|
+
console.log(`[IntegrationEngine] Sync cancelled (${globalIndex}/${totalMaps} maps processed)`);
|
|
669
|
+
aggregate.Success = false;
|
|
670
|
+
aggregate.ErrorMessage = 'Sync cancelled by user';
|
|
671
|
+
break;
|
|
384
672
|
}
|
|
673
|
+
await RunAdaptive(layer, async (m) => {
|
|
674
|
+
if (abortSignal?.aborted)
|
|
675
|
+
return { ok: true, throttled: false };
|
|
676
|
+
const ok = await processOne(m);
|
|
677
|
+
// A data failure (FK/validation/transform) must NOT cut the in-flight cap — only a
|
|
678
|
+
// real source throttle should, and the per-request RateLimiter already backs off on
|
|
679
|
+
// 429s. We have no clean per-map 429 signal here (fetch 429s break the loop), so we
|
|
680
|
+
// never mark `throttled` from a plain failure; concurrency only ramps UP on success.
|
|
681
|
+
return { ok, throttled: false };
|
|
682
|
+
}, concController);
|
|
385
683
|
}
|
|
386
684
|
return aggregate;
|
|
387
685
|
}
|
|
686
|
+
/**
|
|
687
|
+
* Groups the run's entity maps into dependency layers (parents before children) using the
|
|
688
|
+
* IntegrationObject FK graph (RelatedIntegrationObjectID). Layer 0 = roots (no FK dependency on
|
|
689
|
+
* another selected object); layer N depends only on layers < N. Maps within a layer are
|
|
690
|
+
* mutually independent and safe to run concurrently. Falls back to a single layer (all maps,
|
|
691
|
+
* original order) if the graph can't be resolved — preserving current behavior. Stable: the
|
|
692
|
+
* original config order is preserved within each layer.
|
|
693
|
+
*/
|
|
694
|
+
buildEntityMapDependencyLayers(config, logger) {
|
|
695
|
+
const maps = config.entityMaps;
|
|
696
|
+
if (maps.length <= 1)
|
|
697
|
+
return [maps];
|
|
698
|
+
try {
|
|
699
|
+
const graph = this.computeSelectedDependencyGraph(config);
|
|
700
|
+
if (!graph)
|
|
701
|
+
return [maps];
|
|
702
|
+
const { mapToIoId, parentsByIoId } = graph;
|
|
703
|
+
const selectedIoIds = new Set(parentsByIoId.keys());
|
|
704
|
+
// Kahn layering: an IO is ready when all its (selected) parents are already placed.
|
|
705
|
+
const ioLayer = new Map();
|
|
706
|
+
const remaining = new Set(selectedIoIds);
|
|
707
|
+
let layerNum = 0;
|
|
708
|
+
while (remaining.size > 0) {
|
|
709
|
+
const ready = [...remaining].filter(id => [...parentsByIoId.get(id)].every(p => !remaining.has(p)));
|
|
710
|
+
if (ready.length === 0) {
|
|
711
|
+
// cycle (or unresolved) — place the rest in the current layer so they still run,
|
|
712
|
+
// but SURFACE it: parent-before-child ordering is no longer guaranteed for them,
|
|
713
|
+
// so a second-layer object here may fetch before its parents are populated.
|
|
714
|
+
for (const id of remaining)
|
|
715
|
+
ioLayer.set(id, layerNum);
|
|
716
|
+
logger?.warning('dependency-graph', 'DEPENDENCY_LAYERING_DEGRADED', `Dependency cycle or unresolved parent among ${remaining.size} integration object(s) — parent-before-child ordering is NOT guaranteed for them; a second-layer object may fetch before its parents are populated (possible silent-empty).`, { unresolvedObjectCount: remaining.size });
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
for (const id of ready) {
|
|
720
|
+
ioLayer.set(id, layerNum);
|
|
721
|
+
remaining.delete(id);
|
|
722
|
+
}
|
|
723
|
+
layerNum++;
|
|
724
|
+
}
|
|
725
|
+
const byLayer = new Map();
|
|
726
|
+
for (const m of maps) {
|
|
727
|
+
const ioId = mapToIoId.get(m.ID);
|
|
728
|
+
const l = ioId !== undefined ? (ioLayer.get(ioId) ?? 0) : 0; // maps with no IO → treated as roots
|
|
729
|
+
if (!byLayer.has(l))
|
|
730
|
+
byLayer.set(l, []);
|
|
731
|
+
byLayer.get(l).push(m);
|
|
732
|
+
}
|
|
733
|
+
return [...byLayer.keys()].sort((a, b) => a - b).map(k => byLayer.get(k));
|
|
734
|
+
}
|
|
735
|
+
catch (err) {
|
|
736
|
+
// Building the dependency graph itself failed — we still run (single flat layer, original
|
|
737
|
+
// order) but parent-before-child ordering is NOT guaranteed, so SURFACE it rather than
|
|
738
|
+
// silently degrade (this path previously emitted nothing).
|
|
739
|
+
logger?.warning('dependency-graph', 'DEPENDENCY_LAYERING_DEGRADED', `Failed to build the integration dependency graph (${err instanceof Error ? err.message : String(err)}) — running all objects in a single flat layer; parent-before-child ordering is NOT guaranteed, so a second-layer object may fetch before its parents are populated.`, { fallback: 'single-layer' });
|
|
740
|
+
return [maps]; // any failure → single layer, original order
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Builds the selected-object FK dependency graph for a run: each selected IntegrationObject →
|
|
745
|
+
* the set of its SELECTED parent IntegrationObject IDs (via IntegrationObjectField.RelatedIntegrationObjectID).
|
|
746
|
+
* Shared by {@link buildEntityMapDependencyLayers} (parent-before-child ordering) and the
|
|
747
|
+
* second-layer silent-empty tripwire ({@link checkSecondLayerEmpty}). Returns null when the
|
|
748
|
+
* graph can't be resolved (no IOs, or none of the maps resolve to an IO) — callers then fall
|
|
749
|
+
* back to flat/original ordering.
|
|
750
|
+
*/
|
|
751
|
+
computeSelectedDependencyGraph(config) {
|
|
752
|
+
const ios = this.GetIntegrationObjectsByIntegrationID(config.companyIntegration.IntegrationID);
|
|
753
|
+
if (!ios || ios.length === 0)
|
|
754
|
+
return null;
|
|
755
|
+
const ioByName = new Map(); // lower(IO.Name) → IO.ID (upper)
|
|
756
|
+
for (const io of ios)
|
|
757
|
+
ioByName.set(io.Name.toLowerCase(), io.ID.toUpperCase());
|
|
758
|
+
const mapToIoId = new Map(); // entityMap.ID → IO.ID
|
|
759
|
+
const selectedIoIds = new Set();
|
|
760
|
+
for (const m of config.entityMaps) {
|
|
761
|
+
const ioId = m.ExternalObjectName ? ioByName.get(m.ExternalObjectName.toLowerCase()) : undefined;
|
|
762
|
+
if (ioId) {
|
|
763
|
+
mapToIoId.set(m.ID, ioId);
|
|
764
|
+
selectedIoIds.add(ioId);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (selectedIoIds.size === 0)
|
|
768
|
+
return null;
|
|
769
|
+
const parentsByIoId = new Map();
|
|
770
|
+
for (const ioId of selectedIoIds) {
|
|
771
|
+
const set = new Set();
|
|
772
|
+
for (const f of this.GetIntegrationObjectFields(ioId)) {
|
|
773
|
+
const parent = f.RelatedIntegrationObjectID?.toUpperCase();
|
|
774
|
+
if (parent && parent !== ioId && selectedIoIds.has(parent))
|
|
775
|
+
set.add(parent);
|
|
776
|
+
}
|
|
777
|
+
parentsByIoId.set(ioId, set);
|
|
778
|
+
}
|
|
779
|
+
return { mapToIoId, parentsByIoId };
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Surfaces the second-layer silent-empty case as a structured warning. An object that HAS FK
|
|
783
|
+
* parents (an association/dependent — a "second-layer" object) but fetched ZERO records is the
|
|
784
|
+
* classic silent fail: a successful run that quietly produced nothing because its parents
|
|
785
|
+
* weren't synced/mapped or the DAG ordered it too early. Rather than let that look identical to
|
|
786
|
+
* "genuinely no data", we emit a SyncWarning (with the parent record counts) so it is visible
|
|
787
|
+
* over GraphQL. Must be called AFTER the map completes; relies on parents (earlier layers)
|
|
788
|
+
* having already recorded their counts in `processedByIoId`.
|
|
789
|
+
*/
|
|
790
|
+
checkSecondLayerEmpty(entityMap, mapResult, depGraph, processedByIoId, ioNameById, ioCategoryById, logger) {
|
|
791
|
+
if (!depGraph)
|
|
792
|
+
return;
|
|
793
|
+
const ioId = depGraph.mapToIoId.get(entityMap.ID);
|
|
794
|
+
if (!ioId)
|
|
795
|
+
return;
|
|
796
|
+
processedByIoId.set(ioId, (processedByIoId.get(ioId) ?? 0) + mapResult.RecordsProcessed);
|
|
797
|
+
const parents = depGraph.parentsByIoId.get(ioId);
|
|
798
|
+
const category = ioCategoryById.get(ioId);
|
|
799
|
+
// An object is "second-layer" if the FK graph KNOWS it has parents OR it is an association
|
|
800
|
+
// by Category. The Category branch is essential: HubSpot (and similar) associations carry no
|
|
801
|
+
// FK edges (Relationships:[]), so the graph alone is blind to them — Category catches them.
|
|
802
|
+
const isSecondLayer = (parents !== undefined && parents.size > 0) || category === 'Association';
|
|
803
|
+
if (!isSecondLayer)
|
|
804
|
+
return; // not a second-layer object
|
|
805
|
+
if (mapResult.RecordsProcessed > 0)
|
|
806
|
+
return; // it filled in — nothing to flag
|
|
807
|
+
const parentRecordCounts = {};
|
|
808
|
+
if (parents)
|
|
809
|
+
for (const p of parents)
|
|
810
|
+
parentRecordCounts[ioNameById.get(p) ?? p] = processedByIoId.get(p) ?? 0;
|
|
811
|
+
const knownParents = Object.keys(parentRecordCounts);
|
|
812
|
+
const parentsHadRows = Object.values(parentRecordCounts).some(n => n > 0);
|
|
813
|
+
const why = knownParents.length === 0
|
|
814
|
+
? `No FK edges are declared for it (RelatedIntegrationObjectID is unset on its FK fields), so the sync DAG cannot order it after its parents — set those FK edges (or run C8 enrichment) so it orders correctly; until then it may fetch before its parents are populated (possible SILENT FAIL).`
|
|
815
|
+
: parentsHadRows
|
|
816
|
+
? `Its parents DID sync rows — likely a missing FK edge, missing/disabled entity-map, or wrong DAG order (possible SILENT FAIL).`
|
|
817
|
+
: `Its parent layer produced 0 rows — either there is genuinely nothing to associate, or the parents were not synced/mapped.`;
|
|
818
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'SECOND_LAYER_EMPTY', `'${entityMap.ExternalObjectName}' is a second-layer object (${knownParents.length ? `depends on ${knownParents.join(', ')}` : `category '${category}'`}) but fetched 0 records this run. ${why}`, { parentRecordCounts, parentsHadRows, category });
|
|
819
|
+
}
|
|
820
|
+
/** Opt-in sync concurrency from CompanyIntegration.Configuration; default 1 (sequential), clamped [1,16]. */
|
|
821
|
+
getSyncConcurrency(config) {
|
|
822
|
+
try {
|
|
823
|
+
const raw = config.companyIntegration.Configuration;
|
|
824
|
+
if (raw) {
|
|
825
|
+
const parsed = JSON.parse(raw);
|
|
826
|
+
const n = Number(parsed.syncConcurrency);
|
|
827
|
+
if (Number.isFinite(n) && n >= 1)
|
|
828
|
+
return Math.min(Math.floor(n), 16);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
catch { /* fall through */ }
|
|
832
|
+
return 1;
|
|
833
|
+
}
|
|
834
|
+
/** Runs `fn` over items with at most `cap` concurrent executions. cap<=1 → strictly sequential. */
|
|
835
|
+
async runBounded(items, cap, fn) {
|
|
836
|
+
if (cap <= 1) {
|
|
837
|
+
for (const it of items)
|
|
838
|
+
await fn(it);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
let next = 0;
|
|
842
|
+
const workers = Array.from({ length: Math.min(cap, items.length) }, async () => {
|
|
843
|
+
while (true) {
|
|
844
|
+
const i = next++;
|
|
845
|
+
if (i >= items.length)
|
|
846
|
+
return;
|
|
847
|
+
await fn(items[i]);
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
await Promise.all(workers);
|
|
851
|
+
}
|
|
852
|
+
/** Minimum ms between outbound requests for this integration (Integration.BatchRequestWaitTime; 0 = disabled). */
|
|
853
|
+
getRequestSpacingMs(config) {
|
|
854
|
+
try {
|
|
855
|
+
const integ = this.Base.GetIntegrationByID(config.companyIntegration.IntegrationID);
|
|
856
|
+
const ms = integ?.BatchRequestWaitTime ?? -1;
|
|
857
|
+
return ms > 0 ? ms : 0;
|
|
858
|
+
}
|
|
859
|
+
catch {
|
|
860
|
+
return 0;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Rate-limits outbound connector requests per integration, honoring
|
|
865
|
+
* Integration.BatchRequestWaitTime. No-op when the limit is unset/-1 (the default → zero
|
|
866
|
+
* behavior change). Requests are chained per integration so concurrent callers (opt-in
|
|
867
|
+
* parallel sync) are spaced apart too — the safety net that keeps parallelism within the
|
|
868
|
+
* vendor's rate limit.
|
|
869
|
+
*/
|
|
870
|
+
async rateLimit(config) {
|
|
871
|
+
await this.getRateLimiter(config).Acquire(config.companyIntegration.ID);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Adaptive token-bucket limiter for this company integration (plan.md §7 peak-aware rate
|
|
875
|
+
* limiting), lazily created and configured from the connector's RateLimitPolicy — or, when the
|
|
876
|
+
* connector declares none, from Integration.BatchRequestWaitTime (spacing → tokens/sec). Keyed by
|
|
877
|
+
* CompanyIntegrationID (NOT IntegrationID): source limits are per-credential, so two companies on
|
|
878
|
+
* the same vendor have independent budgets and must not share one bucket. {@link reportRateOutcome}
|
|
879
|
+
* feeds 429s/successes back so the rate auto-tunes (AIMD).
|
|
880
|
+
*/
|
|
881
|
+
getRateLimiter(config) {
|
|
882
|
+
const key = config.companyIntegration.ID;
|
|
883
|
+
let rl = this._rateLimiters.get(key);
|
|
884
|
+
if (!rl) {
|
|
885
|
+
const policy = config.connector.RateLimitPolicy;
|
|
886
|
+
const spacingMs = this.getRequestSpacingMs(config);
|
|
887
|
+
const tokensPerSec = policy?.TokensPerSec ?? (spacingMs > 0 ? 1000 / spacingMs : 10);
|
|
888
|
+
rl = new RateLimiter({
|
|
889
|
+
TokensPerSec: tokensPerSec,
|
|
890
|
+
// Floor Burst at 1 so a slow-spacing integration (fractional tokens/sec) still gets
|
|
891
|
+
// one immediate token instead of stalling ~1s on the very first request.
|
|
892
|
+
Burst: Math.max(1, policy?.Burst ?? Math.ceil(tokensPerSec)),
|
|
893
|
+
ThrottleBackoffFactor: policy?.ThrottleBackoffFactor,
|
|
894
|
+
});
|
|
895
|
+
this._rateLimiters.set(key, rl);
|
|
896
|
+
}
|
|
897
|
+
return rl;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Feed a fetch/write outcome back to the company integration's adaptive limiter. No
|
|
901
|
+
* `throttledErr` = a clean response → ramp the rate up; a rate-limit error → back off (honoring
|
|
902
|
+
* the connector's parsed Retry-After). Other errors are ignored (don't ramp, don't back off).
|
|
903
|
+
*/
|
|
904
|
+
reportRateOutcome(config, throttledErr) {
|
|
905
|
+
const key = config.companyIntegration.ID;
|
|
906
|
+
const rl = this._rateLimiters.get(key);
|
|
907
|
+
if (!rl)
|
|
908
|
+
return;
|
|
909
|
+
if (throttledErr === undefined) {
|
|
910
|
+
rl.ReportSuccess(key);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
rl.ReportThrottle(key, config.connector.ExtractRetryAfterMs(throttledErr));
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Feed a push/CRUD result to the limiter so the WRITE path tunes the rate too (the fetch path
|
|
917
|
+
* already does): a 2xx ramps the rate up, a 429 backs it off (honoring Retry-After). Otherwise
|
|
918
|
+
* leave the rate untouched.
|
|
919
|
+
*/
|
|
920
|
+
reportRateForCrud(config, result) {
|
|
921
|
+
if (result.Success) {
|
|
922
|
+
this.reportRateOutcome(config);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
if (result.StatusCode === 429)
|
|
926
|
+
this.reportRateOutcome(config, new Error(result.ErrorMessage ?? '429 rate limit'));
|
|
927
|
+
}
|
|
388
928
|
/**
|
|
389
929
|
* Processes a single entity map based on SyncDirection:
|
|
390
930
|
* - Pull: fetch from external → map → match → apply to MJ
|
|
391
931
|
* - Push: detect MJ changes → map → push to external
|
|
392
932
|
* - Bidirectional: pull first, then push
|
|
393
933
|
*/
|
|
394
|
-
async ProcessSingleEntityMap(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal) {
|
|
934
|
+
async ProcessSingleEntityMap(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
|
|
395
935
|
const direction = config.syncDirection ?? entityMap.SyncDirection ?? 'Pull';
|
|
396
936
|
if (direction === 'Pull') {
|
|
397
|
-
return this.ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
|
|
937
|
+
return this.ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger);
|
|
398
938
|
}
|
|
399
939
|
if (direction === 'Push') {
|
|
400
|
-
return this.ProcessPushSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
|
|
940
|
+
return this.ProcessPushSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger);
|
|
401
941
|
}
|
|
402
942
|
// Bidirectional: pull first, then push
|
|
403
|
-
const pullResult = await this.ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
|
|
404
|
-
const pushResult = await this.ProcessPushSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal);
|
|
943
|
+
const pullResult = await this.ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger);
|
|
944
|
+
const pushResult = await this.ProcessPushSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger);
|
|
405
945
|
this.MergeResult(pullResult, pushResult);
|
|
406
946
|
return pullResult;
|
|
407
947
|
}
|
|
408
948
|
/**
|
|
409
949
|
* Pull sync: fetch from external → map → match → validate → apply to MJ.
|
|
410
950
|
*/
|
|
411
|
-
async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal) {
|
|
951
|
+
async ProcessPullSync(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress, abortSignal, logger) {
|
|
412
952
|
const entityMapID = entityMap.ID;
|
|
413
953
|
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
414
954
|
const watermark = await this.watermarkService.Load(entityMapID, contextUser, 'Pull');
|
|
955
|
+
logger?.emit('sync.entity-map.start', {
|
|
956
|
+
phase: 'pull-detail',
|
|
957
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
958
|
+
fieldMapsCount: fieldMaps.length,
|
|
959
|
+
fieldMaps: fieldMaps.map(fm => ({
|
|
960
|
+
SourceField: fm.SourceFieldName,
|
|
961
|
+
DestField: fm.DestinationFieldName,
|
|
962
|
+
Direction: fm.Direction,
|
|
963
|
+
IsKey: fm.IsKeyField,
|
|
964
|
+
})),
|
|
965
|
+
initialWatermark: watermark?.WatermarkValue ?? null,
|
|
966
|
+
watermarkType: watermark?.WatermarkType ?? null,
|
|
967
|
+
fullSync: config.fullSync,
|
|
968
|
+
});
|
|
415
969
|
// A6: Validate watermark before using it — skip entirely when FullSync requested
|
|
416
970
|
let initialWatermark = config.fullSync ? null : (watermark?.WatermarkValue ?? null);
|
|
417
971
|
if (initialWatermark && watermark) {
|
|
@@ -421,6 +975,53 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
421
975
|
initialWatermark = null;
|
|
422
976
|
}
|
|
423
977
|
}
|
|
978
|
+
// §8a keyset checkpoint-resume: a connector that declares a StableOrderingKey for this object
|
|
979
|
+
// scans by a monotonic seek key (`WHERE <key> > AfterKey ORDER BY <key>`) instead of a timestamp
|
|
980
|
+
// watermark. For those, an INTERRUPTED scan persists its last ordering key on the Pull watermark
|
|
981
|
+
// record (WatermarkType='Cursor'); a clean scan clears it. Resuming from that key is provably
|
|
982
|
+
// safe — the connector re-fetches only `key > AfterKey`, so no record is skipped and any boundary
|
|
983
|
+
// overlap is absorbed by the idempotent upsert (and content-hash keeps unchanged rows write-free).
|
|
984
|
+
// Entirely dormant for non-keyset connectors (StableOrderingKey === null), so the existing
|
|
985
|
+
// timestamp path is untouched.
|
|
986
|
+
//
|
|
987
|
+
// KNOWN LIMITATION (intentional, documented): declaring a StableOrderingKey forces
|
|
988
|
+
// `initialWatermark = null` below — i.e. this engine treats "has a stable ordering key" and "uses
|
|
989
|
+
// a timestamp watermark" as MUTUALLY EXCLUSIVE. They are actually orthogonal (watermark = the
|
|
990
|
+
// *what-to-fetch* incremental filter; keyset = the *where-am-I* resume position), and the clean
|
|
991
|
+
// future shape is two independent connector signals the engine combines. Until then: a connector
|
|
992
|
+
// whose object DOES have a usable server-side date/timestamp incremental MUST NOT declare a
|
|
993
|
+
// StableOrderingKey for that object — doing so would null the watermark and silently turn every
|
|
994
|
+
// incremental sync into a full scan. (Concretely: HubSpot CRM objects use a date-watermark search
|
|
995
|
+
// and deliberately return null here; their >10k-window scale problem is solved connector-locally
|
|
996
|
+
// via keyset *within* the date filter, not by this engine path.) StableOrderingKey is the right
|
|
997
|
+
// tool only for objects with NO usable date watermark (pure repeated full scans).
|
|
998
|
+
const isKeysetConnector = config.connector.StableOrderingKey(entityMap.ExternalObjectName) != null;
|
|
999
|
+
// §7 partition (Merkle) hash-diff reconcile: an OPT-IN mode for watermark-less objects. Instead of
|
|
1000
|
+
// re-applying every fetched record, accumulate them, bucket by stable identity, fold each bucket's
|
|
1001
|
+
// content hashes into one rollup, and compare against last sync's rollups — then deep-apply ONLY the
|
|
1002
|
+
// partitions whose rollup moved (changed/added). Unchanged partitions are proven-identical and
|
|
1003
|
+
// skipped entirely (no match lookup, no upsert). The rollup snapshot lives on the Pull watermark
|
|
1004
|
+
// record (WatermarkType='ChangeToken'); since the object is watermark-less, that field is free, so
|
|
1005
|
+
// null the timestamp watermark here (same orthogonality caveat as keyset). Default-off: a connector
|
|
1006
|
+
// with a real watermark is untouched.
|
|
1007
|
+
const partitionReconcile = !isKeysetConnector && this.isPartitionReconcileEnabled(entityMap);
|
|
1008
|
+
const partitionCount = this.partitionReconcileCount(entityMap);
|
|
1009
|
+
if (partitionReconcile) {
|
|
1010
|
+
initialWatermark = null; // the watermark record holds the rollup snapshot, not a timestamp
|
|
1011
|
+
}
|
|
1012
|
+
let resumeAfterKey;
|
|
1013
|
+
if (isKeysetConnector) {
|
|
1014
|
+
initialWatermark = null; // keyset connectors filter by the seek key, never a timestamp
|
|
1015
|
+
if (!config.fullSync && watermark?.WatermarkType === 'Cursor' && watermark.WatermarkValue) {
|
|
1016
|
+
resumeAfterKey = watermark.WatermarkValue;
|
|
1017
|
+
logger?.emit('sync.resume.keyset', {
|
|
1018
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
1019
|
+
resumeAfterKey,
|
|
1020
|
+
});
|
|
1021
|
+
console.log(`[IntegrationEngine] ${entityMap.ExternalObjectName}: resuming interrupted keyset scan ` +
|
|
1022
|
+
`from ordering key '${resumeAfterKey}' instead of re-scanning from the start.`);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
424
1025
|
const result = {
|
|
425
1026
|
Success: true,
|
|
426
1027
|
RecordsProcessed: 0,
|
|
@@ -433,15 +1034,23 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
433
1034
|
};
|
|
434
1035
|
let hasMore = true;
|
|
435
1036
|
let currentWatermark = initialWatermark;
|
|
1037
|
+
// Safe-floor watermark for data-loss prevention: the high-water mark of the last FULLY-CLEAN batch
|
|
1038
|
+
// (zero errored records). If any batch rolls back, we persist THIS instead of currentWatermark so the
|
|
1039
|
+
// next incremental re-fetches the window covering the failed records (the idempotent upsert + content-
|
|
1040
|
+
// hash skip then reconcile them). Without this, errored-then-rolled-back records fall below the saved
|
|
1041
|
+
// watermark and are never re-fetched — silent permanent data loss.
|
|
1042
|
+
let lastCleanWatermark = initialWatermark;
|
|
436
1043
|
let recordsInMap = 0;
|
|
437
1044
|
let currentPage;
|
|
438
1045
|
let currentOffset;
|
|
439
1046
|
let currentCursor;
|
|
1047
|
+
let currentAfterKey = resumeAfterKey; // §7 keyset/seek resume position (last-seen StableOrderingKey)
|
|
440
1048
|
let batchCount = 0;
|
|
441
1049
|
let previousBatchFingerprint;
|
|
442
1050
|
let fetchCompletedCleanly = true; // flipped to false if fetch aborted or errored mid-way
|
|
443
1051
|
const MAX_BATCHES_PER_MAP = 5000;
|
|
444
1052
|
const fetchedExternalIDs = new Set(); // Track all IDs seen during this pull for orphan detection
|
|
1053
|
+
const accumulatedMapped = []; // partition-reconcile mode: collect mapped records, apply post-loop
|
|
445
1054
|
while (hasMore) {
|
|
446
1055
|
if (abortSignal?.aborted) {
|
|
447
1056
|
console.log(`[IntegrationEngine] Sync cancelled for ${entityMap.ExternalObjectName} after ${recordsInMap} records — saving watermark`);
|
|
@@ -464,17 +1073,64 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
464
1073
|
CurrentPage: currentPage,
|
|
465
1074
|
CurrentOffset: currentOffset,
|
|
466
1075
|
CurrentCursor: currentCursor,
|
|
1076
|
+
AfterKeyValue: currentAfterKey ?? null, // §7 keyset/seek resume (connector opt-in)
|
|
467
1077
|
};
|
|
1078
|
+
logger?.emit('sync.fetch.batch.start', {
|
|
1079
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
1080
|
+
batchIndex: batchCount,
|
|
1081
|
+
watermarkValue: currentWatermark,
|
|
1082
|
+
page: currentPage ?? null,
|
|
1083
|
+
offset: currentOffset ?? null,
|
|
1084
|
+
cursor: currentCursor ?? null,
|
|
1085
|
+
batchSize: this.MaxBatchSize,
|
|
1086
|
+
});
|
|
468
1087
|
let batch;
|
|
1088
|
+
const fetchStart = Date.now();
|
|
469
1089
|
try {
|
|
1090
|
+
await this.rateLimit(config);
|
|
470
1091
|
batch = await config.connector.FetchChanges(ctx);
|
|
1092
|
+
this.reportRateOutcome(config); // clean fetch → ramp the adaptive rate back up
|
|
1093
|
+
// §10: connector type-driven post-processing hook (default no-op) — enforce/normalize
|
|
1094
|
+
// record values to their resolved formats before mapping + write.
|
|
1095
|
+
if (batch.Records.length > 0) {
|
|
1096
|
+
batch.Records = batch.Records.map(r => config.connector.PostProcessRecord(r));
|
|
1097
|
+
}
|
|
471
1098
|
}
|
|
472
1099
|
catch (fetchErr) {
|
|
473
1100
|
const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
|
1101
|
+
// A throttle (429 / rate-limit) backs the adaptive limiter off (honoring Retry-After);
|
|
1102
|
+
// other errors don't touch the rate.
|
|
1103
|
+
if (ClassifyError(fetchErr).Code === 'RATE_LIMIT_EXCEEDED')
|
|
1104
|
+
this.reportRateOutcome(config, fetchErr);
|
|
474
1105
|
console.error(`[IntegrationEngine] FetchChanges error for ${entityMap.ExternalObjectName}: ${errMsg}`);
|
|
1106
|
+
logger?.emit('sync.record.error', {
|
|
1107
|
+
phase: 'fetch',
|
|
1108
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
1109
|
+
batchIndex: batchCount,
|
|
1110
|
+
error: errMsg,
|
|
1111
|
+
});
|
|
475
1112
|
fetchCompletedCleanly = false;
|
|
476
1113
|
break;
|
|
477
1114
|
}
|
|
1115
|
+
logger?.emit('sync.fetch.batch.complete', {
|
|
1116
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
1117
|
+
batchIndex: batchCount,
|
|
1118
|
+
durationMs: Date.now() - fetchStart,
|
|
1119
|
+
recordCount: batch.Records.length,
|
|
1120
|
+
hasMore: batch.HasMore,
|
|
1121
|
+
newWatermark: batch.NewWatermarkValue ?? null,
|
|
1122
|
+
nextPage: batch.NextPage ?? null,
|
|
1123
|
+
nextOffset: batch.NextOffset ?? null,
|
|
1124
|
+
nextCursor: batch.NextCursor ?? null,
|
|
1125
|
+
});
|
|
1126
|
+
// Forward any non-fatal diagnostics the connector attached (e.g. a second-layer object
|
|
1127
|
+
// that found zero parents) into the structured artifact so they're visible over GraphQL
|
|
1128
|
+
// instead of a swallowed console.warn.
|
|
1129
|
+
if (batch.Warnings && batch.Warnings.length > 0) {
|
|
1130
|
+
for (const w of batch.Warnings) {
|
|
1131
|
+
logger?.warning(entityMap.ExternalObjectName ?? 'sync', w.Code, w.Message, w.Data);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
478
1134
|
// If the connector returned more records than MaxBatchSize, log it but never truncate —
|
|
479
1135
|
// all records are written, just in sub-batches to keep DB transactions manageable.
|
|
480
1136
|
if (batch.Records.length > this.MaxBatchSize) {
|
|
@@ -495,10 +1151,18 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
495
1151
|
fetchedExternalIDs.add(rec.ExternalID);
|
|
496
1152
|
}
|
|
497
1153
|
const mapped = this.fieldMappingEngine.Apply(batch.Records, fieldMaps, entityMap.Entity);
|
|
498
|
-
|
|
1154
|
+
// Partition (Merkle) reconcile defers match + apply: accumulate mapped records now; the
|
|
1155
|
+
// partition-diff + selective apply runs once after the full fetch (applyViaPartitionReconcile).
|
|
1156
|
+
if (partitionReconcile)
|
|
1157
|
+
accumulatedMapped.push(...mapped);
|
|
1158
|
+
const resolved = partitionReconcile
|
|
1159
|
+
? []
|
|
1160
|
+
: await this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser);
|
|
499
1161
|
const beforeApply = result.RecordsCreated + result.RecordsUpdated + result.RecordsSkipped + result.RecordsErrored;
|
|
1162
|
+
const erroredBeforeApply = result.RecordsErrored;
|
|
500
1163
|
try {
|
|
501
|
-
|
|
1164
|
+
if (!partitionReconcile)
|
|
1165
|
+
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger);
|
|
502
1166
|
}
|
|
503
1167
|
catch (applyErr) {
|
|
504
1168
|
if (applyErr instanceof SchemaNotGeneratedError) {
|
|
@@ -525,7 +1189,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
525
1189
|
throw applyErr;
|
|
526
1190
|
}
|
|
527
1191
|
const afterApply = result.RecordsCreated + result.RecordsUpdated + result.RecordsSkipped + result.RecordsErrored;
|
|
528
|
-
if (batch.Records.length > 0) {
|
|
1192
|
+
if (!partitionReconcile && batch.Records.length > 0) {
|
|
529
1193
|
const written = afterApply - beforeApply;
|
|
530
1194
|
const offsetInfo = currentOffset != null ? ` (offset ${currentOffset})` : '';
|
|
531
1195
|
console.log(`[IntegrationEngine] ${entityMap.ExternalObjectName}: wrote ${written} records to DB` +
|
|
@@ -544,13 +1208,58 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
544
1208
|
}
|
|
545
1209
|
if (batch.NewWatermarkValue) {
|
|
546
1210
|
currentWatermark = batch.NewWatermarkValue;
|
|
1211
|
+
// Only raise the safe floor when this batch applied with ZERO errors. A batch that rolled back
|
|
1212
|
+
// (RecordsErrored increased) must NOT advance the floor, so its records stay re-fetchable next run.
|
|
1213
|
+
if (result.RecordsErrored === erroredBeforeApply)
|
|
1214
|
+
lastCleanWatermark = currentWatermark;
|
|
547
1215
|
}
|
|
548
1216
|
currentPage = batch.NextPage;
|
|
549
1217
|
currentOffset = batch.NextOffset;
|
|
550
1218
|
currentCursor = batch.NextCursor;
|
|
1219
|
+
currentAfterKey = batch.NextAfterKeyValue ?? currentAfterKey; // §7 advance keyset position
|
|
1220
|
+
// §8a: persist a resumable checkpoint PERIODICALLY (every 25 batches, not every batch —
|
|
1221
|
+
// each emit is an fs.appendFile, so per-batch on a multi-thousand-batch object would flood
|
|
1222
|
+
// the artifact) so a crash/restart can resume near where it stopped (watermark for
|
|
1223
|
+
// incremental, AfterKey/cursor for keyset/no-watermark scans) rather than from scratch.
|
|
1224
|
+
if (batchCount % 25 === 0) {
|
|
1225
|
+
logger?.checkpoint(entityMap.ExternalObjectName ?? entityMap.ID, {
|
|
1226
|
+
watermark: currentWatermark ?? null,
|
|
1227
|
+
afterKey: currentAfterKey ?? null,
|
|
1228
|
+
page: currentPage ?? null,
|
|
1229
|
+
offset: currentOffset ?? null,
|
|
1230
|
+
cursor: currentCursor ?? null,
|
|
1231
|
+
batchIndex: batchCount,
|
|
1232
|
+
recordsInMap,
|
|
1233
|
+
});
|
|
1234
|
+
// Durable floor for a hard process kill: persist the keyset seek position to the
|
|
1235
|
+
// watermark record (the only per-map store the next run loads at startup). The
|
|
1236
|
+
// post-loop save below handles graceful early-exits precisely; this covers a SIGKILL
|
|
1237
|
+
// between graceful checkpoints, costing at most ~25 batches of re-fetch on resume.
|
|
1238
|
+
if (isKeysetConnector && currentAfterKey) {
|
|
1239
|
+
await this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
551
1242
|
hasMore = batch.HasMore === true; // Explicit boolean check — prevents truthy undefined from looping
|
|
552
1243
|
}
|
|
553
|
-
|
|
1244
|
+
// Partition (Merkle) reconcile: the full set is now accumulated — diff it against last sync's
|
|
1245
|
+
// rollups and deep-apply ONLY the changed/added partitions; the new rollup snapshot is persisted
|
|
1246
|
+
// inside. Runs only on a CLEAN fetch (a partial set would mis-skip partitions and lose updates).
|
|
1247
|
+
if (partitionReconcile && fetchCompletedCleanly) {
|
|
1248
|
+
await this.applyViaPartitionReconcile(accumulatedMapped, config, entityMap, fieldMaps, result, contextUser, logger, partitionCount);
|
|
1249
|
+
}
|
|
1250
|
+
if (fetchCompletedCleanly && partitionReconcile) {
|
|
1251
|
+
// The rollup snapshot (not a timestamp) was saved by applyViaPartitionReconcile above.
|
|
1252
|
+
result.WatermarkAfter = null;
|
|
1253
|
+
}
|
|
1254
|
+
else if (fetchCompletedCleanly && isKeysetConnector) {
|
|
1255
|
+
// A clean keyset scan covered the whole ordering range. These connectors have no
|
|
1256
|
+
// timestamp filter — the next scheduled sync re-seeks from the start (content-hash keeps
|
|
1257
|
+
// unchanged rows write-free) — so clear the resume marker rather than writing a timestamp
|
|
1258
|
+
// into it, which the restore logic would otherwise mis-read as a seek key.
|
|
1259
|
+
await this.watermarkService.ClearKeysetPosition(entityMapID, contextUser);
|
|
1260
|
+
result.WatermarkAfter = null;
|
|
1261
|
+
}
|
|
1262
|
+
else if (fetchCompletedCleanly) {
|
|
554
1263
|
// Save a watermark on every clean fetch, even when the connector
|
|
555
1264
|
// can't compute a NewWatermarkValue (empty result set, or a source
|
|
556
1265
|
// object with no modstamp column). Without the fallback, every
|
|
@@ -570,7 +1279,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
570
1279
|
// but at least a watermark row exists for bookkeeping.
|
|
571
1280
|
let finalWatermark;
|
|
572
1281
|
if (currentWatermark) {
|
|
573
|
-
|
|
1282
|
+
// On an incremental where records errored, hold the watermark to the last fully-clean batch's
|
|
1283
|
+
// value (the safe floor) so the next run re-fetches the failed window; the idempotent upsert +
|
|
1284
|
+
// content-hash skip reconcile it. A full sync always advances to "now".
|
|
1285
|
+
const incrementalWatermark = result.RecordsErrored > 0 ? (lastCleanWatermark ?? currentWatermark) : currentWatermark;
|
|
1286
|
+
finalWatermark = config.fullSync ? new Date().toISOString() : incrementalWatermark;
|
|
574
1287
|
}
|
|
575
1288
|
else {
|
|
576
1289
|
finalWatermark = new Date().toISOString();
|
|
@@ -578,11 +1291,18 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
578
1291
|
await this.watermarkService.Update(entityMapID, finalWatermark, contextUser, 'Pull');
|
|
579
1292
|
result.WatermarkAfter = finalWatermark;
|
|
580
1293
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
1294
|
+
else if (isKeysetConnector && currentAfterKey) {
|
|
1295
|
+
// The keyset scan stopped early (cancel / fetch error / safety limit). Persist the precise
|
|
1296
|
+
// last ordering key so the next run resumes the seek from here instead of restarting.
|
|
1297
|
+
await this.watermarkService.SaveKeysetPosition(entityMapID, currentAfterKey, contextUser);
|
|
1298
|
+
result.WatermarkAfter = currentAfterKey;
|
|
1299
|
+
}
|
|
1300
|
+
// Orphan detection: delete/tombstone MJ records whose external counterpart no longer exists.
|
|
1301
|
+
// Runs on a full sync OR a partition-reconcile (both fetch the COMPLETE set, so an MJ record
|
|
1302
|
+
// whose ExternalID isn't in fetchedExternalIDs is genuinely gone — even one inside an otherwise
|
|
1303
|
+
// unchanged/skipped partition). Only on a clean fetch — a partial set would delete live records.
|
|
1304
|
+
if ((config.fullSync || partitionReconcile) && fetchedExternalIDs.size > 0 && fetchCompletedCleanly) {
|
|
1305
|
+
await this.DeleteOrphanedRecords(config.companyIntegration, entityMap, fetchedExternalIDs, result, contextUser, logger);
|
|
586
1306
|
}
|
|
587
1307
|
await this.CreateRunDetail(run, entityMap, result, contextUser);
|
|
588
1308
|
return result;
|
|
@@ -594,7 +1314,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
594
1314
|
* Filters out changes made by the integration engine itself to prevent echo loops.
|
|
595
1315
|
* For each changed record, calls the connector's CreateRecord/UpdateRecord/DeleteRecord.
|
|
596
1316
|
*/
|
|
597
|
-
async ProcessPushSync(config, entityMap, run, contextUser, _entityMapIndex, _totalEntityMaps, _onProgress, _abortSignal) {
|
|
1317
|
+
async ProcessPushSync(config, entityMap, run, contextUser, _entityMapIndex, _totalEntityMaps, _onProgress, _abortSignal, logger) {
|
|
598
1318
|
const entityMapID = entityMap.ID;
|
|
599
1319
|
const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
|
|
600
1320
|
const pushWatermark = await this.watermarkService.Load(entityMapID, contextUser, 'Push');
|
|
@@ -602,6 +1322,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
602
1322
|
// Check connector write capability
|
|
603
1323
|
if (!config.connector.SupportsCreate && !config.connector.SupportsUpdate) {
|
|
604
1324
|
console.log(`[IntegrationEngine] Push skipped for ${entityMap.ExternalObjectName}: connector does not support writes`);
|
|
1325
|
+
logger?.emit('sync.push.candidates', {
|
|
1326
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
1327
|
+
skipped: true,
|
|
1328
|
+
reason: 'connector-does-not-support-writes',
|
|
1329
|
+
supportsCreate: config.connector.SupportsCreate,
|
|
1330
|
+
supportsUpdate: config.connector.SupportsUpdate,
|
|
1331
|
+
});
|
|
605
1332
|
return this.EmptyResult();
|
|
606
1333
|
}
|
|
607
1334
|
// Full push: load ALL records from the MJ entity. Incremental push: only changed records.
|
|
@@ -610,10 +1337,23 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
610
1337
|
: await this.LoadChangedMJRecords(entityMap, lastPushAt, contextUser);
|
|
611
1338
|
if (changedRecords.length === 0) {
|
|
612
1339
|
console.log(`[IntegrationEngine] Push: no changes for ${entityMap.ExternalObjectName} since ${lastPushAt ?? 'beginning'}`);
|
|
1340
|
+
logger?.emit('sync.push.candidates', {
|
|
1341
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
1342
|
+
changedCount: 0,
|
|
1343
|
+
fullSync: config.fullSync,
|
|
1344
|
+
sinceLastPushAt: lastPushAt,
|
|
1345
|
+
});
|
|
613
1346
|
await this.CreateRunDetail(run, entityMap, this.EmptyResult(), contextUser);
|
|
614
1347
|
return this.EmptyResult();
|
|
615
1348
|
}
|
|
616
1349
|
console.log(`[IntegrationEngine] Push: ${changedRecords.length} changed records for ${entityMap.ExternalObjectName}`);
|
|
1350
|
+
logger?.emit('sync.push.candidates', {
|
|
1351
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
1352
|
+
changedCount: changedRecords.length,
|
|
1353
|
+
fullSync: config.fullSync,
|
|
1354
|
+
sinceLastPushAt: lastPushAt,
|
|
1355
|
+
firstFew: changedRecords.slice(0, 5).map(c => ({ recordID: c.RecordID, changeType: c.Type, changedAt: c.ChangedAt ?? null })),
|
|
1356
|
+
});
|
|
617
1357
|
const result = {
|
|
618
1358
|
Success: true, RecordsProcessed: 0, RecordsCreated: 0, RecordsUpdated: 0,
|
|
619
1359
|
RecordsDeleted: 0, RecordsErrored: 0, RecordsSkipped: 0, Errors: [],
|
|
@@ -625,18 +1365,28 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
625
1365
|
console.log(`[IntegrationEngine] Push skipped for ${entityMap.ExternalObjectName}: no field maps with push direction`);
|
|
626
1366
|
return this.EmptyResult();
|
|
627
1367
|
}
|
|
628
|
-
|
|
1368
|
+
// Watermark safety: the push watermark must never advance PAST a record that FAILED to
|
|
1369
|
+
// push, or the next incremental push (which filters ChangedAt > watermark, strictly) would
|
|
1370
|
+
// permanently exclude that record and silently drop the local change. We therefore clamp the
|
|
1371
|
+
// advance strictly BELOW the earliest-failing record's ChangedAt — tracked by VALUE, not
|
|
1372
|
+
// array position, because the dedup that produces changedRecords does not guarantee the
|
|
1373
|
+
// carried ChangedAt is monotonic with order. ISO-8601 ChangedAt strings compare correctly
|
|
1374
|
+
// lexicographically, matching the SQL `ChangedAt > '...'` filter in LoadChangedMJRecords.
|
|
1375
|
+
let firstErrorChangeAt = null; // min ChangedAt among failed pushes
|
|
1376
|
+
const successfulChangeAts = [];
|
|
629
1377
|
for (const change of changedRecords) {
|
|
630
1378
|
result.RecordsProcessed++;
|
|
631
1379
|
try {
|
|
632
|
-
await this.PushSingleRecord(change, config, entityMap, pushFieldMaps, result, contextUser);
|
|
633
|
-
if (change.ChangedAt
|
|
634
|
-
|
|
635
|
-
}
|
|
1380
|
+
await this.PushSingleRecord(change, config, entityMap, pushFieldMaps, result, contextUser, logger);
|
|
1381
|
+
if (change.ChangedAt)
|
|
1382
|
+
successfulChangeAts.push(change.ChangedAt);
|
|
636
1383
|
}
|
|
637
1384
|
catch (err) {
|
|
638
1385
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
639
1386
|
result.RecordsErrored++;
|
|
1387
|
+
if (change.ChangedAt && (!firstErrorChangeAt || change.ChangedAt < firstErrorChangeAt)) {
|
|
1388
|
+
firstErrorChangeAt = change.ChangedAt; // earliest failure gates the watermark
|
|
1389
|
+
}
|
|
640
1390
|
result.Errors.push({
|
|
641
1391
|
ExternalID: change.RecordID,
|
|
642
1392
|
ChangeType: change.Type,
|
|
@@ -647,6 +1397,19 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
647
1397
|
});
|
|
648
1398
|
}
|
|
649
1399
|
}
|
|
1400
|
+
// Advance only to the MAX successful ChangedAt that is strictly BEFORE the earliest failure,
|
|
1401
|
+
// so any failed record (and anything at/after its timestamp) is re-selected next pass. If the
|
|
1402
|
+
// earliest (or only) change failed, latestChangeAt stays null → the watermark is not advanced
|
|
1403
|
+
// at all, guaranteeing retry. A success sharing an identical ChangedAt with a failure is also
|
|
1404
|
+
// re-selected next pass (strict `>` keeps the watermark below that timestamp) — harmless, a
|
|
1405
|
+
// re-push of an unchanged record is idempotent via the existing record-map/dirty-flag path.
|
|
1406
|
+
let latestChangeAt = null;
|
|
1407
|
+
for (const at of successfulChangeAts) {
|
|
1408
|
+
if (firstErrorChangeAt && at >= firstErrorChangeAt)
|
|
1409
|
+
continue;
|
|
1410
|
+
if (!latestChangeAt || at > latestChangeAt)
|
|
1411
|
+
latestChangeAt = at;
|
|
1412
|
+
}
|
|
650
1413
|
// Update push watermark
|
|
651
1414
|
if (latestChangeAt) {
|
|
652
1415
|
await this.watermarkService.Update(entityMapID, latestChangeAt, contextUser, 'Push');
|
|
@@ -725,6 +1488,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
725
1488
|
ExtraFilter: `CompanyIntegrationID='${companyIntegration.ID}' AND EntityID='${entityMap.EntityID}'`,
|
|
726
1489
|
Fields: ['EntityRecordID', 'ExternalSystemRecordID'],
|
|
727
1490
|
ResultType: 'simple',
|
|
1491
|
+
BypassCache: true, // sync decisions must reflect committed record-map state, not a stale cache
|
|
728
1492
|
}, contextUser);
|
|
729
1493
|
const existingMaps = new Map();
|
|
730
1494
|
if (mapResult.Success) {
|
|
@@ -747,7 +1511,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
747
1511
|
});
|
|
748
1512
|
}
|
|
749
1513
|
/** Pushes a single changed MJ record to the external system. */
|
|
750
|
-
async PushSingleRecord(change, config, entityMap, pushFieldMaps, result, contextUser) {
|
|
1514
|
+
async PushSingleRecord(change, config, entityMap, pushFieldMaps, result, contextUser, logger) {
|
|
751
1515
|
// Reverse-map MJ fields to external fields
|
|
752
1516
|
const externalAttributes = {};
|
|
753
1517
|
for (const fm of pushFieldMaps) {
|
|
@@ -769,6 +1533,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
769
1533
|
Fields: ['ExternalSystemRecordID'],
|
|
770
1534
|
MaxRows: 1,
|
|
771
1535
|
ResultType: 'simple',
|
|
1536
|
+
BypassCache: true, // write-back targets the committed external-id mapping
|
|
772
1537
|
}, contextUser);
|
|
773
1538
|
const externalID = mapResult.Success && mapResult.Results.length > 0
|
|
774
1539
|
? mapResult.Results[0].ExternalSystemRecordID
|
|
@@ -779,10 +1544,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
779
1544
|
ContextUser: contextUser,
|
|
780
1545
|
};
|
|
781
1546
|
if (change.Type === 'Delete' && externalID && config.connector.SupportsDelete) {
|
|
1547
|
+
await this.rateLimit(config);
|
|
782
1548
|
const delResult = await config.connector.DeleteRecord({ ...crudBase, ExternalID: externalID });
|
|
1549
|
+
this.reportRateForCrud(config, delResult);
|
|
783
1550
|
if (!delResult.Success) {
|
|
784
1551
|
if (delResult.StatusCode === 403) {
|
|
785
1552
|
console.warn(`[IntegrationEngine] Skipping delete — ${delResult.ErrorMessage}`);
|
|
1553
|
+
this.warnPushSkip(logger, entityMap, 'delete', delResult.ErrorMessage ?? 'forbidden (403)', { statusCode: 403, recordID: change.RecordID });
|
|
786
1554
|
result.RecordsSkipped++;
|
|
787
1555
|
return;
|
|
788
1556
|
}
|
|
@@ -791,13 +1559,31 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
791
1559
|
result.RecordsDeleted++;
|
|
792
1560
|
}
|
|
793
1561
|
else if (externalID) {
|
|
794
|
-
//
|
|
1562
|
+
// Pull-first 3-way combine: snapshot = common ancestor, MJ = ours, external = theirs.
|
|
1563
|
+
// Merge non-overlapping field changes; a same-field-both-sides change is a true conflict
|
|
1564
|
+
// resolved per the entity map's ConflictResolution policy. Safe fallbacks throughout:
|
|
1565
|
+
// no snapshot / no GetRecord / fetch failure → push the full attribute set (prior
|
|
1566
|
+
// last-write-wins behavior). Never throws, never blocks the push on infrastructure.
|
|
1567
|
+
const combine = await this.computePushCombine(change, config, entityMap, pushFieldMaps, externalAttributes, externalID, contextUser, logger);
|
|
1568
|
+
if (combine.action === 'skip') {
|
|
1569
|
+
result.RecordsSkipped++;
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
if (Object.keys(combine.attributes).length === 0) {
|
|
1573
|
+
// After the merge there is nothing for MJ to push (external already had our change,
|
|
1574
|
+
// or external-wins took every conflicting field). Not an error.
|
|
1575
|
+
result.RecordsSkipped++;
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
await this.rateLimit(config);
|
|
795
1579
|
const updResult = await config.connector.UpdateRecord({
|
|
796
|
-
...crudBase, ExternalID: externalID, Attributes:
|
|
1580
|
+
...crudBase, ExternalID: externalID, Attributes: combine.attributes,
|
|
797
1581
|
});
|
|
1582
|
+
this.reportRateForCrud(config, updResult);
|
|
798
1583
|
if (!updResult.Success) {
|
|
799
1584
|
if (updResult.StatusCode === 403) {
|
|
800
1585
|
console.warn(`[IntegrationEngine] Skipping update — ${updResult.ErrorMessage}`);
|
|
1586
|
+
this.warnPushSkip(logger, entityMap, 'update', updResult.ErrorMessage ?? 'forbidden (403)', { statusCode: 403, recordID: change.RecordID });
|
|
801
1587
|
result.RecordsSkipped++;
|
|
802
1588
|
return;
|
|
803
1589
|
}
|
|
@@ -807,12 +1593,15 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
807
1593
|
}
|
|
808
1594
|
else if (config.connector.SupportsCreate) {
|
|
809
1595
|
// Create new external record
|
|
1596
|
+
await this.rateLimit(config);
|
|
810
1597
|
const createResult = await config.connector.CreateRecord({
|
|
811
1598
|
...crudBase, Attributes: externalAttributes,
|
|
812
1599
|
});
|
|
1600
|
+
this.reportRateForCrud(config, createResult);
|
|
813
1601
|
if (!createResult.Success) {
|
|
814
1602
|
if (createResult.StatusCode === 403) {
|
|
815
1603
|
console.warn(`[IntegrationEngine] Skipping create — ${createResult.ErrorMessage}`);
|
|
1604
|
+
this.warnPushSkip(logger, entityMap, 'create', createResult.ErrorMessage ?? 'forbidden (403)', { statusCode: 403, recordID: change.RecordID });
|
|
816
1605
|
result.RecordsSkipped++;
|
|
817
1606
|
return;
|
|
818
1607
|
}
|
|
@@ -821,19 +1610,171 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
821
1610
|
// Persist the new external ID so future syncs update instead of re-creating
|
|
822
1611
|
if (createResult.ExternalID) {
|
|
823
1612
|
await this.SaveRecordMap(config.companyIntegration.ID, createResult.ExternalID, entityMap.EntityID, change.RecordID, contextUser);
|
|
1613
|
+
result.RecordsCreated++;
|
|
1614
|
+
}
|
|
1615
|
+
else {
|
|
1616
|
+
// Create succeeded but the connector returned NO ExternalID, so we cannot write a record map.
|
|
1617
|
+
// Counting this as a clean create is a trap: the next sync sees the MJ record as still-unmapped
|
|
1618
|
+
// and CREATES IT AGAIN externally — unbounded duplicates on every run. Surface it loudly and
|
|
1619
|
+
// count it errored (not created) so the duplicate risk is visible, never silent.
|
|
1620
|
+
this.warnPushSkip(logger, entityMap, 'create', 'create succeeded but the connector returned no ExternalID — no record map written; future syncs would duplicate this record. The connector must return CRUDResult.ExternalID on create.', { recordID: change.RecordID });
|
|
1621
|
+
result.RecordsErrored++;
|
|
824
1622
|
}
|
|
825
|
-
result.RecordsCreated++;
|
|
826
1623
|
}
|
|
827
1624
|
else {
|
|
1625
|
+
// A changed MJ record with no external counterpart, but the connector can't create it —
|
|
1626
|
+
// silently dropping the change would lose it, so surface it.
|
|
1627
|
+
this.warnPushSkip(logger, entityMap, 'create', 'connector does not support create; a new MJ record with no external counterpart was not pushed', { recordID: change.RecordID });
|
|
828
1628
|
result.RecordsSkipped++;
|
|
829
1629
|
}
|
|
830
1630
|
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Surface a SKIPPED push (forbidden write / unsupported op) as a structured SyncWarning so a
|
|
1633
|
+
* change that was NOT written to the external system is visible over GraphQL — not just a
|
|
1634
|
+
* console.warn + a silent RecordsSkipped bump. Mirrors the read-side silent-fail surfacing
|
|
1635
|
+
* (plan.md §8 "things go right, and when they don't it's loud" — applied to the write path).
|
|
1636
|
+
*/
|
|
1637
|
+
warnPushSkip(logger, entityMap, operation, message, data) {
|
|
1638
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'PUSH_SKIPPED', `Push ${operation} skipped — the change was NOT written to the external system: ${message}`, { operation, ...data });
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Pull-first 3-way combine for bidirectional push. Snapshot (last-synced external state) is
|
|
1642
|
+
* the common ancestor; MJ-current is "ours"; the re-fetched external record is "theirs".
|
|
1643
|
+
* - field only WE changed → push our value
|
|
1644
|
+
* - field only THEY changed → leave it (don't push; next pull brings it into MJ)
|
|
1645
|
+
* - both changed, same value → converged, skip
|
|
1646
|
+
* - both changed, different → true conflict → ConflictResolution policy
|
|
1647
|
+
* Returns the attribute subset MJ should actually push. Safe fallbacks: no snapshot / no
|
|
1648
|
+
* GetRecord support / fetch failure → push the full attribute set (prior behavior). Never throws.
|
|
1649
|
+
*/
|
|
1650
|
+
async computePushCombine(change, config, entityMap, pushFieldMaps, fullAttributes, externalID, contextUser, logger) {
|
|
1651
|
+
const snapRaw = change.Fields['__mj_integration_LastSyncedSnapshot'];
|
|
1652
|
+
if (typeof snapRaw !== 'string' || snapRaw.length === 0)
|
|
1653
|
+
return { action: 'proceed', attributes: fullAttributes };
|
|
1654
|
+
let base;
|
|
1655
|
+
try {
|
|
1656
|
+
base = JSON.parse(snapRaw);
|
|
1657
|
+
}
|
|
1658
|
+
catch {
|
|
1659
|
+
return { action: 'proceed', attributes: fullAttributes };
|
|
1660
|
+
}
|
|
1661
|
+
if (!config.connector.SupportsGet)
|
|
1662
|
+
return { action: 'proceed', attributes: fullAttributes };
|
|
1663
|
+
let ext;
|
|
1664
|
+
try {
|
|
1665
|
+
await this.rateLimit(config);
|
|
1666
|
+
ext = await config.connector.GetRecord({
|
|
1667
|
+
CompanyIntegration: config.companyIntegration,
|
|
1668
|
+
ObjectName: entityMap.ExternalObjectName,
|
|
1669
|
+
ExternalID: externalID,
|
|
1670
|
+
ContextUser: contextUser,
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
catch {
|
|
1674
|
+
return { action: 'proceed', attributes: fullAttributes }; // re-fetch failed → don't block push
|
|
1675
|
+
}
|
|
1676
|
+
if (!ext)
|
|
1677
|
+
return { action: 'proceed', attributes: fullAttributes };
|
|
1678
|
+
const policy = entityMap.ConflictResolution || 'DestWins';
|
|
1679
|
+
// MostRecent is a per-record decision: compare the MJ row's last update against the
|
|
1680
|
+
// external record's ModifiedAt once, up front. null = indeterminate (a timestamp is
|
|
1681
|
+
// missing/unparseable) → the conflict falls back to DestWins so it's never dropped.
|
|
1682
|
+
const mostRecentWinner = policy === 'MostRecent' ? this.resolveMostRecentWinner(change.Fields, ext) : null;
|
|
1683
|
+
const mjWinsConflict = policy === 'DestWins'
|
|
1684
|
+
|| (policy === 'MostRecent' && mostRecentWinner !== 'external');
|
|
1685
|
+
const toPush = {};
|
|
1686
|
+
const conflictFields = [];
|
|
1687
|
+
let manualConflict = false;
|
|
1688
|
+
for (const fm of pushFieldMaps) {
|
|
1689
|
+
const baseVal = base[fm.DestinationFieldName];
|
|
1690
|
+
const mjVal = change.Fields[fm.DestinationFieldName];
|
|
1691
|
+
const extVal = ext.Fields[fm.SourceFieldName];
|
|
1692
|
+
const mjChanged = !this.valuesEqual(mjVal, baseVal);
|
|
1693
|
+
const extChanged = !this.valuesEqual(extVal, baseVal);
|
|
1694
|
+
if (mjChanged && !extChanged) {
|
|
1695
|
+
toPush[fm.SourceFieldName] = mjVal; // only we changed → push ours
|
|
1696
|
+
}
|
|
1697
|
+
else if (mjChanged && extChanged && !this.valuesEqual(mjVal, extVal)) {
|
|
1698
|
+
conflictFields.push(fm.DestinationFieldName); // both changed, different → conflict
|
|
1699
|
+
if (policy === 'Manual') {
|
|
1700
|
+
manualConflict = true; // quarantine for a human
|
|
1701
|
+
}
|
|
1702
|
+
else if (mjWinsConflict) {
|
|
1703
|
+
toPush[fm.SourceFieldName] = mjVal; // DestWins, or MostRecent→MJ (incl. indeterminate)
|
|
1704
|
+
}
|
|
1705
|
+
// SourceWins, or MostRecent→external → leave external as-is (don't push this field)
|
|
1706
|
+
}
|
|
1707
|
+
// !mjChanged → nothing to push; both-changed-same → converged.
|
|
1708
|
+
}
|
|
1709
|
+
if (conflictFields.length > 0) {
|
|
1710
|
+
const resolution = policy === 'Manual' ? 'quarantined'
|
|
1711
|
+
: policy === 'SourceWins' ? 'external-wins'
|
|
1712
|
+
: policy === 'MostRecent' ? (mostRecentWinner === 'external' ? 'external-wins (most-recent)' : 'mj-wins (most-recent)')
|
|
1713
|
+
: 'mj-wins';
|
|
1714
|
+
logger?.emit('sync.record.conflict', {
|
|
1715
|
+
entity: entityMap.Entity,
|
|
1716
|
+
externalId: externalID,
|
|
1717
|
+
mjRecordId: change.RecordID,
|
|
1718
|
+
conflictFields,
|
|
1719
|
+
policy,
|
|
1720
|
+
resolution,
|
|
1721
|
+
});
|
|
1722
|
+
if (manualConflict) {
|
|
1723
|
+
await this.markConflictOnMJRecord(change.RecordID, entityMap, conflictFields, contextUser);
|
|
1724
|
+
return { action: 'skip', attributes: {} };
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
return { action: 'proceed', attributes: toPush };
|
|
1728
|
+
}
|
|
1729
|
+
/** Loose value equality for conflict comparison across JSON/string/number/bool/null shapes. */
|
|
1730
|
+
valuesEqual(a, b) {
|
|
1731
|
+
if (a === b)
|
|
1732
|
+
return true;
|
|
1733
|
+
if (a == null && b == null)
|
|
1734
|
+
return true;
|
|
1735
|
+
if (a == null || b == null)
|
|
1736
|
+
return false;
|
|
1737
|
+
return String(a) === String(b);
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* MostRecent conflict resolution: compares the MJ row's last-update time
|
|
1741
|
+
* (`__mj_UpdatedAt`) against the external record's `ModifiedAt`. Record-level recency
|
|
1742
|
+
* (most sources don't expose per-field timestamps), so the caller computes it once
|
|
1743
|
+
* and applies it to every conflicting field. Returns null when a timestamp is
|
|
1744
|
+
* missing/unparseable → caller falls back to DestWins (a conflict is never dropped).
|
|
1745
|
+
*/
|
|
1746
|
+
resolveMostRecentWinner(mjFields, ext) {
|
|
1747
|
+
return mostRecentWinner(mjFields['__mj_UpdatedAt'], ext.ModifiedAt);
|
|
1748
|
+
}
|
|
1749
|
+
/** Marks an MJ mirror record in-conflict (Manual resolution) via its standard sync columns. Best-effort. */
|
|
1750
|
+
async markConflictOnMJRecord(mjRecordID, entityMap, conflictFields, contextUser) {
|
|
1751
|
+
try {
|
|
1752
|
+
const md = this.ProviderToUse;
|
|
1753
|
+
const entity = await md.GetEntityObject(entityMap.Entity, contextUser);
|
|
1754
|
+
const entityInfo = md.EntityByName(entityMap.Entity);
|
|
1755
|
+
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
1756
|
+
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(mjRecordID, pkFields));
|
|
1757
|
+
if (!loaded)
|
|
1758
|
+
return;
|
|
1759
|
+
const fields = entity.Fields ?? [];
|
|
1760
|
+
const hasField = (n) => fields.some(f => f.Name === n);
|
|
1761
|
+
if (hasField('__mj_integration_SyncStatus'))
|
|
1762
|
+
entity.Set('__mj_integration_SyncStatus', 'Conflict');
|
|
1763
|
+
if (hasField('__mj_integration_SyncMessage')) {
|
|
1764
|
+
entity.Set('__mj_integration_SyncMessage', `Bidirectional conflict: external changed ${conflictFields.join(', ')} since last sync; awaiting manual resolution.`);
|
|
1765
|
+
}
|
|
1766
|
+
await entity.Save();
|
|
1767
|
+
}
|
|
1768
|
+
catch {
|
|
1769
|
+
// best-effort
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
831
1772
|
/**
|
|
832
1773
|
* Full-sync orphan detection: finds MJ records that have a record map entry
|
|
833
1774
|
* but were NOT returned by the external system during this full pull.
|
|
834
1775
|
* These records were deleted externally and should be removed from MJ.
|
|
835
1776
|
*/
|
|
836
|
-
async DeleteOrphanedRecords(companyIntegration, entityMap, fetchedExternalIDs, result, contextUser) {
|
|
1777
|
+
async DeleteOrphanedRecords(companyIntegration, entityMap, fetchedExternalIDs, result, contextUser, logger) {
|
|
837
1778
|
const rv = new RunView();
|
|
838
1779
|
const mapResult = await rv.RunView({
|
|
839
1780
|
EntityName: 'MJ: Company Integration Record Maps',
|
|
@@ -841,6 +1782,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
841
1782
|
`AND EntityID='${entityMap.EntityID}'`,
|
|
842
1783
|
Fields: ['EntityRecordID', 'ExternalSystemRecordID'],
|
|
843
1784
|
ResultType: 'simple',
|
|
1785
|
+
BypassCache: true, // orphan-sweep compares against committed record-map state
|
|
844
1786
|
}, contextUser);
|
|
845
1787
|
if (!mapResult.Success)
|
|
846
1788
|
return;
|
|
@@ -848,6 +1790,11 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
848
1790
|
if (orphans.length === 0)
|
|
849
1791
|
return;
|
|
850
1792
|
console.log(`[IntegrationEngine] Orphan detection for ${entityMap.ExternalObjectName}: ${orphans.length} records in MJ not found in external system`);
|
|
1793
|
+
// Surface delete-detection in the structured stream (previously console-only). The orphan
|
|
1794
|
+
// COUNT is already in the run counts via RecordsDeleted, but a dedicated warning makes a
|
|
1795
|
+
// large/unexpected count visible over GraphQL — the early signal of an incomplete upstream
|
|
1796
|
+
// fetch silently archiving live records.
|
|
1797
|
+
logger?.warning(entityMap.ExternalObjectName ?? entityMap.ID, 'ORPHANS_DETECTED', `${orphans.length} record(s) exist in MJ but were not returned by the external system on this full sync — they will be archived/deleted (delete-detection). A large or unexpected count can indicate an incomplete upstream fetch, so review before trusting the deletions.`, { orphanCount: orphans.length });
|
|
851
1798
|
const md = this.ProviderToUse;
|
|
852
1799
|
const entityInfo = md.EntityByName(entityMap.Entity);
|
|
853
1800
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
@@ -919,18 +1866,120 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
919
1866
|
ExtraFilter: `EntityMapID='${entityMapID}' AND Status='Active'`,
|
|
920
1867
|
OrderBy: 'Priority ASC',
|
|
921
1868
|
ResultType: 'entity_object',
|
|
1869
|
+
BypassCache: true, // field maps drive value mapping; a sync must see freshly-applied maps
|
|
922
1870
|
}, contextUser);
|
|
923
1871
|
return result.Success ? result.Results : [];
|
|
924
1872
|
}
|
|
1873
|
+
/** Parses the entity map's Configuration JSON (best-effort) for engine-side feature toggles. */
|
|
1874
|
+
parseEntityMapConfig(entityMap) {
|
|
1875
|
+
const raw = entityMap.Configuration;
|
|
1876
|
+
if (!raw)
|
|
1877
|
+
return null;
|
|
1878
|
+
try {
|
|
1879
|
+
return JSON.parse(raw);
|
|
1880
|
+
}
|
|
1881
|
+
catch {
|
|
1882
|
+
return null;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
/** Opt-in partition (Merkle) reconcile flag from the entity map Configuration (GQL-managed). */
|
|
1886
|
+
isPartitionReconcileEnabled(entityMap) {
|
|
1887
|
+
return this.parseEntityMapConfig(entityMap)?.partitionReconcile === true;
|
|
1888
|
+
}
|
|
1889
|
+
/** Partition count for the reconcile (default 256), from the entity map Configuration if set. */
|
|
1890
|
+
partitionReconcileCount(entityMap) {
|
|
1891
|
+
const n = Number(this.parseEntityMapConfig(entityMap)?.partitionCount);
|
|
1892
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 256;
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* Partition (Merkle) hash-diff reconcile (§7). Given ALL mapped records of a watermark-less object,
|
|
1896
|
+
* bucket them by stable identity, fold each bucket's content hashes into one order-independent rollup,
|
|
1897
|
+
* compare against last sync's rollups, and deep-apply (match + upsert) ONLY the partitions whose rollup
|
|
1898
|
+
* moved (changed/added). Partitions whose rollup matches are proven identical and skipped entirely
|
|
1899
|
+
* (counted as skipped — no match lookup, no write). The new snapshot is persisted ONLY after a clean
|
|
1900
|
+
* apply, so a failure re-reconciles from the prior snapshot next time. Deletes are handled by the
|
|
1901
|
+
* caller's orphan sweep over the full fetched-id set.
|
|
1902
|
+
*
|
|
1903
|
+
* MEMORY: this mode buffers the ENTIRE fetched set (`accumulatedMapped`) in RAM until this post-loop
|
|
1904
|
+
* apply, rather than streaming batch-by-batch — that's the cost of one complete cross-batch snapshot.
|
|
1905
|
+
* It is intended for watermark-less small/medium objects (the kind that re-fetch everything anyway,
|
|
1906
|
+
* e.g. a membership roster of tens of thousands). Don't enable it on a multi-million-row object; for
|
|
1907
|
+
* those, leave it off and rely on per-record content-hash (which streams).
|
|
1908
|
+
*/
|
|
1909
|
+
async applyViaPartitionReconcile(mappedRecords, config, entityMap, fieldMaps, result, contextUser, logger, partitionCount) {
|
|
1910
|
+
const entityMapID = entityMap.ID;
|
|
1911
|
+
const idOf = (r) => r.ExternalRecord.ExternalID;
|
|
1912
|
+
const partitionOf = (r) => partitionKeyForIdentity(idOf(r), partitionCount);
|
|
1913
|
+
// Bucket + rollup the just-fetched full set.
|
|
1914
|
+
const buckets = partitionRecords(mappedRecords, idOf, partitionOf);
|
|
1915
|
+
const newRollups = new Map();
|
|
1916
|
+
for (const [partition, recs] of buckets) {
|
|
1917
|
+
newRollups.set(partition, partitionRollupHash(recs, r => r.MappedFields));
|
|
1918
|
+
}
|
|
1919
|
+
// Diff against last sync's snapshot; only changed/added partitions need a deep apply. On a FORCED
|
|
1920
|
+
// FULL SYNC, treat the snapshot as empty so EVERY partition is re-applied: fullSync is the operator's
|
|
1921
|
+
// explicit "redo everything" — used to repair out-of-band drift (a manual DB edit, a changed field
|
|
1922
|
+
// map, a partition that failed to apply on a prior run). Honoring the snapshot on fullSync would
|
|
1923
|
+
// silently skip exactly the partitions the operator is trying to repair.
|
|
1924
|
+
const stored = config.fullSync
|
|
1925
|
+
? new Map()
|
|
1926
|
+
: await this.watermarkService.LoadPartitionRollups(entityMapID, contextUser);
|
|
1927
|
+
const diff = diffPartitions(newRollups, stored);
|
|
1928
|
+
const toApply = new Set([...diff.changed, ...diff.added]);
|
|
1929
|
+
let appliedRecords = 0;
|
|
1930
|
+
let skippedPartitions = 0;
|
|
1931
|
+
let skippedRecords = 0;
|
|
1932
|
+
try {
|
|
1933
|
+
for (const [partition, recs] of buckets) {
|
|
1934
|
+
if (!toApply.has(partition)) {
|
|
1935
|
+
skippedPartitions++;
|
|
1936
|
+
skippedRecords += recs.length;
|
|
1937
|
+
// Count as processed-and-skipped so the run invariant holds
|
|
1938
|
+
// (processed == created + updated + skipped + errored); these records never enter
|
|
1939
|
+
// ApplyRecords, which is the only other place RecordsProcessed is incremented.
|
|
1940
|
+
result.RecordsProcessed += recs.length;
|
|
1941
|
+
result.RecordsSkipped += recs.length;
|
|
1942
|
+
continue;
|
|
1943
|
+
}
|
|
1944
|
+
const resolved = await this.matchEngine.Resolve(recs, entityMap, fieldMaps, contextUser);
|
|
1945
|
+
await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser, logger);
|
|
1946
|
+
appliedRecords += recs.length;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
catch (applyErr) {
|
|
1950
|
+
if (applyErr instanceof SchemaNotGeneratedError) {
|
|
1951
|
+
result.Errors.push({ ExternalID: '', ChangeType: 'Create', ErrorMessage: applyErr.message, ErrorCode: 'CONFIGURATION_ERROR', Severity: 'Critical' });
|
|
1952
|
+
console.warn(`[IntegrationEngine] ${entityMap.ExternalObjectName} → ${entityMap.Entity}: ${applyErr.message} (partition reconcile aborted; snapshot not advanced)`);
|
|
1953
|
+
return; // do NOT persist the new rollups — next sync re-reconciles from the prior snapshot
|
|
1954
|
+
}
|
|
1955
|
+
throw applyErr;
|
|
1956
|
+
}
|
|
1957
|
+
// Persist the new snapshot only after a clean apply.
|
|
1958
|
+
await this.watermarkService.SavePartitionRollups(entityMapID, newRollups, contextUser);
|
|
1959
|
+
logger?.emit('sync.partition.reconcile', {
|
|
1960
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
1961
|
+
totalRecords: mappedRecords.length,
|
|
1962
|
+
totalPartitions: buckets.size,
|
|
1963
|
+
changedPartitions: diff.changed.length,
|
|
1964
|
+
addedPartitions: diff.added.length,
|
|
1965
|
+
removedPartitions: diff.removed.length,
|
|
1966
|
+
appliedRecords,
|
|
1967
|
+
skippedPartitions,
|
|
1968
|
+
skippedRecords,
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
925
1971
|
/**
|
|
926
1972
|
* Applies resolved records to MJ, handling each individually for error isolation.
|
|
927
1973
|
*/
|
|
928
|
-
async ApplyRecords(records, companyIntegration, entityMap, result, contextUser) {
|
|
929
|
-
// Batched
|
|
930
|
-
//
|
|
931
|
-
// small enough to avoid SQL Server lock escalation (~5000 rows) while
|
|
932
|
-
//
|
|
933
|
-
//
|
|
1974
|
+
async ApplyRecords(records, companyIntegration, entityMap, result, contextUser, logger) {
|
|
1975
|
+
// Batched application with per-record failure isolation (the "grace gap" fix).
|
|
1976
|
+
// Happy path: each batch of up to APPLY_BATCH_SIZE records commits as a single
|
|
1977
|
+
// transaction — small enough to avoid SQL Server lock escalation (~5000 rows) while
|
|
1978
|
+
// amortizing transaction overhead across the batch. When a batch transaction FAILS,
|
|
1979
|
+
// we roll it back, restore the per-batch counter snapshot, and RE-APPLY every record
|
|
1980
|
+
// in the batch in its OWN transaction so only the actually-failing record(s) error
|
|
1981
|
+
// out — the good siblings still get committed. One poison record no longer sinks up
|
|
1982
|
+
// to 500 healthy records.
|
|
934
1983
|
const APPLY_BATCH_SIZE = 500;
|
|
935
1984
|
const provider = this.ProviderToUse;
|
|
936
1985
|
for (let i = 0; i < records.length; i += APPLY_BATCH_SIZE) {
|
|
@@ -939,21 +1988,35 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
939
1988
|
const batchStartCreated = result.RecordsCreated;
|
|
940
1989
|
const batchStartUpdated = result.RecordsUpdated;
|
|
941
1990
|
const batchStartDeleted = result.RecordsDeleted;
|
|
1991
|
+
const batchStartSkipped = result.RecordsSkipped;
|
|
1992
|
+
// One cheap read per batch fetches the stored content hashes for the rows we'd
|
|
1993
|
+
// otherwise load one-by-one. For a watermark-less re-sync where nothing changed,
|
|
1994
|
+
// this lets UpdateRecord skip every per-record load. Best-effort: undefined → the
|
|
1995
|
+
// existing dirty-flag path runs unchanged.
|
|
1996
|
+
const precheckHashes = await this.PrefetchContentHashes(batch, contextUser);
|
|
1997
|
+
// PKs of records the content-hash fast path skipped this batch — still present and
|
|
1998
|
+
// confirmed-unchanged on the source. Collected so we can refresh LastReconciledAt for
|
|
1999
|
+
// all of them in ONE set-based touch after the batch (instead of a frozen-forever stamp).
|
|
2000
|
+
let reconciledSkipIds = [];
|
|
942
2001
|
await provider.BeginTransaction();
|
|
943
2002
|
try {
|
|
944
2003
|
for (const record of batch) {
|
|
945
2004
|
result.RecordsProcessed++;
|
|
946
|
-
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser);
|
|
2005
|
+
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
|
|
947
2006
|
}
|
|
948
2007
|
await provider.CommitTransaction();
|
|
949
2008
|
}
|
|
950
2009
|
catch (err) {
|
|
951
2010
|
await provider.RollbackTransaction();
|
|
2011
|
+
// The batch transaction rolled back; the skip-IDs collected during the failed attempt
|
|
2012
|
+
// never committed. Reset and let the per-record retry re-collect only what commits.
|
|
2013
|
+
reconciledSkipIds = [];
|
|
952
2014
|
// Roll back the in-memory counters that ApplySingleRecord bumped inside the failed batch
|
|
953
2015
|
result.RecordsProcessed = batchStartProcessed;
|
|
954
2016
|
result.RecordsCreated = batchStartCreated;
|
|
955
2017
|
result.RecordsUpdated = batchStartUpdated;
|
|
956
2018
|
result.RecordsDeleted = batchStartDeleted;
|
|
2019
|
+
result.RecordsSkipped = batchStartSkipped;
|
|
957
2020
|
// SchemaNotGeneratedError is per-entity-deterministic — every record in
|
|
958
2021
|
// this object will fail the same way. Bubble it up so ProcessPullSync
|
|
959
2022
|
// can fail-stop the entityMap with one log line instead of producing
|
|
@@ -961,34 +2024,147 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
961
2024
|
if (err instanceof SchemaNotGeneratedError) {
|
|
962
2025
|
throw err;
|
|
963
2026
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
2027
|
+
// Degrade to per-record application so the failure isolates to the poison
|
|
2028
|
+
// record(s) and every good record in this batch still commits.
|
|
2029
|
+
await this.applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
|
|
2030
|
+
}
|
|
2031
|
+
// After the batch settles (committed, or per-record retried), refresh
|
|
2032
|
+
// LastReconciledAt for every content-hash-skipped row in ONE set-based touch.
|
|
2033
|
+
// Best-effort — a touch failure must never break the sync.
|
|
2034
|
+
if (reconciledSkipIds.length > 0) {
|
|
2035
|
+
await this.TouchLastReconciledAt(entityMap, reconciledSkipIds, contextUser, logger);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Refreshes __mj_integration_LastReconciledAt = now for a set of records that the content-hash
|
|
2041
|
+
* fast path skipped (present + confirmed-unchanged on the source). Issues ONE set-based UPDATE
|
|
2042
|
+
* for the whole skip set so the optimization isn't defeated by per-row writes. No-op (and never
|
|
2043
|
+
* throws) when the entity lacks the column, has a composite PK, or the UPDATE fails — best-effort,
|
|
2044
|
+
* mirroring PrefetchContentHashes. Keeps the column's "last confirmed present" semantics honest so
|
|
2045
|
+
* future unseen-since-last-reconcile logic can't misclassify a still-present unchanged record.
|
|
2046
|
+
*/
|
|
2047
|
+
async TouchLastReconciledAt(entityMap, recordIds, contextUser, logger) {
|
|
2048
|
+
try {
|
|
2049
|
+
const md = this.ProviderToUse;
|
|
2050
|
+
const entityInfo = md.EntityByName(entityMap.Entity ?? '');
|
|
2051
|
+
if (!entityInfo)
|
|
2052
|
+
return;
|
|
2053
|
+
const RECONCILED_COLUMN = '__mj_integration_LastReconciledAt';
|
|
2054
|
+
if (!entityInfo.Fields.some(f => f.Name === RECONCILED_COLUMN))
|
|
2055
|
+
return;
|
|
2056
|
+
const pkFields = entityInfo.PrimaryKeys ?? [];
|
|
2057
|
+
if (pkFields.length !== 1)
|
|
2058
|
+
return; // single-PK set-based touch only
|
|
2059
|
+
if (!entityInfo.SchemaName || !entityInfo.BaseTable)
|
|
2060
|
+
return;
|
|
2061
|
+
const provider = md;
|
|
2062
|
+
const dialect = provider.Dialect;
|
|
2063
|
+
const table = `${dialect.QuoteIdentifier(entityInfo.SchemaName)}.${dialect.QuoteIdentifier(entityInfo.BaseTable)}`;
|
|
2064
|
+
const col = dialect.QuoteIdentifier(RECONCILED_COLUMN);
|
|
2065
|
+
const pk = dialect.QuoteIdentifier(pkFields[0].Name);
|
|
2066
|
+
const uniqueIds = Array.from(new Set(recordIds));
|
|
2067
|
+
const inList = uniqueIds.map(id => dialect.QuoteStringLiteral(String(id))).join(',');
|
|
2068
|
+
const now = dialect.QuoteStringLiteral(new Date().toISOString());
|
|
2069
|
+
const sql = `UPDATE ${table} SET ${col} = ${now} WHERE ${pk} IN (${inList})`;
|
|
2070
|
+
await provider.ExecuteSQL(sql, undefined, undefined, contextUser);
|
|
2071
|
+
}
|
|
2072
|
+
catch (err) {
|
|
2073
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2074
|
+
console.warn(`[IntegrationEngine] LastReconciledAt touch skipped for ${entityMap.ExternalObjectName}: ${msg}`);
|
|
2075
|
+
// Best-effort: surface in the structured stream as a non-fatal warning (a touch failure
|
|
2076
|
+
// must never break the sync). Uses the existing 'sync.warning' channel.
|
|
2077
|
+
logger?.warning('reconcile', 'LAST_RECONCILED_TOUCH_FAILED', msg, {
|
|
2078
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
2079
|
+
count: recordIds.length,
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Per-record fallback for a batch whose single transaction failed. Re-applies each
|
|
2085
|
+
* record in its OWN transaction so a failure isolates to that record only — good
|
|
2086
|
+
* siblings commit, the poison record(s) error out with their REAL per-record cause.
|
|
2087
|
+
*
|
|
2088
|
+
* Counters and the run-invariant are preserved: every record bumps RecordsProcessed
|
|
2089
|
+
* exactly once (so the chunk's processed total still equals chunk.length), good
|
|
2090
|
+
* records increment Created/Updated/Skipped via ApplySingleRecord, and each failure
|
|
2091
|
+
* increments RecordsErrored and pushes a SyncRecordError classified with the SAME
|
|
2092
|
+
* ClassifyError used by the connector path. A `sync.record.error` event is emitted
|
|
2093
|
+
* once PER failed record (phase:'save'), carrying that record's real ExternalID /
|
|
2094
|
+
* ChangeType — not a single batch-wide error.
|
|
2095
|
+
*
|
|
2096
|
+
* Begin/Commit/Rollback are always matched per record (no leaked open transaction).
|
|
2097
|
+
*/
|
|
2098
|
+
async applyRecordsIndividually(batch, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds) {
|
|
2099
|
+
const provider = this.ProviderToUse;
|
|
2100
|
+
for (const record of batch) {
|
|
2101
|
+
await provider.BeginTransaction();
|
|
2102
|
+
try {
|
|
2103
|
+
result.RecordsProcessed++;
|
|
2104
|
+
await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds);
|
|
2105
|
+
await provider.CommitTransaction();
|
|
2106
|
+
}
|
|
2107
|
+
catch (recErr) {
|
|
2108
|
+
await provider.RollbackTransaction();
|
|
2109
|
+
// A schema-not-generated failure on one record means EVERY record in this
|
|
2110
|
+
// object will fail identically — bubble it up so the entityMap fail-stops
|
|
2111
|
+
// once rather than emitting per-record duplicates for the whole batch.
|
|
2112
|
+
if (recErr instanceof SchemaNotGeneratedError) {
|
|
2113
|
+
throw recErr;
|
|
977
2114
|
}
|
|
2115
|
+
const classified = ClassifyError(recErr);
|
|
2116
|
+
const msg = recErr instanceof Error ? recErr.message : String(recErr);
|
|
2117
|
+
result.RecordsErrored++;
|
|
2118
|
+
result.Errors.push({
|
|
2119
|
+
ExternalID: record.ExternalRecord.ExternalID,
|
|
2120
|
+
ChangeType: record.ChangeType,
|
|
2121
|
+
ErrorMessage: msg,
|
|
2122
|
+
ErrorCode: classified.Code,
|
|
2123
|
+
Severity: classified.Severity,
|
|
2124
|
+
ExternalRecord: record.ExternalRecord,
|
|
2125
|
+
});
|
|
2126
|
+
// Surface each save-side failure in the durable artifact (one event per
|
|
2127
|
+
// failed record) so isolated failures are visible over GraphQL.
|
|
2128
|
+
logger?.emit('sync.record.error', {
|
|
2129
|
+
phase: 'save',
|
|
2130
|
+
externalObjectName: entityMap.ExternalObjectName,
|
|
2131
|
+
externalId: record.ExternalRecord.ExternalID,
|
|
2132
|
+
changeType: record.ChangeType,
|
|
2133
|
+
error: msg,
|
|
2134
|
+
errorCode: classified.Code,
|
|
2135
|
+
});
|
|
978
2136
|
}
|
|
979
2137
|
}
|
|
980
2138
|
}
|
|
981
2139
|
/**
|
|
982
2140
|
* Applies a single record change (Create, Update, Delete, or Skip).
|
|
983
2141
|
*/
|
|
984
|
-
async ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser) {
|
|
2142
|
+
async ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser, logger, precheckHashes, reconciledSkipIds) {
|
|
2143
|
+
logger?.emit('sync.record.decision', {
|
|
2144
|
+
externalId: record.ExternalRecord.ExternalID,
|
|
2145
|
+
objectType: record.ExternalRecord.ObjectType,
|
|
2146
|
+
entity: record.MJEntityName,
|
|
2147
|
+
changeType: record.ChangeType,
|
|
2148
|
+
matchedMJRecordID: record.MatchedMJRecordID ?? null,
|
|
2149
|
+
});
|
|
2150
|
+
// Capture counters so we can report the concrete per-record outcome in the log.
|
|
2151
|
+
const before = {
|
|
2152
|
+
c: result.RecordsCreated, u: result.RecordsUpdated,
|
|
2153
|
+
d: result.RecordsDeleted, s: result.RecordsSkipped,
|
|
2154
|
+
};
|
|
985
2155
|
switch (record.ChangeType) {
|
|
986
|
-
case 'Create':
|
|
987
|
-
await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
|
|
988
|
-
|
|
2156
|
+
case 'Create': {
|
|
2157
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
|
|
2158
|
+
if (outcome === 'updated')
|
|
2159
|
+
result.RecordsUpdated++;
|
|
2160
|
+
else if (outcome === 'skipped')
|
|
2161
|
+
result.RecordsSkipped++;
|
|
2162
|
+
else
|
|
2163
|
+
result.RecordsCreated++;
|
|
989
2164
|
break;
|
|
2165
|
+
}
|
|
990
2166
|
case 'Update':
|
|
991
|
-
await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser);
|
|
2167
|
+
await this.UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds);
|
|
992
2168
|
break;
|
|
993
2169
|
case 'Delete': {
|
|
994
2170
|
const didDelete = await this.DeleteRecord(record, entityMap, contextUser);
|
|
@@ -1002,15 +2178,64 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1002
2178
|
result.RecordsSkipped++;
|
|
1003
2179
|
break;
|
|
1004
2180
|
}
|
|
2181
|
+
if (logger) {
|
|
2182
|
+
const outcome = result.RecordsCreated > before.c ? 'created' :
|
|
2183
|
+
result.RecordsUpdated > before.u ? 'updated' :
|
|
2184
|
+
result.RecordsDeleted > before.d ? (entityMap.DeleteBehavior === 'SoftDelete' ? 'archived' : 'deleted') :
|
|
2185
|
+
result.RecordsSkipped > before.s ? 'skipped' : 'errored';
|
|
2186
|
+
logger.emit(outcome === 'archived' ? 'sync.record.archived' : 'sync.record.saved', {
|
|
2187
|
+
externalId: record.ExternalRecord.ExternalID,
|
|
2188
|
+
entity: record.MJEntityName,
|
|
2189
|
+
outcome,
|
|
2190
|
+
// The mirror row's __mj_integration_LastSyncedSnapshot is refreshed on create/update.
|
|
2191
|
+
snapshotWritten: outcome === 'created' || outcome === 'updated',
|
|
2192
|
+
matchedMJRecordID: record.MatchedMJRecordID ?? null,
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
1005
2195
|
}
|
|
1006
2196
|
/**
|
|
1007
|
-
*
|
|
2197
|
+
* Upserts an MJ record BY PRIMARY KEY (and saves a record-map entry).
|
|
2198
|
+
*
|
|
2199
|
+
* This is the "unmatched" write path — reached when a record matched neither the RecordMap nor a
|
|
2200
|
+
* key-field lookup, so the caller assumed it was new. Historically it blindly `NewRecord()`+INSERTed,
|
|
2201
|
+
* which COLLIDED with a duplicate-key violation when the dest row already existed but no RecordMap
|
|
2202
|
+
* pointed at it. That happens for real: after the entity maps (and their record maps) are deleted while
|
|
2203
|
+
* the dest rows persist (a maps delete+re-add, or a partial cleanup), a content-changed record matches
|
|
2204
|
+
* neither the (gone) map nor a key field and lands here — over a live PK.
|
|
2205
|
+
*
|
|
2206
|
+
* Fix: load by the record's mapped PK first; only `NewRecord()` when the row does not already exist,
|
|
2207
|
+
* otherwise UPDATE it in place. This makes the create path idempotent on the PK (§7 "idempotent upserts
|
|
2208
|
+
* keyed on PK") and re-establishes the missing record map either way.
|
|
2209
|
+
*
|
|
2210
|
+
* @returns true if an existing row was updated, false if a new row was inserted (so the caller counts correctly).
|
|
1008
2211
|
*/
|
|
1009
2212
|
async CreateRecord(record, companyIntegration, entityMap, contextUser) {
|
|
1010
2213
|
const md = this.ProviderToUse;
|
|
1011
2214
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
1012
|
-
|
|
1013
|
-
|
|
2215
|
+
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
2216
|
+
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
2217
|
+
// Upsert-safe: if the record's mapped fields carry a PK (soft-PK dest tables key on the external
|
|
2218
|
+
// ID), check whether that row already exists before deciding INSERT vs UPDATE. A null mappedPK
|
|
2219
|
+
// (e.g. a server-assigned UUID PK not present in the mapped fields) means a genuinely new row.
|
|
2220
|
+
const mappedPK = this.extractMappedPrimaryKey(record, pkFields);
|
|
2221
|
+
const existed = mappedPK != null
|
|
2222
|
+
? await entity.InnerLoad(this.BuildEntityPrimaryKey(mappedPK, pkFields))
|
|
2223
|
+
: false;
|
|
2224
|
+
if (existed) {
|
|
2225
|
+
// Footprint-clean upsert: set only the BUSINESS fields first; if nothing actually changed
|
|
2226
|
+
// (dirty tracking after SetEntityFields, BEFORE the always-changing integration metadata),
|
|
2227
|
+
// re-establish the possibly-cleared record map and SKIP the write — leaving __mj_UpdatedAt
|
|
2228
|
+
// and the integration LastSynced columns untouched, exactly like the content-hash skip path.
|
|
2229
|
+
this.SetEntityFields(entity, record.MappedFields);
|
|
2230
|
+
if (!entity.Dirty) {
|
|
2231
|
+
await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|'), contextUser);
|
|
2232
|
+
return 'skipped';
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
else {
|
|
2236
|
+
entity.NewRecord();
|
|
2237
|
+
this.SetEntityFields(entity, record.MappedFields);
|
|
2238
|
+
}
|
|
1014
2239
|
this.SetStandardIntegrationFields(entity, record);
|
|
1015
2240
|
// A5: Pre-write validation
|
|
1016
2241
|
this.validateEntity(entity, record.MJEntityName);
|
|
@@ -1020,37 +2245,89 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1020
2245
|
const schemaErr = detectSchemaNotGenerated(record.MJEntityName, errMsg);
|
|
1021
2246
|
if (schemaErr)
|
|
1022
2247
|
throw schemaErr;
|
|
1023
|
-
throw new Error(`Failed to create ${record.MJEntityName} record: ${errMsg}`);
|
|
2248
|
+
throw new Error(`Failed to ${existed ? 'update' : 'create'} ${record.MJEntityName} record: ${errMsg}`);
|
|
1024
2249
|
}
|
|
1025
|
-
// Use the entity's actual PK
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
2250
|
+
// Use the entity's actual PK as the EntityRecordID in the record map, NOT the external ID.
|
|
2251
|
+
// Storing the external ID as EntityRecordID caused UpdateRecord to fail to load the entity
|
|
2252
|
+
// (UUID lookup with a HubSpot numeric ID) and fall back here, producing duplicates on every
|
|
2253
|
+
// incremental sync. SaveRecordMap is an upsert keyed on (CompanyIntegration, Entity, ExternalID),
|
|
2254
|
+
// so this also re-establishes a map that was previously cleared.
|
|
1030
2255
|
const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
|
|
1031
2256
|
await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
|
|
2257
|
+
return existed ? 'updated' : 'created';
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Builds the pipe-joined PK string for a record from its MAPPED fields (case-insensitively), or null
|
|
2261
|
+
* when any PK field is absent/blank — i.e. the PK is not carried by the mapped data (server-assigned),
|
|
2262
|
+
* so the record is genuinely new and must be inserted. Used by CreateRecord for PK-safe upsert.
|
|
2263
|
+
*/
|
|
2264
|
+
extractMappedPrimaryKey(record, pkFields) {
|
|
2265
|
+
if (!pkFields.length)
|
|
2266
|
+
return null;
|
|
2267
|
+
const fields = record.MappedFields ?? {};
|
|
2268
|
+
const lower = new Map();
|
|
2269
|
+
for (const [k, v] of Object.entries(fields))
|
|
2270
|
+
lower.set(k.toLowerCase(), v);
|
|
2271
|
+
const values = [];
|
|
2272
|
+
for (const pk of pkFields) {
|
|
2273
|
+
const v = (pk.Name in fields) ? fields[pk.Name] : lower.get(pk.Name.toLowerCase());
|
|
2274
|
+
if (v == null || String(v) === '')
|
|
2275
|
+
return null;
|
|
2276
|
+
values.push(String(v));
|
|
2277
|
+
}
|
|
2278
|
+
return values.join('|');
|
|
1032
2279
|
}
|
|
1033
2280
|
/**
|
|
1034
2281
|
* Updates an existing MJ record with pre-write validation.
|
|
1035
2282
|
* If the record cannot be loaded (e.g. it was deleted or never fully created),
|
|
1036
2283
|
* falls back to CreateRecord (upsert behavior).
|
|
1037
2284
|
*/
|
|
1038
|
-
async UpdateRecord(record, companyIntegration, entityMap, result, contextUser) {
|
|
2285
|
+
async UpdateRecord(record, companyIntegration, entityMap, result, contextUser, precheckHashes, reconciledSkipIds) {
|
|
1039
2286
|
if (!record.MatchedMJRecordID) {
|
|
1040
|
-
// No matched ID —
|
|
1041
|
-
await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
|
|
1042
|
-
|
|
2287
|
+
// No matched ID — upsert by PK (insert; or update/skip if the PK already exists)
|
|
2288
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
|
|
2289
|
+
if (outcome === 'updated')
|
|
2290
|
+
result.RecordsUpdated++;
|
|
2291
|
+
else if (outcome === 'skipped')
|
|
2292
|
+
result.RecordsSkipped++;
|
|
2293
|
+
else
|
|
2294
|
+
result.RecordsCreated++;
|
|
1043
2295
|
return;
|
|
1044
2296
|
}
|
|
2297
|
+
// Content-hash fast path (watermark-less change detection): if the batch
|
|
2298
|
+
// prefetch produced a stored hash for this record and it equals the freshly
|
|
2299
|
+
// computed hash of the incoming mapped fields, the record is provably
|
|
2300
|
+
// unchanged — skip the per-record DB load AND the write. The dirty-flag check
|
|
2301
|
+
// below is the fallback for entities without the hash column.
|
|
2302
|
+
if (precheckHashes) {
|
|
2303
|
+
const stored = precheckHashes.get(record.MatchedMJRecordID);
|
|
2304
|
+
if (stored && stored === computeContentHash(record.MappedFields ?? {})) {
|
|
2305
|
+
result.RecordsSkipped++;
|
|
2306
|
+
// The record IS still present and confirmed-unchanged on the source — but skipping
|
|
2307
|
+
// the write here means SetStandardIntegrationFields never runs, so __mj_integration_
|
|
2308
|
+
// LastReconciledAt would freeze at first-sync time. Record the PK so the batch can
|
|
2309
|
+
// refresh LastReconciledAt in ONE set-based touch (keeps the skip optimization while
|
|
2310
|
+
// keeping the column's "last confirmed present" semantics honest for future
|
|
2311
|
+
// unseen-since-last-reconcile logic). No per-row write — see TouchLastReconciledAt.
|
|
2312
|
+
if (reconciledSkipIds)
|
|
2313
|
+
reconciledSkipIds.push(record.MatchedMJRecordID);
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
1045
2317
|
const md = this.ProviderToUse;
|
|
1046
2318
|
const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
|
|
1047
2319
|
const entityInfo = md.EntityByName(record.MJEntityName);
|
|
1048
2320
|
const pkFields = entityInfo?.PrimaryKeys ?? (entityInfo?.FirstPrimaryKey ? [entityInfo.FirstPrimaryKey] : []);
|
|
1049
2321
|
const loaded = await entity.InnerLoad(this.BuildEntityPrimaryKey(record.MatchedMJRecordID, pkFields));
|
|
1050
2322
|
if (!loaded) {
|
|
1051
|
-
//
|
|
1052
|
-
await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
|
|
1053
|
-
|
|
2323
|
+
// Matched-ID row vanished — fall back to upsert by PK (insert; or update/skip if PK exists)
|
|
2324
|
+
const outcome = await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
|
|
2325
|
+
if (outcome === 'updated')
|
|
2326
|
+
result.RecordsUpdated++;
|
|
2327
|
+
else if (outcome === 'skipped')
|
|
2328
|
+
result.RecordsSkipped++;
|
|
2329
|
+
else
|
|
2330
|
+
result.RecordsCreated++;
|
|
1054
2331
|
return;
|
|
1055
2332
|
}
|
|
1056
2333
|
this.SetEntityFields(entity, record.MappedFields);
|
|
@@ -1073,8 +2350,64 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1073
2350
|
throw schemaErr;
|
|
1074
2351
|
throw new Error(`Failed to update ${record.MJEntityName} record ${record.MatchedMJRecordID}: ${errMsg}`);
|
|
1075
2352
|
}
|
|
2353
|
+
// Maintain the external↔MJ record map on UPDATE too, not just on CREATE. A record matched via
|
|
2354
|
+
// key fields (MatchEngine.FindByKeyFields queries the dest table directly, NOT the RecordMap)
|
|
2355
|
+
// would otherwise be updated with no map ever written — so the RecordMap drifts from the actual
|
|
2356
|
+
// rows and orphan/delete detection silently degrades. SaveRecordMap is an upsert keyed on
|
|
2357
|
+
// (CompanyIntegration, Entity, ExternalID), so this is idempotent for already-mapped records.
|
|
2358
|
+
const entityRecordID = entity.PrimaryKey.KeyValuePairs.map(kv => String(kv.Value)).join('|');
|
|
2359
|
+
await this.SaveRecordMap(companyIntegration.ID, record.ExternalRecord.ExternalID, entityMap.EntityID, entityRecordID, contextUser);
|
|
1076
2360
|
result.RecordsUpdated++;
|
|
1077
2361
|
}
|
|
2362
|
+
/**
|
|
2363
|
+
* Batch-loads the stored `__mj_integration_ContentHash` for the Update records in a
|
|
2364
|
+
* batch, keyed by matched MJ record ID. Returns undefined (→ no fast-path skip; the
|
|
2365
|
+
* dirty-flag path runs) when the optimization doesn't apply or can't be performed:
|
|
2366
|
+
* - the target entity has no ContentHash column (predates the feature), or
|
|
2367
|
+
* - the entity has a composite PK (we keep the '|'-split out of the fast path), or
|
|
2368
|
+
* - nothing in the batch is an Update with a matched ID, or
|
|
2369
|
+
* - the read fails (best-effort — a logging/optimization read must never break a sync).
|
|
2370
|
+
*/
|
|
2371
|
+
async PrefetchContentHashes(batch, contextUser) {
|
|
2372
|
+
const ids = Array.from(new Set(batch.filter(r => r.ChangeType === 'Update' && r.MatchedMJRecordID)
|
|
2373
|
+
.map(r => r.MatchedMJRecordID)));
|
|
2374
|
+
if (ids.length === 0)
|
|
2375
|
+
return undefined;
|
|
2376
|
+
const entityName = batch[0].MJEntityName;
|
|
2377
|
+
const entityInfo = this.ProviderToUse.EntityByName(entityName);
|
|
2378
|
+
if (!entityInfo)
|
|
2379
|
+
return undefined;
|
|
2380
|
+
if (!entityInfo.Fields.some(f => f.Name === CONTENT_HASH_COLUMN))
|
|
2381
|
+
return undefined;
|
|
2382
|
+
const pkFields = entityInfo.PrimaryKeys ?? [];
|
|
2383
|
+
if (pkFields.length !== 1)
|
|
2384
|
+
return undefined; // single-PK fast path only
|
|
2385
|
+
const pk = pkFields[0].Name;
|
|
2386
|
+
try {
|
|
2387
|
+
const escaped = ids.map(id => `'${String(id).replace(/'/g, "''")}'`).join(',');
|
|
2388
|
+
const rv = new RunView();
|
|
2389
|
+
const res = await rv.RunView({
|
|
2390
|
+
EntityName: entityName,
|
|
2391
|
+
Fields: [pk, CONTENT_HASH_COLUMN],
|
|
2392
|
+
ExtraFilter: `${pk} IN (${escaped})`,
|
|
2393
|
+
ResultType: 'simple',
|
|
2394
|
+
}, contextUser);
|
|
2395
|
+
if (!res.Success)
|
|
2396
|
+
return undefined;
|
|
2397
|
+
const map = new Map();
|
|
2398
|
+
for (const row of res.Results) {
|
|
2399
|
+
const id = row[pk];
|
|
2400
|
+
const hash = row[CONTENT_HASH_COLUMN];
|
|
2401
|
+
if (id != null && typeof hash === 'string' && hash.length > 0) {
|
|
2402
|
+
map.set(String(id), hash);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
return map;
|
|
2406
|
+
}
|
|
2407
|
+
catch {
|
|
2408
|
+
return undefined; // best-effort — never break a sync over a prefetch failure
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
1078
2411
|
/**
|
|
1079
2412
|
* Runs Validate() on an entity if the method exists.
|
|
1080
2413
|
* Throws a validation error with details if validation fails.
|
|
@@ -1107,6 +2440,29 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1107
2440
|
console.log(`[IntegrationEngine] Skipping delete for ${record.MJEntityName} ${record.MatchedMJRecordID} — record not found in MJ DB (may have been deleted already)`);
|
|
1108
2441
|
return false;
|
|
1109
2442
|
}
|
|
2443
|
+
// SoftDelete = mark the mirror row Archived (the standard sync-status for records
|
|
2444
|
+
// removed upstream) and keep it; HardDelete physically removes it. (DoNothing
|
|
2445
|
+
// already returned above.) Previously both behaviors hit entity.Delete() — so
|
|
2446
|
+
// SoftDelete was indistinguishable from HardDelete.
|
|
2447
|
+
if (entityMap.DeleteBehavior === 'SoftDelete') {
|
|
2448
|
+
const fields = entity.Fields ?? [];
|
|
2449
|
+
const hasField = (n) => fields.some(f => f.Name === n);
|
|
2450
|
+
if (hasField('__mj_integration_SyncStatus'))
|
|
2451
|
+
entity.Set('__mj_integration_SyncStatus', 'Archived');
|
|
2452
|
+
if (hasField('__mj_integration_LastSyncedAt'))
|
|
2453
|
+
entity.Set('__mj_integration_LastSyncedAt', new Date().toISOString());
|
|
2454
|
+
// Explicit, queryable tombstone (plan §2.5) — distinct from parsing SyncStatus='Archived'.
|
|
2455
|
+
if (hasField('__mj_integration_IsTombstoned'))
|
|
2456
|
+
entity.Set('__mj_integration_IsTombstoned', true);
|
|
2457
|
+
if (hasField('__mj_integration_DeletedDetectedAt'))
|
|
2458
|
+
entity.Set('__mj_integration_DeletedDetectedAt', new Date().toISOString());
|
|
2459
|
+
const archived = await entity.Save();
|
|
2460
|
+
if (!archived) {
|
|
2461
|
+
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
2462
|
+
console.warn(`[IntegrationEngine] Soft-delete (archive) failed for ${record.MJEntityName} ${record.MatchedMJRecordID} — ${reason}`);
|
|
2463
|
+
}
|
|
2464
|
+
return archived;
|
|
2465
|
+
}
|
|
1110
2466
|
const deleted = await entity.Delete();
|
|
1111
2467
|
if (!deleted) {
|
|
1112
2468
|
const reason = entity.LatestResult?.CompleteMessage ?? 'unknown reason';
|
|
@@ -1144,15 +2500,40 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1144
2500
|
// provider passes "" into a DECIMAL/INT/DATE column and SQL Server
|
|
1145
2501
|
// throws "Error converting data type nvarchar to decimal".
|
|
1146
2502
|
const typeLookup = new Map();
|
|
2503
|
+
const maxLenLookup = new Map();
|
|
1147
2504
|
for (const f of entity.Fields ?? []) {
|
|
1148
2505
|
const rawType = f.EntityFieldInfo?.Type ?? f.Type;
|
|
1149
2506
|
if (rawType)
|
|
1150
2507
|
typeLookup.set(f.Name.toLowerCase(), rawType.toLowerCase());
|
|
2508
|
+
const ml = f.EntityFieldInfo?.MaxLength; // already a CHARACTER count (nvarchar bytes→chars); 0 = unlimited
|
|
2509
|
+
if (typeof ml === 'number' && ml > 0)
|
|
2510
|
+
maxLenLookup.set(f.Name.toLowerCase(), ml);
|
|
1151
2511
|
}
|
|
1152
2512
|
for (const [fieldName, value] of Object.entries(fields)) {
|
|
1153
|
-
|
|
2513
|
+
const key = fieldName.toLowerCase();
|
|
2514
|
+
const coerced = this.coerceIncomingValue(value, typeLookup.get(key));
|
|
2515
|
+
entity.Set(fieldName, this.enforceMaxLength(coerced, maxLenLookup.get(key), fieldName));
|
|
1154
2516
|
}
|
|
1155
2517
|
}
|
|
2518
|
+
/**
|
|
2519
|
+
* §5/§10 type-driven enforcement: clamp an over-length string to the target column's MaxLength
|
|
2520
|
+
* (a character count — SQLMaxLength already converts nvarchar bytes→chars) so a source value
|
|
2521
|
+
* wider than the resolved column is truncated instead of failing the whole row on "value too
|
|
2522
|
+
* long". Non-strings / values that fit / unlimited columns (MaxLength 0) pass through unchanged.
|
|
2523
|
+
*/
|
|
2524
|
+
enforceMaxLength(value, maxLength, fieldName) {
|
|
2525
|
+
if (typeof value !== 'string' || maxLength === undefined || value.length <= maxLength)
|
|
2526
|
+
return value;
|
|
2527
|
+
// NVARCHAR width is in UTF-16 code units, so we cut at `maxLength` code units — but never
|
|
2528
|
+
// mid surrogate pair (slicing between a high+low surrogate yields an invalid string). If the
|
|
2529
|
+
// last kept unit is a high surrogate, drop it (lose one emoji rather than corrupt the value).
|
|
2530
|
+
let cut = maxLength;
|
|
2531
|
+
const lastUnit = value.charCodeAt(cut - 1);
|
|
2532
|
+
if (lastUnit >= 0xD800 && lastUnit <= 0xDBFF)
|
|
2533
|
+
cut -= 1;
|
|
2534
|
+
console.warn(`[IntegrationEngine] Truncated '${fieldName}' ${value.length}→${cut} code units (exceeds column width).`);
|
|
2535
|
+
return value.slice(0, cut);
|
|
2536
|
+
}
|
|
1156
2537
|
/**
|
|
1157
2538
|
* Coerce external values to something MJ's SQL provider can bind safely.
|
|
1158
2539
|
* The external system has already done its best — this is only a safety
|
|
@@ -1169,6 +2550,13 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1169
2550
|
// on missing fields produces it. Better to null than crash the row.
|
|
1170
2551
|
if (typeof value === 'number' && !Number.isFinite(value))
|
|
1171
2552
|
return null;
|
|
2553
|
+
// Structured values (objects/arrays — nested JSON fields from APIs like HubSpot, or the output of
|
|
2554
|
+
// ApplySplit/ApplyCustom) cannot bind to a SQL column and would throw at entity.Set(), sinking the
|
|
2555
|
+
// whole 500-record batch. Serialize to JSON and let the type handling below place it: a string/text
|
|
2556
|
+
// column gets the JSON text; a scalar column won't parse it and nulls it. Grace over a hard failure (§8).
|
|
2557
|
+
if (typeof value === 'object' && !(value instanceof Date)) {
|
|
2558
|
+
value = JSON.stringify(value);
|
|
2559
|
+
}
|
|
1172
2560
|
// Non-string primitives pass through unchanged (numbers, booleans,
|
|
1173
2561
|
// Dates, etc.). The mssql driver handles them natively.
|
|
1174
2562
|
if (typeof value !== 'string')
|
|
@@ -1248,7 +2636,7 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1248
2636
|
* Sets standard integration columns (__mj_integration_*) on target entities.
|
|
1249
2637
|
* Silently skips if the entity doesn't have these columns (e.g., __mj targets).
|
|
1250
2638
|
*/
|
|
1251
|
-
SetStandardIntegrationFields(entity,
|
|
2639
|
+
SetStandardIntegrationFields(entity, record) {
|
|
1252
2640
|
const fieldNames = entity.Fields?.map(f => f.Name) ?? [];
|
|
1253
2641
|
const hasField = (name) => fieldNames.includes(name);
|
|
1254
2642
|
if (hasField('__mj_integration_LastSyncedAt')) {
|
|
@@ -1257,6 +2645,55 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1257
2645
|
if (hasField('__mj_integration_SyncStatus')) {
|
|
1258
2646
|
entity.Set('__mj_integration_SyncStatus', 'Active');
|
|
1259
2647
|
}
|
|
2648
|
+
// Snapshot the external values we just synced — the last-known external state,
|
|
2649
|
+
// kept independent of any later local edits to the mirror row. Powers
|
|
2650
|
+
// watermark-less change detection and the 3-way field-level merge (combine)
|
|
2651
|
+
// on bidirectional push (snapshot = common ancestor).
|
|
2652
|
+
if (hasField('__mj_integration_LastSyncedSnapshot')) {
|
|
2653
|
+
entity.Set('__mj_integration_LastSyncedSnapshot', JSON.stringify(record.MappedFields ?? {}));
|
|
2654
|
+
}
|
|
2655
|
+
// A clean sync clears any prior conflict/error note.
|
|
2656
|
+
if (hasField('__mj_integration_SyncMessage')) {
|
|
2657
|
+
entity.Set('__mj_integration_SyncMessage', null);
|
|
2658
|
+
}
|
|
2659
|
+
// Content hash of the mapped values — the cheap change-detection key for
|
|
2660
|
+
// watermark-less sources. On the next sync, a record whose freshly-computed
|
|
2661
|
+
// hash equals the stored hash can be skipped without loading it (see
|
|
2662
|
+
// PrefetchContentHashes / UpdateRecord). No-op on tables predating the column.
|
|
2663
|
+
if (hasField(CONTENT_HASH_COLUMN)) {
|
|
2664
|
+
entity.Set(CONTENT_HASH_COLUMN, computeContentHash(record.MappedFields ?? {}));
|
|
2665
|
+
}
|
|
2666
|
+
// ── Per-record sync ledger (plan §2.5) ───────────────────────────────────────
|
|
2667
|
+
// The external system's version token for optimistic-concurrency on bidirectional
|
|
2668
|
+
// push (detects "external changed since we last saw it"). HubSpot et al. expose this
|
|
2669
|
+
// as the modified timestamp; sources with no version token leave it null (honest gap).
|
|
2670
|
+
const externalVersion = record.ExternalRecord?.ModifiedAt
|
|
2671
|
+
? new Date(record.ExternalRecord.ModifiedAt).toISOString()
|
|
2672
|
+
: null;
|
|
2673
|
+
if (hasField('__mj_integration_ExternalVersion')) {
|
|
2674
|
+
entity.Set('__mj_integration_ExternalVersion', externalVersion);
|
|
2675
|
+
}
|
|
2676
|
+
// The watermark value we observed for THIS record (per-record, vs the entity-map-level
|
|
2677
|
+
// CompanyIntegrationSyncWatermark) — lets a record carry its own last-seen change marker.
|
|
2678
|
+
if (hasField('__mj_integration_LastSeenModifiedValue')) {
|
|
2679
|
+
entity.Set('__mj_integration_LastSeenModifiedValue', externalVersion);
|
|
2680
|
+
}
|
|
2681
|
+
// Last time this record was confirmed against the source (every successful pull-apply
|
|
2682
|
+
// reconciles it). NOTE: currently updated on full AND incremental syncs; a full-only
|
|
2683
|
+
// refinement (to find records unseen since the last full reconcile) is a documented follow-up.
|
|
2684
|
+
if (hasField('__mj_integration_LastReconciledAt')) {
|
|
2685
|
+
entity.Set('__mj_integration_LastReconciledAt', new Date().toISOString());
|
|
2686
|
+
}
|
|
2687
|
+
// Which side last wrote this row. This is the pull-apply path → 'Pull'. (A bidirectional
|
|
2688
|
+
// push-back path sets 'Push'.) Lets conflict handling know the last writer direction.
|
|
2689
|
+
if (hasField('__mj_integration_LastWriterDirection')) {
|
|
2690
|
+
entity.Set('__mj_integration_LastWriterDirection', 'Pull');
|
|
2691
|
+
}
|
|
2692
|
+
// A live (non-deleted) record. The soft-delete path flips this to true + stamps
|
|
2693
|
+
// DeletedDetectedAt — an explicit, queryable tombstone instead of parsing SyncStatus text.
|
|
2694
|
+
if (hasField('__mj_integration_IsTombstoned')) {
|
|
2695
|
+
entity.Set('__mj_integration_IsTombstoned', false);
|
|
2696
|
+
}
|
|
1260
2697
|
}
|
|
1261
2698
|
/**
|
|
1262
2699
|
* Creates or updates a CompanyIntegrationRecordMap entry to track the external↔MJ mapping.
|
|
@@ -1264,14 +2701,34 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1264
2701
|
async SaveRecordMap(companyIntegrationID, externalID, entityID, entityRecordID, contextUser) {
|
|
1265
2702
|
const md = this.ProviderToUse;
|
|
1266
2703
|
const recordMap = await md.GetEntityObject('MJ: Company Integration Record Maps', contextUser);
|
|
1267
|
-
|
|
2704
|
+
// Upsert by identity: one row per (CompanyIntegration, Entity, external record).
|
|
2705
|
+
// The prior always-NewRecord() behavior created a duplicate map row whenever a
|
|
2706
|
+
// record fell through to this path again (e.g. matching missed), which then made
|
|
2707
|
+
// every by-external-ID lookup ambiguous. Look up an existing mapping first.
|
|
2708
|
+
const rv = new RunView();
|
|
2709
|
+
const existing = await rv.RunView({
|
|
2710
|
+
EntityName: 'MJ: Company Integration Record Maps',
|
|
2711
|
+
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND EntityID='${entityID}' AND ExternalSystemRecordID='${externalID.replace(/'/g, "''")}'`,
|
|
2712
|
+
Fields: ['ID'],
|
|
2713
|
+
MaxRows: 1,
|
|
2714
|
+
ResultType: 'simple',
|
|
2715
|
+
BypassCache: true, // upsert-by-identity: a stale miss here re-creates a duplicate record map
|
|
2716
|
+
}, contextUser);
|
|
2717
|
+
if (existing.Success && existing.Results.length > 0) {
|
|
2718
|
+
const loaded = await recordMap.Load(existing.Results[0].ID);
|
|
2719
|
+
if (!loaded)
|
|
2720
|
+
recordMap.NewRecord();
|
|
2721
|
+
}
|
|
2722
|
+
else {
|
|
2723
|
+
recordMap.NewRecord();
|
|
2724
|
+
}
|
|
1268
2725
|
recordMap.CompanyIntegrationID = companyIntegrationID;
|
|
1269
2726
|
recordMap.ExternalSystemRecordID = externalID;
|
|
1270
2727
|
recordMap.EntityID = entityID;
|
|
1271
2728
|
recordMap.EntityRecordID = entityRecordID;
|
|
1272
2729
|
const saved = await recordMap.Save();
|
|
1273
2730
|
if (!saved) {
|
|
1274
|
-
throw new Error(`Failed to save record map for external ID
|
|
2731
|
+
throw new Error(`Failed to save record map for external ID ${externalID}: ${recordMap.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1275
2732
|
}
|
|
1276
2733
|
}
|
|
1277
2734
|
/**
|
|
@@ -1283,10 +2740,19 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1283
2740
|
detail.NewRecord();
|
|
1284
2741
|
detail.CompanyIntegrationRunID = run.ID;
|
|
1285
2742
|
detail.EntityID = entityMap.EntityID;
|
|
1286
|
-
|
|
2743
|
+
// Stamp the EntityMapID (NOT just the EntityID) into the free-form RecordID so resume
|
|
2744
|
+
// can correlate completion per entity MAP. Two distinct maps can target the same MJ Entity
|
|
2745
|
+
// (CompanyIntegrationEntityMap has no unique (CompanyIntegrationID, EntityID) constraint),
|
|
2746
|
+
// so keying resume on EntityID alone could wrongly skip a second still-pending map sharing
|
|
2747
|
+
// that entity. Format: 'EntityMap:<id>|Processed:<n>'. ResumeOrphanedSyncs parses this.
|
|
2748
|
+
detail.RecordID = `EntityMap:${entityMap.ID}|Processed:${result.RecordsProcessed}`;
|
|
1287
2749
|
detail.Action = result.RecordsCreated > 0 ? 'INSERT' : 'UPDATE';
|
|
1288
2750
|
detail.IsSuccess = result.RecordsErrored === 0;
|
|
1289
|
-
await detail.Save();
|
|
2751
|
+
const detailSaved = await detail.Save();
|
|
2752
|
+
if (!detailSaved) {
|
|
2753
|
+
console.warn(`[IntegrationEngine] Failed to save run detail for entity map ${entityMap.ID}: ` +
|
|
2754
|
+
`${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
2755
|
+
}
|
|
1290
2756
|
}
|
|
1291
2757
|
/**
|
|
1292
2758
|
* Merges an entity-map-level result into the aggregate result.
|
|
@@ -1324,14 +2790,31 @@ export class IntegrationEngine extends BaseSingleton {
|
|
|
1324
2790
|
/**
|
|
1325
2791
|
* Finalizes a successful run with aggregate totals and emits a completion notification.
|
|
1326
2792
|
*/
|
|
1327
|
-
async FinalizeRun(run, result, _contextUser, onNotification) {
|
|
2793
|
+
async FinalizeRun(run, result, _contextUser, onNotification, aborted) {
|
|
1328
2794
|
run.EndedAt = new Date();
|
|
1329
2795
|
run.TotalRecords = result.RecordsProcessed;
|
|
1330
|
-
run
|
|
1331
|
-
|
|
1332
|
-
|
|
2796
|
+
// A user/system-cancelled run must NOT be recorded as 'Success' — that hides the
|
|
2797
|
+
// cancellation in run history (indistinguishable from a clean completion) and is wrong
|
|
2798
|
+
// for any downstream cadence/health logic. Until a first-class 'Cancelled' status value
|
|
2799
|
+
// exists on CompanyIntegrationRun (Status value list is Pending/In Progress/Success/Failed),
|
|
2800
|
+
// finalize an aborted run as 'Failed' with an explicit ErrorLog. The durable progress
|
|
2801
|
+
// artifact additionally carries exitReason='aborted' (see finalizeSyncProgress) so a stopped
|
|
2802
|
+
// run stays distinguishable from a real failure over GraphQL.
|
|
2803
|
+
if (aborted) {
|
|
2804
|
+
run.Status = 'Failed';
|
|
2805
|
+
run.ErrorLog = result.ErrorMessage ?? 'Sync cancelled by user';
|
|
2806
|
+
}
|
|
2807
|
+
else {
|
|
2808
|
+
run.Status = result.RecordsErrored > 0 ? 'Failed' : 'Success';
|
|
2809
|
+
if (result.Errors.length > 0) {
|
|
2810
|
+
run.ErrorLog = JSON.stringify(result.Errors.slice(0, 100));
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
const saved = await run.Save();
|
|
2814
|
+
if (!saved) {
|
|
2815
|
+
console.warn(`[IntegrationEngine] Failed to finalize run ${run.ID}: ` +
|
|
2816
|
+
`${run.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
1333
2817
|
}
|
|
1334
|
-
await run.Save();
|
|
1335
2818
|
if (onNotification) {
|
|
1336
2819
|
const notification = this.buildCompletionNotification(run, result);
|
|
1337
2820
|
this.safeNotify(onNotification, notification);
|