@memberjunction/integration-engine 5.38.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.
Files changed (74) hide show
  1. package/dist/ActionMetadataGenerator.d.ts +8 -1
  2. package/dist/ActionMetadataGenerator.d.ts.map +1 -1
  3. package/dist/ActionMetadataGenerator.js +22 -3
  4. package/dist/ActionMetadataGenerator.js.map +1 -1
  5. package/dist/AdaptiveConcurrency.d.ts +85 -0
  6. package/dist/AdaptiveConcurrency.d.ts.map +1 -0
  7. package/dist/AdaptiveConcurrency.js +148 -0
  8. package/dist/AdaptiveConcurrency.js.map +1 -0
  9. package/dist/BaseIntegrationConnector.d.ts +127 -3
  10. package/dist/BaseIntegrationConnector.d.ts.map +1 -1
  11. package/dist/BaseIntegrationConnector.js +126 -11
  12. package/dist/BaseIntegrationConnector.js.map +1 -1
  13. package/dist/BaseRESTIntegrationConnector.d.ts +80 -15
  14. package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
  15. package/dist/BaseRESTIntegrationConnector.js +314 -64
  16. package/dist/BaseRESTIntegrationConnector.js.map +1 -1
  17. package/dist/ConflictRecency.d.ts +24 -0
  18. package/dist/ConflictRecency.d.ts.map +1 -0
  19. package/dist/ConflictRecency.js +25 -0
  20. package/dist/ConflictRecency.js.map +1 -0
  21. package/dist/ContentHash.d.ts +28 -0
  22. package/dist/ContentHash.d.ts.map +1 -0
  23. package/dist/ContentHash.js +54 -0
  24. package/dist/ContentHash.js.map +1 -0
  25. package/dist/EnrichSchemaConstraints.d.ts +59 -0
  26. package/dist/EnrichSchemaConstraints.d.ts.map +1 -0
  27. package/dist/EnrichSchemaConstraints.js +168 -0
  28. package/dist/EnrichSchemaConstraints.js.map +1 -0
  29. package/dist/FieldMappingEngine.d.ts +22 -0
  30. package/dist/FieldMappingEngine.d.ts.map +1 -1
  31. package/dist/FieldMappingEngine.js +66 -5
  32. package/dist/FieldMappingEngine.js.map +1 -1
  33. package/dist/HashDiff.d.ts +68 -0
  34. package/dist/HashDiff.d.ts.map +1 -0
  35. package/dist/HashDiff.js +108 -0
  36. package/dist/HashDiff.js.map +1 -0
  37. package/dist/IntegrationActionGenerator.d.ts +93 -0
  38. package/dist/IntegrationActionGenerator.d.ts.map +1 -0
  39. package/dist/IntegrationActionGenerator.js +313 -0
  40. package/dist/IntegrationActionGenerator.js.map +1 -0
  41. package/dist/IntegrationConnectorCreationPipeline.d.ts +86 -0
  42. package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -0
  43. package/dist/IntegrationConnectorCreationPipeline.js +226 -0
  44. package/dist/IntegrationConnectorCreationPipeline.js.map +1 -0
  45. package/dist/IntegrationEngine.d.ts +199 -1
  46. package/dist/IntegrationEngine.d.ts.map +1 -1
  47. package/dist/IntegrationEngine.js +1592 -109
  48. package/dist/IntegrationEngine.js.map +1 -1
  49. package/dist/IntegrationSchemaSync.d.ts +82 -0
  50. package/dist/IntegrationSchemaSync.d.ts.map +1 -1
  51. package/dist/IntegrationSchemaSync.js +289 -42
  52. package/dist/IntegrationSchemaSync.js.map +1 -1
  53. package/dist/MatchEngine.d.ts.map +1 -1
  54. package/dist/MatchEngine.js +4 -0
  55. package/dist/MatchEngine.js.map +1 -1
  56. package/dist/RateLimiter.d.ts +117 -0
  57. package/dist/RateLimiter.d.ts.map +1 -0
  58. package/dist/RateLimiter.js +159 -0
  59. package/dist/RateLimiter.js.map +1 -0
  60. package/dist/SyncLogger.d.ts +106 -0
  61. package/dist/SyncLogger.d.ts.map +1 -0
  62. package/dist/SyncLogger.js +176 -0
  63. package/dist/SyncLogger.js.map +1 -0
  64. package/dist/WatermarkService.d.ts +36 -1
  65. package/dist/WatermarkService.d.ts.map +1 -1
  66. package/dist/WatermarkService.js +113 -3
  67. package/dist/WatermarkService.js.map +1 -1
  68. package/dist/index.d.ts +23 -6
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +11 -1
  71. package/dist/index.js.map +1 -1
  72. package/dist/types.d.ts +52 -1
  73. package/dist/types.d.ts.map +1 -1
  74. 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
