@memberjunction/server 6.1.0-edge.2 → 6.1.0-edge.3

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 (162) hide show
  1. package/LICENSE +180 -4
  2. package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
  3. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
  4. package/dist/auth/AuthProviderCatalogRouter.js +69 -0
  5. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
  6. package/dist/auth/AuthProviderEngine.d.ts +96 -0
  7. package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
  8. package/dist/auth/AuthProviderEngine.js +245 -0
  9. package/dist/auth/AuthProviderEngine.js.map +1 -0
  10. package/dist/auth/index.d.ts +11 -0
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/index.js +56 -13
  13. package/dist/auth/index.js.map +1 -1
  14. package/dist/auth/initializeProviders.d.ts +64 -1
  15. package/dist/auth/initializeProviders.d.ts.map +1 -1
  16. package/dist/auth/initializeProviders.js +117 -5
  17. package/dist/auth/initializeProviders.js.map +1 -1
  18. package/dist/config.d.ts +89 -0
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +52 -35
  21. package/dist/config.js.map +1 -1
  22. package/dist/generated/generated.d.ts +501 -1597
  23. package/dist/generated/generated.d.ts.map +1 -1
  24. package/dist/generated/generated.js +2711 -17021
  25. package/dist/generated/generated.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +1 -1
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +7 -4
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/generic/RunViewResolver.d.ts +4 -0
  31. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  32. package/dist/generic/RunViewResolver.js +31 -0
  33. package/dist/generic/RunViewResolver.js.map +1 -1
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +99 -14
  37. package/dist/index.js.map +1 -1
  38. package/dist/integration/RSUProgressBridge.d.ts +83 -0
  39. package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
  40. package/dist/integration/RSUProgressBridge.js +230 -0
  41. package/dist/integration/RSUProgressBridge.js.map +1 -0
  42. package/dist/integration/SchemaRefreshLaunch.d.ts +52 -0
  43. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
  44. package/dist/integration/SchemaRefreshLaunch.js +50 -0
  45. package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
  46. package/dist/providerConfigUnits.d.ts +20 -0
  47. package/dist/providerConfigUnits.d.ts.map +1 -0
  48. package/dist/providerConfigUnits.js +22 -0
  49. package/dist/providerConfigUnits.js.map +1 -0
  50. package/dist/resolvers/FileResolver.d.ts +34 -4
  51. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  52. package/dist/resolvers/FileResolver.js +218 -28
  53. package/dist/resolvers/FileResolver.js.map +1 -1
  54. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +76 -3
  55. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  56. package/dist/resolvers/IntegrationDiscoveryResolver.js +299 -81
  57. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  58. package/dist/resolvers/QueryResolver.d.ts +5 -4
  59. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  60. package/dist/resolvers/QueryResolver.js +29 -14
  61. package/dist/resolvers/QueryResolver.js.map +1 -1
  62. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
  63. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  64. package/dist/resolvers/QuerySystemUserResolver.js +33 -14
  65. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  66. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
  67. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  68. package/dist/resolvers/RunAIAgentResolver.js +20 -7
  69. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  70. package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
  71. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  72. package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
  73. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  74. package/dist/resolvers/UserResolver.d.ts +19 -17
  75. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  76. package/dist/resolvers/UserResolver.js +66 -67
  77. package/dist/resolvers/UserResolver.js.map +1 -1
  78. package/dist/resolvers/currentUserRoles.d.ts +19 -0
  79. package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
  80. package/dist/resolvers/currentUserRoles.js +36 -0
  81. package/dist/resolvers/currentUserRoles.js.map +1 -0
  82. package/dist/rest/MediaAccessKeys.d.ts +17 -0
  83. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  84. package/dist/rest/MediaAccessKeys.js +42 -1
  85. package/dist/rest/MediaAccessKeys.js.map +1 -1
  86. package/dist/rest/MediaStreamHandler.d.ts +3 -2
  87. package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
  88. package/dist/rest/MediaStreamHandler.js +83 -2
  89. package/dist/rest/MediaStreamHandler.js.map +1 -1
  90. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  91. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  92. package/dist/rest/OAuthCallbackHandler.js +129 -43
  93. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  94. package/dist/rest/UploadTokenManager.d.ts +90 -0
  95. package/dist/rest/UploadTokenManager.d.ts.map +1 -0
  96. package/dist/rest/UploadTokenManager.js +156 -0
  97. package/dist/rest/UploadTokenManager.js.map +1 -0
  98. package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
  99. package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
  100. package/dist/services/IntegrationSyncWorkerService.js +132 -0
  101. package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
  102. package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
  103. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  104. package/dist/services/StartTaskGraphDispatcher.js +21 -1
  105. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  106. package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
  107. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  108. package/dist/services/TaskGraphAgentRunner.js +23 -0
  109. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  110. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  111. package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
  112. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  113. package/dist/types.d.ts +6 -0
  114. package/dist/types.d.ts.map +1 -1
  115. package/dist/types.js.map +1 -1
  116. package/package.json +91 -91
  117. package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
  118. package/src/__tests__/AuthProviderEngine.test.ts +278 -0
  119. package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
  120. package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
  121. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  122. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  123. package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
  124. package/src/__tests__/RSUProgressBridge.test.ts +250 -0
  125. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
  126. package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
  127. package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
  128. package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
  129. package/src/__tests__/config-units.test.ts +74 -0
  130. package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
  131. package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
  132. package/src/__tests__/newUsers.test.ts +726 -0
  133. package/src/__tests__/uploadTokenManager.test.ts +159 -0
  134. package/src/auth/AuthProviderCatalogRouter.ts +76 -0
  135. package/src/auth/AuthProviderEngine.ts +253 -0
  136. package/src/auth/index.ts +59 -15
  137. package/src/auth/initializeProviders.ts +128 -7
  138. package/src/config.ts +55 -36
  139. package/src/generated/generated.ts +1911 -10551
  140. package/src/generic/ResolverBase.ts +11 -4
  141. package/src/generic/RunViewResolver.ts +31 -0
  142. package/src/index.ts +108 -15
  143. package/src/integration/RSUProgressBridge.ts +248 -0
  144. package/src/integration/SchemaRefreshLaunch.ts +77 -0
  145. package/src/providerConfigUnits.ts +21 -0
  146. package/src/resolvers/FileResolver.ts +200 -40
  147. package/src/resolvers/IntegrationDiscoveryResolver.ts +316 -68
  148. package/src/resolvers/QueryResolver.ts +25 -11
  149. package/src/resolvers/QuerySystemUserResolver.ts +28 -13
  150. package/src/resolvers/RunAIAgentResolver.ts +21 -6
  151. package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
  152. package/src/resolvers/UserResolver.ts +99 -60
  153. package/src/resolvers/currentUserRoles.ts +47 -0
  154. package/src/rest/MediaAccessKeys.ts +47 -1
  155. package/src/rest/MediaStreamHandler.ts +98 -3
  156. package/src/rest/OAuthCallbackHandler.ts +149 -41
  157. package/src/rest/UploadTokenManager.ts +211 -0
  158. package/src/services/IntegrationSyncWorkerService.ts +141 -0
  159. package/src/services/StartTaskGraphDispatcher.ts +21 -1
  160. package/src/services/TaskGraphAgentRunner.ts +23 -0
  161. package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
  162. package/src/types.ts +6 -0
