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

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 (210) hide show
  1. package/LICENSE +180 -4
  2. package/dist/agentSessions/SessionManager.d.ts +19 -5
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +27 -9
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
  7. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
  8. package/dist/auth/AuthProviderCatalogRouter.js +69 -0
  9. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
  10. package/dist/auth/AuthProviderEngine.d.ts +96 -0
  11. package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
  12. package/dist/auth/AuthProviderEngine.js +245 -0
  13. package/dist/auth/AuthProviderEngine.js.map +1 -0
  14. package/dist/auth/index.d.ts +12 -0
  15. package/dist/auth/index.d.ts.map +1 -1
  16. package/dist/auth/index.js +57 -13
  17. package/dist/auth/index.js.map +1 -1
  18. package/dist/auth/initializeProviders.d.ts +64 -1
  19. package/dist/auth/initializeProviders.d.ts.map +1 -1
  20. package/dist/auth/initializeProviders.js +117 -5
  21. package/dist/auth/initializeProviders.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +89 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +52 -35
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts.map +1 -1
  31. package/dist/context.js +21 -1
  32. package/dist/context.js.map +1 -1
  33. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  34. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
  35. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  36. package/dist/generated/generated.d.ts +619 -1597
  37. package/dist/generated/generated.d.ts.map +1 -1
  38. package/dist/generated/generated.js +3341 -17021
  39. package/dist/generated/generated.js.map +1 -1
  40. package/dist/generic/ResolverBase.d.ts +28 -1
  41. package/dist/generic/ResolverBase.d.ts.map +1 -1
  42. package/dist/generic/ResolverBase.js +57 -8
  43. package/dist/generic/ResolverBase.js.map +1 -1
  44. package/dist/generic/RunViewResolver.d.ts +4 -0
  45. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  46. package/dist/generic/RunViewResolver.js +31 -0
  47. package/dist/generic/RunViewResolver.js.map +1 -1
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +147 -14
  51. package/dist/index.js.map +1 -1
  52. package/dist/integration/CustomColumnPromoter.d.ts +79 -15
  53. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  54. package/dist/integration/CustomColumnPromoter.js +472 -97
  55. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  56. package/dist/integration/InactiveRowWarnings.d.ts +48 -0
  57. package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
  58. package/dist/integration/InactiveRowWarnings.js +57 -0
  59. package/dist/integration/InactiveRowWarnings.js.map +1 -0
  60. package/dist/integration/RSUProgressBridge.d.ts +83 -0
  61. package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
  62. package/dist/integration/RSUProgressBridge.js +230 -0
  63. package/dist/integration/RSUProgressBridge.js.map +1 -0
  64. package/dist/integration/SchemaRefreshLaunch.d.ts +62 -0
  65. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
  66. package/dist/integration/SchemaRefreshLaunch.js +67 -0
  67. package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
  68. package/dist/providerConfigUnits.d.ts +20 -0
  69. package/dist/providerConfigUnits.d.ts.map +1 -0
  70. package/dist/providerConfigUnits.js +22 -0
  71. package/dist/providerConfigUnits.js.map +1 -0
  72. package/dist/resolvers/AdhocQueryResolver.d.ts +4 -0
  73. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  74. package/dist/resolvers/AdhocQueryResolver.js +20 -6
  75. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  76. package/dist/resolvers/FileResolver.d.ts +34 -4
  77. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  78. package/dist/resolvers/FileResolver.js +218 -28
  79. package/dist/resolvers/FileResolver.js.map +1 -1
  80. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts +37 -0
  81. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
  82. package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
  83. package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
  84. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +108 -3
  85. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  86. package/dist/resolvers/IntegrationDiscoveryResolver.js +374 -87
  87. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  88. package/dist/resolvers/QueryResolver.d.ts +5 -4
  89. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  90. package/dist/resolvers/QueryResolver.js +29 -14
  91. package/dist/resolvers/QueryResolver.js.map +1 -1
  92. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
  93. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  94. package/dist/resolvers/QuerySystemUserResolver.js +33 -14
  95. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  96. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
  97. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  98. package/dist/resolvers/RunAIAgentResolver.js +20 -7
  99. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  100. package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
  101. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  102. package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
  103. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  104. package/dist/resolvers/UserResolver.d.ts +19 -17
  105. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  106. package/dist/resolvers/UserResolver.js +66 -67
  107. package/dist/resolvers/UserResolver.js.map +1 -1
  108. package/dist/resolvers/currentUserRoles.d.ts +19 -0
  109. package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
  110. package/dist/resolvers/currentUserRoles.js +36 -0
  111. package/dist/resolvers/currentUserRoles.js.map +1 -0
  112. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
  113. package/dist/resolvers/meetingRecordingRegistration.js +3 -6
  114. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
  115. package/dist/rest/MediaAccessKeys.d.ts +17 -0
  116. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  117. package/dist/rest/MediaAccessKeys.js +42 -1
  118. package/dist/rest/MediaAccessKeys.js.map +1 -1
  119. package/dist/rest/MediaStreamHandler.d.ts +3 -2
  120. package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
  121. package/dist/rest/MediaStreamHandler.js +83 -2
  122. package/dist/rest/MediaStreamHandler.js.map +1 -1
  123. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  124. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  125. package/dist/rest/OAuthCallbackHandler.js +129 -43
  126. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  127. package/dist/rest/UploadTokenManager.d.ts +90 -0
  128. package/dist/rest/UploadTokenManager.d.ts.map +1 -0
  129. package/dist/rest/UploadTokenManager.js +156 -0
  130. package/dist/rest/UploadTokenManager.js.map +1 -0
  131. package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
  132. package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
  133. package/dist/services/IntegrationSyncWorkerService.js +132 -0
  134. package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
  135. package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
  136. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  137. package/dist/services/StartTaskGraphDispatcher.js +21 -1
  138. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  139. package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
  140. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  141. package/dist/services/TaskGraphAgentRunner.js +23 -0
  142. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  143. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  144. package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
  145. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  146. package/dist/types.d.ts +14 -0
  147. package/dist/types.d.ts.map +1 -1
  148. package/dist/types.js.map +1 -1
  149. package/package.json +92 -92
  150. package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
  151. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  152. package/src/__tests__/AuthProviderEngine.test.ts +278 -0
  153. package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
  154. package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
  155. package/src/__tests__/InactiveRowWarnings.test.ts +87 -0
  156. package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
  157. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  158. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  159. package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
  160. package/src/__tests__/RSUProgressBridge.test.ts +250 -0
  161. package/src/__tests__/ReactivateRefreshMessage.test.ts +85 -0
  162. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
  163. package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
  164. package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
  165. package/src/__tests__/SessionManager.test.ts +55 -0
  166. package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
  167. package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
  168. package/src/__tests__/config-units.test.ts +74 -0
  169. package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
  170. package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
  171. package/src/__tests__/newUsers.test.ts +726 -0
  172. package/src/__tests__/uploadTokenManager.test.ts +159 -0
  173. package/src/agentSessions/SessionManager.ts +27 -9
  174. package/src/auth/AuthProviderCatalogRouter.ts +76 -0
  175. package/src/auth/AuthProviderEngine.ts +253 -0
  176. package/src/auth/index.ts +60 -15
  177. package/src/auth/initializeProviders.ts +128 -7
  178. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  179. package/src/config.ts +55 -36
  180. package/src/context.ts +26 -2
  181. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
  182. package/src/generated/generated.ts +2347 -10551
  183. package/src/generic/ResolverBase.ts +67 -8
  184. package/src/generic/RunViewResolver.ts +31 -0
  185. package/src/index.ts +165 -16
  186. package/src/integration/CustomColumnPromoter.ts +520 -98
  187. package/src/integration/InactiveRowWarnings.ts +85 -0
  188. package/src/integration/RSUProgressBridge.ts +248 -0
  189. package/src/integration/SchemaRefreshLaunch.ts +93 -0
  190. package/src/providerConfigUnits.ts +21 -0
  191. package/src/resolvers/AdhocQueryResolver.ts +21 -6
  192. package/src/resolvers/FileResolver.ts +200 -40
  193. package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
  194. package/src/resolvers/IntegrationDiscoveryResolver.ts +390 -71
  195. package/src/resolvers/QueryResolver.ts +25 -11
  196. package/src/resolvers/QuerySystemUserResolver.ts +28 -13
  197. package/src/resolvers/RunAIAgentResolver.ts +21 -6
  198. package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
  199. package/src/resolvers/UserResolver.ts +99 -60
  200. package/src/resolvers/currentUserRoles.ts +47 -0
  201. package/src/resolvers/meetingRecordingRegistration.ts +3 -7
  202. package/src/rest/MediaAccessKeys.ts +47 -1
  203. package/src/rest/MediaStreamHandler.ts +98 -3
  204. package/src/rest/OAuthCallbackHandler.ts +149 -41
  205. package/src/rest/UploadTokenManager.ts +211 -0
  206. package/src/services/IntegrationSyncWorkerService.ts +141 -0
  207. package/src/services/StartTaskGraphDispatcher.ts +21 -1
  208. package/src/services/TaskGraphAgentRunner.ts +23 -0
  209. package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
  210. package/src/types.ts +14 -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,18 @@ 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 { ComputeInactiveRowWarnings } from "../integration/InactiveRowWarnings.js";
