@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
@@ -14,6 +14,7 @@ var IntegrationDiscoveryResolver_1;
14
14
  import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType, Int, Float } from "type-graphql";
15
15
  import { CompositeKey, LocalCacheManager, Metadata, RunView, LogError, LogStatus } from "@memberjunction/core";
16
16
  import { GetReadOnlyProvider, GetReadWriteProvider } from "../util.js";
17
+ import { UUIDsEqual } from "@memberjunction/global";
17
18
  import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
18
19
  import { ConnectorFactory, IntegrationEngine, IntegrationSchemaSync, decideSchemaLimitViolations, IntegrationConnectorCreationPipeline, IntegrationActionGenerator } from "@memberjunction/integration-engine";
19
20
  import { IntegrationEngineBase } from "@memberjunction/integration-engine-base";
@@ -22,6 +23,7 @@ import { RuntimeSchemaManager } from "@memberjunction/schema-engine";
22
23
  import { IntegrationProgressReader } from "@memberjunction/integration-progress-artifacts";
23
24
  import { ResolverBase } from "../generic/ResolverBase.js";
24
25
  import { IntegrationCustomColumnPromoter } from "../integration/CustomColumnPromoter.js";
26
+ import { ComputeCascadeRemovalSet, ComputeRemovedDependencyWarnings, DisableUnselectedEntityMaps, ReenableFieldMapsForEntityMap, ResetPullWatermarks, SetEntityMapEnabled } from "../integration/EntityMapLifecycle.js";
25
27
  import { RequireSystemUser } from "../directives/RequireSystemUser.js";
26
28
  import { UserCache } from "@memberjunction/sqlserver-dataprovider";
27
29
  // ─── RSU Pipeline Output Types ──────────────────────────────────────────────
@@ -43,6 +45,14 @@ __decorate([
43
45
  Field(),
44
46
  __metadata("design:type", String)
45
47
  ], RSUStepOutput.prototype, "Message", void 0);
48
+ __decorate([
49
+ Field(() => Int, { nullable: true }),
50
+ __metadata("design:type", Number)
51
+ ], RSUStepOutput.prototype, "StepIndex", void 0);
52
+ __decorate([
53
+ Field(() => Int, { nullable: true }),
54
+ __metadata("design:type", Number)
55
+ ], RSUStepOutput.prototype, "StepTotal", void 0);
46
56
  RSUStepOutput = __decorate([
47
57
  ObjectType()
48
58
  ], RSUStepOutput);
@@ -137,7 +147,7 @@ __decorate([
137
147
  __metadata("design:type", Boolean)
138
148
  ], ApplyAllInput.prototype, "StartSync", void 0);
139
149
  __decorate([
140
- Field(() => Boolean, { nullable: true, defaultValue: false, description: 'If true, ignores watermarks and does a full re-fetch' }),
150
+ 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.' }),
141
151
  __metadata("design:type", Boolean)
142
152
  ], ApplyAllInput.prototype, "FullSync", void 0);
143
153
  __decorate([
@@ -148,6 +158,10 @@ __decorate([
148
158
  Field({ nullable: true, defaultValue: 'Pull', description: 'SyncDirection applied to all created entity maps: Pull | Push | Bidirectional. Defaults to Pull.' }),
149
159
  __metadata("design:type", String)
150
160
  ], ApplyAllInput.prototype, "DefaultSyncDirection", void 0);
161
+ __decorate([
162
+ 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).' }),
163
+ __metadata("design:type", String)
164
+ ], ApplyAllInput.prototype, "UnselectedAction", void 0);
151
165
  ApplyAllInput = __decorate([
152
166
  InputType()
153
167
  ], ApplyAllInput);
@@ -315,6 +329,10 @@ __decorate([
315
329
  Field({ nullable: true, description: 'Override sync direction stored in the created schedule: Pull | Push | Bidirectional.' }),
316
330
  __metadata("design:type", String)
317
331
  ], ApplyAllBatchInput.prototype, "ScheduleSyncDirection", void 0);
332
+ __decorate([
333
+ 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.' }),
334
+ __metadata("design:type", String)
335
+ ], ApplyAllBatchInput.prototype, "UnselectedAction", void 0);
318
336
  ApplyAllBatchInput = __decorate([
319
337
  InputType()
320
338
  ], ApplyAllBatchInput);
@@ -461,6 +479,26 @@ __decorate([
461
479
  Field(() => [String], { nullable: true }),
462
480
  __metadata("design:type", Array)
463
481
  ], SchemaEvolutionOutput.prototype, "Warnings", void 0);
482
+ __decorate([
483
+ Field(() => [String], { nullable: true }),
484
+ __metadata("design:type", Array)
485
+ ], SchemaEvolutionOutput.prototype, "NewObjects", void 0);
486
+ __decorate([
487
+ Field(() => [String], { nullable: true }),
488
+ __metadata("design:type", Array)
489
+ ], SchemaEvolutionOutput.prototype, "RemovedObjects", void 0);
490
+ __decorate([
491
+ Field(() => [String], { nullable: true }),
492
+ __metadata("design:type", Array)
493
+ ], SchemaEvolutionOutput.prototype, "CascadeRemovedObjects", void 0);
494
+ __decorate([
495
+ Field(() => [String], { nullable: true }),
496
+ __metadata("design:type", Array)
497
+ ], SchemaEvolutionOutput.prototype, "ChangedObjects", void 0);
498
+ __decorate([
499
+ Field(() => [String], { nullable: true }),
500
+ __metadata("design:type", Array)
501
+ ], SchemaEvolutionOutput.prototype, "WatermarksReset", void 0);
464
502
  SchemaEvolutionOutput = __decorate([
465
503
  ObjectType()
466
504
  ], SchemaEvolutionOutput);
@@ -1812,6 +1850,14 @@ __decorate([
1812
1850
  Field({ nullable: true }),
1813
1851
  __metadata("design:type", Boolean)
1814
1852
  ], OperationProgressOutput.prototype, "RSURunning", void 0);
1853
+ __decorate([
1854
+ Field(() => Int, { nullable: true }),
1855
+ __metadata("design:type", Number)
1856
+ ], OperationProgressOutput.prototype, "RSUStepIndex", void 0);
1857
+ __decorate([
1858
+ Field(() => Int, { nullable: true }),
1859
+ __metadata("design:type", Number)
1860
+ ], OperationProgressOutput.prototype, "RSUStepTotal", void 0);
1815
1861
  __decorate([
1816
1862
  Field({ nullable: true }),
1817
1863
  __metadata("design:type", Number)
@@ -2148,6 +2194,10 @@ __decorate([
2148
2194
  Field(),
2149
2195
  __metadata("design:type", Boolean)
2150
2196
  ], ListSourceObjectsItem.prototype, "IsCustom", void 0);
2197
+ __decorate([
2198
+ Field(() => [String], { nullable: true }),
2199
+ __metadata("design:type", Array)
2200
+ ], ListSourceObjectsItem.prototype, "DependsOn", void 0);
2151
2201
  ListSourceObjectsItem = __decorate([
2152
2202
  ObjectType()
2153
2203
  ], ListSourceObjectsItem);
@@ -2274,8 +2324,12 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
2274
2324
  SupportsIncrementalSync: true,
2275
2325
  SupportsWrite: true,
2276
2326
  }));
