@memberjunction/integration-engine 0.0.1 → 5.9.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 (50) hide show
  1. package/README.md +142 -28
  2. package/dist/BaseIntegrationConnector.d.ts +177 -0
  3. package/dist/BaseIntegrationConnector.d.ts.map +1 -0
  4. package/dist/BaseIntegrationConnector.js +110 -0
  5. package/dist/BaseIntegrationConnector.js.map +1 -0
  6. package/dist/BaseRESTIntegrationConnector.d.ts +222 -0
  7. package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -0
  8. package/dist/BaseRESTIntegrationConnector.js +482 -0
  9. package/dist/BaseRESTIntegrationConnector.js.map +1 -0
  10. package/dist/ConnectorFactory.d.ts +28 -0
  11. package/dist/ConnectorFactory.d.ts.map +1 -0
  12. package/dist/ConnectorFactory.js +42 -0
  13. package/dist/ConnectorFactory.js.map +1 -0
  14. package/dist/FieldMappingEngine.d.ts +94 -0
  15. package/dist/FieldMappingEngine.d.ts.map +1 -0
  16. package/dist/FieldMappingEngine.js +250 -0
  17. package/dist/FieldMappingEngine.js.map +1 -0
  18. package/dist/IntegrationEngine.d.ts +173 -0
  19. package/dist/IntegrationEngine.d.ts.map +1 -0
  20. package/dist/IntegrationEngine.js +677 -0
  21. package/dist/IntegrationEngine.js.map +1 -0
  22. package/dist/MatchEngine.d.ts +51 -0
  23. package/dist/MatchEngine.d.ts.map +1 -0
  24. package/dist/MatchEngine.js +129 -0
  25. package/dist/MatchEngine.js.map +1 -0
  26. package/dist/RetryRunner.d.ts +27 -0
  27. package/dist/RetryRunner.d.ts.map +1 -0
  28. package/dist/RetryRunner.js +59 -0
  29. package/dist/RetryRunner.js.map +1 -0
  30. package/dist/WatermarkService.d.ts +44 -0
  31. package/dist/WatermarkService.d.ts.map +1 -0
  32. package/dist/WatermarkService.js +97 -0
  33. package/dist/WatermarkService.js.map +1 -0
  34. package/dist/entity-types.d.ts +90 -0
  35. package/dist/entity-types.d.ts.map +1 -0
  36. package/dist/entity-types.js +13 -0
  37. package/dist/entity-types.js.map +1 -0
  38. package/dist/index.d.ts +16 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +19 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/transforms.d.ts +75 -0
  43. package/dist/transforms.d.ts.map +1 -0
  44. package/dist/transforms.js +2 -0
  45. package/dist/transforms.js.map +1 -0
  46. package/dist/types.d.ts +196 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +44 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +30 -7