60
+ import { BuildCreateConnectionMessage, BuildDetachedRefreshMessage, BuildReactivateMessage, BuildUpdateConnectionMessage } from "../integration/SchemaRefreshLaunch.js";
61
+ // Type-only: the registered runtime class for 'MJ: Company Integrations'. Lets the create path name the
62
+ // server subclass it actually gets back from GetEntityObject with a real type rather than a cast.
63
+ import type { MJCompanyIntegrationEntityServer } from "@memberjunction/core-entities-server";
58
64
  import { AppContext } from "../types.js";
59
65
  import { RequireSystemUser } from "../directives/RequireSystemUser.js";
60
66
  import { UserCache } from "@memberjunction/generic-database-provider";
@@ -569,6 +575,21 @@ class CreateConnectionInput {
569
575
  @ObjectType()
570
576
  class CreateConnectionPipelineSummary {
571
577
  @Field() RunID: string;
578
+ /**
579
+ * True when the pipeline was launched detached (`awaitSchemaRefresh: false`) and is STILL RUNNING —
580
+ * every count below is a placeholder zero, not a result. Tail `RunID` via IntegrationTailRunEvents
581
+ * (or the IntegrationProgress subscription, kind='ConnectorCreation') for the real outcome.
582
+ */
583
+ @Field() InProgress: boolean;
584
+ /**
585
+ * Whether the refresh pipeline itself succeeded. A pipeline that fails at ConnectionTest still
586
+ * RETURNS (it does not throw), with every count at zero — so counts alone cannot distinguish
587
+ * "found nothing to change" from "never got past the credential check". Always false while
588
+ * `InProgress` is true: a detached run's outcome is not known yet.
589
+ */
590
+ @Field() Succeeded: boolean;
591
+ /** The pipeline's own failure reason when `Succeeded` is false and the run has finished. */
592
+ @Field({ nullable: true }) FailureMessage?: string;
572
593
  @Field() ObjectsCreated: number;
573
594
  @Field() ObjectsUpdated: number;
574
595
  @Field() FieldsCreated: number;
@@ -613,6 +634,7 @@ class IntegrationSyncConfigInput {
613
634
  @Field(() => Boolean, { nullable: true, description: '§4 cross-layer pipelining: a child map starts when ITS parents finish, not the whole layer.' }) CrossLayerPipeline?: boolean;
614
635
  @Field(() => Boolean, { nullable: true, description: 'Merkle/partition hash-diff reconcile for watermark-less change detection (buffers the set in RAM).' }) PartitionReconcile?: boolean;
615
636
  @Field(() => Int, { nullable: true, description: 'Time budget (ms) for stage-2 streaming field discovery before it stops and uses what it gathered.' }) DiscoveryTimeBudgetMs?: number;
637
+ @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
638
  @Field(() => Int, { nullable: true, description: 'Batch size for stage-2 streaming field discovery (records per FetchChanges page). Default 500.' }) DiscoveryBatchSize?: number;
617
639
  @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
640
  @Field(() => Boolean, { nullable: true, description: '§7 Comprehensive refresh deactivates Declared objects/fields ABSENT from an AUTHORITATIVE discovery (reversible). Default false.' }) DeactivateAbsent?: boolean;
@@ -631,6 +653,7 @@ class IntegrationSyncConfigOutput {
631
653
  @Field(() => Boolean, { nullable: true }) CrossLayerPipeline?: boolean;
632
654
  @Field(() => Boolean, { nullable: true }) PartitionReconcile?: boolean;
633
655
  @Field(() => Int, { nullable: true }) DiscoveryTimeBudgetMs?: number;
656
+ @Field(() => Int, { nullable: true }) FetchTimeoutMs?: number;
634
657
  @Field(() => Int, { nullable: true }) DiscoveryBatchSize?: number;
635
658
  @Field(() => Int, { nullable: true }) DiscoveryMaxRecords?: number;
636
659
  @Field(() => Boolean, { nullable: true }) DeactivateAbsent?: boolean;
@@ -946,7 +969,7 @@ class IntegrationRunEventsOutput {
946
969
  @Field() IsInFlight: boolean;
947
970
  }
948
971
 
949
- // Sync progress is now tracked inside IntegrationEngine itself via IntegrationEngine.GetSyncProgress()
972
+ // Sync progress is now tracked inside IntegrationEngine itself via IntegrationEngine.GetSyncProgressAsync()
950
973
 
951
974
  @ObjectType()
952
975
  class ConnectionSummaryOutput {
@@ -2316,6 +2339,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2316
2339
  private buildSourceSchemaFromPersistedRows(
2317
2340
  integrationID: string,
2318
2341
  requestedNames?: string[],
2342
+ warningsOut?: string[],
2319
2343
  ): SourceSchemaInfo {
2320
2344
  const engine = IntegrationEngineBase.Instance;
2321
2345
  // ACTIVE-only materialization: an object/field a given tenant doesn't expose is marked
@@ -2334,10 +2358,15 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2334
2358
  for (const io of ios) ioByID.set(io.ID, io.Name);
2335
2359
 
2336
2360
  const result: SourceSchemaInfo = { Objects: [] };
2361
+ const fieldsByObjectName: Record<string, Array<{ Name: string; Status: string | null }>> = {};
2337
2362
  for (const io of ios) {
2338
2363
  if (filter && !filter.has(io.Name.toLowerCase())) continue;
2339
2364
  // Active fields only — an inactive (source-absent / deactivated) field is not materialized.
2340
- const iofs = engine.GetIntegrationObjectFields(io.ID).filter(iof => iof.Status === 'Active');
2365
+ const allFields = engine.GetIntegrationObjectFields(io.ID);
2366
+ const iofs = allFields.filter(iof => iof.Status === 'Active');
2367
+ // Remember what this object declared, active or not — the caller's warning collector
2368
+ // turns the difference into the one message that explains a column that never appeared.
2369
+ if (warningsOut) fieldsByObjectName[io.Name] = allFields.map(f => ({ Name: f.Name, Status: f.Status }));
2341
2370
 
2342
2371
  const fields = iofs.map(iof => {
2343
2372
  const targetIOName = iof.RelatedIntegrationObjectID
@@ -2378,6 +2407,18 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2378
2407
  IncrementalWatermarkField: io.IncrementalWatermarkField ?? undefined,
2379
2408
  });
2380
2409
  }
2410
+
2411
+ // Declared-but-inactive rows this rebuild left out. The caller decides what to do with the
2412
+ // strings (the apply path puts them on its Warnings); computing them costs nothing when no
2413
+ // collector was passed, since the field lists above are only gathered then.
2414
+ if (warningsOut) {
2415
+ warningsOut.push(...ComputeInactiveRowWarnings({
2416
+ RequestedNames: requestedNames ?? null,
2417
+ AllObjects: engine.GetIntegrationObjectsByIntegrationID(integrationID)
2418
+ .map(io => ({ Name: io.Name, Status: io.Status })),
2419
+ FieldsByObjectName: fieldsByObjectName,
2420
+ }));
2421
+ }
2381
2422
  return result;
2382
2423
  }
2383
2424
 
@@ -2386,9 +2427,42 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2386
2427
  user: UserInfo,
2387
2428
  provider: IMetadataProvider,
2388
2429
  universalPKConvention?: string,
2430
+ runID?: string,
2431
+ ): Promise<CreateConnectionPipelineSummary> {
2432
+ // Sync lock (#3656). This pipeline rewrites the very IO/IOF rows, field
2433
+ // maps and DDL a sync reads, so it must not overlap a sync or another
2434
+ // maintenance operation for the same connection. The two other
2435
+ // pipeline call sites — IntegrationRefreshConnectorSchema and
2436
+ // IntegrationSchemaEvolution — already take this lock inline; this is
2437
+ // the third and fourth (create/update/reactivate) finally taking it too.
2438
+ if (!IntegrationEngine.AcquireMaintenanceLock(companyIntegrationID, 'schema refresh')) {
2439
+ throw new Error(
2440
+ 'Schema refresh not started: a sync or another maintenance operation is currently running for this connection. Retry after it completes.'
2441
+ );
2442
+ }
2443
+ try {
2444
+ return await this.runSchemaRefreshPipelineLocked(companyIntegrationID, user, provider, universalPKConvention, runID);
2445
+ } finally {
2446
+ IntegrationEngine.ReleaseMaintenanceLock(companyIntegrationID);
2447
+ }
2448
+ }
2449
+
2450
+ /** The body of {@link runSchemaRefreshPipeline}, run with the maintenance lock held. */
2451
+ private async runSchemaRefreshPipelineLocked(
2452
+ companyIntegrationID: string,
2453
+ user: UserInfo,
2454
+ provider: IMetadataProvider,
2455
+ universalPKConvention?: string,
2456
+ runID?: string,
2389
2457
  ): Promise<CreateConnectionPipelineSummary> {
2390
2458
  const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user, provider);
2391
2459
  const pipeline = new IntegrationConnectorCreationPipeline();
2460
+ // SoftPKClassifier's LLM tier (universal → naming → statistical → LLM →
2461
+ // synthetic) only runs when this callback is supplied. It used to be
2462
+ // wired by the CompanyIntegration save hook; with that hook gone
2463
+ // (#3738) it is wired here, so the create/update/reactivate paths keep
2464
+ // the same PK-inference quality they had before.
2465
+ const llmInference = await buildIntegrationLLMPKCallback(user);
2392
2466
  const runOpts = {
2393
2467
  Connector: connector,
2394
2468
  CompanyIntegration: companyIntegration,
@@ -2397,6 +2471,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2397
2471
  UniversalPKConvention: universalPKConvention || undefined,
2398
2472
  ConsoleMirror: true,
2399
2473
  TriggerType: 'Manual' as const,
2474
+ LLMInference: llmInference ?? undefined,
2475
+ // Caller-supplied runID: lets the detached path hand a tailable ID back to the client
2476
+ // BEFORE the pipeline has done any work. Omitted ⇒ the pipeline generates its own.
2477
+ RunID: runID,
2400
2478
  };
2401
2479
  const result = await pipeline.Run(runOpts as unknown as Parameters<typeof pipeline.Run>[0]);
2402
2480
 
@@ -2407,6 +2485,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2407
2485
 
2408
2486
  return {
2409
2487
  RunID: result.RunID,
2488
+ InProgress: false,
2489
+ Succeeded: result.Success,
2490
+ FailureMessage: result.FailureMessage,
2410
2491
  ObjectsCreated: result.PersistResult?.ObjectsCreated ?? 0,
2411
2492
  ObjectsUpdated: result.PersistResult?.ObjectsUpdated ?? 0,
2412
2493
  FieldsCreated: result.PersistResult?.FieldsCreated ?? 0,
@@ -2423,6 +2504,94 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2423
2504
  };
2424
2505
  }
2425
2506
 
2507
+ /**
2508
+ * Launches the schema-refresh pipeline WITHOUT awaiting it and returns a summary carrying the
2509
+ * tailable `RunID` and `InProgress: true`.
2510
+ *
2511
+ * Why: the pipeline is a live vendor introspect — minutes on a large catalog (HubSpot: 130
2512
+ * objects). Awaiting it inline holds the create/update mutation open for that entire time, so the
2513
+ * connection wizard's Finish button sits on an indeterminate spinner with no way to tell progress
2514
+ * from a hang. The pipeline already writes a complete, durable, per-run event stream keyed by its
2515
+ * runID; the only thing missing was handing that ID to the caller BEFORE the work starts. Hence
2516
+ * the caller-supplied runID.
2517
+ *
2518
+ * Trade-off the caller opts into: the mutation no longer reports what the refresh found (counts
2519
+ * are placeholder zeros) and a refresh failure no longer surfaces in the mutation's Message — both
2520
+ * live on the run stream instead. That is why `awaitSchemaRefresh` defaults to true.
2521
+ *
2522
+ * ON CREATE this is the only refresh that runs. #3738 removed the activation hook that used to
2523
+ * fire the same pipeline inside `Save()`, awaited — which had made the create mutation pay a full
2524
+ * introspect before ever reaching this method, and had run it BEFORE the connection test, so a
2525
+ * rejected connection was rolled back with its discovered schema already written.
2526
+ *
2527
+ * Belt and braces: if the launch below IS coalesced onto some other run for this connection (a
2528
+ * concurrent refresh, a repeat click), the pipeline publishes a terminal alias run under `runID`
2529
+ * naming the run that served it — so the ID handed back here is tailable in every case.
2530
+ */
2531
+ private startSchemaRefreshPipelineDetached(
2532
+ companyIntegrationID: string,
2533
+ user: UserInfo,
2534
+ provider: IMetadataProvider,
2535
+ universalPKConvention?: string,
2536
+ ): CreateConnectionPipelineSummary {
2537
+ const runID = IntegrationProgressEmitter.newRunID('connector');
2538
+ // Deliberately not awaited. Once the pipeline is running it records every outcome on this
2539
+ // run's artifact stream under `runID` itself — but it can also throw BEFORE it ever
2540
+ // constructs its emitter (connector-driver resolution, a missing Integration row), and in
2541
+ // the blocking path that throw surfaced in the mutation's Message. Detached, the caller has
2542
+ // already been handed `runID` and has nothing but the stream to watch, so a pre-emitter
2543
+ // throw must be published onto that stream — otherwise the run never appears at all and a
2544
+ // tailer waits forever on a run ID that will never produce an event.
2545
+ void this.runSchemaRefreshPipeline(companyIntegrationID, user, provider, universalPKConvention, runID)
2546
+ .catch(err => this.publishDetachedLaunchFailure(runID, companyIntegrationID, err));
2547
+ return {
2548
+ RunID: runID,
2549
+ InProgress: true,
2550
+ // Not known yet — the run has only just been launched.
2551
+ Succeeded: false,
2552
+ ObjectsCreated: 0,
2553
+ ObjectsUpdated: 0,
2554
+ FieldsCreated: 0,
2555
+ FieldsUpdated: 0,
2556
+ UnresolvedObjects: [],
2557
+ PKVerdicts: [],
2558
+ };
2559
+ }
2560
+
2561
+ /**
2562
+ * Terminates a detached run that failed BEFORE the pipeline could open its own artifact stream.
2563
+ *
2564
+ * The pipeline constructs its emitter inside `Run()`, so anything that throws on the way there —
2565
+ * connector-driver resolution (`No connector registered for driver class "X"`), a missing
2566
+ * Integration row, a CompanyIntegration that vanished — produces no run directory at all. In the
2567
+ * blocking path that throw surfaced in the mutation's Message. Detached, the caller has already
2568
+ * been handed the run ID and has only the stream to watch, so the failure has to be published
2569
+ * there or the run is invisible forever.
2570
+ *
2571
+ * Only ever called on the rejection path: if the pipeline got far enough to build its own emitter
2572
+ * it terminates its own run, and a pipeline that completed never reaches here.
2573
+ */
2574
+ private publishDetachedLaunchFailure(runID: string, companyIntegrationID: string, err: unknown): void {
2575
+ const message = this.formatError(err);
2576
+ LogError(`Detached schema refresh (run ${runID}) failed: ${message}`);
2577
+ try {
2578
+ const emitter = new IntegrationProgressEmitter({
2579
+ runID,
2580
+ runKind: 'ConnectorCreation',
2581
+ companyIntegrationID,
2582
+ triggerType: 'Manual',
2583
+ startedAt: new Date().toISOString(),
2584
+ });
2585
+ emitter.runStart('Detached schema refresh launch');
2586
+ emitter.stageError('Launch', message, { code: 'schema-refresh-launch-failed' });
2587
+ void emitter.fail(`Schema refresh could not start: ${message}`, 'schema-refresh-launch-failed')
2588
+ .catch(e => LogError(`Detached schema refresh (run ${runID}): failure artifact write failed — ${e}`));
2589
+ } catch (e) {
2590
+ // Progress reporting must never mask the original failure, which is already logged above.
2591
+ LogError(`Detached schema refresh (run ${runID}): could not open failure artifact — ${e}`);
2592
+ }
2593
+ }
2594
+
2426
2595
  /**
2427
2596
  * Rolls back a freshly created connection by deleting both the CompanyIntegration and Credential records.
2428
2597
  */
@@ -2594,6 +2763,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2594
2763
  @Arg("testConnection", () => Boolean, { defaultValue: false }) testConnection: boolean,
2595
2764
  @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
2765
  @Arg("universalPKConvention", { nullable: true, description: "Optional vendor-wide PK hint (e.g. 'id' for HubSpot). Improves SoftPKClassifier convergence." }) universalPKConvention: string | undefined,
2766
+ @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
2767
  @Ctx() ctx: AppContext
2598
2768
  ): Promise<CreateConnectionOutput> {
2599
2769
  try {
@@ -2616,16 +2786,25 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2616
2786
  const credentialID = credential.ID;
2617
2787
 
2618
2788
  // 2. Create CompanyIntegration linked to the Credential
2619
- const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
2789
+ const ci = await md.GetEntityObject<MJCompanyIntegrationEntityServer>('MJ: Company Integrations', user);
2620
2790
  ci.NewRecord();
2621
2791
  ci.IntegrationID = input.IntegrationID;
2622
2792
  ci.CompanyID = input.CompanyID;
2623
2793
  ci.CredentialID = credentialID;
2624
- ci.IsActive = true;
2794
+ // Created INACTIVE on purpose (#3738). A connection is not marked
2795
+ // active until its credential has had a chance to prove itself in
2796
+ // step 3 below — so nothing downstream ever observes an active row
2797
+ // backed by a password that is about to be rejected and rolled back.
2798
+ ci.IsActive = false;
2625
2799
  ci.Name = input.CredentialName; // Name is required on CompanyIntegration
2626
2800
  if (input.ExternalSystemID) ci.ExternalSystemID = input.ExternalSystemID;
2627
2801
  if (input.Configuration) ci.Configuration = input.Configuration;
2628
2802
 
2803
+ // Whether the refresh runs detached (this resolver owns it and returns a tailable RunID)
2804
+ // or inline below. No save-side refresh exists to coordinate with any more — #3738 removed
2805
+ // the activation hook, so every refresh on this path is one this method asked for.
2806
+ const ownSchemaRefresh = runSchemaRefresh && !awaitSchemaRefresh;
2807
+
2629
2808
  const saved = await ci.Save();
2630
2809
  if (!saved) {
2631
2810
  const validationErrors = ci.LatestResult?.Message || 'Unknown validation error';
@@ -2650,12 +2829,27 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2650
2829
  testMessage = testResult.Message;
2651
2830
  }
2652
2831
 
2832
+ // 3b. Credential has proven itself (or the caller declined a test,
2833
+ // which is the pre-existing default) — activate. Ordering matters:
2834
+ // this save is what makes the connection usable, and it must not
2835
+ // happen while the credential is still unverified.
2836
+ ci.IsActive = true;
2837
+ if (!await ci.Save()) {
2838
+ await this.rollbackCreatedConnection(ci, credential);
2839
+ return {
2840
+ Success: false,
2841
+ Message: `Failed to activate CompanyIntegration: ${ci.LatestResult?.Message || 'Unknown error'}`,
2842
+ };
2843
+ }
2844
+
2653
2845
  // 4. Auto-run schema refresh pipeline (intermittent server-side period).
2654
2846
  // Fires whenever runSchemaRefresh=true, regardless of whether the
2655
2847
  // caller also asked for a test. The wizard may have tested separately
2656
2848
  // and just be hitting Create to save.
2657
2849
  let schemaRefreshSummary: CreateConnectionPipelineSummary | undefined;
2658
- if (runSchemaRefresh) {
2850
+ if (ownSchemaRefresh) {
2851
+ schemaRefreshSummary = this.startSchemaRefreshPipelineDetached(ci.ID, user, md, universalPKConvention);
2852
+ } else if (runSchemaRefresh) {
2659
2853
  try {
2660
2854
  const refreshResult = await this.runSchemaRefreshPipeline(
2661
2855
  ci.ID, user, md, universalPKConvention
@@ -2671,9 +2865,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2671
2865
  if (testConnection || schemaRefreshSummary) {
2672
2866
  return {
2673
2867
  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',
2868
+ Message: BuildCreateConnectionMessage(testConnection, schemaRefreshSummary),
2677
2869
  CompanyIntegrationID: ci.ID,
2678
2870
  CredentialID: credentialID,
2679
2871
  ConnectionTestSuccess: testPassed,
@@ -2706,6 +2898,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2706
2898
  @Arg("testConnection", () => Boolean, { defaultValue: false }) testConnection: boolean,
2707
2899
  @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
2900
  @Arg("universalPKConvention", { nullable: true, description: "Optional vendor-wide PK hint (e.g. 'id' for HubSpot)" }) universalPKConvention: string | undefined,
2901
+ @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
2902
  @Ctx() ctx: AppContext
2710
2903
  ): Promise<MutationResultOutput> {
2711
2904
  try {
@@ -2752,15 +2945,16 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2752
2945
  // Fires whenever runSchemaRefresh=true, regardless of whether the
2753
2946
  // caller also asked for a test — the wizard may have tested separately
2754
2947
  // already and is just hitting Update to save edits.
2948
+ if (runSchemaRefresh && !awaitSchemaRefresh) {
2949
+ const detached = this.startSchemaRefreshPipelineDetached(companyIntegrationID, user, md, universalPKConvention);
2950
+ return { Success: true, Message: BuildDetachedRefreshMessage(detached.RunID) };
2951
+ }
2755
2952
  if (runSchemaRefresh) {
2756
2953
  try {
2757
2954
  const refreshResult = await this.runSchemaRefreshPipeline(
2758
2955
  companyIntegrationID, user, md, universalPKConvention
2759
2956
  );
2760
- return {
2761
- Success: true,
2762
- Message: `Updated, schema refresh: ${refreshResult.ObjectsCreated} created, ${refreshResult.ObjectsUpdated} updated, ${refreshResult.UnresolvedObjects.length} PK-unresolved`,
2763
- };
2957
+ return { Success: true, Message: BuildUpdateConnectionMessage(refreshResult) };
2764
2958
  } catch (refreshErr) {
2765
2959
  LogError(`IntegrationUpdateConnection: pipeline error — ${refreshErr}`);
2766
2960
  return { Success: true, Message: `Updated (schema refresh failed: ${this.formatError(refreshErr)})` };
@@ -2803,6 +2997,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2803
2997
  set('crossLayerPipeline', config.CrossLayerPipeline);
2804
2998
  set('partitionReconcile', config.PartitionReconcile);
2805
2999
  set('discoveryTimeBudgetMs', config.DiscoveryTimeBudgetMs);
3000
+ set('fetchTimeoutMs', config.FetchTimeoutMs);
2806
3001
  set('discoveryBatchSize', config.DiscoveryBatchSize);
2807
3002
  set('discoveryMaxRecords', config.DiscoveryMaxRecords);
2808
3003
  set('deactivateAbsent', config.DeactivateAbsent);
@@ -2858,6 +3053,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2858
3053
  CrossLayerPipeline: bool(cfg.crossLayerPipeline),
2859
3054
  PartitionReconcile: bool(cfg.partitionReconcile),
2860
3055
  DiscoveryTimeBudgetMs: num(cfg.discoveryTimeBudgetMs),
3056
+ FetchTimeoutMs: num(cfg.fetchTimeoutMs),
2861
3057
  DiscoveryBatchSize: num(cfg.discoveryBatchSize),
2862
3058
  DiscoveryMaxRecords: num(cfg.discoveryMaxRecords),
2863
3059
  DeactivateAbsent: bool(cfg.deactivateAbsent),
@@ -2967,10 +3163,49 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2967
3163
 
2968
3164
  /**
2969
3165
  * Reactivates a previously deactivated CompanyIntegration by setting IsActive=true.
3166
+ *
3167
+ * Before #3738 this mutation inherited a full schema refresh from the
3168
+ * CompanyIntegration save hook, with no way to decline it. The refresh is
3169
+ * now this resolver's own, explicit step — visible in the API, suppressible
3170
+ * with `runSchemaRefresh: false`, and (since it gained `awaitSchemaRefresh`)
3171
+ * no longer something the caller has to sit through.
3172
+ *
3173
+ * WHY REACTIVATION NO LONGER SCANS THE SOURCE BY DEFAULT. Resuming a
3174
+ * connection and rescanning its schema are separate decisions that this
3175
+ * mutation used to fuse. A one-click resume would spend minutes of a
3176
+ * vendor's rate budget on an introspect nobody asked for, and the catalog is
3177
+ * usually exactly as current as it was when the connection was paused.
3178
+ * `IntegrationRefreshConnectorSchema` is the operation for "rescan now", and
3179
+ * a caller that genuinely wants both passes `runSchemaRefresh: true`.
3180
+ *
3181
+ * WHY THAT REFRESH, WHEN REQUESTED, DEFAULTS TO DETACHED AND ITS SIBLINGS DO
3182
+ * NOT. Create and Update default to `awaitSchemaRefresh: true` because the
3183
+ * caller is in a wizard, waiting on a form, and the counts ARE the answer
3184
+ * they asked for. Reactivate is a one-click toggle on a connection row, and
3185
+ * — unlike its siblings — its entire durable effect is already committed by
3186
+ * the time any refresh begins: the `Save()` above returned. Blocking the
3187
+ * response for the minutes a live introspect takes cannot make that
3188
+ * reactivation any more true; it can only misreport it.
3189
+ *
3190
+ * And it did. The transport, not this resolver, is what breaks: a held
3191
+ * request dies at the load balancer's fixed ceiling, which the app cannot
3192
+ * configure and which differs by front end, and the client then shows a
3193
+ * resumed connection as a failure. Note the shape of that bug — the two failure paths BELOW are both
3194
+ * handled correctly, returning `Success: true` with the refresh problem
3195
+ * appended, precisely because reactivation already happened. The gateway is
3196
+ * the one caller of this mutation that never reaches them.
3197
+ *
3198
+ * So the default here optimises for the operation actually being requested
3199
+ * (resume this connection) rather than for the operation that happens to be
3200
+ * expensive (rescan the source). The refresh still runs — detached, on a
3201
+ * durable, tailable run stream — and `awaitSchemaRefresh: true` restores the
3202
+ * blocking behaviour for a caller that genuinely wants the counts inline.
2970
3203
  */
2971
3204
  @Mutation(() => MutationResultOutput)
2972
3205
  async IntegrationReactivateConnection(
2973
3206
  @Arg("companyIntegrationID") companyIntegrationID: string,
3207
+ @Arg("runSchemaRefresh", () => Boolean, { defaultValue: false, description: "When false (default), reactivation ONLY reactivates — no live scan of the source. Resuming a connection and rescanning its schema are separate decisions, and a resume should not spend minutes of a vendor's rate budget nobody asked for; run IntegrationRefreshConnectorSchema when the catalog actually needs refreshing. Pass true to re-run IntegrationConnectorCreationPipeline as part of the reactivation." }) runSchemaRefresh: boolean,
3208
+ @Arg("awaitSchemaRefresh", () => Boolean, { defaultValue: false, description: "When false (default), the schema refresh is launched detached and this mutation returns as soon as the connection is active, naming the run to tail. Reactivation is already committed at that point, so blocking on a minutes-long live introspect can only delay — or, at the load balancer's fixed request ceiling, misreport — an operation that has already succeeded. Pass true to block until the refresh finishes and get its counts in the message." }) awaitSchemaRefresh: boolean,
2974
3209
  @Ctx() ctx: AppContext
2975
3210
  ): Promise<MutationResultOutput> {
2976
3211
  try {
@@ -2981,7 +3216,26 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2981
3216
  if (!loaded) return { Success: false, Message: 'CompanyIntegration not found' };
2982
3217
  ci.IsActive = true;
2983
3218
  if (!await ci.Save()) return { Success: false, Message: `Failed to reactivate: ${ci.LatestResult?.Message ?? 'Unknown error'}` };
2984
- return { Success: true, Message: 'Reactivated' };
3219
+
3220
+ if (runSchemaRefresh && !awaitSchemaRefresh) {
3221
+ const detached = this.startSchemaRefreshPipelineDetached(companyIntegrationID, user, md);
3222
+ return { Success: true, Message: BuildReactivateMessage(detached) };
3223
+ }
3224
+
3225
+ if (runSchemaRefresh) {
3226
+ try {
3227
+ const refreshResult = await this.runSchemaRefreshPipeline(companyIntegrationID, user, md);
3228
+ return { Success: true, Message: BuildReactivateMessage(refreshResult) };
3229
+ } catch (refreshErr) {
3230
+ // Refresh failure does NOT undo the reactivation — the
3231
+ // connection is active, only discovery failed, and the
3232
+ // operator can re-run IntegrationRefreshConnectorSchema.
3233
+ LogError(`IntegrationReactivateConnection: pipeline error — ${refreshErr}`);
3234
+ return { Success: true, Message: `Reactivated (schema refresh failed: ${this.formatError(refreshErr)})` };
3235
+ }
3236
+ }
3237
+
3238
+ return { Success: true, Message: BuildReactivateMessage(undefined) };
2985
3239
  } catch (e) {
2986
3240
  LogError(`IntegrationReactivateConnection error: ${e}`);
2987
3241
  return { Success: false, Message: this.formatError(e) };
@@ -3138,15 +3392,22 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3138
3392
  // the Phase 0 v5.39.x Save hook. Fall back to live IntrospectSchema only
3139
3393
  // for direct-API callers bypassing the wizard (empty IO cache).
3140
3394
  const requestedNames = new Set(objects.map(o => o.SourceObjectName));
3395
+ // Declared rows this apply will leave out (deactivated objects/fields) — surfaced on the
3396
+ // mutation's Warnings so a column that never appears has a stated reason.
3397
+ const inactiveWarnings: string[] = [];
3141
3398
  let sourceSchema: SourceSchemaInfo = this.buildSourceSchemaFromPersistedRows(
3142
3399
  companyIntegration.IntegrationID,
3143
3400
  Array.from(requestedNames),
3401
+ inactiveWarnings,
3144
3402
  );
3145
3403
  if (sourceSchema.Objects.length === 0) {
3146
3404
  LogError(`[IntegrationApplySchema] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
3147
3405
  const introspect = connector.IntrospectSchema.bind(connector) as
3148
3406
  (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
3149
3407
  sourceSchema = await introspect(companyIntegration, user);
3408
+ // The persisted rows are not what gets materialized on this path, so anything they
3409
+ // said about deactivated rows describes a schema this apply is not using.
3410
+ inactiveWarnings.length = 0;
3150
3411
  } else {
3151
3412
  console.log(
3152
3413
  `[IntegrationApplySchema] Reusing ${sourceSchema.Objects.length} persisted IOs for ${companyIntegration.Integration} ` +
@@ -3181,6 +3442,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3181
3442
  SkipGitCommit: skipGitCommit,
3182
3443
  SkipRestart: skipRestart,
3183
3444
  });
3445
+ if (inactiveWarnings.length > 0) SchemaOutput.Warnings.unshift(...inactiveWarnings);
3184
3446
 
3185
3447
  return {
3186
3448
  Success: PipelineResult.Success,
@@ -3398,12 +3660,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3398
3660
  );
3399
3661
 
3400
3662
  // 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).
3663
+ // Build per-object field map for the pending payload (null = all fields).
3407
3664
  // resolved.sourceObjects is order-aligned with resolvedNames after the
3408
3665
  // resolveSourceObjectsToNames refactor — pair them directly instead
3409
3666
  // of looking up by ID (which broke for name-only selections).
@@ -3426,7 +3683,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3426
3683
  const hadPriorMaps = priorMapsResult.Success && priorMapsResult.Results.length > 0;
3427
3684
  const effectiveFullSync = input.FullSync ?? !hadPriorMaps;
3428
3685
 
3429
- const pendingPayload = {
3686
+ const pendingPayload: RSUPendingWork = {
3430
3687
  CompanyIntegrationID: input.CompanyIntegrationID,
3431
3688
  SourceObjectNames: resolvedNames,
3432
3689
  SourceObjectFields: sourceObjectFields,
@@ -3435,13 +3692,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3435
3692
  ScheduleTimezone: input.ScheduleTimezone,
3436
3693
  StartSync: input.StartSync,
3437
3694
  FullSync: effectiveFullSync,
3438
- SyncScope: input.SyncScope ?? 'created',
3695
+ SyncScope: input.SyncScope === 'all' ? 'all' : 'created',
3439
3696
  UnselectedAction: (input.UnselectedAction ?? 'disable') as 'disable' | 'ignore',
3440
3697
  CreatedAt: new Date().toISOString(),
3441
3698
  };
3442
- rsuInput.PostRestartFiles = [
3443
- { Path: pendingFilePath, Content: JSON.stringify(pendingPayload, null, 2) }
3444
- ];
3699
+ rsuInput.PendingWork = [pendingPayload];
3700
+ rsuInput.ContextUser = user;
3445
3701
 
3446
3702
  // Step 5: Run pipeline (restart kills process at the end).
3447
3703
  // Retrying variant — an install should not die because the migration hit a dropped
@@ -3454,9 +3710,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3454
3710
  Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
3455
3711
  }));
3456
3712
 
3457
- // If pipeline failed, clean up pending file and return error
3713
+ // Pending work is registered only for migrations that succeeded, so a
3714
+ // failed pipeline has nothing to clean up.
3458
3715
  if (!migrationSucceeded) {
3459
- try { (await import('node:fs')).unlinkSync(pendingFilePath); } catch { /* may not exist */ }
3460
3716
  return {
3461
3717
  Success: false,
3462
3718
  Message: `Pipeline failed: ${batchResult.Results[0]?.ErrorMessage ?? 'unknown error'}`,
@@ -3514,7 +3770,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3514
3770
  }
3515
3771
  }
3516
3772
 
3517
- try { (await import('node:fs')).unlinkSync(pendingFilePath); } catch { /* already consumed */ }
3773
+ // The work just happened inline (no restart), so close out its durable rows.
3774
+ for (const pendingWorkID of batchResult.Results[0]?.PendingWorkIDs ?? []) {
3775
+ await rsm.CompletePendingWork(pendingWorkID, user);
3776
+ }
3518
3777
 
3519
3778
  return {
3520
3779
  Success: true,
@@ -3892,6 +4151,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3892
4151
  // Intacct AND silently dropped selections when the second pass returned
3893
4152
  // fewer objects than the first (rate limits, transient errors).
3894
4153
  let sourceSchema: SourceSchemaInfo;
4154
+ // Declared rows this apply will silently leave out (deactivated objects/fields). Collected
4155
+ // during the rebuild and surfaced on the apply's Warnings, alongside the schema-limit ones.
4156
+ const inactiveWarnings: string[] = [];
3895
4157
  if (prefetchedSourceSchema) {
3896
4158
  sourceSchema = prefetchedSourceSchema;
3897
4159
  } else {
@@ -3899,12 +4161,16 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3899
4161
  sourceSchema = this.buildSourceSchemaFromPersistedRows(
3900
4162
  companyIntegration.IntegrationID,
3901
4163
  requestedNamesForReuse,
4164
+ inactiveWarnings,
3902
4165
  );
3903
4166
  if (sourceSchema.Objects.length === 0) {
3904
4167
  LogError(`[buildSchemaForConnector] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
3905
4168
  const introspect = connector.IntrospectSchema.bind(connector) as
3906
4169
  (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
3907
4170
  sourceSchema = await introspect(companyIntegration, user);
4171
+ // The persisted rows are not what gets materialized on this path, so anything they
4172
+ // said about deactivated rows describes a schema this apply is not using.
4173
+ inactiveWarnings.length = 0;
3908
4174
  } else {
3909
4175
  console.log(
3910
4176
  `[buildSchemaForConnector] Reusing ${sourceSchema.Objects.length} persisted IOs for ${companyIntegration.Integration} ` +
@@ -3947,8 +4213,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3947
4213
 
3948
4214
  const builder = new SchemaBuilder();
3949
4215
  const schemaOutput = builder.BuildSchema(input);
3950
- // Surface the auto-disabled over-wide objects (if any) in the apply's Warnings → CLI output.
4216
+ // Surface the auto-disabled over-wide objects (if any) in the apply's Warnings → CLI output,
4217
+ // together with the declared-but-deactivated rows this apply left out.
3951
4218
  if (limitWarnings.length > 0) schemaOutput.Warnings.unshift(...limitWarnings);
4219
+ if (inactiveWarnings.length > 0) schemaOutput.Warnings.unshift(...inactiveWarnings);
3952
4220
 
3953
4221
  if (schemaOutput.Errors.length > 0) {
3954
4222
  throw new Error(`Schema generation failed: ${schemaOutput.Errors.join('; ')}`);
@@ -4103,6 +4371,60 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4103
4371
  }
4104
4372
  }
4105
4373
 
4374
+ /**
4375
+ * Enqueue a sync for worker execution (PR 1 item 8) instead of running it in this
4376
+ * process. Creates the run row with `Status='Queued'` and returns its ID immediately —
4377
+ * so the caller gets a real, trackable RunID with no fire-and-forget polling window,
4378
+ * and the sync survives this process going away before it starts.
4379
+ *
4380
+ * Requires a process running the integration sync worker (`integrationSyncWorker.enabled`);
4381
+ * without one, rows accumulate in the queue unexecuted.
4382
+ */
4383
+ @Mutation(() => StartSyncOutput)
4384
+ @RequireSystemUser()
4385
+ async IntegrationEnqueueSync(
4386
+ @Arg("companyIntegrationID") companyIntegrationID: string,
4387
+ @Arg("fullSync", () => Boolean, { defaultValue: false, description: 'If true, ignores watermarks and re-fetches all records from the source' }) fullSync: boolean,
4388
+ @Arg("entityMapIDs", () => [String], { nullable: true, description: 'Optional: sync only these entity maps. If omitted, syncs all maps for the connector.' }) entityMapIDs: string[],
4389
+ @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,
4390
+ @Ctx() ctx: AppContext
4391
+ ): Promise<StartSyncOutput> {
4392
+ try {
4393
+ const user = this.getAuthenticatedUser(ctx);
4394
+ const provider = GetReadWriteProvider(ctx.providers) as unknown as IMetadataProvider;
4395
+ await IntegrationEngine.Instance.Config(false, user, provider);
4396
+
4397
+ // Same upfront gates as IntegrationStartSync — refusing here beats queueing work
4398
+ // that the engine will only refuse later, in another process, out of the caller's sight.
4399
+ const ciCheck = await provider.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
4400
+ if (await ciCheck.InnerLoad(CompositeKey.FromID(companyIntegrationID)) && ciCheck.IsActive === false) {
4401
+ return { Success: false, Message: 'Connector is deactivated (IsActive=false); sync not enqueued' };
4402
+ }
4403
+ const maintenance = IntegrationEngine.GetMaintenanceLock(companyIntegrationID);
4404
+ if (maintenance) {
4405
+ return { Success: false, Message: `Sync not enqueued: ${maintenance.Reason} is in progress for this connection (since ${maintenance.AcquiredAt.toISOString()}). Retry after it completes.` };
4406
+ }
4407
+
4408
+ const syncOptions: IntegrationSyncOptions = {};
4409
+ if (fullSync) syncOptions.FullSync = true;
4410
+ if (entityMapIDs?.length) syncOptions.EntityMapIDs = entityMapIDs;
4411
+ if (syncDirection) syncOptions.SyncDirection = syncDirection;
4412
+
4413
+ const runID = await IntegrationEngine.Instance.EnqueueSync(
4414
+ companyIntegrationID,
4415
+ user,
4416
+ 'Manual',
4417
+ Object.keys(syncOptions).length > 0 ? syncOptions : undefined,
4418
+ provider
4419
+ );
4420
+
4421
+ return { Success: true, Message: 'Sync enqueued', RunID: runID };
4422
+ } catch (e) {
4423
+ LogError(`IntegrationEnqueueSync error: ${e}`);
4424
+ return { Success: false, Message: this.formatError(e) };
4425
+ }
4426
+ }
4427
+
4106
4428
  /**
4107
4429
  * Cancels a running sync by marking its status as Cancelled.
4108
4430
  */
@@ -4112,10 +4434,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4112
4434
  @Ctx() ctx: AppContext
4113
4435
  ): Promise<MutationResultOutput> {
4114
4436
  try {
4115
- this.getAuthenticatedUser(ctx);
4437
+ const user = this.getAuthenticatedUser(ctx);
4438
+ const provider = GetReadWriteProvider(ctx.providers) as unknown as IMetadataProvider;
4116
4439
 
4117
- // Signal the engine to abort the running sync
4118
- const cancelled = IntegrationEngine.CancelSync(companyIntegrationID);
4440
+ // DB-backed cancel (durable runs): stamps CancelRequestedAt on the live run row, so
4441
+ // the request reaches the owning process wherever it is — this server, another API
4442
+ // node, or a worker — via its heartbeat/boundary checks. No in-memory signal.
4443
+ const cancelled = await IntegrationEngine.CancelSyncAsync(companyIntegrationID, user, provider);
4119
4444
  if (!cancelled) {
4120
4445
  return { Success: false, Message: 'No active sync found for this connector' };
4121
4446
  }
@@ -4712,8 +5037,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4712
5037
  @Ctx() ctx: AppContext
4713
5038
  ): Promise<OperationProgressOutput> {
4714
5039
  try {
4715
- this.getAuthenticatedUser(ctx);
4716
- const syncProgress = IntegrationEngine.GetSyncProgress(companyIntegrationID);
5040
+ const user = this.getAuthenticatedUser(ctx);
5041
+ const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
5042
+ // DB-backed progress (durable runs): reads ProgressJSON from the live run row, so
5043
+ // progress is visible from ANY process — not just the one executing the sync.
5044
+ const syncProgress = await IntegrationEngine.GetSyncProgressAsync(companyIntegrationID, user, provider);
4717
5045
  if (syncProgress) {
4718
5046
  return {
4719
5047
  Success: true,
@@ -5236,18 +5564,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5236
5564
  connInput.CompanyIntegrationID, objects, validatedPlatform, user, skipGitCommit, skipRestart, provider, sourceSchema
5237
5565
  );
5238
5566
 
5239
- // Build per-object field map for pending file
5567
+ // Build per-object field map for the pending payload
5240
5568
  const sourceObjectFields: Record<string, string[] | null> = {};
5241
5569
  for (const name of resolvedNames) {
5242
5570
  sourceObjectFields[name] = fieldsByName.get(name.toLowerCase()) ?? null;
5243
5571
  }
5244
5572
 
5245
5573
  // 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 = {
5574
+ const pendingPayload: RSUPendingWork = {
5251
5575
  CompanyIntegrationID: connInput.CompanyIntegrationID,
5252
5576
  SourceObjectNames: resolvedNames,
5253
5577
  SourceObjectFields: sourceObjectFields,
@@ -5256,15 +5580,15 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5256
5580
  ScheduleTimezone: connInput.ScheduleTimezone,
5257
5581
  StartSync: input.StartSync,
5258
5582
  FullSync: input.FullSync ?? false,
5259
- SyncScope: input.SyncScope ?? 'created',
5260
- SyncDirection: input.SyncDirection,
5261
- ScheduleSyncDirection: input.ScheduleSyncDirection,
5583
+ SyncScope: input.SyncScope === 'all' ? 'all' : 'created',
5584
+ // Unrecognized directions fall back to undefined = "use the entity map's own SyncDirection"
5585
+ SyncDirection: input.SyncDirection && isValidSyncDirection(input.SyncDirection) ? input.SyncDirection : undefined,
5586
+ ScheduleSyncDirection: input.ScheduleSyncDirection && isValidSyncDirection(input.ScheduleSyncDirection) ? input.ScheduleSyncDirection : undefined,
5262
5587
  UnselectedAction: (input.UnselectedAction ?? 'disable') as 'disable' | 'ignore',
5263
5588
  CreatedAt: new Date().toISOString(),
5264
5589
  };
5265
- rsuInput.PostRestartFiles = [
5266
- { Path: pendingFilePath, Content: JSON.stringify(pendingPayload, null, 2) }
5267
- ];
5590
+ rsuInput.PendingWork = [pendingPayload];
5591
+ rsuInput.ContextUser = user;
5268
5592
 
5269
5593
  return {
5270
5594
  connInput,
@@ -5274,7 +5598,6 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5274
5598
  objects,
5275
5599
  schemaOutput,
5276
5600
  rsuInput,
5277
- pendingFilePath,
5278
5601
  };
5279
5602
  })
5280
5603
  );
@@ -5288,7 +5611,6 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5288
5611
  objects: SchemaPreviewObjectInput[];
5289
5612
  schemaOutput: SchemaBuilderOutput;
5290
5613
  rsuInput: RSUPipelineInput;
5291
- pendingFilePath: string;
5292
5614
  }> = [];
5293
5615
  const connectorResults: ApplyAllBatchConnectorResult[] = [];
5294
5616
 
@@ -5340,8 +5662,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5340
5662
  Message: pipelineResult?.ErrorMessage ?? 'Pipeline failed',
5341
5663
  Warnings: build.schemaOutput.Warnings.length > 0 ? build.schemaOutput.Warnings : undefined,
5342
5664
  });
5343
- // Clean up pending file on failure
5344
- try { (await import('node:fs')).unlinkSync(build.pendingFilePath); } catch { /* may not exist */ }
5665
+ // No pending-work cleanup needed — rows are registered only for
5666
+ // migrations that succeeded.
5345
5667
  continue;
5346
5668
  }
5347
5669
 
@@ -5388,10 +5710,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5388
5710
  if (scheduleResult) connResult.ScheduledJobID = scheduleResult;
5389
5711
  }
5390
5712
 
5391
- // Clean up pending file
5392
- try { (await import('node:fs')).unlinkSync(build.pendingFilePath); } catch { /* already consumed */ }
5713
+ // The work just happened inline (no restart), so close out its durable rows.
5714
+ for (const pendingWorkID of pipelineResult.PendingWorkIDs ?? []) {
5715
+ await rsm.CompletePendingWork(pendingWorkID, user);
5716
+ }
5393
5717
 
5394
- connResult.Message = `Applied ${build.objects.length} object(s) — ${entityMapsCreated.length} entity maps created${syncRunID ? ', sync started' : ''}`;
5718
+ connResult.Message =`Applied ${build.objects.length} object(s) — ${entityMapsCreated.length} entity maps created${syncRunID ? ', sync started' : ''}`;
5395
5719
  }
5396
5720
 
5397
5721
  connectorResults.push(connResult);
@@ -5958,26 +6282,21 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5958
6282
  // ApplyAll uses. default: created DISABLED (user enables after the refresh);
5959
6283
  // autoEnableNewObjects opts in. Removal was already handled in Phase 3 → 'ignore'.
5960
6284
  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 ?? []),
6285
+ rsuInput.PendingWork = [
6286
+ ...(rsuInput.PendingWork ?? []),
5966
6287
  {
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),
6288
+ CompanyIntegrationID: companyIntegrationID,
6289
+ SourceObjectNames: newObjects,
6290
+ SourceObjectFields: Object.fromEntries(newObjects.map(n => [n, null])),
6291
+ SchemaName: schemaName,
6292
+ StartSync: false,
6293
+ SyncScope: 'created',
6294
+ UnselectedAction: 'ignore',
6295
+ CreateDisabled: !autoEnableNewObjects,
6296
+ CreatedAt: new Date().toISOString(),
5979
6297
  },
5980
6298
  ];
6299
+ rsuInput.ContextUser = user;
5981
6300
  }
5982
6301
 
5983
6302
  const rsm = RuntimeSchemaManager.Instance;