2327
+ // DAG exposure: per-object parent names from the same FK graph the sync
2328
+ // engine layers by, so a consumer can auto-check dependencies at selection time.
2329
+ const depsByName = this.computeObjectDependencyNames(companyIntegration.IntegrationID);
2277
2330
  const merged = sourceObjects.map(o => {
2278
2331
  const existing = existingByName.get(o.Name);
2332
+ const dependsOn = depsByName.get(o.Name.toLowerCase());
2279
2333
  return {
2280
2334
  Name: o.Name,
2281
2335
  Label: o.Label,
@@ -2285,6 +2339,7 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
2285
2339
  AlreadyPersisted: existing != null,
2286
2340
  IntegrationObjectID: existing?.ID,
2287
2341
  IsCustom: this.isCustomObjectName(o.Name, existing?.IsCustom),
2342
+ DependsOn: dependsOn && dependsOn.length > 0 ? dependsOn : undefined,
2288
2343
  };
2289
2344
  });
2290
2345
  merged.sort((a, b) => a.Name.localeCompare(b.Name));
@@ -2326,6 +2381,53 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
2326
2381
  return existingIsCustom;
2327
2382
  return name.endsWith('__c');
2328
2383
  }
2384
+ /**
2385
+ * DAG exposure: object name (lowercased) → sorted parent object names, derived from
2386
+ * the SAME dependency edges the sync engine layers by — hard FK pointers
2387
+ * (IntegrationObjectField.RelatedIntegrationObjectID) plus soft-FK declarations in the IO's
2388
+ * Configuration (parentObjectName / parentObjectNames map / ReferencedType). Reads the
2389
+ * engine cache only (no DB round-trip); objects with no persisted IO row have no entry.
2390
+ */
2391
+ computeObjectDependencyNames(integrationID) {
2392
+ const engine = IntegrationEngineBase.Instance;
2393
+ const ios = engine.GetIntegrationObjectsByIntegrationID(integrationID);
2394
+ const nameById = new Map(ios.map(io => [io.ID.toUpperCase(), io.Name]));
2395
+ const byLowerName = new Map(ios.map(io => [io.Name.toLowerCase(), io]));
2396
+ const deps = new Map();
2397
+ for (const io of ios) {
2398
+ const parents = new Set();
2399
+ for (const f of engine.GetIntegrationObjectFields(io.ID)) {
2400
+ const parentID = f.RelatedIntegrationObjectID?.toUpperCase();
2401
+ if (!parentID || UUIDsEqual(parentID, io.ID))
2402
+ continue;
2403
+ const parentName = nameById.get(parentID);
2404
+ if (parentName)
2405
+ parents.add(parentName);
2406
+ }
2407
+ const cfgRaw = io.Configuration;
2408
+ if (cfgRaw) {
2409
+ try {
2410
+ const cfg = JSON.parse(cfgRaw);
2411
+ const candidates = [];
2412
+ if (cfg.parentObjectName)
2413
+ candidates.push(cfg.parentObjectName);
2414
+ if (cfg.ReferencedType)
2415
+ candidates.push(cfg.ReferencedType);
2416
+ if (cfg.parentObjectNames && typeof cfg.parentObjectNames === 'object' && !Array.isArray(cfg.parentObjectNames)) {
2417
+ candidates.push(...Object.values(cfg.parentObjectNames).filter((v) => typeof v === 'string'));
2418
+ }
2419
+ for (const c of candidates) {
2420
+ const match = byLowerName.get(c.toLowerCase());
2421
+ if (match && !UUIDsEqual(match.ID, io.ID))
2422
+ parents.add(match.Name);
2423
+ }
2424
+ }
2425
+ catch { /* non-JSON Configuration → no soft-FK parents */ }
2426
+ }
2427
+ deps.set(io.Name.toLowerCase(), [...parents].sort());
2428
+ }
2429
+ return deps;
2430
+ }
2329
2431
  /**
2330
2432
  * Discovers fields on a specific external object.
2331
2433
  */
@@ -2396,6 +2498,13 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
2396
2498
  * `<cwd>/logs/integration-runs/<runID>/progress.jsonl` artifact.
2397
2499
  */
2398
2500
  async IntegrationRefreshConnectorSchema(companyIntegrationID, universalPKConvention, deactivateAbsent, ctx) {
2501
+ // sync lock: a metadata refresh rewrites the IO/IOF rows a sync reads.
2502
+ if (!IntegrationEngine.AcquireMaintenanceLock(companyIntegrationID, 'metadata refresh')) {
2503
+ return {
2504
+ Success: false, RunID: 'not-started',
2505
+ Message: 'Refresh not started: a sync or another maintenance operation is currently running for this connection. Retry after it completes.',
2506
+ };
2507
+ }
2399
2508
  try {
2400
2509
  const user = this.getAuthenticatedUser(ctx);
2401
2510
  const provider = GetReadWriteProvider(ctx.providers);
@@ -2456,6 +2565,9 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
2456
2565
  RunID: 'error',
2457
2566
  };
2458
2567
  }
2568
+ finally {
2569
+ IntegrationEngine.ReleaseMaintenanceLock(companyIntegrationID);
2570
+ }
2459
2571
  }