- * matches the SP-not-found pattern. Otherwise returns null. The pattern
49
- * comes from SQL Server's `Could not find stored procedure '<schema>.<name>'`
50
- * — when CodeGen hasn't created the spCreate/spUpdate/spDelete for an
51
- * entity, BaseEntity.Save() returns false and the SP-not-found error
52
- * lands in entity.LatestResult.CompleteMessage.
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 match = errorMessage.match(/Could not find stored procedure '([^']+)'/i);
56
- if (!match)
57
- return null;
58
- return new SchemaNotGeneratedError(entityName, match[1]);
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 maps already completed in this run
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: ['EntityID'],
150
+ Fields: ['RecordID', 'IsSuccess'],
130
151
  ResultType: 'simple',
131
152
  }, contextUser);
132
- const completedEntityIDs = new Set();
153
+ const completedMapIDs = new Set();
133
154
  if (detailsResult.Success) {
134
155
  for (const d of detailsResult.Results) {
135
- completedEntityIDs.add(d.EntityID.toLowerCase());
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
- `(${completedEntityIDs.size} entity maps already completed)`);
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 => !completedEntityIDs.has((em.EntityID).toLowerCase()));
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
- fullSync: options?.FullSync ?? false,
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
- for (let i = 0; i < totalMaps; i++) {
345
- if (abortSignal?.aborted) {
346
- console.log(`[IntegrationEngine] Sync cancelled before entity map ${i + 1}/${totalMaps}`);
347
- aggregate.Success = false;
348
- aggregate.ErrorMessage = 'Sync cancelled by user';
349
- break;
350
- }
351
- const entityMap = config.entityMaps[i];
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 &lt; 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&lt;=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
- const resolved = await this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser);
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
- await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser);
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
- if (fetchCompletedCleanly) {
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
- finalWatermark = config.fullSync ? new Date().toISOString() : currentWatermark;
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
- // Orphan detection: on full sync, delete MJ records whose external counterpart no longer exists.
582
- // Only runs if the fetch completed cleanly — a partial fetch (aborted, errored, safety-limited)
583
- // means fetchedExternalIDs is incomplete; running deletion on it would be catastrophic.
584
- if (config.fullSync && fetchedExternalIDs.size > 0 && fetchCompletedCleanly) {
585
- await this.DeleteOrphanedRecords(config.companyIntegration, entityMap, fetchedExternalIDs, result, contextUser);
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
- let latestChangeAt = null;
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 && (!latestChangeAt || change.ChangedAt > latestChangeAt)) {
634
- latestChangeAt = change.ChangedAt;
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
- // Update existing external record
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: externalAttributes,
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 atomicity per plans/transaction-group-migration.md: each batch of up to
930
- // APPLY_BATCH_SIZE records commits or rolls back as a unit. This keeps transactions
931
- // small enough to avoid SQL Server lock escalation (~5000 rows) while still giving
932
- // per-batch all-or-nothing semantics. Batch failures report every record in the
933
- // batch as errored since the rollback undid any partial success within the batch.
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
- const classified = ClassifyError(err);
965
- const msg = err instanceof Error ? err.message : String(err);
966
- for (const rec of batch) {
967
- result.RecordsProcessed++;
968
- result.RecordsErrored++;
969
- result.Errors.push({
970
- ExternalID: rec.ExternalRecord.ExternalID,
971
- ChangeType: rec.ChangeType,
972
- ErrorMessage: `Batch rolled back: ${msg}`,
973
- ErrorCode: classified.Code,
974
- Severity: classified.Severity,
975
- ExternalRecord: rec.ExternalRecord,
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
- result.RecordsCreated++;
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
- * Creates a new MJ record with pre-write validation and saves a record map entry.
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
- entity.NewRecord();
1013
- this.SetEntityFields(entity, record.MappedFields);
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 (e.g. the UUID assigned by the DB) as the
1026
- // EntityRecordID in the record map, NOT the external ID. Storing the
1027
- // external ID as EntityRecordID caused UpdateRecord to fail to load the
1028
- // entity (UUID lookup with a HubSpot numeric ID) and fall back to
1029
- // CreateRecord, producing duplicates on every incremental sync.
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 — treat as a new record
1041
- await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
1042
- result.RecordsCreated++;
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
- // Record doesn't exist in DB — fall back to INSERT (upsert)
1052
- await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
1053
- result.RecordsCreated++;
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
- entity.Set(fieldName, this.coerceIncomingValue(value, typeLookup.get(fieldName.toLowerCase())));
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, _record) {
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
- recordMap.NewRecord();
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: ${externalID}`);
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
- detail.RecordID = `Processed:${result.RecordsProcessed}`;
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.Status = result.RecordsErrored > 0 ? 'Failed' : 'Success';
1331
- if (result.Errors.length > 0) {
1332
- run.ErrorLog = JSON.stringify(result.Errors.slice(0, 100));
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);