@memberjunction/server 6.2.0-edge.5 → 6.2.0-edge.7

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 (664) hide show
  1. package/README.md +96 -1
  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 +34 -11
  14. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
  15. package/dist/agentSessions/SessionJanitor.js +88 -44
  16. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  17. package/dist/agentSessions/SessionManager.d.ts +29 -9
  18. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  19. package/dist/agentSessions/SessionManager.js +62 -16
  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/AuthProviderCatalogRouter.d.ts.map +1 -1
  33. package/dist/auth/AuthProviderCatalogRouter.js +2 -0
  34. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -1
  35. package/dist/auth/index.d.ts.map +1 -1
  36. package/dist/auth/index.js +51 -10
  37. package/dist/auth/index.js.map +1 -1
  38. package/dist/auth/initializeProviders.d.ts +10 -0
  39. package/dist/auth/initializeProviders.d.ts.map +1 -1
  40. package/dist/auth/initializeProviders.js +37 -1
  41. package/dist/auth/initializeProviders.js.map +1 -1
  42. package/dist/auth/localDevAuth.d.ts +79 -0
  43. package/dist/auth/localDevAuth.d.ts.map +1 -0
  44. package/dist/auth/localDevAuth.js +439 -0
  45. package/dist/auth/localDevAuth.js.map +1 -0
  46. package/dist/auth/loginEmailResolution.d.ts +41 -0
  47. package/dist/auth/loginEmailResolution.d.ts.map +1 -0
  48. package/dist/auth/loginEmailResolution.js +47 -0
  49. package/dist/auth/loginEmailResolution.js.map +1 -0
  50. package/dist/auth/magicLink/MagicLinkKeys.d.ts +51 -3
  51. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
  52. package/dist/auth/magicLink/MagicLinkKeys.js +130 -13
  53. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
  54. package/dist/auth/magicLink/MagicLinkRouter.d.ts +3 -0
  55. package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
  56. package/dist/auth/magicLink/MagicLinkRouter.js +23 -5
  57. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  58. package/dist/auth/magicLink/MagicLinkService.d.ts +39 -5
  59. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  60. package/dist/auth/magicLink/MagicLinkService.js +221 -9
  61. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  62. package/dist/auth/magicLink/index.d.ts +1 -1
  63. package/dist/auth/magicLink/index.d.ts.map +1 -1
  64. package/dist/auth/magicLink/index.js +1 -1
  65. package/dist/auth/magicLink/index.js.map +1 -1
  66. package/dist/auth/magicLink/redeemLanding.d.ts +2 -2
  67. package/dist/auth/magicLink/redeemLanding.d.ts.map +1 -1
  68. package/dist/auth/magicLink/redeemLanding.js +7 -4
  69. package/dist/auth/magicLink/redeemLanding.js.map +1 -1
  70. package/dist/auth/magicLink/types.d.ts +39 -3
  71. package/dist/auth/magicLink/types.d.ts.map +1 -1
  72. package/dist/auth/magicLink/types.js +0 -4
  73. package/dist/auth/magicLink/types.js.map +1 -1
  74. package/dist/auth/newUsers.d.ts.map +1 -1
  75. package/dist/auth/newUsers.js +1 -0
  76. package/dist/auth/newUsers.js.map +1 -1
  77. package/dist/auth/sessionUserClone.d.ts +1 -1
  78. package/dist/auth/sessionUserClone.d.ts.map +1 -1
  79. package/dist/auth/sessionUserClone.js +3 -2
  80. package/dist/auth/sessionUserClone.js.map +1 -1
  81. package/dist/backgroundSweepConfigUnits.d.ts +231 -0
  82. package/dist/backgroundSweepConfigUnits.d.ts.map +1 -0
  83. package/dist/backgroundSweepConfigUnits.js +180 -0
  84. package/dist/backgroundSweepConfigUnits.js.map +1 -0
  85. package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.d.ts +292 -0
  86. package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.d.ts.map +1 -0
  87. package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.js +442 -0
  88. package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.js.map +1 -0
  89. package/dist/broadcast/index.d.ts +6 -0
  90. package/dist/broadcast/index.d.ts.map +1 -0
  91. package/dist/broadcast/index.js +6 -0
  92. package/dist/broadcast/index.js.map +1 -0
  93. package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.d.ts +43 -0
  94. package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.d.ts.map +1 -0
  95. package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.js +144 -0
  96. package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.js.map +1 -0
  97. package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.d.ts +46 -0
  98. package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.d.ts.map +1 -0
  99. package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.js +149 -0
  100. package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.js.map +1 -0
  101. package/dist/broadcast/providers/GCPPubSubBroadcastProvider.d.ts +49 -0
  102. package/dist/broadcast/providers/GCPPubSubBroadcastProvider.d.ts.map +1 -0
  103. package/dist/broadcast/providers/GCPPubSubBroadcastProvider.js +131 -0
  104. package/dist/broadcast/providers/GCPPubSubBroadcastProvider.js.map +1 -0
  105. package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.d.ts +24 -0
  106. package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.d.ts.map +1 -0
  107. package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.js +51 -0
  108. package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.js.map +1 -0
  109. package/dist/cache.d.ts +3 -2
  110. package/dist/cache.d.ts.map +1 -1
  111. package/dist/cache.js.map +1 -1
  112. package/dist/config.d.ts +1402 -8
  113. package/dist/config.d.ts.map +1 -1
  114. package/dist/config.js +120 -0
  115. package/dist/config.js.map +1 -1
  116. package/dist/context.d.ts +5 -1
  117. package/dist/context.d.ts.map +1 -1
  118. package/dist/context.js +64 -12
  119. package/dist/context.js.map +1 -1
  120. package/dist/coreRoutes.d.ts +3 -1
  121. package/dist/coreRoutes.d.ts.map +1 -1
  122. package/dist/coreRoutes.js +20 -4
  123. package/dist/coreRoutes.js.map +1 -1
  124. package/dist/generated/graphql-schemas/__mj.d.ts +863 -14
  125. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  126. package/dist/generated/graphql-schemas/__mj.js +4167 -62
  127. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  128. package/dist/generic/CacheInvalidationResolver.d.ts +50 -30
  129. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  130. package/dist/generic/CacheInvalidationResolver.js +154 -79
  131. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  132. package/dist/generic/OrphanedConversationDetailReconciler.d.ts +11 -0
  133. package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -1
  134. package/dist/generic/OrphanedConversationDetailReconciler.js +29 -1
  135. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -1
  136. package/dist/generic/PubSubManager.d.ts +10 -0
  137. package/dist/generic/PubSubManager.d.ts.map +1 -1
  138. package/dist/generic/PubSubManager.js +44 -1
  139. package/dist/generic/PubSubManager.js.map +1 -1
  140. package/dist/generic/PushStatusResolver.d.ts +39 -6
  141. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  142. package/dist/generic/PushStatusResolver.js +93 -11
  143. package/dist/generic/PushStatusResolver.js.map +1 -1
  144. package/dist/generic/ResolverBase.d.ts +114 -29
  145. package/dist/generic/ResolverBase.d.ts.map +1 -1
  146. package/dist/generic/ResolverBase.js +509 -220
  147. package/dist/generic/ResolverBase.js.map +1 -1
  148. package/dist/generic/RunViewResolver.d.ts +15 -3
  149. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  150. package/dist/generic/RunViewResolver.js +126 -20
  151. package/dist/generic/RunViewResolver.js.map +1 -1
  152. package/dist/generic/actionOutputFieldSecurity.d.ts +26 -0
  153. package/dist/generic/actionOutputFieldSecurity.d.ts.map +1 -0
  154. package/dist/generic/actionOutputFieldSecurity.js +47 -0
  155. package/dist/generic/actionOutputFieldSecurity.js.map +1 -0
  156. package/dist/generic/fieldSecurityTransport.d.ts +30 -0
  157. package/dist/generic/fieldSecurityTransport.d.ts.map +1 -0
  158. package/dist/generic/fieldSecurityTransport.js +30 -0
  159. package/dist/generic/fieldSecurityTransport.js.map +1 -0
  160. package/dist/index.d.ts +4 -0
  161. package/dist/index.d.ts.map +1 -1
  162. package/dist/index.js +262 -84
  163. package/dist/index.js.map +1 -1
  164. package/dist/integration/ConnectionPause.d.ts +121 -0
  165. package/dist/integration/ConnectionPause.d.ts.map +1 -0
  166. package/dist/integration/ConnectionPause.js +183 -0
  167. package/dist/integration/ConnectionPause.js.map +1 -0
  168. package/dist/integration/CustomColumnPromoter.d.ts +72 -6
  169. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  170. package/dist/integration/CustomColumnPromoter.js +93 -47
  171. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  172. package/dist/integration/EntityMapLifecycle.d.ts +78 -4
  173. package/dist/integration/EntityMapLifecycle.d.ts.map +1 -1
  174. package/dist/integration/EntityMapLifecycle.js +199 -5
  175. package/dist/integration/EntityMapLifecycle.js.map +1 -1
  176. package/dist/integration/InactiveRowWarnings.d.ts +31 -0
  177. package/dist/integration/InactiveRowWarnings.d.ts.map +1 -1
  178. package/dist/integration/InactiveRowWarnings.js +29 -0
  179. package/dist/integration/InactiveRowWarnings.js.map +1 -1
  180. package/dist/integration/ObjectNamesByID.d.ts +28 -0
  181. package/dist/integration/ObjectNamesByID.d.ts.map +1 -0
  182. package/dist/integration/ObjectNamesByID.js +87 -0
  183. package/dist/integration/ObjectNamesByID.js.map +1 -0
  184. package/dist/integration/PostRestartIntrospect.d.ts +26 -0
  185. package/dist/integration/PostRestartIntrospect.d.ts.map +1 -0
  186. package/dist/integration/PostRestartIntrospect.js +27 -0
  187. package/dist/integration/PostRestartIntrospect.js.map +1 -0
  188. package/dist/integration/RSUPostRestartProgress.d.ts +176 -0
  189. package/dist/integration/RSUPostRestartProgress.d.ts.map +1 -0
  190. package/dist/integration/RSUPostRestartProgress.js +323 -0
  191. package/dist/integration/RSUPostRestartProgress.js.map +1 -0
  192. package/dist/integration/RSUProgressBridge.d.ts +41 -2
  193. package/dist/integration/RSUProgressBridge.d.ts.map +1 -1
  194. package/dist/integration/RSUProgressBridge.js +84 -1
  195. package/dist/integration/RSUProgressBridge.js.map +1 -1
  196. package/dist/integration/RunArtifactAuthorization.d.ts +39 -0
  197. package/dist/integration/RunArtifactAuthorization.d.ts.map +1 -0
  198. package/dist/integration/RunArtifactAuthorization.js +45 -0
  199. package/dist/integration/RunArtifactAuthorization.js.map +1 -0
  200. package/dist/integration/SchemaRefreshLaunch.d.ts +9 -0
  201. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -1
  202. package/dist/integration/SchemaRefreshLaunch.js +3 -2
  203. package/dist/integration/SchemaRefreshLaunch.js.map +1 -1
  204. package/dist/integration/ScopedFieldsForBuild.d.ts +46 -0
  205. package/dist/integration/ScopedFieldsForBuild.d.ts.map +1 -0
  206. package/dist/integration/ScopedFieldsForBuild.js +91 -0
  207. package/dist/integration/ScopedFieldsForBuild.js.map +1 -0
  208. package/dist/integration/TargetConfigGuard.d.ts +40 -0
  209. package/dist/integration/TargetConfigGuard.d.ts.map +1 -0
  210. package/dist/integration/TargetConfigGuard.js +40 -0
  211. package/dist/integration/TargetConfigGuard.js.map +1 -0
  212. package/dist/logging/variablesLoggingMiddleware.d.ts +12 -0
  213. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
  214. package/dist/logging/variablesLoggingMiddleware.js +50 -1
  215. package/dist/logging/variablesLoggingMiddleware.js.map +1 -1
  216. package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts.map +1 -1
  217. package/dist/realtimeSessions/SessionVerificationPortsBase.js +1 -0
  218. package/dist/realtimeSessions/SessionVerificationPortsBase.js.map +1 -1
  219. package/dist/realtimeSessions/ownedRealtimeSession.d.ts +8 -6
  220. package/dist/realtimeSessions/ownedRealtimeSession.d.ts.map +1 -1
  221. package/dist/realtimeSessions/ownedRealtimeSession.js +14 -6
  222. package/dist/realtimeSessions/ownedRealtimeSession.js.map +1 -1
  223. package/dist/realtimeSessions/sessionAccess.d.ts +103 -11
  224. package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -1
  225. package/dist/realtimeSessions/sessionAccess.js +147 -22
  226. package/dist/realtimeSessions/sessionAccess.js.map +1 -1
  227. package/dist/realtimeSessions/sessionConfigStamps.js +1 -1
  228. package/dist/realtimeSessions/sessionConfigStamps.js.map +1 -1
  229. package/dist/realtimeSessions/subscriptionAuthorization.d.ts.map +1 -1
  230. package/dist/realtimeSessions/subscriptionAuthorization.js +5 -2
  231. package/dist/realtimeSessions/subscriptionAuthorization.js.map +1 -1
  232. package/dist/realtimeSessions/verificationWorkflow.d.ts +2 -0
  233. package/dist/realtimeSessions/verificationWorkflow.d.ts.map +1 -1
  234. package/dist/realtimeSessions/verificationWorkflow.js +1 -1
  235. package/dist/realtimeSessions/verificationWorkflow.js.map +1 -1
  236. package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
  237. package/dist/realtimeWidget/WidgetRouter.js +19 -3
  238. package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
  239. package/dist/realtimeWidget/WidgetSessionService.d.ts +67 -7
  240. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
  241. package/dist/realtimeWidget/WidgetSessionService.js +203 -19
  242. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  243. package/dist/realtimeWidget/host-identity.d.ts +1 -1
  244. package/dist/realtimeWidget/host-identity.js +1 -1
  245. package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
  246. package/dist/realtimeWidget/visitorIdentity.js +1 -0
  247. package/dist/realtimeWidget/visitorIdentity.js.map +1 -1
  248. package/dist/realtimeWidget/widgetCore.d.ts +20 -1
  249. package/dist/realtimeWidget/widgetCore.d.ts.map +1 -1
  250. package/dist/realtimeWidget/widgetCore.js +21 -8
  251. package/dist/realtimeWidget/widgetCore.js.map +1 -1
  252. package/dist/realtimeWidget/widgetGuestElevation.d.ts +1 -1
  253. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  254. package/dist/realtimeWidget/widgetGuestElevation.js +3 -1
  255. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  256. package/dist/requestOrigin.d.ts +22 -0
  257. package/dist/requestOrigin.d.ts.map +1 -0
  258. package/dist/requestOrigin.js +60 -0
  259. package/dist/requestOrigin.js.map +1 -0
  260. package/dist/resolvers/ActionResolver.d.ts +19 -1
  261. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  262. package/dist/resolvers/ActionResolver.js +63 -12
  263. package/dist/resolvers/ActionResolver.js.map +1 -1
  264. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  265. package/dist/resolvers/AdhocQueryResolver.js +1 -0
  266. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  267. package/dist/resolvers/AgentRunStatusPublisher.d.ts +18 -4
  268. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -1
  269. package/dist/resolvers/AgentRunStatusPublisher.js +32 -8
  270. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -1
  271. package/dist/resolvers/AgentSessionResolver.d.ts +10 -4
  272. package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -1
  273. package/dist/resolvers/AgentSessionResolver.js +44 -17
  274. package/dist/resolvers/AgentSessionResolver.js.map +1 -1
  275. package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -1
  276. package/dist/resolvers/AutotagPipelineResolver.js +2 -1
  277. package/dist/resolvers/AutotagPipelineResolver.js.map +1 -1
  278. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -1
  279. package/dist/resolvers/ConversationRunEventsResolver.js +2 -0
  280. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -1
  281. package/dist/resolvers/EntityCommunicationsResolver.d.ts +13 -1
  282. package/dist/resolvers/EntityCommunicationsResolver.d.ts.map +1 -1
  283. package/dist/resolvers/EntityCommunicationsResolver.js +50 -14
  284. package/dist/resolvers/EntityCommunicationsResolver.js.map +1 -1
  285. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +6 -0
  286. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
  287. package/dist/resolvers/ExecuteRemoteOperationResolver.js +30 -7
  288. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
  289. package/dist/resolvers/FetchEntityVectorsResolver.d.ts +28 -0
  290. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  291. package/dist/resolvers/FetchEntityVectorsResolver.js +117 -21
  292. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  293. package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
  294. package/dist/resolvers/FileCategoryResolver.js +1 -0
  295. package/dist/resolvers/FileCategoryResolver.js.map +1 -1
  296. package/dist/resolvers/FileResolver.d.ts +5 -0
  297. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  298. package/dist/resolvers/FileResolver.js +13 -4
  299. package/dist/resolvers/FileResolver.js.map +1 -1
  300. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  301. package/dist/resolvers/GetDataResolver.js +34 -13
  302. package/dist/resolvers/GetDataResolver.js.map +1 -1
  303. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +401 -11
  304. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  305. package/dist/resolvers/IntegrationDiscoveryResolver.js +2713 -1290
  306. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  307. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  308. package/dist/resolvers/MCPResolver.js +13 -2
  309. package/dist/resolvers/MCPResolver.js.map +1 -1
  310. package/dist/resolvers/MergeRecordsResolver.d.ts +8 -1
  311. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  312. package/dist/resolvers/MergeRecordsResolver.js +31 -3
  313. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  314. package/dist/resolvers/PipelineProgressResolver.d.ts +6 -0
  315. package/dist/resolvers/PipelineProgressResolver.d.ts.map +1 -1
  316. package/dist/resolvers/PipelineProgressResolver.js +5 -3
  317. package/dist/resolvers/PipelineProgressResolver.js.map +1 -1
  318. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  319. package/dist/resolvers/QueryResolver.js +20 -6
  320. package/dist/resolvers/QueryResolver.js.map +1 -1
  321. package/dist/resolvers/QuerySystemUserResolver.d.ts +38 -0
  322. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  323. package/dist/resolvers/QuerySystemUserResolver.js +184 -88
  324. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  325. package/dist/resolvers/RSUResolver.d.ts.map +1 -1
  326. package/dist/resolvers/RSUResolver.js +12 -0
  327. package/dist/resolvers/RSUResolver.js.map +1 -1
  328. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  329. package/dist/resolvers/RealtimeBridgeResolver.js +1 -0
  330. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  331. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +22 -2
  332. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  333. package/dist/resolvers/RealtimeClientSessionResolver.js +118 -33
  334. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  335. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +62 -10
  336. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  337. package/dist/resolvers/RemoteBrowserActionResolver.js +224 -39
  338. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  339. package/dist/resolvers/RunAIAgentResolver.d.ts +80 -16
  340. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  341. package/dist/resolvers/RunAIAgentResolver.js +388 -125
  342. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  343. package/dist/resolvers/RunAIPromptResolver.d.ts +5 -3
  344. package/dist/resolvers/RunAIPromptResolver.d.ts.map +1 -1
  345. package/dist/resolvers/RunAIPromptResolver.js +19 -18
  346. package/dist/resolvers/RunAIPromptResolver.js.map +1 -1
  347. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  348. package/dist/resolvers/RunTemplateResolver.js +7 -5
  349. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  350. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  351. package/dist/resolvers/RunTestResolver.js +3 -1
  352. package/dist/resolvers/RunTestResolver.js.map +1 -1
  353. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  354. package/dist/resolvers/SyncDataResolver.js +4 -0
  355. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  356. package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
  357. package/dist/resolvers/TestQuerySQLResolver.js +1 -0
  358. package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
  359. package/dist/resolvers/TransactionGroupResolver.d.ts +17 -2
  360. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  361. package/dist/resolvers/TransactionGroupResolver.js +60 -15
  362. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  363. package/dist/resolvers/UserViewResolver.d.ts +1 -1
  364. package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
  365. package/dist/resolvers/UserViewResolver.js +2 -1
  366. package/dist/resolvers/UserViewResolver.js.map +1 -1
  367. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
  368. package/dist/resolvers/meetingRecordingRegistration.js +2 -0
  369. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
  370. package/dist/rest/EntityCRUDHandler.d.ts +6 -0
  371. package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
  372. package/dist/rest/EntityCRUDHandler.js +19 -5
  373. package/dist/rest/EntityCRUDHandler.js.map +1 -1
  374. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  375. package/dist/rest/MediaAccessKeys.js +6 -2
  376. package/dist/rest/MediaAccessKeys.js.map +1 -1
  377. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  378. package/dist/rest/OAuthCallbackHandler.js +11 -5
  379. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  380. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  381. package/dist/rest/RESTEndpointHandler.js +8 -5
  382. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  383. package/dist/rest/RealtimeRecordingHandler.d.ts +3 -3
  384. package/dist/rest/RealtimeRecordingHandler.d.ts.map +1 -1
  385. package/dist/rest/RealtimeRecordingHandler.js +6 -8
  386. package/dist/rest/RealtimeRecordingHandler.js.map +1 -1
  387. package/dist/rest/ViewOperationsHandler.d.ts +6 -0
  388. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  389. package/dist/rest/ViewOperationsHandler.js +24 -6
  390. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  391. package/dist/rest/setupRESTEndpoints.d.ts +3 -1
  392. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
  393. package/dist/rest/setupRESTEndpoints.js +11 -2
  394. package/dist/rest/setupRESTEndpoints.js.map +1 -1
  395. package/dist/serverRoleConfigUnits.d.ts +29 -0
  396. package/dist/serverRoleConfigUnits.d.ts.map +1 -0
  397. package/dist/serverRoleConfigUnits.js +33 -0
  398. package/dist/serverRoleConfigUnits.js.map +1 -0
  399. package/dist/services/DurableEntityActionTaskSubmitter.d.ts.map +1 -1
  400. package/dist/services/DurableEntityActionTaskSubmitter.js +6 -1
  401. package/dist/services/DurableEntityActionTaskSubmitter.js.map +1 -1
  402. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  403. package/dist/services/TaskGraphActionRunner.js +2 -26
  404. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  405. package/dist/services/TaskGraphAgentRunner.d.ts +6 -0
  406. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  407. package/dist/services/TaskGraphAgentRunner.js +38 -0
  408. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  409. package/dist/services/TaskGraphContinuationDeliverer.d.ts +5 -0
  410. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  411. package/dist/services/TaskGraphContinuationDeliverer.js +43 -15
  412. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  413. package/dist/services/WorkQueueHostService.d.ts +2 -1
  414. package/dist/services/WorkQueueHostService.d.ts.map +1 -1
  415. package/dist/services/WorkQueueHostService.js +19 -4
  416. package/dist/services/WorkQueueHostService.js.map +1 -1
  417. package/dist/services/workQueueConfig.d.ts +2 -2
  418. package/dist/services/workQueueConfig.js +5 -5
  419. package/dist/services/workQueueConfig.js.map +1 -1
  420. package/dist/sharedCache.js +1 -1
  421. package/dist/sharedCache.js.map +1 -1
  422. package/dist/types.d.ts +40 -1
  423. package/dist/types.d.ts.map +1 -1
  424. package/dist/types.js.map +1 -1
  425. package/package.json +98 -98
  426. package/src/__tests__/APIKeyScopeAuth.test.ts +4 -8
  427. package/src/__tests__/ActionResolver.fieldSecurity.test.ts +237 -0
  428. package/src/__tests__/ActiveOperations.test.ts +68 -0
  429. package/src/__tests__/AdhocQueryResolver.dialect.test.ts +137 -0
  430. package/src/__tests__/AgentRunStatusPublisher.test.ts +49 -0
  431. package/src/__tests__/AgentSessionResolver.ownership.test.ts +150 -0
  432. package/src/__tests__/AssertTargetConfigs.test.ts +75 -0
  433. package/src/__tests__/BatchApplyParity.test.ts +109 -0
  434. package/src/__tests__/BridgeHostInstance.test.ts +42 -0
  435. package/src/__tests__/CollectInactiveRowWarnings.test.ts +115 -0
  436. package/src/__tests__/ConnectionPause.test.ts +192 -0
  437. package/src/__tests__/ConnectionPauseResolver.test.ts +166 -0
  438. package/src/__tests__/ConnectionPauseSave.test.ts +143 -0
  439. package/src/__tests__/CustomColumnPromoterCompletion.test.ts +142 -0
  440. package/src/__tests__/CustomColumnPromoterOnePass.test.ts +15 -1
  441. package/src/__tests__/DeleteConnectionRemovesPerConnectionCatalog.test.ts +51 -0
  442. package/src/__tests__/DetachedDiscoveryStart.test.ts +65 -0
  443. package/src/__tests__/DurableEntityActionTaskSubmitter.test.ts +73 -0
  444. package/src/__tests__/EntityCommunicationsResolver.actingUser.test.ts +151 -0
  445. package/src/__tests__/EntityMapLifecycle.rekey.test.ts +252 -0
  446. package/src/__tests__/EvolutionActsOnWhatItDetects.test.ts +113 -0
  447. package/src/__tests__/ExecuteRemoteOperationResolver.test.ts +213 -0
  448. package/src/__tests__/FetchEntityVectorsResolver.test.ts +206 -3
  449. package/src/__tests__/FileResolver.fieldSecurity.test.ts +82 -0
  450. package/src/__tests__/HandoffOfferExpiry.test.ts +40 -0
  451. package/src/__tests__/IntegrationRunArtifactAuthorization.test.ts +126 -0
  452. package/src/__tests__/MagicLinkKeys.test.ts +102 -0
  453. package/src/__tests__/MediaAccessKeys.test.ts +57 -10
  454. package/src/__tests__/NewArrivalsDefaultDisabled.test.ts +50 -0
  455. package/src/__tests__/ObjectNamesByID.test.ts +176 -0
  456. package/src/__tests__/PipelineProgressResolver.filter.test.ts +58 -0
  457. package/src/__tests__/PostRestartMapsInCatalogScope.test.ts +146 -0
  458. package/src/__tests__/QuerySystemUserResolver.childRows.test.ts +80 -0
  459. package/src/__tests__/QuerySystemUserResolver.collision.test.ts +200 -0
  460. package/src/__tests__/QuerySystemUserResolver.fieldSecurity.test.ts +49 -0
  461. package/src/__tests__/QuerySystemUserResolver.permissions.test.ts +132 -0
  462. package/src/__tests__/RESTEndpointHandler.test.ts +25 -0
  463. package/src/__tests__/RSUPostRestartProgress.test.ts +343 -0
  464. package/src/__tests__/RSUProgressBridge.test.ts +188 -5
  465. package/src/__tests__/RSUStartupSweepWiring.test.ts +30 -0
  466. package/src/__tests__/ReactivateRefreshMessage.test.ts +10 -3
  467. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +23 -3
  468. package/src/__tests__/RealtimeClientSessionResolver.test.ts +346 -46
  469. package/src/__tests__/{RefreshAdoptsNewColumns.test.ts → RefreshReportsNewColumns.test.ts} +25 -16
  470. package/src/__tests__/RemoteBrowserActionRecovery.test.ts +287 -0
  471. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +3 -0
  472. package/src/__tests__/RemoteBrowserActionResolver.ownership.test.ts +100 -0
  473. package/src/__tests__/RemoteBrowserAudioStream.test.ts +3 -0
  474. package/src/__tests__/RemoteBrowserSnapshot.test.ts +3 -0
  475. package/src/__tests__/ResolverBase.clientOldValues.test.ts +344 -14
  476. package/src/__tests__/ResolverBase.deleteReturnProjection.test.ts +52 -0
  477. package/src/__tests__/ResolverBase.encryptedApiZeroReencrypt.test.ts +307 -0
  478. package/src/__tests__/ResolverBase.entityMessageRelay.test.ts +227 -0
  479. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +83 -1
  480. package/src/__tests__/ResolverBase.publishStatusUpdate.test.ts +68 -0
  481. package/src/__tests__/ResolverBase.userViewCacheAndDedup.test.ts +130 -0
  482. package/src/__tests__/RunAIAgentResolver.branch.test.ts +72 -13
  483. package/src/__tests__/RunAIAgentResolver.consultPredecessor.test.ts +289 -0
  484. package/src/__tests__/RunAIAgentResolver.conversationWriter.test.ts +210 -0
  485. package/src/__tests__/RunAIAgentResolver.fireAndForgetCompletion.test.ts +209 -0
  486. package/src/__tests__/RunAIAgentResolver.handoff.test.ts +649 -0
  487. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +19 -27
  488. package/src/__tests__/RunAIAgentResolver.runDataGuard.test.ts +10 -2
  489. package/src/__tests__/RunAIAgentResolver.serverReply.test.ts +358 -0
  490. package/src/__tests__/RunAIAgentResolver.stoppedPredecessor.test.ts +91 -13
  491. package/src/__tests__/RunTemplateResolver.test.ts +35 -2
  492. package/src/__tests__/RunViewResolver.cacheCheckForwarding.test.ts +11 -0
  493. package/src/__tests__/SchemaEvolutionCustomColumns.test.ts +119 -0
  494. package/src/__tests__/SchemaRefreshLaunch.test.ts +24 -2
  495. package/src/__tests__/ScopedFieldsForBuild.test.ts +192 -0
  496. package/src/__tests__/SessionJanitor.test.ts +195 -4
  497. package/src/__tests__/SessionManager.test.ts +164 -0
  498. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -2
  499. package/src/__tests__/TaskGraphAgentRunner.test.ts +272 -16
  500. package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +114 -3
  501. package/src/__tests__/TransactionGroupResolver.fieldSecurity.test.ts +199 -0
  502. package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +15 -0
  503. package/src/__tests__/ViewOperationsHandler.test.ts +9 -7
  504. package/src/__tests__/WidgetSessionService.test.ts +583 -4
  505. package/src/__tests__/WorkQueueHostService.test.ts +25 -7
  506. package/src/__tests__/apiRootBoot.test.ts +1 -1
  507. package/src/__tests__/backgroundSweepConfigUnits.test.ts +153 -0
  508. package/src/__tests__/buildMagicLinkSessionUser.test.ts +96 -0
  509. package/src/__tests__/cacheBroadcastProviders.test.ts +403 -0
  510. package/src/__tests__/cacheInvalidationRecordData.test.ts +45 -0
  511. package/src/__tests__/contextRequestOrigin.test.ts +105 -0
  512. package/src/__tests__/conversationBrowsers.test.ts +62 -0
  513. package/src/__tests__/fieldSecurityFixtures.ts +91 -0
  514. package/src/__tests__/globalCors.test.ts +3 -3
  515. package/src/__tests__/localDevAuth.test.ts +196 -0
  516. package/src/__tests__/magicLinkExtensionCapabilities.test.ts +413 -0
  517. package/src/__tests__/orphanedConversationDetailReconciler.start.test.ts +82 -0
  518. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +10 -0
  519. package/src/__tests__/ownedRealtimeSession.test.ts +14 -0
  520. package/src/__tests__/pushStatusResolver.filter.test.ts +57 -1
  521. package/src/__tests__/realtimeRecordingHandler.test.ts +24 -2
  522. package/src/__tests__/realtimeSessions/RealtimeSessionEventsResolver.test.ts +29 -1
  523. package/src/__tests__/realtimeSessions/ownedRealtimeSession.anonymous.test.ts +31 -0
  524. package/src/__tests__/realtimeSessions/ownedRealtimeSession.namedScoped.test.ts +37 -0
  525. package/src/__tests__/realtimeSessions/sessionAccess.test.ts +224 -7
  526. package/src/__tests__/realtimeSessions/sessionConfigStamps.test.ts +1 -1
  527. package/src/__tests__/realtimeSessions/verificationWorkflow.test.ts +25 -4
  528. package/src/__tests__/requestOrigin.test.ts +104 -0
  529. package/src/__tests__/resolverBase.cloudEventChangedFields.test.ts +157 -0
  530. package/src/__tests__/resolverBase.fieldSecurityTransport.test.ts +222 -0
  531. package/src/__tests__/resolverBase.fls.test.ts +146 -97
  532. package/src/__tests__/restTransport.fieldSecurity.test.ts +148 -0
  533. package/src/__tests__/runParamsTimeZone.test.ts +179 -0
  534. package/src/__tests__/runStreams.fieldSecurity.test.ts +100 -0
  535. package/src/__tests__/runViewProperties.test.ts +114 -0
  536. package/src/__tests__/scalabilityBenchmarks.test.ts +765 -0
  537. package/src/__tests__/scalabilityDatabaseIntegration.test.ts +207 -0
  538. package/src/__tests__/securityHardeningPr6034.test.ts +120 -0
  539. package/src/__tests__/sessionDeadline.test.ts +51 -0
  540. package/src/__tests__/sessionUserClone.test.ts +5 -1
  541. package/src/__tests__/sharedCache.test.ts +11 -0
  542. package/src/__tests__/startupSweepOrder.test.ts +40 -0
  543. package/src/__tests__/unifiedAuth.test.ts +101 -1
  544. package/src/__tests__/widgetGuestElevation.test.ts +2 -0
  545. package/src/agentSessions/BridgeHostInstance.ts +20 -5
  546. package/src/agentSessions/HandoffOfferExpiry.ts +18 -0
  547. package/src/agentSessions/ReturningVisitorRecap.ts +1 -0
  548. package/src/agentSessions/SessionJanitor.ts +95 -45
  549. package/src/agentSessions/SessionManager.ts +73 -20
  550. package/src/agentSessions/conversationBrowsers.ts +143 -0
  551. package/src/agentSessions/sessionDeadline.ts +81 -0
  552. package/src/auth/APIKeyScopeAuth.ts +5 -4
  553. package/src/auth/AuthProviderCatalogRouter.ts +3 -0
  554. package/src/auth/__tests__/loginEmailResolution.test.ts +67 -0
  555. package/src/auth/index.ts +56 -12
  556. package/src/auth/initializeProviders.ts +43 -1
  557. package/src/auth/localDevAuth.ts +528 -0
  558. package/src/auth/loginEmailResolution.ts +67 -0
  559. package/src/auth/magicLink/MagicLinkKeys.ts +147 -15
  560. package/src/auth/magicLink/MagicLinkRouter.ts +33 -6
  561. package/src/auth/magicLink/MagicLinkService.ts +250 -10
  562. package/src/auth/magicLink/index.ts +1 -1
  563. package/src/auth/magicLink/redeemLanding.ts +7 -4
  564. package/src/auth/magicLink/types.ts +38 -3
  565. package/src/auth/newUsers.ts +1 -0
  566. package/src/auth/sessionUserClone.ts +3 -2
  567. package/src/backgroundSweepConfigUnits.ts +213 -0
  568. package/src/broadcast/BaseCacheInvalidationBroadcastProvider.ts +674 -0
  569. package/src/broadcast/index.ts +5 -0
  570. package/src/broadcast/providers/AWSIoTCoreBroadcastProvider.ts +180 -0
  571. package/src/broadcast/providers/AzureWebPubSubBroadcastProvider.ts +182 -0
  572. package/src/broadcast/providers/GCPPubSubBroadcastProvider.ts +169 -0
  573. package/src/broadcast/providers/SequencedRoleBucketBroadcastProvider.ts +57 -0
  574. package/src/cache.ts +2 -1
  575. package/src/config.ts +138 -0
  576. package/src/context.ts +71 -14
  577. package/src/coreRoutes.ts +29 -7
  578. package/src/generated/graphql-schemas/__mj.ts +4145 -1193
  579. package/src/generic/CacheInvalidationResolver.ts +135 -78
  580. package/src/generic/OrphanedConversationDetailReconciler.ts +36 -3
  581. package/src/generic/PubSubManager.ts +54 -1
  582. package/src/generic/PushStatusResolver.ts +108 -11
  583. package/src/generic/ResolverBase.ts +635 -234
  584. package/src/generic/RunViewResolver.ts +176 -64
  585. package/src/generic/actionOutputFieldSecurity.ts +75 -0
  586. package/src/generic/fieldSecurityTransport.ts +62 -0
  587. package/src/index.ts +310 -85
  588. package/src/integration/ConnectionPause.ts +222 -0
  589. package/src/integration/CustomColumnPromoter.ts +129 -54
  590. package/src/integration/EntityMapLifecycle.ts +229 -6
  591. package/src/integration/InactiveRowWarnings.ts +50 -0
  592. package/src/integration/ObjectNamesByID.ts +97 -0
  593. package/src/integration/PostRestartIntrospect.ts +49 -0
  594. package/src/integration/RSUPostRestartProgress.ts +359 -0
  595. package/src/integration/RSUProgressBridge.ts +85 -2
  596. package/src/integration/RunArtifactAuthorization.ts +65 -0
  597. package/src/integration/SchemaRefreshLaunch.ts +12 -2
  598. package/src/integration/ScopedFieldsForBuild.ts +113 -0
  599. package/src/integration/TargetConfigGuard.ts +71 -0
  600. package/src/logging/variablesLoggingMiddleware.ts +59 -3
  601. package/src/realtimeSessions/README.md +7 -3
  602. package/src/realtimeSessions/SessionVerificationPortsBase.ts +1 -0
  603. package/src/realtimeSessions/ownedRealtimeSession.ts +16 -6
  604. package/src/realtimeSessions/sessionAccess.ts +211 -25
  605. package/src/realtimeSessions/sessionConfigStamps.ts +1 -1
  606. package/src/realtimeSessions/subscriptionAuthorization.ts +4 -2
  607. package/src/realtimeSessions/verificationWorkflow.ts +3 -1
  608. package/src/realtimeWidget/WidgetRouter.ts +22 -4
  609. package/src/realtimeWidget/WidgetSessionService.ts +254 -17
  610. package/src/realtimeWidget/host-identity.ts +1 -1
  611. package/src/realtimeWidget/visitorIdentity.ts +1 -0
  612. package/src/realtimeWidget/widgetCore.ts +34 -9
  613. package/src/realtimeWidget/widgetGuestElevation.ts +3 -1
  614. package/src/requestOrigin.ts +64 -0
  615. package/src/resolvers/ActionResolver.ts +78 -15
  616. package/src/resolvers/AdhocQueryResolver.ts +1 -0
  617. package/src/resolvers/AgentRunStatusPublisher.ts +36 -8
  618. package/src/resolvers/AgentSessionResolver.ts +50 -18
  619. package/src/resolvers/AutotagPipelineResolver.ts +2 -1
  620. package/src/resolvers/ConversationRunEventsResolver.ts +2 -0
  621. package/src/resolvers/EntityCommunicationsResolver.ts +56 -15
  622. package/src/resolvers/ExecuteRemoteOperationResolver.ts +41 -9
  623. package/src/resolvers/FetchEntityVectorsResolver.ts +132 -21
  624. package/src/resolvers/FileCategoryResolver.ts +1 -0
  625. package/src/resolvers/FileResolver.ts +14 -4
  626. package/src/resolvers/GetDataResolver.ts +35 -13
  627. package/src/resolvers/IntegrationDiscoveryResolver.ts +1570 -209
  628. package/src/resolvers/MCPResolver.ts +15 -2
  629. package/src/resolvers/MergeRecordsResolver.ts +23 -2
  630. package/src/resolvers/PipelineProgressResolver.ts +12 -3
  631. package/src/resolvers/QueryResolver.ts +20 -6
  632. package/src/resolvers/QuerySystemUserResolver.ts +207 -99
  633. package/src/resolvers/RSUResolver.ts +12 -0
  634. package/src/resolvers/RealtimeBridgeResolver.ts +1 -0
  635. package/src/resolvers/RealtimeClientSessionResolver.ts +144 -40
  636. package/src/resolvers/RemoteBrowserActionResolver.ts +252 -37
  637. package/src/resolvers/RunAIAgentResolver.ts +518 -163
  638. package/src/resolvers/RunAIPromptResolver.ts +18 -18
  639. package/src/resolvers/RunTemplateResolver.ts +9 -5
  640. package/src/resolvers/RunTestResolver.ts +3 -1
  641. package/src/resolvers/SyncDataResolver.ts +4 -0
  642. package/src/resolvers/TestQuerySQLResolver.ts +1 -0
  643. package/src/resolvers/TransactionGroupResolver.ts +66 -15
  644. package/src/resolvers/UserViewResolver.ts +2 -1
  645. package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +52 -0
  646. package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +43 -0
  647. package/src/resolvers/meetingRecordingRegistration.ts +2 -0
  648. package/src/rest/EntityCRUDHandler.ts +22 -5
  649. package/src/rest/MediaAccessKeys.ts +6 -2
  650. package/src/rest/OAuthCallbackHandler.ts +13 -5
  651. package/src/rest/RESTEndpointHandler.ts +8 -5
  652. package/src/rest/RealtimeRecordingHandler.ts +9 -10
  653. package/src/rest/ViewOperationsHandler.ts +26 -7
  654. package/src/rest/setupRESTEndpoints.ts +11 -2
  655. package/src/serverRoleConfigUnits.ts +48 -0
  656. package/src/services/DurableEntityActionTaskSubmitter.ts +10 -2
  657. package/src/services/TaskGraphActionRunner.ts +2 -23
  658. package/src/services/TaskGraphAgentRunner.ts +43 -0
  659. package/src/services/TaskGraphContinuationDeliverer.ts +53 -20
  660. package/src/services/WorkQueueHostService.ts +26 -4
  661. package/src/services/workQueueConfig.ts +5 -5
  662. package/src/sharedCache.ts +1 -1
  663. package/src/types.ts +41 -1
  664. 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 {
@@ -33,7 +33,10 @@ import {
33
33
  IntegrationSchemaSync,
34
34
  decideSchemaLimitViolations,
35
35
  IntegrationConnectorCreationPipeline,
36
- IntegrationActionGenerator
36
+ IntegrationActionGenerator,
37
+ DescribePersistCounts,
38
+ BuildCatalogWriter, WithCatalogScope,
39
+ ObjectsWithWatermarkFieldChange
37
40
  } from "@memberjunction/integration-engine";
38
41
  import type {
39
42
  IntegrationActionVerb,
@@ -53,11 +56,19 @@ import {
53
56
  import { RuntimeSchemaManager, type RSUPipelineStep, type RSUPipelineInput, type RSUPendingWork } from "@memberjunction/schema-engine";
54
57
  import type { SchemaBuilderOutput } from "@memberjunction/integration-schema-builder";
55
58
  import { IntegrationProgressEmitter, IntegrationProgressReader } from "@memberjunction/integration-progress-artifacts";
59
+ import { AuthorizeRunArtifact } from "../integration/RunArtifactAuthorization.js";
60
+ import type { RunArtifactAuthorizationResult } from "../integration/RunArtifactAuthorization.js";
56
61
  import type { IntegrationRunSnapshot, IntegrationRunKind } from "@memberjunction/integration-progress-artifacts";
57
62
  import { ResolverBase } from "../generic/ResolverBase.js";
58
63
  import { IntegrationCustomColumnPromoter } from "../integration/CustomColumnPromoter.js";
59
- import { ComputeCascadeRemovalSet, ComputeRemovedDependencyWarnings, DecideFieldMapReconcile, DisableUnselectedEntityMaps, ReenableFieldMapsForEntityMap, ResetPullWatermarks, SetEntityMapEnabled } from "../integration/EntityMapLifecycle.js";
60
- 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";
61
72
  import { BuildCreateConnectionMessage, BuildDetachedRefreshMessage, BuildReactivateMessage, BuildUpdateConnectionMessage } from "../integration/SchemaRefreshLaunch.js";
62
73
  // Type-only: the registered runtime class for 'MJ: Company Integrations'. Lets the create path name the
63
74
  // server subclass it actually gets back from GetEntityObject with a real type rather than a cast.
@@ -66,6 +77,22 @@ import { AppContext } from "../types.js";
66
77
  import { RequireSystemUser } from "../directives/RequireSystemUser.js";
67
78
  import { UserCache } from "@memberjunction/generic-database-provider";
68
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
+
69
96
  // ─── RSU Pipeline Output Types ──────────────────────────────────────────────
70
97
 
71
98
  @ObjectType()
@@ -175,7 +202,11 @@ class ApplyAllBatchConnectorInput {
175
202
  class ApplyAllBatchInput {
176
203
  @Field(() => [ApplyAllBatchConnectorInput]) Connectors: ApplyAllBatchConnectorInput[];
177
204
  @Field(() => Boolean, { nullable: true, defaultValue: true, description: 'If false, skips sync after schema + entity maps' }) StartSync?: boolean;
178
- @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;
179
210
  @Field({ nullable: true, defaultValue: 'created', description: 'Sync scope: "created" = only newly created entity maps, "all" = all maps for the connector' }) SyncScope?: string;
180
211
  @Field({ nullable: true, description: 'Override sync direction for the initial sync: Pull | Push | Bidirectional. Defaults to entity map SyncDirection.' }) SyncDirection?: string;
181
212
  @Field({ nullable: true, description: 'Override sync direction stored in the created schedule: Pull | Push | Bidirectional.' }) ScheduleSyncDirection?: string;
@@ -241,10 +272,60 @@ class SchemaEvolutionOutput {
241
272
  @Field(() => [String], { nullable: true }) ChangedObjects?: string[];
242
273
  /** U10 — objects whose Pull watermark was reset so the next sync backfills the schema change. */
243
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[];
244
292
  }
245
293
 
246
294
  // ─── Connector Capabilities Output Type ─────────────────────────────────────
247
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
+
248
329
  @ObjectType()
249
330
  class ConnectorCapabilitiesOutput {
250
331
  @Field() Success: boolean;
@@ -409,6 +490,22 @@ class RefreshConnectorSchemaOutput {
409
490
  @Field({ nullable: true }) FailureMessage?: string;
410
491
  }
411
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
+
412
509
  // ─── Generate Integration Action (on-demand Integration-as-Actions) ─────────
413
510
  // Generates + persists a strongly-typed Action (DriverClass='IntegrationActionExecutor')
414
511
  // for one integration/object/verb (or all applicable verbs when verb is omitted) via
@@ -622,6 +719,28 @@ class MutationResultOutput {
622
719
  @Field() Message: string;
623
720
  }
624
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
+
625
744
  // ─── Typed sync-config (rate-limit / concurrency / time-budget as STRUCTURED fields, not a raw
626
745
  // Configuration JSON blob). These map to the CompanyIntegration.Configuration keys the engine
627
746
  // reads at runtime, so they are customizable per-connection via the API instead of hidden code
@@ -864,6 +983,10 @@ class SyncHistoryOutput {
864
983
  @Field() Success: boolean;
865
984
  @Field() Message: string;
866
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;
867
990
  }
868
991
 
869
992
  @ObjectType()
@@ -879,6 +1002,8 @@ class OperationProgressOutput {
879
1002
  @Field({ nullable: true }) RecordsCreated?: number;
880
1003
  @Field({ nullable: true }) RecordsUpdated?: number;
881
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;
882
1007
  @Field({ nullable: true }) RSUStep?: string;
883
1008
  @Field({ nullable: true }) RSURunning?: boolean;
884
1009
  /** U11 — 1-based index of the current RSU step (determinate stepper). */
@@ -889,6 +1014,73 @@ class OperationProgressOutput {
889
1014
  @Field({ nullable: true }) StartedAt?: string;
890
1015
  }
891
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
+
892
1084
  // ── STRUCTURED RUN ARTIFACTS (durable JSONL progress streams) ─────────
893
1085
  // These expose the IntegrationProgressReader over GraphQL so a tenant can ask,
894
1086
  // at any time, "what exactly happened (or is happening) on this run?" — backed
@@ -911,6 +1103,12 @@ class IntegrationRunSummaryArtifactOutput {
911
1103
  @Field() RunKind: string;
912
1104
  @Field({ nullable: true }) IntegrationID?: string;
913
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[];
914
1112
  @Field({ nullable: true }) ObjectName?: string;
915
1113
  @Field({ nullable: true }) TriggerType?: string;
916
1114
  @Field() StartedAt: string;
@@ -949,6 +1147,10 @@ class IntegrationListRunsOutput {
949
1147
  @Field() Success: boolean;
950
1148
  @Field() Message: string;
951
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;
952
1154
  }
953
1155
 
954
1156
  @ObjectType()
@@ -1107,6 +1309,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1107
1309
  @Arg("companyIntegrationID") companyIntegrationID: string,
1108
1310
  @Ctx() ctx: AppContext
1109
1311
  ): Promise<DiscoverObjectsOutput> {
1312
+ return WithCatalogScope(companyIntegrationID, async () => {
1110
1313
  try {
1111
1314
  const user = this.getAuthenticatedUser(ctx);
1112
1315
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
@@ -1132,6 +1335,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1132
1335
  } catch (e) {
1133
1336
  return this.handleDiscoveryError(e);
1134
1337
  }
1338
+ });
1135
1339
  }
1136
1340
 
1137
1341
  /**
@@ -1146,6 +1350,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1146
1350
  @Arg("companyIntegrationID") companyIntegrationID: string,
1147
1351
  @Ctx() ctx: AppContext
1148
1352
  ): Promise<ListSourceObjectsOutput> {
1353
+ return WithCatalogScope(companyIntegrationID, async () => {
1149
1354
  try {
1150
1355
  const user = this.getAuthenticatedUser(ctx);
1151
1356
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
@@ -1211,6 +1416,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1211
1416
  LogError(`IntegrationListSourceObjects error: ${e}`);
1212
1417
  return { Success: false, Message: this.formatError(e) };
1213
1418
  }
1419
+ });
1214
1420
  }
1215
1421
 
1216
1422
  private async loadIntegrationObjectsByIntegrationID(
@@ -1220,7 +1426,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1220
1426
  const rv = new RunView();
1221
1427
  const result = await rv.RunView<MJIntegrationObjectEntity>({
1222
1428
  EntityName: 'MJ: Integration Objects',
1223
- ExtraFilter: `IntegrationID='${integrationID}'`,
1429
+ ExtraFilter: `IntegrationID='${sqlUuid(integrationID, 'integrationID')}'`,
1224
1430
  ResultType: 'entity_object',
1225
1431
  }, user);
1226
1432
  if (!result.Success) {
@@ -1250,6 +1456,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1250
1456
  * (IntegrationObjectField.RelatedIntegrationObjectID) plus soft-FK declarations in the IO's
1251
1457
  * Configuration (parentObjectName / parentObjectNames map / ReferencedType). Reads the
1252
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.
1253
1463
  */
1254
1464
  private computeObjectDependencyNames(integrationID: string): Map<string, string[]> {
1255
1465
  const engine = IntegrationEngineBase.Instance;
@@ -1259,9 +1469,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1259
1469
  const deps = new Map<string, string[]>();
1260
1470
  for (const io of ios) {
1261
1471
  const parents = new Set<string>();
1262
- for (const f of engine.GetIntegrationObjectFields(io.ID)) {
1263
- const parentID = f.RelatedIntegrationObjectID?.toUpperCase();
1264
- 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;
1265
1475
  const parentName = nameById.get(parentID);
1266
1476
  if (parentName) parents.add(parentName);
1267
1477
  }
@@ -1297,6 +1507,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1297
1507
  @Arg("objectName") objectName: string,
1298
1508
  @Ctx() ctx: AppContext
1299
1509
  ): Promise<DiscoverFieldsOutput> {
1510
+ return WithCatalogScope(companyIntegrationID, async () => {
1300
1511
  try {
1301
1512
  const user = this.getAuthenticatedUser(ctx);
1302
1513
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
@@ -1322,6 +1533,193 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1322
1533
  } catch (e) {
1323
1534
  return this.handleDiscoveryError(e);
1324
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;
1325
1723
  }
1326
1724
 
1327
1725
  /**
@@ -1372,6 +1770,59 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1372
1770
  * both on stdout (visible in the MJAPI log file) and in a per-run
1373
1771
  * `<cwd>/logs/integration-runs/<runID>/progress.jsonl` artifact.
1374
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
+
1375
1826
  @Mutation(() => RefreshConnectorSchemaOutput)
1376
1827
  @RequireSystemUser()
1377
1828
  async IntegrationRefreshConnectorSchema(
@@ -1380,6 +1831,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1380
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,
1381
1832
  @Ctx() ctx: AppContext
1382
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
+ }
1383
1842
  // sync lock: a metadata refresh rewrites the IO/IOF rows a sync reads.
1384
1843
  if (!IntegrationEngine.AcquireMaintenanceLock(companyIntegrationID, 'metadata refresh')) {
1385
1844
  return {
@@ -1423,7 +1882,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1423
1882
  return {
1424
1883
  Success: result.Success,
1425
1884
  Message: result.Success
1426
- ? `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)`
1427
1889
  : `Refresh failed: ${result.FailureMessage ?? 'unknown error'}`,
1428
1890
  RunID: result.RunID,
1429
1891
  ObjectsCreated: result.PersistResult?.ObjectsCreated,
@@ -1451,6 +1913,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1451
1913
  } finally {
1452
1914
  IntegrationEngine.ReleaseMaintenanceLock(companyIntegrationID);
1453
1915
  }
1916
+ });
1454
1917
  }
1455
1918
 
1456
1919
  /**
@@ -1597,6 +2060,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1597
2060
  @Arg("platform", { defaultValue: "sqlserver" }) platform: string,
1598
2061
  @Ctx() ctx: AppContext
1599
2062
  ): Promise<SchemaPreviewOutput> {
2063
+ return WithCatalogScope(companyIntegrationID, async () => {
1600
2064
  try {
1601
2065
  const user = this.getAuthenticatedUser(ctx);
1602
2066
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
@@ -1624,7 +2088,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1624
2088
  Platform: validatedPlatform,
1625
2089
  MJVersion: process.env.MJ_VERSION ?? '5.11.0',
1626
2090
  SourceType: companyIntegration.Integration,
1627
- AdditionalSchemaInfoPath: process.env.RSU_ADDITIONAL_SCHEMA_INFO_PATH ?? 'additionalSchemaInfo.json',
2091
+ AdditionalSchemaInfoPath: RuntimeSchemaManager.Instance.AdditionalSchemaInfoPath,
1628
2092
  MigrationsDir: process.env.RSU_MIGRATIONS_PATH ?? 'migrations/rsu',
1629
2093
  MetadataDir: process.env.RSU_METADATA_DIR ?? 'metadata',
1630
2094
  ExistingTables: this.buildExistingTables(targetConfigs, provider),
@@ -1665,6 +2129,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1665
2129
  Message: `Error: ${this.formatError(e)}`
1666
2130
  };
1667
2131
  }
2132
+ });
1668
2133
  }
1669
2134
 
1670
2135
  /**
@@ -1717,7 +2182,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1717
2182
  objects: SchemaPreviewObjectInput[],
1718
2183
  sourceSchema: SourceSchemaInfo,
1719
2184
  platform: 'sqlserver' | 'postgresql',
1720
- connector?: BaseIntegrationConnector
2185
+ connector?: BaseIntegrationConnector,
2186
+ drops: TargetConfigDrops = NewTargetConfigDrops(),
1721
2187
  ): TargetTableConfig[] {
1722
2188
  const mapper = new TypeMapper();
1723
2189
 
@@ -1731,9 +2197,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1731
2197
  // out how many selections actually made it. The picker → ApplyAll →
1732
2198
  // RSU pipeline already had three layers of silent O(N) drops; this
1733
2199
  // makes them at least summarised.
1734
- const droppedNotInSchema: string[] = [];
1735
- const droppedNoFields: string[] = [];
1736
- 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;
1737
2204
 
1738
2205
  const results: TargetTableConfig[] = [];
1739
2206
  for (const obj of objects) {
@@ -1961,21 +2428,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
1961
2428
  .filter(so => !so.SourceObjectName && so.SourceObjectID)
1962
2429
  .map(so => so.SourceObjectID!);
1963
2430
 
1964
- const idToName = new Map<string, string>();
1965
- if (idsToLookup.length > 0) {
1966
- const rv = new RunView();
1967
- const result = await rv.RunView<{ ID: string; Name: string }>({
1968
- EntityName: 'MJ: Integration Objects',
1969
- ExtraFilter: idsToLookup.map(id => `ID='${id}'`).join(' OR '),
1970
- ResultType: 'simple',
1971
- Fields: ['ID', 'Name'],
1972
- }, user);
1973
- if (result.Success) {
1974
- for (const row of result.Results) {
1975
- idToName.set(row.ID.toUpperCase(), row.Name);
1976
- }
1977
- }
1978
- }
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);
1979
2433
 
1980
2434
  const plan: Array<{ Name: string; Fields?: string[] }> = [];
1981
2435
  for (const so of sourceObjects) {
@@ -2014,16 +2468,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2014
2468
  // newly-discovered objects with no persisted row).
2015
2469
  void integrationID;
2016
2470
  if (ids && ids.length > 0) {
2017
- const rv = new RunView();
2018
- const result = await rv.RunView<{ ID: string; Name: string }>({
2019
- EntityName: 'MJ: Integration Objects',
2020
- ExtraFilter: ids.map(id => `ID='${id}'`).join(' OR '),
2021
- ResultType: 'simple',
2022
- Fields: ['ID', 'Name'],
2023
- }, user);
2024
- if (result.Success) {
2025
- return result.Results.map(r => r.Name);
2026
- }
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);
2027
2477
  }
2028
2478
  if (names && names.length > 0) {
2029
2479
  const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
@@ -2065,19 +2515,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2065
2515
  const idsToLookup = sourceObjects
2066
2516
  .map(so => so.SourceObjectID)
2067
2517
  .filter((id): id is string => typeof id === 'string' && id.length > 0);
2068
- const idToName = new Map<string, string>();
2069
- if (idsToLookup.length > 0) {
2070
- const rv = new RunView();
2071
- const result = await rv.RunView<{ ID: string; Name: string }>({
2072
- EntityName: 'MJ: Integration Objects',
2073
- ExtraFilter: idsToLookup.map(id => `ID='${id}'`).join(' OR '),
2074
- ResultType: 'simple',
2075
- Fields: ['ID', 'Name'],
2076
- }, user);
2077
- if (result.Success) {
2078
- for (const r of result.Results) idToName.set(r.ID, r.Name);
2079
- }
2080
- }
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);
2081
2520
 
2082
2521
  const schemaNameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
2083
2522
  const resolvedNames: string[] = [];
@@ -2085,8 +2524,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2085
2524
  const dropped: SourceObjectInput[] = [];
2086
2525
  for (const so of sourceObjects) {
2087
2526
  let name: string | undefined;
2088
- if (so.SourceObjectID && idToName.has(so.SourceObjectID)) {
2089
- name = idToName.get(so.SourceObjectID);
2527
+ if (so.SourceObjectID && idToName.has(so.SourceObjectID.toUpperCase())) {
2528
+ name = idToName.get(so.SourceObjectID.toUpperCase());
2090
2529
  } else if (so.SourceObjectName) {
2091
2530
  // Normalize case against the schema when the connector reports it
2092
2531
  name = schemaNameMap.get(so.SourceObjectName.toLowerCase()) ?? so.SourceObjectName;
@@ -2120,20 +2559,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2120
2559
  ): Promise<void> {
2121
2560
  const idsToResolve = objects.filter(o => o.SourceObjectID && !o.SourceObjectName).map(o => o.SourceObjectID!);
2122
2561
  if (idsToResolve.length > 0) {
2123
- const rv = new RunView();
2124
- const result = await rv.RunView<{ ID: string; Name: string }>({
2125
- EntityName: 'MJ: Integration Objects',
2126
- ExtraFilter: idsToResolve.map(id => `ID='${id}'`).join(' OR '),
2127
- ResultType: 'simple',
2128
- Fields: ['ID', 'Name'],
2129
- }, user);
2130
- if (result.Success) {
2131
- const idToName = new Map(result.Results.map(r => [r.ID.toUpperCase(), r.Name]));
2132
- for (const obj of objects) {
2133
- if (obj.SourceObjectID) {
2134
- const resolved = idToName.get(obj.SourceObjectID.toUpperCase());
2135
- if (resolved) obj.SourceObjectName = resolved;
2136
- }
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;
2137
2567
  }
2138
2568
  }
2139
2569
  }
@@ -2190,8 +2620,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2190
2620
  const rv = new RunView();
2191
2621
  const result = await rv.RunView<MJCompanyIntegrationEntity>({
2192
2622
  EntityName: 'MJ: Company Integrations',
2193
- ExtraFilter: `ID='${companyIntegrationID}'`,
2623
+ ExtraFilter: `ID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
2194
2624
  MaxRows: 1,
2625
+ SkipTotalRowCount: true,
2195
2626
  ResultType: 'simple',
2196
2627
  Fields: ['ID']
2197
2628
  }, user);
@@ -2202,23 +2633,27 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2202
2633
  }
2203
2634
 
2204
2635
  /**
2205
- * Authorizes the caller for a single run artifact based on the run's
2206
- * manifest CompanyIntegrationID. Returns true when the run is tenant-scoped
2207
- * and the caller is authorized for that CompanyIntegration. Returns false
2208
- * for tenant-scoped runs the caller may not read, OR for runs with no
2209
- * CompanyIntegrationID (non-tenant-scoped artifacts are not exposed through
2210
- * 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.
2211
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. */
2212
2651
  private async userCanReadRunArtifact(
2213
2652
  snap: IntegrationRunSnapshot,
2214
2653
  user: UserInfo,
2215
2654
  cache: Map<string, boolean>
2216
2655
  ): Promise<boolean> {
2217
- const ciID = snap.manifest.companyIntegrationID;
2218
- if (!ciID) {
2219
- return false;
2220
- }
2221
- return this.userCanReadCompanyIntegration(ciID, user, cache);
2656
+ return (await this.authorizeRunArtifact(snap, user, cache)).Authorized;
2222
2657
  }
2223
2658
 
2224
2659
  /** Standard authorization-failure message for run-artifact endpoints. */
@@ -2226,6 +2661,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2226
2661
  return `Not authorized to access runs for CompanyIntegration '${companyIntegrationID}'`;
2227
2662
  }
2228
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
+
2229
2671
  /**
2230
2672
  * Loads the CompanyIntegration + its parent Integration, then resolves the
2231
2673
  * appropriate connector via ConnectorFactory.
@@ -2336,11 +2778,16 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2336
2778
  * `IsPrimaryKey=true`.
2337
2779
  * - Foreign-key relationships are reconstructed from
2338
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.
2339
2785
  */
2340
2786
  private buildSourceSchemaFromPersistedRows(
2341
2787
  integrationID: string,
2342
2788
  requestedNames?: string[],
2343
2789
  warningsOut?: string[],
2790
+ scopedFields?: ScopedBuildFields | null,
2344
2791
  ): SourceSchemaInfo {
2345
2792
  const engine = IntegrationEngineBase.Instance;
2346
2793
  // ACTIVE-only materialization: an object/field a given tenant doesn't expose is marked
@@ -2359,15 +2806,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2359
2806
  for (const io of ios) ioByID.set(io.ID, io.Name);
2360
2807
 
2361
2808
  const result: SourceSchemaInfo = { Objects: [] };
2362
- const fieldsByObjectName: Record<string, Array<{ Name: string; Status: string | null }>> = {};
2363
2809
  for (const io of ios) {
2364
2810
  if (filter && !filter.has(io.Name.toLowerCase())) continue;
2365
2811
  // Active fields only — an inactive (source-absent / deactivated) field is not materialized.
2366
- const allFields = engine.GetIntegrationObjectFields(io.ID);
2812
+ const allFields = FieldsForBuild(io.ID, scopedFields, engine);
2367
2813
  const iofs = allFields.filter(iof => iof.Status === 'Active');
2368
- // Remember what this object declared, active or not — the caller's warning collector
2369
- // turns the difference into the one message that explains a column that never appeared.
2370
- if (warningsOut) fieldsByObjectName[io.Name] = allFields.map(f => ({ Name: f.Name, Status: f.Status }));
2371
2814
 
2372
2815
  const fields = iofs.map(iof => {
2373
2816
  const targetIOName = iof.RelatedIntegrationObjectID
@@ -2410,19 +2853,63 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2410
2853
  }
2411
2854
 
2412
2855
  // Declared-but-inactive rows this rebuild left out. The caller decides what to do with the
2413
- // strings (the apply path puts them on its Warnings); computing them costs nothing when no
2414
- // collector was passed, since the field lists above are only gathered then.
2415
- if (warningsOut) {
2416
- warningsOut.push(...ComputeInactiveRowWarnings({
2417
- RequestedNames: requestedNames ?? null,
2418
- AllObjects: engine.GetIntegrationObjectsByIntegrationID(integrationID)
2419
- .map(io => ({ Name: io.Name, Status: io.Status })),
2420
- FieldsByObjectName: fieldsByObjectName,
2421
- }));
2422
- }
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));
2423
2859
  return result;
2424
2860
  }
2425
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
+
2426
2913
  private async runSchemaRefreshPipeline(
2427
2914
  companyIntegrationID: string,
2428
2915
  user: UserInfo,
@@ -2629,7 +3116,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2629
3116
  // Shared-credential safety: do not delete if any OTHER CompanyIntegration uses it.
2630
3117
  const sharedResult = await rv.RunView<MJCompanyIntegrationEntity>({
2631
3118
  EntityName: 'MJ: Company Integrations',
2632
- ExtraFilter: `CredentialID='${credentialID}' AND ID<>'${companyIntegrationID}'`,
3119
+ ExtraFilter: `CredentialID='${sqlUuid(credentialID, 'credentialID')}' AND ID<>'${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
2633
3120
  ResultType: 'simple',
2634
3121
  Fields: ['ID']
2635
3122
  }, user);
@@ -2714,7 +3201,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
2714
3201
  // Filter stays server-side; no client-side `.filter()` post-pass.
2715
3202
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as DatabaseProviderBase;
2716
3203
  const filters: string[] = [];
2717
- if (companyID) filters.push(`CompanyID='${companyID}'`);
3204
+ if (companyID) filters.push(`CompanyID='${sqlUuid(companyID, 'companyID')}'`);
2718
3205
  if (activeOnly) filters.push(`IsActive = ${provider.Dialect.BooleanLiteral(true)}`);
2719
3206
  const filter = filters.join(' AND ');
2720
3207
  const result = await rv.RunView<MJCompanyIntegrationEntity>({
@@ -3066,7 +3553,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3066
3553
  const rv = new RunView();
3067
3554
  const res = await rv.RunView<{ Entity: string }>({
3068
3555
  EntityName: 'MJ: Company Integration Entity Maps',
3069
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
3556
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
3070
3557
  Fields: ['Entity'],
3071
3558
  ResultType: 'simple',
3072
3559
  }, user);
@@ -3087,6 +3574,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3087
3574
  @Arg("entityName", { nullable: true }) entityName: string | undefined,
3088
3575
  @Ctx() ctx: AppContext
3089
3576
  ): Promise<CustomColumnCandidatesOutput> {
3577
+ return WithCatalogScope(companyIntegrationID, async () => {
3090
3578
  try {
3091
3579
  const user = this.getAuthenticatedUser(ctx);
3092
3580
  const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
@@ -3101,6 +3589,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3101
3589
  LogError(`IntegrationListCustomColumnCandidates error: ${e}`);
3102
3590
  return { Success: false, Message: this.formatError(e), Candidates: [] };
3103
3591
  }
3592
+ });
3104
3593
  }
3105
3594
 
3106
3595
  /**
@@ -3117,6 +3606,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3117
3606
  @Arg("entityNames", () => [String], { nullable: true }) entityNames: string[] | undefined,
3118
3607
  @Ctx() ctx: AppContext
3119
3608
  ): Promise<PromoteCustomColumnsOutput> {
3609
+ return WithCatalogScope(companyIntegrationID, async () => {
3120
3610
  try {
3121
3611
  const user = this.getAuthenticatedUser(ctx);
3122
3612
  const provider = GetReadWriteProvider(ctx.providers) as unknown as IMetadataProvider;
@@ -3137,28 +3627,118 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3137
3627
  LogError(`IntegrationPromoteCustomColumns error: ${e}`);
3138
3628
  return { Success: false, Message: this.formatError(e), Promoted: false, ColumnsAdded: [], SchemaUpdatePending: false };
3139
3629
  }
3630
+ });
3140
3631
  }
3141
3632
 
3142
3633
  /**
3143
- * 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}.
3144
3654
  */
3145
- @Mutation(() => MutationResultOutput)
3655
+ @Mutation(() => DeactivateConnectionOutput)
3146
3656
  async IntegrationDeactivateConnection(
3147
3657
  @Arg("companyIntegrationID") companyIntegrationID: string,
3148
3658
  @Ctx() ctx: AppContext
3149
- ): Promise<MutationResultOutput> {
3659
+ ): Promise<DeactivateConnectionOutput> {
3660
+ const empty = { SchedulesPaused: 0, CancelRequested: false, CancelScope: 'none' as CancelScope };
3150
3661
  try {
3151
3662
  const user = this.getAuthenticatedUser(ctx);
3152
3663
  const md = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
3153
3664
  const ci = await md.GetEntityObject<MJCompanyIntegrationEntity>('MJ: Company Integrations', user);
3154
3665
  const loaded = await ci.InnerLoad(CompositeKey.FromID(companyIntegrationID));
3155
- 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.
3156
3669
  ci.IsActive = false;
3157
- if (!await ci.Save()) return { Success: false, Message: 'Failed to deactivate' };
3158
- 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
+ };
3159
3739
  } catch (e) {
3160
3740
  LogError(`IntegrationDeactivateConnection error: ${e}`);
3161
- return { Success: false, Message: this.formatError(e) };
3741
+ return { Success: false, Message: this.formatError(e), ...empty };
3162
3742
  }
3163
3743
  }
3164
3744
 
@@ -3218,25 +3798,53 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3218
3798
  ci.IsActive = true;
3219
3799
  if (!await ci.Save()) return { Success: false, Message: `Failed to reactivate: ${ci.LatestResult?.Message ?? 'Unknown error'}` };
3220
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
+
3221
3829
  if (runSchemaRefresh && !awaitSchemaRefresh) {
3222
3830
  const detached = this.startSchemaRefreshPipelineDetached(companyIntegrationID, user, md);
3223
- return { Success: true, Message: BuildReactivateMessage(detached) };
3831
+ return { Success: true, Message: BuildReactivateMessage(detached) + scheduleNote };
3224
3832
  }
3225
3833
 
3226
3834
  if (runSchemaRefresh) {
3227
3835
  try {
3228
3836
  const refreshResult = await this.runSchemaRefreshPipeline(companyIntegrationID, user, md);
3229
- return { Success: true, Message: BuildReactivateMessage(refreshResult) };
3837
+ return { Success: true, Message: BuildReactivateMessage(refreshResult) + scheduleNote };
3230
3838
  } catch (refreshErr) {
3231
3839
  // Refresh failure does NOT undo the reactivation — the
3232
3840
  // connection is active, only discovery failed, and the
3233
3841
  // operator can re-run IntegrationRefreshConnectorSchema.
3234
3842
  LogError(`IntegrationReactivateConnection: pipeline error — ${refreshErr}`);
3235
- return { Success: true, Message: `Reactivated (schema refresh failed: ${this.formatError(refreshErr)})` };
3843
+ return { Success: true, Message: `Reactivated (schema refresh failed: ${this.formatError(refreshErr)})${scheduleNote}` };
3236
3844
  }
3237
3845
  }
3238
3846
 
3239
- return { Success: true, Message: BuildReactivateMessage(undefined) };
3847
+ return { Success: true, Message: BuildReactivateMessage(undefined) + scheduleNote };
3240
3848
  } catch (e) {
3241
3849
  LogError(`IntegrationReactivateConnection error: ${e}`);
3242
3850
  return { Success: false, Message: this.formatError(e) };
@@ -3255,6 +3863,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3255
3863
  @Arg("entityMaps", () => [EntityMapInput]) entityMaps: EntityMapInput[],
3256
3864
  @Ctx() ctx: AppContext
3257
3865
  ): Promise<CreateEntityMapsOutput> {
3866
+ return WithCatalogScope(companyIntegrationID, async () => {
3258
3867
  try {
3259
3868
  const user = this.getAuthenticatedUser(ctx);
3260
3869
  const md = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
@@ -3297,9 +3906,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3297
3906
  const escapedObjectName = mapDef.ExternalObjectName.replace(/'/g, "''");
3298
3907
  const existingMapResult = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
3299
3908
  EntityName: 'MJ: Company Integration Entity Maps',
3300
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND ExternalObjectName='${escapedObjectName}'`,
3909
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}' AND ExternalObjectName='${escapedObjectName}'`,
3301
3910
  OrderBy: '__mj_CreatedAt ASC',
3302
3911
  MaxRows: 1,
3912
+ SkipTotalRowCount: true,
3303
3913
  ResultType: 'entity_object',
3304
3914
  BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
3305
3915
  }, user);
@@ -3328,7 +3938,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3328
3938
  if (mapDef.FieldMaps) {
3329
3939
  const existingFieldMaps = await new RunView().RunView<{ SourceFieldName: string }>({
3330
3940
  EntityName: 'MJ: Company Integration Field Maps',
3331
- ExtraFilter: `EntityMapID='${entityMapID}'`,
3941
+ ExtraFilter: `EntityMapID='${sqlUuid(entityMapID, 'entityMapID')}'`,
3332
3942
  Fields: ['SourceFieldName'],
3333
3943
  ResultType: 'simple',
3334
3944
  BypassCache: true, // read committed field-map state for the idempotency skip
@@ -3362,6 +3972,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3362
3972
  LogError(`IntegrationCreateEntityMaps error: ${e}`);
3363
3973
  return { Success: false, Message: this.formatError(e) };
3364
3974
  }
3975
+ });
3365
3976
  }
3366
3977
 
3367
3978
  // ── SCHEMA EXECUTION ────────────────────────────────────────────────
@@ -3384,6 +3995,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3384
3995
  @Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
3385
3996
  @Ctx() ctx: AppContext
3386
3997
  ): Promise<ApplySchemaOutput> {
3998
+ return WithCatalogScope(companyIntegrationID, async () => {
3387
3999
  try {
3388
4000
  const user = this.getAuthenticatedUser(ctx);
3389
4001
  const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
@@ -3400,6 +4012,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3400
4012
  companyIntegration.IntegrationID,
3401
4013
  Array.from(requestedNames),
3402
4014
  inactiveWarnings,
4015
+ await LoadScopedFieldsForBuild(companyIntegration.IntegrationID, user),
3403
4016
  );
3404
4017
  if (sourceSchema.Objects.length === 0) {
3405
4018
  LogError(`[IntegrationApplySchema] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
@@ -3418,12 +4031,19 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3418
4031
 
3419
4032
  await this.resolveObjectInputs(objects, sourceSchema, user);
3420
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));
3421
4039
  const filteredSchema: SourceSchemaInfo = {
3422
- Objects: sourceSchema.Objects.filter(o => requestedNames.has(o.ExternalName))
4040
+ Objects: sourceSchema.Objects.filter(o => resolvedNames.has(o.ExternalName))
3423
4041
  };
3424
4042
 
3425
4043
  const validatedPlatform = this.validatePlatform(platform);
3426
- 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);
3427
4047
 
3428
4048
  const input: SchemaBuilderInput = {
3429
4049
  SourceSchema: filteredSchema,
@@ -3431,7 +4051,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3431
4051
  Platform: validatedPlatform,
3432
4052
  MJVersion: process.env.MJ_VERSION ?? '5.11.0',
3433
4053
  SourceType: companyIntegration.Integration,
3434
- AdditionalSchemaInfoPath: process.env.RSU_ADDITIONAL_SCHEMA_INFO_PATH ?? 'additionalSchemaInfo.json',
4054
+ AdditionalSchemaInfoPath: RuntimeSchemaManager.Instance.AdditionalSchemaInfoPath,
3435
4055
  MigrationsDir: process.env.RSU_MIGRATIONS_PATH ?? 'migrations/rsu',
3436
4056
  MetadataDir: process.env.RSU_METADATA_DIR ?? 'metadata',
3437
4057
  ExistingTables: this.buildExistingTables(targetConfigs, provider),
@@ -3466,6 +4086,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3466
4086
  LogError(`IntegrationApplySchema error: ${e}`);
3467
4087
  return { Success: false, Message: this.formatError(e) };
3468
4088
  }
4089
+ });
3469
4090
  }
3470
4091
 
3471
4092
  /**
@@ -3492,9 +4113,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3492
4113
  // Phase 1: Build schema artifacts for each connector's objects
3493
4114
  for (const item of items) {
3494
4115
  try {
3495
- const { schemaOutput, rsuInput } = await this.buildSchemaForConnector(
4116
+ const { schemaOutput, rsuInput } = await WithCatalogScope(item.CompanyIntegrationID, () => this.buildSchemaForConnector(
3496
4117
  item.CompanyIntegrationID, item.Objects, validatedPlatform, user, skipGitCommit, skipRestart, provider
3497
- );
4118
+ ));
3498
4119
  pipelineInputs.push(rsuInput);
3499
4120
  itemResults.push({
3500
4121
  CompanyIntegrationID: item.CompanyIntegrationID,
@@ -3516,8 +4137,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3516
4137
  return { Success: false, Message: 'No valid schema inputs to process', Items: itemResults };
3517
4138
  }
3518
4139
 
4140
+ // Retrying variant, matching `IntegrationApplySchema` (which retries via
4141
+ // SchemaBuilder.RunSchemaPipeline). This is the same install path over the same
4142
+ // SchemaBuilder-generated DDL, and its expensive middle steps — ExecuteMigration,
4143
+ // RunCodeGen, CompileTypeScript, RestartMJAPI — fail transiently. The wrapper replays
4144
+ // only those four, and only while NOTHING in the batch has been applied, so a partially
4145
+ // succeeded batch is still reported as-is rather than double-applied.
3519
4146
  const rsm = RuntimeSchemaManager.Instance;
3520
- const batchResult = await rsm.RunPipelineBatch(pipelineInputs);
4147
+ const batchResult = await rsm.RunPipelineBatchWithRetry(pipelineInputs);
3521
4148
 
3522
4149
  return {
3523
4150
  Success: batchResult.SuccessCount > 0,
@@ -3555,6 +4182,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3555
4182
  @Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
3556
4183
  @Ctx() ctx: AppContext
3557
4184
  ): Promise<ApplyAllOutput> {
4185
+ return WithCatalogScope(input.CompanyIntegrationID, async () => {
3558
4186
  try {
3559
4187
  const user = this.getAuthenticatedUser(ctx);
3560
4188
  const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
@@ -3564,22 +4192,47 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3564
4192
  const { connector, companyIntegration } = await this.resolveConnector(input.CompanyIntegrationID, user, provider);
3565
4193
  const schemaName = this.deriveSchemaName(companyIntegration.Integration);
3566
4194
 
3567
- // Step 1b: Ensure IntegrationEngine cache is populated so the persisted
3568
- // IO/IOF rows are available for reconstruction below.
3569
- 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);
3570
4205
 
3571
4206
  // Step 2: Reconstruct SourceSchemaInfo from the persisted IO/IOF rows
3572
4207
  // (already freshened by the Phase 0 v5.39.x MJCompanyIntegrationEntityServer
3573
4208
  // Save hook on IsActive false→true). Avoids the duplicate vendor-API
3574
4209
  // introspect that used to fire here.
3575
- 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
+ );
3576
4216
  if (sourceSchema.Objects.length === 0) {
3577
4217
  // Fallback: the engine cache is empty (Save hook didn't run, or this
3578
4218
  // is a direct-API caller bypassing the wizard). Do a one-time live
3579
4219
  // introspect + persist + action-generation so the apply still proceeds.
3580
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
+ : [];
3581
4231
  sourceSchema = await (connector.IntrospectSchema.bind(connector) as
3582
- (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
+ );
3583
4236
  try {
3584
4237
  const persistResult = await IntegrationSchemaSync.PersistDiscoveredSchema({
3585
4238
  IntegrationID: companyIntegration.IntegrationID,
@@ -3640,6 +4293,16 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3640
4293
 
3641
4294
  const resolved = await this.resolveSourceObjectsToNames(input.SourceObjects, sourceSchema, user);
3642
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
+ }
3643
4306
 
3644
4307
  // Build SchemaPreviewObjectInput with Fields from the matching
3645
4308
  // SourceObjectInput (resolved.sourceObjects is order-aligned with names).
@@ -3675,9 +4338,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3675
4338
  // map existed for this connection before this apply. Explicit input always wins.
3676
4339
  const priorMapsResult = await new RunView().RunView<{ ID: string }>({
3677
4340
  EntityName: 'MJ: Company Integration Entity Maps',
3678
- ExtraFilter: `CompanyIntegrationID='${input.CompanyIntegrationID.replace(/'/g, "''")}'`,
4341
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(input.CompanyIntegrationID, 'CompanyIntegrationID')}'`,
3679
4342
  Fields: ['ID'],
3680
4343
  MaxRows: 1,
4344
+ SkipTotalRowCount: true,
3681
4345
  ResultType: 'simple',
3682
4346
  BypassCache: true,
3683
4347
  }, user);
@@ -3784,7 +4448,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3784
4448
  SyncRunID: syncRunID ?? undefined,
3785
4449
  ScheduledJobID: scheduledJobID,
3786
4450
  GitCommitSuccess: batchResult.Results[0]?.GitCommitSuccess,
3787
- 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,
3788
4455
  Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
3789
4456
  };
3790
4457
  }
@@ -3796,13 +4463,18 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3796
4463
  Message: `Applied ${objects.length} object(s) — entity maps will be created after restart`,
3797
4464
  Steps: pipelineSteps,
3798
4465
  GitCommitSuccess: batchResult.Results[0]?.GitCommitSuccess,
3799
- 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,
3800
4471
  Warnings: schemaOutput.Warnings.length > 0 ? schemaOutput.Warnings : undefined,
3801
4472
  };
3802
4473
  } catch (e) {
3803
4474
  LogError(`IntegrationApplyAll error: ${e}`);
3804
4475
  return { Success: false, Message: this.formatError(e) };
3805
4476
  }
4477
+ });
3806
4478
  }
3807
4479
 
3808
4480
  /** Derives a SQL-safe schema name from the integration name (e.g., "HubSpot" → "hubspot"). */
@@ -3849,7 +4521,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3849
4521
  const existingMapByExternalName = new Map<string, MJCompanyIntegrationEntityMapEntity>();
3850
4522
  const allMaps = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
3851
4523
  EntityName: 'MJ: Company Integration Entity Maps',
3852
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
4524
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
3853
4525
  ResultType: 'entity_object',
3854
4526
  BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
3855
4527
  }, user);