2460
2572
  /**
2461
2573
  * Generates + persists strongly-typed Action metadata on demand for an
@@ -4035,7 +4147,7 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4035
4147
  Message: `Batch complete: ${batchResult.SuccessCount} succeeded, ${batchResult.FailureCount} failed`,
4036
4148
  Items: itemResults,
4037
4149
  Steps: batchResult.Results[0]?.Steps.map((s) => ({
4038
- Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
4150
+ Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
4039
4151
  })),
4040
4152
  GitCommitSuccess: batchResult.Results[0]?.GitCommitSuccess,
4041
4153
  APIRestarted: batchResult.Results[0]?.APIRestarted,
@@ -4164,6 +4276,19 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4164
4276
  for (let i = 0; i < resolvedNames.length; i++) {
4165
4277
  sourceObjectFields[resolvedNames[i]] = resolved.sourceObjects[i].Fields ?? null;
4166
4278
  }
4279
+ // the sync after a connector's FIRST apply defaults to FULL ("always full
4280
+ // generally as the default"); later applies default incremental. "First" = no entity
4281
+ // map existed for this connection before this apply. Explicit input always wins.
4282
+ const priorMapsResult = await new RunView().RunView({
4283
+ EntityName: 'MJ: Company Integration Entity Maps',
4284
+ ExtraFilter: `CompanyIntegrationID='${input.CompanyIntegrationID.replace(/'/g, "''")}'`,
4285
+ Fields: ['ID'],
4286
+ MaxRows: 1,
4287
+ ResultType: 'simple',
4288
+ BypassCache: true,
4289
+ }, user);
4290
+ const hadPriorMaps = priorMapsResult.Success && priorMapsResult.Results.length > 0;
4291
+ const effectiveFullSync = input.FullSync ?? !hadPriorMaps;
4167
4292
  const pendingPayload = {
4168
4293
  CompanyIntegrationID: input.CompanyIntegrationID,
4169
4294
  SourceObjectNames: resolvedNames,
@@ -4172,8 +4297,9 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4172
4297
  CronExpression: input.CronExpression,
4173
4298
  ScheduleTimezone: input.ScheduleTimezone,
4174
4299
  StartSync: input.StartSync,
4175
- FullSync: input.FullSync ?? false,
4300
+ FullSync: effectiveFullSync,
4176
4301
  SyncScope: input.SyncScope ?? 'created',
4302
+ UnselectedAction: (input.UnselectedAction ?? 'disable'),
4177
4303
  CreatedAt: new Date().toISOString(),
4178
4304
  };
4179
4305
  rsuInput.PostRestartFiles = [
@@ -4184,7 +4310,7 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4184
4310
  const batchResult = await rsm.RunPipelineBatch([rsuInput]);
4185
4311
  const migrationSucceeded = batchResult.SuccessCount > 0;
4186
4312
  const pipelineSteps = batchResult.Results[0]?.Steps.map((s) => ({
4187
- Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
4313
+ Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
4188
4314
  }));
4189
4315
  // If pipeline failed, clean up pending file and return error
4190
4316
  if (!migrationSucceeded) {
@@ -4205,6 +4331,15 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4205
4331
  if (skipRestart) {
4206
4332
  await provider.Refresh();
4207
4333
  const entityMapsCreated = await this.createEntityAndFieldMaps(input.CompanyIntegrationID, objects, connector, companyIntegration, schemaName, user, provider, input.DefaultSyncDirection ?? 'Pull');
4334
+ // Remove-as-disable: objects absent from THIS selection get their entity
4335
+ // map + field maps disabled (data kept; re-selection re-enables). 'ignore' opts out
4336
+ // for additive/subset applies (e.g. test-harness per-scope runs).
4337
+ if ((input.UnselectedAction ?? 'disable') !== 'ignore') {
4338
+ const disabledObjects = await DisableUnselectedEntityMaps(input.CompanyIntegrationID, resolvedNames, user, provider);
4339
+ if (disabledObjects.length > 0) {
4340
+ console.log(`[IntegrationApplyAll] Disabled ${disabledObjects.length} unselected entity map(s): ${disabledObjects.join(', ')}`);
4341
+ }
4342
+ }
4208
4343
  const createdMapIDs = entityMapsCreated.map(em => em.EntityMapID).filter(Boolean);
4209
4344
  const scopedMapIDs = input.SyncScope === 'all' ? undefined : createdMapIDs;
4210
4345
  // Skip sync when SyncScope='created' but 0 new maps were
@@ -4214,7 +4349,7 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4214
4349
  const shouldStartSync = input.StartSync !== false &&
4215
4350
  (input.SyncScope === 'all' || createdMapIDs.length > 0);
4216
4351
  const syncRunID = shouldStartSync
4217
- ? await this.startSyncAfterApply(input.CompanyIntegrationID, user, scopedMapIDs, input.FullSync)
4352
+ ? await this.startSyncAfterApply(input.CompanyIntegrationID, user, scopedMapIDs, effectiveFullSync)
4218
4353
  : null;
4219
4354
  // Create schedule if requested
4220
4355
  let scheduledJobID;
@@ -4340,6 +4475,7 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4340
4475
  existing = existingMapResult.Results[0];
4341
4476
  }
4342
4477
  let em;
4478
+ const wasDisabled = existing ? existing.Status !== 'Active' || existing.SyncEnabled === false : false;
4343
4479
  if (existing) {
4344
4480
  em = existing; // reuse — keeps the map stable across re-applies
4345
4481
  }
@@ -4358,6 +4494,12 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4358
4494
  LogError(`IntegrationApplyAll: failed to save entity map for ${obj.SourceObjectName}`);
4359
4495
  return null;
4360
4496
  }
4497
+ // Re-add re-enables: a previously-removed (disabled) object that is re-selected
4498
+ // comes back WITH its field maps — the create-field-maps idempotency below skips existing
4499
+ // rows, so without this they would stay Disabled forever.
4500
+ if (wasDisabled) {
4501
+ await ReenableFieldMapsForEntityMap(em.ID, user, md);
4502
+ }
4361
4503
  // Discover fields from the source and create 1:1 field maps
4362
4504
  const fieldMapCount = await this.createFieldMapsForEntityMap(em.ID, obj.SourceObjectName, connector, companyIntegration, user, md);
4363
4505
  return {
@@ -4393,7 +4535,7 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4393
4535
  fm.EntityMapID = entityMapID;
4394
4536
  fm.SourceFieldName = field.Name;
4395
4537
  fm.DestinationFieldName = field.Name.replace(/[^A-Za-z0-9_]/g, '_');
4396
- fm.IsKeyField = field.IsUniqueKey;
4538
+ fm.IsKeyField = field.IsPrimaryKey ?? false; // key on the record's PRIMARY key (identity), consistent with processRSUPendingWork + U1 (PK ≠ unique)
4397
4539
  fm.IsRequired = field.IsRequired;
4398
4540
  fm.Direction = 'SourceToDest';
4399
4541
  fm.Status = 'Active';
@@ -4600,6 +4742,13 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4600
4742
  if (await ciCheck.InnerLoad(CompositeKey.FromID(companyIntegrationID)) && ciCheck.IsActive === false) {
4601
4743
  return { Success: false, Message: 'Connector is deactivated (IsActive=false); sync not started' };
4602
4744
  }
4745
+ // sync lock: refuse while a metadata refresh / schema evolution / RSU pipeline
4746
+ // holds the maintenance lock for this connection (the engine also refuses — this gives
4747
+ // the client an immediate, unambiguous answer).
4748
+ const maintenance = IntegrationEngine.GetMaintenanceLock(companyIntegrationID);
4749
+ if (maintenance) {
4750
+ return { Success: false, Message: `Sync not started: ${maintenance.Reason} is in progress for this connection (since ${maintenance.AcquiredAt.toISOString()}). Retry after it completes.` };
4751
+ }
4603
4752
  const syncOptions = {};
4604
4753
  if (fullSync)
4605
4754
  syncOptions.FullSync = true;
@@ -4648,10 +4797,21 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4648
4797
  // null RunID: an optimistic, untrackable result that read as "started" when nothing
4649
4798
  // could be followed. Poll briefly for ANY run for this connector stamped at/after the
4650
4799
  // fire instant; that catches both the still-running and the already-finished cases.
4800
+ //
4801
+ // The row itself isn't created until executeSyncInternal reaches CreateRunRecord,
4802
+ // which runs AFTER LoadRunConfiguration (loads every entity map + field map + builds
4803
+ // the connector instance) — for a large connector (HubSpot, 168 entity maps) that
4804
+ // synchronous setup alone can take several seconds, so a short fixed poll window can
4805
+ // elapse before the row exists at all, not because the sync failed. Live-verified: a
4806
+ // HubSpot StartSync call reported "no run created" while the run (confirmed present in
4807
+ // the DB moments later) was genuinely syncing. Keep the fast path fast (first 2s still
4808
+ // polled every 200ms) but extend the tail so large connectors get a fair window before
4809
+ // we honestly report failure.
4651
4810
  const firedAtFilter = firedAt.toISOString();
4652
4811
  let run = null;
4653
- for (let attempt = 0; attempt < 15 && !run; attempt++) {
4654
- await new Promise(resolve => setTimeout(resolve, 200));
4812
+ const pollIntervalsMs = [...Array(10).fill(200), ...Array(20).fill(1000)];
4813
+ for (let attempt = 0; attempt < pollIntervalsMs.length && !run; attempt++) {
4814
+ await new Promise(resolve => setTimeout(resolve, pollIntervalsMs[attempt]));
4655
4815
  const rv = new RunView();
4656
4816
  const runResult = await rv.RunView({
4657
4817
  EntityName: 'MJ: Company Integration Runs',
@@ -4797,8 +4957,20 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4797
4957
  return { Success: false, Message: `Scheduled job type not found for driver '${driverClass}' (kind='${jobKind}')` };
4798
4958
  }
4799
4959
  const jobTypeID = jobTypeResult.Results[0].ID;
4960
+ // U7 — SERVER-SIDE uniqueness per (CompanyIntegration, job kind/driver): a client
4961
+ // name-regex is not a guarantee, and duplicate jobs mean duplicate sync/rediscovery
4962
+ // runs. When an active/paused job of this kind already targets this connection,
4963
+ // UPDATE it in place instead of creating a twin.
4800
4964
  const job = await md.GetEntityObject('MJ: Scheduled Jobs', user);
4801
- job.NewRecord();
4965
+ const existingJob = await this.findExistingScheduledJob(jobTypeID, input.CompanyIntegrationID, user);
4966
+ let updatedInPlace = false;
4967
+ if (existingJob) {
4968
+ const loaded = await job.InnerLoad(CompositeKey.FromID(existingJob.ID));
4969
+ if (loaded)
4970
+ updatedInPlace = true;
4971
+ }
4972
+ if (!updatedInPlace)
4973
+ job.NewRecord();
4802
4974
  job.JobTypeID = jobTypeID;
4803
4975
  job.Name = input.Name;
4804
4976
  if (input.Description)
@@ -4833,13 +5005,44 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
4833
5005
  ci.CronExpression = input.CronExpression;
4834
5006
  await ci.Save();
4835
5007
  }
4836
- return { Success: true, Message: 'Schedule created', ScheduledJobID: job.ID };
5008
+ return {
5009
+ Success: true,
5010
+ Message: updatedInPlace
5011
+ ? 'Schedule updated — an active schedule of this kind already existed for this connection (server-side uniqueness)'
5012
+ : 'Schedule created',
5013
+ ScheduledJobID: job.ID,
5014
+ };
4837
5015
  }
4838
5016
  catch (e) {
4839
5017
  LogError(`IntegrationCreateSchedule error: ${e}`);
4840
5018
  return { Success: false, Message: this.formatError(e) };
4841
5019
  }
4842
5020
  }
5021
+ /**
5022
+ * U7 — server-side schedule uniqueness: finds an existing (non-deleted) scheduled job of the
5023
+ * given job type that targets the given CompanyIntegration (its Configuration JSON carries
5024
+ * CompanyIntegrationID). Matched case-insensitively across UUID casings (SQL Server upper /
5025
+ * PostgreSQL lower). Returns the newest match or null.
5026
+ */
5027
+ async findExistingScheduledJob(jobTypeID, companyIntegrationID, user) {
5028
+ const esc = (s) => s.replace(/'/g, "''");
5029
+ const lower = esc(companyIntegrationID.toLowerCase());
5030
+ const upper = esc(companyIntegrationID.toUpperCase());
5031
+ const result = await new RunView().RunView({
5032
+ EntityName: 'MJ: Scheduled Jobs',
5033
+ ExtraFilter: `JobTypeID='${esc(jobTypeID)}' AND Status IN ('Active','Paused') AND ` +
5034
+ // Structured "CompanyIntegrationID":"<uuid>" predicate (same form as IntegrationListSchedules),
5035
+ // NOT a bare-UUID substring — so this CI's id can't false-match the same UUID appearing in
5036
+ // another config key (e.g. RelatedCompanyIntegrationID) and repurpose an unrelated job.
5037
+ `(Configuration LIKE '%"CompanyIntegrationID":"${lower}"%' OR Configuration LIKE '%"CompanyIntegrationID":"${upper}"%')`,
5038
+ OrderBy: '__mj_CreatedAt DESC',
5039
+ Fields: ['ID'],
5040
+ MaxRows: 1,
5041
+ ResultType: 'simple',
5042
+ BypassCache: true, // uniqueness must see committed jobs, not a stale filtered cache
5043
+ }, user);
5044
+ return result.Success && result.Results.length > 0 ? result.Results[0] : null;
5045
+ }
4843
5046
  async IntegrationUpdateSchedule(scheduledJobID, cronExpression, timezone, name, ctx) {
4844
5047
  try {
4845
5048
  const user = this.getAuthenticatedUser(ctx);
@@ -5172,7 +5375,10 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
5172
5375
  OperationType: 'rsu',
5173
5376
  IsRunning: true,
5174
5377
  RSURunning: true,
5175
- RSUStep: rsuStatus.LastRunResult ?? 'running',
5378
+ // U11 — determinate "step N of M: <name>" instead of an indeterminate spinner
5379
+ RSUStep: rsuStatus.CurrentStepName ?? rsuStatus.LastRunResult ?? 'running',
5380
+ RSUStepIndex: rsuStatus.CurrentStepIndex ?? undefined,
5381
+ RSUStepTotal: rsuStatus.StepTotal ?? undefined,
5176
5382
  StartedAt: rsuStatus.LastRunAt?.toISOString(),
5177
5383
  ElapsedMs: rsuStatus.LastRunAt ? Date.now() - rsuStatus.LastRunAt.getTime() : undefined,
5178
5384
  };
@@ -5652,6 +5858,7 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
5652
5858
  SyncScope: input.SyncScope ?? 'created',
5653
5859
  SyncDirection: input.SyncDirection,
5654
5860
  ScheduleSyncDirection: input.ScheduleSyncDirection,
5861
+ UnselectedAction: (input.UnselectedAction ?? 'disable'),
5655
5862
  CreatedAt: new Date().toISOString(),
5656
5863
  };
5657
5864
  rsuInput.PostRestartFiles = [
@@ -5763,7 +5970,7 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
5763
5970
  connectorResults.push(connResult);
5764
5971
  }
5765
5972
  const pipelineSteps = batchResult.Results[0]?.Steps.map((s) => ({
5766
- Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
5973
+ Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
5767
5974
  }));
5768
5975
  const successCount = connectorResults.filter(r => r.Success).length;
5769
5976
  const failureCount = connectorResults.filter(r => !r.Success).length;
@@ -5804,8 +6011,13 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
5804
6011
  return null;
5805
6012
  }
