@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.
Files changed (117) hide show
  1. package/README.md +9 -0
  2. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
  3. package/dist/auth/magicLink/MagicLinkKeys.js +11 -4
  4. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
  5. package/dist/auth/magicLink/MagicLinkRouter.js +2 -2
  6. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  7. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkService.js +4 -0
  9. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  10. package/dist/config.d.ts +40 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +12 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/context.d.ts.map +1 -1
  15. package/dist/context.js +28 -2
  16. package/dist/context.js.map +1 -1
  17. package/dist/generated/generated.d.ts +122 -0
  18. package/dist/generated/generated.d.ts.map +1 -1
  19. package/dist/generated/generated.js +629 -0
  20. package/dist/generated/generated.js.map +1 -1
  21. package/dist/generic/ResolverBase.d.ts.map +1 -1
  22. package/dist/generic/ResolverBase.js +4 -2
  23. package/dist/generic/ResolverBase.js.map +1 -1
  24. package/dist/generic/RunViewResolver.d.ts +1 -0
  25. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  26. package/dist/generic/RunViewResolver.js +12 -0
  27. package/dist/generic/RunViewResolver.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +73 -11
  30. package/dist/index.js.map +1 -1
  31. package/dist/integration/CustomColumnPromoter.d.ts +15 -2
  32. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  33. package/dist/integration/CustomColumnPromoter.js +113 -11
  34. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  35. package/dist/integration/EntityMapLifecycle.d.ts +66 -0
  36. package/dist/integration/EntityMapLifecycle.d.ts.map +1 -0
  37. package/dist/integration/EntityMapLifecycle.js +189 -0
  38. package/dist/integration/EntityMapLifecycle.js.map +1 -0
  39. package/dist/realtimeProxy/RealtimeProxyServer.d.ts +10 -0
  40. package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -1
  41. package/dist/realtimeProxy/RealtimeProxyServer.js +67 -1
  42. package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -1
  43. package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
  44. package/dist/realtimeWidget/WidgetRouter.js +6 -2
  45. package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
  46. package/dist/resolvers/AdhocQueryResolver.d.ts +17 -0
  47. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  48. package/dist/resolvers/AdhocQueryResolver.js +81 -25
  49. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  50. package/dist/resolvers/GetDataResolver.d.ts +16 -0
  51. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  52. package/dist/resolvers/GetDataResolver.js +26 -1
  53. package/dist/resolvers/GetDataResolver.js.map +1 -1
  54. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +53 -1
  55. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  56. package/dist/resolvers/IntegrationDiscoveryResolver.js +522 -60
  57. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  58. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  59. package/dist/resolvers/QueryResolver.js +0 -6
  60. package/dist/resolvers/QueryResolver.js.map +1 -1
  61. package/dist/resolvers/RSUResolver.d.ts +10 -0
  62. package/dist/resolvers/RSUResolver.d.ts.map +1 -1
  63. package/dist/resolvers/RSUResolver.js +28 -0
  64. package/dist/resolvers/RSUResolver.js.map +1 -1
  65. package/dist/resolvers/RunAIAgentResolver.d.ts +9 -6
  66. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  67. package/dist/resolvers/RunAIAgentResolver.js +135 -132
  68. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  69. package/dist/resolvers/TransactionGroupResolver.d.ts +8 -1
  70. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  71. package/dist/resolvers/TransactionGroupResolver.js +29 -1
  72. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  73. package/dist/resolvers/adhoc-query-helpers.d.ts +27 -0
  74. package/dist/resolvers/adhoc-query-helpers.d.ts.map +1 -0
  75. package/dist/resolvers/adhoc-query-helpers.js +42 -0
  76. package/dist/resolvers/adhoc-query-helpers.js.map +1 -0
  77. package/dist/rest/OAuthCallbackHandler.d.ts +9 -0
  78. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  79. package/dist/rest/OAuthCallbackHandler.js +21 -4
  80. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  81. package/dist/telephony/media-upgrade-router.d.ts +14 -0
  82. package/dist/telephony/media-upgrade-router.d.ts.map +1 -1
  83. package/dist/telephony/media-upgrade-router.js +17 -1
  84. package/dist/telephony/media-upgrade-router.js.map +1 -1
  85. package/package.json +88 -88
  86. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +114 -0
  87. package/src/__tests__/EntityMapLifecycle.dagWarnings.test.ts +91 -0
  88. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +83 -0
  89. package/src/__tests__/RealtimeProxyServer.test.ts +53 -1
  90. package/src/__tests__/adhoc-query-helpers.test.ts +50 -0
  91. package/src/__tests__/magicLinkService.provisionLog.test.ts +106 -0
  92. package/src/__tests__/media-upgrade-router.test.ts +43 -0
  93. package/src/__tests__/unifiedAuth.test.ts +18 -3
  94. package/src/auth/magicLink/MagicLinkKeys.ts +12 -6
  95. package/src/auth/magicLink/MagicLinkRouter.ts +2 -2
  96. package/src/auth/magicLink/MagicLinkService.ts +4 -0
  97. package/src/config.ts +14 -0
  98. package/src/context.ts +28 -2
  99. package/src/generated/generated.ts +446 -1
  100. package/src/generic/ResolverBase.ts +5 -2
  101. package/src/generic/RunViewResolver.ts +11 -0
  102. package/src/index.ts +75 -11
  103. package/src/integration/CustomColumnPromoter.ts +123 -6
  104. package/src/integration/EntityMapLifecycle.ts +221 -0
  105. package/src/realtimeProxy/RealtimeProxyServer.ts +70 -1
  106. package/src/realtimeWidget/WidgetRouter.ts +6 -2
  107. package/src/resolvers/AdhocQueryResolver.ts +97 -30
  108. package/src/resolvers/GetDataResolver.ts +30 -1
  109. package/src/resolvers/IntegrationDiscoveryResolver.ts +514 -60
  110. package/src/resolvers/QueryResolver.ts +1 -7
  111. package/src/resolvers/RSUResolver.ts +28 -0
  112. package/src/resolvers/RunAIAgentResolver.ts +74 -70
  113. package/src/resolvers/TransactionGroupResolver.ts +36 -2
  114. package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +138 -0
  115. package/src/resolvers/adhoc-query-helpers.ts +50 -0
  116. package/src/rest/OAuthCallbackHandler.ts +22 -4
  117. 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, defaultValue: false, description: 'If true, ignores watermarks and does a full re-fetch' }) FullSync?: boolean;
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: input.FullSync ?? false,
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, input.FullSync)
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.IsUniqueKey;
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
- for (let attempt = 0; attempt < 15 && !run; attempt++) {
3916
- await new Promise(resolve => setTimeout(resolve, 200));
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
- job.NewRecord();
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 { Success: true, Message: 'Schedule created', ScheduledJobID: job.ID };
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
- RSUStep: rsuStatus.LastRunResult ?? 'running',
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
- job.NewRecord();
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
- // Step 1: Get existing entity maps for this CompanyIntegration
5506
- const entityMapsResult = await rv.RunView<MJCompanyIntegrationEntityMapEntity>({
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: 'simple',
5510
- Fields: ['ID', 'ExternalObjectName', 'EntityID']
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
- if (!entityMapsResult.Success || entityMapsResult.Results.length === 0) {
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: false, Message: 'No entity maps found — nothing to evolve',
5516
- HasChanges: false,
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
- const sourceObjectNames = entityMapsResult.Results.map(em => em.ExternalObjectName);
5521
- const objects = this.buildObjectInputsFromNames(sourceObjectNames, schemaName);
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
- // Step 3: Introspect current schema from connector
5547
- const introspect = connector.IntrospectSchema.bind(connector) as
5548
- (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
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 = sourceObjectNames.map(n => nameMap.get(n.toLowerCase()) ?? n);
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
- // Step 4: Build target configs and SchemaBuilder input with ExistingTables
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
- // Step 5: Build schema SchemaBuilder handles evolution (ALTER TABLE) internally
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
- // Step 6: Check if any migration SQL was generated
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
- // Step 8: Run RSU pipeline
5622
- const rsuInput = builder.BuildRSUInput(schemaOutput, schemaInput, {
5623
- SkipGitCommit: skipGitCommit,
5624
- SkipRestart: skipRestart,
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
- const rsm = RuntimeSchemaManager.Instance;
5628
- const batchResult = await rsm.RunPipelineBatch([rsuInput]);
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
- const pipelineResult = batchResult.Results[0];
5631
- const pipelineSteps = pipelineResult?.Steps.map((s: RSUPipelineStep) => ({
5632
- Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
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: pipelineResult?.Success ?? false,
5637
- Message: pipelineResult?.Success
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: pipelineResult?.GitCommitSuccess,
5645
- APIRestarted: pipelineResult?.APIRestarted,
5646
- Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
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 ──────────────────────────────────────────────────