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