@memberjunction/server 5.48.0 → 5.49.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/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 +122 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +629 -0
- 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/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 +522 -60
- 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 +88 -88
- 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__/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/config.ts +14 -0
- package/src/context.ts +28 -2
- package/src/generated/generated.ts +446 -1
- 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/resolvers/AdhocQueryResolver.ts +97 -30
- package/src/resolvers/GetDataResolver.ts +30 -1
- package/src/resolvers/IntegrationDiscoveryResolver.ts +514 -60
- 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,8 +3424,9 @@ 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 = [
|
|
@@ -3335,7 +3439,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3335
3439
|
|
|
3336
3440
|
const migrationSucceeded = batchResult.SuccessCount > 0;
|
|
3337
3441
|
const pipelineSteps = batchResult.Results[0]?.Steps.map((s: RSUPipelineStep) => ({
|
|
3338
|
-
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
|
|
3442
|
+
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
|
|
3339
3443
|
}));
|
|
3340
3444
|
|
|
3341
3445
|
// If pipeline failed, clean up pending file and return error
|
|
@@ -3358,6 +3462,17 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3358
3462
|
input.CompanyIntegrationID, objects, connector, companyIntegration, schemaName, user, provider,
|
|
3359
3463
|
input.DefaultSyncDirection ?? 'Pull'
|
|
3360
3464
|
);
|
|
3465
|
+
// Remove-as-disable: objects absent from THIS selection get their entity
|
|
3466
|
+
// map + field maps disabled (data kept; re-selection re-enables). 'ignore' opts out
|
|
3467
|
+
// for additive/subset applies (e.g. test-harness per-scope runs).
|
|
3468
|
+
if ((input.UnselectedAction ?? 'disable') !== 'ignore') {
|
|
3469
|
+
const disabledObjects = await DisableUnselectedEntityMaps(
|
|
3470
|
+
input.CompanyIntegrationID, resolvedNames, user, provider
|
|
3471
|
+
);
|
|
3472
|
+
if (disabledObjects.length > 0) {
|
|
3473
|
+
console.log(`[IntegrationApplyAll] Disabled ${disabledObjects.length} unselected entity map(s): ${disabledObjects.join(', ')}`);
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3361
3476
|
const createdMapIDs = entityMapsCreated.map(em => em.EntityMapID).filter(Boolean);
|
|
3362
3477
|
const scopedMapIDs = input.SyncScope === 'all' ? undefined : createdMapIDs;
|
|
3363
3478
|
// Skip sync when SyncScope='created' but 0 new maps were
|
|
@@ -3367,7 +3482,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3367
3482
|
const shouldStartSync = input.StartSync !== false &&
|
|
3368
3483
|
(input.SyncScope === 'all' || createdMapIDs.length > 0);
|
|
3369
3484
|
const syncRunID = shouldStartSync
|
|
3370
|
-
? await this.startSyncAfterApply(input.CompanyIntegrationID, user, scopedMapIDs,
|
|
3485
|
+
? await this.startSyncAfterApply(input.CompanyIntegrationID, user, scopedMapIDs, effectiveFullSync)
|
|
3371
3486
|
: null;
|
|
3372
3487
|
|
|
3373
3488
|
// Create schedule if requested
|
|
@@ -3531,6 +3646,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3531
3646
|
}
|
|
3532
3647
|
|
|
3533
3648
|
let em: MJCompanyIntegrationEntityMapEntity;
|
|
3649
|
+
const wasDisabled = existing ? existing.Status !== 'Active' || existing.SyncEnabled === false : false;
|
|
3534
3650
|
if (existing) {
|
|
3535
3651
|
em = existing; // reuse — keeps the map stable across re-applies
|
|
3536
3652
|
} else {
|
|
@@ -3550,6 +3666,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3550
3666
|
return null;
|
|
3551
3667
|
}
|
|
3552
3668
|
|
|
3669
|
+
// Re-add re-enables: a previously-removed (disabled) object that is re-selected
|
|
3670
|
+
// comes back WITH its field maps — the create-field-maps idempotency below skips existing
|
|
3671
|
+
// rows, so without this they would stay Disabled forever.
|
|
3672
|
+
if (wasDisabled) {
|
|
3673
|
+
await ReenableFieldMapsForEntityMap(em.ID, user, md);
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3553
3676
|
// Discover fields from the source and create 1:1 field maps
|
|
3554
3677
|
const fieldMapCount = await this.createFieldMapsForEntityMap(
|
|
3555
3678
|
em.ID, obj.SourceObjectName, connector, companyIntegration, user, md
|
|
@@ -3601,7 +3724,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3601
3724
|
fm.EntityMapID = entityMapID;
|
|
3602
3725
|
fm.SourceFieldName = field.Name;
|
|
3603
3726
|
fm.DestinationFieldName = field.Name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
3604
|
-
fm.IsKeyField = field.
|
|
3727
|
+
fm.IsKeyField = field.IsPrimaryKey ?? false; // key on the record's PRIMARY key (identity), consistent with processRSUPendingWork + U1 (PK ≠ unique)
|
|
3605
3728
|
fm.IsRequired = field.IsRequired;
|
|
3606
3729
|
fm.Direction = 'SourceToDest';
|
|
3607
3730
|
fm.Status = 'Active';
|
|
@@ -3854,6 +3977,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3854
3977
|
return { Success: false, Message: 'Connector is deactivated (IsActive=false); sync not started' };
|
|
3855
3978
|
}
|
|
3856
3979
|
|
|
3980
|
+
// sync lock: refuse while a metadata refresh / schema evolution / RSU pipeline
|
|
3981
|
+
// holds the maintenance lock for this connection (the engine also refuses — this gives
|
|
3982
|
+
// the client an immediate, unambiguous answer).
|
|
3983
|
+
const maintenance = IntegrationEngine.GetMaintenanceLock(companyIntegrationID);
|
|
3984
|
+
if (maintenance) {
|
|
3985
|
+
return { Success: false, Message: `Sync not started: ${maintenance.Reason} is in progress for this connection (since ${maintenance.AcquiredAt.toISOString()}). Retry after it completes.` };
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3857
3988
|
const syncOptions: { FullSync?: boolean; EntityMapIDs?: string[]; SyncDirection?: 'Pull' | 'Push' | 'Bidirectional' } = {};
|
|
3858
3989
|
if (fullSync) syncOptions.FullSync = true;
|
|
3859
3990
|
if (entityMapIDs?.length) syncOptions.EntityMapIDs = entityMapIDs;
|
|
@@ -3910,10 +4041,21 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3910
4041
|
// null RunID: an optimistic, untrackable result that read as "started" when nothing
|
|
3911
4042
|
// could be followed. Poll briefly for ANY run for this connector stamped at/after the
|
|
3912
4043
|
// fire instant; that catches both the still-running and the already-finished cases.
|
|
4044
|
+
//
|
|
4045
|
+
// The row itself isn't created until executeSyncInternal reaches CreateRunRecord,
|
|
4046
|
+
// which runs AFTER LoadRunConfiguration (loads every entity map + field map + builds
|
|
4047
|
+
// the connector instance) — for a large connector (HubSpot, 168 entity maps) that
|
|
4048
|
+
// synchronous setup alone can take several seconds, so a short fixed poll window can
|
|
4049
|
+
// elapse before the row exists at all, not because the sync failed. Live-verified: a
|
|
4050
|
+
// HubSpot StartSync call reported "no run created" while the run (confirmed present in
|
|
4051
|
+
// the DB moments later) was genuinely syncing. Keep the fast path fast (first 2s still
|
|
4052
|
+
// polled every 200ms) but extend the tail so large connectors get a fair window before
|
|
4053
|
+
// we honestly report failure.
|
|
3913
4054
|
const firedAtFilter = firedAt.toISOString();
|
|
3914
4055
|
let run: { ID: string; Status: string } | null = null;
|
|
3915
|
-
|
|
3916
|
-
|
|
4056
|
+
const pollIntervalsMs = [...Array(10).fill(200), ...Array(20).fill(1000)];
|
|
4057
|
+
for (let attempt = 0; attempt < pollIntervalsMs.length && !run; attempt++) {
|
|
4058
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalsMs[attempt]));
|
|
3917
4059
|
const rv = new RunView();
|
|
3918
4060
|
const runResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
|
|
3919
4061
|
EntityName: 'MJ: Company Integration Runs',
|
|
@@ -4087,8 +4229,18 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
4087
4229
|
}
|
|
4088
4230
|
const jobTypeID = jobTypeResult.Results[0].ID;
|
|
4089
4231
|
|
|
4232
|
+
// U7 — SERVER-SIDE uniqueness per (CompanyIntegration, job kind/driver): a client
|
|
4233
|
+
// name-regex is not a guarantee, and duplicate jobs mean duplicate sync/rediscovery
|
|
4234
|
+
// runs. When an active/paused job of this kind already targets this connection,
|
|
4235
|
+
// UPDATE it in place instead of creating a twin.
|
|
4090
4236
|
const job = await md.GetEntityObject<MJScheduledJobEntity>('MJ: Scheduled Jobs', user);
|
|
4091
|
-
|
|
4237
|
+
const existingJob = await this.findExistingScheduledJob(jobTypeID, input.CompanyIntegrationID, user);
|
|
4238
|
+
let updatedInPlace = false;
|
|
4239
|
+
if (existingJob) {
|
|
4240
|
+
const loaded = await job.InnerLoad(CompositeKey.FromID(existingJob.ID));
|
|
4241
|
+
if (loaded) updatedInPlace = true;
|
|
4242
|
+
}
|
|
4243
|
+
if (!updatedInPlace) job.NewRecord();
|
|
4092
4244
|
job.JobTypeID = jobTypeID;
|
|
4093
4245
|
job.Name = input.Name;
|
|
4094
4246
|
if (input.Description) job.Description = input.Description;
|
|
@@ -4120,13 +4272,50 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
4120
4272
|
await ci.Save();
|
|
4121
4273
|
}
|
|
4122
4274
|
|
|
4123
|
-
return {
|
|
4275
|
+
return {
|
|
4276
|
+
Success: true,
|
|
4277
|
+
Message: updatedInPlace
|
|
4278
|
+
? 'Schedule updated — an active schedule of this kind already existed for this connection (server-side uniqueness)'
|
|
4279
|
+
: 'Schedule created',
|
|
4280
|
+
ScheduledJobID: job.ID,
|
|
4281
|
+
};
|
|
4124
4282
|
} catch (e) {
|
|
4125
4283
|
LogError(`IntegrationCreateSchedule error: ${e}`);
|
|
4126
4284
|
return { Success: false, Message: this.formatError(e) };
|
|
4127
4285
|
}
|
|
4128
4286
|
}
|
|
4129
4287
|
|
|
4288
|
+
/**
|
|
4289
|
+
* U7 — server-side schedule uniqueness: finds an existing (non-deleted) scheduled job of the
|
|
4290
|
+
* given job type that targets the given CompanyIntegration (its Configuration JSON carries
|
|
4291
|
+
* CompanyIntegrationID). Matched case-insensitively across UUID casings (SQL Server upper /
|
|
4292
|
+
* PostgreSQL lower). Returns the newest match or null.
|
|
4293
|
+
*/
|
|
4294
|
+
private async findExistingScheduledJob(
|
|
4295
|
+
jobTypeID: string,
|
|
4296
|
+
companyIntegrationID: string,
|
|
4297
|
+
user: UserInfo
|
|
4298
|
+
): Promise<{ ID: string } | null> {
|
|
4299
|
+
const esc = (s: string) => s.replace(/'/g, "''");
|
|
4300
|
+
const lower = esc(companyIntegrationID.toLowerCase());
|
|
4301
|
+
const upper = esc(companyIntegrationID.toUpperCase());
|
|
4302
|
+
const result = await new RunView().RunView<{ ID: string }>({
|
|
4303
|
+
EntityName: 'MJ: Scheduled Jobs',
|
|
4304
|
+
ExtraFilter:
|
|
4305
|
+
`JobTypeID='${esc(jobTypeID)}' AND Status IN ('Active','Paused') AND ` +
|
|
4306
|
+
// Structured "CompanyIntegrationID":"<uuid>" predicate (same form as IntegrationListSchedules),
|
|
4307
|
+
// NOT a bare-UUID substring — so this CI's id can't false-match the same UUID appearing in
|
|
4308
|
+
// another config key (e.g. RelatedCompanyIntegrationID) and repurpose an unrelated job.
|
|
4309
|
+
`(Configuration LIKE '%"CompanyIntegrationID":"${lower}"%' OR Configuration LIKE '%"CompanyIntegrationID":"${upper}"%')`,
|
|
4310
|
+
OrderBy: '__mj_CreatedAt DESC',
|
|
4311
|
+
Fields: ['ID'],
|
|
4312
|
+
MaxRows: 1,
|
|
4313
|
+
ResultType: 'simple',
|
|
4314
|
+
BypassCache: true, // uniqueness must see committed jobs, not a stale filtered cache
|
|
4315
|
+
}, user);
|
|
4316
|
+
return result.Success && result.Results.length > 0 ? result.Results[0] : null;
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4130
4319
|
@Mutation(() => MutationResultOutput)
|
|
4131
4320
|
async IntegrationUpdateSchedule(
|
|
4132
4321
|
@Arg("scheduledJobID") scheduledJobID: string,
|
|
@@ -4491,7 +4680,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
4491
4680
|
OperationType: 'rsu',
|
|
4492
4681
|
IsRunning: true,
|
|
4493
4682
|
RSURunning: true,
|
|
4494
|
-
|
|
4683
|
+
// U11 — determinate "step N of M: <name>" instead of an indeterminate spinner
|
|
4684
|
+
RSUStep: rsuStatus.CurrentStepName ?? rsuStatus.LastRunResult ?? 'running',
|
|
4685
|
+
RSUStepIndex: rsuStatus.CurrentStepIndex ?? undefined,
|
|
4686
|
+
RSUStepTotal: rsuStatus.StepTotal ?? undefined,
|
|
4495
4687
|
StartedAt: rsuStatus.LastRunAt?.toISOString(),
|
|
4496
4688
|
ElapsedMs: rsuStatus.LastRunAt ? Date.now() - rsuStatus.LastRunAt.getTime() : undefined,
|
|
4497
4689
|
};
|
|
@@ -5055,6 +5247,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5055
5247
|
SyncScope: input.SyncScope ?? 'created',
|
|
5056
5248
|
SyncDirection: input.SyncDirection,
|
|
5057
5249
|
ScheduleSyncDirection: input.ScheduleSyncDirection,
|
|
5250
|
+
UnselectedAction: (input.UnselectedAction ?? 'disable') as 'disable' | 'ignore',
|
|
5058
5251
|
CreatedAt: new Date().toISOString(),
|
|
5059
5252
|
};
|
|
5060
5253
|
rsuInput.PostRestartFiles = [
|
|
@@ -5190,7 +5383,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5190
5383
|
}
|
|
5191
5384
|
|
|
5192
5385
|
const pipelineSteps = batchResult.Results[0]?.Steps.map((s: RSUPipelineStep) => ({
|
|
5193
|
-
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
|
|
5386
|
+
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
|
|
5194
5387
|
}));
|
|
5195
5388
|
|
|
5196
5389
|
const successCount = connectorResults.filter(r => r.Success).length;
|
|
@@ -5242,8 +5435,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5242
5435
|
}
|
|
5243
5436
|
const jobTypeID = jobTypeResult.Results[0].ID;
|
|
5244
5437
|
|
|
5438
|
+
// U7 — same server-side uniqueness as IntegrationCreateSchedule: update the existing
|
|
5439
|
+
// sync job for this connection in place rather than creating a duplicate.
|
|
5245
5440
|
const job = await md.GetEntityObject<MJScheduledJobEntity>('MJ: Scheduled Jobs', user);
|
|
5246
|
-
|
|
5441
|
+
const existingJob = await this.findExistingScheduledJob(jobTypeID, companyIntegrationID, user);
|
|
5442
|
+
const loadedExisting = existingJob ? await job.InnerLoad(CompositeKey.FromID(existingJob.ID)) : false;
|
|
5443
|
+
if (!loadedExisting) job.NewRecord();
|
|
5247
5444
|
job.JobTypeID = jobTypeID;
|
|
5248
5445
|
job.Name = `${integrationName} Sync`;
|
|
5249
5446
|
job.CronExpression = cronExpression;
|
|
@@ -5492,8 +5689,19 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5492
5689
|
@Arg("platform", { defaultValue: "sqlserver" }) platform: string,
|
|
5493
5690
|
@Arg("skipGitCommit", { defaultValue: false }) skipGitCommit: boolean,
|
|
5494
5691
|
@Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
|
|
5692
|
+
@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,
|
|
5693
|
+
@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,
|
|
5694
|
+
@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
5695
|
@Ctx() ctx: AppContext
|
|
5496
5696
|
): Promise<SchemaEvolutionOutput> {
|
|
5697
|
+
// sync lock: schema evolution rewrites the metadata, field maps and DDL a sync
|
|
5698
|
+
// reads — data syncs (manual + scheduled) must not run while this is in flight.
|
|
5699
|
+
if (!IntegrationEngine.AcquireMaintenanceLock(companyIntegrationID, 'schema evolution')) {
|
|
5700
|
+
return {
|
|
5701
|
+
Success: false, HasChanges: false,
|
|
5702
|
+
Message: 'Schema evolution not started: a sync or another maintenance operation is currently running for this connection. Retry after it completes.',
|
|
5703
|
+
};
|
|
5704
|
+
}
|
|
5497
5705
|
try {
|
|
5498
5706
|
const user = this.getAuthenticatedUser(ctx);
|
|
5499
5707
|
const validatedPlatform = this.validatePlatform(platform);
|
|
@@ -5502,25 +5710,92 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5502
5710
|
const schemaName = this.deriveSchemaName(companyIntegration.Integration);
|
|
5503
5711
|
const rv = new RunView();
|
|
5504
5712
|
|
|
5505
|
-
//
|
|
5506
|
-
|
|
5713
|
+
// ── Phase 1 — FULL RE-RESOLUTION (: "the exact same algorithm as before") ──
|
|
5714
|
+
// Re-run the creation pipeline: introspect (static + runtime discovery), persist with
|
|
5715
|
+
// the external-wins-when-present overlay, deactivate absent (gated on authoritative
|
|
5716
|
+
// discovery), PK-classify the residual. The diff below is computed against the
|
|
5717
|
+
// RESULT of this resolution, exactly as the first-time flow computed its state.
|
|
5718
|
+
const pipeline = new IntegrationConnectorCreationPipeline();
|
|
5719
|
+
const refresh = await pipeline.Run({
|
|
5720
|
+
Connector: connector,
|
|
5721
|
+
CompanyIntegration: companyIntegration,
|
|
5722
|
+
ContextUser: user,
|
|
5723
|
+
Provider: md,
|
|
5724
|
+
TriggerType: 'Manual',
|
|
5725
|
+
ConsoleMirror: true,
|
|
5726
|
+
DeactivateAbsent: deactivateAbsent ?? this.readConfigBool(companyIntegration.Configuration, 'deactivateAbsent') ?? true,
|
|
5727
|
+
} as unknown as Parameters<typeof pipeline.Run>[0]);
|
|
5728
|
+
if (!refresh.Success) {
|
|
5729
|
+
return { Success: false, HasChanges: false, Message: `Schema evolution aborted — metadata re-resolution failed: ${refresh.FailureMessage ?? 'unknown error'}` };
|
|
5730
|
+
}
|
|
5731
|
+
await md.Refresh();
|
|
5732
|
+
await IntegrationEngine.Instance.Config(true, user, md);
|
|
5733
|
+
|
|
5734
|
+
// ── Phase 2 — DIFF: resolved-Active objects vs prior entity maps ──
|
|
5735
|
+
const activeIOs = IntegrationEngineBase.Instance.GetActiveIntegrationObjects(companyIntegration.IntegrationID);
|
|
5736
|
+
const activeNamesLower = new Set(activeIOs.map(o => o.Name.toLowerCase()));
|
|
5737
|
+
const priorMapsResult = await rv.RunView<MJCompanyIntegrationEntityMapEntity>({
|
|
5507
5738
|
EntityName: 'MJ: Company Integration Entity Maps',
|
|
5508
5739
|
ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
|
|
5509
|
-
ResultType: '
|
|
5510
|
-
|
|
5740
|
+
ResultType: 'entity_object',
|
|
5741
|
+
BypassCache: true, // the diff must read committed state
|
|
5511
5742
|
}, user);
|
|
5743
|
+
const priorMaps = priorMapsResult.Success ? priorMapsResult.Results : [];
|
|
5744
|
+
const priorByLowerName = new Map(priorMaps.map(m => [(m.ExternalObjectName ?? '').toLowerCase(), m] as const));
|
|
5745
|
+
|
|
5746
|
+
const newObjects = activeIOs.filter(io => !priorByLowerName.has(io.Name.toLowerCase())).map(io => io.Name);
|
|
5747
|
+
const removedObjects = priorMaps
|
|
5748
|
+
.filter(m => m.ExternalObjectName && !activeNamesLower.has(m.ExternalObjectName.toLowerCase()))
|
|
5749
|
+
.map(m => m.ExternalObjectName as string);
|
|
5750
|
+
const continuingMaps = priorMaps.filter(m => m.ExternalObjectName && activeNamesLower.has(m.ExternalObjectName.toLowerCase()));
|
|
5751
|
+
|
|
5752
|
+
// ── Phase 3 — REMOVED objects: disable EM + EFMs (data + tables KEPT) ──
|
|
5753
|
+
// Uses the same remove-as-disable helper as ApplyAll: everything not in the resolved
|
|
5754
|
+
// set is disabled; a later re-appearance re-enables (reactivate-on-rediscover).
|
|
5755
|
+
// DAG handling (rsuplan "adding and removing tables"): dependents of a removed object
|
|
5756
|
+
// are NOT auto-removed by default — each broken parent edge is surfaced as a warning so
|
|
5757
|
+
// a consumer can prompt the user. With cascadeRemoveDependents=true (the "force remove
|
|
5758
|
+
// those too" affordance), the TRANSITIVE closure of still-active dependents is disabled
|
|
5759
|
+
// alongside the removed objects (still remove-as-disable, reversible). Edges come from
|
|
5760
|
+
// the same dependency map the sync engine layers by.
|
|
5761
|
+
const depMap = removedObjects.length > 0
|
|
5762
|
+
? this.computeObjectDependencyNames(companyIntegration.IntegrationID)
|
|
5763
|
+
: new Map<string, string[]>();
|
|
5764
|
+
const cascadeRemoved: string[] = cascadeRemoveDependents && removedObjects.length > 0
|
|
5765
|
+
? ComputeCascadeRemovalSet(activeIOs.map(io => io.Name), removedObjects, depMap)
|
|
5766
|
+
: [];
|
|
5767
|
+
const cascadeLower = new Set(cascadeRemoved.map(n => n.toLowerCase()));
|
|
5768
|
+
const keepNames = activeIOs.map(io => io.Name).filter(n => !cascadeLower.has(n.toLowerCase()));
|
|
5769
|
+
const dagRemovalWarnings: string[] = removedObjects.length > 0 && !cascadeRemoveDependents
|
|
5770
|
+
? ComputeRemovedDependencyWarnings(keepNames, removedObjects, depMap)
|
|
5771
|
+
: [];
|
|
5772
|
+
for (const w of dagRemovalWarnings) LogStatus(`[SchemaEvolution][DAG] ${w}`);
|
|
5773
|
+
if (cascadeRemoved.length > 0) LogStatus(`[SchemaEvolution][DAG] cascade-removing ${cascadeRemoved.length} dependent object(s): ${cascadeRemoved.join(', ')}`);
|
|
5774
|
+
if (removedObjects.length > 0 || cascadeRemoved.length > 0) {
|
|
5775
|
+
await DisableUnselectedEntityMaps(companyIntegrationID, keepNames, user, md);
|
|
5776
|
+
}
|
|
5512
5777
|
|
|
5513
|
-
|
|
5778
|
+
// ── Phase 4 — schema build over continuing + new objects (minus cascade-removed) ──
|
|
5779
|
+
const evolveNames = [
|
|
5780
|
+
...continuingMaps.map(m => m.ExternalObjectName as string).filter(n => !cascadeLower.has(n.toLowerCase())),
|
|
5781
|
+
...newObjects,
|
|
5782
|
+
];
|
|
5783
|
+
if (evolveNames.length === 0) {
|
|
5784
|
+
const removedTotal = removedObjects.length + cascadeRemoved.length;
|
|
5514
5785
|
return {
|
|
5515
|
-
Success:
|
|
5516
|
-
|
|
5786
|
+
Success: true, HasChanges: removedTotal > 0,
|
|
5787
|
+
Message: removedTotal > 0
|
|
5788
|
+
? `No objects to evolve — ${removedTotal} removed object(s) disabled${cascadeRemoved.length > 0 ? ` (${cascadeRemoved.length} via cascade)` : ''}`
|
|
5789
|
+
: 'No entity maps and no resolved objects — nothing to evolve',
|
|
5790
|
+
RemovedObjects: removedObjects.length > 0 ? removedObjects : undefined,
|
|
5791
|
+
CascadeRemovedObjects: cascadeRemoved.length > 0 ? cascadeRemoved : undefined,
|
|
5792
|
+
Warnings: dagRemovalWarnings.length > 0 ? dagRemovalWarnings : undefined,
|
|
5517
5793
|
};
|
|
5518
5794
|
}
|
|
5795
|
+
const objects = this.buildObjectInputsFromNames(evolveNames, schemaName);
|
|
5519
5796
|
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
// Step 2: Build ExistingTables from Metadata.Entities matching the schema
|
|
5797
|
+
// Build ExistingTables from Metadata.Entities matching the schema (continuing objects
|
|
5798
|
+
// have a table; new objects don't — SchemaBuilder CREATEs those, ALTERs the rest)
|
|
5524
5799
|
const existingTables: ExistingTableInfo[] = [];
|
|
5525
5800
|
for (const obj of objects) {
|
|
5526
5801
|
const entityInfo = md.Entities.find(
|
|
@@ -5543,14 +5818,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5543
5818
|
}
|
|
5544
5819
|
}
|
|
5545
5820
|
|
|
5546
|
-
//
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
const sourceSchema = await introspect(companyIntegration, user);
|
|
5821
|
+
// Source of truth for the evolution = the PERSISTED post-resolution rows (the refresh in
|
|
5822
|
+
// Phase 1 just wrote them via the overlay) — no duplicate vendor introspect.
|
|
5823
|
+
const sourceSchema = this.buildSourceSchemaFromPersistedRows(companyIntegration.IntegrationID);
|
|
5550
5824
|
|
|
5551
5825
|
// Normalize names to match source schema casing
|
|
5552
5826
|
const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
|
|
5553
|
-
const normalizedNames =
|
|
5827
|
+
const normalizedNames = evolveNames.map(n => nameMap.get(n.toLowerCase()) ?? n);
|
|
5554
5828
|
// Also normalize the objects array SourceObjectName
|
|
5555
5829
|
for (const obj of objects) {
|
|
5556
5830
|
const exact = nameMap.get(obj.SourceObjectName.toLowerCase());
|
|
@@ -5562,7 +5836,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5562
5836
|
Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
|
|
5563
5837
|
};
|
|
5564
5838
|
|
|
5565
|
-
//
|
|
5839
|
+
// Build target configs and SchemaBuilder input with ExistingTables
|
|
5566
5840
|
const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, validatedPlatform, connector);
|
|
5567
5841
|
|
|
5568
5842
|
const schemaInput: SchemaBuilderInput = {
|
|
@@ -5578,7 +5852,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5578
5852
|
EntitySettingsForTargets: {}
|
|
5579
5853
|
};
|
|
5580
5854
|
|
|
5581
|
-
//
|
|
5855
|
+
// ── Phase 5 — build schema (SchemaBuilder CREATEs new tables, ALTERs changed ones) ──
|
|
5582
5856
|
const builder = new SchemaBuilder();
|
|
5583
5857
|
const schemaOutput = builder.BuildSchema(schemaInput);
|
|
5584
5858
|
|
|
@@ -5591,19 +5865,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5591
5865
|
};
|
|
5592
5866
|
}
|
|
5593
5867
|
|
|
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
|
|
5868
|
+
// Per-object DDL diff for CHANGED detection (drives U10 watermark resets + reporting)
|
|
5605
5869
|
let addedColumns = 0;
|
|
5606
5870
|
let modifiedColumns = 0;
|
|
5871
|
+
const changedObjects: string[] = [];
|
|
5607
5872
|
const evolution = new SchemaEvolution();
|
|
5608
5873
|
for (const existing of existingTables) {
|
|
5609
5874
|
const config = targetConfigs.find(
|
|
@@ -5616,39 +5881,228 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
5616
5881
|
const diff = evolution.DiffSchema(sourceObj, config, existing, validatedPlatform);
|
|
5617
5882
|
addedColumns += diff.AddedColumns.length;
|
|
5618
5883
|
modifiedColumns += diff.ModifiedColumns.length;
|
|
5884
|
+
if (diff.AddedColumns.length > 0 || diff.ModifiedColumns.length > 0) {
|
|
5885
|
+
changedObjects.push(config.SourceObjectName);
|
|
5886
|
+
}
|
|
5619
5887
|
}
|
|
5620
5888
|
|
|
5621
|
-
//
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5889
|
+
// ── Phase 6 — reconcile CONTINUING maps' field maps to the resolution ──
|
|
5890
|
+
// Added source fields get field maps (Active on enabled maps, Disabled on disabled
|
|
5891
|
+
// maps); field maps whose source field vanished from the resolution are disabled.
|
|
5892
|
+
// Field maps carry no entity FK, so this is restart-safe done here, pre-RSU.
|
|
5893
|
+
const iosByLowerName = new Map(activeIOs.map(io => [io.Name.toLowerCase(), io] as const));
|
|
5894
|
+
for (const em of continuingMaps) {
|
|
5895
|
+
const io = iosByLowerName.get((em.ExternalObjectName ?? '').toLowerCase());
|
|
5896
|
+
if (!io) continue;
|
|
5897
|
+
const fmChanges = await this.reconcileFieldMapsForEntityMap(em, io.ID, user, md);
|
|
5898
|
+
if ((fmChanges.Added > 0 || fmChanges.Disabled > 0) && em.ExternalObjectName && !changedObjects.includes(em.ExternalObjectName)) {
|
|
5899
|
+
changedObjects.push(em.ExternalObjectName);
|
|
5900
|
+
}
|
|
5901
|
+
}
|
|
5626
5902
|
|
|
5627
|
-
|
|
5628
|
-
|
|
5903
|
+
// ── Phase 7 — U10: reset the Pull watermark of every CHANGED object so the next sync
|
|
5904
|
+
// full-fetches + backfills the schema change (content-hash bounds the rewrite cost).
|
|
5905
|
+
const changedLower = new Set(changedObjects.map(n => n.toLowerCase()));
|
|
5906
|
+
const changedMapIDs = continuingMaps
|
|
5907
|
+
.filter(m => m.ExternalObjectName && changedLower.has(m.ExternalObjectName.toLowerCase()))
|
|
5908
|
+
.map(m => m.ID);
|
|
5909
|
+
const resetMapIDs = await ResetPullWatermarks(changedMapIDs, user, md);
|
|
5910
|
+
const watermarksReset = continuingMaps
|
|
5911
|
+
.filter(m => resetMapIDs.some(id => UUIDsEqual(id, m.ID)))
|
|
5912
|
+
.map(m => m.ExternalObjectName as string);
|
|
5913
|
+
|
|
5914
|
+
const hasDDLChanges = schemaOutput.MigrationFiles.length > 0;
|
|
5915
|
+
const hasChanges = hasDDLChanges || newObjects.length > 0 || removedObjects.length > 0 || changedObjects.length > 0;
|
|
5916
|
+
if (!hasChanges) {
|
|
5917
|
+
return {
|
|
5918
|
+
Success: true,
|
|
5919
|
+
Message: 'No schema changes detected',
|
|
5920
|
+
HasChanges: false,
|
|
5921
|
+
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
5922
|
+
};
|
|
5923
|
+
}
|
|
5629
5924
|
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5925
|
+
// ── Phase 8 — run RSU when DDL changed (migrate → codegen → commit → compile → restart) ──
|
|
5926
|
+
let pipelineSteps: RSUStepOutput[] | undefined;
|
|
5927
|
+
let gitCommitSuccess: boolean | undefined;
|
|
5928
|
+
let apiRestarted: boolean | undefined;
|
|
5929
|
+
if (hasDDLChanges) {
|
|
5930
|
+
const rsuInput = builder.BuildRSUInput(schemaOutput, schemaInput, {
|
|
5931
|
+
SkipGitCommit: skipGitCommit,
|
|
5932
|
+
SkipRestart: skipRestart,
|
|
5933
|
+
// Schema evolution runs a FULL re-resolution of this connector's schema, so its
|
|
5934
|
+
// soft-constraint payload is the complete current truth — authorize the writer to
|
|
5935
|
+
// prune additionalSchemaInfo entries for tables that vanished from the resolution
|
|
5936
|
+
// (rsuplan: "adds new ones, removes old ones that no longer exist"). Subset
|
|
5937
|
+
// ApplyAll builds deliberately do NOT set this.
|
|
5938
|
+
AdditionalSchemaInfoAuthoritative: true,
|
|
5939
|
+
});
|
|
5940
|
+
|
|
5941
|
+
// NEW objects need entities (post-codegen/restart) before their entity maps can be
|
|
5942
|
+
// created — hand them to the post-restart consumer via the SAME pending-work channel
|
|
5943
|
+
// ApplyAll uses. default: created DISABLED (user enables after the refresh);
|
|
5944
|
+
// autoEnableNewObjects opts in. Removal was already handled in Phase 3 → 'ignore'.
|
|
5945
|
+
if (newObjects.length > 0 && !skipRestart) {
|
|
5946
|
+
const { join } = await import('node:path');
|
|
5947
|
+
const rsuWorkDir = process.env.RSU_WORK_DIR || process.cwd();
|
|
5948
|
+
const pendingFilePath = join(rsuWorkDir, '.rsu_pending', `${Date.now()}-evolution.json`);
|
|
5949
|
+
rsuInput.PostRestartFiles = [
|
|
5950
|
+
...(rsuInput.PostRestartFiles ?? []),
|
|
5951
|
+
{
|
|
5952
|
+
Path: pendingFilePath,
|
|
5953
|
+
Content: JSON.stringify({
|
|
5954
|
+
CompanyIntegrationID: companyIntegrationID,
|
|
5955
|
+
SourceObjectNames: newObjects,
|
|
5956
|
+
SourceObjectFields: Object.fromEntries(newObjects.map(n => [n, null])),
|
|
5957
|
+
SchemaName: schemaName,
|
|
5958
|
+
StartSync: false,
|
|
5959
|
+
SyncScope: 'created',
|
|
5960
|
+
UnselectedAction: 'ignore',
|
|
5961
|
+
CreateDisabled: !autoEnableNewObjects,
|
|
5962
|
+
CreatedAt: new Date().toISOString(),
|
|
5963
|
+
}, null, 2),
|
|
5964
|
+
},
|
|
5965
|
+
];
|
|
5966
|
+
}
|
|
5967
|
+
|
|
5968
|
+
const rsm = RuntimeSchemaManager.Instance;
|
|
5969
|
+
const batchResult = await rsm.RunPipelineBatch([rsuInput]);
|
|
5970
|
+
const pipelineResult = batchResult.Results[0];
|
|
5971
|
+
pipelineSteps = pipelineResult?.Steps.map((s: RSUPipelineStep) => ({
|
|
5972
|
+
Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
|
|
5973
|
+
}));
|
|
5974
|
+
gitCommitSuccess = pipelineResult?.GitCommitSuccess;
|
|
5975
|
+
apiRestarted = pipelineResult?.APIRestarted;
|
|
5976
|
+
if (!pipelineResult?.Success) {
|
|
5977
|
+
return {
|
|
5978
|
+
Success: false,
|
|
5979
|
+
Message: `Pipeline failed: ${pipelineResult?.ErrorMessage ?? 'unknown error'}`,
|
|
5980
|
+
HasChanges: true,
|
|
5981
|
+
AddedColumns: addedColumns,
|
|
5982
|
+
ModifiedColumns: modifiedColumns,
|
|
5983
|
+
Steps: pipelineSteps,
|
|
5984
|
+
NewObjects: newObjects.length > 0 ? newObjects : undefined,
|
|
5985
|
+
RemovedObjects: removedObjects.length > 0 ? removedObjects : undefined,
|
|
5986
|
+
ChangedObjects: changedObjects.length > 0 ? changedObjects : undefined,
|
|
5987
|
+
Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
|
|
5988
|
+
};
|
|
5989
|
+
}
|
|
5990
|
+
|
|
5991
|
+
// skipRestart=true: entities exist after provider.Refresh() — create the new
|
|
5992
|
+
// objects' entity maps inline, then apply the disabled-by-default rule.
|
|
5993
|
+
if (newObjects.length > 0 && skipRestart) {
|
|
5994
|
+
await md.Refresh();
|
|
5995
|
+
const newObjectInputs = objects.filter(o => newObjects.some(n => n.toLowerCase() === o.SourceObjectName.toLowerCase()));
|
|
5996
|
+
const created = await this.createEntityAndFieldMaps(
|
|
5997
|
+
companyIntegrationID, newObjectInputs, connector, companyIntegration, schemaName, user, md, 'Pull'
|
|
5998
|
+
);
|
|
5999
|
+
if (!autoEnableNewObjects) {
|
|
6000
|
+
for (const c of created) {
|
|
6001
|
+
if (c.EntityMapID) await SetEntityMapEnabled(c.EntityMapID, false, user, md);
|
|
6002
|
+
}
|
|
6003
|
+
}
|
|
6004
|
+
}
|
|
6005
|
+
}
|
|
6006
|
+
|
|
6007
|
+
const summary = [
|
|
6008
|
+
newObjects.length > 0 ? `${newObjects.length} new object(s) (${autoEnableNewObjects ? 'enabled' : 'created disabled'})` : null,
|
|
6009
|
+
removedObjects.length > 0 ? `${removedObjects.length} removed object(s) disabled` : null,
|
|
6010
|
+
changedObjects.length > 0 ? `${changedObjects.length} changed object(s), ${addedColumns} column(s) added, ${modifiedColumns} modified` : null,
|
|
6011
|
+
watermarksReset.length > 0 ? `${watermarksReset.length} watermark(s) reset for backfill` : null,
|
|
6012
|
+
].filter(Boolean).join('; ');
|
|
5634
6013
|
|
|
5635
6014
|
return {
|
|
5636
|
-
Success:
|
|
5637
|
-
Message:
|
|
5638
|
-
? `Schema evolution applied — ${addedColumns} column(s) added, ${modifiedColumns} column(s) modified`
|
|
5639
|
-
: `Pipeline failed: ${pipelineResult?.ErrorMessage ?? 'unknown error'}`,
|
|
6015
|
+
Success: true,
|
|
6016
|
+
Message: `Schema evolution applied — ${summary || 'no per-object deltas'}`,
|
|
5640
6017
|
HasChanges: true,
|
|
5641
6018
|
AddedColumns: addedColumns,
|
|
5642
6019
|
ModifiedColumns: modifiedColumns,
|
|
5643
6020
|
Steps: pipelineSteps,
|
|
5644
|
-
GitCommitSuccess:
|
|
5645
|
-
APIRestarted:
|
|
5646
|
-
|
|
6021
|
+
GitCommitSuccess: gitCommitSuccess,
|
|
6022
|
+
APIRestarted: apiRestarted,
|
|
6023
|
+
NewObjects: newObjects.length > 0 ? newObjects : undefined,
|
|
6024
|
+
RemovedObjects: removedObjects.length > 0 ? removedObjects : undefined,
|
|
6025
|
+
CascadeRemovedObjects: cascadeRemoved.length > 0 ? cascadeRemoved : undefined,
|
|
6026
|
+
ChangedObjects: changedObjects.length > 0 ? changedObjects : undefined,
|
|
6027
|
+
WatermarksReset: watermarksReset.length > 0 ? watermarksReset : undefined,
|
|
6028
|
+
Warnings: (dagRemovalWarnings.length + schemaOutput.Warnings.length) > 0
|
|
6029
|
+
? [...dagRemovalWarnings, ...schemaOutput.Warnings]
|
|
6030
|
+
: undefined,
|
|
5647
6031
|
};
|
|
5648
6032
|
} catch (e) {
|
|
5649
6033
|
LogError(`IntegrationSchemaEvolution error: ${e}`);
|
|
5650
6034
|
return { Success: false, Message: this.formatError(e), HasChanges: false };
|
|
6035
|
+
} finally {
|
|
6036
|
+
IntegrationEngine.ReleaseMaintenanceLock(companyIntegrationID);
|
|
6037
|
+
}
|
|
6038
|
+
}
|
|
6039
|
+
|
|
6040
|
+
/**
|
|
6041
|
+
* Refresh diff (continuing objects): reconciles one entity map's field maps to the
|
|
6042
|
+
* post-resolution IOF set — source fields WITHOUT a field map get one created (Active when
|
|
6043
|
+
* the map is enabled, Disabled when it isn't), and field maps whose source field is no
|
|
6044
|
+
* longer Active in the resolution are DISABLED (never deleted — a later re-appearance
|
|
6045
|
+
* re-enables via the same reconciliation, since a disabled FM whose field returns flips
|
|
6046
|
+
* back with SetFieldMapsStatus on re-enable or is simply left disabled until then).
|
|
6047
|
+
*/
|
|
6048
|
+
private async reconcileFieldMapsForEntityMap(
|
|
6049
|
+
em: MJCompanyIntegrationEntityMapEntity,
|
|
6050
|
+
integrationObjectID: string,
|
|
6051
|
+
user: UserInfo,
|
|
6052
|
+
md: IMetadataProvider
|
|
6053
|
+
): Promise<{ Added: number; Disabled: number }> {
|
|
6054
|
+
const result = { Added: 0, Disabled: 0 };
|
|
6055
|
+
const activeFields = IntegrationEngineBase.Instance
|
|
6056
|
+
.GetIntegrationObjectFields(integrationObjectID)
|
|
6057
|
+
.filter(f => f.Status === 'Active');
|
|
6058
|
+
const activeFieldNames = new Set(activeFields.map(f => f.Name.toLowerCase()));
|
|
6059
|
+
const mapEnabled = em.Status === 'Active' && em.SyncEnabled === true;
|
|
6060
|
+
|
|
6061
|
+
const fms = await new RunView().RunView<MJCompanyIntegrationFieldMapEntity>({
|
|
6062
|
+
EntityName: 'MJ: Company Integration Field Maps',
|
|
6063
|
+
ExtraFilter: `EntityMapID='${em.ID}'`,
|
|
6064
|
+
ResultType: 'entity_object',
|
|
6065
|
+
BypassCache: true,
|
|
6066
|
+
}, user);
|
|
6067
|
+
const existingByLower = new Map(
|
|
6068
|
+
(fms.Success ? fms.Results : []).map(fm => [(fm.SourceFieldName ?? '').toLowerCase(), fm] as const)
|
|
6069
|
+
);
|
|
6070
|
+
|
|
6071
|
+
// Added source fields → new field maps (state follows the map's enabled state)
|
|
6072
|
+
for (const field of activeFields) {
|
|
6073
|
+
const existing = existingByLower.get(field.Name.toLowerCase());
|
|
6074
|
+
if (existing) {
|
|
6075
|
+
// Field is back in the resolution — re-enable a previously-disabled map row.
|
|
6076
|
+
if (existing.Status !== 'Active' && mapEnabled) {
|
|
6077
|
+
existing.Status = 'Active';
|
|
6078
|
+
if (await existing.Save()) result.Added++; // counted as a change for reporting
|
|
6079
|
+
}
|
|
6080
|
+
continue;
|
|
6081
|
+
}
|
|
6082
|
+
const fm = await md.GetEntityObject<MJCompanyIntegrationFieldMapEntity>('MJ: Company Integration Field Maps', user);
|
|
6083
|
+
fm.NewRecord();
|
|
6084
|
+
fm.EntityMapID = em.ID;
|
|
6085
|
+
fm.SourceFieldName = field.Name;
|
|
6086
|
+
fm.DestinationFieldName = field.Name.replace(/[^A-Za-z0-9_]/g, '_');
|
|
6087
|
+
fm.IsKeyField = field.IsPrimaryKey ?? false; // key on the PRIMARY key (identity), consistent with the initial-apply path + U1 (PK ≠ unique)
|
|
6088
|
+
fm.IsRequired = field.IsRequired === true;
|
|
6089
|
+
fm.Direction = 'SourceToDest';
|
|
6090
|
+
fm.Status = mapEnabled ? 'Active' : 'Inactive';
|
|
6091
|
+
fm.Priority = 0;
|
|
6092
|
+
if (await fm.Save()) result.Added++;
|
|
6093
|
+
else LogError(`[IntegrationSchemaEvolution] Failed to create field map '${field.Name}' on ${em.ExternalObjectName}: ${fm.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
5651
6094
|
}
|
|
6095
|
+
|
|
6096
|
+
// Vanished source fields → disable their field maps (data/columns kept)
|
|
6097
|
+
for (const [lowerName, fm] of existingByLower) {
|
|
6098
|
+
if (activeFieldNames.has(lowerName)) continue;
|
|
6099
|
+
if (fm.Status === 'Inactive') continue;
|
|
6100
|
+
fm.Status = 'Inactive';
|
|
6101
|
+
if (await fm.Save()) result.Disabled++;
|
|
6102
|
+
else LogError(`[IntegrationSchemaEvolution] Failed to disable field map '${fm.SourceFieldName}' on ${em.ExternalObjectName}: ${fm.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
6103
|
+
}
|
|
6104
|
+
|
|
6105
|
+
return result;
|
|
5652
6106
|
}
|
|
5653
6107
|
|
|
5654
6108
|
// ── WEBHOOK HELPER ──────────────────────────────────────────────────
|