@memberjunction/server 6.2.0-edge.6 → 6.2.0-edge.8

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 (463) hide show
  1. package/README.md +69 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/BridgeHostInstance.d.ts +8 -3
  4. package/dist/agentSessions/BridgeHostInstance.d.ts.map +1 -1
  5. package/dist/agentSessions/BridgeHostInstance.js +14 -4
  6. package/dist/agentSessions/BridgeHostInstance.js.map +1 -1
  7. package/dist/agentSessions/HandoffOfferExpiry.d.ts +10 -0
  8. package/dist/agentSessions/HandoffOfferExpiry.d.ts.map +1 -0
  9. package/dist/agentSessions/HandoffOfferExpiry.js +18 -0
  10. package/dist/agentSessions/HandoffOfferExpiry.js.map +1 -0
  11. package/dist/agentSessions/ReturningVisitorRecap.js +1 -0
  12. package/dist/agentSessions/ReturningVisitorRecap.js.map +1 -1
  13. package/dist/agentSessions/SessionJanitor.d.ts +31 -8
  14. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  15. package/dist/agentSessions/SessionJanitor.js +85 -41
  16. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  17. package/dist/agentSessions/SessionManager.d.ts +14 -4
  18. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  19. package/dist/agentSessions/SessionManager.js +28 -4
  20. package/dist/agentSessions/SessionManager.js.map +1 -1
  21. package/dist/agentSessions/conversationBrowsers.d.ts +61 -0
  22. package/dist/agentSessions/conversationBrowsers.d.ts.map +1 -0
  23. package/dist/agentSessions/conversationBrowsers.js +118 -0
  24. package/dist/agentSessions/conversationBrowsers.js.map +1 -0
  25. package/dist/agentSessions/sessionDeadline.d.ts +43 -0
  26. package/dist/agentSessions/sessionDeadline.d.ts.map +1 -0
  27. package/dist/agentSessions/sessionDeadline.js +68 -0
  28. package/dist/agentSessions/sessionDeadline.js.map +1 -0
  29. package/dist/auth/APIKeyScopeAuth.d.ts.map +1 -1
  30. package/dist/auth/APIKeyScopeAuth.js +5 -4
  31. package/dist/auth/APIKeyScopeAuth.js.map +1 -1
  32. package/dist/auth/loginEmailResolution.d.ts +41 -0
  33. package/dist/auth/loginEmailResolution.d.ts.map +1 -0
  34. package/dist/auth/loginEmailResolution.js +47 -0
  35. package/dist/auth/loginEmailResolution.js.map +1 -0
  36. package/dist/auth/magicLink/MagicLinkKeys.d.ts +50 -2
  37. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
  38. package/dist/auth/magicLink/MagicLinkKeys.js +129 -12
  39. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
  40. package/dist/auth/magicLink/MagicLinkRouter.d.ts +3 -0
  41. package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
  42. package/dist/auth/magicLink/MagicLinkRouter.js +23 -5
  43. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  44. package/dist/auth/magicLink/MagicLinkService.d.ts +39 -5
  45. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  46. package/dist/auth/magicLink/MagicLinkService.js +221 -9
  47. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  48. package/dist/auth/magicLink/index.d.ts +1 -1
  49. package/dist/auth/magicLink/index.d.ts.map +1 -1
  50. package/dist/auth/magicLink/index.js +1 -1
  51. package/dist/auth/magicLink/index.js.map +1 -1
  52. package/dist/auth/magicLink/redeemLanding.d.ts +2 -2
  53. package/dist/auth/magicLink/redeemLanding.d.ts.map +1 -1
  54. package/dist/auth/magicLink/redeemLanding.js +7 -4
  55. package/dist/auth/magicLink/redeemLanding.js.map +1 -1
  56. package/dist/auth/magicLink/types.d.ts +27 -3
  57. package/dist/auth/magicLink/types.d.ts.map +1 -1
  58. package/dist/auth/magicLink/types.js +0 -4
  59. package/dist/auth/magicLink/types.js.map +1 -1
  60. package/dist/auth/newUsers.d.ts.map +1 -1
  61. package/dist/auth/newUsers.js +1 -0
  62. package/dist/auth/newUsers.js.map +1 -1
  63. package/dist/backgroundSweepConfigUnits.d.ts +231 -0
  64. package/dist/backgroundSweepConfigUnits.d.ts.map +1 -0
  65. package/dist/backgroundSweepConfigUnits.js +180 -0
  66. package/dist/backgroundSweepConfigUnits.js.map +1 -0
  67. package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.d.ts.map +1 -1
  68. package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.js +2 -1
  69. package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.js.map +1 -1
  70. package/dist/config.d.ts +906 -8
  71. package/dist/config.d.ts.map +1 -1
  72. package/dist/config.js +59 -0
  73. package/dist/config.js.map +1 -1
  74. package/dist/context.d.ts +2 -0
  75. package/dist/context.d.ts.map +1 -1
  76. package/dist/context.js +41 -12
  77. package/dist/context.js.map +1 -1
  78. package/dist/coreRoutes.d.ts +3 -1
  79. package/dist/coreRoutes.d.ts.map +1 -1
  80. package/dist/coreRoutes.js +6 -4
  81. package/dist/coreRoutes.js.map +1 -1
  82. package/dist/generated/graphql-schemas/__mj.d.ts +1072 -5
  83. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  84. package/dist/generated/graphql-schemas/__mj.js +10174 -4618
  85. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  86. package/dist/generic/OrphanedConversationDetailReconciler.d.ts +11 -0
  87. package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -1
  88. package/dist/generic/OrphanedConversationDetailReconciler.js +27 -1
  89. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -1
  90. package/dist/generic/ResolverBase.d.ts +50 -5
  91. package/dist/generic/ResolverBase.d.ts.map +1 -1
  92. package/dist/generic/ResolverBase.js +329 -132
  93. package/dist/generic/ResolverBase.js.map +1 -1
  94. package/dist/generic/RunViewResolver.d.ts +4 -0
  95. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  96. package/dist/generic/RunViewResolver.js +47 -7
  97. package/dist/generic/RunViewResolver.js.map +1 -1
  98. package/dist/globalCors.d.ts.map +1 -1
  99. package/dist/globalCors.js +1 -0
  100. package/dist/globalCors.js.map +1 -1
  101. package/dist/index.d.ts +3 -0
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +201 -43
  104. package/dist/index.js.map +1 -1
  105. package/dist/integration/ConnectionPause.d.ts +121 -0
  106. package/dist/integration/ConnectionPause.d.ts.map +1 -0
  107. package/dist/integration/ConnectionPause.js +183 -0
  108. package/dist/integration/ConnectionPause.js.map +1 -0
  109. package/dist/integration/CustomColumnPromoter.d.ts +72 -6
  110. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  111. package/dist/integration/CustomColumnPromoter.js +87 -46
  112. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  113. package/dist/integration/EntityMapLifecycle.d.ts +78 -4
  114. package/dist/integration/EntityMapLifecycle.d.ts.map +1 -1
  115. package/dist/integration/EntityMapLifecycle.js +199 -5
  116. package/dist/integration/EntityMapLifecycle.js.map +1 -1
  117. package/dist/integration/InactiveRowWarnings.d.ts +31 -0
  118. package/dist/integration/InactiveRowWarnings.d.ts.map +1 -1
  119. package/dist/integration/InactiveRowWarnings.js +29 -0
  120. package/dist/integration/InactiveRowWarnings.js.map +1 -1
  121. package/dist/integration/ObjectNamesByID.d.ts +28 -0
  122. package/dist/integration/ObjectNamesByID.d.ts.map +1 -0
  123. package/dist/integration/ObjectNamesByID.js +87 -0
  124. package/dist/integration/ObjectNamesByID.js.map +1 -0
  125. package/dist/integration/PostRestartIntrospect.d.ts +26 -0
  126. package/dist/integration/PostRestartIntrospect.d.ts.map +1 -0
  127. package/dist/integration/PostRestartIntrospect.js +27 -0
  128. package/dist/integration/PostRestartIntrospect.js.map +1 -0
  129. package/dist/integration/RSUPostRestartProgress.d.ts +176 -0
  130. package/dist/integration/RSUPostRestartProgress.d.ts.map +1 -0
  131. package/dist/integration/RSUPostRestartProgress.js +323 -0
  132. package/dist/integration/RSUPostRestartProgress.js.map +1 -0
  133. package/dist/integration/RSUProgressBridge.d.ts +41 -2
  134. package/dist/integration/RSUProgressBridge.d.ts.map +1 -1
  135. package/dist/integration/RSUProgressBridge.js +84 -1
  136. package/dist/integration/RSUProgressBridge.js.map +1 -1
  137. package/dist/integration/RunArtifactAuthorization.d.ts +39 -0
  138. package/dist/integration/RunArtifactAuthorization.d.ts.map +1 -0
  139. package/dist/integration/RunArtifactAuthorization.js +45 -0
  140. package/dist/integration/RunArtifactAuthorization.js.map +1 -0
  141. package/dist/integration/SchemaRefreshLaunch.d.ts +9 -0
  142. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -1
  143. package/dist/integration/SchemaRefreshLaunch.js +3 -2
  144. package/dist/integration/SchemaRefreshLaunch.js.map +1 -1
  145. package/dist/integration/ScopedFieldsForBuild.d.ts +46 -0
  146. package/dist/integration/ScopedFieldsForBuild.d.ts.map +1 -0
  147. package/dist/integration/ScopedFieldsForBuild.js +91 -0
  148. package/dist/integration/ScopedFieldsForBuild.js.map +1 -0
  149. package/dist/integration/TargetConfigGuard.d.ts +40 -0
  150. package/dist/integration/TargetConfigGuard.d.ts.map +1 -0
  151. package/dist/integration/TargetConfigGuard.js +40 -0
  152. package/dist/integration/TargetConfigGuard.js.map +1 -0
  153. package/dist/logging/StartupLogger.d.ts +50 -0
  154. package/dist/logging/StartupLogger.d.ts.map +1 -1
  155. package/dist/logging/StartupLogger.js +15 -22
  156. package/dist/logging/StartupLogger.js.map +1 -1
  157. package/dist/middleware/BaseServerMiddleware.d.ts +21 -3
  158. package/dist/middleware/BaseServerMiddleware.d.ts.map +1 -1
  159. package/dist/middleware/BaseServerMiddleware.js +4 -2
  160. package/dist/middleware/BaseServerMiddleware.js.map +1 -1
  161. package/dist/realtimeSessions/ownedRealtimeSession.d.ts.map +1 -1
  162. package/dist/realtimeSessions/ownedRealtimeSession.js +2 -3
  163. package/dist/realtimeSessions/ownedRealtimeSession.js.map +1 -1
  164. package/dist/realtimeSessions/sessionAccess.d.ts +15 -0
  165. package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -1
  166. package/dist/realtimeSessions/sessionAccess.js +22 -0
  167. package/dist/realtimeSessions/sessionAccess.js.map +1 -1
  168. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
  169. package/dist/realtimeWidget/WidgetSessionService.js +2 -0
  170. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  171. package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
  172. package/dist/realtimeWidget/visitorIdentity.js +1 -0
  173. package/dist/realtimeWidget/visitorIdentity.js.map +1 -1
  174. package/dist/realtimeWidget/widgetCore.d.ts.map +1 -1
  175. package/dist/realtimeWidget/widgetCore.js +4 -7
  176. package/dist/realtimeWidget/widgetCore.js.map +1 -1
  177. package/dist/realtimeWidget/widgetGuestElevation.js +1 -0
  178. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  179. package/dist/requestOrigin.d.ts +22 -0
  180. package/dist/requestOrigin.d.ts.map +1 -0
  181. package/dist/requestOrigin.js +60 -0
  182. package/dist/requestOrigin.js.map +1 -0
  183. package/dist/resolvers/AgentRunStatusPublisher.d.ts +10 -0
  184. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -1
  185. package/dist/resolvers/AgentRunStatusPublisher.js +18 -2
  186. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -1
  187. package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -1
  188. package/dist/resolvers/AgentSessionResolver.js +6 -2
  189. package/dist/resolvers/AgentSessionResolver.js.map +1 -1
  190. package/dist/resolvers/AutotagPipelineResolver.d.ts +1 -1
  191. package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -1
  192. package/dist/resolvers/AutotagPipelineResolver.js +61 -16
  193. package/dist/resolvers/AutotagPipelineResolver.js.map +1 -1
  194. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -1
  195. package/dist/resolvers/ConversationRunEventsResolver.js +2 -0
  196. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -1
  197. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +6 -0
  198. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
  199. package/dist/resolvers/ExecuteRemoteOperationResolver.js +30 -7
  200. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
  201. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  202. package/dist/resolvers/GetDataResolver.js +34 -13
  203. package/dist/resolvers/GetDataResolver.js.map +1 -1
  204. package/dist/resolvers/InfoResolver.js +1 -1
  205. package/dist/resolvers/InfoResolver.js.map +1 -1
  206. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +401 -11
  207. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  208. package/dist/resolvers/IntegrationDiscoveryResolver.js +2690 -1305
  209. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  210. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  211. package/dist/resolvers/MCPResolver.js +13 -2
  212. package/dist/resolvers/MCPResolver.js.map +1 -1
  213. package/dist/resolvers/QuerySystemUserResolver.d.ts +38 -1
  214. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  215. package/dist/resolvers/QuerySystemUserResolver.js +181 -88
  216. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  217. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  218. package/dist/resolvers/RealtimeBridgeResolver.js +1 -0
  219. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  220. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +6 -0
  221. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  222. package/dist/resolvers/RealtimeClientSessionResolver.js +36 -5
  223. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  224. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +62 -10
  225. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  226. package/dist/resolvers/RemoteBrowserActionResolver.js +217 -34
  227. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  228. package/dist/resolvers/RunAIAgentResolver.d.ts +24 -8
  229. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  230. package/dist/resolvers/RunAIAgentResolver.js +185 -67
  231. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  232. package/dist/resolvers/RunAIPromptResolver.d.ts +5 -3
  233. package/dist/resolvers/RunAIPromptResolver.d.ts.map +1 -1
  234. package/dist/resolvers/RunAIPromptResolver.js +19 -18
  235. package/dist/resolvers/RunAIPromptResolver.js.map +1 -1
  236. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  237. package/dist/resolvers/RunTemplateResolver.js +3 -1
  238. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  239. package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
  240. package/dist/resolvers/SqlLoggingConfigResolver.js +7 -8
  241. package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
  242. package/dist/resolvers/TagGovernanceResolver.d.ts +12 -0
  243. package/dist/resolvers/TagGovernanceResolver.d.ts.map +1 -1
  244. package/dist/resolvers/TagGovernanceResolver.js +84 -0
  245. package/dist/resolvers/TagGovernanceResolver.js.map +1 -1
  246. package/dist/resolvers/TelemetryResolver.d.ts +13 -8
  247. package/dist/resolvers/TelemetryResolver.d.ts.map +1 -1
  248. package/dist/resolvers/TelemetryResolver.js +40 -9
  249. package/dist/resolvers/TelemetryResolver.js.map +1 -1
  250. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
  251. package/dist/resolvers/meetingRecordingRegistration.js +2 -0
  252. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
  253. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -1
  254. package/dist/resolvers/meetings/meetingMutations.js +34 -4
  255. package/dist/resolvers/meetings/meetingMutations.js.map +1 -1
  256. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  257. package/dist/rest/MediaAccessKeys.js +6 -2
  258. package/dist/rest/MediaAccessKeys.js.map +1 -1
  259. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  260. package/dist/rest/OAuthCallbackHandler.js +11 -5
  261. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  262. package/dist/rest/RealtimeRecordingHandler.d.ts +3 -3
  263. package/dist/rest/RealtimeRecordingHandler.d.ts.map +1 -1
  264. package/dist/rest/RealtimeRecordingHandler.js +2 -4
  265. package/dist/rest/RealtimeRecordingHandler.js.map +1 -1
  266. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  267. package/dist/rest/ViewOperationsHandler.js +2 -1
  268. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  269. package/dist/rest/setupRESTEndpoints.d.ts +3 -1
  270. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
  271. package/dist/rest/setupRESTEndpoints.js +11 -2
  272. package/dist/rest/setupRESTEndpoints.js.map +1 -1
  273. package/dist/services/DurableEntityActionTaskSubmitter.d.ts.map +1 -1
  274. package/dist/services/DurableEntityActionTaskSubmitter.js +6 -1
  275. package/dist/services/DurableEntityActionTaskSubmitter.js.map +1 -1
  276. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  277. package/dist/services/TaskGraphActionRunner.js +2 -26
  278. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  279. package/dist/services/TaskGraphAgentRunner.d.ts +6 -0
  280. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  281. package/dist/services/TaskGraphAgentRunner.js +38 -0
  282. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  283. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  284. package/dist/services/TaskGraphContinuationDeliverer.js +1 -0
  285. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  286. package/dist/services/WorkQueueHostService.d.ts +2 -1
  287. package/dist/services/WorkQueueHostService.d.ts.map +1 -1
  288. package/dist/services/WorkQueueHostService.js +19 -4
  289. package/dist/services/WorkQueueHostService.js.map +1 -1
  290. package/dist/services/workQueueConfig.d.ts +2 -2
  291. package/dist/services/workQueueConfig.js +5 -5
  292. package/dist/services/workQueueConfig.js.map +1 -1
  293. package/dist/sharedCache.js +1 -1
  294. package/dist/sharedCache.js.map +1 -1
  295. package/dist/types.d.ts +17 -0
  296. package/dist/types.d.ts.map +1 -1
  297. package/dist/types.js.map +1 -1
  298. package/dist/util.d.ts.map +1 -1
  299. package/dist/util.js +55 -2
  300. package/dist/util.js.map +1 -1
  301. package/package.json +107 -100
  302. package/src/__tests__/APIKeyScopeAuth.test.ts +4 -8
  303. package/src/__tests__/ActiveOperations.test.ts +68 -0
  304. package/src/__tests__/AgentSessionResolver.ownership.test.ts +13 -0
  305. package/src/__tests__/AssertTargetConfigs.test.ts +75 -0
  306. package/src/__tests__/BatchApplyParity.test.ts +109 -0
  307. package/src/__tests__/BridgeHostInstance.test.ts +42 -0
  308. package/src/__tests__/CollectInactiveRowWarnings.test.ts +115 -0
  309. package/src/__tests__/ConnectionPause.test.ts +192 -0
  310. package/src/__tests__/ConnectionPauseResolver.test.ts +166 -0
  311. package/src/__tests__/ConnectionPauseSave.test.ts +143 -0
  312. package/src/__tests__/CustomColumnPromoterCompletion.test.ts +142 -0
  313. package/src/__tests__/DeleteConnectionRemovesPerConnectionCatalog.test.ts +51 -0
  314. package/src/__tests__/DetachedDiscoveryStart.test.ts +65 -0
  315. package/src/__tests__/DurableEntityActionTaskSubmitter.test.ts +73 -0
  316. package/src/__tests__/EntityMapLifecycle.rekey.test.ts +252 -0
  317. package/src/__tests__/ExecuteRemoteOperationResolver.test.ts +213 -0
  318. package/src/__tests__/HandoffOfferExpiry.test.ts +40 -0
  319. package/src/__tests__/IntegrationRunArtifactAuthorization.test.ts +126 -0
  320. package/src/__tests__/MagicLinkKeys.test.ts +102 -0
  321. package/src/__tests__/MediaAccessKeys.test.ts +57 -10
  322. package/src/__tests__/NewArrivalsDefaultDisabled.test.ts +50 -0
  323. package/src/__tests__/ObjectNamesByID.test.ts +176 -0
  324. package/src/__tests__/PostRestartMapsInCatalogScope.test.ts +146 -0
  325. package/src/__tests__/QuerySystemUserResolver.childRows.test.ts +80 -0
  326. package/src/__tests__/QuerySystemUserResolver.collision.test.ts +200 -0
  327. package/src/__tests__/QuerySystemUserResolver.permissions.test.ts +132 -0
  328. package/src/__tests__/RSUPostRestartProgress.test.ts +343 -0
  329. package/src/__tests__/RSUProgressBridge.test.ts +188 -5
  330. package/src/__tests__/RSUStartupSweepWiring.test.ts +30 -0
  331. package/src/__tests__/ReactivateRefreshMessage.test.ts +10 -3
  332. package/src/__tests__/RealtimeClientSessionResolver.test.ts +22 -0
  333. package/src/__tests__/{RefreshAdoptsNewColumns.test.ts → RefreshReportsNewColumns.test.ts} +25 -16
  334. package/src/__tests__/RemoteBrowserActionRecovery.test.ts +287 -0
  335. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +3 -0
  336. package/src/__tests__/RemoteBrowserActionResolver.ownership.test.ts +6 -4
  337. package/src/__tests__/RemoteBrowserAudioStream.test.ts +3 -0
  338. package/src/__tests__/RemoteBrowserSnapshot.test.ts +3 -0
  339. package/src/__tests__/ResolverBase.clientOldValues.test.ts +327 -26
  340. package/src/__tests__/ResolverBase.diagnosticSenderRegistrations.test.ts +26 -0
  341. package/src/__tests__/ResolverBase.encryptedApiZeroReencrypt.test.ts +307 -0
  342. package/src/__tests__/ResolverBase.entityMessageRelay.test.ts +53 -2
  343. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +60 -0
  344. package/src/__tests__/RunAIAgentResolver.branch.test.ts +4 -0
  345. package/src/__tests__/RunAIAgentResolver.consultPredecessor.test.ts +289 -0
  346. package/src/__tests__/RunAIAgentResolver.handoff.test.ts +649 -0
  347. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +2 -0
  348. package/src/__tests__/RunAIAgentResolver.runDataGuard.test.ts +2 -0
  349. package/src/__tests__/RunAIAgentResolver.stoppedPredecessor.test.ts +87 -12
  350. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +28 -4
  351. package/src/__tests__/SchemaEvolutionCustomColumns.test.ts +119 -0
  352. package/src/__tests__/SchemaRefreshLaunch.test.ts +24 -2
  353. package/src/__tests__/ScopedFieldsForBuild.test.ts +192 -0
  354. package/src/__tests__/SessionJanitor.test.ts +195 -4
  355. package/src/__tests__/SessionManager.test.ts +69 -0
  356. package/src/__tests__/TagGovernanceAndAutotagResolvers.test.ts +261 -0
  357. package/src/__tests__/TagGovernanceResolver.test.ts +60 -2
  358. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -2
  359. package/src/__tests__/TaskGraphAgentRunner.test.ts +272 -16
  360. package/src/__tests__/ViewOperationsHandler.test.ts +2 -5
  361. package/src/__tests__/WorkQueueHostService.test.ts +25 -7
  362. package/src/__tests__/backgroundSweepConfigUnits.test.ts +153 -0
  363. package/src/__tests__/contextRequestOrigin.test.ts +105 -0
  364. package/src/__tests__/conversationBrowsers.test.ts +62 -0
  365. package/src/__tests__/globalCors.test.ts +12 -0
  366. package/src/__tests__/magicLinkExtensionCapabilities.test.ts +413 -0
  367. package/src/__tests__/orphanedConversationDetailReconciler.start.test.ts +82 -0
  368. package/src/__tests__/ownedRealtimeSession.test.ts +14 -0
  369. package/src/__tests__/realtimeRecordingHandler.test.ts +7 -2
  370. package/src/__tests__/realtimeSessions/sessionAccess.test.ts +79 -1
  371. package/src/__tests__/requestOrigin.test.ts +104 -0
  372. package/src/__tests__/review-capture-valid-token-log.test.ts +98 -0
  373. package/src/__tests__/runStreams.fieldSecurity.test.ts +5 -5
  374. package/src/__tests__/securityHardeningPr6034.test.ts +120 -0
  375. package/src/__tests__/sessionDeadline.test.ts +51 -0
  376. package/src/__tests__/sharedCache.test.ts +11 -0
  377. package/src/__tests__/unifiedAuth.test.ts +73 -1
  378. package/src/agentSessions/BridgeHostInstance.ts +20 -5
  379. package/src/agentSessions/HandoffOfferExpiry.ts +18 -0
  380. package/src/agentSessions/ReturningVisitorRecap.ts +1 -0
  381. package/src/agentSessions/SessionJanitor.ts +92 -42
  382. package/src/agentSessions/SessionManager.ts +38 -8
  383. package/src/agentSessions/conversationBrowsers.ts +143 -0
  384. package/src/agentSessions/sessionDeadline.ts +81 -0
  385. package/src/auth/APIKeyScopeAuth.ts +5 -4
  386. package/src/auth/__tests__/loginEmailResolution.test.ts +67 -0
  387. package/src/auth/loginEmailResolution.ts +67 -0
  388. package/src/auth/magicLink/MagicLinkKeys.ts +146 -14
  389. package/src/auth/magicLink/MagicLinkRouter.ts +33 -6
  390. package/src/auth/magicLink/MagicLinkService.ts +250 -10
  391. package/src/auth/magicLink/index.ts +1 -1
  392. package/src/auth/magicLink/redeemLanding.ts +7 -4
  393. package/src/auth/magicLink/types.ts +28 -3
  394. package/src/auth/newUsers.ts +1 -0
  395. package/src/backgroundSweepConfigUnits.ts +213 -0
  396. package/src/broadcast/BaseCacheInvalidationBroadcastProvider.ts +2 -1
  397. package/src/config.ts +69 -0
  398. package/src/context.ts +46 -15
  399. package/src/coreRoutes.ts +14 -7
  400. package/src/generated/graphql-schemas/__mj.ts +10360 -6479
  401. package/src/generic/OrphanedConversationDetailReconciler.ts +32 -1
  402. package/src/generic/ResolverBase.ts +422 -143
  403. package/src/generic/RunViewResolver.ts +96 -49
  404. package/src/globalCors.ts +1 -0
  405. package/src/index.ts +246 -44
  406. package/src/integration/ConnectionPause.ts +222 -0
  407. package/src/integration/CustomColumnPromoter.ts +123 -53
  408. package/src/integration/EntityMapLifecycle.ts +229 -6
  409. package/src/integration/InactiveRowWarnings.ts +50 -0
  410. package/src/integration/ObjectNamesByID.ts +97 -0
  411. package/src/integration/PostRestartIntrospect.ts +49 -0
  412. package/src/integration/RSUPostRestartProgress.ts +359 -0
  413. package/src/integration/RSUProgressBridge.ts +85 -2
  414. package/src/integration/RunArtifactAuthorization.ts +65 -0
  415. package/src/integration/SchemaRefreshLaunch.ts +12 -2
  416. package/src/integration/ScopedFieldsForBuild.ts +113 -0
  417. package/src/integration/TargetConfigGuard.ts +71 -0
  418. package/src/logging/StartupLogger.ts +24 -0
  419. package/src/middleware/BaseServerMiddleware.ts +20 -3
  420. package/src/realtimeSessions/ownedRealtimeSession.ts +2 -6
  421. package/src/realtimeSessions/sessionAccess.ts +30 -0
  422. package/src/realtimeWidget/WidgetSessionService.ts +2 -0
  423. package/src/realtimeWidget/visitorIdentity.ts +1 -0
  424. package/src/realtimeWidget/widgetCore.ts +4 -8
  425. package/src/realtimeWidget/widgetGuestElevation.ts +1 -0
  426. package/src/requestOrigin.ts +64 -0
  427. package/src/resolvers/AgentRunStatusPublisher.ts +27 -2
  428. package/src/resolvers/AgentSessionResolver.ts +6 -2
  429. package/src/resolvers/AutotagPipelineResolver.ts +59 -14
  430. package/src/resolvers/ConversationRunEventsResolver.ts +2 -0
  431. package/src/resolvers/ExecuteRemoteOperationResolver.ts +41 -9
  432. package/src/resolvers/GetDataResolver.ts +35 -13
  433. package/src/resolvers/InfoResolver.ts +1 -1
  434. package/src/resolvers/IntegrationDiscoveryResolver.ts +1522 -203
  435. package/src/resolvers/MCPResolver.ts +15 -2
  436. package/src/resolvers/QuerySystemUserResolver.ts +211 -100
  437. package/src/resolvers/RealtimeBridgeResolver.ts +1 -0
  438. package/src/resolvers/RealtimeClientSessionResolver.ts +40 -7
  439. package/src/resolvers/RemoteBrowserActionResolver.ts +242 -32
  440. package/src/resolvers/RunAIAgentResolver.ts +265 -86
  441. package/src/resolvers/RunAIPromptResolver.ts +18 -18
  442. package/src/resolvers/RunTemplateResolver.ts +3 -1
  443. package/src/resolvers/SqlLoggingConfigResolver.ts +7 -9
  444. package/src/resolvers/TagGovernanceResolver.ts +65 -0
  445. package/src/resolvers/TelemetryResolver.ts +44 -9
  446. package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +52 -0
  447. package/src/resolvers/meetingRecordingRegistration.ts +2 -0
  448. package/src/resolvers/meetings/meetingMutations.ts +37 -7
  449. package/src/rest/MediaAccessKeys.ts +6 -2
  450. package/src/rest/OAuthCallbackHandler.ts +13 -5
  451. package/src/rest/RealtimeRecordingHandler.ts +5 -9
  452. package/src/rest/ViewOperationsHandler.ts +2 -1
  453. package/src/rest/setupRESTEndpoints.ts +11 -2
  454. package/src/services/DurableEntityActionTaskSubmitter.ts +10 -2
  455. package/src/services/TaskGraphActionRunner.ts +2 -23
  456. package/src/services/TaskGraphAgentRunner.ts +43 -0
  457. package/src/services/TaskGraphContinuationDeliverer.ts +1 -0
  458. package/src/services/WorkQueueHostService.ts +26 -4
  459. package/src/services/workQueueConfig.ts +5 -5
  460. package/src/sharedCache.ts +1 -1
  461. package/src/types.ts +18 -0
  462. package/src/util.ts +55 -3
  463. package/src/services/TaskOrchestration-Integration.md +0 -188
