@memberjunction/server 5.48.0 → 5.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkKeys.js +11 -4
- package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +2 -2
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +4 -0
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +6 -4
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +28 -2
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +373 -6
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +1975 -39
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +4 -2
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +1 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +73 -11
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +15 -2
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +113 -11
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/integration/EntityMapLifecycle.d.ts +66 -0
- package/dist/integration/EntityMapLifecycle.d.ts.map +1 -0
- package/dist/integration/EntityMapLifecycle.js +189 -0
- package/dist/integration/EntityMapLifecycle.js.map +1 -0
- package/dist/realtimeProxy/RealtimeProxyServer.d.ts +10 -0
- package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
- package/dist/realtimeProxy/RealtimeProxyServer.js +67 -1
- package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.js +6 -2
- package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts +12 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.js +23 -8
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +17 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +81 -25
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts +16 -0
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +26 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +53 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +531 -64
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +0 -6
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RSUResolver.d.ts +10 -0
- package/dist/resolvers/RSUResolver.d.ts.map +1 -1
- package/dist/resolvers/RSUResolver.js +28 -0
- package/dist/resolvers/RSUResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +9 -6
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +135 -132
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts +8 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +29 -1
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/resolvers/adhoc-query-helpers.d.ts +27 -0
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +1 -0
- package/dist/resolvers/adhoc-query-helpers.js +42 -0
- package/dist/resolvers/adhoc-query-helpers.js.map +1 -0
- package/dist/rest/OAuthCallbackHandler.d.ts +9 -0
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +21 -4
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/dist/telephony/media-upgrade-router.d.ts +14 -0
- package/dist/telephony/media-upgrade-router.d.ts.map +1 -1
- package/dist/telephony/media-upgrade-router.js +17 -1
- package/dist/telephony/media-upgrade-router.js.map +1 -1
- package/package.json +91 -89
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +114 -0
- package/src/__tests__/EntityMapLifecycle.dagWarnings.test.ts +91 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +83 -0
- package/src/__tests__/RealtimeProxyServer.test.ts +53 -1
- package/src/__tests__/WidgetSessionService.test.ts +96 -0
- package/src/__tests__/adhoc-query-helpers.test.ts +50 -0
- package/src/__tests__/magicLinkService.provisionLog.test.ts +106 -0
- package/src/__tests__/media-upgrade-router.test.ts +43 -0
- package/src/__tests__/unifiedAuth.test.ts +18 -3
- package/src/auth/magicLink/MagicLinkKeys.ts +12 -6
- package/src/auth/magicLink/MagicLinkRouter.ts +2 -2
- package/src/auth/magicLink/MagicLinkService.ts +4 -0
- package/src/auth/newUsers.ts +6 -5
- package/src/config.ts +14 -0
- package/src/context.ts +28 -2
- package/src/generated/generated.ts +1385 -28
- package/src/generic/ResolverBase.ts +5 -2
- package/src/generic/RunViewResolver.ts +11 -0
- package/src/index.ts +75 -11
- package/src/integration/CustomColumnPromoter.ts +123 -6
- package/src/integration/EntityMapLifecycle.ts +221 -0
- package/src/realtimeProxy/RealtimeProxyServer.ts +70 -1
- package/src/realtimeWidget/WidgetRouter.ts +6 -2
- package/src/realtimeWidget/WidgetSessionService.ts +24 -8
- package/src/resolvers/AdhocQueryResolver.ts +97 -30
- package/src/resolvers/GetDataResolver.ts +30 -1
- package/src/resolvers/IntegrationDiscoveryResolver.ts +523 -64
- package/src/resolvers/QueryResolver.ts +1 -7
- package/src/resolvers/RSUResolver.ts +28 -0
- package/src/resolvers/RunAIAgentResolver.ts +74 -70
- package/src/resolvers/TransactionGroupResolver.ts +36 -2
- package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +138 -0
- package/src/resolvers/adhoc-query-helpers.ts +50 -0
- package/src/rest/OAuthCallbackHandler.ts +22 -4
- package/src/telephony/media-upgrade-router.ts +18 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType, Int, Float } from "type-graphql";
|
|
2
2
|
import { CompositeKey, DatabaseProviderBase, EntityInfo, LocalCacheManager, Metadata, RunView, UserInfo, LogError, LogStatus, IMetadataProvider, TransactionGroupBase } from "@memberjunction/core";
|
|
3
3
|
import { GetReadOnlyProvider, GetReadWriteProvider } from "../util.js";
|
|
4
|
+
import { UUIDsEqual } from "@memberjunction/global";
|
|
4
5
|
import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
|
|
5
6
|
import {
|
|
6
7
|
MJCompanyIntegrationEntity,
|
|
@@ -52,6 +53,7 @@ import { IntegrationProgressReader } from "@memberjunction/integration-progress-
|
|
|
52
53
|
import type { IntegrationRunSnapshot, IntegrationRunKind } from "@memberjunction/integration-progress-artifacts";
|
|
53
54
|
import { ResolverBase } from "../generic/ResolverBase.js";
|
|
54
55
|
import { IntegrationCustomColumnPromoter } from "../integration/CustomColumnPromoter.js";
|
|
56
|
+
import { ComputeCascadeRemovalSet, ComputeRemovedDependencyWarnings, DisableUnselectedEntityMaps, ReenableFieldMapsForEntityMap, ResetPullWatermarks, SetEntityMapEnabled } from "../integration/EntityMapLifecycle.js";
|
|
55
57
|
import { AppContext } from "../types.js";
|
|
56
58
|
import { RequireSystemUser } from "../directives/RequireSystemUser.js";
|
|
57
59
|
import { UserCache } from "@memberjunction/sqlserver-dataprovider";
|
|
@@ -64,6 +66,10 @@ class RSUStepOutput {
|
|
|
64
66
|
@Field() Status: string;
|
|
65
67
|
@Field() DurationMs: number;
|
|
66
68
|
@Field() Message: string;
|
|
69
|
+
/** U11 — 1-based position within the pipeline's expected step sequence (determinate stepper). */
|
|
70
|
+
@Field(() => Int, { nullable: true }) StepIndex?: number;
|
|
71
|
+
/** U11 — expected total steps for the run. */
|
|
72
|
+
@Field(() => Int, { nullable: true }) StepTotal?: number;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
@ObjectType()
|
|
@@ -100,9 +106,10 @@ class ApplyAllInput {
|
|
|
100
106
|
@Field({ nullable: true }) CronExpression?: string;
|
|
101
107
|
@Field({ nullable: true }) ScheduleTimezone?: string;
|
|
102
108
|
@Field(() => Boolean, { nullable: true, defaultValue: true, description: 'If false, skips the sync step after schema + entity maps are created' }) StartSync?: boolean;
|
|
103
|
-
@Field(() => Boolean, { nullable: true,
|
|
109
|
+
@Field(() => Boolean, { nullable: true, description: 'If true, ignores watermarks and does a full re-fetch. When OMITTED: defaults to TRUE on the connector\'s FIRST apply (no prior entity maps — a full sync by default for the first sync), FALSE thereafter.' }) FullSync?: boolean;
|
|
104
110
|
@Field({ nullable: true, defaultValue: 'created', description: 'Sync scope: "created" = only newly created entity maps, "all" = all maps for the connector' }) SyncScope?: string;
|
|
105
111
|
@Field({ nullable: true, defaultValue: 'Pull', description: 'SyncDirection applied to all created entity maps: Pull | Push | Bidirectional. Defaults to Pull.' }) DefaultSyncDirection?: string;
|
|
112
|
+
@Field({ nullable: true, defaultValue: 'disable', description: 'Remove-as-disable: what to do with existing entity maps whose object is NOT in this selection. "disable" (default) = Status=Disabled + SyncEnabled=false (+ field maps disabled; data kept, re-selection re-enables). "ignore" = leave them untouched (additive apply — use for subset/partial applies).' }) UnselectedAction?: string;
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
@ObjectType()
|
|
@@ -164,6 +171,7 @@ class ApplyAllBatchInput {
|
|
|
164
171
|
@Field({ nullable: true, defaultValue: 'created', description: 'Sync scope: "created" = only newly created entity maps, "all" = all maps for the connector' }) SyncScope?: string;
|
|
165
172
|
@Field({ nullable: true, description: 'Override sync direction for the initial sync: Pull | Push | Bidirectional. Defaults to entity map SyncDirection.' }) SyncDirection?: string;
|
|
166
173
|
@Field({ nullable: true, description: 'Override sync direction stored in the created schedule: Pull | Push | Bidirectional.' }) ScheduleSyncDirection?: string;
|
|
174
|
+
@Field({ nullable: true, defaultValue: 'disable', description: 'Remove-as-disable (same as ApplyAllInput.UnselectedAction): "disable" (default) disables entity maps whose object is absent from each connector\'s selection; "ignore" leaves them untouched.' }) UnselectedAction?: string;
|
|
167
175
|
}
|
|
168
176
|
|
|
169
177
|
@ObjectType()
|
|
@@ -215,6 +223,16 @@ class SchemaEvolutionOutput {
|
|
|
215
223
|
@Field({ nullable: true }) GitCommitSuccess?: boolean;
|
|
216
224
|
@Field({ nullable: true }) APIRestarted?: boolean;
|
|
217
225
|
@Field(() => [String], { nullable: true }) Warnings?: string[];
|
|
226
|
+
/** Refresh diff: objects newly present in this resolution (tables created; EM/EFMs created DISABLED unless autoEnableNewObjects). */
|
|
227
|
+
@Field(() => [String], { nullable: true }) NewObjects?: string[];
|
|
228
|
+
/** Refresh diff: objects absent from this resolution — their EM/EFMs were disabled (data kept). */
|
|
229
|
+
@Field(() => [String], { nullable: true }) RemovedObjects?: string[];
|
|
230
|
+
/** DAG cascade (cascadeRemoveDependents=true): still-active dependents of removed objects that were force-disabled with them (transitive closure; data kept, re-add re-enables). */
|
|
231
|
+
@Field(() => [String], { nullable: true }) CascadeRemovedObjects?: string[];
|
|
232
|
+
/** Continuing objects whose column set / constraints changed this run. */
|
|
233
|
+
@Field(() => [String], { nullable: true }) ChangedObjects?: string[];
|
|
234
|
+
/** U10 — objects whose Pull watermark was reset so the next sync backfills the schema change. */
|
|
235
|
+
@Field(() => [String], { nullable: true }) WatermarksReset?: string[];
|
|
218
236
|
}
|
|
219
237
|
|
|
220
238
|
// ─── Connector Capabilities Output Type ─────────────────────────────────────
|
|
@@ -829,6 +847,10 @@ class OperationProgressOutput {
|
|
|
829
847
|
@Field({ nullable: true }) RecordsErrored?: number;
|
|
830
848
|
@Field({ nullable: true }) RSUStep?: string;
|
|
831
849
|
@Field({ nullable: true }) RSURunning?: boolean;
|
|
850
|
+
/** U11 — 1-based index of the current RSU step (determinate stepper). */
|
|
851
|
+
@Field(() => Int, { nullable: true }) RSUStepIndex?: number;
|
|
852
|
+
/** U11 — expected total RSU steps for the in-flight run. */
|
|
853
|
+
@Field(() => Int, { nullable: true }) RSUStepTotal?: number;
|
|
832
854
|
@Field({ nullable: true }) ElapsedMs?: number;
|
|
833
855
|
@Field({ nullable: true }) StartedAt?: string;
|
|
834
856
|
}
|
|
@@ -979,6 +1001,14 @@ class ListSourceObjectsItem {
|
|
|
979
1001
|
@Field({ nullable: true }) IntegrationObjectID?: string;
|
|
980
1002
|
/** True when the source system flags this as user/custom (e.g. SF __c names). */
|
|
981
1003
|
@Field() IsCustom: boolean;
|
|
1004
|
+
/**
|
|
1005
|
+
* DAG exposure: the source object names this object DEPENDS ON (its parents in
|
|
1006
|
+
* the sync DAG — hard FK edges via RelatedIntegrationObjectID plus soft-FK declarations
|
|
1007
|
+
* via Configuration.parentObjectName(s)/ReferencedType). Lets a selection UI auto-check
|
|
1008
|
+
* dependencies when this object is picked and warn when a depended-on object is removed.
|
|
1009
|
+
* Empty for root objects; only populated for objects with a persisted IntegrationObject row.
|
|
1010
|
+
*/
|
|
1011
|
+
@Field(() => [String], { nullable: true }) DependsOn?: string[];
|
|
982
1012
|
}
|
|
983
1013
|
|
|
984
1014
|
@ObjectType()
|
|
@@ -1117,8 +1147,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1117
1147
|
SupportsWrite: true,
|
|
1118
1148
|
}) as ExternalObjectSchema);
|
|
1119
1149
|
|
|
1150
|
+
// DAG exposure: per-object parent names from the same FK graph the sync
|
|
1151
|
+
// engine layers by, so a consumer can auto-check dependencies at selection time.
|
|
1152
|
+
const depsByName = this.computeObjectDependencyNames(companyIntegration.IntegrationID);
|
|
1153
|
+
|
|
1120
1154
|
const merged: ListSourceObjectsItem[] = sourceObjects.map(o => {
|
|
1121
1155
|
const existing = existingByName.get(o.Name);
|
|
1156
|
+
const dependsOn = depsByName.get(o.Name.toLowerCase());
|
|
1122
1157
|
return {
|
|
1123
1158
|
Name: o.Name,
|
|
1124
1159
|
Label: o.Label,
|
|
@@ -1128,6 +1163,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1128
1163
|
AlreadyPersisted: existing != null,
|
|
1129
1164
|
IntegrationObjectID: existing?.ID,
|
|
1130
1165
|
IsCustom: this.isCustomObjectName(o.Name, existing?.IsCustom),
|
|
1166
|
+
DependsOn: dependsOn && dependsOn.length > 0 ? dependsOn : undefined,
|
|
1131
1167
|
};
|
|
1132
1168
|
});
|
|
1133
1169
|
merged.sort((a, b) => a.Name.localeCompare(b.Name));
|
|
@@ -1174,6 +1210,50 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1174
1210
|
return name.endsWith('__c');
|
|
1175
1211
|
}
|
|
1176
1212
|
|
|
1213
|
+
/**
|
|
1214
|
+
* DAG exposure: object name (lowercased) → sorted parent object names, derived from
|
|
1215
|
+
* the SAME dependency edges the sync engine layers by — hard FK pointers
|
|
1216
|
+
* (IntegrationObjectField.RelatedIntegrationObjectID) plus soft-FK declarations in the IO's
|
|
1217
|
+
* Configuration (parentObjectName / parentObjectNames map / ReferencedType). Reads the
|
|
1218
|
+
* engine cache only (no DB round-trip); objects with no persisted IO row have no entry.
|
|
1219
|
+
*/
|
|
1220
|
+
private computeObjectDependencyNames(integrationID: string): Map<string, string[]> {
|
|
1221
|
+
const engine = IntegrationEngineBase.Instance;
|
|
1222
|
+
const ios = engine.GetIntegrationObjectsByIntegrationID(integrationID);
|
|
1223
|
+
const nameById = new Map(ios.map(io => [io.ID.toUpperCase(), io.Name] as const));
|
|
1224
|
+
const byLowerName = new Map(ios.map(io => [io.Name.toLowerCase(), io] as const));
|
|
1225
|
+
const deps = new Map<string, string[]>();
|
|
1226
|
+
for (const io of ios) {
|
|
1227
|
+
const parents = new Set<string>();
|
|
1228
|
+
for (const f of engine.GetIntegrationObjectFields(io.ID)) {
|
|
1229
|
+
const parentID = f.RelatedIntegrationObjectID?.toUpperCase();
|
|
1230
|
+
if (!parentID || UUIDsEqual(parentID, io.ID)) continue;
|
|
1231
|
+
const parentName = nameById.get(parentID);
|
|
1232
|
+
if (parentName) parents.add(parentName);
|
|
1233
|
+
}
|
|
1234
|
+
const cfgRaw = (io as unknown as { Configuration?: string | null }).Configuration;
|
|
1235
|
+
if (cfgRaw) {
|
|
1236
|
+
try {
|
|
1237
|
+
const cfg = JSON.parse(cfgRaw) as {
|
|
1238
|
+
parentObjectName?: string; ReferencedType?: string; parentObjectNames?: Record<string, string>;
|
|
1239
|
+
};
|
|
1240
|
+
const candidates: string[] = [];
|
|
1241
|
+
if (cfg.parentObjectName) candidates.push(cfg.parentObjectName);
|
|
1242
|
+
if (cfg.ReferencedType) candidates.push(cfg.ReferencedType);
|
|
1243
|
+
if (cfg.parentObjectNames && typeof cfg.parentObjectNames === 'object' && !Array.isArray(cfg.parentObjectNames)) {
|
|
1244
|
+
candidates.push(...Object.values(cfg.parentObjectNames).filter((v): v is string => typeof v === 'string'));
|
|
1245
|
+
}
|
|
1246
|
+
for (const c of candidates) {
|
|
1247
|
+
const match = byLowerName.get(c.toLowerCase());
|
|
1248
|
+
if (match && !UUIDsEqual(match.ID, io.ID)) parents.add(match.Name);
|
|
1249
|
+
}
|
|
1250
|
+
} catch { /* non-JSON Configuration → no soft-FK parents */ }
|
|
1251
|
+
}
|
|
1252
|
+
deps.set(io.Name.toLowerCase(), [...parents].sort());
|
|
1253
|
+
}
|
|
1254
|
+
return deps;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1177
1257
|
/**
|
|
1178
1258
|
* Discovers fields on a specific external object.
|
|
1179
1259
|
*/
|
|
@@ -1266,6 +1346,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1266
1346
|
@Arg("deactivateAbsent", { nullable: true, description: "Comprehensive refresh (default true): objects/fields ABSENT from this discovery are deactivated (Status='Disabled', never deleted, reversible on a later rediscovery). Pass false for a scoped/partial discovery so it never disables what it didn't probe." }) deactivateAbsent: boolean | undefined,
|
|
1267
1347
|
@Ctx() ctx: AppContext
|
|
1268
1348
|
): Promise<RefreshConnectorSchemaOutput> {
|
|
1349
|
+
// sync lock: a metadata refresh rewrites the IO/IOF rows a sync reads.
|
|
1350
|
+
if (!IntegrationEngine.AcquireMaintenanceLock(companyIntegrationID, 'metadata refresh')) {
|
|
1351
|
+
return {
|
|
1352
|
+
Success: false, RunID: 'not-started',
|
|
1353
|
+
Message: 'Refresh not started: a sync or another maintenance operation is currently running for this connection. Retry after it completes.',
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1269
1356
|
try {
|
|
1270
1357
|
const user = this.getAuthenticatedUser(ctx);
|
|
1271
1358
|
const provider = GetReadWriteProvider(ctx.providers) as unknown as IMetadataProvider;
|
|
@@ -1327,6 +1414,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
1327
1414
|
Message: `Error: ${this.formatError(e)}`,
|
|
1328
1415
|
RunID: 'error',
|
|
1329
1416
|
};
|
|
1417
|
+
} finally {
|
|
1418
|
+
IntegrationEngine.ReleaseMaintenanceLock(companyIntegrationID);
|
|
1330
1419
|
}
|
|
1331
1420
|
}
|
|
1332
1421
|
|
|
@@ -3162,7 +3251,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3162
3251
|
Message: `Batch complete: ${batchResult.SuccessCount} succeeded, ${batchResult.FailureCount} failed`,
|
|
3163
3252
|
Items: itemResults,
|
|
3164
3253
|
Steps: batchResult.Results[0]?.Steps.map((s: RSUPipelineStep) => ({
|
|
3165
|
-
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
|
|
3254
|
+
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
|
|
3166
3255
|
})),
|
|
3167
3256
|
GitCommitSuccess: batchResult.Results[0]?.GitCommitSuccess,
|
|
3168
3257
|
APIRestarted: batchResult.Results[0]?.APIRestarted,
|
|
@@ -3313,6 +3402,20 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3313
3402
|
sourceObjectFields[resolvedNames[i]] = resolved.sourceObjects[i].Fields ?? null;
|
|
3314
3403
|
}
|
|
3315
3404
|
|
|
3405
|
+
// the sync after a connector's FIRST apply defaults to FULL ("always full
|
|
3406
|
+
// generally as the default"); later applies default incremental. "First" = no entity
|
|
3407
|
+
// map existed for this connection before this apply. Explicit input always wins.
|
|
3408
|
+
const priorMapsResult = await new RunView().RunView<{ ID: string }>({
|
|
3409
|
+
EntityName: 'MJ: Company Integration Entity Maps',
|
|
3410
|
+
ExtraFilter: `CompanyIntegrationID='${input.CompanyIntegrationID.replace(/'/g, "''")}'`,
|
|
3411
|
+
Fields: ['ID'],
|
|
3412
|
+
MaxRows: 1,
|
|
3413
|
+
ResultType: 'simple',
|
|
3414
|
+
BypassCache: true,
|
|
3415
|
+
}, user);
|
|
3416
|
+
const hadPriorMaps = priorMapsResult.Success && priorMapsResult.Results.length > 0;
|
|
3417
|
+
const effectiveFullSync = input.FullSync ?? !hadPriorMaps;
|
|
3418
|
+
|
|
3316
3419
|
const pendingPayload = {
|
|
3317
3420
|
CompanyIntegrationID: input.CompanyIntegrationID,
|
|
3318
3421
|
SourceObjectNames: resolvedNames,
|
|
@@ -3321,21 +3424,24 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3321
3424
|
CronExpression: input.CronExpression,
|
|
3322
3425
|
ScheduleTimezone: input.ScheduleTimezone,
|
|
3323
3426
|
StartSync: input.StartSync,
|
|
3324
|
-
FullSync:
|
|
3427
|
+
FullSync: effectiveFullSync,
|
|
3325
3428
|
SyncScope: input.SyncScope ?? 'created',
|
|
3429
|
+
UnselectedAction: (input.UnselectedAction ?? 'disable') as 'disable' | 'ignore',
|
|
3326
3430
|
CreatedAt: new Date().toISOString(),
|
|
3327
3431
|
};
|
|
3328
3432
|
rsuInput.PostRestartFiles = [
|
|
3329
3433
|
{ Path: pendingFilePath, Content: JSON.stringify(pendingPayload, null, 2) }
|
|
3330
3434
|
];
|
|
3331
3435
|
|
|
3332
|
-
// Step 5: Run pipeline (restart kills process at the end)
|
|
3436
|
+
// Step 5: Run pipeline (restart kills process at the end).
|
|
3437
|
+
// Retrying variant — an install should not die because the migration hit a dropped
|
|
3438
|
+
// connection or a transient lock; it only replays while nothing has been applied.
|
|
3333
3439
|
const rsm = RuntimeSchemaManager.Instance;
|
|
3334
|
-
const batchResult = await rsm.
|
|
3440
|
+
const batchResult = await rsm.RunPipelineBatchWithRetry([rsuInput]);
|
|
3335
3441
|
|
|
3336
3442
|
const migrationSucceeded = batchResult.SuccessCount > 0;
|
|
3337
3443
|
const pipelineSteps = batchResult.Results[0]?.Steps.map((s: RSUPipelineStep) => ({
|
|
3338
|
-
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
|
|
3444
|
+
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
|
|
3339
3445
|
}));
|
|
3340
3446
|
|
|
3341
3447
|
// If pipeline failed, clean up pending file and return error
|
|
@@ -3358,6 +3464,17 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3358
3464
|
input.CompanyIntegrationID, objects, connector, companyIntegration, schemaName, user, provider,
|
|
3359
3465
|
input.DefaultSyncDirection ?? 'Pull'
|
|
3360
3466
|
);
|
|
3467
|
+
// Remove-as-disable: objects absent from THIS selection get their entity
|
|
3468
|
+
// map + field maps disabled (data kept; re-selection re-enables). 'ignore' opts out
|
|
3469
|
+
// for additive/subset applies (e.g. test-harness per-scope runs).
|
|
3470
|
+
if ((input.UnselectedAction ?? 'disable') !== 'ignore') {
|
|
3471
|
+
const disabledObjects = await DisableUnselectedEntityMaps(
|
|
3472
|
+
input.CompanyIntegrationID, resolvedNames, user, provider
|
|
3473
|
+
);
|
|
3474
|
+
if (disabledObjects.length > 0) {
|
|
3475
|
+
console.log(`[IntegrationApplyAll] Disabled ${disabledObjects.length} unselected entity map(s): ${disabledObjects.join(', ')}`);
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3361
3478
|
const createdMapIDs = entityMapsCreated.map(em => em.EntityMapID).filter(Boolean);
|
|
3362
3479
|
const scopedMapIDs = input.SyncScope === 'all' ? undefined : createdMapIDs;
|
|
3363
3480
|
// Skip sync when SyncScope='created' but 0 new maps were
|
|
@@ -3367,7 +3484,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3367
3484
|
const shouldStartSync = input.StartSync !== false &&
|
|
3368
3485
|
(input.SyncScope === 'all' || createdMapIDs.length > 0);
|
|
3369
3486
|
const syncRunID = shouldStartSync
|
|
3370
|
-
? await this.startSyncAfterApply(input.CompanyIntegrationID, user, scopedMapIDs,
|
|
3487
|
+
? await this.startSyncAfterApply(input.CompanyIntegrationID, user, scopedMapIDs, effectiveFullSync)
|
|
3371
3488
|
: null;
|
|
3372
3489
|
|
|
3373
3490
|
// Create schedule if requested
|
|
@@ -3531,6 +3648,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3531
3648
|
}
|
|
3532
3649
|
|
|
3533
3650
|
let em: MJCompanyIntegrationEntityMapEntity;
|
|
3651
|
+
const wasDisabled = existing ? existing.Status !== 'Active' || existing.SyncEnabled === false : false;
|
|
3534
3652
|
if (existing) {
|
|
3535
3653
|
em = existing; // reuse — keeps the map stable across re-applies
|
|
3536
3654
|
} else {
|
|
@@ -3550,6 +3668,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3550
3668
|
return null;
|
|
3551
3669
|
}
|
|
3552
3670
|
|
|
3671
|
+
// Re-add re-enables: a previously-removed (disabled) object that is re-selected
|
|
3672
|
+
// comes back WITH its field maps — the create-field-maps idempotency below skips existing
|
|
3673
|
+
// rows, so without this they would stay Disabled forever.
|
|
3674
|
+
if (wasDisabled) {
|
|
3675
|
+
await ReenableFieldMapsForEntityMap(em.ID, user, md);
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3553
3678
|
// Discover fields from the source and create 1:1 field maps
|
|
3554
3679
|
const fieldMapCount = await this.createFieldMapsForEntityMap(
|
|
3555
3680
|
em.ID, obj.SourceObjectName, connector, companyIntegration, user, md
|
|
@@ -3601,7 +3726,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3601
3726
|
fm.EntityMapID = entityMapID;
|
|
3602
3727
|
fm.SourceFieldName = field.Name;
|
|
3603
3728
|
fm.DestinationFieldName = field.Name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
3604
|
-
fm.IsKeyField = field.
|
|
3729
|
+
fm.IsKeyField = field.IsPrimaryKey ?? false; // key on the record's PRIMARY key (identity), consistent with processRSUPendingWork + U1 (PK ≠ unique)
|
|
3605
3730
|
fm.IsRequired = field.IsRequired;
|
|
3606
3731
|
fm.Direction = 'SourceToDest';
|
|
3607
3732
|
fm.Status = 'Active';
|
|
@@ -3854,6 +3979,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3854
3979
|
return { Success: false, Message: 'Connector is deactivated (IsActive=false); sync not started' };
|
|
3855
3980
|
}
|
|
3856
3981
|
|
|
3982
|
+
// sync lock: refuse while a metadata refresh / schema evolution / RSU pipeline
|
|
3983
|
+
// holds the maintenance lock for this connection (the engine also refuses — this gives
|
|
3984
|
+
// the client an immediate, unambiguous answer).
|
|
3985
|
+
const maintenance = IntegrationEngine.GetMaintenanceLock(companyIntegrationID);
|
|
3986
|
+
if (maintenance) {
|
|
3987
|
+
return { Success: false, Message: `Sync not started: ${maintenance.Reason} is in progress for this connection (since ${maintenance.AcquiredAt.toISOString()}). Retry after it completes.` };
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3857
3990
|
const syncOptions: { FullSync?: boolean; EntityMapIDs?: string[]; SyncDirection?: 'Pull' | 'Push' | 'Bidirectional' } = {};
|
|
3858
3991
|
if (fullSync) syncOptions.FullSync = true;
|
|
3859
3992
|
if (entityMapIDs?.length) syncOptions.EntityMapIDs = entityMapIDs;
|
|
@@ -3910,10 +4043,21 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3910
4043
|
// null RunID: an optimistic, untrackable result that read as "started" when nothing
|
|
3911
4044
|
// could be followed. Poll briefly for ANY run for this connector stamped at/after the
|
|
3912
4045
|
// fire instant; that catches both the still-running and the already-finished cases.
|
|
4046
|
+
//
|
|
4047
|
+
// The row itself isn't created until executeSyncInternal reaches CreateRunRecord,
|
|
4048
|
+
// which runs AFTER LoadRunConfiguration (loads every entity map + field map + builds
|
|
4049
|
+
// the connector instance) — for a large connector (HubSpot, 168 entity maps) that
|
|
4050
|
+
// synchronous setup alone can take several seconds, so a short fixed poll window can
|
|
4051
|
+
// elapse before the row exists at all, not because the sync failed. Live-verified: a
|
|
4052
|
+
// HubSpot StartSync call reported "no run created" while the run (confirmed present in
|
|
4053
|
+
// the DB moments later) was genuinely syncing. Keep the fast path fast (first 2s still
|
|
4054
|
+
// polled every 200ms) but extend the tail so large connectors get a fair window before
|
|
4055
|
+
// we honestly report failure.
|
|
3913
4056
|
const firedAtFilter = firedAt.toISOString();
|
|
3914
4057
|
let run: { ID: string; Status: string } | null = null;
|
|
3915
|
-
|
|
3916
|
-
|
|
4058
|
+
const pollIntervalsMs = [...Array(10).fill(200), ...Array(20).fill(1000)];
|
|
4059
|
+
for (let attempt = 0; attempt < pollIntervalsMs.length && !run; attempt++) {
|
|
4060
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalsMs[attempt]));
|
|
3917
4061
|
const rv = new RunView();
|
|
3918
4062
|
const runResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
|
|
3919
4063
|
EntityName: 'MJ: Company Integration Runs',
|
|
@@ -4087,8 +4231,18 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
4087
4231
|
}
|
|
4088
4232
|
const jobTypeID = jobTypeResult.Results[0].ID;
|
|
4089
4233
|
|
|
4234
|
+
// U7 — SERVER-SIDE uniqueness per (CompanyIntegration, job kind/driver): a client
|
|
4235
|
+
// name-regex is not a guarantee, and duplicate jobs mean duplicate sync/rediscovery
|
|
4236
|
+
// runs. When an active/paused job of this kind already targets this connection,
|
|
4237
|
+
// UPDATE it in place instead of creating a twin.
|
|
4090
4238
|
const job = await md.GetEntityObject<MJScheduledJobEntity>('MJ: Scheduled Jobs', user);
|
|
4091
|
-
|
|
4239
|
+
const existingJob = await this.findExistingScheduledJob(jobTypeID, input.CompanyIntegrationID, user);
|
|
4240
|
+
let updatedInPlace = false;
|
|
4241
|
+
if (existingJob) {
|
|
4242
|
+
const loaded = await job.InnerLoad(CompositeKey.FromID(existingJob.ID));
|
|
4243
|
+
if (loaded) updatedInPlace = true;
|
|
4244
|
+
}
|
|
4245
|
+
if (!updatedInPlace) job.NewRecord();
|
|
4092
4246
|
job.JobTypeID = jobTypeID;
|
|
4093
4247
|
job.Name = input.Name;
|
|
4094
4248
|
if (input.Description) job.Description = input.Description;
|
|
@@ -4120,13 +4274,50 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
4120
4274
|
await ci.Save();
|
|
4121
4275
|
}
|
|
4122
4276
|
|
|
4123
|
-
return {
|
|
4277
|
+
return {
|
|
4278
|
+
Success: true,
|
|
4279
|
+
Message: updatedInPlace
|
|
4280
|
+
? 'Schedule updated — an active schedule of this kind already existed for this connection (server-side uniqueness)'
|
|
4281
|
+
: 'Schedule created',
|
|
4282
|
+
ScheduledJobID: job.ID,
|
|
4283
|
+
};
|
|
4124
4284
|
} catch (e) {
|
|
4125
4285
|
LogError(`IntegrationCreateSchedule error: ${e}`);
|
|
4126
4286
|
return { Success: false, Message: this.formatError(e) };
|
|
4127
4287
|
}
|
|
4128
4288
|
}
|
|
4129
4289
|
|
|
4290
|
+
/**
|
|
4291
|
+
* U7 — server-side schedule uniqueness: finds an existing (non-deleted) scheduled job of the
|
|
4292
|
+
* given job type that targets the given CompanyIntegration (its Configuration JSON carries
|
|
4293
|
+
* CompanyIntegrationID). Matched case-insensitively across UUID casings (SQL Server upper /
|
|
4294
|
+
* PostgreSQL lower). Returns the newest match or null.
|
|
4295
|
+
*/
|
|
4296
|
+
private async findExistingScheduledJob(
|
|
4297
|
+
jobTypeID: string,
|
|
4298
|
+
companyIntegrationID: string,
|
|
4299
|
+
user: UserInfo
|
|
4300
|
+
): Promise<{ ID: string } | null> {
|
|
4301
|
+
const esc = (s: string) => s.replace(/'/g, "''");
|
|
4302
|
+
const lower = esc(companyIntegrationID.toLowerCase());
|
|
4303
|
+
const upper = esc(companyIntegrationID.toUpperCase());
|
|
4304
|
+
const result = await new RunView().RunView<{ ID: string }>({
|
|
4305
|
+
EntityName: 'MJ: Scheduled Jobs',
|
|
4306
|
+
ExtraFilter:
|
|
4307
|
+
`JobTypeID='${esc(jobTypeID)}' AND Status IN ('Active','Paused') AND ` +
|
|
4308
|
+
// Structured "CompanyIntegrationID":"<uuid>" predicate (same form as IntegrationListSchedules),
|
|
4309
|
+
// NOT a bare-UUID substring — so this CI's id can't false-match the same UUID appearing in
|
|
4310
|
+
// another config key (e.g. RelatedCompanyIntegrationID) and repurpose an unrelated job.
|
|
4311
|
+
`(Configuration LIKE '%"CompanyIntegrationID":"${lower}"%' OR Configuration LIKE '%"CompanyIntegrationID":"${upper}"%')`,
|
|
4312
|
+
OrderBy: '__mj_CreatedAt DESC',
|
|
4313
|
+
Fields: ['ID'],
|
|
4314
|
+
MaxRows: 1,
|
|
4315
|
+
ResultType: 'simple',
|
|
4316
|
+
BypassCache: true, // uniqueness must see committed jobs, not a stale filtered cache
|
|
4317
|
+
}, user);
|
|
4318
|
+
return result.Success && result.Results.length > 0 ? result.Results[0] : null;
|
|
4319
|
+
}
|
|
4320
|
+
|
|
4130
4321
|
@Mutation(() => MutationResultOutput)
|
|
4131
4322
|
async IntegrationUpdateSchedule(
|
|
4132
4323
|
@Arg("scheduledJobID") scheduledJobID: string,
|
|
@@ -4491,7 +4682,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
4491
4682
|
OperationType: 'rsu',
|
|
4492
4683
|
IsRunning: true,
|
|
4493
4684
|
RSURunning: true,
|
|
4494
|
-
|
|
4685
|
+
// U11 — determinate "step N of M: <name>" instead of an indeterminate spinner
|
|
4686
|
+
RSUStep: rsuStatus.CurrentStepName ?? rsuStatus.LastRunResult ?? 'running',
|
|
4687
|
+
RSUStepIndex: rsuStatus.CurrentStepIndex ?? undefined,
|
|
4688
|
+
RSUStepTotal: rsuStatus.StepTotal ?? undefined,
|
|
4495
4689
|
StartedAt: rsuStatus.LastRunAt?.toISOString(),
|
|
4496
4690
|
ElapsedMs: rsuStatus.LastRunAt ? Date.now() - rsuStatus.LastRunAt.getTime() : undefined,
|
|
4497
4691
|
};
|
|
@@ -5055,6 +5249,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5055
5249
|
SyncScope: input.SyncScope ?? 'created',
|
|
5056
5250
|
SyncDirection: input.SyncDirection,
|
|
5057
5251
|
ScheduleSyncDirection: input.ScheduleSyncDirection,
|
|
5252
|
+
UnselectedAction: (input.UnselectedAction ?? 'disable') as 'disable' | 'ignore',
|
|
5058
5253
|
CreatedAt: new Date().toISOString(),
|
|
5059
5254
|
};
|
|
5060
5255
|
rsuInput.PostRestartFiles = [
|
|
@@ -5113,10 +5308,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5113
5308
|
};
|
|
5114
5309
|
}
|
|
5115
5310
|
|
|
5116
|
-
// Phase 2: Run all successful RSU inputs through one pipeline batch
|
|
5311
|
+
// Phase 2: Run all successful RSU inputs through one pipeline batch.
|
|
5312
|
+
// Retrying variant — a multi-connector install is the longest-running thing a user
|
|
5313
|
+
// does here, and it should not be lost to one transient step. Replays only while
|
|
5314
|
+
// nothing has been applied, so a partially-committed batch is never re-executed.
|
|
5117
5315
|
const pipelineInputs = successfulBuilds.map(b => b.rsuInput);
|
|
5118
5316
|
const rsm = RuntimeSchemaManager.Instance;
|
|
5119
|
-
const batchResult = await rsm.
|
|
5317
|
+
const batchResult = await rsm.RunPipelineBatchWithRetry(pipelineInputs);
|
|
5120
5318
|
|
|
5121
5319
|
// Phase 3: Post-pipeline — create entity maps, field maps, schedules for each success
|
|
5122
5320
|
for (let i = 0; i < successfulBuilds.length; i++) {
|
|
@@ -5190,7 +5388,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5190
5388
|
}
|
|
5191
5389
|
|
|
5192
5390
|
const pipelineSteps = batchResult.Results[0]?.Steps.map((s: RSUPipelineStep) => ({
|
|
5193
|
-
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
|
|
5391
|
+
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
|
|
5194
5392
|
}));
|
|
5195
5393
|
|
|
5196
5394
|
const successCount = connectorResults.filter(r => r.Success).length;
|
|
@@ -5242,8 +5440,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5242
5440
|
}
|
|
5243
5441
|
const jobTypeID = jobTypeResult.Results[0].ID;
|
|
5244
5442
|
|
|
5443
|
+
// U7 — same server-side uniqueness as IntegrationCreateSchedule: update the existing
|
|
5444
|
+
// sync job for this connection in place rather than creating a duplicate.
|
|
5245
5445
|
const job = await md.GetEntityObject<MJScheduledJobEntity>('MJ: Scheduled Jobs', user);
|
|
5246
|
-
|
|
5446
|
+
const existingJob = await this.findExistingScheduledJob(jobTypeID, companyIntegrationID, user);
|
|
5447
|
+
const loadedExisting = existingJob ? await job.InnerLoad(CompositeKey.FromID(existingJob.ID)) : false;
|
|
5448
|
+
if (!loadedExisting) job.NewRecord();
|
|
5247
5449
|
job.JobTypeID = jobTypeID;
|
|
5248
5450
|
job.Name = `${integrationName} Sync`;
|
|
5249
5451
|
job.CronExpression = cronExpression;
|
|
@@ -5492,8 +5694,19 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5492
5694
|
@Arg("platform", { defaultValue: "sqlserver" }) platform: string,
|
|
5493
5695
|
@Arg("skipGitCommit", { defaultValue: false }) skipGitCommit: boolean,
|
|
5494
5696
|
@Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
|
|
5697
|
+
@Arg("autoEnableNewObjects", { defaultValue: false, description: 'newly-appeared objects get their entity maps created DISABLED by default (the user enables them after the refresh). Pass true to auto-enable them instead.' }) autoEnableNewObjects: boolean,
|
|
5698
|
+
@Arg("deactivateAbsent", { nullable: true, description: 'Deactivate IO/IOF absent from this re-discovery (default true — comprehensive refresh; gated on the connector\'s authoritative-discovery getter).' }) deactivateAbsent: boolean | undefined,
|
|
5699
|
+
@Arg("cascadeRemoveDependents", { defaultValue: false, description: 'When a removed object has still-active dependents (DAG parent edges), also disable the transitive dependent closure ("force remove those too"). Default false: dependents stay active and each broken edge is surfaced as a warning.' }) cascadeRemoveDependents: boolean,
|
|
5495
5700
|
@Ctx() ctx: AppContext
|
|
5496
5701
|
): Promise<SchemaEvolutionOutput> {
|
|
5702
|
+
// sync lock: schema evolution rewrites the metadata, field maps and DDL a sync
|
|
5703
|
+
// reads — data syncs (manual + scheduled) must not run while this is in flight.
|
|
5704
|
+
if (!IntegrationEngine.AcquireMaintenanceLock(companyIntegrationID, 'schema evolution')) {
|
|
5705
|
+
return {
|
|
5706
|
+
Success: false, HasChanges: false,
|
|
5707
|
+
Message: 'Schema evolution not started: a sync or another maintenance operation is currently running for this connection. Retry after it completes.',
|
|
5708
|
+
};
|
|
5709
|
+
}
|
|
5497
5710
|
try {
|
|
5498
5711
|
const user = this.getAuthenticatedUser(ctx);
|
|
5499
5712
|
const validatedPlatform = this.validatePlatform(platform);
|
|
@@ -5502,25 +5715,92 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5502
5715
|
const schemaName = this.deriveSchemaName(companyIntegration.Integration);
|
|
5503
5716
|
const rv = new RunView();
|
|
5504
5717
|
|
|
5505
|
-
//
|
|
5506
|
-
|
|
5718
|
+
// ── Phase 1 — FULL RE-RESOLUTION (: "the exact same algorithm as before") ──
|
|
5719
|
+
// Re-run the creation pipeline: introspect (static + runtime discovery), persist with
|
|
5720
|
+
// the external-wins-when-present overlay, deactivate absent (gated on authoritative
|
|
5721
|
+
// discovery), PK-classify the residual. The diff below is computed against the
|
|
5722
|
+
// RESULT of this resolution, exactly as the first-time flow computed its state.
|
|
5723
|
+
const pipeline = new IntegrationConnectorCreationPipeline();
|
|
5724
|
+
const refresh = await pipeline.Run({
|
|
5725
|
+
Connector: connector,
|
|
5726
|
+
CompanyIntegration: companyIntegration,
|
|
5727
|
+
ContextUser: user,
|
|
5728
|
+
Provider: md,
|
|
5729
|
+
TriggerType: 'Manual',
|
|
5730
|
+
ConsoleMirror: true,
|
|
5731
|
+
DeactivateAbsent: deactivateAbsent ?? this.readConfigBool(companyIntegration.Configuration, 'deactivateAbsent') ?? true,
|
|
5732
|
+
} as unknown as Parameters<typeof pipeline.Run>[0]);
|
|
5733
|
+
if (!refresh.Success) {
|
|
5734
|
+
return { Success: false, HasChanges: false, Message: `Schema evolution aborted — metadata re-resolution failed: ${refresh.FailureMessage ?? 'unknown error'}` };
|
|
5735
|
+
}
|
|
5736
|
+
await md.Refresh();
|
|
5737
|
+
await IntegrationEngine.Instance.Config(true, user, md);
|
|
5738
|
+
|
|
5739
|
+
// ── Phase 2 — DIFF: resolved-Active objects vs prior entity maps ──
|
|
5740
|
+
const activeIOs = IntegrationEngineBase.Instance.GetActiveIntegrationObjects(companyIntegration.IntegrationID);
|
|
5741
|
+
const activeNamesLower = new Set(activeIOs.map(o => o.Name.toLowerCase()));
|
|
5742
|
+
const priorMapsResult = await rv.RunView<MJCompanyIntegrationEntityMapEntity>({
|
|
5507
5743
|
EntityName: 'MJ: Company Integration Entity Maps',
|
|
5508
5744
|
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
5509
|
-
ResultType: '
|
|
5510
|
-
|
|
5745
|
+
ResultType: 'entity_object',
|
|
5746
|
+
BypassCache: true, // the diff must read committed state
|
|
5511
5747
|
}, user);
|
|
5748
|
+
const priorMaps = priorMapsResult.Success ? priorMapsResult.Results : [];
|
|
5749
|
+
const priorByLowerName = new Map(priorMaps.map(m => [(m.ExternalObjectName ?? '').toLowerCase(), m] as const));
|
|
5750
|
+
|
|
5751
|
+
const newObjects = activeIOs.filter(io => !priorByLowerName.has(io.Name.toLowerCase())).map(io => io.Name);
|
|
5752
|
+
const removedObjects = priorMaps
|
|
5753
|
+
.filter(m => m.ExternalObjectName && !activeNamesLower.has(m.ExternalObjectName.toLowerCase()))
|
|
5754
|
+
.map(m => m.ExternalObjectName as string);
|
|
5755
|
+
const continuingMaps = priorMaps.filter(m => m.ExternalObjectName && activeNamesLower.has(m.ExternalObjectName.toLowerCase()));
|
|
5756
|
+
|
|
5757
|
+
// ── Phase 3 — REMOVED objects: disable EM + EFMs (data + tables KEPT) ──
|
|
5758
|
+
// Uses the same remove-as-disable helper as ApplyAll: everything not in the resolved
|
|
5759
|
+
// set is disabled; a later re-appearance re-enables (reactivate-on-rediscover).
|
|
5760
|
+
// DAG handling (rsuplan "adding and removing tables"): dependents of a removed object
|
|
5761
|
+
// are NOT auto-removed by default — each broken parent edge is surfaced as a warning so
|
|
5762
|
+
// a consumer can prompt the user. With cascadeRemoveDependents=true (the "force remove
|
|
5763
|
+
// those too" affordance), the TRANSITIVE closure of still-active dependents is disabled
|
|
5764
|
+
// alongside the removed objects (still remove-as-disable, reversible). Edges come from
|
|
5765
|
+
// the same dependency map the sync engine layers by.
|
|
5766
|
+
const depMap = removedObjects.length > 0
|
|
5767
|
+
? this.computeObjectDependencyNames(companyIntegration.IntegrationID)
|
|
5768
|
+
: new Map<string, string[]>();
|
|
5769
|
+
const cascadeRemoved: string[] = cascadeRemoveDependents && removedObjects.length > 0
|
|
5770
|
+
? ComputeCascadeRemovalSet(activeIOs.map(io => io.Name), removedObjects, depMap)
|
|
5771
|
+
: [];
|
|
5772
|
+
const cascadeLower = new Set(cascadeRemoved.map(n => n.toLowerCase()));
|
|
5773
|
+
const keepNames = activeIOs.map(io => io.Name).filter(n => !cascadeLower.has(n.toLowerCase()));
|
|
5774
|
+
const dagRemovalWarnings: string[] = removedObjects.length > 0 && !cascadeRemoveDependents
|
|
5775
|
+
? ComputeRemovedDependencyWarnings(keepNames, removedObjects, depMap)
|
|
5776
|
+
: [];
|
|
5777
|
+
for (const w of dagRemovalWarnings) LogStatus(`[SchemaEvolution][DAG] ${w}`);
|
|
5778
|
+
if (cascadeRemoved.length > 0) LogStatus(`[SchemaEvolution][DAG] cascade-removing ${cascadeRemoved.length} dependent object(s): ${cascadeRemoved.join(', ')}`);
|
|
5779
|
+
if (removedObjects.length > 0 || cascadeRemoved.length > 0) {
|
|
5780
|
+
await DisableUnselectedEntityMaps(companyIntegrationID, keepNames, user, md);
|
|
5781
|
+
}
|
|
5512
5782
|
|
|
5513
|
-
|
|
5783
|
+
// ── Phase 4 — schema build over continuing + new objects (minus cascade-removed) ──
|
|
5784
|
+
const evolveNames = [
|
|
5785
|
+
...continuingMaps.map(m => m.ExternalObjectName as string).filter(n => !cascadeLower.has(n.toLowerCase())),
|
|
5786
|
+
...newObjects,
|
|
5787
|
+
];
|
|
5788
|
+
if (evolveNames.length === 0) {
|
|
5789
|
+
const removedTotal = removedObjects.length + cascadeRemoved.length;
|
|
5514
5790
|
return {
|
|
5515
|
-
Success:
|
|
5516
|
-
|
|
5791
|
+
Success: true, HasChanges: removedTotal > 0,
|
|
5792
|
+
Message: removedTotal > 0
|
|
5793
|
+
? `No objects to evolve — ${removedTotal} removed object(s) disabled${cascadeRemoved.length > 0 ? ` (${cascadeRemoved.length} via cascade)` : ''}`
|
|
5794
|
+
: 'No entity maps and no resolved objects — nothing to evolve',
|
|
5795
|
+
RemovedObjects: removedObjects.length > 0 ? removedObjects : undefined,
|
|
5796
|
+
CascadeRemovedObjects: cascadeRemoved.length > 0 ? cascadeRemoved : undefined,
|
|
5797
|
+
Warnings: dagRemovalWarnings.length > 0 ? dagRemovalWarnings : undefined,
|
|
5517
5798
|
};
|
|
5518
5799
|
}
|
|
5800
|
+
const objects = this.buildObjectInputsFromNames(evolveNames, schemaName);
|
|
5519
5801
|
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
// Step 2: Build ExistingTables from Metadata.Entities matching the schema
|
|
5802
|
+
// Build ExistingTables from Metadata.Entities matching the schema (continuing objects
|
|
5803
|
+
// have a table; new objects don't — SchemaBuilder CREATEs those, ALTERs the rest)
|
|
5524
5804
|
const existingTables: ExistingTableInfo[] = [];
|
|
5525
5805
|
for (const obj of objects) {
|
|
5526
5806
|
const entityInfo = md.Entities.find(
|
|
@@ -5543,14 +5823,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5543
5823
|
}
|
|
5544
5824
|
}
|
|
5545
5825
|
|
|
5546
|
-
//
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
const sourceSchema = await introspect(companyIntegration, user);
|
|
5826
|
+
// Source of truth for the evolution = the PERSISTED post-resolution rows (the refresh in
|
|
5827
|
+
// Phase 1 just wrote them via the overlay) — no duplicate vendor introspect.
|
|
5828
|
+
const sourceSchema = this.buildSourceSchemaFromPersistedRows(companyIntegration.IntegrationID);
|
|
5550
5829
|
|
|
5551
5830
|
// Normalize names to match source schema casing
|
|
5552
5831
|
const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
|
|
5553
|
-
const normalizedNames =
|
|
5832
|
+
const normalizedNames = evolveNames.map(n => nameMap.get(n.toLowerCase()) ?? n);
|
|
5554
5833
|
// Also normalize the objects array SourceObjectName
|
|
5555
5834
|
for (const obj of objects) {
|
|
5556
5835
|
const exact = nameMap.get(obj.SourceObjectName.toLowerCase());
|
|
@@ -5562,7 +5841,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5562
5841
|
Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
|
|
5563
5842
|
};
|
|
5564
5843
|
|
|
5565
|
-
//
|
|
5844
|
+
// Build target configs and SchemaBuilder input with ExistingTables
|
|
5566
5845
|
const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, validatedPlatform, connector);
|
|
5567
5846
|
|
|
5568
5847
|
const schemaInput: SchemaBuilderInput = {
|
|
@@ -5578,7 +5857,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5578
5857
|
EntitySettingsForTargets: {}
|
|
5579
5858
|
};
|
|
5580
5859
|
|
|
5581
|
-
//
|
|
5860
|
+
// ── Phase 5 — build schema (SchemaBuilder CREATEs new tables, ALTERs changed ones) ──
|
|
5582
5861
|
const builder = new SchemaBuilder();
|
|
5583
5862
|
const schemaOutput = builder.BuildSchema(schemaInput);
|
|
5584
5863
|
|
|
@@ -5591,19 +5870,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5591
5870
|
};
|
|
5592
5871
|
}
|
|
5593
5872
|
|
|
5594
|
-
//
|
|
5595
|
-
if (schemaOutput.MigrationFiles.length === 0) {
|
|
5596
|
-
return {
|
|
5597
|
-
Success: true,
|
|
5598
|
-
Message: 'No schema changes detected',
|
|
5599
|
-
HasChanges: false,
|
|
5600
|
-
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
5601
|
-
};
|
|
5602
|
-
}
|
|
5603
|
-
|
|
5604
|
-
// Step 7: Count added/modified columns by re-diffing
|
|
5873
|
+
// Per-object DDL diff for CHANGED detection (drives U10 watermark resets + reporting)
|
|
5605
5874
|
let addedColumns = 0;
|
|
5606
5875
|
let modifiedColumns = 0;
|
|
5876
|
+
const changedObjects: string[] = [];
|
|
5607
5877
|
const evolution = new SchemaEvolution();
|
|
5608
5878
|
for (const existing of existingTables) {
|
|
5609
5879
|
const config = targetConfigs.find(
|
|
@@ -5616,39 +5886,228 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5616
5886
|
const diff = evolution.DiffSchema(sourceObj, config, existing, validatedPlatform);
|
|
5617
5887
|
addedColumns += diff.AddedColumns.length;
|
|
5618
5888
|
modifiedColumns += diff.ModifiedColumns.length;
|
|
5889
|
+
if (diff.AddedColumns.length > 0 || diff.ModifiedColumns.length > 0) {
|
|
5890
|
+
changedObjects.push(config.SourceObjectName);
|
|
5891
|
+
}
|
|
5619
5892
|
}
|
|
5620
5893
|
|
|
5621
|
-
//
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5894
|
+
// ── Phase 6 — reconcile CONTINUING maps' field maps to the resolution ──
|
|
5895
|
+
// Added source fields get field maps (Active on enabled maps, Disabled on disabled
|
|
5896
|
+
// maps); field maps whose source field vanished from the resolution are disabled.
|
|
5897
|
+
// Field maps carry no entity FK, so this is restart-safe done here, pre-RSU.
|
|
5898
|
+
const iosByLowerName = new Map(activeIOs.map(io => [io.Name.toLowerCase(), io] as const));
|
|
5899
|
+
for (const em of continuingMaps) {
|
|
5900
|
+
const io = iosByLowerName.get((em.ExternalObjectName ?? '').toLowerCase());
|
|
5901
|
+
if (!io) continue;
|
|
5902
|
+
const fmChanges = await this.reconcileFieldMapsForEntityMap(em, io.ID, user, md);
|
|
5903
|
+
if ((fmChanges.Added > 0 || fmChanges.Disabled > 0) && em.ExternalObjectName && !changedObjects.includes(em.ExternalObjectName)) {
|
|
5904
|
+
changedObjects.push(em.ExternalObjectName);
|
|
5905
|
+
}
|
|
5906
|
+
}
|
|
5626
5907
|
|
|
5627
|
-
|
|
5628
|
-
|
|
5908
|
+
// ── Phase 7 — U10: reset the Pull watermark of every CHANGED object so the next sync
|
|
5909
|
+
// full-fetches + backfills the schema change (content-hash bounds the rewrite cost).
|
|
5910
|
+
const changedLower = new Set(changedObjects.map(n => n.toLowerCase()));
|
|
5911
|
+
const changedMapIDs = continuingMaps
|
|
5912
|
+
.filter(m => m.ExternalObjectName && changedLower.has(m.ExternalObjectName.toLowerCase()))
|
|
5913
|
+
.map(m => m.ID);
|
|
5914
|
+
const resetMapIDs = await ResetPullWatermarks(changedMapIDs, user, md);
|
|
5915
|
+
const watermarksReset = continuingMaps
|
|
5916
|
+
.filter(m => resetMapIDs.some(id => UUIDsEqual(id, m.ID)))
|
|
5917
|
+
.map(m => m.ExternalObjectName as string);
|
|
5918
|
+
|
|
5919
|
+
const hasDDLChanges = schemaOutput.MigrationFiles.length > 0;
|
|
5920
|
+
const hasChanges = hasDDLChanges || newObjects.length > 0 || removedObjects.length > 0 || changedObjects.length > 0;
|
|
5921
|
+
if (!hasChanges) {
|
|
5922
|
+
return {
|
|
5923
|
+
Success: true,
|
|
5924
|
+
Message: 'No schema changes detected',
|
|
5925
|
+
HasChanges: false,
|
|
5926
|
+
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
5927
|
+
};
|
|
5928
|
+
}
|
|
5629
5929
|
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5930
|
+
// ── Phase 8 — run RSU when DDL changed (migrate → codegen → commit → compile → restart) ──
|
|
5931
|
+
let pipelineSteps: RSUStepOutput[] | undefined;
|
|
5932
|
+
let gitCommitSuccess: boolean | undefined;
|
|
5933
|
+
let apiRestarted: boolean | undefined;
|
|
5934
|
+
if (hasDDLChanges) {
|
|
5935
|
+
const rsuInput = builder.BuildRSUInput(schemaOutput, schemaInput, {
|
|
5936
|
+
SkipGitCommit: skipGitCommit,
|
|
5937
|
+
SkipRestart: skipRestart,
|
|
5938
|
+
// Schema evolution runs a FULL re-resolution of this connector's schema, so its
|
|
5939
|
+
// soft-constraint payload is the complete current truth — authorize the writer to
|
|
5940
|
+
// prune additionalSchemaInfo entries for tables that vanished from the resolution
|
|
5941
|
+
// (rsuplan: "adds new ones, removes old ones that no longer exist"). Subset
|
|
5942
|
+
// ApplyAll builds deliberately do NOT set this.
|
|
5943
|
+
AdditionalSchemaInfoAuthoritative: true,
|
|
5944
|
+
});
|
|
5945
|
+
|
|
5946
|
+
// NEW objects need entities (post-codegen/restart) before their entity maps can be
|
|
5947
|
+
// created — hand them to the post-restart consumer via the SAME pending-work channel
|
|
5948
|
+
// ApplyAll uses. default: created DISABLED (user enables after the refresh);
|
|
5949
|
+
// autoEnableNewObjects opts in. Removal was already handled in Phase 3 → 'ignore'.
|
|
5950
|
+
if (newObjects.length > 0 && !skipRestart) {
|
|
5951
|
+
const { join } = await import('node:path');
|
|
5952
|
+
const rsuWorkDir = process.env.RSU_WORK_DIR || process.cwd();
|
|
5953
|
+
const pendingFilePath = join(rsuWorkDir, '.rsu_pending', `${Date.now()}-evolution.json`);
|
|
5954
|
+
rsuInput.PostRestartFiles = [
|
|
5955
|
+
...(rsuInput.PostRestartFiles ?? []),
|
|
5956
|
+
{
|
|
5957
|
+
Path: pendingFilePath,
|
|
5958
|
+
Content: JSON.stringify({
|
|
5959
|
+
CompanyIntegrationID: companyIntegrationID,
|
|
5960
|
+
SourceObjectNames: newObjects,
|
|
5961
|
+
SourceObjectFields: Object.fromEntries(newObjects.map(n => [n, null])),
|
|
5962
|
+
SchemaName: schemaName,
|
|
5963
|
+
StartSync: false,
|
|
5964
|
+
SyncScope: 'created',
|
|
5965
|
+
UnselectedAction: 'ignore',
|
|
5966
|
+
CreateDisabled: !autoEnableNewObjects,
|
|
5967
|
+
CreatedAt: new Date().toISOString(),
|
|
5968
|
+
}, null, 2),
|
|
5969
|
+
},
|
|
5970
|
+
];
|
|
5971
|
+
}
|
|
5972
|
+
|
|
5973
|
+
const rsm = RuntimeSchemaManager.Instance;
|
|
5974
|
+
const batchResult = await rsm.RunPipelineBatch([rsuInput]);
|
|
5975
|
+
const pipelineResult = batchResult.Results[0];
|
|
5976
|
+
pipelineSteps = pipelineResult?.Steps.map((s: RSUPipelineStep) => ({
|
|
5977
|
+
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
|
|
5978
|
+
}));
|
|
5979
|
+
gitCommitSuccess = pipelineResult?.GitCommitSuccess;
|
|
5980
|
+
apiRestarted = pipelineResult?.APIRestarted;
|
|
5981
|
+
if (!pipelineResult?.Success) {
|
|
5982
|
+
return {
|
|
5983
|
+
Success: false,
|
|
5984
|
+
Message: `Pipeline failed: ${pipelineResult?.ErrorMessage ?? 'unknown error'}`,
|
|
5985
|
+
HasChanges: true,
|
|
5986
|
+
AddedColumns: addedColumns,
|
|
5987
|
+
ModifiedColumns: modifiedColumns,
|
|
5988
|
+
Steps: pipelineSteps,
|
|
5989
|
+
NewObjects: newObjects.length > 0 ? newObjects : undefined,
|
|
5990
|
+
RemovedObjects: removedObjects.length > 0 ? removedObjects : undefined,
|
|
5991
|
+
ChangedObjects: changedObjects.length > 0 ? changedObjects : undefined,
|
|
5992
|
+
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
5993
|
+
};
|
|
5994
|
+
}
|
|
5995
|
+
|
|
5996
|
+
// skipRestart=true: entities exist after provider.Refresh() — create the new
|
|
5997
|
+
// objects' entity maps inline, then apply the disabled-by-default rule.
|
|
5998
|
+
if (newObjects.length > 0 && skipRestart) {
|
|
5999
|
+
await md.Refresh();
|
|
6000
|
+
const newObjectInputs = objects.filter(o => newObjects.some(n => n.toLowerCase() === o.SourceObjectName.toLowerCase()));
|
|
6001
|
+
const created = await this.createEntityAndFieldMaps(
|
|
6002
|
+
companyIntegrationID, newObjectInputs, connector, companyIntegration, schemaName, user, md, 'Pull'
|
|
6003
|
+
);
|
|
6004
|
+
if (!autoEnableNewObjects) {
|
|
6005
|
+
for (const c of created) {
|
|
6006
|
+
if (c.EntityMapID) await SetEntityMapEnabled(c.EntityMapID, false, user, md);
|
|
6007
|
+
}
|
|
6008
|
+
}
|
|
6009
|
+
}
|
|
6010
|
+
}
|
|
6011
|
+
|
|
6012
|
+
const summary = [
|
|
6013
|
+
newObjects.length > 0 ? `${newObjects.length} new object(s) (${autoEnableNewObjects ? 'enabled' : 'created disabled'})` : null,
|
|
6014
|
+
removedObjects.length > 0 ? `${removedObjects.length} removed object(s) disabled` : null,
|
|
6015
|
+
changedObjects.length > 0 ? `${changedObjects.length} changed object(s), ${addedColumns} column(s) added, ${modifiedColumns} modified` : null,
|
|
6016
|
+
watermarksReset.length > 0 ? `${watermarksReset.length} watermark(s) reset for backfill` : null,
|
|
6017
|
+
].filter(Boolean).join('; ');
|
|
5634
6018
|
|
|
5635
6019
|
return {
|
|
5636
|
-
Success:
|
|
5637
|
-
Message:
|
|
5638
|
-
? `Schema evolution applied — ${addedColumns} column(s) added, ${modifiedColumns} column(s) modified`
|
|
5639
|
-
: `Pipeline failed: ${pipelineResult?.ErrorMessage ?? 'unknown error'}`,
|
|
6020
|
+
Success: true,
|
|
6021
|
+
Message: `Schema evolution applied — ${summary || 'no per-object deltas'}`,
|
|
5640
6022
|
HasChanges: true,
|
|
5641
6023
|
AddedColumns: addedColumns,
|
|
5642
6024
|
ModifiedColumns: modifiedColumns,
|
|
5643
6025
|
Steps: pipelineSteps,
|
|
5644
|
-
GitCommitSuccess:
|
|
5645
|
-
APIRestarted:
|
|
5646
|
-
|
|
6026
|
+
GitCommitSuccess: gitCommitSuccess,
|
|
6027
|
+
APIRestarted: apiRestarted,
|
|
6028
|
+
NewObjects: newObjects.length > 0 ? newObjects : undefined,
|
|
6029
|
+
RemovedObjects: removedObjects.length > 0 ? removedObjects : undefined,
|
|
6030
|
+
CascadeRemovedObjects: cascadeRemoved.length > 0 ? cascadeRemoved : undefined,
|
|
6031
|
+
ChangedObjects: changedObjects.length > 0 ? changedObjects : undefined,
|
|
6032
|
+
WatermarksReset: watermarksReset.length > 0 ? watermarksReset : undefined,
|
|
6033
|
+
Warnings: (dagRemovalWarnings.length + schemaOutput.Warnings.length) > 0
|
|
6034
|
+
? [...dagRemovalWarnings, ...schemaOutput.Warnings]
|
|
6035
|
+
: undefined,
|
|
5647
6036
|
};
|
|
5648
6037
|
} catch (e) {
|
|
5649
6038
|
LogError(`IntegrationSchemaEvolution error: ${e}`);
|
|
5650
6039
|
return { Success: false, Message: this.formatError(e), HasChanges: false };
|
|
6040
|
+
} finally {
|
|
6041
|
+
IntegrationEngine.ReleaseMaintenanceLock(companyIntegrationID);
|
|
6042
|
+
}
|
|
6043
|
+
}
|
|
6044
|
+
|
|
6045
|
+
/**
|
|
6046
|
+
* Refresh diff (continuing objects): reconciles one entity map's field maps to the
|
|
6047
|
+
* post-resolution IOF set — source fields WITHOUT a field map get one created (Active when
|
|
6048
|
+
* the map is enabled, Disabled when it isn't), and field maps whose source field is no
|
|
6049
|
+
* longer Active in the resolution are DISABLED (never deleted — a later re-appearance
|
|
6050
|
+
* re-enables via the same reconciliation, since a disabled FM whose field returns flips
|
|
6051
|
+
* back with SetFieldMapsStatus on re-enable or is simply left disabled until then).
|
|
6052
|
+
*/
|
|
6053
|
+
private async reconcileFieldMapsForEntityMap(
|
|
6054
|
+
em: MJCompanyIntegrationEntityMapEntity,
|
|
6055
|
+
integrationObjectID: string,
|
|
6056
|
+
user: UserInfo,
|
|
6057
|
+
md: IMetadataProvider
|
|
6058
|
+
): Promise<{ Added: number; Disabled: number }> {
|
|
6059
|
+
const result = { Added: 0, Disabled: 0 };
|
|
6060
|
+
const activeFields = IntegrationEngineBase.Instance
|
|
6061
|
+
.GetIntegrationObjectFields(integrationObjectID)
|
|
6062
|
+
.filter(f => f.Status === 'Active');
|
|
6063
|
+
const activeFieldNames = new Set(activeFields.map(f => f.Name.toLowerCase()));
|
|
6064
|
+
const mapEnabled = em.Status === 'Active' && em.SyncEnabled === true;
|
|
6065
|
+
|
|
6066
|
+
const fms = await new RunView().RunView<MJCompanyIntegrationFieldMapEntity>({
|
|
6067
|
+
EntityName: 'MJ: Company Integration Field Maps',
|
|
6068
|
+
ExtraFilter: `EntityMapID='${em.ID}'`,
|
|
6069
|
+
ResultType: 'entity_object',
|
|
6070
|
+
BypassCache: true,
|
|
6071
|
+
}, user);
|
|
6072
|
+
const existingByLower = new Map(
|
|
6073
|
+
(fms.Success ? fms.Results : []).map(fm => [(fm.SourceFieldName ?? '').toLowerCase(), fm] as const)
|
|
6074
|
+
);
|
|
6075
|
+
|
|
6076
|
+
// Added source fields → new field maps (state follows the map's enabled state)
|
|
6077
|
+
for (const field of activeFields) {
|
|
6078
|
+
const existing = existingByLower.get(field.Name.toLowerCase());
|
|
6079
|
+
if (existing) {
|
|
6080
|
+
// Field is back in the resolution — re-enable a previously-disabled map row.
|
|
6081
|
+
if (existing.Status !== 'Active' && mapEnabled) {
|
|
6082
|
+
existing.Status = 'Active';
|
|
6083
|
+
if (await existing.Save()) result.Added++; // counted as a change for reporting
|
|
6084
|
+
}
|
|
6085
|
+
continue;
|
|
6086
|
+
}
|
|
6087
|
+
const fm = await md.GetEntityObject<MJCompanyIntegrationFieldMapEntity>('MJ: Company Integration Field Maps', user);
|
|
6088
|
+
fm.NewRecord();
|
|
6089
|
+
fm.EntityMapID = em.ID;
|
|
6090
|
+
fm.SourceFieldName = field.Name;
|
|
6091
|
+
fm.DestinationFieldName = field.Name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
6092
|
+
fm.IsKeyField = field.IsPrimaryKey ?? false; // key on the PRIMARY key (identity), consistent with the initial-apply path + U1 (PK ≠ unique)
|
|
6093
|
+
fm.IsRequired = field.IsRequired === true;
|
|
6094
|
+
fm.Direction = 'SourceToDest';
|
|
6095
|
+
fm.Status = mapEnabled ? 'Active' : 'Inactive';
|
|
6096
|
+
fm.Priority = 0;
|
|
6097
|
+
if (await fm.Save()) result.Added++;
|
|
6098
|
+
else LogError(`[IntegrationSchemaEvolution] Failed to create field map '${field.Name}' on ${em.ExternalObjectName}: ${fm.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
5651
6099
|
}
|
|
6100
|
+
|
|
6101
|
+
// Vanished source fields → disable their field maps (data/columns kept)
|
|
6102
|
+
for (const [lowerName, fm] of existingByLower) {
|
|
6103
|
+
if (activeFieldNames.has(lowerName)) continue;
|
|
6104
|
+
if (fm.Status === 'Inactive') continue;
|
|
6105
|
+
fm.Status = 'Inactive';
|
|
6106
|
+
if (await fm.Save()) result.Disabled++;
|
|
6107
|
+
else LogError(`[IntegrationSchemaEvolution] Failed to disable field map '${fm.SourceFieldName}' on ${em.ExternalObjectName}: ${fm.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
6108
|
+
}
|
|
6109
|
+
|
|
6110
|
+
return result;
|
|
5652
6111
|
}
|
|
5653
6112
|
|
|
5654
6113
|
// ── WEBHOOK HELPER ──────────────────────────────────────────────────
|