@@ -39,6 +39,7 @@ import type {
39
39
  GenerateIntegrationActionResult
40
40
  } from "@memberjunction/integration-engine";
41
41
  import { IntegrationEngineBase } from "@memberjunction/integration-engine-base";
42
+ import { buildIntegrationLLMPKCallback } from "@memberjunction/core-entities-server";
42
43
  import {
43
44
  SchemaBuilder,
44
45
  TypeMapper,
@@ -48,13 +49,17 @@ import {
48
49
  ExistingTableInfo,
49
50
  SchemaEvolution
50
51
  } from "@memberjunction/integration-schema-builder";
51
- import { RuntimeSchemaManager, type RSUPipelineStep, type RSUPipelineInput } from "@memberjunction/schema-engine";
52
+ import { RuntimeSchemaManager, type RSUPipelineStep, type RSUPipelineInput, type RSUPendingWork } from "@memberjunction/schema-engine";
52
53
  import type { SchemaBuilderOutput } from "@memberjunction/integration-schema-builder";
53
- import { IntegrationProgressReader } from "@memberjunction/integration-progress-artifacts";
54
+ import { IntegrationProgressEmitter, IntegrationProgressReader } from "@memberjunction/integration-progress-artifacts";
54
55
  import type { IntegrationRunSnapshot, IntegrationRunKind } from "@memberjunction/integration-progress-artifacts";
55
56
  import { ResolverBase } from "../generic/ResolverBase.js";
56
57
  import { IntegrationCustomColumnPromoter } from "../integration/CustomColumnPromoter.js";
57
58
  import { ComputeCascadeRemovalSet, ComputeRemovedDependencyWarnings, DisableUnselectedEntityMaps, ReenableFieldMapsForEntityMap, ResetPullWatermarks, SetEntityMapEnabled } from "../integration/EntityMapLifecycle.js";
59
+ import { BuildCreateConnectionMessage, BuildDetachedRefreshMessage, BuildUpdateConnectionMessage } from "../integration/SchemaRefreshLaunch.js";
60
+ // Type-only: the registered runtime class for 'MJ: Company Integrations'. Lets the create path name the
61
+ // server subclass it actually gets back from GetEntityObject with a real type rather than a cast.
62
+ import type { MJCompanyIntegrationEntityServer } from "@memberjunction/core-entities-server";
58
63
  import { AppContext } from "../types.js";
59
64
  import { RequireSystemUser } from "../directives/RequireSystemUser.js";
60
65
  import { UserCache } from "@memberjunction/generic-database-provider";
@@ -569,6 +574,21 @@ class CreateConnectionInput {
569
574
  @ObjectType()
570
575
  class CreateConnectionPipelineSummary {
571
576
  @Field() RunID: string;
577
+ /**
578
+ * True when the pipeline was launched detached (`awaitSchemaRefresh: false`) and is STILL RUNNING —
579
+ * every count below is a placeholder zero, not a result. Tail `RunID` via IntegrationTailRunEvents
580
+ * (or the IntegrationProgress subscription, kind='ConnectorCreation') for the real outcome.
581
+ */
582
+ @Field() InProgress: boolean;
583
+ /**
584
+ * Whether the refresh pipeline itself succeeded. A pipeline that fails at ConnectionTest still
585
+ * RETURNS (it does not throw), with every count at zero — so counts alone cannot distinguish
586
+ * "found nothing to change" from "never got past the credential check". Always false while
587
+ * `InProgress` is true: a detached run's outcome is not known yet.
588
+ */
589
+ @Field() Succeeded: boolean;
590
+ /** The pipeline's own failure reason when `Succeeded` is false and the run has finished. */
591
+ @Field({ nullable: true }) FailureMessage?: string;
572
592
  @Field() ObjectsCreated: number;
573
593
  @Field() ObjectsUpdated: number;
574
594
  @Field() FieldsCreated: number;
@@ -613,6 +633,7 @@ class IntegrationSyncConfigInput {
613
633
  @Field(() => Boolean, { nullable: true, description: '§4 cross-layer pipelining: a child map starts when ITS parents finish, not the whole layer.' }) CrossLayerPipeline?: boolean;
614
634
  @Field(() => Boolean, { nullable: true, description: 'Merkle/partition hash-diff reconcile for watermark-less change detection (buffers the set in RAM).' }) PartitionReconcile?: boolean;
615
635
  @Field(() => Int, { nullable: true, description: 'Time budget (ms) for stage-2 streaming field discovery before it stops and uses what it gathered.' }) DiscoveryTimeBudgetMs?: number;
636
+ @Field(() => Int, { nullable: true, description: 'Per-page FetchChanges timeout (ms). Raise it for a connector that fans out one request per parent record, whose page time scales with BatchSize. Wins over the connector\'s own FetchChangesTimeoutMs; unset = the connector\'s value, else the framework default (30000).' }) FetchTimeoutMs?: number;
616
637
  @Field(() => Int, { nullable: true, description: 'Batch size for stage-2 streaming field discovery (records per FetchChanges page). Default 500.' }) DiscoveryBatchSize?: number;
617
638
  @Field(() => Int, { nullable: true, description: 'Max records sampled in stage-2 streaming field discovery (a column corpus + PK guess; NOT a full scan). Default 500.' }) DiscoveryMaxRecords?: number;
618
639
  @Field(() => Boolean, { nullable: true, description: '§7 Comprehensive refresh deactivates Declared objects/fields ABSENT from an AUTHORITATIVE discovery (reversible). Default false.' }) DeactivateAbsent?: boolean;
@@ -631,6 +652,7 @@ class IntegrationSyncConfigOutput {
631
652
  @Field(() => Boolean, { nullable: true }) CrossLayerPipeline?: boolean;
632
653
  @Field(() => Boolean, { nullable: true }) PartitionReconcile?: boolean;
633
654
  @Field(() => Int, { nullable: true }) DiscoveryTimeBudgetMs?: number;
655
+ @Field(() => Int, { nullable: true }) FetchTimeoutMs?: number;
634
656
  @Field(() => Int, { nullable: true }) DiscoveryBatchSize?: number;
635
657
  @Field(() => Int, { nullable: true }) DiscoveryMaxRecords?: number;
636
658
  @Field(() => Boolean, { nullable: true }) DeactivateAbsent?: boolean;
@@ -946,7 +968,7 @@ class IntegrationRunEventsOutput {
946
968
  @Field() IsInFlight: boolean;
947
969
  }
948
970
 
949
- // Sync progress is now tracked inside IntegrationEngine itself via IntegrationEngine.GetSyncProgress()
971
+ // Sync progress is now tracked inside IntegrationEngine itself via IntegrationEngine.GetSyncProgressAsync()
950
972
 
951
973
  @ObjectType()
952
974
  class ConnectionSummaryOutput {
@@ -2386,9 +2408,42 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2386
2408
  user: UserInfo,
2387
2409
  provider: IMetadataProvider,
2388
2410
  universalPKConvention?: string,
2411
+ runID?: string,
2412
+ ): Promise<CreateConnectionPipelineSummary> {
2413
+ // Sync lock (#3656). This pipeline rewrites the very IO/IOF rows, field
2414
+ // maps and DDL a sync reads, so it must not overlap a sync or another
2415
+ // maintenance operation for the same connection. The two other
2416
+ // pipeline call sites — IntegrationRefreshConnectorSchema and
2417
+ // IntegrationSchemaEvolution — already take this lock inline; this is
2418
+ // the third and fourth (create/update/reactivate) finally taking it too.
2419
+ if (!IntegrationEngine.AcquireMaintenanceLock(companyIntegrationID, 'schema refresh')) {
2420
+ throw new Error(
2421
+ 'Schema refresh not started: a sync or another maintenance operation is currently running for this connection. Retry after it completes.'
2422
+ );
2423
+ }
2424
+ try {
2425
+ return await this.runSchemaRefreshPipelineLocked(companyIntegrationID, user, provider, universalPKConvention, runID);
2426
+ } finally {
2427
+ IntegrationEngine.ReleaseMaintenanceLock(companyIntegrationID);
2428
+ }
2429
+ }
2430
+
2431
+ /** The body of {@link runSchemaRefreshPipeline}, run with the maintenance lock held. */
2432
+ private async runSchemaRefreshPipelineLocked(
2433
+ companyIntegrationID: string,
2434
+ user: UserInfo,
2435
+ provider: IMetadataProvider,
2436
+ universalPKConvention?: string,
2437
+ runID?: string,
2389
2438
  ): Promise<CreateConnectionPipelineSummary> {
2390
2439
  const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user, provider);
2391
2440
  const pipeline = new IntegrationConnectorCreationPipeline();
2441
+ // SoftPKClassifier's LLM tier (universal → naming → statistical → LLM →
2442
+ // synthetic) only runs when this callback is supplied. It used to be
2443
+ // wired by the CompanyIntegration save hook; with that hook gone
2444
+ // (#3738) it is wired here, so the create/update/reactivate paths keep
2445
+ // the same PK-inference quality they had before.
2446
+ const llmInference = await buildIntegrationLLMPKCallback(user);
2392
2447
  const runOpts = {
2393
2448
  Connector: connector,
2394
2449
  CompanyIntegration: companyIntegration,
@@ -2397,6 +2452,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2397
2452
  UniversalPKConvention: universalPKConvention || undefined,
2398
2453
  ConsoleMirror: true,
2399
2454
  TriggerType: 'Manual' as const,
2455
+ LLMInference: llmInference ?? undefined,
2456
+ // Caller-supplied runID: lets the detached path hand a tailable ID back to the client
2457
+ // BEFORE the pipeline has done any work. Omitted ⇒ the pipeline generates its own.
2458
+ RunID: runID,
2400
2459
  };
2401
2460
  const result = await pipeline.Run(runOpts as unknown as Parameters<typeof pipeline.Run>[0]);
2402
2461
 
@@ -2407,6 +2466,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2407
2466
 
2408
2467
  return {
2409
2468
  RunID: result.RunID,
2469
+ InProgress: false,
2470
+ Succeeded: result.Success,
2471
+ FailureMessage: result.FailureMessage,
2410
2472
  ObjectsCreated: result.PersistResult?.ObjectsCreated ?? 0,
2411
2473
  ObjectsUpdated: result.PersistResult?.ObjectsUpdated ?? 0,
2412
2474
  FieldsCreated: result.PersistResult?.FieldsCreated ?? 0,
@@ -2423,6 +2485,94 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2423
2485
  };
2424
2486
  }
2425
2487
 
2488
+ /**
2489
+ * Launches the schema-refresh pipeline WITHOUT awaiting it and returns a summary carrying the
2490
+ * tailable `RunID` and `InProgress: true`.
2491
+ *
2492
+ * Why: the pipeline is a live vendor introspect — minutes on a large catalog (HubSpot: 130
2493
+ * objects). Awaiting it inline holds the create/update mutation open for that entire time, so the
2494
+ * connection wizard's Finish button sits on an indeterminate spinner with no way to tell progress
2495
+ * from a hang. The pipeline already writes a complete, durable, per-run event stream keyed by its
2496
+ * runID; the only thing missing was handing that ID to the caller BEFORE the work starts. Hence
2497
+ * the caller-supplied runID.
2498
+ *
2499
+ * Trade-off the caller opts into: the mutation no longer reports what the refresh found (counts
2500
+ * are placeholder zeros) and a refresh failure no longer surfaces in the mutation's Message — both
2501
+ * live on the run stream instead. That is why `awaitSchemaRefresh` defaults to true.
2502
+ *
2503
+ * ON CREATE this is the only refresh that runs. #3738 removed the activation hook that used to
2504
+ * fire the same pipeline inside `Save()`, awaited — which had made the create mutation pay a full
2505
+ * introspect before ever reaching this method, and had run it BEFORE the connection test, so a
2506
+ * rejected connection was rolled back with its discovered schema already written.
2507
+ *
2508
+ * Belt and braces: if the launch below IS coalesced onto some other run for this connection (a
2509
+ * concurrent refresh, a repeat click), the pipeline publishes a terminal alias run under `runID`
2510
+ * naming the run that served it — so the ID handed back here is tailable in every case.
2511
+ */
2512
+ private startSchemaRefreshPipelineDetached(
2513
+ companyIntegrationID: string,
2514
+ user: UserInfo,
2515
+ provider: IMetadataProvider,
2516
+ universalPKConvention?: string,
2517
+ ): CreateConnectionPipelineSummary {
2518
+ const runID = IntegrationProgressEmitter.newRunID('connector');
2519
+ // Deliberately not awaited. Once the pipeline is running it records every outcome on this
2520
+ // run's artifact stream under `runID` itself — but it can also throw BEFORE it ever
2521
+ // constructs its emitter (connector-driver resolution, a missing Integration row), and in
2522
+ // the blocking path that throw surfaced in the mutation's Message. Detached, the caller has
2523
+ // already been handed `runID` and has nothing but the stream to watch, so a pre-emitter
2524
+ // throw must be published onto that stream — otherwise the run never appears at all and a
2525
+ // tailer waits forever on a run ID that will never produce an event.
2526
+ void this.runSchemaRefreshPipeline(companyIntegrationID, user, provider, universalPKConvention, runID)
2527
+ .catch(err => this.publishDetachedLaunchFailure(runID, companyIntegrationID, err));
2528
+ return {
2529
+ RunID: runID,
2530
+ InProgress: true,
2531
+ // Not known yet — the run has only just been launched.
2532
+ Succeeded: false,
2533
+ ObjectsCreated: 0,
2534
+ ObjectsUpdated: 0,
2535
+ FieldsCreated: 0,
2536
+ FieldsUpdated: 0,
2537
+ UnresolvedObjects: [],
2538
+ PKVerdicts: [],
2539
+ };
2540
+ }
2541
+
2542
+ /**
2543
+ * Terminates a detached run that failed BEFORE the pipeline could open its own artifact stream.
2544
+ *
2545
+ * The pipeline constructs its emitter inside `Run()`, so anything that throws on the way there —
2546
+ * connector-driver resolution (`No connector registered for driver class "X"`), a missing
2547
+ * Integration row, a CompanyIntegration that vanished — produces no run directory at all. In the
2548
+ * blocking path that throw surfaced in the mutation's Message. Detached, the caller has already
2549
+ * been handed the run ID and has only the stream to watch, so the failure has to be published
2550
+ * there or the run is invisible forever.
2551
+ *
2552
+ * Only ever called on the rejection path: if the pipeline got far enough to build its own emitter
2553
+ * it terminates its own run, and a pipeline that completed never reaches here.
2554
+ */
2555
+ private publishDetachedLaunchFailure(runID: string, companyIntegrationID: string, err: unknown): void {
2556
+ const message = this.formatError(err);
2557
+ LogError(`Detached schema refresh (run ${runID}) failed: ${message}`);
2558
+ try {
2559
+ const emitter = new IntegrationProgressEmitter({
2560
+ runID,
2561
+ runKind: 'ConnectorCreation',
2562
+ companyIntegrationID,
2563
+ triggerType: 'Manual',
2564
+ startedAt: new Date().toISOString(),
2565
+ });
2566
+ emitter.runStart('Detached schema refresh launch');
2567
+ emitter.stageError('Launch', message, { code: 'schema-refresh-launch-failed' });
2568
+ void emitter.fail(`Schema refresh could not start: ${message}`, 'schema-refresh-launch-failed')
2569
+ .catch(e => LogError(`Detached schema refresh (run ${runID}): failure artifact write failed — ${e}`));
2570
+ } catch (e) {
2571
+ // Progress reporting must never mask the original failure, which is already logged above.
2572
+ LogError(`Detached schema refresh (run ${runID}): could not open failure artifact — ${e}`);
2573
+ }
2574
+ }
2575
+
2426
2576
  /**
2427
2577
  * Rolls back a freshly created connection by deleting both the CompanyIntegration and Credential records.
2428
2578
  */
@@ -2594,6 +2744,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2594
2744
  @Arg("testConnection", () => Boolean, { defaultValue: false }) testConnection: boolean,
2595
2745
  @Arg("runSchemaRefresh", () => Boolean, { defaultValue: true, description: "When true (default) and TestConnection succeeds, automatically runs IntegrationConnectorCreationPipeline (live introspect → persist Declared/Discovered/Custom → SoftPKClassifier). The intermittent server-side work the wizard's Forward step represents." }) runSchemaRefresh: boolean,
2596
2746
  @Arg("universalPKConvention", { nullable: true, description: "Optional vendor-wide PK hint (e.g. 'id' for HubSpot). Improves SoftPKClassifier convergence." }) universalPKConvention: string | undefined,
2747
+ @Arg("awaitSchemaRefresh", () => Boolean, { defaultValue: true, description: "When false, the schema refresh is launched detached and this mutation returns immediately with SchemaRefresh.RunID + InProgress=true — tail that run instead of blocking on a minutes-long live introspect. The introspect happens exactly once either way, and always AFTER the connection test, so a failed test rolls back a connection that left no discovered schema behind. Default true preserves the blocking behaviour (counts returned inline)." }) awaitSchemaRefresh: boolean,
2597
2748
  @Ctx() ctx: AppContext
2598
2749
  ): Promise<CreateConnectionOutput> {
2599
2750
  try {
@@ -2616,16 +2767,25 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2616
2767
  const credentialID = credential.ID;
2617
2768
 
2618
2769
  // 2. Create CompanyIntegration linked to the Credential
2619
- const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
2770
+ const ci = await md.GetEntityObject<MJCompanyIntegrationEntityServer>('MJ: Company Integrations', user);
2620
2771
  ci.NewRecord();
2621
2772
  ci.IntegrationID = input.IntegrationID;
2622
2773
  ci.CompanyID = input.CompanyID;
2623
2774
  ci.CredentialID = credentialID;
2624
- ci.IsActive = true;
2775
+ // Created INACTIVE on purpose (#3738). A connection is not marked
2776
+ // active until its credential has had a chance to prove itself in
2777
+ // step 3 below — so nothing downstream ever observes an active row
2778
+ // backed by a password that is about to be rejected and rolled back.
2779
+ ci.IsActive = false;
2625
2780
  ci.Name = input.CredentialName; // Name is required on CompanyIntegration
2626
2781
  if (input.ExternalSystemID) ci.ExternalSystemID = input.ExternalSystemID;
2627
2782
  if (input.Configuration) ci.Configuration = input.Configuration;
2628
2783
 
2784
+ // Whether the refresh runs detached (this resolver owns it and returns a tailable RunID)
2785
+ // or inline below. No save-side refresh exists to coordinate with any more — #3738 removed
2786
+ // the activation hook, so every refresh on this path is one this method asked for.
2787
+ const ownSchemaRefresh = runSchemaRefresh && !awaitSchemaRefresh;
2788
+
2629
2789
  const saved = await ci.Save();
2630
2790
  if (!saved) {
2631
2791
  const validationErrors = ci.LatestResult?.Message || 'Unknown validation error';
@@ -2650,12 +2810,27 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2650
2810
  testMessage = testResult.Message;
2651
2811
  }
2652
2812
 
2813
+ // 3b. Credential has proven itself (or the caller declined a test,
2814
+ // which is the pre-existing default) — activate. Ordering matters:
2815
+ // this save is what makes the connection usable, and it must not
2816
+ // happen while the credential is still unverified.
2817
+ ci.IsActive = true;
2818
+ if (!await ci.Save()) {
2819
+ await this.rollbackCreatedConnection(ci, credential);
2820
+ return {
2821
+ Success: false,
2822
+ Message: `Failed to activate CompanyIntegration: ${ci.LatestResult?.Message || 'Unknown error'}`,
2823
+ };
2824
+ }
2825
+
2653
2826
  // 4. Auto-run schema refresh pipeline (intermittent server-side period).
2654
2827
  // Fires whenever runSchemaRefresh=true, regardless of whether the
2655
2828
  // caller also asked for a test. The wizard may have tested separately
2656
2829
  // and just be hitting Create to save.
2657
2830
  let schemaRefreshSummary: CreateConnectionPipelineSummary | undefined;
2658
- if (runSchemaRefresh) {
2831
+ if (ownSchemaRefresh) {
2832
+ schemaRefreshSummary = this.startSchemaRefreshPipelineDetached(ci.ID, user, md, universalPKConvention);
2833
+ } else if (runSchemaRefresh) {
2659
2834
  try {
2660
2835
  const refreshResult = await this.runSchemaRefreshPipeline(
2661
2836
  ci.ID, user, md, universalPKConvention
@@ -2671,9 +2846,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2671
2846
  if (testConnection || schemaRefreshSummary) {
2672
2847
  return {
2673
2848
  Success: true,
2674
- Message: schemaRefreshSummary
2675
- ? `Connection created${testConnection ? ', test passed' : ''}, schema refresh: ${schemaRefreshSummary.ObjectsCreated} created, ${schemaRefreshSummary.ObjectsUpdated} updated, ${schemaRefreshSummary.UnresolvedObjects.length} PK-unresolved`
2676
- : 'Connection created and test passed',
2849
+ Message: BuildCreateConnectionMessage(testConnection, schemaRefreshSummary),
2677
2850
  CompanyIntegrationID: ci.ID,
2678
2851
  CredentialID: credentialID,
2679
2852
  ConnectionTestSuccess: testPassed,
@@ -2706,6 +2879,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2706
2879
  @Arg("testConnection", () => Boolean, { defaultValue: false }) testConnection: boolean,
2707
2880
  @Arg("runSchemaRefresh", () => Boolean, { defaultValue: true, description: "When true (default) and TestConnection succeeds, automatically runs IntegrationConnectorCreationPipeline. Same intermittent server-side step as the create flow." }) runSchemaRefresh: boolean,
2708
2881
  @Arg("universalPKConvention", { nullable: true, description: "Optional vendor-wide PK hint (e.g. 'id' for HubSpot)" }) universalPKConvention: string | undefined,
2882
+ @Arg("awaitSchemaRefresh", () => Boolean, { defaultValue: true, description: "When false, the schema refresh is launched detached and this mutation returns immediately with the run ID to tail instead of blocking on a minutes-long live introspect. Default true preserves the blocking behaviour." }) awaitSchemaRefresh: boolean,
2709
2883
  @Ctx() ctx: AppContext
2710
2884
  ): Promise<MutationResultOutput> {
2711
2885
  try {
@@ -2752,15 +2926,16 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2752
2926
  // Fires whenever runSchemaRefresh=true, regardless of whether the
2753
2927
  // caller also asked for a test — the wizard may have tested separately
2754
2928
  // already and is just hitting Update to save edits.
2929
+ if (runSchemaRefresh && !awaitSchemaRefresh) {
2930
+ const detached = this.startSchemaRefreshPipelineDetached(companyIntegrationID, user, md, universalPKConvention);
2931
+ return { Success: true, Message: BuildDetachedRefreshMessage(detached.RunID) };
2932
+ }
2755
2933
  if (runSchemaRefresh) {
2756
2934
  try {
2757
2935
  const refreshResult = await this.runSchemaRefreshPipeline(
2758
2936
  companyIntegrationID, user, md, universalPKConvention
2759
2937
  );
2760
- return {
2761
- Success: true,
2762
- Message: `Updated, schema refresh: ${refreshResult.ObjectsCreated} created, ${refreshResult.ObjectsUpdated} updated, ${refreshResult.UnresolvedObjects.length} PK-unresolved`,
2763
- };
2938
+ return { Success: true, Message: BuildUpdateConnectionMessage(refreshResult) };
2764
2939
  } catch (refreshErr) {
2765
2940
  LogError(`IntegrationUpdateConnection: pipeline error — ${refreshErr}`);
2766
2941
  return { Success: true, Message: `Updated (schema refresh failed: ${this.formatError(refreshErr)})` };
@@ -2803,6 +2978,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2803
2978
  set('crossLayerPipeline', config.CrossLayerPipeline);
2804
2979
  set('partitionReconcile', config.PartitionReconcile);
2805
2980
  set('discoveryTimeBudgetMs', config.DiscoveryTimeBudgetMs);
2981
+ set('fetchTimeoutMs', config.FetchTimeoutMs);
2806
2982
  set('discoveryBatchSize', config.DiscoveryBatchSize);
2807
2983
  set('discoveryMaxRecords', config.DiscoveryMaxRecords);
2808
2984
  set('deactivateAbsent', config.DeactivateAbsent);
@@ -2858,6 +3034,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2858
3034
  CrossLayerPipeline: bool(cfg.crossLayerPipeline),
2859
3035
  PartitionReconcile: bool(cfg.partitionReconcile),
2860
3036
  DiscoveryTimeBudgetMs: num(cfg.discoveryTimeBudgetMs),
3037
+ FetchTimeoutMs: num(cfg.fetchTimeoutMs),
2861
3038
  DiscoveryBatchSize: num(cfg.discoveryBatchSize),
2862
3039
  DiscoveryMaxRecords: num(cfg.discoveryMaxRecords),
2863
3040
  DeactivateAbsent: bool(cfg.deactivateAbsent),
@@ -2967,10 +3144,16 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2967
3144
 
2968
3145
  /**
2969
3146
  * Reactivates a previously deactivated CompanyIntegration by setting IsActive=true.
3147
+ *
3148
+ * Before #3738 this mutation inherited a full schema refresh from the
3149
+ * CompanyIntegration save hook, with no way to decline it. The refresh is
3150
+ * now this resolver's own, explicit step — same default behaviour, but
3151
+ * visible in the API and suppressible with `runSchemaRefresh: false`.
2970
3152
  */
2971
3153
  @Mutation(() => MutationResultOutput)
2972
3154
  async IntegrationReactivateConnection(
2973
3155
  @Arg("companyIntegrationID") companyIntegrationID: string,
3156
+ @Arg("runSchemaRefresh", () => Boolean, { defaultValue: true, description: "When true (default), re-runs IntegrationConnectorCreationPipeline after reactivating so the catalog reflects the source as it is now. Pass false to reactivate without a live scan — useful when the catalog is known-current, or when the source is large enough that discovery should be scheduled separately." }) runSchemaRefresh: boolean,
2974
3157
  @Ctx() ctx: AppContext
2975
3158
  ): Promise<MutationResultOutput> {
2976
3159
  try {
@@ -2981,6 +3164,23 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2981
3164
  if (!loaded) return { Success: false, Message: 'CompanyIntegration not found' };
2982
3165
  ci.IsActive = true;
2983
3166
  if (!await ci.Save()) return { Success: false, Message: `Failed to reactivate: ${ci.LatestResult?.Message ?? 'Unknown error'}` };
3167
+
3168
+ if (runSchemaRefresh) {
3169
+ try {
3170
+ const refreshResult = await this.runSchemaRefreshPipeline(companyIntegrationID, user, md);
3171
+ return {
3172
+ Success: true,
3173
+ Message: `Reactivated, schema refresh: ${refreshResult.ObjectsCreated} created, ${refreshResult.ObjectsUpdated} updated, ${refreshResult.UnresolvedObjects.length} PK-unresolved`,
3174
+ };
3175
+ } catch (refreshErr) {
3176
+ // Refresh failure does NOT undo the reactivation — the
3177
+ // connection is active, only discovery failed, and the
3178
+ // operator can re-run IntegrationRefreshConnectorSchema.
3179
+ LogError(`IntegrationReactivateConnection: pipeline error — ${refreshErr}`);
3180
+ return { Success: true, Message: `Reactivated (schema refresh failed: ${this.formatError(refreshErr)})` };
3181
+ }
3182
+ }
3183
+
2984
3184
  return { Success: true, Message: 'Reactivated' };
2985
3185
  } catch (e) {
2986
3186
  LogError(`IntegrationReactivateConnection error: ${e}`);
@@ -3398,12 +3598,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3398
3598
  );
3399
3599
 
3400
3600
  // Step 4: Inject integration post-restart payload into RSU input.
3401
- const { join } = await import('node:path');
3402
- const rsuWorkDir = process.env.RSU_WORK_DIR || process.cwd();
3403
- const pendingWorkDir = join(rsuWorkDir, '.rsu_pending');
3404
- const pendingFilePath = join(pendingWorkDir, `${Date.now()}.json`);
3405
-
3406
- // Build per-object field map for pending file (null = all fields).
3601
+ // Build per-object field map for the pending payload (null = all fields).
3407
3602
  // resolved.sourceObjects is order-aligned with resolvedNames after the
3408
3603
  // resolveSourceObjectsToNames refactor — pair them directly instead
3409
3604
  // of looking up by ID (which broke for name-only selections).
@@ -3426,7 +3621,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3426
3621
  const hadPriorMaps = priorMapsResult.Success && priorMapsResult.Results.length > 0;
3427
3622
  const effectiveFullSync = input.FullSync ?? !hadPriorMaps;
3428
3623
 
3429
- const pendingPayload = {
3624
+ const pendingPayload: RSUPendingWork = {
3430
3625
  CompanyIntegrationID: input.CompanyIntegrationID,
3431
3626
  SourceObjectNames: resolvedNames,
3432
3627
  SourceObjectFields: sourceObjectFields,
@@ -3435,13 +3630,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3435
3630
  ScheduleTimezone: input.ScheduleTimezone,
3436
3631
  StartSync: input.StartSync,
3437
3632
  FullSync: effectiveFullSync,
3438
- SyncScope: input.SyncScope ?? 'created',
3633
+ SyncScope: input.SyncScope === 'all' ? 'all' : 'created',
3439
3634
  UnselectedAction: (input.UnselectedAction ?? 'disable') as 'disable' | 'ignore',
3440
3635
  CreatedAt: new Date().toISOString(),
3441
3636
  };
3442
- rsuInput.PostRestartFiles = [
3443
- { Path: pendingFilePath, Content: JSON.stringify(pendingPayload, null, 2) }
3444
- ];
3637
+ rsuInput.PendingWork = [pendingPayload];
3638
+ rsuInput.ContextUser = user;
3445
3639
 
3446
3640
  // Step 5: Run pipeline (restart kills process at the end).
3447
3641
  // Retrying variant — an install should not die because the migration hit a dropped
@@ -3454,9 +3648,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3454
3648
  Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
3455
3649
  }));
3456
3650
 
3457
- // If pipeline failed, clean up pending file and return error
3651
+ // Pending work is registered only for migrations that succeeded, so a
3652
+ // failed pipeline has nothing to clean up.
3458
3653
  if (!migrationSucceeded) {
3459
- try { (await import('node:fs')).unlinkSync(pendingFilePath); } catch { /* may not exist */ }
3460
3654
  return {
3461
3655
  Success: false,
3462
3656
  Message: `Pipeline failed: ${batchResult.Results[0]?.ErrorMessage ?? 'unknown error'}`,
@@ -3514,7 +3708,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3514
3708
  }
3515
3709
  }
3516
3710
 
3517
- try { (await import('node:fs')).unlinkSync(pendingFilePath); } catch { /* already consumed */ }
3711
+ // The work just happened inline (no restart), so close out its durable rows.
3712
+ for (const pendingWorkID of batchResult.Results[0]?.PendingWorkIDs ?? []) {
3713
+ await rsm.CompletePendingWork(pendingWorkID, user);
3714
+ }
3518
3715
 
3519
3716
  return {
3520
3717
  Success: true,
@@ -4103,6 +4300,60 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4103
4300
  }
4104
4301
  }
4105
4302
 
4303
+ /**
4304
+ * Enqueue a sync for worker execution (PR 1 item 8) instead of running it in this
4305
+ * process. Creates the run row with `Status='Queued'` and returns its ID immediately —
4306
+ * so the caller gets a real, trackable RunID with no fire-and-forget polling window,
4307
+ * and the sync survives this process going away before it starts.
4308
+ *
4309
+ * Requires a process running the integration sync worker (`integrationSyncWorker.enabled`);
4310
+ * without one, rows accumulate in the queue unexecuted.
4311
+ */
4312
+ @Mutation(() => StartSyncOutput)
4313
+ @RequireSystemUser()
4314
+ async IntegrationEnqueueSync(
4315
+ @Arg("companyIntegrationID") companyIntegrationID: string,
4316
+ @Arg("fullSync", () => Boolean, { defaultValue: false, description: 'If true, ignores watermarks and re-fetches all records from the source' }) fullSync: boolean,
4317
+ @Arg("entityMapIDs", () => [String], { nullable: true, description: 'Optional: sync only these entity maps. If omitted, syncs all maps for the connector.' }) entityMapIDs: string[],
4318
+ @Arg("syncDirection", () => String, { nullable: true, description: 'Override sync direction: Pull | Push | Bidirectional. If omitted, each entity map\'s own SyncDirection is used.' }) syncDirection: 'Pull' | 'Push' | 'Bidirectional' | undefined,
4319
+ @Ctx() ctx: AppContext
4320
+ ): Promise<StartSyncOutput> {
4321
+ try {
4322
+ const user = this.getAuthenticatedUser(ctx);
4323
+ const provider = GetReadWriteProvider(ctx.providers) as unknown as IMetadataProvider;
4324
+ await IntegrationEngine.Instance.Config(false, user, provider);
4325
+
4326
+ // Same upfront gates as IntegrationStartSync — refusing here beats queueing work
4327
+ // that the engine will only refuse later, in another process, out of the caller's sight.
4328
+ const ciCheck = await provider.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
4329
+ if (await ciCheck.InnerLoad(CompositeKey.FromID(companyIntegrationID)) && ciCheck.IsActive === false) {
4330
+ return { Success: false, Message: 'Connector is deactivated (IsActive=false); sync not enqueued' };
4331
+ }
4332
+ const maintenance = IntegrationEngine.GetMaintenanceLock(companyIntegrationID);
4333
+ if (maintenance) {
4334
+ return { Success: false, Message: `Sync not enqueued: ${maintenance.Reason} is in progress for this connection (since ${maintenance.AcquiredAt.toISOString()}). Retry after it completes.` };
4335
+ }
4336
+
4337
+ const syncOptions: IntegrationSyncOptions = {};
4338
+ if (fullSync) syncOptions.FullSync = true;
4339
+ if (entityMapIDs?.length) syncOptions.EntityMapIDs = entityMapIDs;
4340
+ if (syncDirection) syncOptions.SyncDirection = syncDirection;
4341
+
4342
+ const runID = await IntegrationEngine.Instance.EnqueueSync(
4343
+ companyIntegrationID,
4344
+ user,
4345
+ 'Manual',
4346
+ Object.keys(syncOptions).length > 0 ? syncOptions : undefined,
4347
+ provider
4348
+ );
4349
+
4350
+ return { Success: true, Message: 'Sync enqueued', RunID: runID };
4351
+ } catch (e) {
4352
+ LogError(`IntegrationEnqueueSync error: ${e}`);
4353
+ return { Success: false, Message: this.formatError(e) };
4354
+ }
4355
+ }
4356
+
4106
4357
  /**
4107
4358
  * Cancels a running sync by marking its status as Cancelled.
4108
4359
  */
@@ -4112,10 +4363,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4112
4363
  @Ctx() ctx: AppContext
4113
4364
  ): Promise<MutationResultOutput> {
4114
4365
  try {
4115
- this.getAuthenticatedUser(ctx);
4366
+ const user = this.getAuthenticatedUser(ctx);
4367
+ const provider = GetReadWriteProvider(ctx.providers) as unknown as IMetadataProvider;
4116
4368
 
4117
- // Signal the engine to abort the running sync
4118
- const cancelled = IntegrationEngine.CancelSync(companyIntegrationID);
4369
+ // DB-backed cancel (durable runs): stamps CancelRequestedAt on the live run row, so
4370
+ // the request reaches the owning process wherever it is — this server, another API
4371
+ // node, or a worker — via its heartbeat/boundary checks. No in-memory signal.
4372
+ const cancelled = await IntegrationEngine.CancelSyncAsync(companyIntegrationID, user, provider);
4119
4373
  if (!cancelled) {
4120
4374
  return { Success: false, Message: 'No active sync found for this connector' };
4121
4375
  }
@@ -4712,8 +4966,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4712
4966
  @Ctx() ctx: AppContext
4713
4967
  ): Promise<OperationProgressOutput> {
4714
4968
  try {
4715
- this.getAuthenticatedUser(ctx);
4716
- const syncProgress = IntegrationEngine.GetSyncProgress(companyIntegrationID);
4969
+ const user = this.getAuthenticatedUser(ctx);
4970
+ const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
4971
+ // DB-backed progress (durable runs): reads ProgressJSON from the live run row, so
4972
+ // progress is visible from ANY process — not just the one executing the sync.
4973
+ const syncProgress = await IntegrationEngine.GetSyncProgressAsync(companyIntegrationID, user, provider);
4717
4974
  if (syncProgress) {
4718
4975
  return {
4719
4976
  Success: true,
@@ -5236,18 +5493,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5236
5493
  connInput.CompanyIntegrationID, objects, validatedPlatform, user, skipGitCommit, skipRestart, provider, sourceSchema
5237
5494
  );
5238
5495
 
5239
- // Build per-object field map for pending file
5496
+ // Build per-object field map for the pending payload
5240
5497
  const sourceObjectFields: Record<string, string[] | null> = {};
5241
5498
  for (const name of resolvedNames) {
5242
5499
  sourceObjectFields[name] = fieldsByName.get(name.toLowerCase()) ?? null;
5243
5500
  }
5244
5501
 
5245
5502
  // Inject post-restart pending work payload
5246
- const { join } = await import('node:path');
5247
- const rsuWorkDir = process.env.RSU_WORK_DIR || process.cwd();
5248
- const pendingWorkDir = join(rsuWorkDir, '.rsu_pending');
5249
- const pendingFilePath = join(pendingWorkDir, `${Date.now()}_${connInput.CompanyIntegrationID}.json`);
5250
- const pendingPayload = {
5503
+ const pendingPayload: RSUPendingWork = {
5251
5504
  CompanyIntegrationID: connInput.CompanyIntegrationID,
5252
5505
  SourceObjectNames: resolvedNames,
5253
5506
  SourceObjectFields: sourceObjectFields,
@@ -5256,15 +5509,15 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5256
5509
  ScheduleTimezone: connInput.ScheduleTimezone,
5257
5510
  StartSync: input.StartSync,
5258
5511
  FullSync: input.FullSync ?? false,
5259
- SyncScope: input.SyncScope ?? 'created',
5260
- SyncDirection: input.SyncDirection,
5261
- ScheduleSyncDirection: input.ScheduleSyncDirection,
5512
+ SyncScope: input.SyncScope === 'all' ? 'all' : 'created',
5513
+ // Unrecognized directions fall back to undefined = "use the entity map's own SyncDirection"
5514
+ SyncDirection: input.SyncDirection && isValidSyncDirection(input.SyncDirection) ? input.SyncDirection : undefined,
5515
+ ScheduleSyncDirection: input.ScheduleSyncDirection && isValidSyncDirection(input.ScheduleSyncDirection) ? input.ScheduleSyncDirection : undefined,
5262
5516
  UnselectedAction: (input.UnselectedAction ?? 'disable') as 'disable' | 'ignore',
5263
5517
  CreatedAt: new Date().toISOString(),
5264
5518
  };
5265
- rsuInput.PostRestartFiles = [
5266
- { Path: pendingFilePath, Content: JSON.stringify(pendingPayload, null, 2) }
5267
- ];
5519
+ rsuInput.PendingWork = [pendingPayload];
5520
+ rsuInput.ContextUser = user;
5268
5521
 
5269
5522
  return {
5270
5523
  connInput,
@@ -5274,7 +5527,6 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5274
5527
  objects,
5275
5528
  schemaOutput,
5276
5529
  rsuInput,
5277
- pendingFilePath,
5278
5530
  };
5279
5531
  })
5280
5532
  );
@@ -5288,7 +5540,6 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5288
5540
  objects: SchemaPreviewObjectInput[];
5289
5541
  schemaOutput: SchemaBuilderOutput;
5290
5542
  rsuInput: RSUPipelineInput;
5291
- pendingFilePath: string;
5292
5543
  }> = [];
5293
5544
  const connectorResults: ApplyAllBatchConnectorResult[] = [];
5294
5545
 
@@ -5340,8 +5591,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5340
5591
  Message: pipelineResult?.ErrorMessage ?? 'Pipeline failed',
5341
5592
  Warnings: build.schemaOutput.Warnings.length > 0 ? build.schemaOutput.Warnings : undefined,
5342
5593
  });
5343
- // Clean up pending file on failure
5344
- try { (await import('node:fs')).unlinkSync(build.pendingFilePath); } catch { /* may not exist */ }
5594
+ // No pending-work cleanup needed — rows are registered only for
5595
+ // migrations that succeeded.
5345
5596
  continue;
5346
5597
  }
5347
5598
 
@@ -5388,10 +5639,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5388
5639
  if (scheduleResult) connResult.ScheduledJobID = scheduleResult;
5389
5640
  }
5390
5641
 
5391
- // Clean up pending file
5392
- try { (await import('node:fs')).unlinkSync(build.pendingFilePath); } catch { /* already consumed */ }
5642
+ // The work just happened inline (no restart), so close out its durable rows.
5643
+ for (const pendingWorkID of pipelineResult.PendingWorkIDs ?? []) {
5644
+ await rsm.CompletePendingWork(pendingWorkID, user);
5645
+ }
5393
5646
 
5394
- connResult.Message = `Applied ${build.objects.length} object(s) — ${entityMapsCreated.length} entity maps created${syncRunID ? ', sync started' : ''}`;
5647
+ connResult.Message =`Applied ${build.objects.length} object(s) — ${entityMapsCreated.length} entity maps created${syncRunID ? ', sync started' : ''}`;
5395
5648
  }
5396
5649
 
5397
5650
  connectorResults.push(connResult);
@@ -5958,26 +6211,21 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5958
6211
  // ApplyAll uses. default: created DISABLED (user enables after the refresh);
5959
6212
  // autoEnableNewObjects opts in. Removal was already handled in Phase 3 → 'ignore'.
5960
6213
  if (newObjects.length > 0 && !skipRestart) {
5961
- const { join } = await import('node:path');
5962
- const rsuWorkDir = process.env.RSU_WORK_DIR || process.cwd();
5963
- const pendingFilePath = join(rsuWorkDir, '.rsu_pending', `${Date.now()}-evolution.json`);
5964
- rsuInput.PostRestartFiles = [
5965
- ...(rsuInput.PostRestartFiles ?? []),
6214
+ rsuInput.PendingWork = [
6215
+ ...(rsuInput.PendingWork ?? []),
5966
6216
  {
5967
- Path: pendingFilePath,
5968
- Content: JSON.stringify({
5969
- CompanyIntegrationID: companyIntegrationID,
5970
- SourceObjectNames: newObjects,
5971
- SourceObjectFields: Object.fromEntries(newObjects.map(n => [n, null])),
5972
- SchemaName: schemaName,
5973
- StartSync: false,
5974
- SyncScope: 'created',
5975
- UnselectedAction: 'ignore',
5976
- CreateDisabled: !autoEnableNewObjects,
5977
- CreatedAt: new Date().toISOString(),
5978
- }, null, 2),
6217
+ CompanyIntegrationID: companyIntegrationID,
6218
+ SourceObjectNames: newObjects,
6219
+ SourceObjectFields: Object.fromEntries(newObjects.map(n => [n, null])),
6220
+ SchemaName: schemaName,
6221
+ StartSync: false,
6222
+ SyncScope: 'created',
6223
+ UnselectedAction: 'ignore',
6224
+ CreateDisabled: !autoEnableNewObjects,
6225
+ CreatedAt: new Date().toISOString(),
5979
6226
  },
5980
6227
  ];
6228
+ rsuInput.ContextUser = user;
5981
6229
  }
5982
6230
 
5983
6231
  const rsm = RuntimeSchemaManager.Instance;