@@ -1,8 +1,8 @@
1
1
  import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType, Int, Float } from "type-graphql";
2
- import { CompositeKey, DatabaseProviderBase, EntityInfo, LocalCacheManager, Metadata, RunView, UserInfo, LogError, LogStatus, IMetadataProvider, TransactionGroupBase } from "@memberjunction/core";
2
+ import { BaseEntity, CompositeKey, DatabaseProviderBase, EntityInfo, LocalCacheManager, Metadata, RunView, UserInfo, LogError, LogStatus, IMetadataProvider, TransactionGroupBase } from "@memberjunction/core";
3
3
  import { GetReadOnlyProvider, GetReadWriteProvider } from "../util.js";
4
4
  import { NoLog } from "../logging/NoLog.js";
5
- import { UUIDsEqual } from "@memberjunction/global";
5
+ import { EscapeSQLString, UUIDsEqual } from "@memberjunction/global";
6
6
  import { DrainResponseBody, SafeFetch } from "@memberjunction/network-utils";
7
7
  import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
8
8
  import {
@@ -34,6 +34,8 @@ import {
34
34
  decideSchemaLimitViolations,
35
35
  IntegrationConnectorCreationPipeline,
36
36
  IntegrationActionGenerator,
37
+ DescribePersistCounts,
38
+ BuildCatalogWriter, WithCatalogScope,
37
39
  ObjectsWithWatermarkFieldChange
38
40
  } from "@memberjunction/integration-engine";
39
41
  import type {
@@ -54,11 +56,19 @@ import {
54
56
  import { RuntimeSchemaManager, type RSUPipelineStep, type RSUPipelineInput, type RSUPendingWork } from "@memberjunction/schema-engine";
55
57
  import type { SchemaBuilderOutput } from "@memberjunction/integration-schema-builder";
56
58
  import { IntegrationProgressEmitter, IntegrationProgressReader } from "@memberjunction/integration-progress-artifacts";
59
+ import { AuthorizeRunArtifact } from "../integration/RunArtifactAuthorization.js";
60
+ import type { RunArtifactAuthorizationResult } from "../integration/RunArtifactAuthorization.js";
57
61
  import type { IntegrationRunSnapshot, IntegrationRunKind } from "@memberjunction/integration-progress-artifacts";
58
62
  import { ResolverBase } from "../generic/ResolverBase.js";
59
63
  import { IntegrationCustomColumnPromoter } from "../integration/CustomColumnPromoter.js";
60
- import { ComputeCascadeRemovalSet, ComputeRemovedDependencyWarnings, DecideFieldMapReconcile, DisableUnselectedEntityMaps, ReenableFieldMapsForEntityMap, ResetPullWatermarks, SetEntityMapEnabled } from "../integration/EntityMapLifecycle.js";
61
- import { ComputeInactiveRowWarnings } from "../integration/InactiveRowWarnings.js";
64
+ import { CatalogIdentityKey, ClearRekeyedObjectData, ComputeCascadeRemovalSet, ComputeRemovedDependencyWarnings, DecideFieldMapReconcile, DecideRekeyed, DisableUnselectedEntityMaps, IdentityKeyFields, ReenableFieldMapsForEntityMap, ResetPullWatermarks, SetEntityMapEnabled } from "../integration/EntityMapLifecycle.js";
65
+ import { CollectInactiveRowWarnings } from "../integration/InactiveRowWarnings.js";
66
+ import { DecidePauseWrite, DecideSchedulesToPause, DecideSchedulesToResume, DescribeCancelOutcome, DescribeCancelScope, DescribePauseOutcome, ReadPausedSchedules, WritePausedSchedules } from "../integration/ConnectionPause.js";
67
+ import type { CancelScope, ScheduleJobState } from "../integration/ConnectionPause.js";
68
+ import { ReadResourcePressure, EvaluatePressure } from "@memberjunction/integration-engine";
69
+ import { FieldsForBuild, LoadScopedFieldsForBuild, type ScopedBuildFields } from "../integration/ScopedFieldsForBuild.js";
70
+ import { LookupObjectNamesByID } from "../integration/ObjectNamesByID.js";
71
+ import { AssertTargetConfigs, NewTargetConfigDrops, type TargetConfigDrops } from "../integration/TargetConfigGuard.js";
62
72
  import { BuildCreateConnectionMessage, BuildDetachedRefreshMessage, BuildReactivateMessage, BuildUpdateConnectionMessage } from "../integration/SchemaRefreshLaunch.js";
63
73
  // Type-only: the registered runtime class for 'MJ: Company Integrations'. Lets the create path name the
64
74
  // server subclass it actually gets back from GetEntityObject with a real type rather than a cast.
@@ -67,6 +77,22 @@ import { AppContext } from "../types.js";
67
77
  import { RequireSystemUser } from "../directives/RequireSystemUser.js";
68
78
  import { UserCache } from "@memberjunction/generic-database-provider";
69
79
 
80
+ // ─── SQL filter guard ───────────────────────────────────────────────────────
81
+
82
+ const UUID_SHAPE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
83
+
84
+ /**
85
+ * Validates that a caller-supplied record id is a UUID before it is interpolated into a SQL
86
+ * filter, and returns it unchanged. Every id this resolver family accepts is a UNIQUEIDENTIFIER,
87
+ * so anything else is refused outright rather than escaped — a validated UUID cannot carry SQL.
88
+ */
89
+ function sqlUuid(value: string, argName: string): string {
90
+ if (typeof value !== 'string' || !UUID_SHAPE.test(value)) {
91
+ throw new Error(`${argName} must be a UUID`);
92
+ }
93
+ return value;
94
+ }
95
+
70
96
  // ─── RSU Pipeline Output Types ──────────────────────────────────────────────
71
97
 
72
98
  @ObjectType()
@@ -176,7 +202,11 @@ class ApplyAllBatchConnectorInput {
176
202
  class ApplyAllBatchInput {
177
203
  @Field(() => [ApplyAllBatchConnectorInput]) Connectors: ApplyAllBatchConnectorInput[];
178
204
  @Field(() => Boolean, { nullable: true, defaultValue: true, description: 'If false, skips sync after schema + entity maps' }) StartSync?: boolean;
179
- @Field(() => Boolean, { nullable: true, defaultValue: false, description: 'If true, ignores watermarks and does a full re-fetch' }) FullSync?: boolean;
205
+ // NO defaultValue. It must stay UNDEFINED when the caller says nothing, so the resolver can
206
+ // tell "not specified" from "explicitly incremental" and apply the first-apply rule
207
+ // (everything.txt: "always full generally as the default" for a connector's first RSU).
208
+ // A defaultValue of false made that determination dead code on this path for its whole life.
209
+ @Field(() => Boolean, { nullable: true, description: 'Ignore watermarks and do a full re-fetch. Omit to let the server choose: FULL on a connection first apply, incremental after.' }) FullSync?: boolean;
180
210
  @Field({ nullable: true, defaultValue: 'created', description: 'Sync scope: "created" = only newly created entity maps, "all" = all maps for the connector' }) SyncScope?: string;
181
211
  @Field({ nullable: true, description: 'Override sync direction for the initial sync: Pull | Push | Bidirectional. Defaults to entity map SyncDirection.' }) SyncDirection?: string;
182
212
  @Field({ nullable: true, description: 'Override sync direction stored in the created schedule: Pull | Push | Bidirectional.' }) ScheduleSyncDirection?: string;
@@ -242,10 +272,60 @@ class SchemaEvolutionOutput {
242
272
  @Field(() => [String], { nullable: true }) ChangedObjects?: string[];
243
273
  /** U10 — objects whose Pull watermark was reset so the next sync backfills the schema change. */
244
274
  @Field(() => [String], { nullable: true }) WatermarksReset?: string[];
275
+ /**
276
+ * Objects whose stored rows were CLEARED because the connector changed what identifies a row.
277
+ * Reported separately from WatermarksReset: a reset means "fetch it all again", this means
278
+ * "what was here could not be reconciled with the source and has been rebuilt from it".
279
+ */
280
+ @Field(() => [String], { nullable: true }) RekeyedObjects?: string[];
281
+ /**
282
+ * Custom columns the source has been sending that are still sitting in the overflow JSON,
283
+ * offered here so a schema refresh is where you see them.
284
+ *
285
+ * everything.txt: promoting one "requires that the user accepts" it, so this is REPORT-ONLY.
286
+ * Pass the column names back in `acceptCustomColumns` to actually materialise them — in the
287
+ * same migration and the same restart as the refresh's own DDL.
288
+ */
289
+ @Field(() => [CustomColumnCandidate], { nullable: true }) CustomColumnCandidates?: CustomColumnCandidate[];
290
+ /** The accepted columns that were folded into this run's migration. */
291
+ @Field(() => [PromotedColumn], { nullable: true }) PromotedColumns?: PromotedColumn[];
245
292
  }
246
293
 
247
294
  // ─── Connector Capabilities Output Type ─────────────────────────────────────
248
295
 
296
+ /**
297
+ * One tunable setting, described by the connector.
298
+ *
299
+ * Exists so a surface can render the settings for a connection it knows nothing about, and so the
300
+ * DEFAULT it shows is the engine's real one. The alternative — a hardcoded list per surface — is
301
+ * how a sample size of 500 came to be shown and stamped against an engine whose default is 50.
302
+ */
303
+ @ObjectType()
304
+ class ConnectorSettingOutput {
305
+ @Field() Key: string;
306
+ @Field() Label: string;
307
+ @Field() Group: string;
308
+ @Field() Type: string;
309
+ @Field(() => [String], { nullable: true }) Options?: string[];
310
+ /** JSON-encoded: the value is polymorphic per setting, and Type says how to read it. */
311
+ @Field({ nullable: true }) DefaultJSON?: string;
312
+ @Field(() => Float, { nullable: true }) Min?: number;
313
+ @Field(() => Float, { nullable: true }) Max?: number;
314
+ /** 'basic' | 'advanced' | 'expert' — how a surface decides what to show. */
315
+ @Field() Tier: string;
316
+ /** 'connection' | 'object' */
317
+ @Field() AppliesTo: string;
318
+ @Field() Description: string;
319
+ @Field() Sensitive: boolean;
320
+ }
321
+
322
+ @ObjectType()
323
+ class ConnectorSettingsSchemaOutput {
324
+ @Field() Success: boolean;
325
+ @Field() Message: string;
326
+ @Field(() => [ConnectorSettingOutput]) Settings: ConnectorSettingOutput[];
327
+ }
328
+
249
329
  @ObjectType()
250
330
  class ConnectorCapabilitiesOutput {
251
331
  @Field() Success: boolean;
@@ -410,6 +490,22 @@ class RefreshConnectorSchemaOutput {
410
490
  @Field({ nullable: true }) FailureMessage?: string;
411
491
  }
412
492
 
493
+ /**
494
+ * The reply to a DETACHED discovery start. Deliberately a different shape from
495
+ * RefreshConnectorSchemaOutput: that one reports real counts because it waited, and handing back
496
+ * placeholder zeros from a run that has not begun is exactly the confusion this type avoids.
497
+ */
498
+ @ObjectType()
499
+ class StartSchemaRefreshOutput {
500
+ @Field() Success: boolean;
501
+ @Field() Message: string;
502
+ /** Tail this with IntegrationTailRunEvents. 'not-started' when Success is false. */
503
+ @Field() RunID: string;
504
+ @Field() InProgress: boolean;
505
+ /** Set when the refusal was a lock: names what is holding it, so the UI can say why. */
506
+ @Field({ nullable: true }) BlockedBy?: string;
507
+ }
508
+
413
509
  // ─── Generate Integration Action (on-demand Integration-as-Actions) ─────────
414
510
  // Generates + persists a strongly-typed Action (DriverClass='IntegrationActionExecutor')
415
511
  // for one integration/object/verb (or all applicable verbs when verb is omitted) via
@@ -623,6 +719,28 @@ class MutationResultOutput {
623
719
  @Field() Message: string;
624
720
  }
625
721
 
722
+ /**
723
+ * Deactivate returns more than a boolean because pausing a connection is not one act.
724
+ *
725
+ * A caller has to be able to tell "paused, and the sync you were watching is stopping" from
726
+ * "paused, and the sync you were watching will run to completion in a process I cannot reach".
727
+ * Both are successful pauses. Only one of them means the work has stopped, and a UI that says
728
+ * "stopping" for the second is lying to the operator about the state of their data.
729
+ */
730
+ @ObjectType()
731
+ class DeactivateConnectionOutput {
732
+ @Field() Success: boolean;
733
+ @Field() Message: string;
734
+ /** How many scheduled jobs this pause actually moved from Active to Paused. */
735
+ @Field(() => Int) SchedulesPaused: number;
736
+ /** The live sync run at the moment of pause, when there was one. */
737
+ @Field({ nullable: true }) InFlightSyncRunID?: string;
738
+ /** True when a live run existed AND a cancel was signalled for it. */
739
+ @Field() CancelRequested: boolean;
740
+ /** 'durable' | 'this-process' | 'unknown' | 'none' — see ConnectionPause.DescribeCancelScope. */
741
+ @Field() CancelScope: string;
742
+ }
743
+
626
744
  // ─── Typed sync-config (rate-limit / concurrency / time-budget as STRUCTURED fields, not a raw
627
745
  // Configuration JSON blob). These map to the CompanyIntegration.Configuration keys the engine
628
746
  // reads at runtime, so they are customizable per-connection via the API instead of hidden code
@@ -865,6 +983,10 @@ class SyncHistoryOutput {
865
983
  @Field() Success: boolean;
866
984
  @Field() Message: string;
867
985
  @Field(() => [SyncRunSummaryOutput], { nullable: true }) Runs?: SyncRunSummaryOutput[];
986
+ /** Total runs for this connection, so a pager can size itself without fetching every row. */
987
+ @Field({ nullable: true }) TotalKnown?: number;
988
+ /** True when rows exist beyond this page. */
989
+ @Field({ nullable: true }) HasMore?: boolean;
868
990
  }
869
991
 
870
992
  @ObjectType()
@@ -880,6 +1002,8 @@ class OperationProgressOutput {
880
1002
  @Field({ nullable: true }) RecordsCreated?: number;
881
1003
  @Field({ nullable: true }) RecordsUpdated?: number;
882
1004
  @Field({ nullable: true }) RecordsErrored?: number;
1005
+ /** Records the sync chose not to write because the content hash matched. */
1006
+ @Field({ nullable: true }) RecordsSkipped?: number;
883
1007
  @Field({ nullable: true }) RSUStep?: string;
884
1008
  @Field({ nullable: true }) RSURunning?: boolean;
885
1009
  /** U11 — 1-based index of the current RSU step (determinate stepper). */
@@ -890,6 +1014,73 @@ class OperationProgressOutput {
890
1014
  @Field({ nullable: true }) StartedAt?: string;
891
1015
  }
892
1016
 
1017
+ @ObjectType()
1018
+ class ActiveOperationOutput {
1019
+ /** 'sync' | 'rsu' | 'discovery' | 'maintenance' */
1020
+ @Field() Kind: string;
1021
+ /**
1022
+ * 'connection' when this operation provably belongs to the requested connection;
1023
+ * 'workspace' when it is process-wide and cannot be attributed to one. The UI must not claim a
1024
+ * workspace-scoped RSU belongs to the connector being viewed - that mislabelling is why an
1025
+ * unrelated schema update read as "your connector is building".
1026
+ */
1027
+ @Field() Scope: string;
1028
+ @Field() Label: string;
1029
+ @Field({ nullable: true }) RunID?: string;
1030
+ @Field({ nullable: true }) StartedAt?: Date;
1031
+ @Field({ nullable: true }) StepLabel?: string;
1032
+ @Field(() => Int, { nullable: true }) StepIndex?: number;
1033
+ @Field(() => Int, { nullable: true }) StepTotal?: number;
1034
+ @Field(() => Int, { nullable: true }) RecordsCreated?: number;
1035
+ @Field(() => Int, { nullable: true }) RecordsUpdated?: number;
1036
+ @Field(() => Int, { nullable: true }) RecordsSkipped?: number;
1037
+ @Field(() => Int, { nullable: true }) RecordsErrored?: number;
1038
+ /** Only a sync can be cancelled, and only from the process running it. */
1039
+ @Field() Cancellable: boolean;
1040
+ }
1041
+
1042
+ @ObjectType()
1043
+ class ActiveOperationsOutput {
1044
+ @Field() Success: boolean;
1045
+ @Field() Message: string;
1046
+ @Field(() => [ActiveOperationOutput], { nullable: true }) Operations?: ActiveOperationOutput[];
1047
+ /** What holds this connection's maintenance lock, if anything. */
1048
+ @Field({ nullable: true }) MaintenanceLockReason?: string;
1049
+ }
1050
+
1051
+ @ObjectType()
1052
+ class ResourcePressureFindingOutput {
1053
+ @Field() Code: string;
1054
+ @Field() Message: string;
1055
+ }
1056
+
1057
+ @ObjectType()
1058
+ class ResourcePressureOutput {
1059
+ @Field() Success: boolean;
1060
+ @Field() Message: string;
1061
+ @Field(() => Float, { nullable: true }) HeapUsedFraction?: number;
1062
+ @Field(() => Float, { nullable: true }) HeapUsedMB?: number;
1063
+ @Field(() => Float, { nullable: true }) HeapLimitMB?: number;
1064
+ @Field(() => Float, { nullable: true }) ResidentMB?: number;
1065
+ /**
1066
+ * Resident set as a fraction of HOST memory, plus the host totals it is derived from.
1067
+ *
1068
+ * ResidentMB alone is a number with no denominator: a client cannot tell whether 3,478 MB is
1069
+ * comfortable or one step from a SIGKILL without knowing the box. It was the latter on the
1070
+ * sandbox on 2026-09-14 — 3,478 MB of 3,830 MB, killed by the kernel, while HeapUsedFraction
1071
+ * (the only judgeable number this type exposed) sat unremarkable because `--max-old-space-size`
1072
+ * bounds V8's old space and not the response buffers that actually filled the machine.
1073
+ */
1074
+ @Field(() => Float, { nullable: true }) ResidentFraction?: number;
1075
+ @Field(() => Float, { nullable: true }) HostMemTotalMB?: number;
1076
+ @Field(() => Float, { nullable: true }) HostMemAvailableMB?: number;
1077
+ @Field(() => Float, { nullable: true }) ArtifactDiskFreeMB?: number;
1078
+ @Field(() => Float, { nullable: true }) WorkDirFreeMB?: number;
1079
+ @Field(() => Int, { nullable: true }) ActiveSyncCount?: number;
1080
+ /** Empty when nothing is under pressure. Ordered most severe first. */
1081
+ @Field(() => [ResourcePressureFindingOutput], { nullable: true }) Findings?: ResourcePressureFindingOutput[];
1082
+ }
1083
+
893
1084
  // ── STRUCTURED RUN ARTIFACTS (durable JSONL progress streams) ─────────
894
1085
  // These expose the IntegrationProgressReader over GraphQL so a tenant can ask,
895
1086
  // at any time, "what exactly happened (or is happening) on this run?" — backed
@@ -912,6 +1103,12 @@ class IntegrationRunSummaryArtifactOutput {
912
1103
  @Field() RunKind: string;
913
1104
  @Field({ nullable: true }) IntegrationID?: string;
914
1105
  @Field({ nullable: true }) CompanyIntegrationID?: string;
1106
+ /**
1107
+ * Every connection the run touched. Populated for runs that can span more than one (an RSU
1108
+ * batch); `CompanyIntegrationID` above is set only when this holds exactly one. A client should
1109
+ * read this rather than the singular field when it needs to know what a run covers.
1110
+ */
1111
+ @Field(() => [String], { nullable: true }) CompanyIntegrationIDs?: string[];
915
1112
  @Field({ nullable: true }) ObjectName?: string;
916
1113
  @Field({ nullable: true }) TriggerType?: string;
917
1114
  @Field() StartedAt: string;
@@ -950,6 +1147,10 @@ class IntegrationListRunsOutput {
950
1147
  @Field() Success: boolean;
951
1148
  @Field() Message: string;
952
1149
  @Field(() => [IntegrationRunSummaryArtifactOutput], { nullable: true }) Runs?: IntegrationRunSummaryArtifactOutput[];
1150
+ /** Feed back as `offset` for the next page. Absent when there is no next page. */
1151
+ @Field({ nullable: true }) NextOffset?: number;
1152
+ /** True when the store holds more runs past this page. */
1153
+ @Field({ nullable: true }) HasMore?: boolean;
953
1154
  }
954
1155
 
955
1156
  @ObjectType()
@@ -1108,6 +1309,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1108
1309
  @Arg("companyIntegrationID") companyIntegrationID: string,
1109
1310
  @Ctx() ctx: AppContext
1110
1311
  ): Promise<DiscoverObjectsOutput> {
1312
+ return WithCatalogScope(companyIntegrationID, async () => {
1111
1313
  try {
1112
1314
  const user = this.getAuthenticatedUser(ctx);
1113
1315
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
@@ -1133,6 +1335,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1133
1335
  } catch (e) {
1134
1336
  return this.handleDiscoveryError(e);
1135
1337
  }
1338
+ });
1136
1339
  }
1137
1340
 
1138
1341
  /**
@@ -1147,6 +1350,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1147
1350
  @Arg("companyIntegrationID") companyIntegrationID: string,
1148
1351
  @Ctx() ctx: AppContext
1149
1352
  ): Promise<ListSourceObjectsOutput> {
1353
+ return WithCatalogScope(companyIntegrationID, async () => {
1150
1354
  try {
1151
1355
  const user = this.getAuthenticatedUser(ctx);
1152
1356
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
@@ -1212,6 +1416,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1212
1416
  LogError(`IntegrationListSourceObjects error: ${e}`);
1213
1417
  return { Success: false, Message: this.formatError(e) };
1214
1418
  }
1419
+ });
1215
1420
  }
1216
1421
 
1217
1422
  private async loadIntegrationObjectsByIntegrationID(
@@ -1221,7 +1426,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1221
1426
  const rv = new RunView();
1222
1427
  const result = await rv.RunView<MJIntegrationObjectEntity>({
1223
1428
  EntityName: 'MJ: Integration Objects',
1224
- ExtraFilter: `IntegrationID='${integrationID}'`,
1429
+ ExtraFilter: `IntegrationID='${sqlUuid(integrationID, 'integrationID')}'`,
1225
1430
  ResultType: 'entity_object',
1226
1431
  }, user);
1227
1432
  if (!result.Success) {
@@ -1251,6 +1456,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1251
1456
  * (IntegrationObjectField.RelatedIntegrationObjectID) plus soft-FK declarations in the IO's
1252
1457
  * Configuration (parentObjectName / parentObjectNames map / ReferencedType). Reads the
1253
1458
  * engine cache only (no DB round-trip); objects with no persisted IO row have no entry.
1459
+ *
1460
+ * Hard FK parents come from `GetRelatedIntegrationObjectIDs`, which answers a per-connection
1461
+ * object from the scope's edge set. Its full field rows are cold here (only the sync loop warms
1462
+ * them), so reading them would find no parents at all.
1254
1463
  */
1255
1464
  private computeObjectDependencyNames(integrationID: string): Map<string, string[]> {
1256
1465
  const engine = IntegrationEngineBase.Instance;
@@ -1260,9 +1469,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1260
1469
  const deps = new Map<string, string[]>();
1261
1470
  for (const io of ios) {
1262
1471
  const parents = new Set<string>();
1263
- for (const f of engine.GetIntegrationObjectFields(io.ID)) {
1264
- const parentID = f.RelatedIntegrationObjectID?.toUpperCase();
1265
- if (!parentID || UUIDsEqual(parentID, io.ID)) continue;
1472
+ for (const relatedID of engine.GetRelatedIntegrationObjectIDs(io.ID)) {
1473
+ const parentID = relatedID.toUpperCase();
1474
+ if (UUIDsEqual(parentID, io.ID)) continue;
1266
1475
  const parentName = nameById.get(parentID);
1267
1476
  if (parentName) parents.add(parentName);
1268
1477
  }
@@ -1298,6 +1507,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1298
1507
  @Arg("objectName") objectName: string,
1299
1508
  @Ctx() ctx: AppContext
1300
1509
  ): Promise<DiscoverFieldsOutput> {
1510
+ return WithCatalogScope(companyIntegrationID, async () => {
1301
1511
  try {
1302
1512
  const user = this.getAuthenticatedUser(ctx);
1303
1513
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
@@ -1323,6 +1533,193 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1323
1533
  } catch (e) {
1324
1534
  return this.handleDiscoveryError(e);
1325
1535
  }
1536
+ });
1537
+ }
1538
+
1539
+ /**
1540
+ * Tests credentials that have NOT been saved anywhere.
1541
+ *
1542
+ * ── Why this exists ───────────────────────────────────────────────────────
1543
+ *
1544
+ * `IntegrationTestConnection` takes a `companyIntegrationID`, so it can only test a
1545
+ * connection that already exists. That forces every setup wizard into create-then-test:
1546
+ * the workspace writes a Credential and a CompanyIntegration, THEN finds out the
1547
+ * password was wrong, and something has to go back and delete them. Two cases escape
1548
+ * that cleanup — a credential test that outlives the caller's gateway (most likely
1549
+ * precisely when a host or tenant id is wrong, because that is what makes a vendor call
1550
+ * hang rather than refuse), and a test that passes before a later step fails. Both leave
1551
+ * a connection that answers every listing and syncs nothing.
1552
+ *
1553
+ * This inverts the order. Nothing is written, so there is nothing to clean up.
1554
+ *
1555
+ * ── How it can work without persisting ────────────────────────────────────
1556
+ *
1557
+ * The connector is resolved from the INTEGRATION row, not from a connection —
1558
+ * `ConnectorFactory.Resolve` already only needs the former. The credentials then ride in
1559
+ * on a transient CompanyIntegration that is constructed with `NewRecord()` and
1560
+ * DELIBERATELY NEVER SAVED, carrying the values in `Configuration`.
1561
+ *
1562
+ * That works because every connector resolves its auth material the same way: prefer the
1563
+ * linked Credential when `CredentialID` is set, otherwise fall back to the
1564
+ * `Configuration` JSON. It is a de-facto framework convention rather than a documented
1565
+ * one — verified across all seven connectors this was written for (NetSuite, Totara,
1566
+ * Nimble AMS, OpenWater, PheedLoop, PropFuel, Elevate), each of which reads
1567
+ * `CredentialID` first and `Configuration` second. A connector that reads ONLY
1568
+ * `CredentialID` would report missing credentials here rather than misbehave, which is
1569
+ * the safe direction to fail: the caller learns the probe cannot help and falls back to
1570
+ * create-then-test, exactly as before.
1571
+ *
1572
+ * ── Rules this endpoint holds itself to ───────────────────────────────────
1573
+ *
1574
+ * - NOTHING IS PERSISTED. No Credential, no CompanyIntegration, no Company. The
1575
+ * transient entity is a local and never leaves this method.
1576
+ * - THE VALUES ARE NEVER LOGGED. Not on success, not on failure, not inside an error
1577
+ * message. Only the integration id and the outcome are ever written down.
1578
+ * - IT CANNOT HANG. A hard deadline applies regardless of what the connector does,
1579
+ * because a probe that inherits the gateway-timeout problem solves nothing. A
1580
+ * timeout is reported as a timeout, which is itself the diagnosis for an unreachable
1581
+ * host.
1582
+ * - IT IS RATE LIMITED per (user, integration). The endpoint turns caller-supplied
1583
+ * input into an outbound vendor request, so an unbounded one is a request amplifier.
1584
+ */
1585
+ @Query(() => ConnectionTestOutput)
1586
+ async IntegrationProbeCredentials(
1587
+ @Arg("integrationID") integrationID: string,
1588
+ @Arg("credentialValues") credentialValues: string,
1589
+ @Ctx() ctx: AppContext
1590
+ ): Promise<ConnectionTestOutput> {
1591
+ try {
1592
+ const user = this.getAuthenticatedUser(ctx);
1593
+
1594
+ const limited = IntegrationDiscoveryResolver.probeRateLimited(user.ID, integrationID, Date.now());
1595
+ if (limited) {
1596
+ return { Success: false, Message: limited };
1597
+ }
1598
+
1599
+ // Must be a JSON OBJECT. A connector's parser is entitled to assume that much, and
1600
+ // handing it a bare string or an array is a way to reach a parse path nobody tests.
1601
+ let parsed: unknown;
1602
+ try {
1603
+ parsed = JSON.parse(credentialValues);
1604
+ } catch {
1605
+ return { Success: false, Message: 'The credentials could not be read as JSON.' };
1606
+ }
1607
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1608
+ return { Success: false, Message: 'The credentials must be a JSON object of field names to values.' };
1609
+ }
1610
+
1611
+ const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
1612
+
1613
+ const integration = await provider.GetEntityObject<MJIntegrationEntity>('MJ: Integrations', user);
1614
+ if (!(await integration.Load(integrationID))) {
1615
+ return { Success: false, Message: `Integration with ID "${integrationID}" not found` };
1616
+ }
1617
+ const connector = ConnectorFactory.Resolve(integration);
1618
+
1619
+ // The transient carrier. NewRecord() and never Save() — see the header.
1620
+ const probeCI = await provider.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
1621
+ probeCI.NewRecord();
1622
+ probeCI.Set('IntegrationID', integrationID);
1623
+ probeCI.Set('Configuration', credentialValues);
1624
+
1625
+ const testConnection = connector.TestConnection.bind(connector) as
1626
+ (ci: unknown, u: unknown) => Promise<ConnectionTestResult>;
1627
+
1628
+ const result = await IntegrationDiscoveryResolver.withProbeDeadline(
1629
+ testConnection(probeCI, user),
1630
+ integration.Name
1631
+ );
1632
+
1633
+ return {
1634
+ Success: result.Success,
1635
+ Message: result.Message,
1636
+ ServerVersion: result.ServerVersion
1637
+ };
1638
+ } catch (e) {
1639
+ // formatError only ever sees the thrown message. The credential values are not in
1640
+ // scope of anything logged here, and must never be added to it.
1641
+ LogError(`IntegrationProbeCredentials error for integration ${integrationID}: ${this.formatError(e)}`);
1642
+ return {
1643
+ Success: false,
1644
+ Message: `Error: ${this.formatError(e)}`
1645
+ };
1646
+ }
1647
+ }
1648
+
1649
+ /**
1650
+ * The probe's hard deadline.
1651
+ *
1652
+ * `TestConnectionMs` exists on the connector config but is advisory — an implementation
1653
+ * that does not consult it can block for as long as the vendor's socket does, which is
1654
+ * the whole failure this endpoint exists to avoid. So the bound is applied here, where it
1655
+ * cannot be opted out of.
1656
+ *
1657
+ * 20 seconds: comfortably longer than any healthy handshake, comfortably shorter than the
1658
+ * ~240s gateway ceiling that create-then-test keeps running into.
1659
+ */
1660
+ private static readonly PROBE_DEADLINE_MS = 20_000;
1661
+
1662
+ private static withProbeDeadline(
1663
+ work: Promise<ConnectionTestResult>,
1664
+ vendorName: string
1665
+ ): Promise<ConnectionTestResult> {
1666
+ let timer: ReturnType<typeof setTimeout> | undefined;
1667
+ const deadline = new Promise<ConnectionTestResult>(resolve => {
1668
+ timer = setTimeout(
1669
+ () =>
1670
+ resolve({
1671
+ Success: false,
1672
+ // A timeout IS the diagnosis for an unreachable host or a wrong
1673
+ // tenant path, so it says that rather than "unknown error".
1674
+ Message:
1675
+ `${vendorName} did not respond within ` +
1676
+ `${Math.round(IntegrationDiscoveryResolver.PROBE_DEADLINE_MS / 1000)} seconds. ` +
1677
+ `Check the host and any account or organization identifier in the values above.`
1678
+ }),
1679
+ IntegrationDiscoveryResolver.PROBE_DEADLINE_MS
1680
+ );
1681
+ timer.unref?.();
1682
+ });
1683
+ return Promise.race([work, deadline]).finally(() => {
1684
+ if (timer) {
1685
+ clearTimeout(timer);
1686
+ }
1687
+ });
1688
+ }
1689
+
1690
+ /**
1691
+ * Per (user, integration) rate limit.
1692
+ *
1693
+ * This endpoint converts caller-supplied input into an outbound request to a third party,
1694
+ * which makes an unbounded version a request amplifier pointed at somebody else's API —
1695
+ * and the fastest way to get a tenant's key rate-limited by its own vendor. In-process
1696
+ * and per-instance on purpose: it is a courtesy bound on a human typing into a form, not
1697
+ * a security boundary, and giving it a durable store would buy nothing a form's own pace
1698
+ * does not already provide.
1699
+ */
1700
+ private static readonly PROBE_WINDOW_MS = 60_000;
1701
+ private static readonly PROBE_MAX_PER_WINDOW = 10;
1702
+ private static readonly probeHits = new Map<string, number[]>();
1703
+
1704
+ private static probeRateLimited(userID: string, integrationID: string, now: number): string | null {
1705
+ const key = `${userID}:${integrationID}`.toLowerCase();
1706
+ const window = now - IntegrationDiscoveryResolver.PROBE_WINDOW_MS;
1707
+ const hits = (IntegrationDiscoveryResolver.probeHits.get(key) ?? []).filter(t => t > window);
1708
+ if (hits.length >= IntegrationDiscoveryResolver.PROBE_MAX_PER_WINDOW) {
1709
+ return 'Too many credential checks in the last minute — wait a moment and try again.';
1710
+ }
1711
+ hits.push(now);
1712
+ IntegrationDiscoveryResolver.probeHits.set(key, hits);
1713
+ // Bound the map so a long-lived process cannot accumulate a key per (user, integration)
1714
+ // pair forever. Anything with no hits inside the window is already spent.
1715
+ if (IntegrationDiscoveryResolver.probeHits.size > 500) {
1716
+ for (const [k, v] of IntegrationDiscoveryResolver.probeHits) {
1717
+ if (!v.some(t => t > window)) {
1718
+ IntegrationDiscoveryResolver.probeHits.delete(k);
1719
+ }
1720
+ }
1721
+ }
1722
+ return null;
1326
1723
  }
1327
1724
 
1328
1725
  /**
@@ -1373,6 +1770,59 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1373
1770
  * both on stdout (visible in the MJAPI log file) and in a per-run
1374
1771
  * `<cwd>/logs/integration-runs/<runID>/progress.jsonl` artifact.
1375
1772
  */
1773
+ /**
1774
+ * Start discovery and return immediately with a tailable RunID.
1775
+ *
1776
+ * plan.md is emphatic that this must exist: "going to a page should NEVER, NEVER trigger such
1777
+ * things automatically, it must in a very clean way tell the user to click a button to start
1778
+ * discovery of tables". A button needs a call that returns at once; the synchronous mutation
1779
+ * runs the whole pipeline inline and times out at the gateway on a large catalog, which is why
1780
+ * both surfaces ended up auto-triggering on page entry and adopting whatever run they found.
1781
+ *
1782
+ * A DISTINCT mutation rather than a flag on the synchronous one: that reply promises real
1783
+ * counts because it waited, and a detached launch can only offer placeholder zeros.
1784
+ *
1785
+ * The lock is PROBED here so a second click is refused with a reason the UI can show. The
1786
+ * pipeline still acquires it for real - this probe is not the guard, it is the clean answer in
1787
+ * the common case. Losing the race just means the launch fails on the run stream instead.
1788
+ */
1789
+ @Mutation(() => StartSchemaRefreshOutput)
1790
+ @RequireSystemUser()
1791
+ async IntegrationStartSchemaRefresh(
1792
+ @Arg("companyIntegrationID") companyIntegrationID: string,
1793
+ @Arg("universalPKConvention", { nullable: true, description: "Optional vendor-wide PK convention hint (e.g. 'id' for HubSpot)" }) universalPKConvention: string | undefined,
1794
+ @Ctx() ctx: AppContext
1795
+ ): Promise<StartSchemaRefreshOutput> {
1796
+ const user = this.GetUserFromPayload(ctx.userPayload);
1797
+ // The pipeline WRITES the catalog, so it needs the read-write provider - the same one the
1798
+ // synchronous refresh mutation uses. A read-only provider here would fail at the first save.
1799
+ const md = GetReadWriteProvider(ctx.providers) as unknown as IMetadataProvider;
1800
+
1801
+ const paused = await this.describeIfPaused(companyIntegrationID, user, md);
1802
+ if (paused) {
1803
+ return { Success: false, InProgress: false, RunID: 'not-started', BlockedBy: 'connection paused', Message: `Discovery not started: ${paused}` };
1804
+ }
1805
+
1806
+ const held = IntegrationEngine.GetMaintenanceLock(companyIntegrationID);
1807
+ if (held) {
1808
+ return {
1809
+ Success: false,
1810
+ InProgress: false,
1811
+ RunID: 'not-started',
1812
+ BlockedBy: held.Reason,
1813
+ Message: `Discovery not started: ${held.Reason} is already running for this connection. It will be available when that finishes.`,
1814
+ };
1815
+ }
1816
+
1817
+ const summary = this.startSchemaRefreshPipelineDetached(companyIntegrationID, user, md, universalPKConvention);
1818
+ return {
1819
+ Success: true,
1820
+ InProgress: true,
1821
+ RunID: summary.RunID,
1822
+ Message: 'Discovery started.',
1823
+ };
1824
+ }
1825
+
1376
1826
  @Mutation(() => RefreshConnectorSchemaOutput)
1377
1827
  @RequireSystemUser()
1378
1828
  async IntegrationRefreshConnectorSchema(
@@ -1381,6 +1831,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1381
1831
  @Arg("deactivateAbsent", { nullable: true, description: "Comprehensive refresh (default true): objects/fields ABSENT from this discovery are deactivated (Status='Disabled', never deleted, reversible on a later rediscovery). Pass false for a scoped/partial discovery so it never disables what it didn't probe." }) deactivateAbsent: boolean | undefined,
1382
1832
  @Ctx() ctx: AppContext
1383
1833
  ): Promise<RefreshConnectorSchemaOutput> {
1834
+ return WithCatalogScope(companyIntegrationID, async () => {
1835
+ // Checked BEFORE the lock is taken, so a refusal owes no release.
1836
+ {
1837
+ const user = this.getAuthenticatedUser(ctx);
1838
+ const md = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
1839
+ const paused = await this.describeIfPaused(companyIntegrationID, user, md);
1840
+ if (paused) return { Success: false, RunID: 'not-started', Message: `Refresh not started: ${paused}` };
1841
+ }
1384
1842
  // sync lock: a metadata refresh rewrites the IO/IOF rows a sync reads.
1385
1843
  if (!IntegrationEngine.AcquireMaintenanceLock(companyIntegrationID, 'metadata refresh')) {
1386
1844
  return {
@@ -1424,7 +1882,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1424
1882
  return {
1425
1883
  Success: result.Success,
1426
1884
  Message: result.Success
1427
- ? `Refresh complete: ${result.PersistResult?.ObjectsCreated ?? 0} created, ${result.PersistResult?.ObjectsUpdated ?? 0} updated, ${result.UnresolvedObjects.length} IOs still PK-less (deferred to additionalSchemaInfo authoring)`
1885
+ // Objects AND fields. A PRESUPPOSED connector creates no objects on any run, so
1886
+ // the object counts alone report a refresh that reshaped hundreds of fields as
1887
+ // "0 created, 0 updated" — see DescribePersistCounts.
1888
+ ? `Refresh complete: ${DescribePersistCounts(result.PersistResult)}, ${result.UnresolvedObjects.length} IOs still PK-less (deferred to additionalSchemaInfo authoring)`
1428
1889
  : `Refresh failed: ${result.FailureMessage ?? 'unknown error'}`,
1429
1890
  RunID: result.RunID,
1430
1891
  ObjectsCreated: result.PersistResult?.ObjectsCreated,
@@ -1452,6 +1913,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1452
1913
  } finally {
1453
1914
  IntegrationEngine.ReleaseMaintenanceLock(companyIntegrationID);
1454
1915
  }
1916
+ });
1455
1917
  }
1456
1918
 
1457
1919
  /**
@@ -1598,6 +2060,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1598
2060
  @Arg("platform", { defaultValue: "sqlserver" }) platform: string,
1599
2061
  @Ctx() ctx: AppContext
1600
2062
  ): Promise<SchemaPreviewOutput> {
2063
+ return WithCatalogScope(companyIntegrationID, async () => {
1601
2064
  try {
1602
2065
  const user = this.getAuthenticatedUser(ctx);
1603
2066
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
@@ -1666,6 +2129,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1666
2129
  Message: `Error: ${this.formatError(e)}`
1667
2130
  };
1668
2131
  }
2132
+ });
1669
2133
  }
1670
2134
 
1671
2135
  /**
@@ -1718,7 +2182,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1718
2182
  objects: SchemaPreviewObjectInput[],
1719
2183
  sourceSchema: SourceSchemaInfo,
1720
2184
  platform: 'sqlserver' | 'postgresql',
1721
- connector?: BaseIntegrationConnector
2185
+ connector?: BaseIntegrationConnector,
2186
+ drops: TargetConfigDrops = NewTargetConfigDrops(),
1722
2187
  ): TargetTableConfig[] {
1723
2188
  const mapper = new TypeMapper();
1724
2189
 
@@ -1732,9 +2197,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1732
2197
  // out how many selections actually made it. The picker → ApplyAll →
1733
2198
  // RSU pipeline already had three layers of silent O(N) drops; this
1734
2199
  // makes them at least summarised.
1735
- const droppedNotInSchema: string[] = [];
1736
- const droppedNoFields: string[] = [];
1737
- const droppedNoPrimaryKey: string[] = [];
2200
+ // The caller's lists when it passes them, so the NO_TARGET_TABLES error can name each reason.
2201
+ const droppedNotInSchema = drops.NotInSchema;
2202
+ const droppedNoFields = drops.NoFields;
2203
+ const droppedNoPrimaryKey = drops.NoPrimaryKey;
1738
2204
 
1739
2205
  const results: TargetTableConfig[] = [];
1740
2206
  for (const obj of objects) {
@@ -1962,21 +2428,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1962
2428
  .filter(so => !so.SourceObjectName && so.SourceObjectID)
1963
2429
  .map(so => so.SourceObjectID!);
1964
2430
 
1965
- const idToName = new Map<string, string>();
1966
- if (idsToLookup.length > 0) {
1967
- const rv = new RunView();
1968
- const result = await rv.RunView<{ ID: string; Name: string }>({
1969
- EntityName: 'MJ: Integration Objects',
1970
- ExtraFilter: idsToLookup.map(id => `ID='${id}'`).join(' OR '),
1971
- ResultType: 'simple',
1972
- Fields: ['ID', 'Name'],
1973
- }, user);
1974
- if (result.Success) {
1975
- for (const row of result.Results) {
1976
- idToName.set(row.ID.toUpperCase(), row.Name);
1977
- }
1978
- }
1979
- }
2431
+ // Per-connection ids first (the picker's ids inside a catalog scope), then the shared catalog.
2432
+ const idToName = await LookupObjectNamesByID(idsToLookup.map(id => sqlUuid(id, 'ID')), user);
1980
2433
 
1981
2434
  const plan: Array<{ Name: string; Fields?: string[] }> = [];
1982
2435
  for (const so of sourceObjects) {
@@ -2015,16 +2468,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2015
2468
  // newly-discovered objects with no persisted row).
2016
2469
  void integrationID;
2017
2470
  if (ids && ids.length > 0) {
2018
- const rv = new RunView();
2019
- const result = await rv.RunView<{ ID: string; Name: string }>({
2020
- EntityName: 'MJ: Integration Objects',
2021
- ExtraFilter: ids.map(id => `ID='${id}'`).join(' OR '),
2022
- ResultType: 'simple',
2023
- Fields: ['ID', 'Name'],
2024
- }, user);
2025
- if (result.Success) {
2026
- return result.Results.map(r => r.Name);
2027
- }
2471
+ // Through the connection's catalog first, and in INPUT order: the batch flow zips these
2472
+ // names back onto its id cursor, which a database-ordered result did not match.
2473
+ const idToName = await LookupObjectNamesByID(ids.map(id => sqlUuid(id, 'ID')), user);
2474
+ return ids
2475
+ .map(id => idToName.get(String(id).toUpperCase()))
2476
+ .filter((n): n is string => !!n);
2028
2477
  }
2029
2478
  if (names && names.length > 0) {
2030
2479
  const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
@@ -2066,19 +2515,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2066
2515
  const idsToLookup = sourceObjects
2067
2516
  .map(so => so.SourceObjectID)
2068
2517
  .filter((id): id is string => typeof id === 'string' && id.length > 0);
2069
- const idToName = new Map<string, string>();
2070
- if (idsToLookup.length > 0) {
2071
- const rv = new RunView();
2072
- const result = await rv.RunView<{ ID: string; Name: string }>({
2073
- EntityName: 'MJ: Integration Objects',
2074
- ExtraFilter: idsToLookup.map(id => `ID='${id}'`).join(' OR '),
2075
- ResultType: 'simple',
2076
- Fields: ['ID', 'Name'],
2077
- }, user);
2078
- if (result.Success) {
2079
- for (const r of result.Results) idToName.set(r.ID, r.Name);
2080
- }
2081
- }
2518
+ // Per-connection ids first (the picker's ids inside a catalog scope), then the shared catalog.
2519
+ const idToName = await LookupObjectNamesByID(idsToLookup.map(id => sqlUuid(id, 'ID')), user);
2082
2520
 
2083
2521
  const schemaNameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
2084
2522
  const resolvedNames: string[] = [];
@@ -2086,8 +2524,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2086
2524
  const dropped: SourceObjectInput[] = [];
2087
2525
  for (const so of sourceObjects) {
2088
2526
  let name: string | undefined;
2089
- if (so.SourceObjectID && idToName.has(so.SourceObjectID)) {
2090
- name = idToName.get(so.SourceObjectID);
2527
+ if (so.SourceObjectID && idToName.has(so.SourceObjectID.toUpperCase())) {
2528
+ name = idToName.get(so.SourceObjectID.toUpperCase());
2091
2529
  } else if (so.SourceObjectName) {
2092
2530
  // Normalize case against the schema when the connector reports it
2093
2531
  name = schemaNameMap.get(so.SourceObjectName.toLowerCase()) ?? so.SourceObjectName;
@@ -2121,20 +2559,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2121
2559
  ): Promise<void> {
2122
2560
  const idsToResolve = objects.filter(o => o.SourceObjectID && !o.SourceObjectName).map(o => o.SourceObjectID!);
2123
2561
  if (idsToResolve.length > 0) {
2124
- const rv = new RunView();
2125
- const result = await rv.RunView<{ ID: string; Name: string }>({
2126
- EntityName: 'MJ: Integration Objects',
2127
- ExtraFilter: idsToResolve.map(id => `ID='${id}'`).join(' OR '),
2128
- ResultType: 'simple',
2129
- Fields: ['ID', 'Name'],
2130
- }, user);
2131
- if (result.Success) {
2132
- const idToName = new Map(result.Results.map(r => [r.ID.toUpperCase(), r.Name]));
2133
- for (const obj of objects) {
2134
- if (obj.SourceObjectID) {
2135
- const resolved = idToName.get(obj.SourceObjectID.toUpperCase());
2136
- if (resolved) obj.SourceObjectName = resolved;
2137
- }
2562
+ const idToName = await LookupObjectNamesByID(idsToResolve.map(id => sqlUuid(id, 'ID')), user);
2563
+ for (const obj of objects) {
2564
+ if (obj.SourceObjectID) {
2565
+ const resolved = idToName.get(obj.SourceObjectID.toUpperCase());
2566
+ if (resolved) obj.SourceObjectName = resolved;
2138
2567
  }
2139
2568
  }
2140
2569
  }
@@ -2191,8 +2620,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2191
2620
  const rv = new RunView();
2192
2621
  const result = await rv.RunView<MJCompanyIntegrationEntity>({
2193
2622
  EntityName: 'MJ: Company Integrations',
2194
- ExtraFilter: `ID='${companyIntegrationID}'`,
2623
+ ExtraFilter: `ID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
2195
2624
  MaxRows: 1,
2625
+ SkipTotalRowCount: true,
2196
2626
  ResultType: 'simple',
2197
2627
  Fields: ['ID']
2198
2628
  }, user);
@@ -2203,23 +2633,27 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2203
2633
  }
2204
2634
 
2205
2635
  /**
2206
- * Authorizes the caller for a single run artifact based on the run's
2207
- * manifest CompanyIntegrationID. Returns true when the run is tenant-scoped
2208
- * and the caller is authorized for that CompanyIntegration. Returns false
2209
- * for tenant-scoped runs the caller may not read, OR for runs with no
2210
- * CompanyIntegrationID (non-tenant-scoped artifacts are not exposed through
2211
- * these per-company endpoints).
2636
+ * Authorizes the caller for a single run artifact against EVERY connection the run touched.
2637
+ *
2638
+ * The rule itself — AND over the run's whole connection set, and why anything weaker leaks —
2639
+ * lives in {@link AuthorizeRunArtifact}. This method supplies the per-connection check, which is
2640
+ * the part that needs the caller's context and the data provider's row-level security.
2212
2641
  */
2642
+ private async authorizeRunArtifact(
2643
+ snap: IntegrationRunSnapshot,
2644
+ user: UserInfo,
2645
+ cache: Map<string, boolean>
2646
+ ): Promise<RunArtifactAuthorizationResult> {
2647
+ return AuthorizeRunArtifact(snap.manifest, ciID => this.userCanReadCompanyIntegration(ciID, user, cache));
2648
+ }
2649
+
2650
+ /** Boolean form of {@link authorizeRunArtifact}, for the list path which needs no message. */
2213
2651
  private async userCanReadRunArtifact(
2214
2652
  snap: IntegrationRunSnapshot,
2215
2653
  user: UserInfo,
2216
2654
  cache: Map<string, boolean>
2217
2655
  ): Promise<boolean> {
2218
- const ciID = snap.manifest.companyIntegrationID;
2219
- if (!ciID) {
2220
- return false;
2221
- }
2222
- return this.userCanReadCompanyIntegration(ciID, user, cache);
2656
+ return (await this.authorizeRunArtifact(snap, user, cache)).Authorized;
2223
2657
  }
2224
2658
 
2225
2659
  /** Standard authorization-failure message for run-artifact endpoints. */
@@ -2227,6 +2661,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2227
2661
  return `Not authorized to access runs for CompanyIntegration '${companyIntegrationID}'`;
2228
2662
  }
2229
2663
 
2664
+ /** The denial message for a run, naming the connection that blocked it when there is one. */
2665
+ private notAuthorizedForRunMessage(runID: string, deniedCompanyIntegrationID?: string): string {
2666
+ return deniedCompanyIntegrationID
2667
+ ? this.notAuthorizedForCompanyIntegrationMessage(deniedCompanyIntegrationID)
2668
+ : `Not authorized to access run '${runID}'`;
2669
+ }
2670
+
2230
2671
  /**
2231
2672
  * Loads the CompanyIntegration + its parent Integration, then resolves the
2232
2673
  * appropriate connector via ConnectorFactory.
@@ -2337,11 +2778,16 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2337
2778
  * `IsPrimaryKey=true`.
2338
2779
  * - Foreign-key relationships are reconstructed from
2339
2780
  * `RelatedIntegrationObjectID` lookups against the same cache.
2781
+ * - Under a per-connection catalog the FIELD rows come from `scopedFields`
2782
+ * (`LoadScopedFieldsForBuild`), never from the engine: a connection's field rows
2783
+ * are warmed on demand by the sync loop only, so the engine answers `[]` for them
2784
+ * during an apply and every object would build with no columns.
2340
2785
  */
2341
2786
  private buildSourceSchemaFromPersistedRows(
2342
2787
  integrationID: string,
2343
2788
  requestedNames?: string[],
2344
2789
  warningsOut?: string[],
2790
+ scopedFields?: ScopedBuildFields | null,
2345
2791
  ): SourceSchemaInfo {
2346
2792
  const engine = IntegrationEngineBase.Instance;
2347
2793
  // ACTIVE-only materialization: an object/field a given tenant doesn't expose is marked
@@ -2360,15 +2806,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2360
2806
  for (const io of ios) ioByID.set(io.ID, io.Name);
2361
2807
 
2362
2808
  const result: SourceSchemaInfo = { Objects: [] };
2363
- const fieldsByObjectName: Record<string, Array<{ Name: string; Status: string | null }>> = {};
2364
2809
  for (const io of ios) {
2365
2810
  if (filter && !filter.has(io.Name.toLowerCase())) continue;
2366
2811
  // Active fields only — an inactive (source-absent / deactivated) field is not materialized.
2367
- const allFields = engine.GetIntegrationObjectFields(io.ID);
2812
+ const allFields = FieldsForBuild(io.ID, scopedFields, engine);
2368
2813
  const iofs = allFields.filter(iof => iof.Status === 'Active');
2369
- // Remember what this object declared, active or not — the caller's warning collector
2370
- // turns the difference into the one message that explains a column that never appeared.
2371
- if (warningsOut) fieldsByObjectName[io.Name] = allFields.map(f => ({ Name: f.Name, Status: f.Status }));
2372
2814
 
2373
2815
  const fields = iofs.map(iof => {
2374
2816
  const targetIOName = iof.RelatedIntegrationObjectID
@@ -2411,19 +2853,63 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2411
2853
  }
2412
2854
 
2413
2855
  // Declared-but-inactive rows this rebuild left out. The caller decides what to do with the
2414
- // strings (the apply path puts them on its Warnings); computing them costs nothing when no
2415
- // collector was passed, since the field lists above are only gathered then.
2416
- if (warningsOut) {
2417
- warningsOut.push(...ComputeInactiveRowWarnings({
2418
- RequestedNames: requestedNames ?? null,
2419
- AllObjects: engine.GetIntegrationObjectsByIntegrationID(integrationID)
2420
- .map(io => ({ Name: io.Name, Status: io.Status })),
2421
- FieldsByObjectName: fieldsByObjectName,
2422
- }));
2423
- }
2856
+ // strings (the apply path puts them on its Warnings); nothing is computed when no collector
2857
+ // was passed.
2858
+ if (warningsOut) warningsOut.push(...this.collectInactiveRowWarnings(integrationID, requestedNames, scopedFields));
2424
2859
  return result;
2425
2860
  }
2426
2861
 
2862
+ /**
2863
+ * Brings the integration catalog caches to committed state, then re-configures the engine from it.
2864
+ *
2865
+ * Both halves are needed and the ORDER is the point. `mj sync push` writes IO/IOF rows through
2866
+ * stored procedures, which fire no `BaseEntity` change events, so nothing invalidates the RunView
2867
+ * cache and `Config(true)` re-queries straight back into the stale entries. The per-connection
2868
+ * catalog entities are guarded individually because a workspace without that migration has not
2869
+ * registered them, and a missing entity must not fail an apply.
2870
+ *
2871
+ * One method so the single apply and the batch apply cannot drift apart on it again.
2872
+ */
2873
+ private async refreshIntegrationCatalogCaches(user: UserInfo, provider?: IMetadataProvider): Promise<void> {
2874
+ await LocalCacheManager.Instance.InvalidateEntityCaches('MJ: Integration Objects');
2875
+ await LocalCacheManager.Instance.InvalidateEntityCaches('MJ: Integration Object Fields');
2876
+ for (const perConnection of ['MJ: Company Integration Objects', 'MJ: Company Integration Object Fields']) {
2877
+ try { await LocalCacheManager.Instance.InvalidateEntityCaches(perConnection); } catch { /* not registered here */ }
2878
+ }
2879
+ await IntegrationEngine.Instance.Config(true, user, provider);
2880
+ }
2881
+
2882
+ /**
2883
+ * The declared-but-inactive-row warnings for an apply, computed from the catalog rather than from
2884
+ * whichever schema object the apply happens to be holding.
2885
+ *
2886
+ * Separate from {@link buildSourceSchemaFromPersistedRows} because the two are independent
2887
+ * questions, and conflating them cost the batch path its warnings entirely: the batch builds its
2888
+ * source schema once and hands it down as `prefetchedSourceSchema`, which skips the rebuild — and
2889
+ * with it skipped the only place these warnings were ever collected. The warnings come from the
2890
+ * IO/IOF rows' `Status`, which is knowable whenever the integration and the requested names are,
2891
+ * so it never needed to ride along with a rebuild.
2892
+ *
2893
+ * `requestedNames` empty/undefined means "everything active", which deliberately does NOT report
2894
+ * deactivated objects (a large catalog carries hundreds and announcing them on every apply is
2895
+ * noise) — see ComputeInactiveRowWarnings.
2896
+ *
2897
+ * `scopedFields` is the connection's field rows when the apply runs under a per-connection catalog
2898
+ * (`LoadScopedFieldsForBuild`). The FIELD half of the warnings reads through it for the same reason
2899
+ * the rebuild does: the engine answers `[]` for a connection's fields during an apply, so reading
2900
+ * them from the engine would silently drop every field warning. Without it the engine is read, as
2901
+ * for the shared catalog.
2902
+ */
2903
+ private collectInactiveRowWarnings(integrationID: string, requestedNames?: string[], scopedFields?: ScopedBuildFields | null): string[] {
2904
+ const engine = IntegrationEngineBase.Instance;
2905
+ if (!scopedFields) return CollectInactiveRowWarnings(engine, integrationID, requestedNames);
2906
+ return CollectInactiveRowWarnings({
2907
+ GetActiveIntegrationObjects: id => engine.GetActiveIntegrationObjects(id),
2908
+ GetIntegrationObjectsByIntegrationID: id => engine.GetIntegrationObjectsByIntegrationID(id),
2909
+ GetIntegrationObjectFields: objectID => FieldsForBuild(objectID, scopedFields, engine),
2910
+ }, integrationID, requestedNames);
2911
+ }
2912
+
2427
2913
  private async runSchemaRefreshPipeline(
2428
2914
  companyIntegrationID: string,
2429
2915
  user: UserInfo,
@@ -2630,7 +3116,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2630
3116
  // Shared-credential safety: do not delete if any OTHER CompanyIntegration uses it.
2631
3117
  const sharedResult = await rv.RunView<MJCompanyIntegrationEntity>({
2632
3118
  EntityName: 'MJ: Company Integrations',
2633
- ExtraFilter: `CredentialID='${credentialID}' AND ID<>'${companyIntegrationID}'`,
3119
+ ExtraFilter: `CredentialID='${sqlUuid(credentialID, 'credentialID')}' AND ID<>'${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
2634
3120
  ResultType: 'simple',
2635
3121
  Fields: ['ID']
2636
3122
  }, user);
@@ -2715,7 +3201,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2715
3201
  // Filter stays server-side; no client-side `.filter()` post-pass.
2716
3202
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as DatabaseProviderBase;
2717
3203
  const filters: string[] = [];
2718
- if (companyID) filters.push(`CompanyID='${companyID}'`);
3204
+ if (companyID) filters.push(`CompanyID='${sqlUuid(companyID, 'companyID')}'`);
2719
3205
  if (activeOnly) filters.push(`IsActive = ${provider.Dialect.BooleanLiteral(true)}`);
2720
3206
  const filter = filters.join(' AND ');
2721
3207
  const result = await rv.RunView<MJCompanyIntegrationEntity>({
@@ -3067,7 +3553,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3067
3553
  const rv = new RunView();
3068
3554
  const res = await rv.RunView<{ Entity: string }>({
3069
3555
  EntityName: 'MJ: Company Integration Entity Maps',
3070
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
3556
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
3071
3557
  Fields: ['Entity'],
3072
3558
  ResultType: 'simple',
3073
3559
  }, user);
@@ -3088,6 +3574,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3088
3574
  @Arg("entityName", { nullable: true }) entityName: string | undefined,
3089
3575
  @Ctx() ctx: AppContext
3090
3576
  ): Promise<CustomColumnCandidatesOutput> {
3577
+ return WithCatalogScope(companyIntegrationID, async () => {
3091
3578
  try {
3092
3579
  const user = this.getAuthenticatedUser(ctx);
3093
3580
  const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
@@ -3102,6 +3589,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3102
3589
  LogError(`IntegrationListCustomColumnCandidates error: ${e}`);
3103
3590
  return { Success: false, Message: this.formatError(e), Candidates: [] };
3104
3591
  }
3592
+ });
3105
3593
  }
3106
3594
 
3107
3595
  /**
@@ -3118,6 +3606,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3118
3606
  @Arg("entityNames", () => [String], { nullable: true }) entityNames: string[] | undefined,
3119
3607
  @Ctx() ctx: AppContext
3120
3608
  ): Promise<PromoteCustomColumnsOutput> {
3609
+ return WithCatalogScope(companyIntegrationID, async () => {
3121
3610
  try {
3122
3611
  const user = this.getAuthenticatedUser(ctx);
3123
3612
  const provider = GetReadWriteProvider(ctx.providers) as unknown as IMetadataProvider;
@@ -3138,28 +3627,118 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3138
3627
  LogError(`IntegrationPromoteCustomColumns error: ${e}`);
3139
3628
  return { Success: false, Message: this.formatError(e), Promoted: false, ColumnsAdded: [], SchemaUpdatePending: false };
3140
3629
  }
3630
+ });
3141
3631
  }
3142
3632
 
3143
3633
  /**
3144
- * Soft-deletes a CompanyIntegration by setting IsActive=false.
3634
+ * Pauses a connection: IsActive=false, both scheduled jobs paused, and a stop requested for
3635
+ * any sync already in flight.
3636
+ *
3637
+ * It used to be the first of those three only. The engine checks IsActive before a sync, so
3638
+ * manual syncs did stop — but the CRON jobs kept firing (a sync schedule woke, was refused,
3639
+ * and wrote a failed run, every tick, for as long as the connection stayed paused), the
3640
+ * DISCOVERY schedule kept firing and was not refused at all (a paused connection went on
3641
+ * rescanning the vendor on a timer, spending rate budget and rewriting the catalog the pause
3642
+ * was meant to freeze), and an in-flight sync ran to completion.
3643
+ *
3644
+ * ORDER IS DELIBERATE. IsActive is written FIRST, because it is the flag every other actor
3645
+ * reads: once it is false, nothing new can start, so a failure in any later step degrades the
3646
+ * pause rather than voiding it. The schedules come next. The cancel is last and is
3647
+ * best-effort by nature — a running sync belongs to whichever process owns it.
3648
+ *
3649
+ * WHAT PAUSE DOES NOT TOUCH: entity maps. Their Status is the user's own selection of which
3650
+ * tables sync, and a pause that silently deselected tables would be undone wrong on resume.
3651
+ *
3652
+ * The reply distinguishes a stop that will happen from one that will not — see
3653
+ * {@link DeactivateConnectionOutput}.
3145
3654
  */
3146
- @Mutation(() => MutationResultOutput)
3655
+ @Mutation(() => DeactivateConnectionOutput)
3147
3656
  async IntegrationDeactivateConnection(
3148
3657
  @Arg("companyIntegrationID") companyIntegrationID: string,
3149
3658
  @Ctx() ctx: AppContext
3150
- ): Promise<MutationResultOutput> {
3659
+ ): Promise<DeactivateConnectionOutput> {
3660
+ const empty = { SchedulesPaused: 0, CancelRequested: false, CancelScope: 'none' as CancelScope };
3151
3661
  try {
3152
3662
  const user = this.getAuthenticatedUser(ctx);
3153
3663
  const md = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
3154
3664
  const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
3155
3665
  const loaded = await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID));
3156
- if (!loaded) return { Success: false, Message: 'CompanyIntegration not found' };
3666
+ if (!loaded) return { Success: false, Message: 'CompanyIntegration not found', ...empty };
3667
+
3668
+ // 1. The flag first — see ORDER IS DELIBERATE above.
3157
3669
  ci.IsActive = false;
3158
- if (!await ci.Save()) return { Success: false, Message: 'Failed to deactivate' };
3159
- return { Success: true, Message: 'Deactivated' };
3670
+ if (!await ci.Save()) return { Success: false, Message: 'Failed to deactivate', ...empty };
3671
+
3672
+ const notes: string[] = [];
3673
+
3674
+ // 2. Schedules. A pause of an already-paused connection moves nothing and must not
3675
+ // overwrite the record the FIRST pause left — DecidePauseWrite is that guard.
3676
+ // The record is saved BEFORE any job moves: a job paused without a saved record is
3677
+ // one resume can never put back, so a record that will not save pauses nothing.
3678
+ let schedulesPaused = 0;
3679
+ try {
3680
+ const jobs = await this.findScheduledJobsForConnection(companyIntegrationID, user);
3681
+ const toPause = DecideSchedulesToPause(jobs);
3682
+ const stored = ReadPausedSchedules(ci.Configuration);
3683
+ const write = DecidePauseWrite(stored, toPause);
3684
+ if (write) {
3685
+ ci.Configuration = WritePausedSchedules(ci.Configuration, write);
3686
+ if (!await ci.Save()) {
3687
+ throw new Error(`recording the schedules to pause failed: ${ci.LatestResult?.Message ?? 'unknown error'}`);
3688
+ }
3689
+ }
3690
+ for (const rec of toPause) {
3691
+ if (await this.setScheduledJobStatus(rec.ID, 'Paused', user, md)) schedulesPaused++;
3692
+ }
3693
+ // Mirror onto the connection row the way the post-restart scheduler does, so a
3694
+ // reader that only knows about ScheduleEnabled still sees a paused connection.
3695
+ // The record is already saved, so a failure here costs only the mirror.
3696
+ if (schedulesPaused > 0) {
3697
+ ci.ScheduleEnabled = false;
3698
+ if (!await ci.Save()) {
3699
+ LogError(`IntegrationDeactivateConnection: clearing ScheduleEnabled failed — ${ci.LatestResult?.Message ?? 'unknown error'}`);
3700
+ }
3701
+ }
3702
+ notes.push(DescribePauseOutcome(toPause, stored.length > 0 && toPause.length === 0));
3703
+ } catch (schedErr) {
3704
+ // A schedule we could not pause is worth saying out loud: the connection IS
3705
+ // paused, but a cron may still wake it and write refused runs.
3706
+ LogError(`IntegrationDeactivateConnection: pausing schedules failed — ${schedErr}`);
3707
+ notes.push('Could not pause the schedules — a scheduled run may still fire and be refused.');
3708
+ }
3709
+
3710
+ // 3. Cancel whatever is running, and report how far the request actually reached.
3711
+ let inFlightRunID: string | undefined;
3712
+ let scope: CancelScope = 'none';
3713
+ try {
3714
+ const live = await IntegrationEngine.GetSyncProgressAsync(companyIntegrationID, user, md);
3715
+ if (live) {
3716
+ inFlightRunID = live.RunID ?? undefined;
3717
+ const durable = await IntegrationEngine.CancelSyncAsync(companyIntegrationID, user, md);
3718
+ scope = DescribeCancelScope({
3719
+ liveRunPresent: true,
3720
+ durableRequestRecorded: durable,
3721
+ runningInThisProcess: IntegrationEngine.IsSyncRunningInThisProcess(companyIntegrationID),
3722
+ });
3723
+ const sentence = DescribeCancelOutcome(scope);
3724
+ if (sentence) notes.push(sentence);
3725
+ }
3726
+ } catch (cancelErr) {
3727
+ LogError(`IntegrationDeactivateConnection: cancel request failed — ${cancelErr}`);
3728
+ notes.push('A sync may still be running; no new sync will start.');
3729
+ }
3730
+
3731
+ return {
3732
+ Success: true,
3733
+ Message: ['Deactivated.', ...notes.filter(Boolean)].join(' '),
3734
+ SchedulesPaused: schedulesPaused,
3735
+ InFlightSyncRunID: inFlightRunID,
3736
+ CancelRequested: scope === 'durable' || scope === 'this-process',
3737
+ CancelScope: scope,
3738
+ };
3160
3739
  } catch (e) {
3161
3740
  LogError(`IntegrationDeactivateConnection error: ${e}`);
3162
- return { Success: false, Message: this.formatError(e) };
3741
+ return { Success: false, Message: this.formatError(e), ...empty };
3163
3742
  }
3164
3743
  }
3165
3744
 
@@ -3219,25 +3798,53 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3219
3798
  ci.IsActive = true;
3220
3799
  if (!await ci.Save()) return { Success: false, Message: `Failed to reactivate: ${ci.LatestResult?.Message ?? 'Unknown error'}` };
3221
3800
 
3801
+ // Put back EXACTLY what the pause took. Not "set every schedule Active": a connection
3802
+ // can be paused while one of its schedules was already off because the operator turned
3803
+ // it off, and inferring would silently restart work nobody asked for. The record
3804
+ // written at pause is the only thing that can tell those apart.
3805
+ let schedulesResumed = 0;
3806
+ let scheduleNote = '';
3807
+ try {
3808
+ const stored = ReadPausedSchedules(ci.Configuration);
3809
+ if (stored.length > 0) {
3810
+ const jobs = await this.findScheduledJobsForConnection(companyIntegrationID, user);
3811
+ for (const id of DecideSchedulesToResume(stored, jobs)) {
3812
+ if (await this.setScheduledJobStatus(id, 'Active', user, md)) schedulesResumed++;
3813
+ }
3814
+ // Consume the record whether or not every job came back: a job that was
3815
+ // deleted while paused is never coming back, and keeping the record would make
3816
+ // the next pause a no-op via DecidePauseWrite's already-paused guard. That is
3817
+ // also why a record that will not clear is said out loud rather than logged.
3818
+ ci.Configuration = WritePausedSchedules(ci.Configuration, []);
3819
+ if (schedulesResumed > 0) ci.ScheduleEnabled = true;
3820
+ if (!await ci.Save()) {
3821
+ LogError(`IntegrationReactivateConnection: clearing the paused-schedule record failed — ${ci.LatestResult?.Message ?? 'unknown error'}`);
3822
+ scheduleNote = ' The record of paused schedules could not be cleared, so the next pause will not record the schedules it stops.';
3823
+ }
3824
+ }
3825
+ } catch (resumeErr) {
3826
+ LogError(`IntegrationReactivateConnection: restoring schedules failed — ${resumeErr}`);
3827
+ }
3828
+
3222
3829
  if (runSchemaRefresh && !awaitSchemaRefresh) {
3223
3830
  const detached = this.startSchemaRefreshPipelineDetached(companyIntegrationID, user, md);
3224
- return { Success: true, Message: BuildReactivateMessage(detached) };
3831
+ return { Success: true, Message: BuildReactivateMessage(detached) + scheduleNote };
3225
3832
  }
3226
3833
 
3227
3834
  if (runSchemaRefresh) {
3228
3835
  try {
3229
3836
  const refreshResult = await this.runSchemaRefreshPipeline(companyIntegrationID, user, md);
3230
- return { Success: true, Message: BuildReactivateMessage(refreshResult) };
3837
+ return { Success: true, Message: BuildReactivateMessage(refreshResult) + scheduleNote };
3231
3838
  } catch (refreshErr) {
3232
3839
  // Refresh failure does NOT undo the reactivation — the
3233
3840
  // connection is active, only discovery failed, and the
3234
3841
  // operator can re-run IntegrationRefreshConnectorSchema.
3235
3842
  LogError(`IntegrationReactivateConnection: pipeline error — ${refreshErr}`);
3236
- return { Success: true, Message: `Reactivated (schema refresh failed: ${this.formatError(refreshErr)})` };
3843
+ return { Success: true, Message: `Reactivated (schema refresh failed: ${this.formatError(refreshErr)})${scheduleNote}` };
3237
3844
  }
3238
3845
  }
3239
3846
 
3240
- return { Success: true, Message: BuildReactivateMessage(undefined) };
3847
+ return { Success: true, Message: BuildReactivateMessage(undefined) + scheduleNote };
3241
3848
  } catch (e) {
3242
3849
  LogError(`IntegrationReactivateConnection error: ${e}`);
3243
3850
  return { Success: false, Message: this.formatError(e) };
@@ -3256,6 +3863,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3256
3863
  @Arg("entityMaps", () => [EntityMapInput]) entityMaps: EntityMapInput[],
3257
3864
  @Ctx() ctx: AppContext
3258
3865
  ): Promise<CreateEntityMapsOutput> {
3866
+ return WithCatalogScope(companyIntegrationID, async () => {
3259
3867
  try {
3260
3868
  const user = this.getAuthenticatedUser(ctx);
3261
3869
  const md = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
@@ -3298,9 +3906,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3298
3906
  const escapedObjectName = mapDef.ExternalObjectName.replace(/'/g, "''");
3299
3907
  const existingMapResult = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
3300
3908
  EntityName: 'MJ: Company Integration Entity Maps',
3301
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND ExternalObjectName='${escapedObjectName}'`,
3909
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}' AND ExternalObjectName='${escapedObjectName}'`,
3302
3910
  OrderBy: '__mj_CreatedAt ASC',
3303
3911
  MaxRows: 1,
3912
+ SkipTotalRowCount: true,
3304
3913
  ResultType: 'entity_object',
3305
3914
  BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
3306
3915
  }, user);
@@ -3329,7 +3938,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3329
3938
  if (mapDef.FieldMaps) {
3330
3939
  const existingFieldMaps = await new RunView().RunView<{ SourceFieldName: string }>({
3331
3940
  EntityName: 'MJ: Company Integration Field Maps',
3332
- ExtraFilter: `EntityMapID='${entityMapID}'`,
3941
+ ExtraFilter: `EntityMapID='${sqlUuid(entityMapID, 'entityMapID')}'`,
3333
3942
  Fields: ['SourceFieldName'],
3334
3943
  ResultType: 'simple',
3335
3944
  BypassCache: true, // read committed field-map state for the idempotency skip
@@ -3363,6 +3972,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3363
3972
  LogError(`IntegrationCreateEntityMaps error: ${e}`);
3364
3973
  return { Success: false, Message: this.formatError(e) };
3365
3974
  }
3975
+ });
3366
3976
  }
3367
3977
 
3368
3978
  // ── SCHEMA EXECUTION ────────────────────────────────────────────────
@@ -3385,6 +3995,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3385
3995
  @Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
3386
3996
  @Ctx() ctx: AppContext
3387
3997
  ): Promise<ApplySchemaOutput> {
3998
+ return WithCatalogScope(companyIntegrationID, async () => {
3388
3999
  try {
3389
4000
  const user = this.getAuthenticatedUser(ctx);
3390
4001
  const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
@@ -3401,6 +4012,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3401
4012
  companyIntegration.IntegrationID,
3402
4013
  Array.from(requestedNames),
3403
4014
  inactiveWarnings,
4015
+ await LoadScopedFieldsForBuild(companyIntegration.IntegrationID, user),
3404
4016
  );
3405
4017
  if (sourceSchema.Objects.length === 0) {
3406
4018
  LogError(`[IntegrationApplySchema] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
@@ -3419,12 +4031,19 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3419
4031
 
3420
4032
  await this.resolveObjectInputs(objects, sourceSchema, user);
3421
4033
 
4034
+ // Filtered on the names AFTER resolveObjectInputs has corrected their casing, as
4035
+ // buildSchemaForConnector does. The raw input set is case-sensitive, so a caller passing
4036
+ // `Clicks` for a stored `clicks` selected nothing, and NO_TARGET_TABLES then told them to
4037
+ // re-run discovery, which cannot fix a casing mismatch.
4038
+ const resolvedNames = new Set(objects.map(o => o.SourceObjectName));
3422
4039
  const filteredSchema: SourceSchemaInfo = {
3423
- Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
4040
+ Objects: sourceSchema.Objects.filter(o => resolvedNames.has(o.ExternalName))
3424
4041
  };
3425
4042
 
3426
4043
  const validatedPlatform = this.validatePlatform(platform);
3427
- const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, validatedPlatform, connector);
4044
+ const drops = NewTargetConfigDrops();
4045
+ const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, validatedPlatform, connector, drops);
4046
+ AssertTargetConfigs(objects, targetConfigs, companyIntegration.Integration, drops);
3428
4047
 
3429
4048
  const input: SchemaBuilderInput = {
3430
4049
  SourceSchema: filteredSchema,
@@ -3467,6 +4086,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3467
4086
  LogError(`IntegrationApplySchema error: ${e}`);
3468
4087
  return { Success: false, Message: this.formatError(e) };
3469
4088
  }
4089
+ });
3470
4090
  }
3471
4091
 
3472
4092
  /**
@@ -3493,9 +4113,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3493
4113
  // Phase 1: Build schema artifacts for each connector's objects
3494
4114
  for (const item of items) {
3495
4115
  try {
3496
- const { schemaOutput, rsuInput } = await this.buildSchemaForConnector(
4116
+ const { schemaOutput, rsuInput } = await WithCatalogScope(item.CompanyIntegrationID, () => this.buildSchemaForConnector(
3497
4117
  item.CompanyIntegrationID, item.Objects, validatedPlatform, user, skipGitCommit, skipRestart, provider
3498
- );
4118
+ ));
3499
4119
  pipelineInputs.push(rsuInput);
3500
4120
  itemResults.push({
3501
4121
  CompanyIntegrationID: item.CompanyIntegrationID,
@@ -3562,6 +4182,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3562
4182
  @Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
3563
4183
  @Ctx() ctx: AppContext
3564
4184
  ): Promise<ApplyAllOutput> {
4185
+ return WithCatalogScope(input.CompanyIntegrationID, async () => {
3565
4186
  try {
3566
4187
  const user = this.getAuthenticatedUser(ctx);
3567
4188
  const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
@@ -3571,22 +4192,47 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3571
4192
  const { connector, companyIntegration } = await this.resolveConnector(input.CompanyIntegrationID, user, provider);
3572
4193
  const schemaName = this.deriveSchemaName(companyIntegration.Integration);
3573
4194
 
3574
- // Step 1b: Ensure IntegrationEngine cache is populated so the persisted
3575
- // IO/IOF rows are available for reconstruction below.
3576
- await IntegrationEngine.Instance.Config(false, user);
4195
+ // Step 1b: Ensure IntegrationEngine cache holds the CURRENT IO/IOF rows, because the
4196
+ // reconstruction below is entirely made of them.
4197
+ //
4198
+ // This used to be `Config(false)` — "configure if not already configured" — so an apply
4199
+ // in a process that had configured the engine at boot rebuilt its source schema from a
4200
+ // snapshot of arbitrary age, and an apply that followed an `mj sync push` (whose stored
4201
+ // procedures fire no BaseEntity change events, so no cache is invalidated) rebuilt it
4202
+ // from rows that no longer existed. The BATCH mutation has always invalidated and forced;
4203
+ // the single one is not entitled to a cheaper answer to the same question.
4204
+ await this.refreshIntegrationCatalogCaches(user, provider);
3577
4205
 
3578
4206
  // Step 2: Reconstruct SourceSchemaInfo from the persisted IO/IOF rows
3579
4207
  // (already freshened by the Phase 0 v5.39.x MJCompanyIntegrationEntityServer
3580
4208
  // Save hook on IsActive false→true). Avoids the duplicate vendor-API
3581
4209
  // introspect that used to fire here.
3582
- let sourceSchema: SourceSchemaInfo = this.buildSourceSchemaFromPersistedRows(companyIntegration.IntegrationID);
4210
+ let sourceSchema: SourceSchemaInfo = this.buildSourceSchemaFromPersistedRows(
4211
+ companyIntegration.IntegrationID,
4212
+ undefined,
4213
+ undefined,
4214
+ await LoadScopedFieldsForBuild(companyIntegration.IntegrationID, user),
4215
+ );
3583
4216
  if (sourceSchema.Objects.length === 0) {
3584
4217
  // Fallback: the engine cache is empty (Save hook didn't run, or this
3585
4218
  // is a direct-API caller bypassing the wizard). Do a one-time live
3586
4219
  // introspect + persist + action-generation so the apply still proceeds.
3587
4220
  LogError(`[IntegrationApplyAll] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
4221
+ // FILTERED, by the same decision the batch mutation uses. This fallback described the
4222
+ // vendor's ENTIRE surface and then threw all but the selected objects away — about 70
4223
+ // seconds of describes on a large Salesforce account, for a selection of two or three.
4224
+ // The gate is deliberately the batch's own `shouldUseFilteredIntrospection` rather than
4225
+ // a new rule: a connector with dozens of objects is fine describing them all, and
4226
+ // narrowing the describe for one that ISN'T on the filtered flow would change which
4227
+ // objects a legacy apply persists.
4228
+ const filteredNames = this.shouldUseFilteredIntrospection(connector, input.SourceObjects)
4229
+ ? (await this.resolveSelectionPlan(input.SourceObjects, user)).map(p => p.Name)
4230
+ : [];
3588
4231
  sourceSchema = await (connector.IntrospectSchema.bind(connector) as
3589
- (ci: unknown, u: unknown) => Promise<SourceSchemaInfo>)(companyIntegration, user);
4232
+ (ci: unknown, u: unknown, opts?: { ObjectNames?: string[] }) => Promise<SourceSchemaInfo>)(
4233
+ companyIntegration, user,
4234
+ filteredNames.length > 0 ? { ObjectNames: filteredNames } : undefined,
4235
+ );
3590
4236
  try {
3591
4237
  const persistResult = await IntegrationSchemaSync.PersistDiscoveredSchema({
3592
4238
  IntegrationID: companyIntegration.IntegrationID,
@@ -3647,6 +4293,16 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3647
4293
 
3648
4294
  const resolved = await this.resolveSourceObjectsToNames(input.SourceObjects, sourceSchema, user);
3649
4295
  const resolvedNames = resolved.names;
4296
+ // An apply that resolved nothing builds nothing: refuse it here, before the migration,
4297
+ // CodeGen and the restart, which otherwise all run for a Success with 0 tables.
4298
+ if (input.SourceObjects.length > 0 && resolvedNames.length === 0) {
4299
+ return {
4300
+ Success: false,
4301
+ Message: `Apply refused: none of the ${input.SourceObjects.length} selected object(s) resolved to this ` +
4302
+ `connection's catalog (${resolved.droppedCount} dropped) — nothing was built. Re-open the table ` +
4303
+ `picker and select the tables again.`,
4304
+ };
4305
+ }
3650
4306
 
3651
4307
  // Build SchemaPreviewObjectInput with Fields from the matching
3652
4308
  // SourceObjectInput (resolved.sourceObjects is order-aligned with names).
@@ -3682,9 +4338,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3682
4338
  // map existed for this connection before this apply. Explicit input always wins.
3683
4339
  const priorMapsResult = await new RunView().RunView<{ ID: string }>({
3684
4340
  EntityName: 'MJ: Company Integration Entity Maps',
3685
- ExtraFilter: `CompanyIntegrationID='${input.CompanyIntegrationID.replace(/'/g, "''")}'`,
4341
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(input.CompanyIntegrationID, 'CompanyIntegrationID')}'`,
3686
4342
  Fields: ['ID'],
3687
4343
  MaxRows: 1,
4344
+ SkipTotalRowCount: true,
3688
4345
  ResultType: 'simple',
3689
4346
  BypassCache: true,
3690
4347
  }, user);
@@ -3791,7 +4448,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3791
4448
  SyncRunID: syncRunID ?? undefined,
3792
4449
  ScheduledJobID: scheduledJobID,
3793
4450
  GitCommitSuccess: batchResult.Results[0]?.GitCommitSuccess,
3794
- APIRestarted: false,
4451
+ // READ, not assumed — the same field the batch mutation returns. The pipeline sets
4452
+ // it only when the restart step actually succeeded, and `skipRestart` is a request,
4453
+ // not a record of what happened.
4454
+ APIRestarted: batchResult.Results[0]?.APIRestarted ?? false,
3795
4455
  Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
3796
4456
  };
3797
4457
  }
@@ -3803,13 +4463,18 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3803
4463
  Message: `Applied ${objects.length} object(s) — entity maps will be created after restart`,
3804
4464
  Steps: pipelineSteps,
3805
4465
  GitCommitSuccess: batchResult.Results[0]?.GitCommitSuccess,
3806
- APIRestarted: true,
4466
+ // Reaching this line means pm2 has NOT killed us yet — which is also exactly what a
4467
+ // FAILED restart looks like. Hardcoding `true` here reported a restart that never
4468
+ // happened, and the comment above conceded the guess; RunPipelineBatch sets
4469
+ // APIRestarted only when restartMJAPI() returned ok, so read it.
4470
+ APIRestarted: batchResult.Results[0]?.APIRestarted ?? false,
3807
4471
  Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
3808
4472
  };
3809
4473
  } catch (e) {
3810
4474
  LogError(`IntegrationApplyAll error: ${e}`);
3811
4475
  return { Success: false, Message: this.formatError(e) };
3812
4476
  }
4477
+ });
3813
4478
  }
3814
4479
 
3815
4480
  /** Derives a SQL-safe schema name from the integration name (e.g., "HubSpot" → "hubspot"). */
@@ -3856,7 +4521,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3856
4521
  const existingMapByExternalName = new Map<string, MJCompanyIntegrationEntityMapEntity>();
3857
4522
  const allMaps = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
3858
4523
  EntityName: 'MJ: Company Integration Entity Maps',
3859
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
4524
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
3860
4525
  ResultType: 'entity_object',
3861
4526
  BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
3862
4527
  }, user);
@@ -3915,9 +4580,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3915
4580
  const escapedObjectName = obj.SourceObjectName.replace(/'/g, "''");
3916
4581
  const existingMapResult = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
3917
4582
  EntityName: 'MJ: Company Integration Entity Maps',
3918
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND ExternalObjectName='${escapedObjectName}'`,
4583
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}' AND ExternalObjectName='${escapedObjectName}'`,
3919
4584
  OrderBy: '__mj_CreatedAt ASC',
3920
4585
  MaxRows: 1,
4586
+ SkipTotalRowCount: true,
3921
4587
  ResultType: 'entity_object',
3922
4588
  BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
3923
4589
  }, user);
@@ -3984,7 +4650,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3984
4650
  // already exist for this entity map, or re-applies multiply them in lockstep.
3985
4651
  const existingFieldMaps = await new RunView().RunView<{ SourceFieldName: string }>({
3986
4652
  EntityName: 'MJ: Company Integration Field Maps',
3987
- ExtraFilter: `EntityMapID='${entityMapID}'`,
4653
+ ExtraFilter: `EntityMapID='${sqlUuid(entityMapID, 'entityMapID')}'`,
3988
4654
  Fields: ['SourceFieldName'],
3989
4655
  ResultType: 'simple',
3990
4656
  BypassCache: true, // read committed field-map state for the idempotency skip
@@ -4040,9 +4706,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4040
4706
  const rv = new RunView();
4041
4707
  const runResult = await rv.RunView<{ ID: string }>({
4042
4708
  EntityName: 'MJ: Company Integration Runs',
4043
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND Status='In Progress'`,
4709
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}' AND Status='In Progress'`,
4044
4710
  OrderBy: '__mj_CreatedAt DESC',
4045
4711
  MaxRows: 1,
4712
+ SkipTotalRowCount: true,
4046
4713
  ResultType: 'simple',
4047
4714
  Fields: ['ID']
4048
4715
  }, user);
@@ -4092,16 +4759,23 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4092
4759
  const limit = IntegrationEngine.Instance.MaxColumnsPerTable;
4093
4760
  const overLimit = objects.filter(o => colCount(o) > limit);
4094
4761
  if (overLimit.length > 0) {
4762
+ // THE SHARPEST CROSS-CONNECTION COLLISION, and the clearest thing the per-connection
4763
+ // catalog fixes. Disabling an over-wide object used to write the SHARED row, so one
4764
+ // customer's column selection silently disabled that object for every other customer
4765
+ // on the same connector — with no error and nothing in either workspace to explain it.
4766
+ // Through the writer, a per-connection connection disables only its own row.
4767
+ const writer = BuildCatalogWriter(md, companyIntegration, user);
4095
4768
  const ioIDByName = new Map(
4096
- IntegrationEngineBase.Instance.GetActiveIntegrationObjects(companyIntegration.IntegrationID)
4769
+ (await writer.ObjectsInScope())
4770
+ .filter(io => io.Status === 'Active')
4097
4771
  .map(io => [io.Name.toLowerCase(), io.ID] as [string, string])
4098
4772
  );
4099
4773
  for (const o of overLimit) {
4100
4774
  warnings.push(`Disabled "${o.SourceObjectName}" (${colCount(o)} columns > limit ${limit}) — too wide to materialize as one table; reduce its columns or it stays disabled.`);
4101
4775
  const ioID = ioIDByName.get(o.SourceObjectName.toLowerCase());
4102
4776
  if (ioID) {
4103
- const io = await md.GetEntityObject<MJIntegrationObjectEntity>('MJ: Integration Objects', user);
4104
- if (await io.Load(ioID)) {
4777
+ const io = await writer.LoadObject(ioID);
4778
+ if (io) {
4105
4779
  io.Status = 'Disabled';
4106
4780
  if (!await io.Save()) {
4107
4781
  LogError(`enforceSchemaLimits: failed to disable over-wide IO '${o.SourceObjectName}': ${io.LatestResult?.CompleteMessage ?? 'unknown error'}`);
@@ -4162,14 +4836,26 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4162
4836
  // Declared rows this apply will silently leave out (deactivated objects/fields). Collected
4163
4837
  // during the rebuild and surfaced on the apply's Warnings, alongside the schema-limit ones.
4164
4838
  const inactiveWarnings: string[] = [];
4839
+ // Loaded once for both branches: under a per-connection catalog the engine answers [] for
4840
+ // this connection's fields during an apply, so a field-level warning read through it is lost.
4841
+ const scopedFields = await LoadScopedFieldsForBuild(companyIntegration.IntegrationID, user);
4165
4842
  if (prefetchedSourceSchema) {
4166
4843
  sourceSchema = prefetchedSourceSchema;
4844
+ // A prefetched schema skips the rebuild, and the rebuild used to be the only place these
4845
+ // warnings were collected — so the BATCH apply (the one caller that prefetches) lost them
4846
+ // twice over: once at its own rebuild, which passed no collector, and again here. They do
4847
+ // not depend on the rebuild: they are the catalog's own Status rows, which are exactly as
4848
+ // readable on this branch as on the other one.
4849
+ inactiveWarnings.push(...this.collectInactiveRowWarnings(
4850
+ companyIntegration.IntegrationID, objects.map(o => o.SourceObjectName), scopedFields,
4851
+ ));
4167
4852
  } else {
4168
4853
  const requestedNamesForReuse = objects.map(o => o.SourceObjectName);
4169
4854
  sourceSchema = this.buildSourceSchemaFromPersistedRows(
4170
4855
  companyIntegration.IntegrationID,
4171
4856
  requestedNamesForReuse,
4172
4857
  inactiveWarnings,
4858
+ scopedFields,
4173
4859
  );
4174
4860
  if (sourceSchema.Objects.length === 0) {
4175
4861
  LogError(`[buildSchemaForConnector] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
@@ -4204,7 +4890,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4204
4890
  // Caps are operator/env guardrails (MJ_INTEGRATION_MAX_TABLES / _MAX_COLUMNS_PER_TABLE).
4205
4891
  const limitWarnings = await this.enforceSchemaLimits(objects, filteredSchema, companyIntegration, user, provider);
4206
4892
 
4207
- const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, platform, connector);
4893
+ const drops = NewTargetConfigDrops();
4894
+ const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, platform, connector, drops);
4895
+ AssertTargetConfigs(objects, targetConfigs, companyIntegration.Integration, drops);
4208
4896
 
4209
4897
  const input: SchemaBuilderInput = {
4210
4898
  SourceSchema: filteredSchema,
@@ -4250,6 +4938,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4250
4938
  @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,
4251
4939
  @Ctx() ctx: AppContext
4252
4940
  ): Promise<StartSyncOutput> {
4941
+ return WithCatalogScope(companyIntegrationID, async () => {
4253
4942
  try {
4254
4943
  const user = this.getAuthenticatedUser(ctx);
4255
4944
  await IntegrationEngine.Instance.Config(false, user);
@@ -4347,9 +5036,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4347
5036
  const rv = new RunView();
4348
5037
  const runResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
4349
5038
  EntityName: 'MJ: Company Integration Runs',
4350
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND StartedAt >= '${firedAtFilter}'`,
5039
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}' AND StartedAt >= '${firedAtFilter}'`,
4351
5040
  OrderBy: 'StartedAt DESC',
4352
5041
  MaxRows: 1,
5042
+ SkipTotalRowCount: true,
4353
5043
  ResultType: 'simple',
4354
5044
  Fields: ['ID', 'Status', 'StartedAt']
4355
5045
  }, user);
@@ -4377,6 +5067,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4377
5067
  LogError(`IntegrationStartSync error: ${e}`);
4378
5068
  return { Success: false, Message: this.formatError(e) };
4379
5069
  }
5070
+ });
4380
5071
  }
4381
5072
 
4382
5073
  /**
@@ -4481,8 +5172,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4481
5172
  const rv = new RunView();
4482
5173
  const ciResult = await rv.RunView<MJCompanyIntegrationEntity>({
4483
5174
  EntityName: 'MJ: Company Integrations',
4484
- ExtraFilter: `ID='${companyIntegrationID}'`,
5175
+ ExtraFilter: `ID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
4485
5176
  MaxRows: 1,
5177
+ SkipTotalRowCount: true,
4486
5178
  ResultType: 'entity_object',
4487
5179
  }, user);
4488
5180
 
@@ -4500,6 +5192,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4500
5192
  EntityName: 'MJ: Integrations',
4501
5193
  ExtraFilter: `ID='${companyIntegration.IntegrationID}'`,
4502
5194
  MaxRows: 1,
5195
+ SkipTotalRowCount: true,
4503
5196
  ResultType: 'entity_object',
4504
5197
  }, user);
4505
5198
  if (!integResult.Success || integResult.Results.length === 0) {
@@ -4566,6 +5259,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4566
5259
  EntityName: 'MJ: Scheduled Job Types',
4567
5260
  ExtraFilter: `DriverClass='${driverClass}'`,
4568
5261
  MaxRows: 1,
5262
+ SkipTotalRowCount: true,
4569
5263
  ResultType: 'simple',
4570
5264
  Fields: ['ID']
4571
5265
  }, user);
@@ -4630,6 +5324,102 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4630
5324
  }
4631
5325
  }
4632
5326
 
5327
+ /**
5328
+ * Every scheduled job that targets this connection, with its kind, for pause/resume.
5329
+ *
5330
+ * Deliberately ONE view over both driver classes rather than two calls: pause has to move
5331
+ * the pair together, and a partial failure that pauses the sync job but leaves the discovery
5332
+ * job running is the exact defect this whole change exists to remove.
5333
+ *
5334
+ * Uses the same structured `"CompanyIntegrationID":"<uuid>"` predicate as
5335
+ * {@link findExistingScheduledJob} — a bare-UUID substring would false-match the same id
5336
+ * appearing under a different config key and pause an unrelated connection's job.
5337
+ */
5338
+ private async findScheduledJobsForConnection(
5339
+ companyIntegrationID: string,
5340
+ user: UserInfo
5341
+ ): Promise<ScheduleJobState[]> {
5342
+ const rv = new RunView();
5343
+ const types = await rv.RunView<MJScheduledJobTypeEntity>({
5344
+ EntityName: 'MJ: Scheduled Job Types',
5345
+ ExtraFilter: `DriverClass IN ('IntegrationSyncScheduledJobDriver','IntegrationDiscoveryScheduledJobDriver')`,
5346
+ Fields: ['ID', 'DriverClass'],
5347
+ ResultType: 'simple',
5348
+ }, user);
5349
+ if (!types.Success || types.Results.length === 0) return [];
5350
+
5351
+ const kindByTypeID = new Map<string, 'sync' | 'discovery'>();
5352
+ for (const t of types.Results) {
5353
+ kindByTypeID.set(t.ID, t.DriverClass === 'IntegrationDiscoveryScheduledJobDriver' ? 'discovery' : 'sync');
5354
+ }
5355
+
5356
+ const validCompanyIntegrationID = sqlUuid(companyIntegrationID, 'companyIntegrationID');
5357
+ const lower = validCompanyIntegrationID.toLowerCase();
5358
+ const upper = validCompanyIntegrationID.toUpperCase();
5359
+ const typeList = [...kindByTypeID.keys()].map(id => `'${EscapeSQLString(id)}'`).join(',');
5360
+ const jobs = await rv.RunView<{ ID: string; Status: string | null; JobTypeID: string }>({
5361
+ EntityName: 'MJ: Scheduled Jobs',
5362
+ ExtraFilter:
5363
+ `JobTypeID IN (${typeList}) AND ` +
5364
+ `(Configuration LIKE '%"CompanyIntegrationID":"${lower}"%' OR Configuration LIKE '%"CompanyIntegrationID":"${upper}"%')`,
5365
+ Fields: ['ID', 'Status', 'JobTypeID'],
5366
+ ResultType: 'simple',
5367
+ BypassCache: true, // pause must see committed status, not a stale filtered cache
5368
+ }, user);
5369
+ if (!jobs.Success) return [];
5370
+ return jobs.Results.map(j => ({
5371
+ ID: j.ID,
5372
+ Status: j.Status,
5373
+ Kind: kindByTypeID.get(j.JobTypeID) ?? 'sync',
5374
+ }));
5375
+ }
5376
+
5377
+ /**
5378
+ * A refusal reason when this connection is paused, or null when it is not.
5379
+ *
5380
+ * everything.txt C6 makes deactivation mean "stop touching the source". Syncs already honoured
5381
+ * that — the engine checks IsActive before RunSync — but DISCOVERY never did. A paused
5382
+ * connection could still be re-scanned by hand or by a cron that outlived the pause, which
5383
+ * spends the customer's rate budget and rewrites the very catalog the pause was meant to
5384
+ * freeze. Pausing the schedules stops the timer; this stops everything else.
5385
+ *
5386
+ * Fails OPEN: a connection we cannot read is not declared paused. The gate exists to honour an
5387
+ * explicit pause, not to become a new way for discovery to be unavailable.
5388
+ */
5389
+ private async describeIfPaused(
5390
+ companyIntegrationID: string,
5391
+ user: UserInfo,
5392
+ md: IMetadataProvider
5393
+ ): Promise<string | null> {
5394
+ try {
5395
+ const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
5396
+ if (!await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID))) return null;
5397
+ if (ci.IsActive) return null;
5398
+ return 'This connection is paused. Resume it first — a paused connection is not scanned, so that its saved schema stays as it was.';
5399
+ } catch (e) {
5400
+ LogError(`describeIfPaused(${companyIntegrationID}) failed, treating as active: ${e}`);
5401
+ return null;
5402
+ }
5403
+ }
5404
+
5405
+ /** Moves one scheduled job to a status. Returns false on any failure; the caller reports honestly. */
5406
+ private async setScheduledJobStatus(
5407
+ scheduledJobID: string,
5408
+ status: 'Active' | 'Paused',
5409
+ user: UserInfo,
5410
+ md: IMetadataProvider
5411
+ ): Promise<boolean> {
5412
+ try {
5413
+ const job = await md.GetEntityObject<MJScheduledJobEntity>('MJ: Scheduled Jobs', user);
5414
+ if (!await job.InnerLoad(CompositeKey.FromID(scheduledJobID))) return false;
5415
+ job.Status = status;
5416
+ return await job.Save();
5417
+ } catch (e) {
5418
+ LogError(`setScheduledJobStatus(${scheduledJobID}, ${status}) failed: ${e}`);
5419
+ return false;
5420
+ }
5421
+ }
5422
+
4633
5423
  /**
4634
5424
  * U7 — server-side schedule uniqueness: finds an existing (non-deleted) scheduled job of the
4635
5425
  * given job type that targets the given CompanyIntegration (its Configuration JSON carries
@@ -4641,13 +5431,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4641
5431
  companyIntegrationID: string,
4642
5432
  user: UserInfo
4643
5433
  ): Promise<{ ID: string } | null> {
4644
- const esc = (s: string) => s.replace(/'/g, "''");
4645
- const lower = esc(companyIntegrationID.toLowerCase());
4646
- const upper = esc(companyIntegrationID.toUpperCase());
5434
+ const validJobTypeID = sqlUuid(jobTypeID, 'jobTypeID');
5435
+ const validCompanyIntegrationID = sqlUuid(companyIntegrationID, 'companyIntegrationID');
5436
+ const lower = validCompanyIntegrationID.toLowerCase();
5437
+ const upper = validCompanyIntegrationID.toUpperCase();
4647
5438
  const result = await new RunView().RunView<{ ID: string }>({
4648
5439
  EntityName: 'MJ: Scheduled Jobs',
4649
5440
  ExtraFilter:
4650
- `JobTypeID='${esc(jobTypeID)}' AND Status IN ('Active','Paused') AND ` +
5441
+ `JobTypeID='${validJobTypeID}' AND Status IN ('Active','Paused') AND ` +
4651
5442
  // Structured "CompanyIntegrationID":"<uuid>" predicate (same form as IntegrationListSchedules),
4652
5443
  // NOT a bare-UUID substring — so this CI's id can't false-match the same UUID appearing in
4653
5444
  // another config key (e.g. RelatedCompanyIntegrationID) and repurpose an unrelated job.
@@ -4655,6 +5446,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4655
5446
  OrderBy: '__mj_CreatedAt DESC',
4656
5447
  Fields: ['ID'],
4657
5448
  MaxRows: 1,
5449
+ SkipTotalRowCount: true,
4658
5450
  ResultType: 'simple',
4659
5451
  BypassCache: true, // uniqueness must see committed jobs, not a stale filtered cache
4660
5452
  }, user);
@@ -4743,7 +5535,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4743
5535
  // Clear ScheduledJobID on any CompanyIntegration that references this job
4744
5536
  const ciResult = await rv.RunView<MJCompanyIntegrationEntity>({
4745
5537
  EntityName: 'MJ: Company Integrations',
4746
- ExtraFilter: `ScheduledJobID='${scheduledJobID}'`,
5538
+ ExtraFilter: `ScheduledJobID='${sqlUuid(scheduledJobID, 'scheduledJobID')}'`,
4747
5539
  ResultType: 'entity_object',
4748
5540
  }, sysUser);
4749
5541
  if (ciResult.Success) {
@@ -4758,7 +5550,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4758
5550
  // Null out ScheduledJobRunID on CompanyIntegrationRuns that reference this job's runs
4759
5551
  const jobRunsResult = await rv.RunView<MJScheduledJobRunEntity>({
4760
5552
  EntityName: 'MJ: Scheduled Job Runs',
4761
- ExtraFilter: `ScheduledJobID='${scheduledJobID}'`,
5553
+ ExtraFilter: `ScheduledJobID='${sqlUuid(scheduledJobID, 'scheduledJobID')}'`,
4762
5554
  ResultType: 'entity_object',
4763
5555
  }, sysUser);
4764
5556
  if (jobRunsResult.Success) {
@@ -4814,7 +5606,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4814
5606
  const rv = new RunView();
4815
5607
  const result = await rv.RunView<{ ID: string; Name: string; Status: string; CronExpression: string; Timezone: string; NextRunAt: string; LastRunAt: string; RunCount: number; SuccessCount: number; FailureCount: number }>({
4816
5608
  EntityName: 'MJ: Scheduled Jobs',
4817
- ExtraFilter: `Configuration LIKE '%"CompanyIntegrationID":"${companyIntegrationID}"%'`,
5609
+ ExtraFilter: `Configuration LIKE '%"CompanyIntegrationID":"${sqlUuid(companyIntegrationID, 'companyIntegrationID')}"%'`,
4818
5610
  OrderBy: '__mj_CreatedAt DESC',
4819
5611
  ResultType: 'simple',
4820
5612
  Fields: ['ID', 'Name', 'Status', 'CronExpression', 'Timezone', 'NextRunAt', 'LastRunAt', 'RunCount', 'SuccessCount', 'FailureCount']
@@ -4855,7 +5647,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4855
5647
  const rv = new RunView();
4856
5648
  const result = await rv.RunView<EntityMapSummaryOutput>({
4857
5649
  EntityName: 'MJ: Company Integration Entity Maps',
4858
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
5650
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
4859
5651
  OrderBy: 'Priority ASC',
4860
5652
  ResultType: 'simple',
4861
5653
  Fields: ['ID', 'EntityID', 'Entity', 'ExternalObjectName', 'SyncDirection', 'Priority', 'Status', 'Configuration'],
@@ -4884,7 +5676,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4884
5676
  const rv = new RunView();
4885
5677
  const result = await rv.RunView<FieldMapSummaryOutput>({
4886
5678
  EntityName: 'MJ: Company Integration Field Maps',
4887
- ExtraFilter: `EntityMapID='${entityMapID}'`,
5679
+ ExtraFilter: `EntityMapID='${sqlUuid(entityMapID, 'entityMapID')}'`,
4888
5680
  OrderBy: 'SourceFieldName',
4889
5681
  ResultType: 'simple',
4890
5682
  BypassCache: true, // operational list must reflect COMMITTED state
@@ -4967,7 +5759,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4967
5759
  // Delete field maps
4968
5760
  const fmResult = await rv.RunView<MJCompanyIntegrationFieldMapEntity>({
4969
5761
  EntityName: 'MJ: Company Integration Field Maps',
4970
- ExtraFilter: `EntityMapID='${entityMapID}'`,
5762
+ ExtraFilter: `EntityMapID='${sqlUuid(entityMapID, 'entityMapID')}'`,
4971
5763
  ResultType: 'entity_object'
4972
5764
  }, sysUser);
4973
5765
  if (fmResult.Success) {
@@ -4977,7 +5769,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4977
5769
  // Delete watermarks
4978
5770
  const wmResult = await rv.RunView<MJCompanyIntegrationSyncWatermarkEntity>({
4979
5771
  EntityName: 'MJ: Company Integration Sync Watermarks',
4980
- ExtraFilter: `EntityMapID='${entityMapID}'`,
5772
+ ExtraFilter: `EntityMapID='${sqlUuid(entityMapID, 'entityMapID')}'`,
4981
5773
  ResultType: 'entity_object'
4982
5774
  }, sysUser);
4983
5775
  if (wmResult.Success) {
@@ -5063,6 +5855,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5063
5855
  RecordsCreated: syncProgress.RecordsCreated,
5064
5856
  RecordsUpdated: syncProgress.RecordsUpdated,
5065
5857
  RecordsErrored: syncProgress.RecordsErrored,
5858
+ RecordsSkipped: syncProgress.RecordsSkipped,
5066
5859
  StartedAt: syncProgress.StartedAt.toISOString(),
5067
5860
  ElapsedMs: Date.now() - syncProgress.StartedAt.getTime(),
5068
5861
  };
@@ -5091,7 +5884,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5091
5884
  const [mapsResult, runsResult] = await rv.RunViews([
5092
5885
  {
5093
5886
  EntityName: 'MJ: Company Integration Entity Maps',
5094
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
5887
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5095
5888
  ResultType: 'simple',
5096
5889
  Fields: ['ID', 'Status'],
5097
5890
  // Status must reflect the live state immediately after a deselect/reselect
@@ -5101,9 +5894,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5101
5894
  },
5102
5895
  {
5103
5896
  EntityName: 'MJ: Company Integration Runs',
5104
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
5897
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5105
5898
  OrderBy: 'StartedAt DESC',
5106
5899
  MaxRows: 1,
5900
+ SkipTotalRowCount: true,
5107
5901
  ResultType: 'simple',
5108
5902
  Fields: ['ID', 'Status', 'StartedAt', 'EndedAt', 'TotalRecords'],
5109
5903
  BypassCache: true
@@ -5142,29 +5936,221 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5142
5936
  }
5143
5937
  }
5144
5938
 
5939
+ /**
5940
+ * Paged run history, newest first.
5941
+ *
5942
+ * plan.md: "history must show a paginated list with all runs (25 per page) in the history, it
5943
+ * should not shwo just aprt of it". Both surfaces used to fake paging by re-fetching with a
5944
+ * DOUBLED limit and slicing client-side, which cannot reach past the cap and re-transfers
5945
+ * everything already shown.
5946
+ *
5947
+ * `offset` maps to RunView's StartRow. TotalKnown comes from a separate count so a pager can
5948
+ * size itself without fetching every row.
5949
+ */
5950
+ /**
5951
+ * Everything running for a connection, in one call.
5952
+ *
5953
+ * plan.md: "In the UI for the integration ... there shouldbe an area that shows thigns that are
5954
+ * running actively, this can be syncs, rsu, discovery, etc." and "Let us say i clear browser
5955
+ * cache and then i go to that integration page, i should see the same steps i mentioned
5956
+ * hapepning there to". Every field here is derived SERVER-side, so a cleared browser sees the
5957
+ * same thing — nothing depends on client state.
5958
+ *
5959
+ * Previously a surface had to fan out to four queries and stitch them, and each got the
5960
+ * attribution wrong in its own way. The two rules that fan-out kept breaking are encoded here:
5961
+ *
5962
+ * - RSU status from GetStatus is PROCESS-WIDE. It is only attributed to this connection when
5963
+ * an RSU run artifact actually names it; otherwise it is reported Scope='workspace'.
5964
+ * - A ConnectorCreation run is discovery, never a sync. Narrating it as a sync is what made a
5965
+ * first-time setup claim it was syncing records it had not fetched.
5966
+ */
5967
+ @Query(() => ActiveOperationsOutput)
5968
+ async IntegrationGetActiveOperations(
5969
+ @Arg("companyIntegrationID") companyIntegrationID: string,
5970
+ @Ctx() ctx: AppContext
5971
+ ): Promise<ActiveOperationsOutput> {
5972
+ try {
5973
+ const user = this.getAuthenticatedUser(ctx);
5974
+ const authCache = new Map<string, boolean>();
5975
+ if (!(await this.userCanReadCompanyIntegration(companyIntegrationID, user, authCache))) {
5976
+ return { Success: false, Message: this.notAuthorizedForCompanyIntegrationMessage(companyIntegrationID) };
5977
+ }
5978
+
5979
+ const ops: ActiveOperationOutput[] = [];
5980
+
5981
+ // ── the sync, from the DURABLE snapshot so any process can answer ──
5982
+ // GetSyncProgressAsync prefers the durable run-row snapshot and falls back to this
5983
+ // process's own — necessary because the ProgressJSON column arrives in MJ 6.1.x and
5984
+ // every tenant here is 5.51, where the durable read silently finds nothing.
5985
+ const syncProgress = await IntegrationEngine.GetSyncProgressAsync(companyIntegrationID, user);
5986
+ if (syncProgress) {
5987
+ ops.push({
5988
+ Kind: 'sync',
5989
+ Scope: 'connection',
5990
+ Label: `Syncing ${syncProgress.CurrentEntity || 'data'}`,
5991
+ StartedAt: syncProgress.StartedAt,
5992
+ StepLabel: syncProgress.CurrentEntity || undefined,
5993
+ StepIndex: syncProgress.EntityMapsCompleted,
5994
+ StepTotal: syncProgress.EntityMapsTotal,
5995
+ RecordsCreated: syncProgress.RecordsCreated,
5996
+ RecordsUpdated: syncProgress.RecordsUpdated,
5997
+ RecordsSkipped: syncProgress.RecordsSkipped,
5998
+ RecordsErrored: syncProgress.RecordsErrored,
5999
+ Cancellable: true,
6000
+ });
6001
+ }
6002
+
6003
+ // ── in-flight run artifacts for THIS connection ──
6004
+ const reader = new IntegrationProgressReader();
6005
+ const inFlight = await reader.ListRuns({ companyIntegrationID, inFlightOnly: true }, 25);
6006
+ let rsuNamesThisConnection = false;
6007
+ for (const snap of inFlight) {
6008
+ const kind = snap.manifest.runKind;
6009
+ // 'SyncRun' is already reported above from the durable snapshot. Everything else
6010
+ // that is not RSU is a discovery-shaped run - ConnectorCreation especially, which
6011
+ // must NEVER be narrated as a sync: it fetches nothing.
6012
+ if (kind === 'SyncRun') continue;
6013
+ if (kind === 'RSU') rsuNamesThisConnection = true;
6014
+ ops.push({
6015
+ Kind: kind === 'RSU' ? 'rsu' : 'discovery',
6016
+ Scope: 'connection',
6017
+ Label: kind === 'RSU' ? 'Updating your workspace schema' : 'Discovering tables and columns',
6018
+ RunID: snap.manifest.runID,
6019
+ StartedAt: new Date(snap.manifest.startedAt),
6020
+ StepLabel: snap.latestEvent?.message ?? undefined,
6021
+ Cancellable: false,
6022
+ });
6023
+ }
6024
+
6025
+ // ── RSU reported by the process, when no artifact tied it to this connection ──
6026
+ const rsu = RuntimeSchemaManager.Instance.GetStatus();
6027
+ if (rsu?.Running && !rsuNamesThisConnection) {
6028
+ ops.push({
6029
+ Kind: 'rsu',
6030
+ Scope: 'workspace',
6031
+ Label: 'Your workspace is updating',
6032
+ StepLabel: rsu.CurrentStepName ?? undefined,
6033
+ StepIndex: rsu.CurrentStepIndex ?? undefined,
6034
+ StepTotal: rsu.StepTotal ?? undefined,
6035
+ Cancellable: false,
6036
+ });
6037
+ }
6038
+
6039
+ const lock = IntegrationEngine.GetMaintenanceLock(companyIntegrationID);
6040
+ return {
6041
+ Success: true,
6042
+ Message: `${ops.length} active operation(s)`,
6043
+ Operations: ops,
6044
+ MaintenanceLockReason: lock?.Reason,
6045
+ };
6046
+ } catch (e) {
6047
+ LogError(`IntegrationGetActiveOperations error: ${e}`);
6048
+ return { Success: false, Message: this.formatError(e) };
6049
+ }
6050
+ }
6051
+
6052
+ /**
6053
+ * What the tenant can see about its own resource headroom.
6054
+ *
6055
+ * plan.md line 158: "what happens if we run out of storage when we sync, how do we alert the
6056
+ * user, OOM (MJC should handle)". The tenant MEASURES - it is the process that fills the heap
6057
+ * and the disk - and the control plane decides. Before this there was no measurement at all,
6058
+ * so a sync that died of OOM or a full disk did so with no warning ahead of it and no
6059
+ * explanation after.
6060
+ *
6061
+ * Deliberately not authorized per connection: this is a property of the workspace, not of any
6062
+ * one connector, and the control plane polls it to decide admission.
6063
+ */
6064
+ @Query(() => ResourcePressureOutput)
6065
+ @RequireSystemUser()
6066
+ async IntegrationGetResourcePressure(
6067
+ @Ctx() _ctx: AppContext
6068
+ ): Promise<ResourcePressureOutput> {
6069
+ try {
6070
+ let runDirCount: number | null = null;
6071
+ try {
6072
+ runDirCount = (await new IntegrationProgressReader().ListRuns({}, 100000)).length;
6073
+ } catch { /* the artifact dir may not exist yet on a fresh workspace */ }
6074
+
6075
+ const reading = await ReadResourcePressure({ runDirCount });
6076
+ const findings = EvaluatePressure(reading);
6077
+ const mb = (b: number | null) => (b === null ? undefined : Math.round(b / (1024 * 1024)));
6078
+ return {
6079
+ Success: true,
6080
+ Message: findings.length === 0 ? 'No resource pressure' : findings[0].Message,
6081
+ HeapUsedFraction: reading.HeapUsedFraction,
6082
+ HeapUsedMB: mb(reading.HeapUsedBytes),
6083
+ HeapLimitMB: mb(reading.HeapLimitBytes),
6084
+ ResidentMB: mb(reading.ResidentBytes),
6085
+ ResidentFraction: reading.ResidentFraction ?? undefined,
6086
+ HostMemTotalMB: mb(reading.HostMemTotalBytes),
6087
+ HostMemAvailableMB: mb(reading.HostMemAvailableBytes),
6088
+ ArtifactDiskFreeMB: mb(reading.ArtifactDiskFreeBytes),
6089
+ WorkDirFreeMB: mb(reading.WorkDirFreeBytes),
6090
+ ActiveSyncCount: reading.ActiveSyncCount,
6091
+ Findings: findings.map(f => ({ Code: f.Code, Message: f.Message })),
6092
+ };
6093
+ } catch (e) {
6094
+ LogError(`IntegrationGetResourcePressure error: ${e}`);
6095
+ return { Success: false, Message: this.formatError(e) };
6096
+ }
6097
+ }
6098
+
5145
6099
  @Query(() => SyncHistoryOutput)
5146
6100
  async IntegrationGetSyncHistory(
5147
6101
  @Arg("companyIntegrationID") companyIntegrationID: string,
5148
- @Arg("limit", { defaultValue: 20 }) limit: number,
6102
+ @Arg("limit", { defaultValue: 25 }) limit: number,
6103
+ @Arg("offset", { nullable: true, defaultValue: 0, description: 'Rows to skip. With limit, this is the page.' }) offset: number,
5149
6104
  @Ctx() ctx: AppContext
5150
6105
  ): Promise<SyncHistoryOutput> {
5151
6106
  try {
5152
6107
  const user = this.getAuthenticatedUser(ctx);
6108
+ const filter = `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`;
5153
6109
  const rv = new RunView();
5154
6110
  const result = await rv.RunView<SyncRunSummaryOutput>({
5155
6111
  EntityName: 'MJ: Company Integration Runs',
5156
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
6112
+ ExtraFilter: filter,
5157
6113
  OrderBy: 'StartedAt DESC',
5158
6114
  MaxRows: limit,
6115
+ StartRow: Math.max(0, offset ?? 0),
5159
6116
  ResultType: 'simple',
5160
6117
  Fields: ['ID', 'Status', 'StartedAt', 'EndedAt', 'TotalRecords', 'RunByUserID']
5161
6118
  }, user);
5162
6119
 
5163
6120
  if (!result.Success) return { Success: false, Message: result.ErrorMessage || 'Query failed' };
6121
+
6122
+ // A separate count, not result.TotalRowCount: the row query is capped by MaxRows, so its
6123
+ // count reflects the PAGE. A pager needs the whole set.
6124
+ let totalKnown: number | undefined;
6125
+ const countRes = await new RunView().RunView({
6126
+ EntityName: 'MJ: Company Integration Runs',
6127
+ ExtraFilter: filter,
6128
+ ResultType: 'count_only',
6129
+ }, user);
6130
+ if (countRes?.Success) totalKnown = countRes.TotalRowCount;
6131
+
6132
+ // Per-entity created / updated / skipped / errored, recovered from each run's artifact.
6133
+ // The run ROW carries only TotalRecords, so EntityDetails existed on this type and was
6134
+ // never populated — the empty breakdown thing.txt reports. Artifacts are pruned by the
6135
+ // retention cap and are node-local, so an old run yields nothing; that is left ABSENT
6136
+ // rather than zeroed, so the UI can say "not recorded" instead of claiming zero.
6137
+ const reader = new IntegrationProgressReader();
6138
+ await Promise.all(result.Results.map(async (run) => {
6139
+ try {
6140
+ const outcomes = await reader.EntityOutcomes(run.ID);
6141
+ if (outcomes.length > 0) run.EntityDetails = outcomes;
6142
+ } catch { /* a missing or pruned artifact is not an error */ }
6143
+ }));
6144
+
6145
+ const start = Math.max(0, offset ?? 0);
5164
6146
  return {
5165
6147
  Success: true,
5166
6148
  Message: `${result.Results.length} runs`,
5167
- Runs: result.Results
6149
+ Runs: result.Results,
6150
+ TotalKnown: totalKnown,
6151
+ HasMore: totalKnown === undefined
6152
+ ? result.Results.length === limit
6153
+ : start + result.Results.length < totalKnown,
5168
6154
  };
5169
6155
  } catch (e) {
5170
6156
  LogError(`IntegrationGetSyncHistory error: ${e}`);
@@ -5187,6 +6173,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5187
6173
  @Arg("runKind", { nullable: true }) runKind?: string,
5188
6174
  @Arg("inFlightOnly", { nullable: true }) inFlightOnly?: boolean,
5189
6175
  @Arg("limit", { defaultValue: 50 }) limit?: number,
6176
+ @Arg("offset", { nullable: true, defaultValue: 0, description: 'Runs to skip in the underlying store. Use NextOffset from the previous page.' }) offset?: number,
5190
6177
  ): Promise<IntegrationListRunsOutput> {
5191
6178
  try {
5192
6179
  const user = this.getAuthenticatedUser(ctx);
@@ -5202,18 +6189,43 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5202
6189
  }
5203
6190
 
5204
6191
  const reader = new IntegrationProgressReader();
5205
- const snaps = await reader.ListRuns({
6192
+ const want = limit ?? 50;
6193
+ const filter = {
5206
6194
  companyIntegrationID,
5207
6195
  runKind: runKind as IntegrationRunKind | undefined,
5208
6196
  inFlightOnly: inFlightOnly ?? false,
5209
- }, limit ?? 50);
5210
-
5211
- // Filter to only the runs the caller is authorized to read. When
5212
- // scoped to a single (already-authorized) connector this is a no-op;
5213
- // for the cross-connector listing it prevents one tenant from seeing
5214
- // another tenant's runs.
5215
- const authorizedSnaps = await this.filterAuthorizedRuns(snaps, user, authCache);
5216
- return { Success: true, Message: `${authorizedSnaps.length} run(s)`, Runs: authorizedSnaps.map(s => this.toRunSummaryArtifact(s)) };
6197
+ };
6198
+
6199
+ // AUTHORIZE-THEN-PAGE. Authorization happens after the store returns, so a page can
6200
+ // come back short because the caller could not read some of it — not because the store
6201
+ // ran out. Returning that short page as if it were the end is what made "load older
6202
+ // runs" stop early. So refill from the next offset until the page is full or the store
6203
+ // is genuinely exhausted.
6204
+ //
6205
+ // The refill is BOUNDED: an unauthorized caller would otherwise walk the entire store
6206
+ // one page at a time on a single request.
6207
+ const MAX_REFILLS = 5;
6208
+ let cursor = Math.max(0, offset ?? 0);
6209
+ let exhausted = false;
6210
+ const authorizedSnaps: IntegrationRunSnapshot[] = [];
6211
+ for (let round = 0; round <= MAX_REFILLS && authorizedSnaps.length < want; round++) {
6212
+ const batch = await reader.ListRuns(filter, want, cursor);
6213
+ if (batch.length === 0) { exhausted = true; break; }
6214
+ cursor += batch.length;
6215
+ if (batch.length < want) exhausted = true;
6216
+ const ok = await this.filterAuthorizedRuns(batch, user, authCache);
6217
+ authorizedSnaps.push(...ok);
6218
+ if (exhausted) break;
6219
+ }
6220
+ const page = authorizedSnaps.slice(0, want);
6221
+ const hasMore = !exhausted || authorizedSnaps.length > want;
6222
+ return {
6223
+ Success: true,
6224
+ Message: `${page.length} run(s)`,
6225
+ Runs: page.map(s => this.toRunSummaryArtifact(s)),
6226
+ NextOffset: hasMore ? cursor : undefined,
6227
+ HasMore: hasMore,
6228
+ };
5217
6229
  } catch (e) {
5218
6230
  LogError(`IntegrationListRuns error: ${e}`);
5219
6231
  return { Success: false, Message: this.formatError(e) };
@@ -5250,14 +6262,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5250
6262
  const snap = await reader.GetRun(runID);
5251
6263
  if (!snap) return { Success: false, Message: `Run '${runID}' not found` };
5252
6264
 
5253
- const authorized = await this.userCanReadRunArtifact(snap, user, new Map<string, boolean>());
5254
- if (!authorized) {
5255
- const ciID = snap.manifest.companyIntegrationID;
6265
+ const auth = await this.authorizeRunArtifact(snap, user, new Map<string, boolean>());
6266
+ if (!auth.Authorized) {
5256
6267
  return {
5257
6268
  Success: false,
5258
- Message: ciID
5259
- ? this.notAuthorizedForCompanyIntegrationMessage(ciID)
5260
- : `Not authorized to access run '${runID}'`,
6269
+ Message: this.notAuthorizedForRunMessage(runID, auth.DeniedCompanyIntegrationID),
5261
6270
  };
5262
6271
  }
5263
6272
 
@@ -5291,14 +6300,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5291
6300
  const snap = await reader.GetRun(runID);
5292
6301
  if (!snap) return { Success: false, Message: `Run '${runID}' not found`, LatestSeq: sinceSeq ?? 0, IsInFlight: false };
5293
6302
 
5294
- const authorized = await this.userCanReadRunArtifact(snap, user, new Map<string, boolean>());
5295
- if (!authorized) {
5296
- const ciID = snap.manifest.companyIntegrationID;
6303
+ const auth = await this.authorizeRunArtifact(snap, user, new Map<string, boolean>());
6304
+ if (!auth.Authorized) {
5297
6305
  return {
5298
6306
  Success: false,
5299
- Message: ciID
5300
- ? this.notAuthorizedForCompanyIntegrationMessage(ciID)
5301
- : `Not authorized to access run '${runID}'`,
6307
+ Message: this.notAuthorizedForRunMessage(runID, auth.DeniedCompanyIntegrationID),
5302
6308
  LatestSeq: sinceSeq ?? 0,
5303
6309
  IsInFlight: false,
5304
6310
  };
@@ -5336,6 +6342,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5336
6342
  RunKind: s.manifest.runKind,
5337
6343
  IntegrationID: s.manifest.integrationID,
5338
6344
  CompanyIntegrationID: s.manifest.companyIntegrationID,
6345
+ CompanyIntegrationIDs: s.manifest.companyIntegrationIDs,
5339
6346
  ObjectName: s.manifest.objectName,
5340
6347
  TriggerType: s.manifest.triggerType,
5341
6348
  StartedAt: s.manifest.startedAt,
@@ -5367,6 +6374,49 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5367
6374
 
5368
6375
  // ── CONNECTOR CAPABILITIES ──────────────────────────────────────────
5369
6376
 
6377
+ /**
6378
+ * Returns the settings the connector bound to a CompanyIntegration declares in its
6379
+ * `SettingsSchema` — key, label, group, type, default, bounds and tier — so a client can
6380
+ * render the connection's settings form without knowing the connector. A connector that
6381
+ * declares none returns an empty list.
6382
+ */
6383
+ @Query(() => ConnectorSettingsSchemaOutput)
6384
+ async IntegrationGetConnectorSettingsSchema(
6385
+ @Arg("companyIntegrationID") companyIntegrationID: string,
6386
+ @Ctx() ctx: AppContext
6387
+ ): Promise<ConnectorSettingsSchemaOutput> {
6388
+ try {
6389
+ const user = this.getAuthenticatedUser(ctx);
6390
+ const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
6391
+ const { connector } = await this.resolveConnector(companyIntegrationID, user, provider);
6392
+ const schema = connector.SettingsSchema ?? [];
6393
+ return {
6394
+ Success: true,
6395
+ Message: `${schema.length} setting(s)`,
6396
+ Settings: schema.map(s => ({
6397
+ Key: s.Key,
6398
+ Label: s.Label,
6399
+ Group: s.Group,
6400
+ Type: s.Type,
6401
+ Options: s.Options,
6402
+ // Serialized because the value is genuinely polymorphic per setting and a
6403
+ // typed union here would be a schema change every time one is added. The
6404
+ // renderer already knows the Type, so it knows how to read this.
6405
+ DefaultJSON: s.Default === undefined ? undefined : JSON.stringify(s.Default),
6406
+ Min: s.Min,
6407
+ Max: s.Max,
6408
+ Tier: s.Tier,
6409
+ AppliesTo: s.AppliesTo,
6410
+ Description: s.Description,
6411
+ Sensitive: s.Sensitive,
6412
+ })),
6413
+ };
6414
+ } catch (e) {
6415
+ LogError(`IntegrationGetConnectorSettingsSchema error: ${e}`);
6416
+ return { Success: false, Message: this.formatError(e), Settings: [] };
6417
+ }
6418
+ }
6419
+
5370
6420
  /**
5371
6421
  * Returns the CRUD capability flags for the connector bound to a CompanyIntegration.
5372
6422
  * Use this to determine which operations (Create/Update/Delete/Search) are supported
@@ -5377,6 +6427,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5377
6427
  @Arg("companyIntegrationID") companyIntegrationID: string,
5378
6428
  @Ctx() ctx: AppContext
5379
6429
  ): Promise<ConnectorCapabilitiesOutput> {
6430
+ return WithCatalogScope(companyIntegrationID, async () => {
5380
6431
  try {
5381
6432
  const user = this.getAuthenticatedUser(ctx);
5382
6433
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
@@ -5395,6 +6446,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5395
6446
  LogError(`IntegrationGetConnectorCapabilities error: ${e}`);
5396
6447
  return { Success: false, Message: this.formatError(e) };
5397
6448
  }
6449
+ });
5398
6450
  }
5399
6451
 
5400
6452
  // ── APPLY ALL BATCH ─────────────────────────────────────────────────
@@ -5419,20 +6471,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5419
6471
  const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
5420
6472
  const validatedPlatform = this.validatePlatform(platform);
5421
6473
 
5422
- // Bust RunView caches for integration metadata BEFORE Config(true).
5423
- // mj sync push writes records via stored procedures which do NOT fire
5424
- // BaseEntity change events, so the RunView cache is never auto-invalidated.
5425
- // Explicitly clearing these entries ensures Config(true) re-queries the DB.
5426
- await LocalCacheManager.Instance.InvalidateEntityCaches('MJ: Integration Objects');
5427
- await LocalCacheManager.Instance.InvalidateEntityCaches('MJ: Integration Object Fields');
5428
-
5429
- // Force-refresh integration metadata cache so IntrospectSchema
5430
- // picks up any IntegrationObject/Field changes made via mj sync push
5431
- await IntegrationEngine.Instance.Config(true, user);
6474
+ await this.refreshIntegrationCatalogCaches(user, provider);
5432
6475
 
5433
6476
  // Phase 1: Build schema for each connector in parallel
5434
6477
  const buildResults = await Promise.allSettled(
5435
- input.Connectors.map(async (connInput) => {
6478
+ // One catalog scope per connection — the innermost wins, so each build resolves its own.
6479
+ input.Connectors.map((connInput) => WithCatalogScope(connInput.CompanyIntegrationID, async () => {
5436
6480
  const { connector, companyIntegration } = await this.resolveConnector(connInput.CompanyIntegrationID, user, provider);
5437
6481
  const schemaName = this.deriveSchemaName(companyIntegration.Integration);
5438
6482
  console.log(
@@ -5501,7 +6545,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5501
6545
  // persisted rows instead.
5502
6546
  //
5503
6547
  // Persist is also skipped here — the Save hook already did it.
5504
- sourceSchema = this.buildSourceSchemaFromPersistedRows(companyIntegration.IntegrationID);
6548
+ sourceSchema = this.buildSourceSchemaFromPersistedRows(
6549
+ companyIntegration.IntegrationID,
6550
+ undefined,
6551
+ undefined,
6552
+ await LoadScopedFieldsForBuild(companyIntegration.IntegrationID, user),
6553
+ );
5505
6554
  if (sourceSchema.Objects.length === 0) {
5506
6555
  // Defensive fallback: if the engine cache is empty (hook
5507
6556
  // didn't run, or this is a direct-API caller that bypasses
@@ -5556,6 +6605,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5556
6605
  for (let i = 0; i < resolvedNames.length; i++) {
5557
6606
  fieldsByName.set(resolvedNames[i].toLowerCase(), orderedSources[i].Fields);
5558
6607
  }
6608
+ // Mirror the filtered flow: nothing resolved means nothing to build.
6609
+ if (connInput.SourceObjects.length > 0 && resolvedNames.length === 0) {
6610
+ throw new Error(
6611
+ `No source objects resolved — none of the ${connInput.SourceObjects.length} selection(s) ` +
6612
+ `matched this connection's catalog or the shared catalog; nothing was built`
6613
+ );
6614
+ }
5559
6615
  }
5560
6616
 
5561
6617
  const objects = resolvedNames.map(name => {
@@ -5579,6 +6635,26 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5579
6635
  }
5580
6636
 
5581
6637
  // Inject post-restart pending work payload
6638
+ // everything.txt: "for the very first RSU of a connector, the sync afterwards
6639
+ // should be chosen to be full/incremental, but always full generally as the
6640
+ // default." The single-connector path has always done this; the batch path did
6641
+ // not, and its input default of false made the null-coalesce below dead. So a
6642
+ // re-apply that the caller left unspecified ran INCREMENTAL against objects
6643
+ // whose watermark had just been reset by the schema change.
6644
+ const priorMapsResult = await new RunView().RunView<{ ID: string }>({
6645
+ EntityName: 'MJ: Company Integration Entity Maps',
6646
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(connInput.CompanyIntegrationID, 'CompanyIntegrationID')}'`,
6647
+ Fields: ['ID'],
6648
+ MaxRows: 1,
6649
+ ResultType: 'simple',
6650
+ BypassCache: true,
6651
+ SkipTotalRowCount: true,
6652
+ }, user);
6653
+ const hadPriorMaps = priorMapsResult.Success && priorMapsResult.Results.length > 0;
6654
+ // One value for both ways the sync starts: the restart path queues it below, and
6655
+ // the skipRestart branch starts the sync itself from the build it is handed.
6656
+ const fullSync = input.FullSync ?? !hadPriorMaps;
6657
+
5582
6658
  const pendingPayload: RSUPendingWork = {
5583
6659
  CompanyIntegrationID: connInput.CompanyIntegrationID,
5584
6660
  SourceObjectNames: resolvedNames,
@@ -5587,7 +6663,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5587
6663
  CronExpression: connInput.CronExpression,
5588
6664
  ScheduleTimezone: connInput.ScheduleTimezone,
5589
6665
  StartSync: input.StartSync,
5590
- FullSync: input.FullSync ?? false,
6666
+ FullSync: fullSync,
5591
6667
  SyncScope: input.SyncScope === 'all' ? 'all' : 'created',
5592
6668
  // Unrecognized directions fall back to undefined = "use the entity map's own SyncDirection"
5593
6669
  SyncDirection: input.SyncDirection && isValidSyncDirection(input.SyncDirection) ? input.SyncDirection : undefined,
@@ -5606,8 +6682,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5606
6682
  objects,
5607
6683
  schemaOutput,
5608
6684
  rsuInput,
6685
+ // carried so the skipRestart branch can apply remove-as-disable
6686
+ resolvedNames,
6687
+ // carried so the skipRestart branch starts the sync the restart path queues
6688
+ fullSync,
5609
6689
  };
5610
- })
6690
+ }))
5611
6691
  );
5612
6692
 
5613
6693
  // Separate successes and failures
@@ -5615,6 +6695,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5615
6695
  connInput: ApplyAllBatchConnectorInput;
5616
6696
  connector: BaseIntegrationConnector;
5617
6697
  companyIntegration: MJCompanyIntegrationEntity;
6698
+ resolvedNames: string[];
6699
+ fullSync: boolean;
5618
6700
  schemaName: string;
5619
6701
  objects: SchemaPreviewObjectInput[];
5620
6702
  schemaOutput: SchemaBuilderOutput;
@@ -5659,6 +6741,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5659
6741
  // Phase 3: Post-pipeline — create entity maps, field maps, schedules for each success
5660
6742
  for (let i = 0; i < successfulBuilds.length; i++) {
5661
6743
  const build = successfulBuilds[i];
6744
+ // Entity maps, the unselected-map pass and the first sync all read this connection's catalog.
6745
+ await WithCatalogScope(build.connInput.CompanyIntegrationID, async () => {
5662
6746
  const pipelineResult = batchResult.Results[i];
5663
6747
  const integrationName = build.companyIntegration.Integration;
5664
6748
 
@@ -5672,7 +6756,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5672
6756
  });
5673
6757
  // No pending-work cleanup needed — rows are registered only for
5674
6758
  // migrations that succeeded.
5675
- continue;
6759
+ return; // stands in for `continue` — this body is the per-connection scope's arrow
5676
6760
  }
5677
6761
 
5678
6762
  const connResult: ApplyAllBatchConnectorResult = {
@@ -5693,6 +6777,20 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5693
6777
  );
5694
6778
  connResult.EntityMapsCreated = entityMapsCreated;
5695
6779
 
6780
+ // Remove-as-disable, same rule the single-connector path applies. Without this
6781
+ // the batch path silently ignored UnselectedAction: a user who deselected a
6782
+ // table and applied with skipRestart kept syncing it. Not reachable while the
6783
+ // surfaces used different mutations; it becomes reachable the moment one of
6784
+ // them moves onto the batch path, which is exactly what the shared adapter does.
6785
+ if ((input.UnselectedAction ?? 'disable') !== 'ignore') {
6786
+ const disabledObjects = await DisableUnselectedEntityMaps(
6787
+ build.connInput.CompanyIntegrationID, build.resolvedNames, user, provider
6788
+ );
6789
+ if (disabledObjects.length > 0) {
6790
+ console.log(`[IntegrationApplyAllBatch] Disabled ${disabledObjects.length} unselected entity map(s): ${disabledObjects.join(', ')}`);
6791
+ }
6792
+ }
6793
+
5696
6794
  const createdMapIDs = entityMapsCreated.map(em => em.EntityMapID).filter(Boolean);
5697
6795
  const scopedMapIDs = input.SyncScope === 'all' ? undefined : createdMapIDs;
5698
6796
 
@@ -5705,7 +6803,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5705
6803
  const shouldStartSync = input.StartSync !== false &&
5706
6804
  (input.SyncScope === 'all' || createdMapIDs.length > 0);
5707
6805
  const syncRunID = shouldStartSync
5708
- ? await this.startSyncAfterApply(build.connInput.CompanyIntegrationID, user, scopedMapIDs, input.FullSync)
6806
+ ? await this.startSyncAfterApply(build.connInput.CompanyIntegrationID, user, scopedMapIDs, build.fullSync)
5709
6807
  : null;
5710
6808
  if (syncRunID) connResult.SyncRunID = syncRunID;
5711
6809
 
@@ -5727,6 +6825,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5727
6825
  }
5728
6826
 
5729
6827
  connectorResults.push(connResult);
6828
+ });
5730
6829
  }
5731
6830
 
5732
6831
  const pipelineSteps = batchResult.Results[0]?.Steps.map((s: RSUPipelineStep) => ({
@@ -5773,6 +6872,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5773
6872
  EntityName: 'MJ: Scheduled Job Types',
5774
6873
  ExtraFilter: `DriverClass='IntegrationSyncScheduledJobDriver'`,
5775
6874
  MaxRows: 1,
6875
+ SkipTotalRowCount: true,
5776
6876
  ResultType: 'simple',
5777
6877
  Fields: ['ID']
5778
6878
  }, user);
@@ -5847,7 +6947,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5847
6947
  // Step 2: Null out ScheduledJobRunID on CompanyIntegrationRuns (break FK before deleting job runs)
5848
6948
  const ciRunsResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
5849
6949
  EntityName: 'MJ: Company Integration Runs',
5850
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
6950
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5851
6951
  ResultType: 'entity_object'
5852
6952
  }, sysUser);
5853
6953
  if (ciRunsResult.Success) {
@@ -5862,7 +6962,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5862
6962
  // Step 3: Delete scheduled job runs + jobs (reference CI via Configuration JSON)
5863
6963
  const jobsResult = await rv.RunView<MJScheduledJobEntity>({
5864
6964
  EntityName: 'MJ: Scheduled Jobs',
5865
- ExtraFilter: `Configuration LIKE '%${companyIntegrationID}%'`,
6965
+ ExtraFilter: `Configuration LIKE '%${sqlUuid(companyIntegrationID, 'companyIntegrationID')}%'`,
5866
6966
  ResultType: 'entity_object'
5867
6967
  }, sysUser);
5868
6968
  if (jobsResult.Success) {
@@ -5912,7 +7012,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5912
7012
  // Step 4: Delete entity map children (field maps, watermarks, record maps)
5913
7013
  const entityMapsResult = await rv.RunView<MJCompanyIntegrationEntityMapEntity>({
5914
7014
  EntityName: 'MJ: Company Integration Entity Maps',
5915
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
7015
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5916
7016
  ResultType: 'entity_object'
5917
7017
  }, sysUser);
5918
7018
  const entityMaps = entityMapsResult.Success ? entityMapsResult.Results : [];
@@ -5944,7 +7044,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5944
7044
 
5945
7045
  const rmResult = await rv.RunView<MJCompanyIntegrationRecordMapEntity>({
5946
7046
  EntityName: 'MJ: Company Integration Record Maps',
5947
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
7047
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5948
7048
  ResultType: 'entity_object'
5949
7049
  }, sysUser);
5950
7050
  if (rmResult.Success) {
@@ -5964,7 +7064,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5964
7064
  // Step 6: Delete employee-company integration links
5965
7065
  const empResult = await rv.RunView<MJEmployeeCompanyIntegrationEntity>({
5966
7066
  EntityName: 'MJ: Employee Company Integrations',
5967
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
7067
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5968
7068
  ResultType: 'entity_object'
5969
7069
  }, sysUser);
5970
7070
  if (empResult.Success) {
@@ -5974,6 +7074,39 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5974
7074
  }
5975
7075
  }
5976
7076
 
7077
+ // Step 6b: the per-connection catalog. Its rows carry a plain FK to this connection and
7078
+ // nothing cascades, so without this the CompanyIntegration delete below violates the
7079
+ // constraint and the WHOLE transaction rolls back — "Transaction failed — all deletes
7080
+ // rolled back" for any connection that ever ran a discovery on the per-connection
7081
+ // catalog (sandbox 2026-09-11, MJ-CAT-18). Fields first: they reference the objects,
7082
+ // including each other's objects through RelatedCompanyIntegrationObjectID, and every
7083
+ // row that can point at this connection's objects is this connection's own. Guarded on
7084
+ // registration, so a workspace without the catalog migration deletes exactly as before.
7085
+ if (md.Entities.some(e => e.Name === 'MJ: Company Integration Objects')) {
7086
+ const cioResult = await rv.RunView<BaseEntity>({
7087
+ EntityName: 'MJ: Company Integration Objects',
7088
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
7089
+ ResultType: 'entity_object',
7090
+ }, sysUser);
7091
+ const perConnectionObjects = cioResult.Success ? cioResult.Results : [];
7092
+ if (perConnectionObjects.length > 0) {
7093
+ const objectIDs = perConnectionObjects.map(o => `'${String(o.Get('ID')).replace(/'/g, "''")}'`).join(',');
7094
+ const ciofResult = await rv.RunView<BaseEntity>({
7095
+ EntityName: 'MJ: Company Integration Object Fields',
7096
+ ExtraFilter: `CompanyIntegrationObjectID IN (${objectIDs})`,
7097
+ ResultType: 'entity_object',
7098
+ }, sysUser);
7099
+ for (const field of (ciofResult.Success ? ciofResult.Results : [])) {
7100
+ field.TransactionGroup = tg;
7101
+ await field.Delete();
7102
+ }
7103
+ for (const object of perConnectionObjects) {
7104
+ object.TransactionGroup = tg;
7105
+ await object.Delete();
7106
+ }
7107
+ }
7108
+ }
7109
+
5977
7110
  // Step 7: Delete the CompanyIntegration itself FIRST, so its CredentialID FK reference
5978
7111
  // is gone before we delete the Credential. (Deleting the Credential first violates the
5979
7112
  // CompanyIntegration.CredentialID foreign key — the constraint is checked per-statement
@@ -6036,12 +7169,44 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6036
7169
  @Arg("platform", { defaultValue: "sqlserver" }) platform: string,
6037
7170
  @Arg("skipGitCommit", { defaultValue: false }) skipGitCommit: boolean,
6038
7171
  @Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
6039
- @Arg("autoEnableNewObjects", { defaultValue: true, description: 'newly-appeared objects get their entity maps created ENABLED — a refresh is an explicit request to bring the source\'s current shape in. Pass false to create them disabled and enable them by hand instead.' }) autoEnableNewObjects: boolean,
6040
- @Arg("autoEnableNewColumns", { defaultValue: true, description: 'newly-appeared COLUMNS on an enabled object get their field maps created ENABLED, matching autoEnableNewObjects. Pass false to create them disabled instead. NOTE: this governs the REFRESH only — a column discovered mid-SYNC is never auto-created; it is captured as a candidate and needs acceptance (Configuration.autoPromoteCustomColumns, default false).' }) autoEnableNewColumns: boolean,
7172
+ // CONTRACT: new arrivals are DISCOVERED, not adopted. everything.txt is explicit — "we
7173
+ // create entity maps for all objects that are determined as things that should be now
7174
+ // added but arent, we enable nothing (because the user needs to, after the refresh, then
7175
+ // go turn them on)".
7176
+ //
7177
+ // This defaulted TRUE for a while on the claim that a disabled new object "is absent from
7178
+ // IntrospectSchema — so it never reached key inference and never reached a migration".
7179
+ // That claim is wrong, and the code says so plainly:
7180
+ // - `CreateDisabled` governs ONLY the entity/field map Status and SyncEnabled
7181
+ // (index.ts:1833-1834, :1880). It never touches IntegrationObject.Status.
7182
+ // - New IntegrationObject rows are written Status='Active' unconditionally
7183
+ // (IntegrationSchemaSync.ts:852), so they are returned by GetActiveIntegrationObjects.
7184
+ // - Phase 4 builds over `evolveNames = continuingMaps + newObjects`, and newObjects is
7185
+ // included regardless of this flag — so the table IS created either way.
7186
+ // The only thing this flag decides is whether the new object starts SYNCING unasked.
7187
+ @Arg("autoEnableNewObjects", { defaultValue: false, description: 'newly-appeared objects get their entity maps created DISABLED — their tables are still created, they simply do not start syncing until the user enables them. Pass true to auto-enable instead.' }) autoEnableNewObjects: boolean,
7188
+ @Arg("autoEnableNewColumns", { defaultValue: false, description: 'newly-appeared COLUMNS on an enabled object get their field maps created DISABLED, matching autoEnableNewObjects. Pass true to auto-enable instead. NOTE: this governs the REFRESH only — a column discovered mid-SYNC is never auto-created; it is captured as a candidate and needs acceptance (Configuration.autoPromoteCustomColumns, default false).' }) autoEnableNewColumns: boolean,
6041
7189
  @Arg("deactivateAbsent", { nullable: true, description: 'Deactivate IO/IOF absent from this re-discovery (default true — comprehensive refresh; gated on the connector\'s authoritative-discovery getter).' }) deactivateAbsent: boolean | undefined,
6042
7190
  @Arg("cascadeRemoveDependents", { defaultValue: false, description: 'When a removed object has still-active dependents (DAG parent edges), also disable the transitive dependent closure ("force remove those too"). Default false: dependents stay active and each broken edge is surfaced as a warning.' }) cascadeRemoveDependents: boolean,
7191
+ // everything.txt: a custom column the source started sending is CAPTURED automatically but
7192
+ // materialising it "requires that the user accepts" it. So candidates are always REPORTED
7193
+ // (CustomColumnCandidates on the output) and this list is how they come back accepted.
7194
+ // Empty default = report and promote nothing, which is the contract.
7195
+ //
7196
+ // Accepted columns ride THIS run's migration rather than starting their own. Promoting in a
7197
+ // second batch would restart the workspace twice for one user action, and back-to-back RSU
7198
+ // restarts are their own incident class here.
7199
+ @Arg("acceptCustomColumns", () => [String], { defaultValue: [], description: 'Column names from a previous run\'s CustomColumnCandidates to materialise as part of THIS refresh — one migration, one restart. Default empty: candidates are reported and nothing is promoted.' }) acceptCustomColumns: string[],
6043
7200
  @Ctx() ctx: AppContext
6044
7201
  ): Promise<SchemaEvolutionOutput> {
7202
+ return WithCatalogScope(companyIntegrationID, async () => {
7203
+ // Checked BEFORE the lock is taken, so a refusal owes no release.
7204
+ {
7205
+ const user = this.getAuthenticatedUser(ctx);
7206
+ const md = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
7207
+ const paused = await this.describeIfPaused(companyIntegrationID, user, md);
7208
+ if (paused) return { Success: false, HasChanges: false, Message: `Schema evolution not started: ${paused}` };
7209
+ }
6045
7210
  // sync lock: schema evolution rewrites the metadata, field maps and DDL a sync
6046
7211
  // reads — data syncs (manual + scheduled) must not run while this is in flight.
6047
7212
  if (!IntegrationEngine.AcquireMaintenanceLock(companyIntegrationID, 'schema evolution')) {
@@ -6053,7 +7218,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6053
7218
  try {
6054
7219
  const user = this.getAuthenticatedUser(ctx);
6055
7220
  const validatedPlatform = this.validatePlatform(platform);
6056
- const md = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
7221
+ const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true });
7222
+ const md = provider as unknown as IMetadataProvider;
6057
7223
  const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user, md);
6058
7224
  const schemaName = this.deriveSchemaName(companyIntegration.Integration);
6059
7225
  const rv = new RunView();
@@ -6084,7 +7250,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6084
7250
  const activeNamesLower = new Set(activeIOs.map(o => o.Name.toLowerCase()));
6085
7251
  const priorMapsResult = await rv.RunView<MJCompanyIntegrationEntityMapEntity>({
6086
7252
  EntityName: 'MJ: Company Integration Entity Maps',
6087
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
7253
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
6088
7254
  ResultType: 'entity_object',
6089
7255
  BypassCache: true, // the diff must read committed state
6090
7256
  }, user);
@@ -6167,8 +7333,15 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6167
7333
  }
6168
7334
 
6169
7335
  // Source of truth for the evolution = the PERSISTED post-resolution rows (the refresh in
6170
- // Phase 1 just wrote them via the overlay) — no duplicate vendor introspect.
6171
- const sourceSchema = this.buildSourceSchemaFromPersistedRows(companyIntegration.IntegrationID);
7336
+ // Phase 1 just wrote them via the overlay) — no duplicate vendor introspect. Phase 6 reads
7337
+ // the same field rows, so they are loaded once.
7338
+ const scopedFields = await LoadScopedFieldsForBuild(companyIntegration.IntegrationID, user);
7339
+ const sourceSchema = this.buildSourceSchemaFromPersistedRows(
7340
+ companyIntegration.IntegrationID,
7341
+ undefined,
7342
+ undefined,
7343
+ scopedFields,
7344
+ );
6172
7345
 
6173
7346
  // Normalize names to match source schema casing
6174
7347
  const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
@@ -6185,7 +7358,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6185
7358
  };
6186
7359
 
6187
7360
  // Build target configs and SchemaBuilder input with ExistingTables
6188
- const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, validatedPlatform, connector);
7361
+ const drops = NewTargetConfigDrops();
7362
+ const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, validatedPlatform, connector, drops);
7363
+ // By here Phase 1 has persisted the re-resolved catalog and Phase 3 has disabled the removed
7364
+ // objects' maps, so "Nothing was applied" would be untrue. Failing is still right: Phase 6
7365
+ // would otherwise reconcile field maps against an empty resolution.
7366
+ AssertTargetConfigs(objects, targetConfigs, companyIntegration.Integration, drops,
7367
+ `The refreshed catalog had already been saved and the removed objects' entity maps disabled; ` +
7368
+ `no table was built and no migration ran.`);
6189
7369
 
6190
7370
  const schemaInput: SchemaBuilderInput = {
6191
7371
  SourceSchema: filteredSchema,
@@ -6204,6 +7384,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6204
7384
  const builder = new SchemaBuilder();
6205
7385
  const schemaOutput = builder.BuildSchema(schemaInput);
6206
7386
 
7387
+ // Declared rows this evolution leaves out (deactivated objects/fields). This path calls
7388
+ // SchemaBuilder directly rather than through buildSchemaForConnector, so it never carried
7389
+ // them — an operator whose selected table came back without a column had nothing to read.
7390
+ // Unshifted onto schemaOutput.Warnings, which is what every return below surfaces.
7391
+ const inactiveWarnings = this.collectInactiveRowWarnings(companyIntegration.IntegrationID, normalizedNames, scopedFields);
7392
+ if (inactiveWarnings.length > 0) schemaOutput.Warnings.unshift(...inactiveWarnings);
7393
+
6207
7394
  if (schemaOutput.Errors.length > 0) {
6208
7395
  return {
6209
7396
  Success: false,
@@ -6272,12 +7459,64 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6272
7459
  for (const em of continuingMaps) {
6273
7460
  const io = iosByLowerName.get((em.ExternalObjectName ?? '').toLowerCase());
6274
7461
  if (!io) continue;
6275
- const fmChanges = await this.reconcileFieldMapsForEntityMap(em, io.ID, user, md, autoEnableNewColumns);
7462
+ const fmChanges = await this.reconcileFieldMapsForEntityMap(em, io.ID, user, md, autoEnableNewColumns, scopedFields);
6276
7463
  if ((fmChanges.Added > 0 || fmChanges.Disabled > 0) && em.ExternalObjectName && !changedObjects.includes(em.ExternalObjectName)) {
6277
7464
  changedObjects.push(em.ExternalObjectName);
6278
7465
  }
6279
7466
  }
6280
7467
 
7468
+ // ── Phase 6b — RE-KEYED objects: the identity of every stored row just changed ──
7469
+ //
7470
+ // A connector upgrade can change an object's primary key (PheedLoop 1.4.6 makes
7471
+ // Attendees `code + eventCode`, because it is fetched once per event and an attendee at
7472
+ // two events returns twice with the same `code`). `ToExternalRecord` joins every
7473
+ // IsPrimaryKey field with '|' in Sequence order, so changing the key — its members OR
7474
+ // their order — re-identifies every record the source will send from now on. Nothing can
7475
+ // match a stored row again: the RecordMap is keyed `EntityID|ExternalID`, and
7476
+ // `MatchEngine.FindByKeyFields` queries the destination table by key fields that are
7477
+ // NULL on rows written before the change. Left alone, the next sync inserts the entire
7478
+ // source ALONGSIDE what is there.
7479
+ //
7480
+ // Compared against the key the TABLES were built with (the entity's registered fields),
7481
+ // not against the previous catalog — the built key is what the stored rows were actually
7482
+ // written under, and it is the only side that cannot drift from them.
7483
+ //
7484
+ // The catalog side reads the same field rows as the build and Phase 6. A cold
7485
+ // per-connection object answers `[]` from the engine, and an empty key against a real
7486
+ // built key reads as a re-key, which deletes the object's record maps and rows.
7487
+ const rekeyTargets: Array<{
7488
+ ExternalObjectName: string; EntityMapID: string; EntityID: string;
7489
+ SchemaName: string; BaseTable: string;
7490
+ }> = [];
7491
+ for (const em of continuingMaps) {
7492
+ const io = iosByLowerName.get((em.ExternalObjectName ?? '').toLowerCase());
7493
+ if (!io || !em.EntityID) continue;
7494
+ const entity = md.EntityByID(em.EntityID);
7495
+ if (!entity) continue;
7496
+ const builtKey = IdentityKeyFields(entity.Fields.map(ef => ({
7497
+ Name: ef.Name, IsPrimaryKey: ef.IsPrimaryKey, Sequence: ef.Sequence,
7498
+ })));
7499
+ const catalogKey = CatalogIdentityKey(
7500
+ FieldsForBuild(io.ID, scopedFields, IntegrationEngineBase.Instance)
7501
+ );
7502
+ if (!DecideRekeyed(builtKey, catalogKey)) continue;
7503
+ rekeyTargets.push({
7504
+ ExternalObjectName: em.ExternalObjectName as string,
7505
+ EntityMapID: em.ID,
7506
+ EntityID: em.EntityID,
7507
+ SchemaName: entity.SchemaName,
7508
+ BaseTable: entity.BaseTable,
7509
+ });
7510
+ // A re-key is a change even when no column moved — a pure reorder alters no DDL,
7511
+ // and without this the object would not reach the watermark reset below.
7512
+ if (em.ExternalObjectName && !changedObjects.includes(em.ExternalObjectName)) {
7513
+ changedObjects.push(em.ExternalObjectName);
7514
+ }
7515
+ }
7516
+ const rekeyedObjects = await ClearRekeyedObjectData(
7517
+ rekeyTargets, companyIntegrationID, user, provider
7518
+ );
7519
+
6281
7520
  // ── Phase 7 — U10: reset the Pull watermark of every CHANGED object so the next sync
6282
7521
  // full-fetches + backfills the schema change (content-hash bounds the rewrite cost).
6283
7522
  const changedLower = new Set(changedObjects.map(n => n.toLowerCase()));
@@ -6304,6 +7543,46 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6304
7543
  let pipelineSteps: RSUStepOutput[] | undefined;
6305
7544
  let gitCommitSuccess: boolean | undefined;
6306
7545
  let apiRestarted: boolean | undefined;
7546
+ // ── Overflow columns: always report, promote only what came back accepted. ──
7547
+ //
7548
+ // A refresh is where an operator asks "what has changed on the source?", and columns
7549
+ // the connector has been quietly capturing into the overflow JSON are part of that
7550
+ // answer. Reporting is free and read-only. Promoting is not, so it needs the explicit
7551
+ // acceptance everything.txt asks for.
7552
+ let customColumnCandidates: CustomColumnCandidate[] = [];
7553
+ let promotionInputs: RSUPipelineInput[] = [];
7554
+ let promotedColumns: PromotedColumn[] = [];
7555
+ try {
7556
+ const promoter = new IntegrationCustomColumnPromoter(user, md);
7557
+ const mapped = await this.getMappedEntityNames(companyIntegrationID, user);
7558
+ for (const en of mapped) {
7559
+ customColumnCandidates.push(...await promoter.ListCandidates(companyIntegrationID, en));
7560
+ }
7561
+ const accepted = new Set((acceptCustomColumns ?? []).map(c => c.toLowerCase()));
7562
+ if (accepted.size > 0) {
7563
+ // Only entities that actually own an accepted column — planning an entity with
7564
+ // nothing accepted would promote its OTHER candidates too, which is precisely
7565
+ // the un-asked-for materialisation the acceptance gate exists to prevent.
7566
+ const entitiesWithAccepted = Array.from(new Set(
7567
+ customColumnCandidates.filter(c => accepted.has(c.ColumnName.toLowerCase())).map(c => c.EntityName)
7568
+ ));
7569
+ if (entitiesWithAccepted.length > 0) {
7570
+ const plan = await promoter.PlanPromotion(companyIntegrationID, entitiesWithAccepted);
7571
+ if (plan) {
7572
+ promotionInputs = plan.BatchInputs;
7573
+ promotedColumns = plan.Plans.flatMap(p => p.work.map(w => ({ EntityName: p.entityName, ColumnName: w.columnName })));
7574
+ }
7575
+ }
7576
+ }
7577
+ } catch (candErr) {
7578
+ // Neither reporting nor promoting overflow columns is what a refresh is FOR. A
7579
+ // failure here must not fail the refresh — it costs the offer, not the evolution.
7580
+ LogError(`IntegrationSchemaEvolution: custom-column candidates unavailable — ${candErr}`);
7581
+ customColumnCandidates = [];
7582
+ promotionInputs = [];
7583
+ promotedColumns = [];
7584
+ }
7585
+
6307
7586
  if (hasDDLChanges) {
6308
7587
  const rsuInput = builder.BuildRSUInput(schemaOutput, schemaInput, {
6309
7588
  SkipGitCommit: skipGitCommit,
@@ -6344,7 +7623,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6344
7623
  // metadata already re-resolved and the DDL not applied — the half-state an operator
6345
7624
  // then has to diagnose. Replay happens only while nothing was applied.
6346
7625
  const rsm = RuntimeSchemaManager.Instance;
6347
- const batchResult = await rsm.RunPipelineBatchWithRetry([rsuInput]);
7626
+ // ONE batch: the refresh's own DDL plus any accepted overflow columns. RSU
7627
+ // registers PendingWork from EVERY input, so the promoter's post-restart
7628
+ // completion survives being appended here rather than run as its own batch.
7629
+ const batchResult = await rsm.RunPipelineBatchWithRetry([rsuInput, ...promotionInputs]);
6348
7630
  const pipelineResult = batchResult.Results[0];
6349
7631
  pipelineSteps = pipelineResult?.Steps.map((s: RSUPipelineStep) => ({
6350
7632
  Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
@@ -6380,19 +7662,49 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6380
7662
  }
6381
7663
  }
6382
7664
  }
7665
+ } else if (promotionInputs.length > 0) {
7666
+ // The source's own shape is unchanged, but the operator accepted overflow columns.
7667
+ // They still need a migration; there is simply no evolution DDL to share it with.
7668
+ // Retrying variant, as above: it replays only while nothing was applied.
7669
+ const rsm = RuntimeSchemaManager.Instance;
7670
+ const batchResult = await rsm.RunPipelineBatchWithRetry(promotionInputs);
7671
+ const first = batchResult.Results[0];
7672
+ pipelineSteps = first?.Steps.map((s: RSUPipelineStep) => ({
7673
+ Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
7674
+ }));
7675
+ gitCommitSuccess = first?.GitCommitSuccess;
7676
+ apiRestarted = first?.APIRestarted;
7677
+ if (!first?.Success) {
7678
+ return {
7679
+ Success: false,
7680
+ Message: `Column promotion failed: ${first?.ErrorMessage ?? 'unknown error'}`,
7681
+ HasChanges: false,
7682
+ Steps: pipelineSteps,
7683
+ CustomColumnCandidates: customColumnCandidates.length > 0 ? customColumnCandidates : undefined,
7684
+ };
7685
+ }
6383
7686
  }
6384
7687
 
7688
+ // Report only what is STILL outstanding: a column accepted this run is being created
7689
+ // right now, so leaving it in the offer would invite the operator to accept it twice.
7690
+ const acceptedNames = new Set(promotedColumns.map(c => c.ColumnName.toLowerCase()));
7691
+ const outstandingCandidates = customColumnCandidates.filter(c => !acceptedNames.has(c.ColumnName.toLowerCase()));
7692
+
6385
7693
  const summary = [
6386
7694
  newObjects.length > 0 ? `${newObjects.length} new object(s) (${autoEnableNewObjects ? 'enabled' : 'created disabled'})` : null,
6387
7695
  removedObjects.length > 0 ? `${removedObjects.length} removed object(s) disabled` : null,
6388
7696
  changedObjects.length > 0 ? `${changedObjects.length} changed object(s), ${addedColumns} column(s) added (${autoEnableNewColumns ? 'enabled' : 'created disabled'}), ${modifiedColumns} modified` : null,
6389
7697
  watermarksReset.length > 0 ? `${watermarksReset.length} watermark(s) reset for backfill` : null,
7698
+ promotedColumns.length > 0 ? `${promotedColumns.length} accepted custom column(s) materialised` : null,
7699
+ outstandingCandidates.length > 0 ? `${outstandingCandidates.length} custom column(s) awaiting your acceptance` : null,
6390
7700
  ].filter(Boolean).join('; ');
6391
7701
 
6392
7702
  return {
6393
7703
  Success: true,
6394
7704
  Message: `Schema evolution applied — ${summary || 'no per-object deltas'}`,
6395
7705
  HasChanges: true,
7706
+ CustomColumnCandidates: outstandingCandidates.length > 0 ? outstandingCandidates : undefined,
7707
+ PromotedColumns: promotedColumns.length > 0 ? promotedColumns : undefined,
6396
7708
  AddedColumns: addedColumns,
6397
7709
  ModifiedColumns: modifiedColumns,
6398
7710
  Steps: pipelineSteps,
@@ -6403,6 +7715,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6403
7715
  CascadeRemovedObjects: cascadeRemoved.length > 0 ? cascadeRemoved : undefined,
6404
7716
  ChangedObjects: changedObjects.length > 0 ? changedObjects : undefined,
6405
7717
  WatermarksReset: watermarksReset.length > 0 ? watermarksReset : undefined,
7718
+ RekeyedObjects: rekeyedObjects.length > 0 ? rekeyedObjects : undefined,
6406
7719
  Warnings: (dagRemovalWarnings.length + schemaOutput.Warnings.length) > 0
6407
7720
  ? [...dagRemovalWarnings, ...schemaOutput.Warnings]
6408
7721
  : undefined,
@@ -6413,6 +7726,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6413
7726
  } finally {
6414
7727
  IntegrationEngine.ReleaseMaintenanceLock(companyIntegrationID);
6415
7728
  }
7729
+ });
6416
7730
  }
6417
7731
 
6418
7732
  /**
@@ -6432,17 +7746,22 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6432
7746
  * A field map whose source field is no longer Active in the resolution is DISABLED, never
6433
7747
  * deleted — a later re-appearance re-enables it through the same reconciliation (the
6434
7748
  * field-is-back branch above), so retiring and restoring a column are both non-destructive.
7749
+ *
7750
+ * Under a per-connection catalog the field rows come from `scopedFields`, never from the
7751
+ * engine: the engine answers `[]` for a cold per-connection object, and an empty field list here
7752
+ * disables every Active field map on the object.
6435
7753
  */
6436
7754
  private async reconcileFieldMapsForEntityMap(
6437
7755
  em: MJCompanyIntegrationEntityMapEntity,
6438
7756
  integrationObjectID: string,
6439
7757
  user: UserInfo,
6440
7758
  md: IMetadataProvider,
6441
- autoEnableNewColumns = true
7759
+ // see decideFieldMapReconcile — an omitted argument must not silently adopt
7760
+ autoEnableNewColumns = false,
7761
+ scopedFields?: ScopedBuildFields | null,
6442
7762
  ): Promise<{ Added: number; Disabled: number }> {
6443
7763
  const result = { Added: 0, Disabled: 0 };
6444
- const activeFields = IntegrationEngineBase.Instance
6445
- .GetIntegrationObjectFields(integrationObjectID)
7764
+ const activeFields = FieldsForBuild(integrationObjectID, scopedFields, IntegrationEngineBase.Instance)
6446
7765
  .filter(f => f.Status === 'Active');
6447
7766
  const mapEnabled = em.Status === 'Active' && em.SyncEnabled === true;
6448
7767