5806
6013
  const jobTypeID = jobTypeResult.Results[0].ID;
6014
+ // U7 — same server-side uniqueness as IntegrationCreateSchedule: update the existing
6015
+ // sync job for this connection in place rather than creating a duplicate.
5807
6016
  const job = await md.GetEntityObject('MJ: Scheduled Jobs', user);
5808
- job.NewRecord();
6017
+ const existingJob = await this.findExistingScheduledJob(jobTypeID, companyIntegrationID, user);
6018
+ const loadedExisting = existingJob ? await job.InnerLoad(CompositeKey.FromID(existingJob.ID)) : false;
6019
+ if (!loadedExisting)
6020
+ job.NewRecord();
5809
6021
  job.JobTypeID = jobTypeID;
5810
6022
  job.Name = `${integrationName} Sync`;
5811
6023
  job.CronExpression = cronExpression;
@@ -6024,7 +6236,15 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
6024
6236
  * applies ALTER TABLE migrations via the RSU pipeline.
6025
6237
  * Compares the current connector introspection against existing MJ entities.
6026
6238
  */
6027
- async IntegrationSchemaEvolution(companyIntegrationID, platform, skipGitCommit, skipRestart, ctx) {
6239
+ async IntegrationSchemaEvolution(companyIntegrationID, platform, skipGitCommit, skipRestart, autoEnableNewObjects, deactivateAbsent, cascadeRemoveDependents, ctx) {
6240
+ // sync lock: schema evolution rewrites the metadata, field maps and DDL a sync
6241
+ // reads — data syncs (manual + scheduled) must not run while this is in flight.
6242
+ if (!IntegrationEngine.AcquireMaintenanceLock(companyIntegrationID, 'schema evolution')) {
6243
+ return {
6244
+ Success: false, HasChanges: false,
6245
+ Message: 'Schema evolution not started: a sync or another maintenance operation is currently running for this connection. Retry after it completes.',
6246
+ };
6247
+ }
6028
6248
  try {
6029
6249
  const user = this.getAuthenticatedUser(ctx);
6030
6250
  const validatedPlatform = this.validatePlatform(platform);
@@ -6032,22 +6252,89 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
6032
6252
  const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user, md);
6033
6253
  const schemaName = this.deriveSchemaName(companyIntegration.Integration);
6034
6254
  const rv = new RunView();
6035
- // Step 1: Get existing entity maps for this CompanyIntegration
6036
- const entityMapsResult = await rv.RunView({
6255
+ // ── Phase 1 — FULL RE-RESOLUTION (: "the exact same algorithm as before") ──
6256
+ // Re-run the creation pipeline: introspect (static + runtime discovery), persist with
6257
+ // the external-wins-when-present overlay, deactivate absent (gated on authoritative
6258
+ // discovery), PK-classify the residual. The diff below is computed against the
6259
+ // RESULT of this resolution, exactly as the first-time flow computed its state.
6260
+ const pipeline = new IntegrationConnectorCreationPipeline();
6261
+ const refresh = await pipeline.Run({
6262
+ Connector: connector,
6263
+ CompanyIntegration: companyIntegration,
6264
+ ContextUser: user,
6265
+ Provider: md,
6266
+ TriggerType: 'Manual',
6267
+ ConsoleMirror: true,
6268
+ DeactivateAbsent: deactivateAbsent ?? this.readConfigBool(companyIntegration.Configuration, 'deactivateAbsent') ?? true,
6269
+ });
6270
+ if (!refresh.Success) {
6271
+ return { Success: false, HasChanges: false, Message: `Schema evolution aborted — metadata re-resolution failed: ${refresh.FailureMessage ?? 'unknown error'}` };
6272
+ }
6273
+ await md.Refresh();
6274
+ await IntegrationEngine.Instance.Config(true, user, md);
6275
+ // ── Phase 2 — DIFF: resolved-Active objects vs prior entity maps ──
6276
+ const activeIOs = IntegrationEngineBase.Instance.GetActiveIntegrationObjects(companyIntegration.IntegrationID);
6277
+ const activeNamesLower = new Set(activeIOs.map(o => o.Name.toLowerCase()));
6278
+ const priorMapsResult = await rv.RunView({
6037
6279
  EntityName: 'MJ: Company Integration Entity Maps',
6038
6280
  ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
6039
- ResultType: 'simple',
6040
- Fields: ['ID', 'ExternalObjectName', 'EntityID']
6281
+ ResultType: 'entity_object',
6282
+ BypassCache: true, // the diff must read committed state
6041
6283
  }, user);
6042
- if (!entityMapsResult.Success || entityMapsResult.Results.length === 0) {
6284
+ const priorMaps = priorMapsResult.Success ? priorMapsResult.Results : [];
6285
+ const priorByLowerName = new Map(priorMaps.map(m => [(m.ExternalObjectName ?? '').toLowerCase(), m]));
6286
+ const newObjects = activeIOs.filter(io => !priorByLowerName.has(io.Name.toLowerCase())).map(io => io.Name);
6287
+ const removedObjects = priorMaps
6288
+ .filter(m => m.ExternalObjectName && !activeNamesLower.has(m.ExternalObjectName.toLowerCase()))
6289
+ .map(m => m.ExternalObjectName);
6290
+ const continuingMaps = priorMaps.filter(m => m.ExternalObjectName && activeNamesLower.has(m.ExternalObjectName.toLowerCase()));
6291
+ // ── Phase 3 — REMOVED objects: disable EM + EFMs (data + tables KEPT) ──
6292
+ // Uses the same remove-as-disable helper as ApplyAll: everything not in the resolved
6293
+ // set is disabled; a later re-appearance re-enables (reactivate-on-rediscover).
6294
+ // DAG handling (rsuplan "adding and removing tables"): dependents of a removed object
6295
+ // are NOT auto-removed by default — each broken parent edge is surfaced as a warning so
6296
+ // a consumer can prompt the user. With cascadeRemoveDependents=true (the "force remove
6297
+ // those too" affordance), the TRANSITIVE closure of still-active dependents is disabled
6298
+ // alongside the removed objects (still remove-as-disable, reversible). Edges come from
6299
+ // the same dependency map the sync engine layers by.
6300
+ const depMap = removedObjects.length > 0
6301
+ ? this.computeObjectDependencyNames(companyIntegration.IntegrationID)
6302
+ : new Map();
6303
+ const cascadeRemoved = cascadeRemoveDependents && removedObjects.length > 0
6304
+ ? ComputeCascadeRemovalSet(activeIOs.map(io => io.Name), removedObjects, depMap)
6305
+ : [];
6306
+ const cascadeLower = new Set(cascadeRemoved.map(n => n.toLowerCase()));
6307
+ const keepNames = activeIOs.map(io => io.Name).filter(n => !cascadeLower.has(n.toLowerCase()));
6308
+ const dagRemovalWarnings = removedObjects.length > 0 && !cascadeRemoveDependents
6309
+ ? ComputeRemovedDependencyWarnings(keepNames, removedObjects, depMap)
6310
+ : [];
6311
+ for (const w of dagRemovalWarnings)
6312
+ LogStatus(`[SchemaEvolution][DAG] ${w}`);
6313
+ if (cascadeRemoved.length > 0)
6314
+ LogStatus(`[SchemaEvolution][DAG] cascade-removing ${cascadeRemoved.length} dependent object(s): ${cascadeRemoved.join(', ')}`);
6315
+ if (removedObjects.length > 0 || cascadeRemoved.length > 0) {
6316
+ await DisableUnselectedEntityMaps(companyIntegrationID, keepNames, user, md);
6317
+ }
6318
+ // ── Phase 4 — schema build over continuing + new objects (minus cascade-removed) ──
6319
+ const evolveNames = [
6320
+ ...continuingMaps.map(m => m.ExternalObjectName).filter(n => !cascadeLower.has(n.toLowerCase())),
6321
+ ...newObjects,
6322
+ ];
6323
+ if (evolveNames.length === 0) {
6324
+ const removedTotal = removedObjects.length + cascadeRemoved.length;
6043
6325
  return {
6044
- Success: false, Message: 'No entity maps found — nothing to evolve',
6045
- HasChanges: false,
6326
+ Success: true, HasChanges: removedTotal > 0,
6327
+ Message: removedTotal > 0
6328
+ ? `No objects to evolve — ${removedTotal} removed object(s) disabled${cascadeRemoved.length > 0 ? ` (${cascadeRemoved.length} via cascade)` : ''}`
6329
+ : 'No entity maps and no resolved objects — nothing to evolve',
6330
+ RemovedObjects: removedObjects.length > 0 ? removedObjects : undefined,
6331
+ CascadeRemovedObjects: cascadeRemoved.length > 0 ? cascadeRemoved : undefined,
6332
+ Warnings: dagRemovalWarnings.length > 0 ? dagRemovalWarnings : undefined,
6046
6333
  };
6047
6334
  }
6048
- const sourceObjectNames = entityMapsResult.Results.map(em => em.ExternalObjectName);
6049
- const objects = this.buildObjectInputsFromNames(sourceObjectNames, schemaName);
6050
- // Step 2: Build ExistingTables from Metadata.Entities matching the schema
6335
+ const objects = this.buildObjectInputsFromNames(evolveNames, schemaName);
6336
+ // Build ExistingTables from Metadata.Entities matching the schema (continuing objects
6337
+ // have a table; new objects don't SchemaBuilder CREATEs those, ALTERs the rest)
6051
6338
  const existingTables = [];
6052
6339
  for (const obj of objects) {
6053
6340
  const entityInfo = md.Entities.find(e => e.SchemaName.toLowerCase() === schemaName.toLowerCase()
@@ -6067,12 +6354,12 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
6067
6354
  });
6068
6355
  }
6069
6356
  }
6070
- // Step 3: Introspect current schema from connector
6071
- const introspect = connector.IntrospectSchema.bind(connector);
6072
- const sourceSchema = await introspect(companyIntegration, user);
6357
+ // Source of truth for the evolution = the PERSISTED post-resolution rows (the refresh in
6358
+ // Phase 1 just wrote them via the overlay) — no duplicate vendor introspect.
6359
+ const sourceSchema = this.buildSourceSchemaFromPersistedRows(companyIntegration.IntegrationID);
6073
6360
  // Normalize names to match source schema casing
6074
6361
  const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
6075
- const normalizedNames = sourceObjectNames.map(n => nameMap.get(n.toLowerCase()) ?? n);
6362
+ const normalizedNames = evolveNames.map(n => nameMap.get(n.toLowerCase()) ?? n);
6076
6363
  // Also normalize the objects array SourceObjectName
6077
6364
  for (const obj of objects) {
6078
6365
  const exact = nameMap.get(obj.SourceObjectName.toLowerCase());
@@ -6083,7 +6370,7 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
6083
6370
  const filteredSchema = {
6084
6371
  Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
6085
6372
  };
6086
- // Step 4: Build target configs and SchemaBuilder input with ExistingTables
6373
+ // Build target configs and SchemaBuilder input with ExistingTables
6087
6374
  const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, validatedPlatform, connector);
6088
6375
  const schemaInput = {
6089
6376
  SourceSchema: filteredSchema,
@@ -6097,7 +6384,7 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
6097
6384
  ExistingTables: existingTables,
6098
6385
  EntitySettingsForTargets: {}
6099
6386
  };
6100
- // Step 5: Build schema SchemaBuilder handles evolution (ALTER TABLE) internally
6387
+ // ── Phase 5 build schema (SchemaBuilder CREATEs new tables, ALTERs changed ones) ──
6101
6388
  const builder = new SchemaBuilder();
6102
6389
  const schemaOutput = builder.BuildSchema(schemaInput);
6103
6390
  if (schemaOutput.Errors.length > 0) {
@@ -6108,18 +6395,10 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
6108
6395
  Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
6109
6396
  };
6110
6397
  }
6111
- // Step 6: Check if any migration SQL was generated
6112
- if (schemaOutput.MigrationFiles.length === 0) {
6113
- return {
6114
- Success: true,
6115
- Message: 'No schema changes detected',
6116
- HasChanges: false,
6117
- Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
6118
- };
6119
- }
6120
- // Step 7: Count added/modified columns by re-diffing
6398
+ // Per-object DDL diff for CHANGED detection (drives U10 watermark resets + reporting)
6121
6399
  let addedColumns = 0;
6122
6400
  let modifiedColumns = 0;
6401
+ const changedObjects = [];
6123
6402
  const evolution = new SchemaEvolution();
6124
6403
  for (const existing of existingTables) {
6125
6404
  const config = targetConfigs.find(c => c.SchemaName.toLowerCase() === existing.SchemaName.toLowerCase()
@@ -6132,36 +6411,216 @@ let IntegrationDiscoveryResolver = class IntegrationDiscoveryResolver extends Re
6132
6411
  const diff = evolution.DiffSchema(sourceObj, config, existing, validatedPlatform);
6133
6412
  addedColumns += diff.AddedColumns.length;
6134
6413
  modifiedColumns += diff.ModifiedColumns.length;
6414
+ if (diff.AddedColumns.length > 0 || diff.ModifiedColumns.length > 0) {
6415
+ changedObjects.push(config.SourceObjectName);
6416
+ }
6135
6417
  }
6136
- // Step 8: Run RSU pipeline
6137
- const rsuInput = builder.BuildRSUInput(schemaOutput, schemaInput, {
6138
- SkipGitCommit: skipGitCommit,
6139
- SkipRestart: skipRestart,
6140
- });
6141
- const rsm = RuntimeSchemaManager.Instance;
6142
- const batchResult = await rsm.RunPipelineBatch([rsuInput]);
6143
- const pipelineResult = batchResult.Results[0];
6144
- const pipelineSteps = pipelineResult?.Steps.map((s) => ({
6145
- Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message,
6146
- }));
6418
+ // ── Phase 6 reconcile CONTINUING maps' field maps to the resolution ──
6419
+ // Added source fields get field maps (Active on enabled maps, Disabled on disabled
6420
+ // maps); field maps whose source field vanished from the resolution are disabled.
6421
+ // Field maps carry no entity FK, so this is restart-safe done here, pre-RSU.
6422
+ const iosByLowerName = new Map(activeIOs.map(io => [io.Name.toLowerCase(), io]));
6423
+ for (const em of continuingMaps) {
6424
+ const io = iosByLowerName.get((em.ExternalObjectName ?? '').toLowerCase());
6425
+ if (!io)
6426
+ continue;
6427
+ const fmChanges = await this.reconcileFieldMapsForEntityMap(em, io.ID, user, md);
6428
+ if ((fmChanges.Added > 0 || fmChanges.Disabled > 0) && em.ExternalObjectName && !changedObjects.includes(em.ExternalObjectName)) {
6429
+ changedObjects.push(em.ExternalObjectName);
6430
+ }
6431
+ }
6432
+ // ── Phase 7 — U10: reset the Pull watermark of every CHANGED object so the next sync
6433
+ // full-fetches + backfills the schema change (content-hash bounds the rewrite cost).
6434
+ const changedLower = new Set(changedObjects.map(n => n.toLowerCase()));
6435
+ const changedMapIDs = continuingMaps
6436
+ .filter(m => m.ExternalObjectName && changedLower.has(m.ExternalObjectName.toLowerCase()))
6437
+ .map(m => m.ID);
6438
+ const resetMapIDs = await ResetPullWatermarks(changedMapIDs, user, md);
6439
+ const watermarksReset = continuingMaps
6440
+ .filter(m => resetMapIDs.some(id => UUIDsEqual(id, m.ID)))
6441
+ .map(m => m.ExternalObjectName);
6442
+ const hasDDLChanges = schemaOutput.MigrationFiles.length > 0;
6443
+ const hasChanges = hasDDLChanges || newObjects.length > 0 || removedObjects.length > 0 || changedObjects.length > 0;
6444
+ if (!hasChanges) {
6445
+ return {
6446
+ Success: true,
6447
+ Message: 'No schema changes detected',
6448
+ HasChanges: false,
6449
+ Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
6450
+ };
6451
+ }
6452
+ // ── Phase 8 — run RSU when DDL changed (migrate → codegen → commit → compile → restart) ──
6453
+ let pipelineSteps;
6454
+ let gitCommitSuccess;
6455
+ let apiRestarted;
6456
+ if (hasDDLChanges) {
6457
+ const rsuInput = builder.BuildRSUInput(schemaOutput, schemaInput, {
6458
+ SkipGitCommit: skipGitCommit,
6459
+ SkipRestart: skipRestart,
6460
+ // Schema evolution runs a FULL re-resolution of this connector's schema, so its
6461
+ // soft-constraint payload is the complete current truth — authorize the writer to
6462
+ // prune additionalSchemaInfo entries for tables that vanished from the resolution
6463
+ // (rsuplan: "adds new ones, removes old ones that no longer exist"). Subset
6464
+ // ApplyAll builds deliberately do NOT set this.
6465
+ AdditionalSchemaInfoAuthoritative: true,
6466
+ });
6467
+ // NEW objects need entities (post-codegen/restart) before their entity maps can be
6468
+ // created — hand them to the post-restart consumer via the SAME pending-work channel
6469
+ // ApplyAll uses. default: created DISABLED (user enables after the refresh);
6470
+ // autoEnableNewObjects opts in. Removal was already handled in Phase 3 → 'ignore'.
6471
+ if (newObjects.length > 0 && !skipRestart) {
6472
+ const { join } = await import('node:path');
6473
+ const rsuWorkDir = process.env.RSU_WORK_DIR || process.cwd();
6474
+ const pendingFilePath = join(rsuWorkDir, '.rsu_pending', `${Date.now()}-evolution.json`);
6475
+ rsuInput.PostRestartFiles = [
6476
+ ...(rsuInput.PostRestartFiles ?? []),
6477
+ {
6478
+ Path: pendingFilePath,
6479
+ Content: JSON.stringify({
6480
+ CompanyIntegrationID: companyIntegrationID,
6481
+ SourceObjectNames: newObjects,
6482
+ SourceObjectFields: Object.fromEntries(newObjects.map(n => [n, null])),
6483
+ SchemaName: schemaName,
6484
+ StartSync: false,
6485
+ SyncScope: 'created',
6486
+ UnselectedAction: 'ignore',
6487
+ CreateDisabled: !autoEnableNewObjects,
6488
+ CreatedAt: new Date().toISOString(),
6489
+ }, null, 2),
6490
+ },
6491
+ ];
6492
+ }
6493
+ const rsm = RuntimeSchemaManager.Instance;
6494
+ const batchResult = await rsm.RunPipelineBatch([rsuInput]);
6495
+ const pipelineResult = batchResult.Results[0];
6496
+ pipelineSteps = pipelineResult?.Steps.map((s) => ({
6497
+ Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
6498
+ }));
6499
+ gitCommitSuccess = pipelineResult?.GitCommitSuccess;
6500
+ apiRestarted = pipelineResult?.APIRestarted;
6501
+ if (!pipelineResult?.Success) {
6502
+ return {
6503
+ Success: false,
6504
+ Message: `Pipeline failed: ${pipelineResult?.ErrorMessage ?? 'unknown error'}`,
6505
+ HasChanges: true,
6506
+ AddedColumns: addedColumns,
6507
+ ModifiedColumns: modifiedColumns,
6508
+ Steps: pipelineSteps,
6509
+ NewObjects: newObjects.length > 0 ? newObjects : undefined,
6510
+ RemovedObjects: removedObjects.length > 0 ? removedObjects : undefined,
6511
+ ChangedObjects: changedObjects.length > 0 ? changedObjects : undefined,
6512
+ Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
6513
+ };
6514
+ }
6515
+ // skipRestart=true: entities exist after provider.Refresh() — create the new
6516
+ // objects' entity maps inline, then apply the disabled-by-default rule.
6517
+ if (newObjects.length > 0 && skipRestart) {
6518
+ await md.Refresh();
6519
+ const newObjectInputs = objects.filter(o => newObjects.some(n => n.toLowerCase() === o.SourceObjectName.toLowerCase()));
6520
+ const created = await this.createEntityAndFieldMaps(companyIntegrationID, newObjectInputs, connector, companyIntegration, schemaName, user, md, 'Pull');
6521
+ if (!autoEnableNewObjects) {
6522
+ for (const c of created) {
6523
+ if (c.EntityMapID)
6524
+ await SetEntityMapEnabled(c.EntityMapID, false, user, md);
6525
+ }
6526
+ }
6527
+ }
6528
+ }
6529
+ const summary = [
6530
+ newObjects.length > 0 ? `${newObjects.length} new object(s) (${autoEnableNewObjects ? 'enabled' : 'created disabled'})` : null,
6531
+ removedObjects.length > 0 ? `${removedObjects.length} removed object(s) disabled` : null,
6532
+ changedObjects.length > 0 ? `${changedObjects.length} changed object(s), ${addedColumns} column(s) added, ${modifiedColumns} modified` : null,
6533
+ watermarksReset.length > 0 ? `${watermarksReset.length} watermark(s) reset for backfill` : null,
6534
+ ].filter(Boolean).join('; ');
6147
6535
  return {
6148
- Success: pipelineResult?.Success ?? false,
6149
- Message: pipelineResult?.Success
6150
- ? `Schema evolution applied — ${addedColumns} column(s) added, ${modifiedColumns} column(s) modified`
6151
- : `Pipeline failed: ${pipelineResult?.ErrorMessage ?? 'unknown error'}`,
6536
+ Success: true,
6537
+ Message: `Schema evolution applied — ${summary || 'no per-object deltas'}`,
6152
6538
  HasChanges: true,
6153
6539
  AddedColumns: addedColumns,
6154
6540
  ModifiedColumns: modifiedColumns,
6155
6541
  Steps: pipelineSteps,
6156
- GitCommitSuccess: pipelineResult?.GitCommitSuccess,
6157
- APIRestarted: pipelineResult?.APIRestarted,
6158
- Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
6542
+ GitCommitSuccess: gitCommitSuccess,
6543
+ APIRestarted: apiRestarted,
6544
+ NewObjects: newObjects.length > 0 ? newObjects : undefined,
6545
+ RemovedObjects: removedObjects.length > 0 ? removedObjects : undefined,
6546
+ CascadeRemovedObjects: cascadeRemoved.length > 0 ? cascadeRemoved : undefined,
6547
+ ChangedObjects: changedObjects.length > 0 ? changedObjects : undefined,
6548
+ WatermarksReset: watermarksReset.length > 0 ? watermarksReset : undefined,
6549
+ Warnings: (dagRemovalWarnings.length + schemaOutput.Warnings.length) > 0
6550
+ ? [...dagRemovalWarnings, ...schemaOutput.Warnings]
6551
+ : undefined,
6159
6552
  };
6160
6553
  }
6161
6554
  catch (e) {
6162
6555
  LogError(`IntegrationSchemaEvolution error: ${e}`);
6163
6556
  return { Success: false, Message: this.formatError(e), HasChanges: false };
6164
6557
  }
6558
+ finally {
6559
+ IntegrationEngine.ReleaseMaintenanceLock(companyIntegrationID);
6560
+ }
6561
+ }
6562
+ /**
6563
+ * Refresh diff (continuing objects): reconciles one entity map's field maps to the
6564
+ * post-resolution IOF set — source fields WITHOUT a field map get one created (Active when
6565
+ * the map is enabled, Disabled when it isn't), and field maps whose source field is no
6566
+ * longer Active in the resolution are DISABLED (never deleted — a later re-appearance
6567
+ * re-enables via the same reconciliation, since a disabled FM whose field returns flips
6568
+ * back with SetFieldMapsStatus on re-enable or is simply left disabled until then).
6569
+ */
6570
+ async reconcileFieldMapsForEntityMap(em, integrationObjectID, user, md) {
6571
+ const result = { Added: 0, Disabled: 0 };
6572
+ const activeFields = IntegrationEngineBase.Instance
6573
+ .GetIntegrationObjectFields(integrationObjectID)
6574
+ .filter(f => f.Status === 'Active');
6575
+ const activeFieldNames = new Set(activeFields.map(f => f.Name.toLowerCase()));
6576
+ const mapEnabled = em.Status === 'Active' && em.SyncEnabled === true;
6577
+ const fms = await new RunView().RunView({
6578
+ EntityName: 'MJ: Company Integration Field Maps',
6579
+ ExtraFilter: `EntityMapID='${em.ID}'`,
6580
+ ResultType: 'entity_object',
6581
+ BypassCache: true,
6582
+ }, user);
6583
+ const existingByLower = new Map((fms.Success ? fms.Results : []).map(fm => [(fm.SourceFieldName ?? '').toLowerCase(), fm]));
6584
+ // Added source fields → new field maps (state follows the map's enabled state)
6585
+ for (const field of activeFields) {
6586
+ const existing = existingByLower.get(field.Name.toLowerCase());
6587
+ if (existing) {
6588
+ // Field is back in the resolution — re-enable a previously-disabled map row.
6589
+ if (existing.Status !== 'Active' && mapEnabled) {
6590
+ existing.Status = 'Active';
6591
+ if (await existing.Save())
6592
+ result.Added++; // counted as a change for reporting
6593
+ }
6594
+ continue;
6595
+ }
6596
+ const fm = await md.GetEntityObject('MJ: Company Integration Field Maps', user);
6597
+ fm.NewRecord();
6598
+ fm.EntityMapID = em.ID;
6599
+ fm.SourceFieldName = field.Name;
6600
+ fm.DestinationFieldName = field.Name.replace(/[^A-Za-z0-9_]/g, '_');
6601
+ fm.IsKeyField = field.IsPrimaryKey ?? false; // key on the PRIMARY key (identity), consistent with the initial-apply path + U1 (PK ≠ unique)
6602
+ fm.IsRequired = field.IsRequired === true;
6603
+ fm.Direction = 'SourceToDest';
6604
+ fm.Status = mapEnabled ? 'Active' : 'Inactive';
6605
+ fm.Priority = 0;
6606
+ if (await fm.Save())
6607
+ result.Added++;
6608
+ else
6609
+ LogError(`[IntegrationSchemaEvolution] Failed to create field map '${field.Name}' on ${em.ExternalObjectName}: ${fm.LatestResult?.CompleteMessage ?? 'unknown error'}`);
6610
+ }
6611
+ // Vanished source fields → disable their field maps (data/columns kept)
6612
+ for (const [lowerName, fm] of existingByLower) {
6613
+ if (activeFieldNames.has(lowerName))
6614
+ continue;
6615
+ if (fm.Status === 'Inactive')
6616
+ continue;
6617
+ fm.Status = 'Inactive';
6618
+ if (await fm.Save())
6619
+ result.Disabled++;
6620
+ else
6621
+ LogError(`[IntegrationSchemaEvolution] Failed to disable field map '${fm.SourceFieldName}' on ${em.ExternalObjectName}: ${fm.LatestResult?.CompleteMessage ?? 'unknown error'}`);
6622
+ }
6623
+ return result;
6165
6624
  }
6166
6625
  // ── WEBHOOK HELPER ──────────────────────────────────────────────────
6167
6626
  async sendWebhook(url, payload) {
@@ -6611,9 +7070,12 @@ __decorate([
6611
7070
  __param(1, Arg("platform", { defaultValue: "sqlserver" })),
6612
7071
  __param(2, Arg("skipGitCommit", { defaultValue: false })),
6613
7072
  __param(3, Arg("skipRestart", { defaultValue: false })),
6614
- __param(4, Ctx()),
7073
+ __param(4, 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.' })),
7074
+ __param(5, 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).' })),
7075
+ __param(6, 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.' })),
7076
+ __param(7, Ctx()),
6615
7077
  __metadata("design:type", Function),
6616
- __metadata("design:paramtypes", [String, String, Boolean, Boolean, Object]),
7078
+ __metadata("design:paramtypes", [String, String, Boolean, Boolean, Boolean, Boolean, Boolean, Object]),
6617
7079
  __metadata("design:returntype", Promise)
6618
7080
  ], IntegrationDiscoveryResolver.prototype, "IntegrationSchemaEvolution", null);
6619
7081
  IntegrationDiscoveryResolver = IntegrationDiscoveryResolver_1 = __decorate([