@@ -0,0 +1,677 @@
1
+ import { CompositeKey, Metadata, RunView } from '@memberjunction/core';
2
+ import { BaseSingleton } from '@memberjunction/global';
3
+ import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
4
+ import { ClassifyError } from './types.js';
5
+ import { ConnectorFactory } from './ConnectorFactory.js';
6
+ import { FieldMappingEngine } from './FieldMappingEngine.js';
7
+ import { MatchEngine } from './MatchEngine.js';
8
+ import { WatermarkService } from './WatermarkService.js';
9
+ /** Default batch size for fetching records from external systems */
10
+ const DEFAULT_BATCH_SIZE = 200;
11
+ /**
12
+ * Server-side Integration Engine.
13
+ *
14
+ * Wraps IntegrationEngineBase (cached metadata) via composition and provides
15
+ * the full sync orchestration pipeline: fetch → map → match → validate → apply.
16
+ *
17
+ * Pattern mirrors AIEngine wrapping AIEngineBase.
18
+ *
19
+ * Usage:
20
+ * await IntegrationEngine.Instance.Config(false, contextUser);
21
+ * const result = await IntegrationEngine.Instance.RunSync(companyIntegrationID, contextUser);
22
+ */
23
+ export class IntegrationEngine extends BaseSingleton {
24
+ constructor() {
25
+ super();
26
+ this.fieldMappingEngine = new FieldMappingEngine();
27
+ this.matchEngine = new MatchEngine();
28
+ this.watermarkService = new WatermarkService();
29
+ /** Configurable maximum batch size. Connector batches exceeding this are truncated. */
30
+ this.MaxBatchSize = DEFAULT_BATCH_SIZE;
31
+ }
32
+ /** In-process lock map to prevent concurrent syncs for the same CompanyIntegration */
33
+ static { this.activeSyncs = new Map(); }
34
+ /**
35
+ * Executes a full sync run for a company integration.
36
+ * If a sync is already running for the same integration, waits for it to finish
37
+ * and returns the existing result (concurrency lock).
38
+ *
39
+ * @param companyIntegrationID - ID of the CompanyIntegration to sync
40
+ * @param contextUser - User context for all data operations
41
+ * @param triggerType - What triggered this sync (defaults to 'Manual')
42
+ * @param onProgress - Optional callback invoked with progress updates during sync
43
+ * @param onNotification - Optional callback invoked with a notification when the sync completes or fails
44
+ * @returns Aggregate sync result with record counts and errors
45
+ */
46
+ async RunSync(companyIntegrationID, contextUser, triggerType = 'Manual', onProgress, onNotification) {
47
+ const lockKey = companyIntegrationID.toLowerCase();
48
+ const existing = IntegrationEngine.activeSyncs.get(lockKey);
49
+ if (existing) {
50
+ console.warn(`[IntegrationEngine] Sync already running for ${lockKey}, waiting...`);
51
+ return existing;
52
+ }
53
+ const syncPromise = this.executeSyncInternal(companyIntegrationID, contextUser, triggerType, onProgress, onNotification);
54
+ IntegrationEngine.activeSyncs.set(lockKey, syncPromise);
55
+ try {
56
+ return await syncPromise;
57
+ }
58
+ finally {
59
+ IntegrationEngine.activeSyncs.delete(lockKey);
60
+ }
61
+ }
62
+ /**
63
+ * Internal sync execution method. Contains the full orchestration logic.
64
+ */
65
+ async executeSyncInternal(companyIntegrationID, contextUser, triggerType, onProgress, onNotification) {
66
+ const config = await this.LoadRunConfiguration(companyIntegrationID, contextUser);
67
+ const run = await this.CreateRunRecord(config.companyIntegration, triggerType, contextUser);
68
+ try {
69
+ const result = await this.ExecuteEntityMaps(config, run, contextUser, onProgress);
70
+ await this.FinalizeRun(run, result, contextUser, onNotification);
71
+ return result;
72
+ }
73
+ catch (err) {
74
+ await this.FailRun(run, err, contextUser, onNotification);
75
+ throw err;
76
+ }
77
+ }
78
+ /**
79
+ * Loads all configuration needed for a sync run.
80
+ */
81
+ async LoadRunConfiguration(companyIntegrationID, contextUser) {
82
+ const rv = new RunView();
83
+ const [ciResult, entityMapsResult, integrationsResult] = await rv.RunViews([
84
+ {
85
+ EntityName: 'MJ: Company Integrations',
86
+ ExtraFilter: `ID='${companyIntegrationID}'`,
87
+ MaxRows: 1,
88
+ ResultType: 'entity_object',
89
+ },
90
+ {
91
+ EntityName: 'MJ: Company Integration Entity Maps',
92
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND SyncEnabled=1 AND Status='Active'`,
93
+ OrderBy: 'Priority ASC',
94
+ ResultType: 'entity_object',
95
+ },
96
+ {
97
+ EntityName: 'MJ: Integrations',
98
+ ExtraFilter: '',
99
+ ResultType: 'entity_object',
100
+ },
101
+ ], contextUser);
102
+ const companyIntegration = ciResult.Results[0];
103
+ if (!companyIntegration) {
104
+ throw new Error(`CompanyIntegration not found: ${companyIntegrationID}`);
105
+ }
106
+ const integration = integrationsResult.Results
107
+ .find(i => i.Get('ID') === companyIntegration.IntegrationID);
108
+ if (!integration) {
109
+ throw new Error(`Integration not found for CompanyIntegration: ${companyIntegrationID}`);
110
+ }
111
+ const connector = ConnectorFactory.Resolve(integration);
112
+ return {
113
+ companyIntegration,
114
+ entityMaps: entityMapsResult.Results,
115
+ integration,
116
+ connector,
117
+ };
118
+ }
119
+ /**
120
+ * Creates a new CompanyIntegrationRun record to track this sync.
121
+ */
122
+ async CreateRunRecord(companyIntegration, triggerType, contextUser) {
123
+ const md = new Metadata();
124
+ const run = await md.GetEntityObject('MJ: Company Integration Runs', contextUser);
125
+ run.NewRecord();
126
+ run.CompanyIntegrationID = companyIntegration.Get('ID');
127
+ run.RunByUserID = contextUser.ID;
128
+ run.StartedAt = new Date();
129
+ run.Status = 'In Progress';
130
+ run.TotalRecords = 0;
131
+ run.ConfigData = JSON.stringify({ triggerType });
132
+ const saved = await run.Save();
133
+ if (!saved) {
134
+ throw new Error('Failed to create CompanyIntegrationRun record');
135
+ }
136
+ return run;
137
+ }
138
+ /**
139
+ * Processes all entity maps, aggregating results with progress tracking.
140
+ */
141
+ async ExecuteEntityMaps(config, run, contextUser, onProgress) {
142
+ const aggregate = {
143
+ Success: true,
144
+ RecordsProcessed: 0,
145
+ RecordsCreated: 0,
146
+ RecordsUpdated: 0,
147
+ RecordsDeleted: 0,
148
+ RecordsErrored: 0,
149
+ RecordsSkipped: 0,
150
+ Errors: [],
151
+ };
152
+ const totalMaps = config.entityMaps.length;
153
+ for (let i = 0; i < totalMaps; i++) {
154
+ const entityMap = config.entityMaps[i];
155
+ try {
156
+ const mapResult = await this.ProcessSingleEntityMap(config, entityMap, run, contextUser, i, totalMaps, onProgress);
157
+ this.MergeResult(aggregate, mapResult);
158
+ }
159
+ catch (err) {
160
+ const objName = entityMap.ExternalObjectName ?? String(entityMap.Get('ID'));
161
+ const errMsg = err instanceof Error ? err.message : String(err);
162
+ console.error(`[IntegrationEngine] Entity map '${objName}' failed: ${errMsg}`);
163
+ aggregate.RecordsErrored++;
164
+ aggregate.Errors.push({
165
+ ExternalID: objName,
166
+ ChangeType: 'Skip',
167
+ ErrorMessage: errMsg,
168
+ ErrorCode: 'CONNECTOR_ERROR',
169
+ Severity: 'Critical',
170
+ ExternalRecord: { ExternalID: objName, ObjectType: objName, Fields: {} },
171
+ });
172
+ }
173
+ }
174
+ return aggregate;
175
+ }
176
+ /**
177
+ * Processes a single entity map: fetch → map → match → validate → apply.
178
+ */
179
+ async ProcessSingleEntityMap(config, entityMap, run, contextUser, entityMapIndex, totalEntityMaps, onProgress) {
180
+ const entityMapID = entityMap.Get('ID');
181
+ const fieldMaps = await this.LoadFieldMaps(entityMapID, contextUser);
182
+ const watermark = await this.watermarkService.Load(entityMapID, contextUser);
183
+ // A6: Validate watermark before using it
184
+ let initialWatermark = watermark?.WatermarkValue ?? null;
185
+ if (initialWatermark && watermark) {
186
+ const watermarkType = (watermark.WatermarkType ?? 'Timestamp');
187
+ if (!this.watermarkService.ValidateWatermark(initialWatermark, watermarkType)) {
188
+ console.warn(`[IntegrationEngine] Invalid watermark '${initialWatermark}' for EntityMap ${entityMap.Get('ID')}, resetting to full fetch`);
189
+ initialWatermark = null;
190
+ }
191
+ }
192
+ const result = {
193
+ Success: true,
194
+ RecordsProcessed: 0,
195
+ RecordsCreated: 0,
196
+ RecordsUpdated: 0,
197
+ RecordsDeleted: 0,
198
+ RecordsErrored: 0,
199
+ RecordsSkipped: 0,
200
+ Errors: [],
201
+ };
202
+ let hasMore = true;
203
+ let currentWatermark = initialWatermark;
204
+ let recordsInMap = 0;
205
+ while (hasMore) {
206
+ const ctx = {
207
+ CompanyIntegration: config.companyIntegration,
208
+ ObjectName: entityMap.ExternalObjectName,
209
+ WatermarkValue: currentWatermark,
210
+ BatchSize: this.MaxBatchSize,
211
+ ContextUser: contextUser,
212
+ };
213
+ const batch = await config.connector.FetchChanges(ctx);
214
+ // A7: Batch size enforcement — truncate if connector returns more than MaxBatchSize
215
+ let batchRecords = batch.Records;
216
+ if (batchRecords.length > this.MaxBatchSize) {
217
+ console.warn(`[IntegrationEngine] Connector returned ${batchRecords.length} records, exceeding MaxBatchSize of ${this.MaxBatchSize}. Truncating.`);
218
+ batchRecords = batchRecords.slice(0, this.MaxBatchSize);
219
+ }
220
+ const mapped = this.fieldMappingEngine.Apply(batchRecords, fieldMaps, entityMap.Entity);
221
+ const resolved = await this.matchEngine.Resolve(mapped, entityMap, fieldMaps, contextUser);
222
+ await this.ApplyRecords(resolved, config.companyIntegration, entityMap, result, contextUser);
223
+ recordsInMap += batchRecords.length;
224
+ // A8: Progress tracking
225
+ if (onProgress) {
226
+ this.emitProgress(onProgress, entityMapIndex, totalEntityMaps, recordsInMap, recordsInMap);
227
+ }
228
+ if (batch.NewWatermarkValue) {
229
+ currentWatermark = batch.NewWatermarkValue;
230
+ }
231
+ hasMore = batch.HasMore;
232
+ }
233
+ if (currentWatermark) {
234
+ await this.watermarkService.Update(entityMapID, currentWatermark, contextUser);
235
+ result.WatermarkAfter = currentWatermark;
236
+ }
237
+ await this.CreateRunDetail(run, entityMap, result, contextUser);
238
+ return result;
239
+ }
240
+ /**
241
+ * Emits a progress update via the callback.
242
+ */
243
+ emitProgress(onProgress, entityMapIndex, totalEntityMaps, processedInMap, totalInMap) {
244
+ const mapProgress = totalEntityMaps > 0 ? entityMapIndex / totalEntityMaps : 0;
245
+ const inMapProgress = totalInMap > 0 ? processedInMap / totalInMap : 0;
246
+ const overallPercent = Math.round((mapProgress + inMapProgress / totalEntityMaps) * 100);
247
+ const progress = {
248
+ EntityMapIndex: entityMapIndex,
249
+ TotalEntityMaps: totalEntityMaps,
250
+ RecordsProcessedInCurrentMap: processedInMap,
251
+ TotalRecordsInCurrentMap: totalInMap,
252
+ PercentComplete: Math.min(overallPercent, 100),
253
+ };
254
+ onProgress(progress);
255
+ }
256
+ /**
257
+ * Loads active field maps for a given entity map.
258
+ */
259
+ async LoadFieldMaps(entityMapID, contextUser) {
260
+ const rv = new RunView();
261
+ const result = await rv.RunView({
262
+ EntityName: 'MJ: Company Integration Field Maps',
263
+ ExtraFilter: `EntityMapID='${entityMapID}' AND Status='Active'`,
264
+ OrderBy: 'Priority ASC',
265
+ ResultType: 'entity_object',
266
+ }, contextUser);
267
+ return result.Success ? result.Results : [];
268
+ }
269
+ /**
270
+ * Applies resolved records to MJ, handling each individually for error isolation.
271
+ */
272
+ async ApplyRecords(records, companyIntegration, entityMap, result, contextUser) {
273
+ for (const record of records) {
274
+ result.RecordsProcessed++;
275
+ try {
276
+ await this.ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser);
277
+ }
278
+ catch (err) {
279
+ const classified = ClassifyError(err);
280
+ result.RecordsErrored++;
281
+ result.Errors.push({
282
+ ExternalID: record.ExternalRecord.ExternalID,
283
+ ChangeType: record.ChangeType,
284
+ ErrorMessage: err instanceof Error ? err.message : String(err),
285
+ ErrorCode: classified.Code,
286
+ Severity: classified.Severity,
287
+ ExternalRecord: record.ExternalRecord,
288
+ });
289
+ }
290
+ }
291
+ }
292
+ /**
293
+ * Applies a single record change (Create, Update, Delete, or Skip).
294
+ */
295
+ async ApplySingleRecord(record, companyIntegration, entityMap, result, contextUser) {
296
+ switch (record.ChangeType) {
297
+ case 'Create':
298
+ await this.CreateRecord(record, companyIntegration, entityMap, contextUser);
299
+ result.RecordsCreated++;
300
+ break;
301
+ case 'Update':
302
+ await this.UpdateRecord(record, contextUser);
303
+ result.RecordsUpdated++;
304
+ break;
305
+ case 'Delete':
306
+ await this.DeleteRecord(record, entityMap, contextUser);
307
+ result.RecordsDeleted++;
308
+ break;
309
+ case 'Skip':
310
+ result.RecordsSkipped++;
311
+ break;
312
+ }
313
+ }
314
+ /**
315
+ * Creates a new MJ record with pre-write validation and saves a record map entry.
316
+ */
317
+ async CreateRecord(record, companyIntegration, entityMap, contextUser) {
318
+ const md = new Metadata();
319
+ const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
320
+ entity.NewRecord();
321
+ this.SetEntityFields(entity, record.MappedFields);
322
+ this.SetStandardIntegrationFields(entity, record);
323
+ // A5: Pre-write validation
324
+ this.validateEntity(entity, record.MJEntityName);
325
+ const saved = await entity.Save();
326
+ if (!saved) {
327
+ throw new Error(`Failed to create ${record.MJEntityName} record`);
328
+ }
329
+ // Use the external ID as the entity record identifier in the record map
330
+ await this.SaveRecordMap(companyIntegration.Get('ID'), record.ExternalRecord.ExternalID, entityMap.EntityID, record.ExternalRecord.ExternalID, contextUser);
331
+ }
332
+ /**
333
+ * Updates an existing MJ record with pre-write validation.
334
+ */
335
+ async UpdateRecord(record, contextUser) {
336
+ if (!record.MatchedMJRecordID) {
337
+ throw new Error('Cannot update record without MatchedMJRecordID');
338
+ }
339
+ const md = new Metadata();
340
+ const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
341
+ // Use the entity's actual PK field (SourceRecordID for integration tables, ID for __mj targets)
342
+ const entityInfo = md.Entities.find(e => e.Name === record.MJEntityName);
343
+ const pkFieldName = entityInfo?.FirstPrimaryKey?.Name ?? 'ID';
344
+ const loaded = await entity.InnerLoad(this.BuildCompositeKey(record.MatchedMJRecordID, pkFieldName));
345
+ if (!loaded) {
346
+ throw new Error(`Failed to load ${record.MJEntityName} record ${record.MatchedMJRecordID}`);
347
+ }
348
+ this.SetEntityFields(entity, record.MappedFields);
349
+ this.SetStandardIntegrationFields(entity, record);
350
+ // A5: Pre-write validation
351
+ this.validateEntity(entity, record.MJEntityName);
352
+ const saved = await entity.Save();
353
+ if (!saved) {
354
+ throw new Error(`Failed to update ${record.MJEntityName} record ${record.MatchedMJRecordID}`);
355
+ }
356
+ }
357
+ /**
358
+ * Runs Validate() on an entity if the method exists.
359
+ * Throws a validation error with details if validation fails.
360
+ */
361
+ validateEntity(entity, entityName) {
362
+ if (typeof entity.Validate !== 'function')
363
+ return;
364
+ const validationResult = entity.Validate();
365
+ if (validationResult && !validationResult.Success) {
366
+ const messages = validationResult.Errors?.map(e => e.Message).join('; ') ?? 'Validation failed';
367
+ throw new Error(`Validation failed for ${entityName}: ${messages}`);
368
+ }
369
+ }
370
+ /**
371
+ * Deletes (or soft-deletes) an MJ record based on the entity map's DeleteBehavior.
372
+ */
373
+ async DeleteRecord(record, entityMap, contextUser) {
374
+ if (!record.MatchedMJRecordID) {
375
+ throw new Error('Cannot delete record without MatchedMJRecordID');
376
+ }
377
+ if (entityMap.DeleteBehavior === 'DoNothing')
378
+ return;
379
+ const md = new Metadata();
380
+ const entity = await md.GetEntityObject(record.MJEntityName, contextUser);
381
+ const entityInfo = md.Entities.find(e => e.Name === record.MJEntityName);
382
+ const pkFieldName = entityInfo?.FirstPrimaryKey?.Name ?? 'ID';
383
+ const loaded = await entity.InnerLoad(this.BuildCompositeKey(record.MatchedMJRecordID, pkFieldName));
384
+ if (!loaded) {
385
+ throw new Error(`Failed to load ${record.MJEntityName} for deletion: ${record.MatchedMJRecordID}`);
386
+ }
387
+ const deleted = await entity.Delete();
388
+ if (!deleted) {
389
+ throw new Error(`Failed to delete ${record.MJEntityName} record ${record.MatchedMJRecordID}`);
390
+ }
391
+ }
392
+ /**
393
+ * Builds a CompositeKey for a record lookup.
394
+ * Integration tables use the natural PK field from the source system.
395
+ */
396
+ BuildCompositeKey(id, fieldName = 'ID') {
397
+ const key = new CompositeKey();
398
+ key.KeyValuePairs.push({ FieldName: fieldName, Value: id });
399
+ return key;
400
+ }
401
+ /**
402
+ * Sets fields on a BaseEntity instance from a field value map.
403
+ */
404
+ SetEntityFields(entity, fields) {
405
+ for (const [fieldName, value] of Object.entries(fields)) {
406
+ entity.Set(fieldName, value);
407
+ }
408
+ }
409
+ /**
410
+ * Sets standard integration columns (__mj_integration_*) on target entities.
411
+ * Silently skips if the entity doesn't have these columns (e.g., __mj targets).
412
+ */
413
+ SetStandardIntegrationFields(entity, _record) {
414
+ const fieldNames = entity.Fields?.map(f => f.Name) ?? [];
415
+ const hasField = (name) => fieldNames.includes(name);
416
+ if (hasField('__mj_integration_LastSyncedAt')) {
417
+ entity.Set('__mj_integration_LastSyncedAt', new Date().toISOString());
418
+ }
419
+ if (hasField('__mj_integration_SyncStatus')) {
420
+ entity.Set('__mj_integration_SyncStatus', 'Active');
421
+ }
422
+ }
423
+ /**
424
+ * Creates or updates a CompanyIntegrationRecordMap entry to track the external↔MJ mapping.
425
+ */
426
+ async SaveRecordMap(companyIntegrationID, externalID, entityID, entityRecordID, contextUser) {
427
+ const md = new Metadata();
428
+ const recordMap = await md.GetEntityObject('MJ: Company Integration Record Maps', contextUser);
429
+ recordMap.NewRecord();
430
+ recordMap.CompanyIntegrationID = companyIntegrationID;
431
+ recordMap.ExternalSystemRecordID = externalID;
432
+ recordMap.EntityID = entityID;
433
+ recordMap.EntityRecordID = entityRecordID;
434
+ const saved = await recordMap.Save();
435
+ if (!saved) {
436
+ throw new Error(`Failed to save record map for external ID: ${externalID}`);
437
+ }
438
+ }
439
+ /**
440
+ * Creates a CompanyIntegrationRunDetail record for audit/reporting.
441
+ */
442
+ async CreateRunDetail(run, entityMap, result, contextUser) {
443
+ const md = new Metadata();
444
+ const detail = await md.GetEntityObject('MJ: Company Integration Run Details', contextUser);
445
+ detail.NewRecord();
446
+ detail.CompanyIntegrationRunID = run.Get('ID');
447
+ detail.EntityID = entityMap.EntityID;
448
+ detail.RecordID = `Processed:${result.RecordsProcessed}`;
449
+ detail.Action = result.RecordsCreated > 0 ? 'INSERT' : 'UPDATE';
450
+ detail.IsSuccess = result.RecordsErrored === 0;
451
+ await detail.Save();
452
+ }
453
+ /**
454
+ * Merges an entity-map-level result into the aggregate result.
455
+ */
456
+ MergeResult(aggregate, mapResult) {
457
+ aggregate.RecordsProcessed += mapResult.RecordsProcessed;
458
+ aggregate.RecordsCreated += mapResult.RecordsCreated;
459
+ aggregate.RecordsUpdated += mapResult.RecordsUpdated;
460
+ aggregate.RecordsDeleted += mapResult.RecordsDeleted;
461
+ aggregate.RecordsErrored += mapResult.RecordsErrored;
462
+ aggregate.RecordsSkipped += mapResult.RecordsSkipped;
463
+ aggregate.Errors.push(...mapResult.Errors);
464
+ if (mapResult.RecordsErrored > 0) {
465
+ aggregate.Success = false;
466
+ }
467
+ }
468
+ /**
469
+ * Finalizes a successful run with aggregate totals and emits a completion notification.
470
+ */
471
+ async FinalizeRun(run, result, _contextUser, onNotification) {
472
+ run.EndedAt = new Date();
473
+ run.TotalRecords = result.RecordsProcessed;
474
+ run.Status = result.RecordsErrored > 0 ? 'Failed' : 'Success';
475
+ if (result.Errors.length > 0) {
476
+ run.ErrorLog = JSON.stringify(result.Errors.slice(0, 100));
477
+ }
478
+ await run.Save();
479
+ if (onNotification) {
480
+ const notification = this.buildCompletionNotification(run, result);
481
+ this.safeNotify(onNotification, notification);
482
+ }
483
+ }
484
+ /**
485
+ * Marks a run as failed after an unrecoverable error and emits a failure notification.
486
+ */
487
+ async FailRun(run, err, _contextUser, onNotification) {
488
+ run.EndedAt = new Date();
489
+ run.Status = 'Failed';
490
+ run.ErrorLog = err instanceof Error ? err.message : String(err);
491
+ await run.Save();
492
+ if (onNotification) {
493
+ const failResult = {
494
+ Success: false,
495
+ RecordsProcessed: 0,
496
+ RecordsCreated: 0,
497
+ RecordsUpdated: 0,
498
+ RecordsDeleted: 0,
499
+ RecordsErrored: 0,
500
+ RecordsSkipped: 0,
501
+ Errors: [],
502
+ };
503
+ const notification = this.buildFailureNotification(run, err, failResult);
504
+ this.safeNotify(onNotification, notification);
505
+ }
506
+ }
507
+ /**
508
+ * Builds a SyncNotification for a completed run (success or completed-with-errors).
509
+ */
510
+ buildCompletionNotification(run, result) {
511
+ const hasErrors = result.RecordsErrored > 0;
512
+ const event = hasErrors ? 'SyncCompletedWithErrors' : 'SyncCompleted';
513
+ const severity = hasErrors ? 'Warning' : 'Info';
514
+ const integrationName = run.Get('Integration') ?? 'Unknown Integration';
515
+ const subject = hasErrors
516
+ ? `Integration Sync Completed with Errors: ${integrationName}`
517
+ : `Integration Sync Completed: ${integrationName}`;
518
+ const body = this.buildSyncResultBody(integrationName, result);
519
+ return {
520
+ Event: event,
521
+ Severity: severity,
522
+ CompanyIntegrationID: run.CompanyIntegrationID,
523
+ RunID: run.Get('ID'),
524
+ Subject: subject,
525
+ Body: body,
526
+ Result: result,
527
+ OccurredAt: new Date(),
528
+ };
529
+ }
530
+ /**
531
+ * Builds a SyncNotification for a catastrophically failed run.
532
+ */
533
+ buildFailureNotification(run, err, result) {
534
+ const integrationName = run.Get('Integration') ?? 'Unknown Integration';
535
+ const errorMessage = err instanceof Error ? err.message : String(err);
536
+ const subject = `Integration Sync Failed: ${integrationName}`;
537
+ const body = [
538
+ `Integration: ${integrationName}`,
539
+ `Status: Failed`,
540
+ `Error: ${errorMessage}`,
541
+ `Time: ${new Date().toISOString()}`,
542
+ ].join('\n');
543
+ return {
544
+ Event: 'SyncFailed',
545
+ Severity: 'Error',
546
+ CompanyIntegrationID: run.CompanyIntegrationID,
547
+ RunID: run.Get('ID'),
548
+ Subject: subject,
549
+ Body: body,
550
+ Result: result,
551
+ OccurredAt: new Date(),
552
+ };
553
+ }
554
+ /**
555
+ * Formats a human-readable sync result body for notification messages.
556
+ */
557
+ buildSyncResultBody(integrationName, result) {
558
+ const lines = [
559
+ `Integration: ${integrationName}`,
560
+ `Status: ${result.RecordsErrored > 0 ? 'Completed with errors' : 'Success'}`,
561
+ '',
562
+ 'Record Counts:',
563
+ ` Processed: ${result.RecordsProcessed}`,
564
+ ` Created: ${result.RecordsCreated}`,
565
+ ` Updated: ${result.RecordsUpdated}`,
566
+ ` Deleted: ${result.RecordsDeleted}`,
567
+ ` Skipped: ${result.RecordsSkipped}`,
568
+ ];
569
+ if (result.RecordsErrored > 0) {
570
+ lines.push(` Errors: ${result.RecordsErrored}`);
571
+ if (result.Errors.length > 0) {
572
+ lines.push('', 'Error Summary (first 5):');
573
+ for (const e of result.Errors.slice(0, 5)) {
574
+ lines.push(` [${e.ErrorCode}] ${e.ExternalID}: ${e.ErrorMessage}`);
575
+ }
576
+ }
577
+ }
578
+ lines.push('', `Completed at: ${new Date().toISOString()}`);
579
+ return lines.join('\n');
580
+ }
581
+ /**
582
+ * Invokes the notification callback without propagating exceptions.
583
+ */
584
+ safeNotify(callback, notification) {
585
+ try {
586
+ callback(notification);
587
+ }
588
+ catch (notifyErr) {
589
+ console.warn('[IntegrationEngine] Notification callback threw:', notifyErr);
590
+ }
591
+ }
592
+ // ── Composition: delegate metadata to IntegrationEngineBase ───────
593
+ get Base() {
594
+ return IntegrationEngineBase.Instance;
595
+ }
596
+ /**
597
+ * Configures the engine by loading all integration metadata.
598
+ * Delegates to IntegrationEngineBase.Config().
599
+ */
600
+ async Config(forceRefresh, contextUser, provider) {
601
+ await IntegrationEngineBase.Instance.Config(forceRefresh ?? false, contextUser, provider);
602
+ }
603
+ // ── Metadata Accessors (delegated to Base) ────────────────────────
604
+ get Integrations() {
605
+ return this.Base.Integrations;
606
+ }
607
+ get SourceTypes() {
608
+ return this.Base.SourceTypes;
609
+ }
610
+ get CompanyIntegrations() {
611
+ return this.Base.CompanyIntegrations;
612
+ }
613
+ get EntityMaps() {
614
+ return this.Base.EntityMaps;
615
+ }
616
+ get FieldMaps() {
617
+ return this.Base.FieldMaps;
618
+ }
619
+ get Watermarks() {
620
+ return this.Base.Watermarks;
621
+ }
622
+ // ── Convenience Lookups (delegated to Base) ───────────────────────
623
+ GetIntegrationByID(id) {
624
+ return this.Base.GetIntegrationByID(id);
625
+ }
626
+ GetIntegrationByName(name) {
627
+ return this.Base.GetIntegrationByName(name);
628
+ }
629
+ GetCompanyIntegrationByID(id) {
630
+ return this.Base.GetCompanyIntegrationByID(id);
631
+ }
632
+ GetCompanyIntegrationsByIntegrationID(integrationID) {
633
+ return this.Base.GetCompanyIntegrationsByIntegrationID(integrationID);
634
+ }
635
+ GetEntityMapsForCompanyIntegration(companyIntegrationID) {
636
+ return this.Base.GetEntityMapsForCompanyIntegration(companyIntegrationID);
637
+ }
638
+ GetFieldMapsForEntityMap(entityMapID) {
639
+ return this.Base.GetFieldMapsForEntityMap(entityMapID);
640
+ }
641
+ GetEnabledEntityMaps(companyIntegrationID) {
642
+ return this.Base.GetEnabledEntityMaps(companyIntegrationID);
643
+ }
644
+ GetIntegrationForCompanyIntegration(companyIntegrationID) {
645
+ return this.Base.GetIntegrationForCompanyIntegration(companyIntegrationID);
646
+ }
647
+ // ── Integration Object Metadata (delegated to Base) ──────────────
648
+ get IntegrationObjects() {
649
+ return this.Base.IntegrationObjects;
650
+ }
651
+ get IntegrationObjectFields() {
652
+ return this.Base.IntegrationObjectFields;
653
+ }
654
+ GetIntegrationObjectsByIntegrationID(integrationID) {
655
+ return this.Base.GetIntegrationObjectsByIntegrationID(integrationID);
656
+ }
657
+ GetIntegrationObject(integrationID, objectName) {
658
+ return this.Base.GetIntegrationObject(integrationID, objectName);
659
+ }
660
+ GetIntegrationObjectByID(objectID) {
661
+ return this.Base.GetIntegrationObjectByID(objectID);
662
+ }
663
+ GetIntegrationObjectFields(objectID) {
664
+ return this.Base.GetIntegrationObjectFields(objectID);
665
+ }
666
+ GetActiveIntegrationObjects(integrationID) {
667
+ return this.Base.GetActiveIntegrationObjects(integrationID);
668
+ }
669
+ GetObjectsInDependencyOrder(integrationID) {
670
+ return this.Base.GetObjectsInDependencyOrder(integrationID);
671
+ }
672
+ // ── Singleton ─────────────────────────────────────────────────────
673
+ static get Instance() {
674
+ return IntegrationEngine.getInstance();
675
+ }
676
+ }
677
+ //# sourceMappingURL=IntegrationEngine.js.map