@memberjunction/integration-engine 6.1.0-edge.2 → 6.1.0-edge.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +180 -4
- package/dist/BaseIntegrationConnector.d.ts +115 -4
- package/dist/BaseIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseIntegrationConnector.js +191 -24
- package/dist/BaseIntegrationConnector.js.map +1 -1
- package/dist/BaseRESTIntegrationConnector.d.ts +14 -1
- package/dist/BaseRESTIntegrationConnector.d.ts.map +1 -1
- package/dist/BaseRESTIntegrationConnector.js +50 -3
- package/dist/BaseRESTIntegrationConnector.js.map +1 -1
- package/dist/BatchIdentity.d.ts +47 -0
- package/dist/BatchIdentity.d.ts.map +1 -0
- package/dist/BatchIdentity.js +78 -0
- package/dist/BatchIdentity.js.map +1 -0
- package/dist/DiscoveryWatchdog.d.ts +103 -0
- package/dist/DiscoveryWatchdog.d.ts.map +1 -0
- package/dist/DiscoveryWatchdog.js +166 -0
- package/dist/DiscoveryWatchdog.js.map +1 -0
- package/dist/FieldMapValidation.d.ts +40 -0
- package/dist/FieldMapValidation.d.ts.map +1 -0
- package/dist/FieldMapValidation.js +58 -0
- package/dist/FieldMapValidation.js.map +1 -0
- package/dist/FieldMappingEngine.d.ts +1 -13
- package/dist/FieldMappingEngine.d.ts.map +1 -1
- package/dist/FieldMappingEngine.js +18 -3
- package/dist/FieldMappingEngine.js.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.d.ts +92 -1
- package/dist/IntegrationConnectorCreationPipeline.d.ts.map +1 -1
- package/dist/IntegrationConnectorCreationPipeline.js +186 -11
- package/dist/IntegrationConnectorCreationPipeline.js.map +1 -1
- package/dist/IntegrationEngine.d.ts +198 -21
- package/dist/IntegrationEngine.d.ts.map +1 -1
- package/dist/IntegrationEngine.js +1092 -165
- package/dist/IntegrationEngine.js.map +1 -1
- package/dist/IntegrationSchemaSync.d.ts +8 -0
- package/dist/IntegrationSchemaSync.d.ts.map +1 -1
- package/dist/IntegrationSchemaSync.js +39 -2
- package/dist/IntegrationSchemaSync.js.map +1 -1
- package/dist/KeylessRecordGuard.d.ts +56 -0
- package/dist/KeylessRecordGuard.d.ts.map +1 -0
- package/dist/KeylessRecordGuard.js +80 -0
- package/dist/KeylessRecordGuard.js.map +1 -0
- package/dist/ResumeConcurrency.d.ts +46 -0
- package/dist/ResumeConcurrency.d.ts.map +1 -0
- package/dist/ResumeConcurrency.js +74 -0
- package/dist/ResumeConcurrency.js.map +1 -0
- package/dist/RetryAfter.d.ts +50 -0
- package/dist/RetryAfter.d.ts.map +1 -0
- package/dist/RetryAfter.js +127 -0
- package/dist/RetryAfter.js.map +1 -0
- package/dist/RetryRunner.d.ts +26 -2
- package/dist/RetryRunner.d.ts.map +1 -1
- package/dist/RetryRunner.js +11 -3
- package/dist/RetryRunner.js.map +1 -1
- package/dist/RunOwnershipService.d.ts +177 -0
- package/dist/RunOwnershipService.d.ts.map +1 -0
- package/dist/RunOwnershipService.js +279 -0
- package/dist/RunOwnershipService.js.map +1 -0
- package/dist/SyncDirectives.d.ts +66 -0
- package/dist/SyncDirectives.d.ts.map +1 -0
- package/dist/SyncDirectives.js +123 -0
- package/dist/SyncDirectives.js.map +1 -0
- package/dist/SyncLogger.d.ts +1 -1
- package/dist/SyncLogger.d.ts.map +1 -1
- package/dist/SyncLogger.js.map +1 -1
- package/dist/WatermarkService.d.ts +8 -0
- package/dist/WatermarkService.d.ts.map +1 -1
- package/dist/WatermarkService.js +18 -0
- package/dist/WatermarkService.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +66 -1
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start-of-run validation for the MJ side of a field map.
|
|
3
|
+
*
|
|
4
|
+
* A field map's `DestinationFieldName` is the MJ column, in BOTH directions — pull writes it,
|
|
5
|
+
* push reads it back out to build the external payload. Nothing checks that the column exists.
|
|
6
|
+
* `BaseEntity.Set` looks the field up and, when it finds nothing, returns without doing anything
|
|
7
|
+
* and without saying anything: the value is dropped for every record, the entity is never dirtied
|
|
8
|
+
* by it, the save succeeds, and the run reports the rows as written. The observable result is a
|
|
9
|
+
* column's worth of data that never arrives, on a sync that reports success — and if that field is
|
|
10
|
+
* a key field, every record is re-created on the next run because it can never be matched again.
|
|
11
|
+
*
|
|
12
|
+
* This is the cheap check that turns that into one warning per map per run: the map is metadata,
|
|
13
|
+
* so it is verifiable the moment the run knows its entity, long before the first record is fetched.
|
|
14
|
+
* The decision is kept pure so it is tested without a provider or an entity.
|
|
15
|
+
*/
|
|
16
|
+
/** The parts of a field map this validation reads. */
|
|
17
|
+
export type ValidatableFieldMap = {
|
|
18
|
+
SourceFieldName: string;
|
|
19
|
+
DestinationFieldName: string;
|
|
20
|
+
Status?: string | null;
|
|
21
|
+
IsKeyField?: boolean | null;
|
|
22
|
+
};
|
|
23
|
+
/** One map that cannot bind, with the detail an operator needs to fix it. */
|
|
24
|
+
export type UnbindableFieldMap = {
|
|
25
|
+
SourceFieldName: string;
|
|
26
|
+
DestinationFieldName: string;
|
|
27
|
+
IsKeyField: boolean;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Returns the ACTIVE field maps whose destination column does not exist on the target entity.
|
|
31
|
+
*
|
|
32
|
+
* Matching is case-insensitive, because `BaseEntity` resolves field names case-insensitively — a
|
|
33
|
+
* map that differs only in casing binds fine and is not a finding. An empty `entityFieldNames`
|
|
34
|
+
* means the caller could not resolve the entity at all; that is a different (already reported)
|
|
35
|
+
* condition, so nothing is returned rather than flagging every map.
|
|
36
|
+
*/
|
|
37
|
+
export declare function FindUnbindableFieldMaps(fieldMaps: readonly ValidatableFieldMap[], entityFieldNames: readonly string[]): UnbindableFieldMap[];
|
|
38
|
+
/** The operator-facing sentence for a set of unbindable maps on one entity map. */
|
|
39
|
+
export declare function DescribeUnbindableFieldMaps(unbindable: readonly UnbindableFieldMap[], externalObjectName: string, entityName: string): string;
|
|
40
|
+
//# sourceMappingURL=FieldMapValidation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FieldMapValidation.d.ts","sourceRoot":"","sources":["../src/FieldMapValidation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,sDAAsD;AACtD,MAAM,MAAM,mBAAmB,GAAG;IAC9B,eAAe,EAAE,MAAM,CAAC;IACxB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,UAAU,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;CAC/B,CAAC;AAEF,6EAA6E;AAC7E,MAAM,MAAM,kBAAkB,GAAG;IAC7B,eAAe,EAAE,MAAM,CAAC;IACxB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,UAAU,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACnC,SAAS,EAAE,SAAS,mBAAmB,EAAE,EACzC,gBAAgB,EAAE,SAAS,MAAM,EAAE,GACpC,kBAAkB,EAAE,CAgBtB;AAED,mFAAmF;AACnF,wBAAgB,2BAA2B,CACvC,UAAU,EAAE,SAAS,kBAAkB,EAAE,EACzC,kBAAkB,EAAE,MAAM,EAC1B,UAAU,EAAE,MAAM,GACnB,MAAM,CAaR"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start-of-run validation for the MJ side of a field map.
|
|
3
|
+
*
|
|
4
|
+
* A field map's `DestinationFieldName` is the MJ column, in BOTH directions — pull writes it,
|
|
5
|
+
* push reads it back out to build the external payload. Nothing checks that the column exists.
|
|
6
|
+
* `BaseEntity.Set` looks the field up and, when it finds nothing, returns without doing anything
|
|
7
|
+
* and without saying anything: the value is dropped for every record, the entity is never dirtied
|
|
8
|
+
* by it, the save succeeds, and the run reports the rows as written. The observable result is a
|
|
9
|
+
* column's worth of data that never arrives, on a sync that reports success — and if that field is
|
|
10
|
+
* a key field, every record is re-created on the next run because it can never be matched again.
|
|
11
|
+
*
|
|
12
|
+
* This is the cheap check that turns that into one warning per map per run: the map is metadata,
|
|
13
|
+
* so it is verifiable the moment the run knows its entity, long before the first record is fetched.
|
|
14
|
+
* The decision is kept pure so it is tested without a provider or an entity.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Returns the ACTIVE field maps whose destination column does not exist on the target entity.
|
|
18
|
+
*
|
|
19
|
+
* Matching is case-insensitive, because `BaseEntity` resolves field names case-insensitively — a
|
|
20
|
+
* map that differs only in casing binds fine and is not a finding. An empty `entityFieldNames`
|
|
21
|
+
* means the caller could not resolve the entity at all; that is a different (already reported)
|
|
22
|
+
* condition, so nothing is returned rather than flagging every map.
|
|
23
|
+
*/
|
|
24
|
+
export function FindUnbindableFieldMaps(fieldMaps, entityFieldNames) {
|
|
25
|
+
if (entityFieldNames.length === 0)
|
|
26
|
+
return [];
|
|
27
|
+
const known = new Set(entityFieldNames.map(n => n.toLowerCase()));
|
|
28
|
+
const unbindable = [];
|
|
29
|
+
for (const fm of fieldMaps) {
|
|
30
|
+
if (fm.Status !== 'Active')
|
|
31
|
+
continue; // an inactive map is never applied
|
|
32
|
+
const dest = fm.DestinationFieldName;
|
|
33
|
+
if (!dest)
|
|
34
|
+
continue; // no destination is a different defect
|
|
35
|
+
if (known.has(dest.toLowerCase()))
|
|
36
|
+
continue;
|
|
37
|
+
unbindable.push({
|
|
38
|
+
SourceFieldName: fm.SourceFieldName,
|
|
39
|
+
DestinationFieldName: dest,
|
|
40
|
+
IsKeyField: fm.IsKeyField === true,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return unbindable;
|
|
44
|
+
}
|
|
45
|
+
/** The operator-facing sentence for a set of unbindable maps on one entity map. */
|
|
46
|
+
export function DescribeUnbindableFieldMaps(unbindable, externalObjectName, entityName) {
|
|
47
|
+
const pairs = unbindable.map(u => `${u.SourceFieldName} -> ${u.DestinationFieldName}${u.IsKeyField ? ' (KEY)' : ''}`);
|
|
48
|
+
const keyCount = unbindable.filter(u => u.IsKeyField).length;
|
|
49
|
+
return (`${unbindable.length} active field map(s) for '${externalObjectName}' target a column that does not exist ` +
|
|
50
|
+
`on '${entityName}': ${pairs.join(', ')}. Those values are silently dropped for every record — the write ` +
|
|
51
|
+
`succeeds without them` +
|
|
52
|
+
(keyCount > 0
|
|
53
|
+
? `, and because ${keyCount === 1 ? 'one of them is a KEY field' : `${keyCount} of them are KEY fields`}, ` +
|
|
54
|
+
`records cannot be matched on a later run and will be re-created.`
|
|
55
|
+
: `.`) +
|
|
56
|
+
` Apply the schema so the column exists, or point the map at a column that does.`);
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=FieldMapValidation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FieldMapValidation.js","sourceRoot":"","sources":["../src/FieldMapValidation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAiBH;;;;;;;GAOG;AACH,MAAM,UAAU,uBAAuB,CACnC,SAAyC,EACzC,gBAAmC;IAEnC,IAAI,gBAAgB,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAC7C,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IAClE,MAAM,UAAU,GAAyB,EAAE,CAAC;IAC5C,KAAK,MAAM,EAAE,IAAI,SAAS,EAAE,CAAC;QACzB,IAAI,EAAE,CAAC,MAAM,KAAK,QAAQ;YAAE,SAAS,CAAa,mCAAmC;QACrF,MAAM,IAAI,GAAG,EAAE,CAAC,oBAAoB,CAAC;QACrC,IAAI,CAAC,IAAI;YAAE,SAAS,CAA8B,uCAAuC;QACzF,IAAI,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC;YAAE,SAAS;QAC5C,UAAU,CAAC,IAAI,CAAC;YACZ,eAAe,EAAE,EAAE,CAAC,eAAe;YACnC,oBAAoB,EAAE,IAAI;YAC1B,UAAU,EAAE,EAAE,CAAC,UAAU,KAAK,IAAI;SACrC,CAAC,CAAC;IACP,CAAC;IACD,OAAO,UAAU,CAAC;AACtB,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,2BAA2B,CACvC,UAAyC,EACzC,kBAA0B,EAC1B,UAAkB;IAElB,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,eAAe,OAAO,CAAC,CAAC,oBAAoB,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACtH,MAAM,QAAQ,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC;IAC7D,OAAO,CACH,GAAG,UAAU,CAAC,MAAM,6BAA6B,kBAAkB,wCAAwC;QAC3G,OAAO,UAAU,MAAM,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,mEAAmE;QAC1G,uBAAuB;QACvB,CAAC,QAAQ,GAAG,CAAC;YACT,CAAC,CAAC,iBAAiB,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,4BAA4B,CAAC,CAAC,CAAC,GAAG,QAAQ,yBAAyB,IAAI;gBACzG,kEAAkE;YACpE,CAAC,CAAC,GAAG,CAAC;QACV,iFAAiF,CACpF,CAAC;AACN,CAAC"}
|
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
import type { ICompanyIntegrationFieldMap } from './entity-types.js';
|
|
2
2
|
import type { ExternalRecord, MappedRecord } from './types.js';
|
|
3
|
-
/**
|
|
4
|
-
* Engine responsible for applying field-level mappings and transformations from external records to
|
|
5
|
-
* MJ entity fields.
|
|
6
|
-
*
|
|
7
|
-
* The per-value transform pipeline (direct / regex / split / combine / lookup / format / coerce /
|
|
8
|
-
* substring / custom) is owned by the shared {@link FieldTransformEngine} in `@memberjunction/global`
|
|
9
|
-
* and reused here, so integration sync and the rules-based bulk-update processor run the *exact same*
|
|
10
|
-
* transform implementation — one place to fix, one place to extend. This engine keeps only the
|
|
11
|
-
* integration-specific concerns: source flattening, field-map iteration, and unmapped-field overflow
|
|
12
|
-
* capture. See the Field Rules guide in `@memberjunction/global` for the shared engine, and
|
|
13
|
-
* `EntityFieldRules` in `@memberjunction/core` for the metadata-aware entity-update sibling.
|
|
14
|
-
*/
|
|
15
3
|
export declare class FieldMappingEngine {
|
|
16
4
|
/** The shared transform pipeline. Holds its own LRU cache of compiled custom expressions. */
|
|
17
5
|
private readonly transformEngine;
|
|
@@ -24,7 +12,7 @@ export declare class FieldMappingEngine {
|
|
|
24
12
|
* @param entityName - Target MJ entity name
|
|
25
13
|
* @returns Array of mapped records with transformed field values
|
|
26
14
|
*/
|
|
27
|
-
Apply(records: ExternalRecord[], fieldMaps: ICompanyIntegrationFieldMap[], entityName: string): MappedRecord[];
|
|
15
|
+
Apply(records: ExternalRecord[], fieldMaps: ICompanyIntegrationFieldMap[], entityName: string, excludedSourceNames?: ReadonlySet<string>): MappedRecord[];
|
|
28
16
|
/**
|
|
29
17
|
* Maps a single external record through all active field mappings.
|
|
30
18
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FieldMappingEngine.d.ts","sourceRoot":"","sources":["../src/FieldMappingEngine.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"FieldMappingEngine.d.ts","sourceRoot":"","sources":["../src/FieldMappingEngine.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAkB/D,qBAAa,kBAAkB;IAC3B,6FAA6F;IAC7F,OAAO,CAAC,QAAQ,CAAC,eAAe,CAA8B;IAE9D;;;;;;;;OAQG;IACI,KAAK,CACR,OAAO,EAAE,cAAc,EAAE,EACzB,SAAS,EAAE,2BAA2B,EAAE,EACxC,UAAU,EAAE,MAAM,EAClB,mBAAmB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAC1C,YAAY,EAAE;IAMjB;;OAEG;IACH,OAAO,CAAC,eAAe;IAqDvB;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IAUzB;;OAEG;IACH,OAAO,CAAC,sBAAsB;CAWjC"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FieldTransformEngine } from '@memberjunction/global';
|
|
2
2
|
import { computeUnmappedFields } from './CustomOverflow.js';
|
|
3
|
+
import { StripExcludedFields } from './SyncDirectives.js';
|
|
3
4
|
import { flattenRecord, hasNestedObject } from './RecordFlatten.js';
|
|
4
5
|
/**
|
|
5
6
|
* Engine responsible for applying field-level mappings and transformations from external records to
|
|
@@ -13,6 +14,8 @@ import { flattenRecord, hasNestedObject } from './RecordFlatten.js';
|
|
|
13
14
|
* capture. See the Field Rules guide in `@memberjunction/global` for the shared engine, and
|
|
14
15
|
* `EntityFieldRules` in `@memberjunction/core` for the metadata-aware entity-update sibling.
|
|
15
16
|
*/
|
|
17
|
+
/** Shared empty set for the common no-exclusions call. */
|
|
18
|
+
const EMPTY_EXCLUSIONS = new Set();
|
|
16
19
|
export class FieldMappingEngine {
|
|
17
20
|
constructor() {
|
|
18
21
|
/** The shared transform pipeline. Holds its own LRU cache of compiled custom expressions. */
|
|
@@ -27,14 +30,26 @@ export class FieldMappingEngine {
|
|
|
27
30
|
* @param entityName - Target MJ entity name
|
|
28
31
|
* @returns Array of mapped records with transformed field values
|
|
29
32
|
*/
|
|
30
|
-
Apply(records, fieldMaps, entityName) {
|
|
33
|
+
Apply(records, fieldMaps, entityName, excludedSourceNames) {
|
|
31
34
|
const activeMaps = fieldMaps.filter(fm => fm.Status === 'Active');
|
|
32
|
-
|
|
35
|
+
const excluded = excludedSourceNames ?? EMPTY_EXCLUSIONS;
|
|
36
|
+
return records.map(record => this.MapSingleRecord(record, activeMaps, entityName, excluded));
|
|
33
37
|
}
|
|
34
38
|
/**
|
|
35
39
|
* Maps a single external record through all active field mappings.
|
|
36
40
|
*/
|
|
37
|
-
MapSingleRecord(record, fieldMaps, entityName) {
|
|
41
|
+
MapSingleRecord(record, fieldMaps, entityName, excludedSourceNames) {
|
|
42
|
+
// Field-level exclusions strip BEFORE flatten and BEFORE mapping, so an excluded
|
|
43
|
+
// key reaches neither MappedFields, nor computeUnmappedFields (-> CustomOverflow),
|
|
44
|
+
// nor the content hash (whose basis is MappedFields). Deactivating a field map
|
|
45
|
+
// does NOT do this - the unmapped key would flow into overflow instead.
|
|
46
|
+
// No-exclusions and no-match paths return the original object: zero allocation.
|
|
47
|
+
if (excludedSourceNames.size > 0) {
|
|
48
|
+
const stripped = StripExcludedFields(record.Fields, excludedSourceNames);
|
|
49
|
+
if (stripped !== record.Fields) {
|
|
50
|
+
record = { ...record, Fields: stripped };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
38
53
|
// Flatten nested source objects to scalar columns (e.g. checkin_question.id →
|
|
39
54
|
// checkin_question_id) BEFORE mapping. The match keys on the mapped PK VALUE
|
|
40
55
|
// (MatchEngine.FindByKeyFields), so an object-valued source field would produce a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FieldMappingEngine.js","sourceRoot":"","sources":["../src/FieldMappingEngine.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAC9D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAKpE;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,kBAAkB;IAA/B;QACI,6FAA6F;QAC5E,oBAAe,GAAG,IAAI,oBAAoB,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"FieldMappingEngine.js","sourceRoot":"","sources":["../src/FieldMappingEngine.ts"],"names":[],"mappings":"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"}
|
|
@@ -45,6 +45,29 @@ export interface ConnectorCreationPipelineOptions {
|
|
|
45
45
|
* false so it never disables what it didn't look at. Threaded to PersistDiscoveredSchema.
|
|
46
46
|
*/
|
|
47
47
|
DeactivateAbsent?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Hard ceiling for the WHOLE run. Default {@link DEFAULT_RUN_DEADLINE_MS}; 0 disables it.
|
|
50
|
+
*
|
|
51
|
+
* Every other budget in this system bounds something INSIDE a stage, and none of them can preempt
|
|
52
|
+
* an `await` that never settles. A connector's `outOfTime()` is only checked BETWEEN requests; an
|
|
53
|
+
* HTTP abort signal governs only its own request; the discovery sample budget is spent by the code
|
|
54
|
+
* reading the stream. There is always one more layer able to stall — and when one does, this
|
|
55
|
+
* pipeline waits on it forever.
|
|
56
|
+
*
|
|
57
|
+
* Forever is literal. `complete()` and `fail()` are the only writers of `result.json` and both sit
|
|
58
|
+
* inside the try/catch around the stages, so a stage that never returns reaches neither. Since
|
|
59
|
+
* `isInFlight` is computed as "result.json is absent", the run then reports itself running for the
|
|
60
|
+
* rest of time: no client can learn otherwise and no retry clears it.
|
|
61
|
+
*
|
|
62
|
+
* Observed live 2026-08-12 three times on one connector: ConnectionTest completes in ~1s, Introspect
|
|
63
|
+
* starts, and the event stream is flat for ten minutes and counting — against a reference run that
|
|
64
|
+
* finished the entire pipeline in 3m53s.
|
|
65
|
+
*
|
|
66
|
+
* This does NOT stop the stalled work; a promise is not cancellable, so it keeps running until the
|
|
67
|
+
* process ends. It stops WAITING on it, so the run fails honestly, writes its artifact, and becomes
|
|
68
|
+
* retryable. A reported failure you can act on beats silence you cannot.
|
|
69
|
+
*/
|
|
70
|
+
RunDeadlineMs?: number;
|
|
48
71
|
}
|
|
49
72
|
/** Outcome of a single pipeline invocation. */
|
|
50
73
|
export interface ConnectorCreationPipelineResult {
|
|
@@ -84,8 +107,41 @@ export interface ConnectorCreationPipelineResult {
|
|
|
84
107
|
* `__mj.Entity`. The pipeline emits `entity.skipped-no-pk` events for visibility.
|
|
85
108
|
*/
|
|
86
109
|
export declare class IntegrationConnectorCreationPipeline {
|
|
87
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* In-flight runs by CompanyIntegrationID — coalesces a concurrent duplicate onto the same promise.
|
|
112
|
+
*
|
|
113
|
+
* Carries `at` because the entry is removed in a `finally`, which only fires when the promise
|
|
114
|
+
* SETTLES. A run that hangs therefore owns this slot forever, and every later refresh for that
|
|
115
|
+
* connector takes the `if (inFlight) return inFlight` path and attaches to a promise that will
|
|
116
|
+
* never resolve. No new run starts, no run.start is emitted, nothing reaches the workspace log —
|
|
117
|
+
* from outside the request simply vanishes, and the connector is unrefreshable until the process
|
|
118
|
+
* restarts.
|
|
119
|
+
*
|
|
120
|
+
* That is the whole explanation for behaviour that looked random for two days: a fresh process
|
|
121
|
+
* discovers in ~4 minutes; one hang poisons the slot; every attempt after it hangs; a restart
|
|
122
|
+
* clears the map and it "works again" until the next hang. Observed live 2026-08-12 — a run stuck
|
|
123
|
+
* at EventCount 5 with healthy runs on either side of it.
|
|
124
|
+
*
|
|
125
|
+
* Coalescing is right for concurrent callers, but it is only SAFE if runs terminate, and nothing
|
|
126
|
+
* guarantees that. So the entry expires: past {@link IN_FLIGHT_MAX_AGE_MS} a caller stops trusting
|
|
127
|
+
* it and runs fresh. That does not stop the stalled work (a promise is not cancellable) — it stops
|
|
128
|
+
* one hang from costing every future attempt.
|
|
129
|
+
*/
|
|
88
130
|
private static readonly inFlightRuns;
|
|
131
|
+
/**
|
|
132
|
+
* How long a coalescing entry may be trusted before a new caller runs fresh instead of joining it.
|
|
133
|
+
*
|
|
134
|
+
* Generous on purpose: a legitimate large discovery must still coalesce, and re-running one is
|
|
135
|
+
* expensive. This is not a run deadline — it bounds how long ONE hang can hijack other people's
|
|
136
|
+
* requests, nothing more.
|
|
137
|
+
*/
|
|
138
|
+
private static readonly IN_FLIGHT_MAX_AGE_MS;
|
|
139
|
+
/**
|
|
140
|
+
* Default whole-run ceiling. Deliberately far above any healthy run — the reference Totara run
|
|
141
|
+
* completes in under four minutes and a large Salesforce-backed catalog in tens — so this only ever
|
|
142
|
+
* fires on work that has genuinely stopped, never on work that is merely big.
|
|
143
|
+
*/
|
|
144
|
+
private static readonly DEFAULT_RUN_DEADLINE_MS;
|
|
89
145
|
/** Just-completed runs by CompanyIntegrationID — coalesces a *sequential* duplicate within the window. */
|
|
90
146
|
private static readonly recentRuns;
|
|
91
147
|
/** Default coalesce window (ms) when the env override is unset/invalid. */
|
|
@@ -106,8 +162,43 @@ export declare class IntegrationConnectorCreationPipeline {
|
|
|
106
162
|
* per CompanyIntegration and hand both callers the same result — no double introspect/persist/
|
|
107
163
|
* classify, no double live API calls, and the resolver still gets a real summary. A legitimate
|
|
108
164
|
* re-refresh later (outside the window) runs fresh.
|
|
165
|
+
*
|
|
166
|
+
* COALESCING vs. A CALLER-SUPPLIED `RunID`. A coalesced call never reaches `runInternal`, so
|
|
167
|
+
* `opts.RunID` is not the ID of the run that served it. That is fine for a caller who awaits the
|
|
168
|
+
* result (it gets the real `RunID` back), but NOT for a caller that already handed `opts.RunID` to
|
|
169
|
+
* a client as "the run to tail" — for that client, the ID would resolve to a run directory that
|
|
170
|
+
* never gets created, and `IntegrationTailRunEvents` would answer "Run not found" forever, which
|
|
171
|
+
* is indistinguishable from "the run hasn't opened its stream yet". So whenever a caller supplied
|
|
172
|
+
* a `RunID` and coalescing served a different run, we publish a terminal ALIAS run under the
|
|
173
|
+
* requested ID pointing at the run that actually did the work. See {@link honourRequestedRunID}.
|
|
109
174
|
*/
|
|
110
175
|
Run(opts: ConnectorCreationPipelineOptions): Promise<ConnectorCreationPipelineResult>;
|
|
176
|
+
/**
|
|
177
|
+
* Keeps a caller-supplied `opts.RunID` tailable even when coalescing served the call from a
|
|
178
|
+
* DIFFERENT run.
|
|
179
|
+
*
|
|
180
|
+
* Why this exists: `IntegrationCreateConnection`/`IntegrationUpdateConnection` can launch the
|
|
181
|
+
* refresh detached — they mint a run ID, hand it to the client as "tail this", and only then call
|
|
182
|
+
* `Run()`. On create, the connection's `IsActive` false→true Save has ALREADY awaited a full
|
|
183
|
+
* pipeline run for the same CompanyIntegration (MJCompanyIntegrationEntityServer), so the
|
|
184
|
+
* resolver's call lands inside the coalesce window every time. Without this, the minted ID names
|
|
185
|
+
* a run directory that is never created: the detached promise RESOLVES (so the launcher's
|
|
186
|
+
* rejection handler never fires) and the client polls `IntegrationTailRunEvents` forever on
|
|
187
|
+
* `Run '<id>' not found`, which reads exactly like "not started yet".
|
|
188
|
+
*
|
|
189
|
+
* So: publish a real, terminal, one-stage run under the requested ID whose events name the run
|
|
190
|
+
* that actually did the work. The tail resolves, carries the served run's outcome, and
|
|
191
|
+
* `data.servedByRunID` lets a client hop to the full stream. The returned result is the served
|
|
192
|
+
* run unchanged — a caller that awaits still gets the true `RunID`.
|
|
193
|
+
*/
|
|
194
|
+
private honourRequestedRunID;
|
|
195
|
+
/**
|
|
196
|
+
* Writes the alias run described in {@link honourRequestedRunID}. Best-effort: an artifact-write
|
|
197
|
+
* failure must never turn a schema refresh that genuinely succeeded into a thrown error, so this
|
|
198
|
+
* only logs. The alias mirrors the served run's success/failure so a client tailing it cannot
|
|
199
|
+
* read a failed refresh as a clean one.
|
|
200
|
+
*/
|
|
201
|
+
private publishCoalescedAlias;
|
|
111
202
|
/** Drops recent-run entries older than the coalesce window so the map stays bounded. */
|
|
112
203
|
private static pruneRecentRuns;
|
|
113
204
|
private runInternal;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IntegrationConnectorCreationPipeline.d.ts","sourceRoot":"","sources":["../src/IntegrationConnectorCreationPipeline.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAExE,OAAO,KAAK,EACR,0BAA0B,EAG7B,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAEH,KAAK,sBAAsB,EAC9B,MAAM,gDAAgD,CAAC;AACxD,OAAO,EAEH,KAAK,kBAAkB,EAC1B,MAAM,2CAA2C,CAAC;AACnD,OAAO,EAAE,wBAAwB,EAAuD,MAAM,+BAA+B,CAAC;AAE9H,OAAO,EAAyB,KAAK,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAC7F,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D,qDAAqD;AACrD,MAAM,WAAW,gCAAgC;IAC7C,uEAAuE;IACvE,SAAS,EAAE,wBAAwB,CAAC;IACpC,mDAAmD;IACnD,kBAAkB,EAAE,0BAA0B,CAAC;IAC/C,8CAA8C;IAC9C,WAAW,EAAE,QAAQ,CAAC;IACtB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,+EAA+E;IAC/E,iBAAiB,CAAC,EAAE,uBAAuB,CAAC;IAC5C,uEAAuE;IACvE,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,kBAAkB,CAAC;IAClC,gFAAgF;IAChF,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACpE;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kDAAkD;IAClD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+CAA+C;IAC/C,WAAW,CAAC,EAAE,QAAQ,GAAG,WAAW,GAAG,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;IAC1E;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"IntegrationConnectorCreationPipeline.d.ts","sourceRoot":"","sources":["../src/IntegrationConnectorCreationPipeline.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Metadata } from '@memberjunction/core';
|
|
1
|
+
import { LogError, Metadata } from '@memberjunction/core';
|
|
2
2
|
import { IntegrationProgressEmitter, } from '@memberjunction/integration-progress-artifacts';
|
|
3
3
|
import { SoftPKClassifier, } from '@memberjunction/integration-pk-classifier';
|
|
4
4
|
import { IntegrationEngineBase } from '@memberjunction/integration-engine-base';
|
|
@@ -21,8 +21,41 @@ import { IntegrationSchemaSync } from './IntegrationSchemaSync.js';
|
|
|
21
21
|
* `__mj.Entity`. The pipeline emits `entity.skipped-no-pk` events for visibility.
|
|
22
22
|
*/
|
|
23
23
|
export class IntegrationConnectorCreationPipeline {
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* In-flight runs by CompanyIntegrationID — coalesces a concurrent duplicate onto the same promise.
|
|
26
|
+
*
|
|
27
|
+
* Carries `at` because the entry is removed in a `finally`, which only fires when the promise
|
|
28
|
+
* SETTLES. A run that hangs therefore owns this slot forever, and every later refresh for that
|
|
29
|
+
* connector takes the `if (inFlight) return inFlight` path and attaches to a promise that will
|
|
30
|
+
* never resolve. No new run starts, no run.start is emitted, nothing reaches the workspace log —
|
|
31
|
+
* from outside the request simply vanishes, and the connector is unrefreshable until the process
|
|
32
|
+
* restarts.
|
|
33
|
+
*
|
|
34
|
+
* That is the whole explanation for behaviour that looked random for two days: a fresh process
|
|
35
|
+
* discovers in ~4 minutes; one hang poisons the slot; every attempt after it hangs; a restart
|
|
36
|
+
* clears the map and it "works again" until the next hang. Observed live 2026-08-12 — a run stuck
|
|
37
|
+
* at EventCount 5 with healthy runs on either side of it.
|
|
38
|
+
*
|
|
39
|
+
* Coalescing is right for concurrent callers, but it is only SAFE if runs terminate, and nothing
|
|
40
|
+
* guarantees that. So the entry expires: past {@link IN_FLIGHT_MAX_AGE_MS} a caller stops trusting
|
|
41
|
+
* it and runs fresh. That does not stop the stalled work (a promise is not cancellable) — it stops
|
|
42
|
+
* one hang from costing every future attempt.
|
|
43
|
+
*/
|
|
25
44
|
static { this.inFlightRuns = new Map(); }
|
|
45
|
+
/**
|
|
46
|
+
* How long a coalescing entry may be trusted before a new caller runs fresh instead of joining it.
|
|
47
|
+
*
|
|
48
|
+
* Generous on purpose: a legitimate large discovery must still coalesce, and re-running one is
|
|
49
|
+
* expensive. This is not a run deadline — it bounds how long ONE hang can hijack other people's
|
|
50
|
+
* requests, nothing more.
|
|
51
|
+
*/
|
|
52
|
+
static { this.IN_FLIGHT_MAX_AGE_MS = 20 * 60_000; }
|
|
53
|
+
/**
|
|
54
|
+
* Default whole-run ceiling. Deliberately far above any healthy run — the reference Totara run
|
|
55
|
+
* completes in under four minutes and a large Salesforce-backed catalog in tens — so this only ever
|
|
56
|
+
* fires on work that has genuinely stopped, never on work that is merely big.
|
|
57
|
+
*/
|
|
58
|
+
static { this.DEFAULT_RUN_DEADLINE_MS = 45 * 60_000; }
|
|
26
59
|
/** Just-completed runs by CompanyIntegrationID — coalesces a *sequential* duplicate within the window. */
|
|
27
60
|
static { this.recentRuns = new Map(); }
|
|
28
61
|
/** Default coalesce window (ms) when the env override is unset/invalid. */
|
|
@@ -46,6 +79,15 @@ export class IntegrationConnectorCreationPipeline {
|
|
|
46
79
|
* per CompanyIntegration and hand both callers the same result — no double introspect/persist/
|
|
47
80
|
* classify, no double live API calls, and the resolver still gets a real summary. A legitimate
|
|
48
81
|
* re-refresh later (outside the window) runs fresh.
|
|
82
|
+
*
|
|
83
|
+
* COALESCING vs. A CALLER-SUPPLIED `RunID`. A coalesced call never reaches `runInternal`, so
|
|
84
|
+
* `opts.RunID` is not the ID of the run that served it. That is fine for a caller who awaits the
|
|
85
|
+
* result (it gets the real `RunID` back), but NOT for a caller that already handed `opts.RunID` to
|
|
86
|
+
* a client as "the run to tail" — for that client, the ID would resolve to a run directory that
|
|
87
|
+
* never gets created, and `IntegrationTailRunEvents` would answer "Run not found" forever, which
|
|
88
|
+
* is indistinguishable from "the run hasn't opened its stream yet". So whenever a caller supplied
|
|
89
|
+
* a `RunID` and coalescing served a different run, we publish a terminal ALIAS run under the
|
|
90
|
+
* requested ID pointing at the run that actually did the work. See {@link honourRequestedRunID}.
|
|
49
91
|
*/
|
|
50
92
|
async Run(opts) {
|
|
51
93
|
const ciID = opts.CompanyIntegration?.ID;
|
|
@@ -53,21 +95,109 @@ export class IntegrationConnectorCreationPipeline {
|
|
|
53
95
|
return this.runInternal(opts); // no key to de-dup on — run directly
|
|
54
96
|
const cls = IntegrationConnectorCreationPipeline;
|
|
55
97
|
const inFlight = cls.inFlightRuns.get(ciID);
|
|
56
|
-
if (inFlight)
|
|
57
|
-
|
|
98
|
+
if (inFlight) {
|
|
99
|
+
if (Date.now() - inFlight.at < cls.IN_FLIGHT_MAX_AGE_MS) {
|
|
100
|
+
// A concurrent run is already going — share it, but THROUGH honourRequestedRunID so a
|
|
101
|
+
// caller that supplied its own RunID still gets that ID published as a tailable alias.
|
|
102
|
+
// Returning `inFlight.promise` directly (as this branch did before the rebase) discards
|
|
103
|
+
// the requested ID, which is exactly the defect #3354 fixed: the client polls a run
|
|
104
|
+
// directory that is never created and reads "Run not found" forever, indistinguishable
|
|
105
|
+
// from "hasn't started yet".
|
|
106
|
+
return this.honourRequestedRunID(opts, await inFlight.promise);
|
|
107
|
+
}
|
|
108
|
+
// Too old to be believed. Evicted rather than awaited: joining it is how one hang made a
|
|
109
|
+
// connector permanently unrefreshable. The stalled work carries on unattended — nothing
|
|
110
|
+
// here can cancel it — but this caller gets a real run instead of inheriting the stall.
|
|
111
|
+
LogError(`[ConnectorCreationPipeline] Discarding an in-flight run for ${ciID} that has not settled in ` +
|
|
112
|
+
`${Math.round((Date.now() - inFlight.at) / 60000)}min — starting a fresh run. The previous run is ` +
|
|
113
|
+
`stuck and will never terminate; its artifact stays in-flight until the workspace restarts.`);
|
|
114
|
+
cls.inFlightRuns.delete(ciID);
|
|
115
|
+
}
|
|
58
116
|
cls.pruneRecentRuns();
|
|
59
117
|
const recent = cls.recentRuns.get(ciID);
|
|
60
118
|
if (recent)
|
|
61
|
-
return recent.result; //
|
|
119
|
+
return this.honourRequestedRunID(opts, recent.result); // just completed — reuse it
|
|
62
120
|
const promise = this.runInternal(opts);
|
|
63
|
-
cls.inFlightRuns.set(ciID, promise);
|
|
121
|
+
cls.inFlightRuns.set(ciID, { promise, at: Date.now() });
|
|
64
122
|
try {
|
|
65
123
|
const result = await promise;
|
|
66
124
|
cls.recentRuns.set(ciID, { result, at: Date.now() });
|
|
67
125
|
return result;
|
|
68
126
|
}
|
|
69
127
|
finally {
|
|
70
|
-
|
|
128
|
+
// Only clear the slot if it is still OURS. An eviction above may have handed it to a newer
|
|
129
|
+
// run; deleting blindly would drop that entry and let a third caller start yet another
|
|
130
|
+
// duplicate.
|
|
131
|
+
if (cls.inFlightRuns.get(ciID)?.promise === promise) {
|
|
132
|
+
cls.inFlightRuns.delete(ciID);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Keeps a caller-supplied `opts.RunID` tailable even when coalescing served the call from a
|
|
138
|
+
* DIFFERENT run.
|
|
139
|
+
*
|
|
140
|
+
* Why this exists: `IntegrationCreateConnection`/`IntegrationUpdateConnection` can launch the
|
|
141
|
+
* refresh detached — they mint a run ID, hand it to the client as "tail this", and only then call
|
|
142
|
+
* `Run()`. On create, the connection's `IsActive` false→true Save has ALREADY awaited a full
|
|
143
|
+
* pipeline run for the same CompanyIntegration (MJCompanyIntegrationEntityServer), so the
|
|
144
|
+
* resolver's call lands inside the coalesce window every time. Without this, the minted ID names
|
|
145
|
+
* a run directory that is never created: the detached promise RESOLVES (so the launcher's
|
|
146
|
+
* rejection handler never fires) and the client polls `IntegrationTailRunEvents` forever on
|
|
147
|
+
* `Run '<id>' not found`, which reads exactly like "not started yet".
|
|
148
|
+
*
|
|
149
|
+
* So: publish a real, terminal, one-stage run under the requested ID whose events name the run
|
|
150
|
+
* that actually did the work. The tail resolves, carries the served run's outcome, and
|
|
151
|
+
* `data.servedByRunID` lets a client hop to the full stream. The returned result is the served
|
|
152
|
+
* run unchanged — a caller that awaits still gets the true `RunID`.
|
|
153
|
+
*/
|
|
154
|
+
async honourRequestedRunID(opts, served) {
|
|
155
|
+
const requested = opts.RunID;
|
|
156
|
+
if (!requested || requested === served.RunID)
|
|
157
|
+
return served;
|
|
158
|
+
await this.publishCoalescedAlias(requested, opts, served);
|
|
159
|
+
return served;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Writes the alias run described in {@link honourRequestedRunID}. Best-effort: an artifact-write
|
|
163
|
+
* failure must never turn a schema refresh that genuinely succeeded into a thrown error, so this
|
|
164
|
+
* only logs. The alias mirrors the served run's success/failure so a client tailing it cannot
|
|
165
|
+
* read a failed refresh as a clean one.
|
|
166
|
+
*/
|
|
167
|
+
async publishCoalescedAlias(requestedRunID, opts, served) {
|
|
168
|
+
try {
|
|
169
|
+
const emitter = new IntegrationProgressEmitter({
|
|
170
|
+
runID: requestedRunID,
|
|
171
|
+
runKind: 'ConnectorCreation',
|
|
172
|
+
integrationID: opts.CompanyIntegration.IntegrationID,
|
|
173
|
+
companyIntegrationID: opts.CompanyIntegration.ID,
|
|
174
|
+
triggerType: opts.TriggerType ?? 'Pipeline',
|
|
175
|
+
startedAt: new Date().toISOString(),
|
|
176
|
+
expectedStages: ['Coalesced'],
|
|
177
|
+
context: { servedByRunID: served.RunID, coalesced: true },
|
|
178
|
+
}, { rootDir: opts.ArtifactRootDir, consoleMirror: opts.ConsoleMirror });
|
|
179
|
+
const pointer = `A schema refresh for this connection was already running (or had just ` +
|
|
180
|
+
`completed), so this request was served by run ${served.RunID} instead of starting a ` +
|
|
181
|
+
`second live introspect. Tail that run for the full event stream.`;
|
|
182
|
+
emitter.runStart(pointer);
|
|
183
|
+
emitter.stageComplete('Coalesced', { processed: 1, succeeded: served.Success ? 1 : 0 });
|
|
184
|
+
if (served.Success) {
|
|
185
|
+
const p = served.PersistResult;
|
|
186
|
+
await emitter.complete(`${pointer} Outcome: ${p?.ObjectsCreated ?? 0} objects created, ` +
|
|
187
|
+
`${p?.ObjectsUpdated ?? 0} updated, ${served.UnresolvedObjects.length} unresolved PKs.`);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
emitter.stageError('Coalesced', served.FailureMessage ?? 'no reason reported', {
|
|
191
|
+
code: 'coalesced-run-failed',
|
|
192
|
+
servedByRunID: served.RunID,
|
|
193
|
+
});
|
|
194
|
+
await emitter.fail(`${pointer} That run FAILED: ${served.FailureMessage ?? 'no reason reported'}`, 'coalesced-run-failed');
|
|
195
|
+
}
|
|
196
|
+
await emitter.flush();
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
console.warn(`[IntegrationConnectorCreationPipeline] Could not publish coalesced-run alias ` +
|
|
200
|
+
`${requestedRunID} → ${served.RunID}: ${err instanceof Error ? err.message : String(err)}`);
|
|
71
201
|
}
|
|
72
202
|
}
|
|
73
203
|
/** Drops recent-run entries older than the coalesce window so the map stays bounded. */
|
|
@@ -98,12 +228,44 @@ export class IntegrationConnectorCreationPipeline {
|
|
|
98
228
|
rootDir: opts.ArtifactRootDir,
|
|
99
229
|
consoleMirror: opts.ConsoleMirror,
|
|
100
230
|
});
|
|
231
|
+
const startedMs = Date.now();
|
|
101
232
|
emitter.runStart(`Connector creation pipeline started for ${opts.CompanyIntegration.Integration ?? '(integration)'} run=${runID}`);
|
|
233
|
+
// THE RUN MUST END. Raced rather than awaited: a stage that never settles cannot be cancelled,
|
|
234
|
+
// but it can be stopped being waited on — which is the difference between a run that fails and
|
|
235
|
+
// one that is in-flight forever. See RunDeadlineMs.
|
|
236
|
+
const deadlineMs = opts.RunDeadlineMs ?? IntegrationConnectorCreationPipeline.DEFAULT_RUN_DEADLINE_MS;
|
|
237
|
+
// The default is 45min, but RunDeadlineMs is a public knob and a caller may set seconds — in
|
|
238
|
+
// which case rounding to minutes reported the failure as a "deadline of 0min", which reads as
|
|
239
|
+
// a bug in the pipeline rather than the limit the caller actually asked for.
|
|
240
|
+
const deadlineLabel = deadlineMs >= 60_000
|
|
241
|
+
? `${Math.round(deadlineMs / 60_000)}min`
|
|
242
|
+
: `${(deadlineMs / 1000).toFixed(deadlineMs % 1000 === 0 ? 0 : 1)}s`;
|
|
243
|
+
let deadlineTimer;
|
|
244
|
+
const withDeadline = async (stage, work) => {
|
|
245
|
+
if (deadlineMs <= 0)
|
|
246
|
+
return work;
|
|
247
|
+
const remaining = deadlineMs - (Date.now() - startedMs);
|
|
248
|
+
if (remaining <= 0) {
|
|
249
|
+
throw new Error(`Run deadline of ${deadlineLabel} exceeded before stage "${stage}" could start.`);
|
|
250
|
+
}
|
|
251
|
+
return Promise.race([
|
|
252
|
+
work,
|
|
253
|
+
new Promise((_, reject) => {
|
|
254
|
+
deadlineTimer = setTimeout(() => reject(new Error(`Stage "${stage}" did not finish within the run deadline of ` +
|
|
255
|
+
`${deadlineLabel}. The work may still be running on this ` +
|
|
256
|
+
`process — it cannot be cancelled — but the run is being failed so it stops ` +
|
|
257
|
+
`reporting itself in-flight and can be retried.`)), remaining);
|
|
258
|
+
// Never hold the process open for a deadline nobody is waiting on.
|
|
259
|
+
deadlineTimer.unref?.();
|
|
260
|
+
}),
|
|
261
|
+
]).finally(() => { if (deadlineTimer)
|
|
262
|
+
clearTimeout(deadlineTimer); });
|
|
263
|
+
};
|
|
102
264
|
try {
|
|
103
|
-
await this.StageConnectionTest(emitter, opts);
|
|
104
|
-
const sourceSchema = await this.StageIntrospect(emitter, opts);
|
|
105
|
-
const persistResult = await this.StagePersist(emitter, opts, sourceSchema);
|
|
106
|
-
const { verdicts, unresolved } = await this.StagePKClassify(emitter, opts);
|
|
265
|
+
await withDeadline('ConnectionTest', this.StageConnectionTest(emitter, opts));
|
|
266
|
+
const sourceSchema = await withDeadline('Introspect', this.StageIntrospect(emitter, opts));
|
|
267
|
+
const persistResult = await withDeadline('Persist', this.StagePersist(emitter, opts, sourceSchema));
|
|
268
|
+
const { verdicts, unresolved } = await withDeadline('PKClassify', this.StagePKClassify(emitter, opts));
|
|
107
269
|
emitter.stageComplete('Pipeline', {
|
|
108
270
|
processed: persistResult.ObjectsCreated + persistResult.ObjectsUpdated,
|
|
109
271
|
succeeded: verdicts.filter(v => v.Confident).length,
|
|
@@ -303,6 +465,19 @@ export class IntegrationConnectorCreationPipeline {
|
|
|
303
465
|
for (const fieldLog of persistResult.FieldMergeLog) {
|
|
304
466
|
emitter.fieldAdded(fieldLog.ObjectName, fieldLog.FieldName, fieldLog.EffectiveSource);
|
|
305
467
|
}
|
|
468
|
+
// A comprehensive refresh deactivates declared objects/fields it did not observe. That is
|
|
469
|
+
// the intended behaviour, but it is also the point where a connector's declared field stops
|
|
470
|
+
// being materialized by every later apply — so say which ones, on the progress stream the
|
|
471
|
+
// caller is already reading, instead of only in a server console line.
|
|
472
|
+
if (persistResult.ObjectsDeactivated.length > 0 || persistResult.FieldsDeactivated.length > 0) {
|
|
473
|
+
emitter.warning('Persist', 'DECLARED_ROWS_DEACTIVATED', `${persistResult.ObjectsDeactivated.length} object(s) and ${persistResult.FieldsDeactivated.length} ` +
|
|
474
|
+
`field(s) were declared but not observed by this authoritative discovery, so they were deactivated ` +
|
|
475
|
+
`(never deleted) and will not be materialized by a later apply until they are observed again or ` +
|
|
476
|
+
`re-enabled.`, {
|
|
477
|
+
objects: persistResult.ObjectsDeactivated,
|
|
478
|
+
fields: persistResult.FieldsDeactivated,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
306
481
|
emitter.stageComplete('Persist', {
|
|
307
482
|
processed: persistResult.ObjectsCreated + persistResult.ObjectsUpdated,
|
|
308
483
|
succeeded: persistResult.ObjectsCreated + persistResult.ObjectsUpdated,
|