@@ -3908,9 +4580,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3908
4580
  const escapedObjectName = obj.SourceObjectName.replace(/'/g, "''");
3909
4581
  const existingMapResult = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
3910
4582
  EntityName: 'MJ: Company Integration Entity Maps',
3911
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND ExternalObjectName='${escapedObjectName}'`,
4583
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}' AND ExternalObjectName='${escapedObjectName}'`,
3912
4584
  OrderBy: '__mj_CreatedAt ASC',
3913
4585
  MaxRows: 1,
4586
+ SkipTotalRowCount: true,
3914
4587
  ResultType: 'entity_object',
3915
4588
  BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
3916
4589
  }, user);
@@ -3977,7 +4650,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3977
4650
  // already exist for this entity map, or re-applies multiply them in lockstep.
3978
4651
  const existingFieldMaps = await new RunView().RunView<{ SourceFieldName: string }>({
3979
4652
  EntityName: 'MJ: Company Integration Field Maps',
3980
- ExtraFilter: `EntityMapID='${entityMapID}'`,
4653
+ ExtraFilter: `EntityMapID='${sqlUuid(entityMapID, 'entityMapID')}'`,
3981
4654
  Fields: ['SourceFieldName'],
3982
4655
  ResultType: 'simple',
3983
4656
  BypassCache: true, // read committed field-map state for the idempotency skip
@@ -4033,9 +4706,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4033
4706
  const rv = new RunView();
4034
4707
  const runResult = await rv.RunView<{ ID: string }>({
4035
4708
  EntityName: 'MJ: Company Integration Runs',
4036
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND Status='In Progress'`,
4709
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}' AND Status='In Progress'`,
4037
4710
  OrderBy: '__mj_CreatedAt DESC',
4038
4711
  MaxRows: 1,
4712
+ SkipTotalRowCount: true,
4039
4713
  ResultType: 'simple',
4040
4714
  Fields: ['ID']
4041
4715
  }, user);
@@ -4085,16 +4759,23 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4085
4759
  const limit = IntegrationEngine.Instance.MaxColumnsPerTable;
4086
4760
  const overLimit = objects.filter(o => colCount(o) > limit);
4087
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);
4088
4768
  const ioIDByName = new Map(
4089
- IntegrationEngineBase.Instance.GetActiveIntegrationObjects(companyIntegration.IntegrationID)
4769
+ (await writer.ObjectsInScope())
4770
+ .filter(io => io.Status === 'Active')
4090
4771
  .map(io => [io.Name.toLowerCase(), io.ID] as [string, string])
4091
4772
  );
4092
4773
  for (const o of overLimit) {
4093
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.`);
4094
4775
  const ioID = ioIDByName.get(o.SourceObjectName.toLowerCase());
4095
4776
  if (ioID) {
4096
- const io = await md.GetEntityObject<MJIntegrationObjectEntity>('MJ: Integration Objects', user);
4097
- if (await io.Load(ioID)) {
4777
+ const io = await writer.LoadObject(ioID);
4778
+ if (io) {
4098
4779
  io.Status = 'Disabled';
4099
4780
  if (!await io.Save()) {
4100
4781
  LogError(`enforceSchemaLimits: failed to disable over-wide IO '${o.SourceObjectName}': ${io.LatestResult?.CompleteMessage ?? 'unknown error'}`);
@@ -4155,14 +4836,26 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4155
4836
  // Declared rows this apply will silently leave out (deactivated objects/fields). Collected
4156
4837
  // during the rebuild and surfaced on the apply's Warnings, alongside the schema-limit ones.
4157
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);
4158
4842
  if (prefetchedSourceSchema) {
4159
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
+ ));
4160
4852
  } else {
4161
4853
  const requestedNamesForReuse = objects.map(o => o.SourceObjectName);
4162
4854
  sourceSchema = this.buildSourceSchemaFromPersistedRows(
4163
4855
  companyIntegration.IntegrationID,
4164
4856
  requestedNamesForReuse,
4165
4857
  inactiveWarnings,
4858
+ scopedFields,
4166
4859
  );
4167
4860
  if (sourceSchema.Objects.length === 0) {
4168
4861
  LogError(`[buildSchemaForConnector] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
@@ -4197,7 +4890,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4197
4890
  // Caps are operator/env guardrails (MJ_INTEGRATION_MAX_TABLES / _MAX_COLUMNS_PER_TABLE).
4198
4891
  const limitWarnings = await this.enforceSchemaLimits(objects, filteredSchema, companyIntegration, user, provider);
4199
4892
 
4200
- 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);
4201
4896
 
4202
4897
  const input: SchemaBuilderInput = {
4203
4898
  SourceSchema: filteredSchema,
@@ -4205,7 +4900,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4205
4900
  Platform: platform,
4206
4901
  MJVersion: process.env.MJ_VERSION ?? '5.11.0',
4207
4902
  SourceType: companyIntegration.Integration,
4208
- AdditionalSchemaInfoPath: process.env.RSU_ADDITIONAL_SCHEMA_INFO_PATH ?? 'additionalSchemaInfo.json',
4903
+ AdditionalSchemaInfoPath: RuntimeSchemaManager.Instance.AdditionalSchemaInfoPath,
4209
4904
  MigrationsDir: process.env.RSU_MIGRATIONS_PATH ?? 'migrations/rsu',
4210
4905
  MetadataDir: process.env.RSU_METADATA_DIR ?? 'metadata',
4211
4906
  ExistingTables: this.buildExistingTables(targetConfigs, provider),
@@ -4243,6 +4938,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4243
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,
4244
4939
  @Ctx() ctx: AppContext
4245
4940
  ): Promise<StartSyncOutput> {
4941
+ return WithCatalogScope(companyIntegrationID, async () => {
4246
4942
  try {
4247
4943
  const user = this.getAuthenticatedUser(ctx);
4248
4944
  await IntegrationEngine.Instance.Config(false, user);
@@ -4340,9 +5036,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4340
5036
  const rv = new RunView();
4341
5037
  const runResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
4342
5038
  EntityName: 'MJ: Company Integration Runs',
4343
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND StartedAt >= '${firedAtFilter}'`,
5039
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}' AND StartedAt >= '${firedAtFilter}'`,
4344
5040
  OrderBy: 'StartedAt DESC',
4345
5041
  MaxRows: 1,
5042
+ SkipTotalRowCount: true,
4346
5043
  ResultType: 'simple',
4347
5044
  Fields: ['ID', 'Status', 'StartedAt']
4348
5045
  }, user);
@@ -4370,6 +5067,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4370
5067
  LogError(`IntegrationStartSync error: ${e}`);
4371
5068
  return { Success: false, Message: this.formatError(e) };
4372
5069
  }
5070
+ });
4373
5071
  }
4374
5072
 
4375
5073
  /**
@@ -4474,8 +5172,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4474
5172
  const rv = new RunView();
4475
5173
  const ciResult = await rv.RunView<MJCompanyIntegrationEntity>({
4476
5174
  EntityName: 'MJ: Company Integrations',
4477
- ExtraFilter: `ID='${companyIntegrationID}'`,
5175
+ ExtraFilter: `ID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
4478
5176
  MaxRows: 1,
5177
+ SkipTotalRowCount: true,
4479
5178
  ResultType: 'entity_object',
4480
5179
  }, user);
4481
5180
 
@@ -4493,6 +5192,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4493
5192
  EntityName: 'MJ: Integrations',
4494
5193
  ExtraFilter: `ID='${companyIntegration.IntegrationID}'`,
4495
5194
  MaxRows: 1,
5195
+ SkipTotalRowCount: true,
4496
5196
  ResultType: 'entity_object',
4497
5197
  }, user);
4498
5198
  if (!integResult.Success || integResult.Results.length === 0) {
@@ -4559,6 +5259,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4559
5259
  EntityName: 'MJ: Scheduled Job Types',
4560
5260
  ExtraFilter: `DriverClass='${driverClass}'`,
4561
5261
  MaxRows: 1,
5262
+ SkipTotalRowCount: true,
4562
5263
  ResultType: 'simple',
4563
5264
  Fields: ['ID']
4564
5265
  }, user);
@@ -4623,6 +5324,102 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4623
5324
  }
4624
5325
  }
4625
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
+
4626
5423
  /**
4627
5424
  * U7 — server-side schedule uniqueness: finds an existing (non-deleted) scheduled job of the
4628
5425
  * given job type that targets the given CompanyIntegration (its Configuration JSON carries
@@ -4634,13 +5431,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4634
5431
  companyIntegrationID: string,
4635
5432
  user: UserInfo
4636
5433
  ): Promise<{ ID: string } | null> {
4637
- const esc = (s: string) => s.replace(/'/g, "''");
4638
- const lower = esc(companyIntegrationID.toLowerCase());
4639
- 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();
4640
5438
  const result = await new RunView().RunView<{ ID: string }>({
4641
5439
  EntityName: 'MJ: Scheduled Jobs',
4642
5440
  ExtraFilter:
4643
- `JobTypeID='${esc(jobTypeID)}' AND Status IN ('Active','Paused') AND ` +
5441
+ `JobTypeID='${validJobTypeID}' AND Status IN ('Active','Paused') AND ` +
4644
5442
  // Structured "CompanyIntegrationID":"<uuid>" predicate (same form as IntegrationListSchedules),
4645
5443
  // NOT a bare-UUID substring — so this CI's id can't false-match the same UUID appearing in
4646
5444
  // another config key (e.g. RelatedCompanyIntegrationID) and repurpose an unrelated job.
@@ -4648,6 +5446,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4648
5446
  OrderBy: '__mj_CreatedAt DESC',
4649
5447
  Fields: ['ID'],
4650
5448
  MaxRows: 1,
5449
+ SkipTotalRowCount: true,
4651
5450
  ResultType: 'simple',
4652
5451
  BypassCache: true, // uniqueness must see committed jobs, not a stale filtered cache
4653
5452
  }, user);
@@ -4736,7 +5535,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4736
5535
  // Clear ScheduledJobID on any CompanyIntegration that references this job
4737
5536
  const ciResult = await rv.RunView<MJCompanyIntegrationEntity>({
4738
5537
  EntityName: 'MJ: Company Integrations',
4739
- ExtraFilter: `ScheduledJobID='${scheduledJobID}'`,
5538
+ ExtraFilter: `ScheduledJobID='${sqlUuid(scheduledJobID, 'scheduledJobID')}'`,
4740
5539
  ResultType: 'entity_object',
4741
5540
  }, sysUser);
4742
5541
  if (ciResult.Success) {
@@ -4751,7 +5550,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4751
5550
  // Null out ScheduledJobRunID on CompanyIntegrationRuns that reference this job's runs
4752
5551
  const jobRunsResult = await rv.RunView<MJScheduledJobRunEntity>({
4753
5552
  EntityName: 'MJ: Scheduled Job Runs',
4754
- ExtraFilter: `ScheduledJobID='${scheduledJobID}'`,
5553
+ ExtraFilter: `ScheduledJobID='${sqlUuid(scheduledJobID, 'scheduledJobID')}'`,
4755
5554
  ResultType: 'entity_object',
4756
5555
  }, sysUser);
4757
5556
  if (jobRunsResult.Success) {
@@ -4807,7 +5606,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4807
5606
  const rv = new RunView();
4808
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 }>({
4809
5608
  EntityName: 'MJ: Scheduled Jobs',
4810
- ExtraFilter: `Configuration LIKE '%"CompanyIntegrationID":"${companyIntegrationID}"%'`,
5609
+ ExtraFilter: `Configuration LIKE '%"CompanyIntegrationID":"${sqlUuid(companyIntegrationID, 'companyIntegrationID')}"%'`,
4811
5610
  OrderBy: '__mj_CreatedAt DESC',
4812
5611
  ResultType: 'simple',
4813
5612
  Fields: ['ID', 'Name', 'Status', 'CronExpression', 'Timezone', 'NextRunAt', 'LastRunAt', 'RunCount', 'SuccessCount', 'FailureCount']
@@ -4848,7 +5647,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4848
5647
  const rv = new RunView();
4849
5648
  const result = await rv.RunView<EntityMapSummaryOutput>({
4850
5649
  EntityName: 'MJ: Company Integration Entity Maps',
4851
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
5650
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
4852
5651
  OrderBy: 'Priority ASC',
4853
5652
  ResultType: 'simple',
4854
5653
  Fields: ['ID', 'EntityID', 'Entity', 'ExternalObjectName', 'SyncDirection', 'Priority', 'Status', 'Configuration'],
@@ -4877,7 +5676,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4877
5676
  const rv = new RunView();
4878
5677
  const result = await rv.RunView<FieldMapSummaryOutput>({
4879
5678
  EntityName: 'MJ: Company Integration Field Maps',
4880
- ExtraFilter: `EntityMapID='${entityMapID}'`,
5679
+ ExtraFilter: `EntityMapID='${sqlUuid(entityMapID, 'entityMapID')}'`,
4881
5680
  OrderBy: 'SourceFieldName',
4882
5681
  ResultType: 'simple',
4883
5682
  BypassCache: true, // operational list must reflect COMMITTED state
@@ -4960,7 +5759,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4960
5759
  // Delete field maps
4961
5760
  const fmResult = await rv.RunView<MJCompanyIntegrationFieldMapEntity>({
4962
5761
  EntityName: 'MJ: Company Integration Field Maps',
4963
- ExtraFilter: `EntityMapID='${entityMapID}'`,
5762
+ ExtraFilter: `EntityMapID='${sqlUuid(entityMapID, 'entityMapID')}'`,
4964
5763
  ResultType: 'entity_object'
4965
5764
  }, sysUser);
4966
5765
  if (fmResult.Success) {
@@ -4970,7 +5769,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
4970
5769
  // Delete watermarks
4971
5770
  const wmResult = await rv.RunView<MJCompanyIntegrationSyncWatermarkEntity>({
4972
5771
  EntityName: 'MJ: Company Integration Sync Watermarks',
4973
- ExtraFilter: `EntityMapID='${entityMapID}'`,
5772
+ ExtraFilter: `EntityMapID='${sqlUuid(entityMapID, 'entityMapID')}'`,
4974
5773
  ResultType: 'entity_object'
4975
5774
  }, sysUser);
4976
5775
  if (wmResult.Success) {
@@ -5056,6 +5855,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5056
5855
  RecordsCreated: syncProgress.RecordsCreated,
5057
5856
  RecordsUpdated: syncProgress.RecordsUpdated,
5058
5857
  RecordsErrored: syncProgress.RecordsErrored,
5858
+ RecordsSkipped: syncProgress.RecordsSkipped,
5059
5859
  StartedAt: syncProgress.StartedAt.toISOString(),
5060
5860
  ElapsedMs: Date.now() - syncProgress.StartedAt.getTime(),
5061
5861
  };
@@ -5084,7 +5884,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5084
5884
  const [mapsResult, runsResult] = await rv.RunViews([
5085
5885
  {
5086
5886
  EntityName: 'MJ: Company Integration Entity Maps',
5087
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
5887
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5088
5888
  ResultType: 'simple',
5089
5889
  Fields: ['ID', 'Status'],
5090
5890
  // Status must reflect the live state immediately after a deselect/reselect
@@ -5094,9 +5894,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5094
5894
  },
5095
5895
  {
5096
5896
  EntityName: 'MJ: Company Integration Runs',
5097
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
5897
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5098
5898
  OrderBy: 'StartedAt DESC',
5099
5899
  MaxRows: 1,
5900
+ SkipTotalRowCount: true,
5100
5901
  ResultType: 'simple',
5101
5902
  Fields: ['ID', 'Status', 'StartedAt', 'EndedAt', 'TotalRecords'],
5102
5903
  BypassCache: true
@@ -5135,29 +5936,221 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5135
5936
  }
5136
5937
  }
5137
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
+
5138
6099
  @Query(() => SyncHistoryOutput)
5139
6100
  async IntegrationGetSyncHistory(
5140
6101
  @Arg("companyIntegrationID") companyIntegrationID: string,
5141
- @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,
5142
6104
  @Ctx() ctx: AppContext
5143
6105
  ): Promise<SyncHistoryOutput> {
5144
6106
  try {
5145
6107
  const user = this.getAuthenticatedUser(ctx);
6108
+ const filter = `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`;
5146
6109
  const rv = new RunView();
5147
6110
  const result = await rv.RunView<SyncRunSummaryOutput>({
5148
6111
  EntityName: 'MJ: Company Integration Runs',
5149
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
6112
+ ExtraFilter: filter,
5150
6113
  OrderBy: 'StartedAt DESC',
5151
6114
  MaxRows: limit,
6115
+ StartRow: Math.max(0, offset ?? 0),
5152
6116
  ResultType: 'simple',
5153
6117
  Fields: ['ID', 'Status', 'StartedAt', 'EndedAt', 'TotalRecords', 'RunByUserID']
5154
6118
  }, user);
5155
6119
 
5156
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);
5157
6146
  return {
5158
6147
  Success: true,
5159
6148
  Message: `${result.Results.length} runs`,
5160
- 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,
5161
6154
  };
5162
6155
  } catch (e) {
5163
6156
  LogError(`IntegrationGetSyncHistory error: ${e}`);
@@ -5180,6 +6173,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5180
6173
  @Arg("runKind", { nullable: true }) runKind?: string,
5181
6174
  @Arg("inFlightOnly", { nullable: true }) inFlightOnly?: boolean,
5182
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,
5183
6177
  ): Promise<IntegrationListRunsOutput> {
5184
6178
  try {
5185
6179
  const user = this.getAuthenticatedUser(ctx);
@@ -5195,18 +6189,43 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5195
6189
  }
5196
6190
 
5197
6191
  const reader = new IntegrationProgressReader();
5198
- const snaps = await reader.ListRuns({
6192
+ const want = limit ?? 50;
6193
+ const filter = {
5199
6194
  companyIntegrationID,
5200
6195
  runKind: runKind as IntegrationRunKind | undefined,
5201
6196
  inFlightOnly: inFlightOnly ?? false,
5202
- }, limit ?? 50);
5203
-
5204
- // Filter to only the runs the caller is authorized to read. When
5205
- // scoped to a single (already-authorized) connector this is a no-op;
5206
- // for the cross-connector listing it prevents one tenant from seeing
5207
- // another tenant's runs.
5208
- const authorizedSnaps = await this.filterAuthorizedRuns(snaps, user, authCache);
5209
- 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
+ };
5210
6229
  } catch (e) {
5211
6230
  LogError(`IntegrationListRuns error: ${e}`);
5212
6231
  return { Success: false, Message: this.formatError(e) };
@@ -5243,14 +6262,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5243
6262
  const snap = await reader.GetRun(runID);
5244
6263
  if (!snap) return { Success: false, Message: `Run '${runID}' not found` };
5245
6264
 
5246
- const authorized = await this.userCanReadRunArtifact(snap, user, new Map<string, boolean>());
5247
- if (!authorized) {
5248
- const ciID = snap.manifest.companyIntegrationID;
6265
+ const auth = await this.authorizeRunArtifact(snap, user, new Map<string, boolean>());
6266
+ if (!auth.Authorized) {
5249
6267
  return {
5250
6268
  Success: false,
5251
- Message: ciID
5252
- ? this.notAuthorizedForCompanyIntegrationMessage(ciID)
5253
- : `Not authorized to access run '${runID}'`,
6269
+ Message: this.notAuthorizedForRunMessage(runID, auth.DeniedCompanyIntegrationID),
5254
6270
  };
5255
6271
  }
5256
6272
 
@@ -5284,14 +6300,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5284
6300
  const snap = await reader.GetRun(runID);
5285
6301
  if (!snap) return { Success: false, Message: `Run '${runID}' not found`, LatestSeq: sinceSeq ?? 0, IsInFlight: false };
5286
6302
 
5287
- const authorized = await this.userCanReadRunArtifact(snap, user, new Map<string, boolean>());
5288
- if (!authorized) {
5289
- const ciID = snap.manifest.companyIntegrationID;
6303
+ const auth = await this.authorizeRunArtifact(snap, user, new Map<string, boolean>());
6304
+ if (!auth.Authorized) {
5290
6305
  return {
5291
6306
  Success: false,
5292
- Message: ciID
5293
- ? this.notAuthorizedForCompanyIntegrationMessage(ciID)
5294
- : `Not authorized to access run '${runID}'`,
6307
+ Message: this.notAuthorizedForRunMessage(runID, auth.DeniedCompanyIntegrationID),
5295
6308
  LatestSeq: sinceSeq ?? 0,
5296
6309
  IsInFlight: false,
5297
6310
  };
@@ -5329,6 +6342,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5329
6342
  RunKind: s.manifest.runKind,
5330
6343
  IntegrationID: s.manifest.integrationID,
5331
6344
  CompanyIntegrationID: s.manifest.companyIntegrationID,
6345
+ CompanyIntegrationIDs: s.manifest.companyIntegrationIDs,
5332
6346
  ObjectName: s.manifest.objectName,
5333
6347
  TriggerType: s.manifest.triggerType,
5334
6348
  StartedAt: s.manifest.startedAt,
@@ -5360,6 +6374,49 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5360
6374
 
5361
6375
  // ── CONNECTOR CAPABILITIES ──────────────────────────────────────────
5362
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
+
5363
6420
  /**
5364
6421
  * Returns the CRUD capability flags for the connector bound to a CompanyIntegration.
5365
6422
  * Use this to determine which operations (Create/Update/Delete/Search) are supported
@@ -5370,6 +6427,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5370
6427
  @Arg("companyIntegrationID") companyIntegrationID: string,
5371
6428
  @Ctx() ctx: AppContext
5372
6429
  ): Promise<ConnectorCapabilitiesOutput> {
6430
+ return WithCatalogScope(companyIntegrationID, async () => {
5373
6431
  try {
5374
6432
  const user = this.getAuthenticatedUser(ctx);
5375
6433
  const provider = GetReadOnlyProvider(ctx.providers, { allowFallbackToReadWrite: true }) as unknown as IMetadataProvider;
@@ -5388,6 +6446,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5388
6446
  LogError(`IntegrationGetConnectorCapabilities error: ${e}`);
5389
6447
  return { Success: false, Message: this.formatError(e) };
5390
6448
  }
6449
+ });
5391
6450
  }
5392
6451
 
5393
6452
  // ── APPLY ALL BATCH ─────────────────────────────────────────────────
@@ -5412,20 +6471,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5412
6471
  const provider = GetReadWriteProvider(ctx.providers, { allowFallbackToReadOnly: true }) as unknown as IMetadataProvider;
5413
6472
  const validatedPlatform = this.validatePlatform(platform);
5414
6473
 
5415
- // Bust RunView caches for integration metadata BEFORE Config(true).
5416
- // mj sync push writes records via stored procedures which do NOT fire
5417
- // BaseEntity change events, so the RunView cache is never auto-invalidated.
5418
- // Explicitly clearing these entries ensures Config(true) re-queries the DB.
5419
- await LocalCacheManager.Instance.InvalidateEntityCaches('MJ: Integration Objects');
5420
- await LocalCacheManager.Instance.InvalidateEntityCaches('MJ: Integration Object Fields');
5421
-
5422
- // Force-refresh integration metadata cache so IntrospectSchema
5423
- // picks up any IntegrationObject/Field changes made via mj sync push
5424
- await IntegrationEngine.Instance.Config(true, user);
6474
+ await this.refreshIntegrationCatalogCaches(user, provider);
5425
6475
 
5426
6476
  // Phase 1: Build schema for each connector in parallel
5427
6477
  const buildResults = await Promise.allSettled(
5428
- 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 () => {
5429
6480
  const { connector, companyIntegration } = await this.resolveConnector(connInput.CompanyIntegrationID, user, provider);
5430
6481
  const schemaName = this.deriveSchemaName(companyIntegration.Integration);
5431
6482
  console.log(
@@ -5494,7 +6545,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5494
6545
  // persisted rows instead.
5495
6546
  //
5496
6547
  // Persist is also skipped here — the Save hook already did it.
5497
- sourceSchema = this.buildSourceSchemaFromPersistedRows(companyIntegration.IntegrationID);
6548
+ sourceSchema = this.buildSourceSchemaFromPersistedRows(
6549
+ companyIntegration.IntegrationID,
6550
+ undefined,
6551
+ undefined,
6552
+ await LoadScopedFieldsForBuild(companyIntegration.IntegrationID, user),
6553
+ );
5498
6554
  if (sourceSchema.Objects.length === 0) {
5499
6555
  // Defensive fallback: if the engine cache is empty (hook
5500
6556
  // didn't run, or this is a direct-API caller that bypasses
@@ -5549,6 +6605,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5549
6605
  for (let i = 0; i < resolvedNames.length; i++) {
5550
6606
  fieldsByName.set(resolvedNames[i].toLowerCase(), orderedSources[i].Fields);
5551
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
+ }
5552
6615
  }
5553
6616
 
5554
6617
  const objects = resolvedNames.map(name => {
@@ -5572,6 +6635,26 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5572
6635
  }
5573
6636
 
5574
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
+
5575
6658
  const pendingPayload: RSUPendingWork = {
5576
6659
  CompanyIntegrationID: connInput.CompanyIntegrationID,
5577
6660
  SourceObjectNames: resolvedNames,
@@ -5580,7 +6663,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5580
6663
  CronExpression: connInput.CronExpression,
5581
6664
  ScheduleTimezone: connInput.ScheduleTimezone,
5582
6665
  StartSync: input.StartSync,
5583
- FullSync: input.FullSync ?? false,
6666
+ FullSync: fullSync,
5584
6667
  SyncScope: input.SyncScope === 'all' ? 'all' : 'created',
5585
6668
  // Unrecognized directions fall back to undefined = "use the entity map's own SyncDirection"
5586
6669
  SyncDirection: input.SyncDirection && isValidSyncDirection(input.SyncDirection) ? input.SyncDirection : undefined,
@@ -5599,8 +6682,12 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5599
6682
  objects,
5600
6683
  schemaOutput,
5601
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,
5602
6689
  };
5603
- })
6690
+ }))
5604
6691
  );
5605
6692
 
5606
6693
  // Separate successes and failures
@@ -5608,6 +6695,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5608
6695
  connInput: ApplyAllBatchConnectorInput;
5609
6696
  connector: BaseIntegrationConnector;
5610
6697
  companyIntegration: MJCompanyIntegrationEntity;
6698
+ resolvedNames: string[];
6699
+ fullSync: boolean;
5611
6700
  schemaName: string;
5612
6701
  objects: SchemaPreviewObjectInput[];
5613
6702
  schemaOutput: SchemaBuilderOutput;
@@ -5652,6 +6741,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5652
6741
  // Phase 3: Post-pipeline — create entity maps, field maps, schedules for each success
5653
6742
  for (let i = 0; i < successfulBuilds.length; i++) {
5654
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 () => {
5655
6746
  const pipelineResult = batchResult.Results[i];
5656
6747
  const integrationName = build.companyIntegration.Integration;
5657
6748
 
@@ -5665,7 +6756,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5665
6756
  });
5666
6757
  // No pending-work cleanup needed — rows are registered only for
5667
6758
  // migrations that succeeded.
5668
- continue;
6759
+ return; // stands in for `continue` — this body is the per-connection scope's arrow
5669
6760
  }
5670
6761
 
5671
6762
  const connResult: ApplyAllBatchConnectorResult = {
@@ -5686,6 +6777,20 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5686
6777
  );
5687
6778
  connResult.EntityMapsCreated = entityMapsCreated;
5688
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
+
5689
6794
  const createdMapIDs = entityMapsCreated.map(em => em.EntityMapID).filter(Boolean);
5690
6795
  const scopedMapIDs = input.SyncScope === 'all' ? undefined : createdMapIDs;
5691
6796
 
@@ -5698,7 +6803,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5698
6803
  const shouldStartSync = input.StartSync !== false &&
5699
6804
  (input.SyncScope === 'all' || createdMapIDs.length > 0);
5700
6805
  const syncRunID = shouldStartSync
5701
- ? await this.startSyncAfterApply(build.connInput.CompanyIntegrationID, user, scopedMapIDs, input.FullSync)
6806
+ ? await this.startSyncAfterApply(build.connInput.CompanyIntegrationID, user, scopedMapIDs, build.fullSync)
5702
6807
  : null;
5703
6808
  if (syncRunID) connResult.SyncRunID = syncRunID;
5704
6809
 
@@ -5720,6 +6825,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5720
6825
  }
5721
6826
 
5722
6827
  connectorResults.push(connResult);
6828
+ });
5723
6829
  }
5724
6830
 
5725
6831
  const pipelineSteps = batchResult.Results[0]?.Steps.map((s: RSUPipelineStep) => ({
@@ -5766,6 +6872,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5766
6872
  EntityName: 'MJ: Scheduled Job Types',
5767
6873
  ExtraFilter: `DriverClass='IntegrationSyncScheduledJobDriver'`,
5768
6874
  MaxRows: 1,
6875
+ SkipTotalRowCount: true,
5769
6876
  ResultType: 'simple',
5770
6877
  Fields: ['ID']
5771
6878
  }, user);
@@ -5840,7 +6947,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5840
6947
  // Step 2: Null out ScheduledJobRunID on CompanyIntegrationRuns (break FK before deleting job runs)
5841
6948
  const ciRunsResult = await rv.RunView<MJCompanyIntegrationRunEntity>({
5842
6949
  EntityName: 'MJ: Company Integration Runs',
5843
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
6950
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5844
6951
  ResultType: 'entity_object'
5845
6952
  }, sysUser);
5846
6953
  if (ciRunsResult.Success) {
@@ -5855,7 +6962,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5855
6962
  // Step 3: Delete scheduled job runs + jobs (reference CI via Configuration JSON)
5856
6963
  const jobsResult = await rv.RunView<MJScheduledJobEntity>({
5857
6964
  EntityName: 'MJ: Scheduled Jobs',
5858
- ExtraFilter: `Configuration LIKE '%${companyIntegrationID}%'`,
6965
+ ExtraFilter: `Configuration LIKE '%${sqlUuid(companyIntegrationID, 'companyIntegrationID')}%'`,
5859
6966
  ResultType: 'entity_object'
5860
6967
  }, sysUser);
5861
6968
  if (jobsResult.Success) {
@@ -5905,7 +7012,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5905
7012
  // Step 4: Delete entity map children (field maps, watermarks, record maps)
5906
7013
  const entityMapsResult = await rv.RunView<MJCompanyIntegrationEntityMapEntity>({
5907
7014
  EntityName: 'MJ: Company Integration Entity Maps',
5908
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
7015
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5909
7016
  ResultType: 'entity_object'
5910
7017
  }, sysUser);
5911
7018
  const entityMaps = entityMapsResult.Success ? entityMapsResult.Results : [];
@@ -5937,7 +7044,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5937
7044
 
5938
7045
  const rmResult = await rv.RunView<MJCompanyIntegrationRecordMapEntity>({
5939
7046
  EntityName: 'MJ: Company Integration Record Maps',
5940
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
7047
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5941
7048
  ResultType: 'entity_object'
5942
7049
  }, sysUser);
5943
7050
  if (rmResult.Success) {
@@ -5957,7 +7064,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5957
7064
  // Step 6: Delete employee-company integration links
5958
7065
  const empResult = await rv.RunView<MJEmployeeCompanyIntegrationEntity>({
5959
7066
  EntityName: 'MJ: Employee Company Integrations',
5960
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
7067
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
5961
7068
  ResultType: 'entity_object'
5962
7069
  }, sysUser);
5963
7070
  if (empResult.Success) {
@@ -5967,6 +7074,39 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5967
7074
  }
5968
7075
  }
5969
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
+
5970
7110
  // Step 7: Delete the CompanyIntegration itself FIRST, so its CredentialID FK reference
5971
7111
  // is gone before we delete the Credential. (Deleting the Credential first violates the
5972
7112
  // CompanyIntegration.CredentialID foreign key — the constraint is checked per-statement
@@ -6029,12 +7169,44 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6029
7169
  @Arg("platform", { defaultValue: "sqlserver" }) platform: string,
6030
7170
  @Arg("skipGitCommit", { defaultValue: false }) skipGitCommit: boolean,
6031
7171
  @Arg("skipRestart", { defaultValue: false }) skipRestart: boolean,
6032
- @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,
6033
- @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,
6034
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,
6035
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[],
6036
7200
  @Ctx() ctx: AppContext
6037
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
+ }
6038
7210
  // sync lock: schema evolution rewrites the metadata, field maps and DDL a sync
6039
7211
  // reads — data syncs (manual + scheduled) must not run while this is in flight.
6040
7212
  if (!IntegrationEngine.AcquireMaintenanceLock(companyIntegrationID, 'schema evolution')) {
@@ -6046,7 +7218,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6046
7218
  try {
6047
7219
  const user = this.getAuthenticatedUser(ctx);
6048
7220
  const validatedPlatform = this.validatePlatform(platform);
6049
- 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;
6050
7223
  const { connector, companyIntegration } = await this.resolveConnector(companyIntegrationID, user, md);
6051
7224
  const schemaName = this.deriveSchemaName(companyIntegration.Integration);
6052
7225
  const rv = new RunView();
@@ -6077,7 +7250,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6077
7250
  const activeNamesLower = new Set(activeIOs.map(o => o.Name.toLowerCase()));
6078
7251
  const priorMapsResult = await rv.RunView<MJCompanyIntegrationEntityMapEntity>({
6079
7252
  EntityName: 'MJ: Company Integration Entity Maps',
6080
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
7253
+ ExtraFilter: `CompanyIntegrationID='${sqlUuid(companyIntegrationID, 'companyIntegrationID')}'`,
6081
7254
  ResultType: 'entity_object',
6082
7255
  BypassCache: true, // the diff must read committed state
6083
7256
  }, user);
@@ -6160,8 +7333,15 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6160
7333
  }
6161
7334
 
6162
7335
  // Source of truth for the evolution = the PERSISTED post-resolution rows (the refresh in
6163
- // Phase 1 just wrote them via the overlay) — no duplicate vendor introspect.
6164
- 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
+ );
6165
7345
 
6166
7346
  // Normalize names to match source schema casing
6167
7347
  const nameMap = new Map(sourceSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.ExternalName]));
@@ -6178,7 +7358,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6178
7358
  };
6179
7359
 
6180
7360
  // Build target configs and SchemaBuilder input with ExistingTables
6181
- 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.`);
6182
7369
 
6183
7370
  const schemaInput: SchemaBuilderInput = {
6184
7371
  SourceSchema: filteredSchema,
@@ -6186,7 +7373,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6186
7373
  Platform: validatedPlatform,
6187
7374
  MJVersion: process.env.MJ_VERSION ?? '5.11.0',
6188
7375
  SourceType: companyIntegration.Integration,
6189
- AdditionalSchemaInfoPath: process.env.RSU_ADDITIONAL_SCHEMA_INFO_PATH ?? 'additionalSchemaInfo.json',
7376
+ AdditionalSchemaInfoPath: RuntimeSchemaManager.Instance.AdditionalSchemaInfoPath,
6190
7377
  MigrationsDir: process.env.RSU_MIGRATIONS_PATH ?? 'migrations/rsu',
6191
7378
  MetadataDir: process.env.RSU_METADATA_DIR ?? 'metadata',
6192
7379
  ExistingTables: existingTables,
@@ -6197,6 +7384,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6197
7384
  const builder = new SchemaBuilder();
6198
7385
  const schemaOutput = builder.BuildSchema(schemaInput);
6199
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
+
6200
7394
  if (schemaOutput.Errors.length > 0) {
6201
7395
  return {
6202
7396
  Success: false,
@@ -6227,6 +7421,36 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6227
7421
  }
6228
7422
  }
6229
7423
 
7424
+ // ── Phase 5b — MJ-RUN-37: a WATERMARK-FIELD swap is a change the DDL diff cannot see ──
7425
+ //
7426
+ // A connector upgrade can move an object's incremental cursor to a different source
7427
+ // column (`modified_at` → `last_updated`). Phase 1's persist already DETECTS that — the
7428
+ // overlay rewrites `IntegrationObject.IncrementalWatermarkField` and records it in the
7429
+ // merge log's `ChangedAttributes`. Nothing consumed it, and no other source of
7430
+ // `changedObjects` can: a cursor swap adds no column (so `diff.AddedColumns` and
7431
+ // `ModifiedColumns` are both empty) and touches no field map (so Phase 6 stays quiet).
7432
+ //
7433
+ // The object was therefore treated as unchanged, its Pull watermark survived, and the
7434
+ // next incremental sync applied the OLD column's stored value as a lower bound on the
7435
+ // NEW one. Where the new column sorts later, every row below that value is filtered out
7436
+ // AT THE SOURCE — permanently, and with no artifact: the source reports a total
7437
+ // consistent with the filter it was given, so fetched equals expected and the run
7438
+ // closes clean. Absence, not duplication, is what this produces.
7439
+ //
7440
+ // Scoped to CONTINUING maps deliberately. A new object has no watermark to invalidate
7441
+ // and a removed one has no sync left to protect; widening this to every persisted object
7442
+ // would inflate `ChangedObjects` (and `hasChanges`) with names Phase 7 cannot act on.
7443
+ const watermarkFieldChanged = ObjectsWithWatermarkFieldChange(
7444
+ refresh.PersistResult?.ObjectMergeLog ?? [],
7445
+ continuingMaps.map(m => m.ExternalObjectName ?? ''),
7446
+ );
7447
+ for (const name of watermarkFieldChanged) {
7448
+ if (!changedObjects.some(n => n.toLowerCase() === name.toLowerCase())) changedObjects.push(name);
7449
+ }
7450
+ if (watermarkFieldChanged.length > 0) {
7451
+ LogStatus(`[SchemaEvolution] IncrementalWatermarkField changed on ${watermarkFieldChanged.length} object(s) — resetting their Pull watermarks: ${watermarkFieldChanged.join(', ')}`);
7452
+ }
7453
+
6230
7454
  // ── Phase 6 — reconcile CONTINUING maps' field maps to the resolution ──
6231
7455
  // Added source fields get field maps (Active on enabled maps, Disabled on disabled
6232
7456
  // maps); field maps whose source field vanished from the resolution are disabled.
@@ -6235,12 +7459,64 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6235
7459
  for (const em of continuingMaps) {
6236
7460
  const io = iosByLowerName.get((em.ExternalObjectName ?? '').toLowerCase());
6237
7461
  if (!io) continue;
6238
- const fmChanges = await this.reconcileFieldMapsForEntityMap(em, io.ID, user, md, autoEnableNewColumns);
7462
+ const fmChanges = await this.reconcileFieldMapsForEntityMap(em, io.ID, user, md, autoEnableNewColumns, scopedFields);
6239
7463
  if ((fmChanges.Added > 0 || fmChanges.Disabled > 0) && em.ExternalObjectName && !changedObjects.includes(em.ExternalObjectName)) {
6240
7464
  changedObjects.push(em.ExternalObjectName);
6241
7465
  }
6242
7466
  }
6243
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
+
6244
7520
  // ── Phase 7 — U10: reset the Pull watermark of every CHANGED object so the next sync
6245
7521
  // full-fetches + backfills the schema change (content-hash bounds the rewrite cost).
6246
7522
  const changedLower = new Set(changedObjects.map(n => n.toLowerCase()));
@@ -6267,6 +7543,46 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6267
7543
  let pipelineSteps: RSUStepOutput[] | undefined;
6268
7544
  let gitCommitSuccess: boolean | undefined;
6269
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
+
6270
7586
  if (hasDDLChanges) {
6271
7587
  const rsuInput = builder.BuildRSUInput(schemaOutput, schemaInput, {
6272
7588
  SkipGitCommit: skipGitCommit,
@@ -6301,8 +7617,16 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6301
7617
  rsuInput.ContextUser = user;
6302
7618
  }
6303
7619
 
7620
+ // Retrying variant, for the same reason every other install path uses it: a refresh
7621
+ // is unattended and long, and a transient ExecuteMigration / RunCodeGen /
7622
+ // CompileTypeScript / RestartMJAPI failure would otherwise abandon it with the
7623
+ // metadata already re-resolved and the DDL not applied — the half-state an operator
7624
+ // then has to diagnose. Replay happens only while nothing was applied.
6304
7625
  const rsm = RuntimeSchemaManager.Instance;
6305
- const batchResult = await rsm.RunPipelineBatch([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]);
6306
7630
  const pipelineResult = batchResult.Results[0];
6307
7631
  pipelineSteps = pipelineResult?.Steps.map((s: RSUPipelineStep) => ({
6308
7632
  Name: s.Name, Status: s.Status, DurationMs: s.DurationMs, Message: s.Message, StepIndex: s.StepIndex, StepTotal: s.StepTotal,
@@ -6338,19 +7662,49 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6338
7662
  }
6339
7663
  }
6340
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
+ }
6341
7686
  }
6342
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
+
6343
7693
  const summary = [
6344
7694
  newObjects.length > 0 ? `${newObjects.length} new object(s) (${autoEnableNewObjects ? 'enabled' : 'created disabled'})` : null,
6345
7695
  removedObjects.length > 0 ? `${removedObjects.length} removed object(s) disabled` : null,
6346
7696
  changedObjects.length > 0 ? `${changedObjects.length} changed object(s), ${addedColumns} column(s) added (${autoEnableNewColumns ? 'enabled' : 'created disabled'}), ${modifiedColumns} modified` : null,
6347
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,
6348
7700
  ].filter(Boolean).join('; ');
6349
7701
 
6350
7702
  return {
6351
7703
  Success: true,
6352
7704
  Message: `Schema evolution applied — ${summary || 'no per-object deltas'}`,
6353
7705
  HasChanges: true,
7706
+ CustomColumnCandidates: outstandingCandidates.length > 0 ? outstandingCandidates : undefined,
7707
+ PromotedColumns: promotedColumns.length > 0 ? promotedColumns : undefined,
6354
7708
  AddedColumns: addedColumns,
6355
7709
  ModifiedColumns: modifiedColumns,
6356
7710
  Steps: pipelineSteps,
@@ -6361,6 +7715,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6361
7715
  CascadeRemovedObjects: cascadeRemoved.length > 0 ? cascadeRemoved : undefined,
6362
7716
  ChangedObjects: changedObjects.length > 0 ? changedObjects : undefined,
6363
7717
  WatermarksReset: watermarksReset.length > 0 ? watermarksReset : undefined,
7718
+ RekeyedObjects: rekeyedObjects.length > 0 ? rekeyedObjects : undefined,
6364
7719
  Warnings: (dagRemovalWarnings.length + schemaOutput.Warnings.length) > 0
6365
7720
  ? [...dagRemovalWarnings, ...schemaOutput.Warnings]
6366
7721
  : undefined,
@@ -6371,6 +7726,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6371
7726
  } finally {
6372
7727
  IntegrationEngine.ReleaseMaintenanceLock(companyIntegrationID);
6373
7728
  }
7729
+ });
6374
7730
  }
6375
7731
 
6376
7732
  /**
@@ -6390,17 +7746,22 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
6390
7746
  * A field map whose source field is no longer Active in the resolution is DISABLED, never
6391
7747
  * deleted — a later re-appearance re-enables it through the same reconciliation (the
6392
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.
6393
7753
  */
6394
7754
  private async reconcileFieldMapsForEntityMap(
6395
7755
  em: MJCompanyIntegrationEntityMapEntity,
6396
7756
  integrationObjectID: string,
6397
7757
  user: UserInfo,
6398
7758
  md: IMetadataProvider,
6399
- autoEnableNewColumns = true
7759
+ // see decideFieldMapReconcile — an omitted argument must not silently adopt
7760
+ autoEnableNewColumns = false,
7761
+ scopedFields?: ScopedBuildFields | null,
6400
7762
  ): Promise<{ Added: number; Disabled: number }> {
6401
7763
  const result = { Added: 0, Disabled: 0 };
6402
- const activeFields = IntegrationEngineBase.Instance
6403
- .GetIntegrationObjectFields(integrationObjectID)
7764
+ const activeFields = FieldsForBuild(integrationObjectID, scopedFields, IntegrationEngineBase.Instance)
6404
7765
  .filter(f => f.Status === 'Active');
6405
7766
  const mapEnabled = em.Status === 'Active' && em.SyncEnabled === true;
6406
7767