@reventlessdev/reventless-local 3.0.0-alpha.113
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/CHANGELOG.md +1197 -0
- package/LICENSE +202 -0
- package/__mocks__/emptyModule.js +2 -0
- package/__mocks__/nodeSqlite.mjs +6 -0
- package/package.json +67 -0
- package/rescript.json +39 -0
- package/src/Platform.res +2162 -0
- package/src/Platform.res.mjs +3389 -0
- package/src/adapter/Api/LocalGraphQL_Adapter.res +29 -0
- package/src/adapter/Api/LocalGraphQL_Adapter.res.mjs +26 -0
- package/src/adapter/Api/LocalGraphQL_SubscriptionResolvers.res +146 -0
- package/src/adapter/Api/LocalGraphQL_SubscriptionResolvers.res.mjs +88 -0
- package/src/adapter/Auth/Auth_GraphqlContext.res +83 -0
- package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +76 -0
- package/src/adapter/Auth/LocalAuth.res +239 -0
- package/src/adapter/Auth/LocalAuth.res.mjs +247 -0
- package/src/adapter/Auth/UserStore.res +134 -0
- package/src/adapter/Auth/UserStore.res.mjs +146 -0
- package/src/adapter/Backend.res +46 -0
- package/src/adapter/Backend.res.mjs +66 -0
- package/src/adapter/BackendState.res +34 -0
- package/src/adapter/BackendState.res.mjs +42 -0
- package/src/adapter/Cloner/LocalClonerRunner.res +13 -0
- package/src/adapter/Cloner/LocalClonerRunner.res.mjs +12 -0
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +329 -0
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +301 -0
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +76 -0
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs +57 -0
- package/src/adapter/CommandGenerator/LocalCommandGeneratorResolvers.res +19 -0
- package/src/adapter/CommandGenerator/LocalCommandGeneratorResolvers.res.mjs +19 -0
- package/src/adapter/CommandTopic/LocalCommandTopicChannel.res +104 -0
- package/src/adapter/CommandTopic/LocalCommandTopicChannel.res.mjs +82 -0
- package/src/adapter/CommandTopic/LocalCommandTopicRemoteChannel.res +32 -0
- package/src/adapter/CommandTopic/LocalCommandTopicRemoteChannel.res.mjs +38 -0
- package/src/adapter/Counter/LocalCounterHandler.res +42 -0
- package/src/adapter/Counter/LocalCounterHandler.res.mjs +45 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +131 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +140 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +334 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +299 -0
- package/src/adapter/DomainGraphQL_Server.res +565 -0
- package/src/adapter/DomainGraphQL_Server.res.mjs +576 -0
- package/src/adapter/DomainMCP_Server.res +369 -0
- package/src/adapter/DomainMCP_Server.res.mjs +358 -0
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res +87 -0
- package/src/adapter/EventCollector/LocalEventCollectorChannel.res.mjs +45 -0
- package/src/adapter/EventLog/EventLogStorage_InMemory.res +105 -0
- package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +117 -0
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +145 -0
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +155 -0
- package/src/adapter/EventTopic/LocalEventTopicPublisher.res +36 -0
- package/src/adapter/EventTopic/LocalEventTopicPublisher.res.mjs +31 -0
- package/src/adapter/GraphQL_Server.res +6 -0
- package/src/adapter/GraphQL_Server.res.mjs +154 -0
- package/src/adapter/GraphQL_ServerInstance.res +310 -0
- package/src/adapter/GraphQL_ServerInstance.res.mjs +257 -0
- package/src/adapter/Heartbeat/LocalHeartbeatRunner.res +57 -0
- package/src/adapter/Heartbeat/LocalHeartbeatRunner.res.mjs +34 -0
- package/src/adapter/LocalBus.res +498 -0
- package/src/adapter/LocalBus.res.mjs +1011 -0
- package/src/adapter/LocalPluginSpec.res +16 -0
- package/src/adapter/LocalPluginSpec.res.mjs +33 -0
- package/src/adapter/LocalSideEffectHandler.res +44 -0
- package/src/adapter/LocalSideEffectHandler.res.mjs +34 -0
- package/src/adapter/MCP_Server.res +4 -0
- package/src/adapter/MCP_Server.res.mjs +67 -0
- package/src/adapter/MCP_ServerInstance.res +362 -0
- package/src/adapter/MCP_ServerInstance.res.mjs +313 -0
- package/src/adapter/PlatformGraphQL_Server.res +75 -0
- package/src/adapter/PlatformGraphQL_Server.res.mjs +84 -0
- package/src/adapter/PlatformMCP_Server.res +23 -0
- package/src/adapter/PlatformMCP_Server.res.mjs +46 -0
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +793 -0
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +569 -0
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +242 -0
- package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +241 -0
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +339 -0
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +318 -0
- package/src/adapter/QueryDb/SortKey_Filter.res +48 -0
- package/src/adapter/QueryDb/SortKey_Filter.res.mjs +46 -0
- package/src/adapter/QueryEngine/LocalQueryEngine.res +57 -0
- package/src/adapter/QueryEngine/LocalQueryEngine.res.mjs +56 -0
- package/src/adapter/Runtime/LocalAggregateRuntime_Builder.res +91 -0
- package/src/adapter/Runtime/LocalAggregateRuntime_Builder.res.mjs +50 -0
- package/src/adapter/Runtime/LocalEventCollectorRuntime_Builder.res +47 -0
- package/src/adapter/Runtime/LocalEventCollectorRuntime_Builder.res.mjs +37 -0
- package/src/adapter/Runtime/LocalPluginRuntime_Builder.res +11 -0
- package/src/adapter/Runtime/LocalPluginRuntime_Builder.res.mjs +30 -0
- package/src/adapter/Runtime/LocalRuntimeEnvironment.res +54 -0
- package/src/adapter/Runtime/LocalRuntimeEnvironment.res.mjs +40 -0
- package/src/adapter/Scheduler/LocalScheduledPublisher.res +102 -0
- package/src/adapter/Scheduler/LocalScheduledPublisher.res.mjs +84 -0
- package/src/adapter/SqliteDriver.res +52 -0
- package/src/adapter/SqliteDriver.res.mjs +76 -0
- package/src/adapter/SqliteDriver.resi +31 -0
- package/src/adapter/Task/LocalTaskBucket.res +75 -0
- package/src/adapter/Task/LocalTaskBucket.res.mjs +48 -0
- package/src/adapter/Task/TaskBucket_Sqlite.res +47 -0
- package/src/adapter/Task/TaskBucket_Sqlite.res.mjs +40 -0
- package/src/components/Aggregate_Builder.res +55 -0
- package/src/components/Aggregate_Builder.res.mjs +140 -0
- package/src/components/AutomationSlice_Builder.res +41 -0
- package/src/components/AutomationSlice_Builder.res.mjs +57 -0
- package/src/components/Counter_Builder.res +14 -0
- package/src/components/Counter_Builder.res.mjs +32 -0
- package/src/components/DcbEventLog_Builder.res +18 -0
- package/src/components/DcbEventLog_Builder.res.mjs +24 -0
- package/src/components/ExtensionPoint_Builder.res +25 -0
- package/src/components/ExtensionPoint_Builder.res.mjs +44 -0
- package/src/components/InboundTranslationSlice_Builder.res +34 -0
- package/src/components/InboundTranslationSlice_Builder.res.mjs +45 -0
- package/src/components/OutboundTranslationSlice_Builder.res +43 -0
- package/src/components/OutboundTranslationSlice_Builder.res.mjs +59 -0
- package/src/components/Plugin_Builder.res +37 -0
- package/src/components/Plugin_Builder.res.mjs +67 -0
- package/src/components/ReadModel_Builder.res +47 -0
- package/src/components/ReadModel_Builder.res.mjs +54 -0
- package/src/components/StateChangeSlice_Builder.res +19 -0
- package/src/components/StateChangeSlice_Builder.res.mjs +21 -0
- package/src/components/StateViewSlice_Builder.res +42 -0
- package/src/components/StateViewSlice_Builder.res.mjs +58 -0
- package/src/components/Task_Builder.res +28 -0
- package/src/components/Task_Builder.res.mjs +41 -0
- package/src/reexport/CommandTopic.res +3 -0
- package/src/reexport/CommandTopic.res.mjs +55 -0
- package/src/test/Mocks/MockDcbEventLogStorage.res +136 -0
- package/src/test/Mocks/MockDcbEventLogStorage.res.mjs +146 -0
- package/src/test/Mocks/MockEventLogStorage.res +57 -0
- package/src/test/Mocks/MockEventLogStorage.res.mjs +65 -0
- package/src/test/Mocks/MockPublisher.res +29 -0
- package/src/test/Mocks/MockPublisher.res.mjs +28 -0
- package/src/test/Mocks/MockQueryDbStorage.res +104 -0
- package/src/test/Mocks/MockQueryDbStorage.res.mjs +136 -0
- package/src/test/TestRunner.res +96 -0
- package/src/test/TestRunner.res.mjs +71 -0
- package/tests/SplitApiFixtures.res +84 -0
- package/tests/SplitApiFixtures.res.mjs +88 -0
- package/tests/SplitApiTest.res +102 -0
- package/tests/SplitApiTest.res.mjs +82 -0
- package/tests/TestFixtures.res +54 -0
- package/tests/TestFixtures.res.mjs +65 -0
- package/tests/adapter/BackendParityTest.res +94 -0
- package/tests/adapter/BackendParityTest.res.mjs +96 -0
- package/tests/adapter/CollectNEventsTest.res +82 -0
- package/tests/adapter/CollectNEventsTest.res.mjs +83 -0
- package/tests/adapter/CommandAuthorizationTest.res +200 -0
- package/tests/adapter/CommandAuthorizationTest.res.mjs +190 -0
- package/tests/adapter/CommandTopicChannelTest.res +174 -0
- package/tests/adapter/CommandTopicChannelTest.res.mjs +170 -0
- package/tests/adapter/CounterHandlerTest.res +113 -0
- package/tests/adapter/CounterHandlerTest.res.mjs +96 -0
- package/tests/adapter/DcbEventLogStorageSqliteTest.res +157 -0
- package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +165 -0
- package/tests/adapter/DcbEventLogStorageTest.res +187 -0
- package/tests/adapter/DcbEventLogStorageTest.res.mjs +195 -0
- package/tests/adapter/EventCollectorChannelTest.res +166 -0
- package/tests/adapter/EventCollectorChannelTest.res.mjs +178 -0
- package/tests/adapter/EventLogStorageSqliteTest.res +105 -0
- package/tests/adapter/EventLogStorageSqliteTest.res.mjs +116 -0
- package/tests/adapter/EventTapTest.res +102 -0
- package/tests/adapter/EventTapTest.res.mjs +94 -0
- package/tests/adapter/EventTopicPublisherTest.res +76 -0
- package/tests/adapter/EventTopicPublisherTest.res.mjs +86 -0
- package/tests/adapter/GraphQL_SchemaInspectorTest.res +763 -0
- package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +578 -0
- package/tests/adapter/GraphQL_SubscriptionResolversTest.res +161 -0
- package/tests/adapter/GraphQL_SubscriptionResolversTest.res.mjs +134 -0
- package/tests/adapter/HeartbeatRunnerTest.res +136 -0
- package/tests/adapter/HeartbeatRunnerTest.res.mjs +96 -0
- package/tests/adapter/LocalAuthHttpTest.res +224 -0
- package/tests/adapter/LocalAuthHttpTest.res.mjs +237 -0
- package/tests/adapter/LocalAuthLoginTest.res +180 -0
- package/tests/adapter/LocalAuthLoginTest.res.mjs +207 -0
- package/tests/adapter/LocalAuthTest.res +106 -0
- package/tests/adapter/LocalAuthTest.res.mjs +145 -0
- package/tests/adapter/LocalAuthUserStoreTest.res +172 -0
- package/tests/adapter/LocalAuthUserStoreTest.res.mjs +208 -0
- package/tests/adapter/LocalBusBoundedTest.res +157 -0
- package/tests/adapter/LocalBusBoundedTest.res.mjs +168 -0
- package/tests/adapter/LocalBusPubSubTest.res +81 -0
- package/tests/adapter/LocalBusPubSubTest.res.mjs +81 -0
- package/tests/adapter/QueryDbGsiTtlTest.res +268 -0
- package/tests/adapter/QueryDbGsiTtlTest.res.mjs +209 -0
- package/tests/adapter/QueryDbListResolverTest.res +361 -0
- package/tests/adapter/QueryDbListResolverTest.res.mjs +426 -0
- package/tests/adapter/QueryDbStorageSqliteTest.res +159 -0
- package/tests/adapter/QueryDbStorageSqliteTest.res.mjs +126 -0
- package/tests/adapter/QueryDbStorageTest.res +171 -0
- package/tests/adapter/QueryDbStorageTest.res.mjs +170 -0
- package/tests/adapter/QueryEngineTest.res +152 -0
- package/tests/adapter/QueryEngineTest.res.mjs +148 -0
- package/tests/adapter/ScheduledPublisherTest.res +199 -0
- package/tests/adapter/ScheduledPublisherTest.res.mjs +203 -0
- package/tests/adapter/SortKey_FilterTest.res +103 -0
- package/tests/adapter/SortKey_FilterTest.res.mjs +135 -0
- package/tests/adapter/SqliteDriverTest.res +86 -0
- package/tests/adapter/SqliteDriverTest.res.mjs +101 -0
- package/tests/adapter/TaskBucketSqliteTest.res +44 -0
- package/tests/adapter/TaskBucketSqliteTest.res.mjs +40 -0
- package/tests/adapter/TaskBucketTest.res +106 -0
- package/tests/adapter/TaskBucketTest.res.mjs +118 -0
- package/tests/components/aggregate/AggregateFixtures.res +78 -0
- package/tests/components/aggregate/AggregateFixtures.res.mjs +109 -0
- package/tests/components/aggregate/AggregateTest.res +129 -0
- package/tests/components/aggregate/AggregateTest.res.mjs +129 -0
- package/tests/components/automationslice/AutomationSliceCallbackTest.res +212 -0
- package/tests/components/automationslice/AutomationSliceCallbackTest.res.mjs +233 -0
- package/tests/components/automationslice/AutomationSliceFixtures.res +189 -0
- package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +276 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +289 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +453 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res +47 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res.mjs +31 -0
- package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res +213 -0
- package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +268 -0
- package/tests/components/automationslice/MixedSourceAutomationSliceTest.res +118 -0
- package/tests/components/automationslice/MixedSourceAutomationSliceTest.res.mjs +99 -0
- package/tests/components/commandgenerator/CommandGeneratorFixtures.res +73 -0
- package/tests/components/commandgenerator/CommandGeneratorFixtures.res.mjs +109 -0
- package/tests/components/commandgenerator/CommandGeneratorTest.res +43 -0
- package/tests/components/commandgenerator/CommandGeneratorTest.res.mjs +46 -0
- package/tests/components/commandtopic/CommandTopicFixtures.res +37 -0
- package/tests/components/commandtopic/CommandTopicFixtures.res.mjs +68 -0
- package/tests/components/commandtopic/CommandTopicStreamFixtures.res +43 -0
- package/tests/components/commandtopic/CommandTopicStreamFixtures.res.mjs +91 -0
- package/tests/components/commandtopic/CommandTopicStreamTest.res +30 -0
- package/tests/components/commandtopic/CommandTopicStreamTest.res.mjs +35 -0
- package/tests/components/commandtopic/CommandTopicTest.res +55 -0
- package/tests/components/commandtopic/CommandTopicTest.res.mjs +62 -0
- package/tests/components/counter/CounterFixtures.res +42 -0
- package/tests/components/counter/CounterFixtures.res.mjs +34 -0
- package/tests/components/counter/CounterTest.res +46 -0
- package/tests/components/counter/CounterTest.res.mjs +61 -0
- package/tests/components/dcb/DcbCrossPartitionFixtures.res +170 -0
- package/tests/components/dcb/DcbCrossPartitionFixtures.res.mjs +276 -0
- package/tests/components/dcb/DcbCrossPartitionReadTest.res +68 -0
- package/tests/components/dcb/DcbCrossPartitionReadTest.res.mjs +78 -0
- package/tests/components/dcb/DcbEventLogAppendStreamTest.res +72 -0
- package/tests/components/dcb/DcbEventLogAppendStreamTest.res.mjs +82 -0
- package/tests/components/dcb/DcbEventLogStreamTest.res +103 -0
- package/tests/components/dcb/DcbEventLogStreamTest.res.mjs +91 -0
- package/tests/components/dcb/DcbFixtures.res +145 -0
- package/tests/components/dcb/DcbFixtures.res.mjs +221 -0
- package/tests/components/dcb/DcbTest.res +55 -0
- package/tests/components/dcb/DcbTest.res.mjs +55 -0
- package/tests/components/eventcollector/EventCollectorFixtures.res +42 -0
- package/tests/components/eventcollector/EventCollectorFixtures.res.mjs +49 -0
- package/tests/components/eventcollector/EventCollectorTest.res +37 -0
- package/tests/components/eventcollector/EventCollectorTest.res.mjs +36 -0
- package/tests/components/eventlog/EventLogAppendStreamTest.res +93 -0
- package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +113 -0
- package/tests/components/eventlog/EventLogConflictTest.res +86 -0
- package/tests/components/eventlog/EventLogConflictTest.res.mjs +81 -0
- package/tests/components/eventlog/EventLogFixtures.res +52 -0
- package/tests/components/eventlog/EventLogFixtures.res.mjs +80 -0
- package/tests/components/eventlog/EventLogStreamTest.res +98 -0
- package/tests/components/eventlog/EventLogStreamTest.res.mjs +142 -0
- package/tests/components/eventlog/EventLogTest.res +53 -0
- package/tests/components/eventlog/EventLogTest.res.mjs +74 -0
- package/tests/components/eventtopic/EventTopicFixtures.res +47 -0
- package/tests/components/eventtopic/EventTopicFixtures.res.mjs +77 -0
- package/tests/components/eventtopic/EventTopicStreamFixtures.res +27 -0
- package/tests/components/eventtopic/EventTopicStreamFixtures.res.mjs +62 -0
- package/tests/components/eventtopic/EventTopicStreamTest.res +35 -0
- package/tests/components/eventtopic/EventTopicStreamTest.res.mjs +44 -0
- package/tests/components/eventtopic/EventTopicTest.res +35 -0
- package/tests/components/eventtopic/EventTopicTest.res.mjs +43 -0
- package/tests/components/extensionpoint/ExtensionPointFixtures.res +145 -0
- package/tests/components/extensionpoint/ExtensionPointFixtures.res.mjs +188 -0
- package/tests/components/extensionpoint/ExtensionPointTest.res +62 -0
- package/tests/components/extensionpoint/ExtensionPointTest.res.mjs +55 -0
- package/tests/components/heartbeat/HeartbeatFixtures.res +48 -0
- package/tests/components/heartbeat/HeartbeatFixtures.res.mjs +46 -0
- package/tests/components/heartbeat/HeartbeatTest.res +67 -0
- package/tests/components/heartbeat/HeartbeatTest.res.mjs +65 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +241 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +239 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res +36 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +73 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res +191 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +297 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res +78 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res.mjs +137 -0
- package/tests/components/querydb/QueryDbFixtures.res +39 -0
- package/tests/components/querydb/QueryDbFixtures.res.mjs +73 -0
- package/tests/components/querydb/QueryDbSubIdFixtures.res +44 -0
- package/tests/components/querydb/QueryDbSubIdFixtures.res.mjs +80 -0
- package/tests/components/querydb/QueryDbSubIdTest.res +104 -0
- package/tests/components/querydb/QueryDbSubIdTest.res.mjs +104 -0
- package/tests/components/querydb/QueryDbTest.res +81 -0
- package/tests/components/querydb/QueryDbTest.res.mjs +126 -0
- package/tests/components/readmodel/DcbReadModelE2EFixtures.res +215 -0
- package/tests/components/readmodel/DcbReadModelE2EFixtures.res.mjs +308 -0
- package/tests/components/readmodel/DcbReadModelE2ETest.res +45 -0
- package/tests/components/readmodel/DcbReadModelE2ETest.res.mjs +37 -0
- package/tests/components/readmodel/DcbReadModelTypoFailFastTest.res +88 -0
- package/tests/components/readmodel/DcbReadModelTypoFailFastTest.res.mjs +152 -0
- package/tests/components/readmodel/MixedSourceReadModelFixtures.res +181 -0
- package/tests/components/readmodel/MixedSourceReadModelFixtures.res.mjs +277 -0
- package/tests/components/readmodel/MixedSourceReadModelTest.res +61 -0
- package/tests/components/readmodel/MixedSourceReadModelTest.res.mjs +68 -0
- package/tests/components/readmodel/ReadModelFixtures.res +138 -0
- package/tests/components/readmodel/ReadModelFixtures.res.mjs +169 -0
- package/tests/components/readmodel/ReadModelTest.res +46 -0
- package/tests/components/readmodel/ReadModelTest.res.mjs +36 -0
- package/tests/components/scheduler/SchedulerFixtures.res +59 -0
- package/tests/components/scheduler/SchedulerFixtures.res.mjs +63 -0
- package/tests/components/scheduler/SchedulerTest.res +76 -0
- package/tests/components/scheduler/SchedulerTest.res.mjs +86 -0
- package/tests/components/sideeffecthandler/SideEffectHandlerFixtures.res +133 -0
- package/tests/components/sideeffecthandler/SideEffectHandlerFixtures.res.mjs +149 -0
- package/tests/components/sideeffecthandler/SideEffectHandlerTest.res +39 -0
- package/tests/components/sideeffecthandler/SideEffectHandlerTest.res.mjs +28 -0
- package/tests/components/stateviewslice/StateViewSliceFixtures.res +117 -0
- package/tests/components/stateviewslice/StateViewSliceFixtures.res.mjs +188 -0
- package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res +117 -0
- package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res.mjs +183 -0
- package/tests/components/stateviewslice/StateViewSliceSubIdTest.res +78 -0
- package/tests/components/stateviewslice/StateViewSliceSubIdTest.res.mjs +107 -0
- package/tests/components/stateviewslice/StateViewSliceTest.res +59 -0
- package/tests/components/stateviewslice/StateViewSliceTest.res.mjs +72 -0
- package/tests/components/task/TaskFixtures.res +87 -0
- package/tests/components/task/TaskFixtures.res.mjs +82 -0
- package/tests/components/task/TaskTest.res +98 -0
- package/tests/components/task/TaskTest.res.mjs +46 -0
- package/tests/plugin/PluginBehavior_GWT.res +146 -0
- package/tests/plugin/PluginBehavior_GWT.res.mjs +281 -0
- package/tests/plugin/Plugin_Fixtures.res +61 -0
- package/tests/plugin/Plugin_Fixtures.res.mjs +105 -0
- package/tests/plugin/PluginsProjection_GWT.res +95 -0
- package/tests/plugin/PluginsProjection_GWT.res.mjs +120 -0
- package/tests/plugin/UIFragmentRegistryProjection_GWT.res +72 -0
- package/tests/plugin/UIFragmentRegistryProjection_GWT.res.mjs +117 -0
- package/tests/setup/sqliteGlobal.cjs +12 -0
package/src/Platform.res
ADDED
|
@@ -0,0 +1,2162 @@
|
|
|
1
|
+
// Local Platform — implements ReventlessInfra.Platform.T using in-memory or SQLite data structures (selected via Backend).
|
|
2
|
+
// Pulumi mock mode is activated automatically when Platform.Make() is applied.
|
|
3
|
+
//
|
|
4
|
+
// Example:
|
|
5
|
+
// module Platform = Platform.Make()
|
|
6
|
+
// module App = MyPlugin.Make(Platform)
|
|
7
|
+
//
|
|
8
|
+
// The platform starts a GraphQL server on port 4000 after all components are built.
|
|
9
|
+
// Stop it with TestRunner.stopGraphQLServer() in afterAll.
|
|
10
|
+
|
|
11
|
+
// Install compact Effect logger (strips timestamp=/level=/fiber= metadata from log output).
|
|
12
|
+
// Self-installing at module import time — must be imported before any Effect.runPromise call.
|
|
13
|
+
let _ = ReventlessCore.EffectLogger.install
|
|
14
|
+
|
|
15
|
+
// The local platform is a local-dev tool, so default to Debug-level logging
|
|
16
|
+
// (surfaces e.g. the slice/aggregate `deciding on state:` lines). An explicit
|
|
17
|
+
// LOG_LEVEL env var still overrides this (e.g. LOG_LEVEL=info or =silent).
|
|
18
|
+
ReventlessCore.EffectLogger.setDefaultMinLevel(ReventlessCore.Logger.Debug)
|
|
19
|
+
|
|
20
|
+
let log = ReventlessCore.Logger.fromEnv()
|
|
21
|
+
|
|
22
|
+
// Module-level ref to hold the platform GraphQL server instance in split mode.
|
|
23
|
+
// Populated by makePlatform when splitApi=true.
|
|
24
|
+
let platformGraphQLRef: ref<option<GraphQL_ServerInstance.t>> = ref(None)
|
|
25
|
+
let getPlatformGraphQL = () => platformGraphQLRef.contents
|
|
26
|
+
|
|
27
|
+
// Module-level ref to hold the platform MCP server instance in split mode.
|
|
28
|
+
// Populated by makePlatform when splitApi=true.
|
|
29
|
+
let platformMCPRef: ref<option<MCP_ServerInstance.t>> = ref(None)
|
|
30
|
+
|
|
31
|
+
// Configurable platform — set silent=true to suppress diagnostic warnings in tests,
|
|
32
|
+
// splitApi=true to serve core and plugin APIs on separate ports,
|
|
33
|
+
// backend=Backend.Sqlite({...}) to opt into file-backed SQLite persistence.
|
|
34
|
+
// Usage: module Platform = Platform.MakeWithConfig({let silent = true; let splitApi = false; let cloner = false; let backend = Backend.Memory})
|
|
35
|
+
module MakeWithConfig = (
|
|
36
|
+
Config: {
|
|
37
|
+
let silent: bool
|
|
38
|
+
let splitApi: bool
|
|
39
|
+
let cloner: bool
|
|
40
|
+
let backend: Backend.t
|
|
41
|
+
let commandHandlerConfig: ReventlessCore.Runtime.commandHandlerConfigs
|
|
42
|
+
},
|
|
43
|
+
): (ReventlessInfra.Platform.T with type api = unit and type role = unit) => {
|
|
44
|
+
// commandHandlerConfig is accepted for transport-neutral parity with the
|
|
45
|
+
// AWS platform. Lambda-specific knobs (memorySize, timeout, sqsBatchSize,
|
|
46
|
+
// …) are no-ops in-memory. envVars are not propagated here because the
|
|
47
|
+
// local platform has no per-component process boundary.
|
|
48
|
+
let _ = Config.commandHandlerConfig
|
|
49
|
+
// Activate Pulumi mock mode — must happen before any component creation.
|
|
50
|
+
// Idempotent, so safe to call even if TestRunner.setup() was already called.
|
|
51
|
+
let _ = TestRunner.setup()
|
|
52
|
+
|
|
53
|
+
type api = unit
|
|
54
|
+
type role = unit
|
|
55
|
+
type apiTarget = Domain | Platform
|
|
56
|
+
|
|
57
|
+
let api = ()
|
|
58
|
+
let apiRole = ()
|
|
59
|
+
|
|
60
|
+
// Wire the backend before any storage maker runs. Dispatching adapters
|
|
61
|
+
// (EventLogStorage_InMemory, QueryDbStorage_InMemory, ...) read BackendState
|
|
62
|
+
// inside `make` to decide whether to allocate in-memory dicts or open a
|
|
63
|
+
// SQLite handle. resetOnStart truncates the file at construction.
|
|
64
|
+
let _ = switch Config.backend {
|
|
65
|
+
| Backend.Memory => BackendState.setMemory()
|
|
66
|
+
| Backend.Sqlite({path, resetOnStart}) =>
|
|
67
|
+
if resetOnStart {
|
|
68
|
+
Backend.removeFileIfExists(path)
|
|
69
|
+
}
|
|
70
|
+
let db = SqliteDriver.openDb(~path)
|
|
71
|
+
BackendState.setSqlite(~db, ~path)
|
|
72
|
+
// Persistent stores keep their event numbering across restarts and app
|
|
73
|
+
// switches: seed the in-memory event-tap counter from the rows already on
|
|
74
|
+
// disk so the timeline's #N continues instead of restarting at 1. (After a
|
|
75
|
+
// reset the file is empty, so this seeds 0 — a clean session starts at #1.)
|
|
76
|
+
LocalBus.seedEventTapSeq(
|
|
77
|
+
EventLogStorage_Sqlite.countAll(db) + DcbEventLogStorage_Sqlite.countAll(db),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module Bus = LocalBus.Impl({
|
|
82
|
+
let capacity = None
|
|
83
|
+
let silent = Config.silent
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Track which API target is active during deployPlugin / admin schema registration.
|
|
87
|
+
// Domain = plugin-facing (default); Platform = admin/core (split mode).
|
|
88
|
+
let currentDeployTarget: ref<apiTarget> = ref(Domain)
|
|
89
|
+
|
|
90
|
+
// Track which GraphQL servers have had admin schema (Plugin queries/mutations) registered.
|
|
91
|
+
// Prevents double-registration when makePlatform and deployPlugin both target the same server.
|
|
92
|
+
let adminRegisteredServers: ref<array<GraphQL_ServerInstance.t>> = ref([])
|
|
93
|
+
|
|
94
|
+
// Relay support record for domain QueryDbs (platform QueryDbs pass None).
|
|
95
|
+
let domainRelaySupport: QueryDbResolvers_GraphQL.relaySupport = {
|
|
96
|
+
encodeGlobalId: DomainGraphQL_Server.encodeGlobalId,
|
|
97
|
+
registerNodeType: DomainGraphQL_Server.registerNodeType,
|
|
98
|
+
registerNodeResolverCallback: DomainGraphQL_Server.registerNodeResolverCallback,
|
|
99
|
+
nodeTypeRegistry: DomainGraphQL_Server.nodeTypeRegistry,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Resolve the active GraphQL server based on current deploy target.
|
|
103
|
+
// In unified mode always routes to DomainGraphQL_Server (single server).
|
|
104
|
+
// In split mode routes based on currentDeployTarget.
|
|
105
|
+
let resolveTargetGraphQL = (): GraphQL_ServerInstance.t =>
|
|
106
|
+
if !Config.splitApi {
|
|
107
|
+
DomainGraphQL_Server.asInterface
|
|
108
|
+
} else {
|
|
109
|
+
switch currentDeployTarget.contents {
|
|
110
|
+
| Domain => DomainGraphQL_Server.asInterface
|
|
111
|
+
| Platform => PlatformGraphQL_Server.asInterface
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Resolve the active MCP server based on current deploy target.
|
|
116
|
+
// In unified mode always routes to DomainMCP_Server.
|
|
117
|
+
// In split mode routes based on currentDeployTarget.
|
|
118
|
+
let resolveTargetMCP = (): MCP_ServerInstance.t =>
|
|
119
|
+
if !Config.splitApi {
|
|
120
|
+
DomainMCP_Server.asInterface
|
|
121
|
+
} else {
|
|
122
|
+
switch currentDeployTarget.contents {
|
|
123
|
+
| Domain => DomainMCP_Server.asInterface
|
|
124
|
+
| Platform => PlatformMCP_Server.asInterface
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Platform hook record — callbacks known at MakeWithConfig time plus a
|
|
129
|
+
// mutable ref for admin extension points (set by makePlatform/deployPlugin
|
|
130
|
+
// after Admin.construct returns, before plugins are built).
|
|
131
|
+
let hooks: ReventlessCore.Plugin_Helpers.platformHooks = {
|
|
132
|
+
adminExtensionPoints: ref(Pulumi.Output.make(Dict.make())),
|
|
133
|
+
// Platform context — populated by makePlatform/deployPlugin before plugin build.
|
|
134
|
+
scheduler: ref(None),
|
|
135
|
+
schedulerRoleUrn: ref(Pulumi.Output.make("")),
|
|
136
|
+
api: ref(None),
|
|
137
|
+
apiRole: ref(None),
|
|
138
|
+
deployTarget: ref("Domain"),
|
|
139
|
+
// Phase 1: register SDL + resolver stub synchronously.
|
|
140
|
+
// Pass the resolved server so the correct target (domain or platform) receives the schema.
|
|
141
|
+
mutationResolverHook: (~kind, ~fields, ~commandSchema, ~commandAuthorization) => {
|
|
142
|
+
let server = resolveTargetGraphQL()
|
|
143
|
+
switch kind {
|
|
144
|
+
| ReventlessCore.Plugin_Helpers.Aggregate =>
|
|
145
|
+
CommandGeneratorResolvers_GraphQL.register(
|
|
146
|
+
~fields,
|
|
147
|
+
~commandSchema,
|
|
148
|
+
~commandAuthorization,
|
|
149
|
+
~server,
|
|
150
|
+
)
|
|
151
|
+
| Dcb =>
|
|
152
|
+
fields->Array.forEach(field =>
|
|
153
|
+
CommandGeneratorResolvers_GraphQL.registerDcb(
|
|
154
|
+
~fieldName=field,
|
|
155
|
+
~commandSchema,
|
|
156
|
+
~commandAuthorization,
|
|
157
|
+
~server,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
// Phase 2: bind generateCommand when Output.apply resolves.
|
|
163
|
+
mutationBindHook: CommandGeneratorResolvers_GraphQL.bindHandler,
|
|
164
|
+
// InboundTranslationSlice hooks — pass resolved server for correct target routing.
|
|
165
|
+
inboundMutationResolverHook: (~fieldName, ~externalInputSchema) =>
|
|
166
|
+
InboundTranslationResolvers_GraphQL.register(
|
|
167
|
+
~fieldName,
|
|
168
|
+
~externalInputSchema,
|
|
169
|
+
~server=resolveTargetGraphQL(),
|
|
170
|
+
),
|
|
171
|
+
inboundMutationBindReceiveHook: InboundTranslationResolvers_GraphQL.bindReceive,
|
|
172
|
+
// Register GraphQL type definitions from the generated schema fragment.
|
|
173
|
+
schemaTypeRegistrationHook: sdlTypes => resolveTargetGraphQL().registerTypes(~sdlTypes),
|
|
174
|
+
// MCP tools and resources — registered during plugin construction.
|
|
175
|
+
// See the large lambda below; it references Bus for QueryDb lookups.
|
|
176
|
+
mcpSchemaRegistrationHook: ({pluginName, mutationEntries, queryEntries, eventLogEntries, subscriptionFields}) => {
|
|
177
|
+
let mcp = resolveTargetMCP()
|
|
178
|
+
mcp.registerToolsFromEntries(~pluginName, ~mutationEntries, ~commandHandler=async (
|
|
179
|
+
toolName,
|
|
180
|
+
args,
|
|
181
|
+
identity,
|
|
182
|
+
) => {
|
|
183
|
+
switch CommandGeneratorResolvers_GraphQL.handlerRefs->Dict.get(toolName) {
|
|
184
|
+
| Some(handlerRef) =>
|
|
185
|
+
switch handlerRef.contents {
|
|
186
|
+
| Some(generateCommand) =>
|
|
187
|
+
let payload: ReventlessCore.CommandGenerator.payload = {
|
|
188
|
+
command: toolName,
|
|
189
|
+
arguments: args->Obj.magic,
|
|
190
|
+
meta: {ip: [], user: identity.userId, info: `mcp/tools/${toolName}`},
|
|
191
|
+
identity,
|
|
192
|
+
}
|
|
193
|
+
let outcome = await generateCommand(payload)->Effect.runPromise
|
|
194
|
+
outcome->CommandGeneratorResolvers_GraphQL.commandOutcomeToJson->JSON.stringify
|
|
195
|
+
| None => `{"error":"no handler for ${toolName}"}`
|
|
196
|
+
}
|
|
197
|
+
| None => `{"error":"no handler for ${toolName}"}`
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
mcp.registerResourcesFromEntries(~pluginName, ~queryEntries, ~queryHandler=async (
|
|
202
|
+
resourceName,
|
|
203
|
+
uri,
|
|
204
|
+
) => {
|
|
205
|
+
let segments = uri->String.split("/")
|
|
206
|
+
let id = segments->Array.at(-1)->Option.getOr("")
|
|
207
|
+
let queryDbName =
|
|
208
|
+
ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry
|
|
209
|
+
->Dict.toArray
|
|
210
|
+
->Array.find(((_, entry)) =>
|
|
211
|
+
entry.singleFieldName == resourceName || entry.listFieldName == resourceName
|
|
212
|
+
)
|
|
213
|
+
->Option.map(((name, _)) => name)
|
|
214
|
+
->Option.getOr(resourceName)
|
|
215
|
+
switch Bus.getQueryDb(queryDbName) {
|
|
216
|
+
| Some(ops) =>
|
|
217
|
+
if id->String.length > 0 && id != resourceName {
|
|
218
|
+
let items = await ops.loadStream(id)
|
|
219
|
+
->Stream.runCollect
|
|
220
|
+
->Effect.catchAll(_ => Effect.succeed([]))
|
|
221
|
+
->Effect.runPromise
|
|
222
|
+
switch items->Array.get(0) {
|
|
223
|
+
| Some(item) => item
|
|
224
|
+
| None => JSON.Encode.null
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
switch Bus.getQueryDbScan(queryDbName) {
|
|
228
|
+
| Some(scanAll) => scanAll()->JSON.Encode.array
|
|
229
|
+
| None => []->JSON.Encode.array
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
| None => JSON.Encode.null
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
mcp.registerEventHistoryResourcesFromEntries(
|
|
237
|
+
~pluginName,
|
|
238
|
+
~eventLogEntries,
|
|
239
|
+
~eventLogHandler=async (resourceName, uri) => {
|
|
240
|
+
let pathPart = uri->String.split("?")->Array.getUnsafe(0)
|
|
241
|
+
let segments = pathPart->String.split("/")
|
|
242
|
+
let entityId = segments->Array.at(-1)->Option.getOr("")
|
|
243
|
+
let (limit, after) = {
|
|
244
|
+
let parts = uri->String.split("?")
|
|
245
|
+
switch parts->Array.get(1) {
|
|
246
|
+
| None => (None, None)
|
|
247
|
+
| Some(qs) =>
|
|
248
|
+
let params = Dict.make()
|
|
249
|
+
qs
|
|
250
|
+
->String.split("&")
|
|
251
|
+
->Array.forEach(param => {
|
|
252
|
+
let kv = param->String.split("=")
|
|
253
|
+
switch (kv->Array.get(0), kv->Array.get(1)) {
|
|
254
|
+
| (Some(k), Some(v)) => params->Dict.set(k, v)
|
|
255
|
+
| _ => ()
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
(
|
|
259
|
+
params->Dict.get("limit")->Option.flatMap(v => Int.fromString(v)),
|
|
260
|
+
params->Dict.get("after"),
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let makePaginatedResponse = (
|
|
266
|
+
~events: array<JSON.t>,
|
|
267
|
+
~hasMore: bool,
|
|
268
|
+
~nextAfter: option<string>,
|
|
269
|
+
) =>
|
|
270
|
+
Dict.fromArray([
|
|
271
|
+
("events", events->JSON.Encode.array),
|
|
272
|
+
(
|
|
273
|
+
"pagination",
|
|
274
|
+
Dict.fromArray([
|
|
275
|
+
("hasMore", hasMore->JSON.Encode.bool),
|
|
276
|
+
("nextAfter", nextAfter->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
|
|
277
|
+
])->JSON.Encode.object,
|
|
278
|
+
),
|
|
279
|
+
])->JSON.Encode.object
|
|
280
|
+
|
|
281
|
+
let paginate = (events: array<JSON.t>, getPosition: JSON.t => option<string>) => {
|
|
282
|
+
let filtered = switch after {
|
|
283
|
+
| Some(afterPos) =>
|
|
284
|
+
let idx =
|
|
285
|
+
events->Array.findIndex(e => getPosition(e)->Option.mapOr(false, p => p > afterPos))
|
|
286
|
+
if idx >= 0 {
|
|
287
|
+
events->Array.slice(~start=idx, ~end=events->Array.length)
|
|
288
|
+
} else {
|
|
289
|
+
[]
|
|
290
|
+
}
|
|
291
|
+
| None => events
|
|
292
|
+
}
|
|
293
|
+
let (limited, hasMore) = switch limit {
|
|
294
|
+
| Some(n) if filtered->Array.length > n => (
|
|
295
|
+
filtered->Array.slice(~start=0, ~end=n),
|
|
296
|
+
true,
|
|
297
|
+
)
|
|
298
|
+
| _ => (filtered, false)
|
|
299
|
+
}
|
|
300
|
+
let nextAfterVal = if hasMore {
|
|
301
|
+
limited->Array.at(-1)->Option.flatMap(getPosition)
|
|
302
|
+
} else {
|
|
303
|
+
None
|
|
304
|
+
}
|
|
305
|
+
makePaginatedResponse(~events=limited, ~hasMore, ~nextAfter=nextAfterVal)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let matchingEntry =
|
|
309
|
+
eventLogEntries->Array.find(entry =>
|
|
310
|
+
resourceName->String.includes(entry.displayName->String.toLowerCase)
|
|
311
|
+
)
|
|
312
|
+
switch matchingEntry {
|
|
313
|
+
| Some(entry) =>
|
|
314
|
+
switch Bus.getEventLogReplay(entry.busKey) {
|
|
315
|
+
| Some(replay) =>
|
|
316
|
+
let events = await replay(entityId)
|
|
317
|
+
paginate(events, e => {
|
|
318
|
+
switch e->JSON.Decode.object {
|
|
319
|
+
| Some(obj) =>
|
|
320
|
+
obj
|
|
321
|
+
->Dict.get("position")
|
|
322
|
+
->Option.flatMap(JSON.Decode.string)
|
|
323
|
+
| None => None
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
| None =>
|
|
327
|
+
switch Bus.getDcbEventLogRead(entry.busKey) {
|
|
328
|
+
| Some(read) =>
|
|
329
|
+
let result = await read(~query=[])
|
|
330
|
+
let filtered = if entityId->String.length > 0 && entityId != resourceName {
|
|
331
|
+
result.events->Array.filter(e => e.tags->Array.some(tag => tag.value == entityId))
|
|
332
|
+
} else {
|
|
333
|
+
result.events
|
|
334
|
+
}
|
|
335
|
+
let serialized = filtered->Array.map(e =>
|
|
336
|
+
Dict.fromArray([
|
|
337
|
+
("position", JSON.Encode.string(e.position)),
|
|
338
|
+
("event", JSON.Encode.string(e.eventType)),
|
|
339
|
+
("data", e.data),
|
|
340
|
+
(
|
|
341
|
+
"tags",
|
|
342
|
+
e.tags
|
|
343
|
+
->Array.map(
|
|
344
|
+
t =>
|
|
345
|
+
Dict.fromArray([
|
|
346
|
+
("key", JSON.Encode.string(t.key)),
|
|
347
|
+
("value", JSON.Encode.string(t.value)),
|
|
348
|
+
])->JSON.Encode.object,
|
|
349
|
+
)
|
|
350
|
+
->JSON.Encode.array,
|
|
351
|
+
),
|
|
352
|
+
])->JSON.Encode.object
|
|
353
|
+
)
|
|
354
|
+
paginate(serialized, e => {
|
|
355
|
+
switch e->JSON.Decode.object {
|
|
356
|
+
| Some(obj) =>
|
|
357
|
+
obj
|
|
358
|
+
->Dict.get("position")
|
|
359
|
+
->Option.flatMap(JSON.Decode.string)
|
|
360
|
+
| None => None
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
| None => makePaginatedResponse(~events=[], ~hasMore=false, ~nextAfter=None)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
| None => makePaginatedResponse(~events=[], ~hasMore=false, ~nextAfter=None)
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
// Wire GraphQL WebSocket subscriptions (Source A only; Source C is
|
|
372
|
+
// generated as SDL but its resolvers are currently future work).
|
|
373
|
+
// Source B no longer flows through GraphQL — clients consume Events
|
|
374
|
+
// channels directly. The in-memory equivalent is wired in Phase 8.
|
|
375
|
+
let _ = queryEntries
|
|
376
|
+
if subscriptionFields->Array.length > 0 {
|
|
377
|
+
open LocalGraphQL_SubscriptionResolvers
|
|
378
|
+
let server = resolveTargetGraphQL()
|
|
379
|
+
|
|
380
|
+
// Source A: bridge each EventTopic bus topic → yoga PubSub channel
|
|
381
|
+
eventLogEntries->Array.forEach(entry =>
|
|
382
|
+
bridgeSourceA(
|
|
383
|
+
~subscribeToEvents=Bus.subscribeToEvents,
|
|
384
|
+
~displayName=entry.displayName,
|
|
385
|
+
~busTopicName=entry.busKey,
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
// Register subscription SDL fields and resolvers with the GraphQL server
|
|
390
|
+
let makeEntry = topic => ({fieldName: topic, topic}: subscriptionEntry)
|
|
391
|
+
let sourceAEntries = eventLogEntries->Array.map(e => makeEntry(sourceATopic(e.displayName)))
|
|
392
|
+
registerAll(~server, ~sdlFields=subscriptionFields, ~sourceAEntries, ~sourceBEntries=[])
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
module AggregateMaker = Aggregate_Builder.MakeWithHooks(
|
|
398
|
+
Bus,
|
|
399
|
+
{
|
|
400
|
+
let hooks = hooks
|
|
401
|
+
},
|
|
402
|
+
)
|
|
403
|
+
module ReadModelMaker = ReadModel_Builder.Make(Bus)
|
|
404
|
+
module ExtensionPointMaker = ExtensionPoint_Builder.Make(Bus)
|
|
405
|
+
module TaskMaker = Task_Builder.Make(Bus)
|
|
406
|
+
module StateViewSliceMaker = StateViewSlice_Builder.Make(Bus)
|
|
407
|
+
module AutomationSliceMaker = AutomationSlice_Builder.Make(Bus)
|
|
408
|
+
module OutboundTranslationSliceMaker = OutboundTranslationSlice_Builder.Make(Bus)
|
|
409
|
+
module InboundTranslationSliceMaker = InboundTranslationSlice_Builder.Make(Bus)
|
|
410
|
+
|
|
411
|
+
module Aggregate = {
|
|
412
|
+
module Make = (
|
|
413
|
+
Spec: Reventless.Aggregate.Spec,
|
|
414
|
+
Behavior: Reventless.Behavior.T with module Spec := Spec,
|
|
415
|
+
EventMappings: ReventlessInfra.EventMapper.Mappings with module Target := Spec,
|
|
416
|
+
): (ReventlessInfra.Aggregate.T with type api = unit) => AggregateMaker.Make(
|
|
417
|
+
Spec,
|
|
418
|
+
Behavior,
|
|
419
|
+
EventMappings,
|
|
420
|
+
)
|
|
421
|
+
/** Async variant — in-memory uses the same channel (no FIFO distinction). */
|
|
422
|
+
module MakeAsync = (
|
|
423
|
+
Spec: Reventless.Aggregate.Spec,
|
|
424
|
+
Behavior: Reventless.Behavior.T with module Spec := Spec,
|
|
425
|
+
EventMappings: ReventlessInfra.EventMapper.Mappings with module Target := Spec,
|
|
426
|
+
): (ReventlessInfra.Aggregate.T with type api = unit) => AggregateMaker.Make(
|
|
427
|
+
Spec,
|
|
428
|
+
Behavior,
|
|
429
|
+
EventMappings,
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
module ReadModel = {
|
|
434
|
+
module Make = (
|
|
435
|
+
Spec: Reventless.ReadModel.Spec,
|
|
436
|
+
Mappings: Reventless.Projection.Mappings with module Target := Spec,
|
|
437
|
+
): (
|
|
438
|
+
ReventlessInfra.ReadModel.T with module Spec = Spec and type api = unit and type role = unit
|
|
439
|
+
) => ReadModelMaker.Make(Spec, Mappings)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// In-memory has no DynamoDB streams — stream variant is identical to plain ReadModel.
|
|
443
|
+
module ReadModelStream = {
|
|
444
|
+
module Make = (
|
|
445
|
+
Spec: Reventless.ReadModel.Spec,
|
|
446
|
+
Mappings: Reventless.Projection.Mappings with module Target := Spec,
|
|
447
|
+
): (
|
|
448
|
+
ReventlessInfra.ReadModel.T with module Spec = Spec and type api = unit and type role = unit
|
|
449
|
+
) => ReadModelMaker.Make(Spec, Mappings)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
module ExtensionPoint = {
|
|
453
|
+
module Make = (
|
|
454
|
+
Mapping: ReventlessInfra.ExtensionPointMapping.Mapping,
|
|
455
|
+
): ReventlessInfra.ExtensionPoint.T => {
|
|
456
|
+
module Spec = Mapping.ExtensionPoint
|
|
457
|
+
module CompiledMapping = ReventlessInfra.ExtensionPointMapping.Make(Mapping)
|
|
458
|
+
module Mappings: ReventlessInfra.ExtensionPoint.Mappings with module Spec := Spec = {
|
|
459
|
+
module type Mapping = ReventlessInfra.ExtensionPointMapping.T
|
|
460
|
+
with module ExtensionPoint := Spec
|
|
461
|
+
let name = Mapping.Delegate.name
|
|
462
|
+
// The mapping FILE's moduleUrl, not the spec's — the EventCollector
|
|
463
|
+
// runtime dynamic-imports this URL to find mapOutgoingEvent.
|
|
464
|
+
let moduleUrl = Mapping.moduleUrl
|
|
465
|
+
let mappings: array<module(Mapping)> = [module(CompiledMapping)]
|
|
466
|
+
}
|
|
467
|
+
module Inner = ExtensionPointMaker.Make(Spec, Mappings)
|
|
468
|
+
include Inner
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
module Make2 = (
|
|
472
|
+
Mapping1: ReventlessInfra.ExtensionPointMapping.Mapping,
|
|
473
|
+
Mapping2: ReventlessInfra.ExtensionPointMapping.Mapping
|
|
474
|
+
with module ExtensionPoint = Mapping1.ExtensionPoint,
|
|
475
|
+
): ReventlessInfra.ExtensionPoint.T => {
|
|
476
|
+
module Spec = Mapping1.ExtensionPoint
|
|
477
|
+
module CM1 = ReventlessInfra.ExtensionPointMapping.Make(Mapping1)
|
|
478
|
+
module CM2 = ReventlessInfra.ExtensionPointMapping.Make(Mapping2)
|
|
479
|
+
module Mappings: ReventlessInfra.ExtensionPoint.Mappings with module Spec := Spec = {
|
|
480
|
+
module type Mapping = ReventlessInfra.ExtensionPointMapping.T
|
|
481
|
+
with module ExtensionPoint := Spec
|
|
482
|
+
let name =
|
|
483
|
+
Mapping1.Delegate.name ++ "+" ++ Mapping2.Delegate.name
|
|
484
|
+
// First mapping's URL — matches the user-extension merge convention.
|
|
485
|
+
let moduleUrl = Mapping1.moduleUrl
|
|
486
|
+
let mappings: array<module(Mapping)> = [module(CM1), module(CM2)]
|
|
487
|
+
}
|
|
488
|
+
module Inner = ExtensionPointMaker.Make(Spec, Mappings)
|
|
489
|
+
include Inner
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
module Make3 = (
|
|
493
|
+
Mapping1: ReventlessInfra.ExtensionPointMapping.Mapping,
|
|
494
|
+
Mapping2: ReventlessInfra.ExtensionPointMapping.Mapping
|
|
495
|
+
with module ExtensionPoint = Mapping1.ExtensionPoint,
|
|
496
|
+
Mapping3: ReventlessInfra.ExtensionPointMapping.Mapping
|
|
497
|
+
with module ExtensionPoint = Mapping1.ExtensionPoint,
|
|
498
|
+
): ReventlessInfra.ExtensionPoint.T => {
|
|
499
|
+
module Spec = Mapping1.ExtensionPoint
|
|
500
|
+
module CM1 = ReventlessInfra.ExtensionPointMapping.Make(Mapping1)
|
|
501
|
+
module CM2 = ReventlessInfra.ExtensionPointMapping.Make(Mapping2)
|
|
502
|
+
module CM3 = ReventlessInfra.ExtensionPointMapping.Make(Mapping3)
|
|
503
|
+
module Mappings: ReventlessInfra.ExtensionPoint.Mappings with module Spec := Spec = {
|
|
504
|
+
module type Mapping = ReventlessInfra.ExtensionPointMapping.T
|
|
505
|
+
with module ExtensionPoint := Spec
|
|
506
|
+
let name =
|
|
507
|
+
Mapping1.Delegate.name ++
|
|
508
|
+
"+" ++
|
|
509
|
+
Mapping2.Delegate.name ++
|
|
510
|
+
"+" ++
|
|
511
|
+
Mapping3.Delegate.name
|
|
512
|
+
// First mapping's URL — matches the user-extension merge convention.
|
|
513
|
+
let moduleUrl = Mapping1.moduleUrl
|
|
514
|
+
let mappings: array<module(Mapping)> = [module(CM1), module(CM2), module(CM3)]
|
|
515
|
+
}
|
|
516
|
+
module Inner = ExtensionPointMaker.Make(Spec, Mappings)
|
|
517
|
+
include Inner
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
module MakeMulti = (
|
|
521
|
+
Spec: ReventlessInfra.ExtensionPointMapping.Spec,
|
|
522
|
+
Mappings: ReventlessInfra.ExtensionPoint.Mappings with module Spec := Spec,
|
|
523
|
+
): ReventlessInfra.ExtensionPoint.T => ExtensionPointMaker.Make(Spec, Mappings)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
module Extension = {
|
|
527
|
+
module Make = (
|
|
528
|
+
Mapping: ReventlessInfra.ExtensionMapping.Mapping,
|
|
529
|
+
): ReventlessInfra.Extension.Blueprint => {
|
|
530
|
+
module Spec = Mapping.ExtensionPoint
|
|
531
|
+
module CompiledMapping = ReventlessInfra.ExtensionMapping.Make(Mapping)
|
|
532
|
+
module type Mapping = ReventlessInfra.ExtensionMapping.T
|
|
533
|
+
with module ExtensionPoint := Spec
|
|
534
|
+
let name = Mapping.Delegate.name
|
|
535
|
+
let moduleUrl = Mapping.moduleUrl
|
|
536
|
+
let delegateModuleUrl = Mapping.delegateModuleUrl
|
|
537
|
+
let mappings: array<module(Mapping)> = [module(CompiledMapping)]
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
module Task = {
|
|
543
|
+
module Make = (Spec: ReventlessInfra.Task.Spec): (
|
|
544
|
+
ReventlessInfra.Task.T with module Spec = Spec
|
|
545
|
+
) => TaskMaker.Make(Spec)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
module Counter = Counter_Builder.Make(Bus)
|
|
549
|
+
|
|
550
|
+
module StateChangeSlice = {
|
|
551
|
+
module Make = (
|
|
552
|
+
Spec: Reventless.StateChangeSlice.Spec,
|
|
553
|
+
Behavior: Reventless.StateChangeSlice.Behavior with module Spec := Spec,
|
|
554
|
+
): (ReventlessInfra.StateChangeSlice.T with module Spec = Spec) =>
|
|
555
|
+
StateChangeSlice_Builder.Make(Spec, Behavior)
|
|
556
|
+
/** Async variant — in-memory uses the same channel (no FIFO distinction). */
|
|
557
|
+
module MakeAsync = (
|
|
558
|
+
Spec: Reventless.StateChangeSlice.Spec,
|
|
559
|
+
Behavior: Reventless.StateChangeSlice.Behavior with module Spec := Spec,
|
|
560
|
+
): (ReventlessInfra.StateChangeSlice.T with module Spec = Spec) => {
|
|
561
|
+
include StateChangeSlice_Builder.Make(Spec, Behavior)
|
|
562
|
+
let isAsync = true
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
module StateViewSlice = {
|
|
567
|
+
module Make = (
|
|
568
|
+
Spec: Reventless.StateViewSlice.Spec,
|
|
569
|
+
Projection: Reventless.StateViewSlice.Projection with module Spec := Spec,
|
|
570
|
+
): (ReventlessInfra.StateViewSlice.T with module Spec = Spec) =>
|
|
571
|
+
StateViewSliceMaker.Make(Spec, Projection)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// In-memory has no DynamoDB streams — stream variant is identical to plain StateViewSlice.
|
|
575
|
+
module StateViewSliceStream = {
|
|
576
|
+
module Make = (
|
|
577
|
+
Spec: Reventless.StateViewSlice.Spec,
|
|
578
|
+
Projection: Reventless.StateViewSlice.Projection with module Spec := Spec,
|
|
579
|
+
): (ReventlessInfra.StateViewSlice.T with module Spec = Spec) =>
|
|
580
|
+
StateViewSliceMaker.Make(Spec, Projection)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
module AutomationSlice = {
|
|
584
|
+
module Make = (
|
|
585
|
+
Spec: Reventless.AutomationSlice.Spec,
|
|
586
|
+
Automation: Reventless.AutomationSlice.Automation with module Spec := Spec,
|
|
587
|
+
): (ReventlessInfra.AutomationSlice.T with module Spec = Spec) =>
|
|
588
|
+
AutomationSliceMaker.Make(Spec, Automation)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
module OutboundTranslationSlice = {
|
|
592
|
+
module Make = (
|
|
593
|
+
Spec: Reventless.OutboundTranslationSlice.Spec,
|
|
594
|
+
Translation: Reventless.OutboundTranslationSlice.Translation with module Spec := Spec,
|
|
595
|
+
): (ReventlessInfra.OutboundTranslationSlice.T with module Spec = Spec) =>
|
|
596
|
+
OutboundTranslationSliceMaker.Make(Spec, Translation)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
module InboundTranslationSlice = {
|
|
600
|
+
module Make = (
|
|
601
|
+
Spec: Reventless.InboundTranslationSlice.Spec,
|
|
602
|
+
Translation: Reventless.InboundTranslationSlice.Translation with module Spec := Spec,
|
|
603
|
+
): (ReventlessInfra.InboundTranslationSlice.T with module Spec = Spec) =>
|
|
604
|
+
InboundTranslationSliceMaker.Make(Spec, Translation)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Empty base fragment — no types, no mutations, no queries.
|
|
608
|
+
// Used by the plugin Api in split mode so plugin schema has no core fields.
|
|
609
|
+
let emptyBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
|
|
610
|
+
types: [],
|
|
611
|
+
mutations: [],
|
|
612
|
+
queries: [],
|
|
613
|
+
subscriptions: [],
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
module Api = {
|
|
617
|
+
module Make = (
|
|
618
|
+
FragmentConfig: {
|
|
619
|
+
let baseFragment: ReventlessInfra.Api.schemaFragment
|
|
620
|
+
},
|
|
621
|
+
): ReventlessInfra.Api.T => {
|
|
622
|
+
module Builder = ReventlessCore.Api_Builder.Make(LocalGraphQL_Adapter)
|
|
623
|
+
// In split mode, the plugin API uses an empty base fragment so plugin schema
|
|
624
|
+
// has no core fields. In unified mode, use the provided base fragment as-is.
|
|
625
|
+
let effectiveBaseFragment = if Config.splitApi {
|
|
626
|
+
emptyBaseFragment
|
|
627
|
+
} else {
|
|
628
|
+
FragmentConfig.baseFragment
|
|
629
|
+
}
|
|
630
|
+
let make = (~name, ~opts=?) =>
|
|
631
|
+
Builder.make(~name, ~baseFragment=effectiveBaseFragment, ~opts?)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
module PluginMaker = Plugin_Builder.Make(
|
|
636
|
+
Bus,
|
|
637
|
+
{
|
|
638
|
+
let hooks = hooks
|
|
639
|
+
},
|
|
640
|
+
)
|
|
641
|
+
module Plugin: ReventlessInfra.Plugin.T
|
|
642
|
+
with type api = unit
|
|
643
|
+
and type role = unit
|
|
644
|
+
and type component = ReventlessCore.Plugin.component = {
|
|
645
|
+
type api = unit
|
|
646
|
+
type role = unit
|
|
647
|
+
type component = ReventlessCore.Plugin.component
|
|
648
|
+
let make = PluginMaker.make
|
|
649
|
+
let makeAutoUIManifest = PluginMaker.makeAutoUIManifest
|
|
650
|
+
let makePluginDefinition = PluginMaker.makePluginDefinition
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
module EventCollectorChannel = LocalEventCollectorChannel.Make(Bus)
|
|
654
|
+
module QE = LocalQueryEngine.Make(Bus)
|
|
655
|
+
|
|
656
|
+
// Admin-internal Plugin aggregate. Constructed here so it can be threaded into
|
|
657
|
+
// Admin.construct's ~aggregates parameter — registerAdminAggregateMutations then
|
|
658
|
+
// wires Platform_Plugin_Activate / Platform_Plugin_Deactivate through the standard
|
|
659
|
+
// CommandGenerator auto-flow (same path user-plugin aggregates use). Internal-protocol
|
|
660
|
+
// variants (Heartbeat, Connect, Disconnect, ReportIncompatibility) carry `@noApi` and
|
|
661
|
+
// are filtered out before SDL/resolver generation.
|
|
662
|
+
module LocalPluginAggregate: ReventlessInfra.Aggregate.T with type api = unit = AggregateMaker.Make(
|
|
663
|
+
ReventlessCore.PluginSpec,
|
|
664
|
+
ReventlessCore.PluginBehavior,
|
|
665
|
+
ReventlessInfra.NoEventMappings.Make(ReventlessCore.PluginSpec),
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
// Admin Plugin read model — driven by LocalPluginAggregate's event topic via
|
|
669
|
+
// the real projection subscriptions (same path as AWS). PluginsReadModel is the
|
|
670
|
+
// name-keyed current view ("Plugins"). The projection module re-exports `mappings`
|
|
671
|
+
// and an inner `Mappings` submodule but doesn't satisfy
|
|
672
|
+
// `Reventless.Projection.Mappings` at the top level (the `Mapping` type lives in
|
|
673
|
+
// the submodule), so we flatten it into a wrapper module — mirrors the AWS
|
|
674
|
+
// Platform.res PluginReadModelMappings wrapper.
|
|
675
|
+
module PluginsReadModelMappings: Reventless.Projection.Mappings
|
|
676
|
+
with module Target := ReventlessCore.PluginsReadModelSpec = {
|
|
677
|
+
module M = ReventlessCore.PluginsProjection.Mappings
|
|
678
|
+
module type Mapping = M.Mapping
|
|
679
|
+
let moduleUrl: string = ReventlessCore.PluginsProjection.moduleUrl
|
|
680
|
+
let mappings: array<module(Mapping)> = ReventlessCore.PluginsProjection.mappings
|
|
681
|
+
}
|
|
682
|
+
module PluginsReadModel = ReadModelMaker.MakeNoResolver(
|
|
683
|
+
ReventlessCore.PluginsReadModelSpec,
|
|
684
|
+
PluginsReadModelMappings,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
module Admin = ReventlessCore.Platform_Admin.Make(
|
|
689
|
+
LocalRuntimeEnvironment,
|
|
690
|
+
EventCollectorChannel,
|
|
691
|
+
QE,
|
|
692
|
+
LocalClonerRunner,
|
|
693
|
+
ReventlessCore.PluginRuntime_Builder_Micro.Make(
|
|
694
|
+
LocalRuntimeEnvironment,
|
|
695
|
+
EventCollectorChannel,
|
|
696
|
+
),
|
|
697
|
+
DcbEventLogStorage_InMemory.Make(Bus),
|
|
698
|
+
LocalEventTopicPublisher.Make(Bus),
|
|
699
|
+
LocalCommandTopicChannel.Make(Bus),
|
|
700
|
+
LocalCommandTopicChannel.Make(Bus),
|
|
701
|
+
{
|
|
702
|
+
let silent = Config.silent
|
|
703
|
+
let splitApi = Config.splitApi
|
|
704
|
+
let cloner = Config.cloner
|
|
705
|
+
let hooks = hooks
|
|
706
|
+
},
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
module type PluginMaker = {
|
|
710
|
+
let make: unit => Plugin.component
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
let makeScheduler = () => {
|
|
714
|
+
module SP = LocalScheduledPublisher.Make(Bus)
|
|
715
|
+
module S = ReventlessCore.Scheduler_Builder.Make(SP)
|
|
716
|
+
let component = S.make()
|
|
717
|
+
component->ReventlessCore.Component.operations
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
@val external processEnv: dict<string> = "process.env"
|
|
721
|
+
let graphqlDebug = processEnv->Dict.get("GRAPHQL_DEBUG")->Option.isSome
|
|
722
|
+
|
|
723
|
+
// Per-session server ports for the VS Code local platform runner (features
|
|
724
|
+
// plan Phase 9). The runner spawns one platform child per app folder; default
|
|
725
|
+
// ports (4000/4001/3001/3002) collide when several run, so each is overridable
|
|
726
|
+
// via env. Defaults preserve today's behaviour for all non-runner callers.
|
|
727
|
+
let resolvePort = (key, fallback) =>
|
|
728
|
+
processEnv->Dict.get(key)->Option.flatMap(v => Int.fromString(v))->Option.getOr(fallback)
|
|
729
|
+
let domainPort = resolvePort("REVENTLESS_DOMAIN_PORT", 4000)
|
|
730
|
+
let domainMcpPort = resolvePort("REVENTLESS_DOMAIN_MCP_PORT", 3001)
|
|
731
|
+
let platformPort = resolvePort("REVENTLESS_PLATFORM_PORT", 4001)
|
|
732
|
+
let platformMcpPort = resolvePort("REVENTLESS_PLATFORM_MCP_PORT", 3002)
|
|
733
|
+
|
|
734
|
+
type mcpSupported = | @as(true) McpSupported | @as(false) McpNotSupported
|
|
735
|
+
let mcpSupported = McpSupported
|
|
736
|
+
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
// Shared helpers — used by both makePlatform and deployPlugin.
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
// The Plugin QueryDb store ("Plugins") is now owned by the real PluginsReadModel
|
|
742
|
+
// projection (wired into Admin.construct ~readModels). QueryDbStorage_InMemory
|
|
743
|
+
// registers it in the Bus under PluginsReadModelSpec.name, so the admin query
|
|
744
|
+
// resolvers / status gate that scan `Bus.getQueryDbScan(pluginQueryDbName)` pick
|
|
745
|
+
// it up automatically — no hand-rolled seed store here anymore. Rows are produced
|
|
746
|
+
// by dispatching a synthetic `Connect(pluginDefinition)` to LocalPluginAggregate
|
|
747
|
+
// (see connectPlugin).
|
|
748
|
+
|
|
749
|
+
// UIFragmentRegistry QueryDb store ops — initialized lazily on first seed call.
|
|
750
|
+
let uiFragmentQueryDbOpsRef: ref<option<ReventlessCore.QueryDb_Adapter.operations>> = ref(None)
|
|
751
|
+
|
|
752
|
+
let ensureUIFragmentRegistryQueryDbStore = () => {
|
|
753
|
+
switch uiFragmentQueryDbOpsRef.contents {
|
|
754
|
+
| Some(ops) => ops
|
|
755
|
+
| None =>
|
|
756
|
+
let name = ReventlessCore.UIFragmentRegistryReadModelSpec.name
|
|
757
|
+
let store: ref<dict<array<JSON.t>>> = ref(Dict.make())
|
|
758
|
+
let allItems: ref<array<JSON.t>> = ref([])
|
|
759
|
+
let syncAll = () => {
|
|
760
|
+
allItems.contents =
|
|
761
|
+
store.contents
|
|
762
|
+
->Dict.toArray
|
|
763
|
+
->Array.flatMap(((id, items)) =>
|
|
764
|
+
items->Array.map(item => {
|
|
765
|
+
let obj = item->JSON.Decode.object->Option.getOr(Dict.make())
|
|
766
|
+
if !(obj->Dict.get("id")->Option.isSome) {
|
|
767
|
+
let copy = Dict.make()
|
|
768
|
+
obj->Dict.toArray->Array.forEach(((k, v)) => copy->Dict.set(k, v))
|
|
769
|
+
copy->Dict.set("id", JSON.Encode.string(id))
|
|
770
|
+
JSON.Encode.object(copy)
|
|
771
|
+
} else {
|
|
772
|
+
item
|
|
773
|
+
}
|
|
774
|
+
})
|
|
775
|
+
)
|
|
776
|
+
}
|
|
777
|
+
let ops: ReventlessCore.QueryDb_Adapter.operations = {
|
|
778
|
+
load: async id => Ok(store.contents->Dict.get(id)->Option.getOr([])),
|
|
779
|
+
loadStream: id =>
|
|
780
|
+
store.contents->Dict.get(id)->Option.getOr([])->Stream.fromIterable,
|
|
781
|
+
save: async (id, state, _, _) => {
|
|
782
|
+
store.contents->Dict.set(id, [state])
|
|
783
|
+
syncAll()
|
|
784
|
+
Ok()
|
|
785
|
+
},
|
|
786
|
+
saveBatch: async batch => {
|
|
787
|
+
batch->Array.forEach(((id, state, _)) => store.contents->Dict.set(id, [state]))
|
|
788
|
+
syncAll()
|
|
789
|
+
Ok()
|
|
790
|
+
},
|
|
791
|
+
count: async (_, _, inc) => Ok(inc),
|
|
792
|
+
delete: async (id, _) => {
|
|
793
|
+
store.contents->Dict.delete(id)
|
|
794
|
+
syncAll()
|
|
795
|
+
Ok()
|
|
796
|
+
},
|
|
797
|
+
deleteBatch: async ids => {
|
|
798
|
+
ids->Array.forEach(((id, _)) => store.contents->Dict.delete(id))
|
|
799
|
+
syncAll()
|
|
800
|
+
Ok()
|
|
801
|
+
},
|
|
802
|
+
}
|
|
803
|
+
Bus.registerQueryDb(name, ops)
|
|
804
|
+
Bus.registerQueryDbScan(name, () => allItems.contents)
|
|
805
|
+
Bus.registerQueryDbStream(name, () => allItems.contents->Stream.fromIterable)
|
|
806
|
+
uiFragmentQueryDbOpsRef := Some(ops)
|
|
807
|
+
ops
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// In-memory store for plugin structures, keyed by plugin ID.
|
|
812
|
+
let pluginStructuresStore: ref<dict<Reventless.Plugin.pluginStructure>> = ref(Dict.make())
|
|
813
|
+
|
|
814
|
+
let seedPluginStructuresStore = (~pluginComponents: array<ReventlessCore.Plugin.component>) => {
|
|
815
|
+
pluginComponents->Array.forEach(plugin => {
|
|
816
|
+
let outputs: ReventlessInfra.Plugin.outputs = plugin->ReventlessCore.Component.outputs
|
|
817
|
+
let _ =
|
|
818
|
+
(outputs.id, outputs.pluginStructure)
|
|
819
|
+
->Pulumi.Output.all2
|
|
820
|
+
->Pulumi.Output.apply(((id, ps)) => {
|
|
821
|
+
switch ps {
|
|
822
|
+
| Some(def) => pluginStructuresStore.contents->Dict.set(id, def)
|
|
823
|
+
| None => ()
|
|
824
|
+
}
|
|
825
|
+
})
|
|
826
|
+
})
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Bus keys for the admin Plugin aggregate (name-keyed). ComponentType.toName maps
|
|
830
|
+
// CommandTopic → "CmdTopic" and EventTopic → "EventTopic".
|
|
831
|
+
let pluginCmdTopicKey = ReventlessCore.PluginSpec.name ++ "Aggr" ++ "CmdTopic" // "PluginAggrCmdTopic"
|
|
832
|
+
let pluginEventTopicKey =
|
|
833
|
+
ReventlessCore.PluginSpec.name ++ "Aggr" ++ "EventTopic" // "PluginAggrEventTopic"
|
|
834
|
+
|
|
835
|
+
// Dispatch a Plugin aggregate command in-process via the Bus command topic. The
|
|
836
|
+
// body is the {id, meta, command} shape LocalCommandTopicChannel produces; the
|
|
837
|
+
// aggregate id is the plugin NAME. dispatchCommand parks the command until the
|
|
838
|
+
// aggregate's handler is registered (LocalBus pending-queue), so call-before-wire
|
|
839
|
+
// is safe.
|
|
840
|
+
let dispatchPluginCommand = (~pluginName: string, ~command: ReventlessCore.PluginSpec.command) => {
|
|
841
|
+
let cmdJson: Reventless.Message.commandJson = {
|
|
842
|
+
id: pluginName,
|
|
843
|
+
meta: ReventlessCore.Message.generateMeta(~service=ReventlessCore.PluginSpec.name),
|
|
844
|
+
commandJson: command->S.reverseConvertToJsonOrThrow(ReventlessCore.PluginSpec.commandSchema),
|
|
845
|
+
}
|
|
846
|
+
Bus.dispatchCommand(pluginCmdTopicKey, ReventlessCore.CommandTopic.encodeCommandJson(cmdJson))
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Subscription topics for the admin live-update channels.
|
|
850
|
+
let pluginStatusSubTopic = "onPluginStatusChange"
|
|
851
|
+
let uiFragmentSubTopic = "onUIFragmentChange"
|
|
852
|
+
|
|
853
|
+
// Relocated Source-C emission: subscribe to the Plugin aggregate's event topic and
|
|
854
|
+
// fan each lifecycle event out to the matching GraphQL subscription. Status now
|
|
855
|
+
// flows from folding aggregate events (Connect / Activate / Deactivate / Retire),
|
|
856
|
+
// not from direct QueryDb writes, so the subscription emission lives here rather
|
|
857
|
+
// than inside the mutation resolvers. Idempotent: registered once per platform
|
|
858
|
+
// instance (guarded by pluginEventsSubscribed). Subscribe BEFORE any Connect
|
|
859
|
+
// dispatch — LocalBus drops events with no subscribers.
|
|
860
|
+
let pluginEventsSubscribed = ref(false)
|
|
861
|
+
let subscribeToPluginEvents = () =>
|
|
862
|
+
if !pluginEventsSubscribed.contents {
|
|
863
|
+
pluginEventsSubscribed := true
|
|
864
|
+
let publishStatus = (~name, ~status) =>
|
|
865
|
+
LocalGraphQL_SubscriptionResolvers.publish(
|
|
866
|
+
pluginStatusSubTopic,
|
|
867
|
+
JSON.Encode.object(
|
|
868
|
+
Dict.fromArray([
|
|
869
|
+
("pluginId", JSON.Encode.string(name)),
|
|
870
|
+
("status", JSON.Encode.string(status)),
|
|
871
|
+
]),
|
|
872
|
+
),
|
|
873
|
+
)
|
|
874
|
+
let publishUIFragment = (~name, ~changeKind, ~manifest) =>
|
|
875
|
+
LocalGraphQL_SubscriptionResolvers.publish(
|
|
876
|
+
uiFragmentSubTopic,
|
|
877
|
+
JSON.Encode.object(
|
|
878
|
+
Dict.fromArray([
|
|
879
|
+
("pluginId", JSON.Encode.string(name)),
|
|
880
|
+
("changeKind", JSON.Encode.string(changeKind)),
|
|
881
|
+
("manifest", manifest),
|
|
882
|
+
]),
|
|
883
|
+
),
|
|
884
|
+
)
|
|
885
|
+
let manifestJson = (m: Reventless.Plugin.uiFragmentManifest) =>
|
|
886
|
+
m->S.reverseConvertToJsonOrThrow(Reventless.Plugin.uiFragmentManifestSchema)
|
|
887
|
+
Bus.subscribeToEvents(pluginEventTopicKey, async (_service, _meta, eventJson) => {
|
|
888
|
+
switch eventJson->S.convertOrThrow(ReventlessCore.PluginSpec.eventSchema) {
|
|
889
|
+
| VersionConnected(def)
|
|
890
|
+
| VersionActivated(def)
|
|
891
|
+
| VersionPromoted(def) =>
|
|
892
|
+
publishStatus(~name=def.name, ~status="Connected")
|
|
893
|
+
| VersionDisconnected(def) => publishStatus(~name=def.name, ~status="Disconnected")
|
|
894
|
+
| VersionDeactivated(def) => publishStatus(~name=def.name, ~status="Inactive")
|
|
895
|
+
| VersionRetired(def) => publishStatus(~name=def.name, ~status="Retired")
|
|
896
|
+
| UIFragmentRegistered(data) =>
|
|
897
|
+
publishUIFragment(
|
|
898
|
+
~name=ReventlessCore.Plugin.name(data.pluginId),
|
|
899
|
+
~changeKind="Registered",
|
|
900
|
+
~manifest=manifestJson(data.manifest),
|
|
901
|
+
)
|
|
902
|
+
| UIFragmentUpdated(data) =>
|
|
903
|
+
publishUIFragment(
|
|
904
|
+
~name=ReventlessCore.Plugin.name(data.pluginId),
|
|
905
|
+
~changeKind="Updated",
|
|
906
|
+
~manifest=manifestJson(data.newManifest),
|
|
907
|
+
)
|
|
908
|
+
| UIFragmentDeregistered(data) =>
|
|
909
|
+
publishUIFragment(
|
|
910
|
+
~name=ReventlessCore.Plugin.name(data.pluginId),
|
|
911
|
+
~changeKind="Deregistered",
|
|
912
|
+
~manifest=JSON.Encode.null,
|
|
913
|
+
)
|
|
914
|
+
| VersionDetected(_)
|
|
915
|
+
| VersionSuperseded(_)
|
|
916
|
+
| IncompatiblePluginDetected(_) => ()
|
|
917
|
+
| exception _ => ()
|
|
918
|
+
}
|
|
919
|
+
})
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Drive the admin Plugin read models the same way AWS does: build a
|
|
923
|
+
// `pluginDefinition` from the constructed plugin component outputs and dispatch a
|
|
924
|
+
// synthetic `Connect(def)` to LocalPluginAggregate, keyed by the plugin NAME (the
|
|
925
|
+
// Plugin aggregate is name-keyed). The Plugin aggregate emits `VersionConnected`
|
|
926
|
+
// (+ `UIFragmentRegistered` / `VersionSuperseded`) on its event topic; the real
|
|
927
|
+
// PluginsProjection subscription (registered by
|
|
928
|
+
// Admin.construct ~readModels) folds those events into the "Plugins" current view
|
|
929
|
+
// QueryDb store. This replaces the previous
|
|
930
|
+
// direct-write seed.
|
|
931
|
+
//
|
|
932
|
+
// dispatchCommand parks the command until LocalPluginAggregate's handler is
|
|
933
|
+
// registered (LocalBus pending-queue), so ordering against async wiring is safe.
|
|
934
|
+
// The aggregate's emitted events feed the read-model EventCollectors via the bus;
|
|
935
|
+
// by the time projection handlers are live the parked command drains.
|
|
936
|
+
let connectPlugin = (~pluginComponents: array<ReventlessCore.Plugin.component>) => {
|
|
937
|
+
pluginComponents->Array.forEach(plugin => {
|
|
938
|
+
let outputs: ReventlessInfra.Plugin.outputs = plugin->ReventlessCore.Component.outputs
|
|
939
|
+
let _ =
|
|
940
|
+
(
|
|
941
|
+
outputs.id,
|
|
942
|
+
outputs.version,
|
|
943
|
+
outputs.eventCollector,
|
|
944
|
+
outputs.extensionPoints,
|
|
945
|
+
outputs.extensions,
|
|
946
|
+
outputs.apiSchemaFragment,
|
|
947
|
+
outputs.uiFragments,
|
|
948
|
+
outputs.pluginStructure,
|
|
949
|
+
)
|
|
950
|
+
->Pulumi.Output.all8
|
|
951
|
+
->Pulumi.Output.apply(((
|
|
952
|
+
id,
|
|
953
|
+
version,
|
|
954
|
+
eventCollector,
|
|
955
|
+
extensionPoints,
|
|
956
|
+
extensions,
|
|
957
|
+
apiSchemaFragment,
|
|
958
|
+
uiFragments,
|
|
959
|
+
pluginStructure,
|
|
960
|
+
)) => {
|
|
961
|
+
let pluginName = id->String.split("@")->Array.get(0)->Option.getOr(id)
|
|
962
|
+
let pluginDefinition: Reventless.Plugin.pluginDefinition = {
|
|
963
|
+
id,
|
|
964
|
+
name: pluginName,
|
|
965
|
+
version,
|
|
966
|
+
eventCollector: eventCollector.name,
|
|
967
|
+
extensionPoints: extensionPoints
|
|
968
|
+
->Dict.toArray
|
|
969
|
+
->Array.map(
|
|
970
|
+
((epName, ep: ReventlessInfra.ExtensionPoint.outputs)) => {
|
|
971
|
+
Reventless.Plugin.name: epName,
|
|
972
|
+
commandTopic: epName,
|
|
973
|
+
eventTopic: ep.name,
|
|
974
|
+
},
|
|
975
|
+
),
|
|
976
|
+
extensions: extensions
|
|
977
|
+
->Dict.toArray
|
|
978
|
+
->Array.map(
|
|
979
|
+
((_, ext: ReventlessInfra.Extension.outputs)) => {
|
|
980
|
+
Reventless.Plugin.name: ext.name,
|
|
981
|
+
extensionPointName: ext.extensionPointName,
|
|
982
|
+
dcbSources: [],
|
|
983
|
+
},
|
|
984
|
+
),
|
|
985
|
+
extensionProtocols: [],
|
|
986
|
+
apiSchemaFragment,
|
|
987
|
+
apiTarget: None,
|
|
988
|
+
uiFragments,
|
|
989
|
+
structure: pluginStructure,
|
|
990
|
+
dcbEventLog: None,
|
|
991
|
+
}
|
|
992
|
+
// Dispatch Connect(def) to the Plugin aggregate (id = plugin NAME).
|
|
993
|
+
let _ = dispatchPluginCommand(
|
|
994
|
+
~pluginName,
|
|
995
|
+
~command=ReventlessCore.PluginSpec.Connect(pluginDefinition),
|
|
996
|
+
)
|
|
997
|
+
})
|
|
998
|
+
})
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Seed the UIFragmentRegistry QueryDb from plugin outputs that carry a uiFragments manifest.
|
|
1002
|
+
let seedUIFragmentRegistryQueryDb = (~pluginComponents: array<ReventlessCore.Plugin.component>) => {
|
|
1003
|
+
let uiFragmentOps = ensureUIFragmentRegistryQueryDbStore()
|
|
1004
|
+
pluginComponents->Array.forEach(plugin => {
|
|
1005
|
+
let outputs: ReventlessInfra.Plugin.outputs = plugin->ReventlessCore.Component.outputs
|
|
1006
|
+
let _ =
|
|
1007
|
+
(outputs.id, outputs.uiFragments)
|
|
1008
|
+
->Pulumi.Output.all2
|
|
1009
|
+
->Pulumi.Output.apply(((id, uiFragments)) => {
|
|
1010
|
+
switch uiFragments {
|
|
1011
|
+
| Some(manifest) =>
|
|
1012
|
+
let state: ReventlessCore.UIFragmentRegistryReadModelSpec.state = {
|
|
1013
|
+
pluginId: id,
|
|
1014
|
+
remoteEntryUrl: manifest.remoteEntryUrl,
|
|
1015
|
+
panels: manifest.panels,
|
|
1016
|
+
pages: manifest.pages,
|
|
1017
|
+
registeredAt: Date.make()->Date.toISOString,
|
|
1018
|
+
updatedAt: Date.make()->Date.toISOString,
|
|
1019
|
+
}
|
|
1020
|
+
let entry =
|
|
1021
|
+
state->S.reverseConvertToJsonOrThrow(
|
|
1022
|
+
ReventlessCore.UIFragmentRegistryReadModelSpec.stateSchema,
|
|
1023
|
+
)
|
|
1024
|
+
let _ = uiFragmentOps.save(id, entry, Any, None)
|
|
1025
|
+
| None => ()
|
|
1026
|
+
}
|
|
1027
|
+
})
|
|
1028
|
+
})
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Extract extension wiring metadata from a plugin's outputs.
|
|
1032
|
+
// Returns an Output so the caller can chain on the resolved value.
|
|
1033
|
+
let extractExtensionWirings = (
|
|
1034
|
+
pluginName: string,
|
|
1035
|
+
pluginVersion: string,
|
|
1036
|
+
pluginOutputs: ReventlessInfra.Plugin.outputs,
|
|
1037
|
+
): Pulumi.Output.t<array<ReventlessCore.Plugin_Helpers.extensionWiring>> => {
|
|
1038
|
+
pluginOutputs.extensions->Pulumi.Output.apply(exts =>
|
|
1039
|
+
exts
|
|
1040
|
+
->Dict.valuesToArray
|
|
1041
|
+
->Array.map((ext: ReventlessInfra.Extension.outputs) => {
|
|
1042
|
+
let providerPlugin =
|
|
1043
|
+
ext.extensionPointName->String.split(".")->Array.getUnsafe(0)
|
|
1044
|
+
let wiring: ReventlessCore.Plugin_Helpers.extensionWiring = {
|
|
1045
|
+
extensionName: ext.name,
|
|
1046
|
+
extensionPointName: ext.extensionPointName,
|
|
1047
|
+
providerPlugin,
|
|
1048
|
+
providerVersion: "",
|
|
1049
|
+
subscriberPlugin: pluginName,
|
|
1050
|
+
subscriberVersion: pluginVersion,
|
|
1051
|
+
}
|
|
1052
|
+
wiring
|
|
1053
|
+
})
|
|
1054
|
+
)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Fire onPluginDeployed hooks for each plugin that was built.
|
|
1058
|
+
let firePluginDeployedHooks = (
|
|
1059
|
+
~builtInfos: array<ReventlessCore.Plugin_Helpers.pluginBuiltInfo>,
|
|
1060
|
+
~pluginOutputs=?,
|
|
1061
|
+
) => {
|
|
1062
|
+
let environment = Pulumi.Pulumi.getStackName()
|
|
1063
|
+
builtInfos->Array.forEachWithIndex((info, i) => {
|
|
1064
|
+
switch ReventlessCore.Plugin_Helpers.onPluginDeployedHook.contents {
|
|
1065
|
+
| Some(hook) =>
|
|
1066
|
+
let wiringsOutput = switch pluginOutputs->Option.flatMap(arr => arr->Array.get(i)) {
|
|
1067
|
+
| Some(outputs) => extractExtensionWirings(info.name, info.version, outputs)
|
|
1068
|
+
| None => Pulumi.Output.make([])
|
|
1069
|
+
}
|
|
1070
|
+
let _ = wiringsOutput->Pulumi.Output.apply(wirings => {
|
|
1071
|
+
let deployedInfo: ReventlessCore.Plugin_Helpers.pluginDeployedInfo = {
|
|
1072
|
+
name: info.name,
|
|
1073
|
+
version: info.version,
|
|
1074
|
+
environment,
|
|
1075
|
+
stackName: environment,
|
|
1076
|
+
deployedAt: Date.make()->Date.toISOString,
|
|
1077
|
+
actor: "local",
|
|
1078
|
+
deploymentId: Date.make()->Date.toISOString,
|
|
1079
|
+
kind: ?info.kind,
|
|
1080
|
+
displayName: ?info.displayName,
|
|
1081
|
+
vendor: ?info.vendor,
|
|
1082
|
+
architectureType: ?info.architectureType,
|
|
1083
|
+
components: info.components->Array.map(
|
|
1084
|
+
(c): ReventlessCore.Plugin_Helpers.pluginDeployedComponent => {
|
|
1085
|
+
name: c.name,
|
|
1086
|
+
kind: c.kind,
|
|
1087
|
+
schema: c.schema,
|
|
1088
|
+
resources: [],
|
|
1089
|
+
subComponents: [],
|
|
1090
|
+
},
|
|
1091
|
+
),
|
|
1092
|
+
extensionWirings: wirings,
|
|
1093
|
+
}
|
|
1094
|
+
let _ = hook(deployedInfo)
|
|
1095
|
+
})
|
|
1096
|
+
| None => ()
|
|
1097
|
+
}
|
|
1098
|
+
})
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Start all servers. In split mode this is deferred — called explicitly by the
|
|
1102
|
+
// caller after all makePlatform/deployPlugin calls are complete. In unified mode
|
|
1103
|
+
// this is a no-op (start() is called inline inside makePlatform/deployPlugin).
|
|
1104
|
+
let startServers = () => {
|
|
1105
|
+
if Config.splitApi {
|
|
1106
|
+
// Hydrate Login store from .reventless/users.yaml (or ~users/~usersFile
|
|
1107
|
+
// if provided programmatically) before the Domain server accepts
|
|
1108
|
+
// requests. Unified mode does this in makePlatform; split mode defers
|
|
1109
|
+
// server startup to here so it must do the same.
|
|
1110
|
+
UserStore.autoLoadOnce()
|
|
1111
|
+
DomainGraphQL_Server.start(~port=domainPort, ())
|
|
1112
|
+
DomainMCP_Server.start(~port=domainMcpPort, ())
|
|
1113
|
+
PlatformGraphQL_Server.start(
|
|
1114
|
+
~port=platformPort,
|
|
1115
|
+
~contextFactory=Auth_GraphqlContext.buildAuthContext,
|
|
1116
|
+
(),
|
|
1117
|
+
)
|
|
1118
|
+
PlatformMCP_Server.start(~port=platformMcpPort, ())
|
|
1119
|
+
}
|
|
1120
|
+
if graphqlDebug {
|
|
1121
|
+
DomainGraphQL_Server.printDiagnostics()
|
|
1122
|
+
DomainMCP_Server.printDiagnostics()
|
|
1123
|
+
if Config.splitApi {
|
|
1124
|
+
PlatformGraphQL_Server.printDiagnostics()
|
|
1125
|
+
PlatformMCP_Server.printDiagnostics()
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
// Fire onPlatformDeployed after all servers are started so late-deployed
|
|
1129
|
+
// plugins (e.g. PlatformInspector) have their handler refs populated.
|
|
1130
|
+
ReventlessCore.Plugin_Helpers.firePlatformDeployedHook({
|
|
1131
|
+
name: "local",
|
|
1132
|
+
environment: Pulumi.Pulumi.getStackName(),
|
|
1133
|
+
region: "local",
|
|
1134
|
+
domainApiEndpoint: "http://localhost:4000/graphql",
|
|
1135
|
+
domainApiRoleArn: "local",
|
|
1136
|
+
platformApiEndpoint: if Config.splitApi {
|
|
1137
|
+
"http://localhost:4001/graphql"
|
|
1138
|
+
} else {
|
|
1139
|
+
"http://localhost:4000/graphql"
|
|
1140
|
+
},
|
|
1141
|
+
platformApiRoleArn: "local",
|
|
1142
|
+
adminResources: [],
|
|
1143
|
+
})
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// -- Admin mutation helpers --------------------------------------------------
|
|
1147
|
+
// Transform SDL mutation fields so their return type is CommandResult! instead
|
|
1148
|
+
// of the String! placeholder that GraphQL_FragmentGenerator emits.
|
|
1149
|
+
let adminMutationSdl = (mutations: array<string>): array<string> =>
|
|
1150
|
+
mutations->Array.map(field => {
|
|
1151
|
+
let suffix = ": String!"
|
|
1152
|
+
let n = field->String.length - suffix->String.length
|
|
1153
|
+
if n > 0 && field->String.endsWith(suffix) {
|
|
1154
|
+
field->String.slice(~start=0, ~end=n) ++ ": CommandResult!"
|
|
1155
|
+
} else {
|
|
1156
|
+
field
|
|
1157
|
+
}
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
// Build a CommandAccepted JSON object for admin mutation responses.
|
|
1161
|
+
let commandAccepted = (~msgId, ~entityId=?) =>
|
|
1162
|
+
Dict.fromArray([
|
|
1163
|
+
("__typename", JSON.Encode.string("CommandAccepted")),
|
|
1164
|
+
("msgId", JSON.Encode.string(msgId)),
|
|
1165
|
+
("entityId", entityId->Option.map(JSON.Encode.string)->Option.getOr(JSON.Encode.null)),
|
|
1166
|
+
("eventCount", JSON.Encode.int(0)),
|
|
1167
|
+
])->JSON.Encode.object
|
|
1168
|
+
|
|
1169
|
+
// Build a Relay connection JSON object for admin list query responses.
|
|
1170
|
+
let connectionResponse = (items: array<JSON.t>): JSON.t => {
|
|
1171
|
+
let edges =
|
|
1172
|
+
items->Array.mapWithIndex((item, i) =>
|
|
1173
|
+
Dict.fromArray([("node", item), ("cursor", Int.toString(i)->JSON.Encode.string)])->JSON.Encode.object
|
|
1174
|
+
)
|
|
1175
|
+
let hasItems = edges->Array.length > 0
|
|
1176
|
+
Dict.fromArray([
|
|
1177
|
+
("edges", edges->JSON.Encode.array),
|
|
1178
|
+
(
|
|
1179
|
+
"pageInfo",
|
|
1180
|
+
Dict.fromArray([
|
|
1181
|
+
("hasNextPage", JSON.Encode.bool(false)),
|
|
1182
|
+
("hasPreviousPage", JSON.Encode.bool(false)),
|
|
1183
|
+
("startCursor", hasItems ? JSON.Encode.string("0") : JSON.Encode.null),
|
|
1184
|
+
(
|
|
1185
|
+
"endCursor",
|
|
1186
|
+
hasItems ? JSON.Encode.string(Int.toString(edges->Array.length - 1)) : JSON.Encode.null,
|
|
1187
|
+
),
|
|
1188
|
+
])->JSON.Encode.object,
|
|
1189
|
+
),
|
|
1190
|
+
])->JSON.Encode.object
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Generic admin composite-key / index query resolvers. `GraphQL_FragmentGenerator`
|
|
1194
|
+
// now emits `<single>Items` (sub-id) and `<single>By<Index>` (GSI) query fields
|
|
1195
|
+
// for any admin read model whose spec declares a `subIdConfig` / `config.indexes`
|
|
1196
|
+
// (see PluginBaseFragment.res — closing the admin/ordinary parity gap). Register
|
|
1197
|
+
// the matching resolvers here, driven off the same `PluginBaseFragment.queryEntries`
|
|
1198
|
+
// the SDL is generated from, so the local GraphQL surface stays in lockstep with
|
|
1199
|
+
// the SDL instead of positional hand-wiring. Purely additive — `single` / `list`
|
|
1200
|
+
// keep their per-entry handling registered above. `~live` toggles real partition
|
|
1201
|
+
// loads (plugin path) vs empty stubs (platform-only path with no QueryDb seeded).
|
|
1202
|
+
let registerAdminItemsAndIndexResolvers = (~queryResolvers, ~live: bool) =>
|
|
1203
|
+
ReventlessCore.PluginBaseFragment.queryEntries->Array.forEach(entry => {
|
|
1204
|
+
let queryDbName = entry.specName->Option.getOr(entry.returnTypeName)
|
|
1205
|
+
// `<single>Items(id, …)` — all rows under one partition (composite key).
|
|
1206
|
+
switch entry.subIdField {
|
|
1207
|
+
| Some(_) =>
|
|
1208
|
+
queryResolvers->Dict.set(
|
|
1209
|
+
entry.singleFieldName ++ "Items",
|
|
1210
|
+
async (_root, args, _ctx): JSON.t =>
|
|
1211
|
+
if !live {
|
|
1212
|
+
connectionResponse([])
|
|
1213
|
+
} else {
|
|
1214
|
+
let id =
|
|
1215
|
+
args
|
|
1216
|
+
->JSON.Decode.object
|
|
1217
|
+
->Option.flatMap(d => d->Dict.get("id"))
|
|
1218
|
+
->Option.flatMap(JSON.Decode.string)
|
|
1219
|
+
->Option.getOr("")
|
|
1220
|
+
let items = switch Bus.getQueryDb(queryDbName) {
|
|
1221
|
+
| Some(ops) =>
|
|
1222
|
+
await ops.loadStream(id)
|
|
1223
|
+
->Stream.runCollect
|
|
1224
|
+
->Effect.catchAll(_ => Effect.succeed([]))
|
|
1225
|
+
->Effect.runPromise
|
|
1226
|
+
| None => []
|
|
1227
|
+
}
|
|
1228
|
+
connectionResponse(items)
|
|
1229
|
+
},
|
|
1230
|
+
)
|
|
1231
|
+
| None => ()
|
|
1232
|
+
}
|
|
1233
|
+
// `<single>By<Index>` — no admin read model declares an `@index` yet, so this
|
|
1234
|
+
// branch is currently inert. The empty-connection resolver keeps the SDL field
|
|
1235
|
+
// (when the first admin GSI is added, Part 2b) from being resolver-less; the
|
|
1236
|
+
// indexed scan is fleshed out together with that first concrete index.
|
|
1237
|
+
switch entry.indexQueries {
|
|
1238
|
+
| Some(indexes) =>
|
|
1239
|
+
indexes->Array.forEach((ic: Reventless.ReadModel.indexConfig) => {
|
|
1240
|
+
let stripped =
|
|
1241
|
+
ic.index->String.startsWith("by") && ic.index->String.length > 2
|
|
1242
|
+
? ic.index->String.slice(~start=2, ~end=ic.index->String.length)
|
|
1243
|
+
: ic.index
|
|
1244
|
+
let fieldName = entry.singleFieldName ++ "By" ++ stripped->String.capitalize
|
|
1245
|
+
queryResolvers->Dict.set(fieldName, async (_root, _args, _ctx): JSON.t =>
|
|
1246
|
+
connectionResponse([])
|
|
1247
|
+
)
|
|
1248
|
+
})
|
|
1249
|
+
| None => ()
|
|
1250
|
+
}
|
|
1251
|
+
})
|
|
1252
|
+
|
|
1253
|
+
let makePlatform = (~version, ~plugins: array<module(PluginMaker)>) => {
|
|
1254
|
+
log.info(~comp="Platform", `v${version}`)
|
|
1255
|
+
log.info(
|
|
1256
|
+
~comp="Platform",
|
|
1257
|
+
`silent: ${Config.silent->Bool.toString}, splitApi: ${Config.splitApi->Bool.toString}, cloner: ${Config.cloner->Bool.toString}`,
|
|
1258
|
+
)
|
|
1259
|
+
log.info(
|
|
1260
|
+
~comp="Platform",
|
|
1261
|
+
switch Config.backend {
|
|
1262
|
+
| Backend.Memory => "backend: memory"
|
|
1263
|
+
| Backend.Sqlite({path, resetOnStart}) =>
|
|
1264
|
+
`backend: sqlite (${path}${resetOnStart ? ", resetOnStart" : ""})`
|
|
1265
|
+
},
|
|
1266
|
+
)
|
|
1267
|
+
// Create scheduler and populate platform context refs.
|
|
1268
|
+
let scheduler = makeScheduler()
|
|
1269
|
+
hooks.scheduler := Some(scheduler)
|
|
1270
|
+
hooks.api := Some({val: ()->Obj.magic})
|
|
1271
|
+
hooks.apiRole := Some({val: ()->Obj.magic})
|
|
1272
|
+
|
|
1273
|
+
let admin = Admin.construct(
|
|
1274
|
+
~version,
|
|
1275
|
+
~extensionPoints=[],
|
|
1276
|
+
~aggregates=[module(LocalPluginAggregate)],
|
|
1277
|
+
~readModels=[module(PluginsReadModel)],
|
|
1278
|
+
~scheduler,
|
|
1279
|
+
~resourceNaming=LocalPluginSpec.resourceNaming,
|
|
1280
|
+
~api=(),
|
|
1281
|
+
~apiRole=(),
|
|
1282
|
+
~stateChangeSlices=[],
|
|
1283
|
+
~stateViewSlices=[],
|
|
1284
|
+
~automationSlices=[],
|
|
1285
|
+
~outboundTranslationSlices=[],
|
|
1286
|
+
~inboundTranslationSlices=[],
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
// Share admin extension points with Plugin_Builder via the hooks ref.
|
|
1290
|
+
hooks.adminExtensionPoints :=
|
|
1291
|
+
admin.extensionPointsOutputs->Pulumi.Output.apply(eps =>
|
|
1292
|
+
eps->Array.map(ep => (ep.name, ep))->Dict.fromArray
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
// Intercept onPluginBuiltHook to collect per-plugin metadata synchronously.
|
|
1296
|
+
// The hook fires inside Component.make (synchronous) so we get a complete
|
|
1297
|
+
// list before the async Output.apply callbacks run.
|
|
1298
|
+
// We chain the existing hook (SdkService registration) so it still fires.
|
|
1299
|
+
let environment = Pulumi.Pulumi.getStackName()
|
|
1300
|
+
let existingBuiltHook = ReventlessCore.Plugin_Helpers.onPluginBuiltHook.contents
|
|
1301
|
+
let builtInfos: ref<array<ReventlessCore.Plugin_Helpers.pluginBuiltInfo>> = ref([])
|
|
1302
|
+
ReventlessCore.Plugin_Helpers.registerOnPluginBuilt(info => {
|
|
1303
|
+
existingBuiltHook->Option.forEach(h => h(info))
|
|
1304
|
+
builtInfos.contents->Array.push(info)
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
// Build each plugin.
|
|
1308
|
+
let plugins = plugins->Array.map(plugin => {
|
|
1309
|
+
module P = unpack(plugin)
|
|
1310
|
+
P.make()
|
|
1311
|
+
})
|
|
1312
|
+
|
|
1313
|
+
// Restore original hook and fire deployed hooks synchronously.
|
|
1314
|
+
// receiveRegistry entries are pre-populated (queuing forwarder) so calls
|
|
1315
|
+
// are parked and drained once bindReceive fires (async, via Output.apply).
|
|
1316
|
+
ReventlessCore.Plugin_Helpers.onPluginBuiltHook.contents = existingBuiltHook
|
|
1317
|
+
let allPluginOutputs = plugins->Array.map(p =>
|
|
1318
|
+
(ReventlessCore.Component.outputs(p): ReventlessInfra.Plugin.outputs)
|
|
1319
|
+
)
|
|
1320
|
+
firePluginDeployedHooks(~builtInfos=builtInfos.contents, ~pluginOutputs=allPluginOutputs)
|
|
1321
|
+
|
|
1322
|
+
// Wire cross-plugin Extension → EP EventTopic subscriptions.
|
|
1323
|
+
// Each plugin's Extension lists an extensionPointName; the matching EP EventTopic bus key
|
|
1324
|
+
// is derived deterministically. Bus.subscribeEventCollectorToTopic defers if the
|
|
1325
|
+
// EventCollector handler hasn't resolved yet (it fires after ~2 microtask ticks).
|
|
1326
|
+
plugins->Array.forEach(plugin => {
|
|
1327
|
+
let outputs: ReventlessInfra.Plugin.outputs = plugin->ReventlessCore.Component.outputs
|
|
1328
|
+
let _ =
|
|
1329
|
+
(outputs.eventCollector, outputs.extensions)
|
|
1330
|
+
->Pulumi.Output.all2
|
|
1331
|
+
->Pulumi.Output.apply(((eventCollector, extensions)) => {
|
|
1332
|
+
extensions->Dict.toArray->Array.forEach(((_, ext: ReventlessInfra.Extension.outputs)) => {
|
|
1333
|
+
let epTopicKey =
|
|
1334
|
+
ext.extensionPointName->String.replace(".", "") ++ "ExtPointEventTopic"
|
|
1335
|
+
Bus.subscribeEventCollectorToTopic(eventCollector.name, epTopicKey)
|
|
1336
|
+
})
|
|
1337
|
+
})
|
|
1338
|
+
})
|
|
1339
|
+
ReventlessCore.Plugin_Helpers.firePlatformDeployedHook({
|
|
1340
|
+
name: "local",
|
|
1341
|
+
environment,
|
|
1342
|
+
region: "local",
|
|
1343
|
+
domainApiEndpoint: "http://localhost:4000/graphql",
|
|
1344
|
+
domainApiRoleArn: "local",
|
|
1345
|
+
platformApiEndpoint: "http://localhost:4000/graphql",
|
|
1346
|
+
platformApiRoleArn: "local",
|
|
1347
|
+
adminResources: [],
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
// Subscribe to the Plugin aggregate event topic (Source-C live-update emission)
|
|
1351
|
+
// BEFORE dispatching Connect — LocalBus drops events with no subscribers.
|
|
1352
|
+
subscribeToPluginEvents()
|
|
1353
|
+
// Drive the admin Plugin read models via synthetic Connect dispatch through
|
|
1354
|
+
// LocalPluginAggregate (replaces the old direct-write seed). The real
|
|
1355
|
+
// PluginsProjection folds the emitted events into the "Plugins" QueryDb store.
|
|
1356
|
+
connectPlugin(~pluginComponents=plugins)
|
|
1357
|
+
seedUIFragmentRegistryQueryDb(~pluginComponents=plugins)
|
|
1358
|
+
seedPluginStructuresStore(~pluginComponents=plugins)
|
|
1359
|
+
|
|
1360
|
+
// Seed the built-in admin plugin's structure so its Auto UI (Plugin list with
|
|
1361
|
+
// Activate/Deactivate buttons) renders alongside the user plugins. Admin.construct
|
|
1362
|
+
// does not flow through pluginStructure outputs, so we register the synthetic
|
|
1363
|
+
// structure manually.
|
|
1364
|
+
pluginStructuresStore.contents->Dict.set(
|
|
1365
|
+
ReventlessCore.Platform_Admin_Structure.pluginId,
|
|
1366
|
+
ReventlessCore.Platform_Admin_Structure.structure,
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
let pluginQueryDbName = ReventlessCore.PluginsReadModelSpec.name
|
|
1370
|
+
let uiFragmentQueryDbName = ReventlessCore.UIFragmentRegistryReadModelSpec.name
|
|
1371
|
+
|
|
1372
|
+
// Wire the per-mutation plugin-status gate (Part 2.3 of the resolver plan).
|
|
1373
|
+
// Resolves at call-time so updates to Plugin RM status are reflected immediately.
|
|
1374
|
+
// Admin mutations (Platform_*) bypass the gate — the built-in admin plugin can't
|
|
1375
|
+
// be deactivated and its mutations have a separate admin-group SDL directive on
|
|
1376
|
+
// AWS. The first underscore-delimited segment of the mutation name is the owning
|
|
1377
|
+
// plugin's Spec name.
|
|
1378
|
+
//
|
|
1379
|
+
// Error codes follow the three-tier model in docs/analysis/plugin-lifecycle-tiers.md:
|
|
1380
|
+
// `Disconnected` → `PluginUnavailable` (retryable; tier 1)
|
|
1381
|
+
// `Inactive` → `PluginInactive` (admin-controlled; tier 2)
|
|
1382
|
+
CommandGeneratorResolvers_GraphQL.setPluginStatusGate(field => {
|
|
1383
|
+
let parts = field->String.split("_")
|
|
1384
|
+
let pluginPrefix = parts->Array.get(0)->Option.getOr("")
|
|
1385
|
+
if pluginPrefix === "Platform" {
|
|
1386
|
+
None
|
|
1387
|
+
} else {
|
|
1388
|
+
let statusByName = Dict.make()
|
|
1389
|
+
switch Bus.getQueryDbScan(pluginQueryDbName) {
|
|
1390
|
+
| Some(scanAll) =>
|
|
1391
|
+
scanAll()->Array.forEach(json =>
|
|
1392
|
+
switch json->S.convertOrThrow(ReventlessCore.PluginsReadModelSpec.stateSchema) {
|
|
1393
|
+
| state => statusByName->Dict.set(state.name, state.status)
|
|
1394
|
+
| exception _ => ()
|
|
1395
|
+
}
|
|
1396
|
+
)
|
|
1397
|
+
| None => ()
|
|
1398
|
+
}
|
|
1399
|
+
switch statusByName->Dict.get(pluginPrefix) {
|
|
1400
|
+
| Some(Connected) => None
|
|
1401
|
+
| Some(Disconnected) => Some(("PluginUnavailable", "plugin is disconnected"))
|
|
1402
|
+
| Some(Inactive) => Some(("PluginInactive", "plugin is inactive"))
|
|
1403
|
+
| Some(Retired) => Some(("PluginRetired", "plugin version has been retired"))
|
|
1404
|
+
| None => None
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
})
|
|
1408
|
+
|
|
1409
|
+
// Set deploy target to Platform for admin schema registration.
|
|
1410
|
+
// resolveTargetGraphQL/MCP() will return PlatformGraphQL_Server / PlatformMCP_Server
|
|
1411
|
+
// in split mode, or DomainGraphQL_Server / DomainMCP_Server in unified mode.
|
|
1412
|
+
currentDeployTarget.contents = Platform
|
|
1413
|
+
|
|
1414
|
+
// Derive field names from the schema entries — single source of truth.
|
|
1415
|
+
let adminQueryEntry = ReventlessCore.PluginBaseFragment.queryEntries->Array.getUnsafe(0)
|
|
1416
|
+
let singleQueryField = adminQueryEntry.singleFieldName
|
|
1417
|
+
let listQueryField = adminQueryEntry.listFieldName
|
|
1418
|
+
let adminMutationEntries = ReventlessCore.AdminApi.mutationEntries(~cloner=Config.cloner)
|
|
1419
|
+
let adminMutationFieldNames = adminMutationEntries->Array.flatMap(entry => entry.fieldNames)
|
|
1420
|
+
|
|
1421
|
+
// Register the admin Plugin aggregate's types/queries/mutations to the platform target.
|
|
1422
|
+
let platformGraphQL = resolveTargetGraphQL()
|
|
1423
|
+
let platformMCP = resolveTargetMCP()
|
|
1424
|
+
let baseParts = ReventlessCore.GraphQL_Stitcher.decode(
|
|
1425
|
+
ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
|
|
1426
|
+
)
|
|
1427
|
+
platformGraphQL.registerTypes(~sdlTypes=baseParts.types)
|
|
1428
|
+
|
|
1429
|
+
// Resolvers for the admin Plugin queries — backed by Bus Plugin QueryDb.
|
|
1430
|
+
let queryResolvers = Dict.make()
|
|
1431
|
+
queryResolvers->Dict.set(singleQueryField, async (_root, args, _ctx): JSON.t => {
|
|
1432
|
+
let id =
|
|
1433
|
+
args
|
|
1434
|
+
->JSON.Decode.object
|
|
1435
|
+
->Option.flatMap(d => d->Dict.get("id"))
|
|
1436
|
+
->Option.flatMap(JSON.Decode.string)
|
|
1437
|
+
->Option.getOr("")
|
|
1438
|
+
switch Bus.getQueryDb(pluginQueryDbName) {
|
|
1439
|
+
| Some(ops) =>
|
|
1440
|
+
let items = await ops.loadStream(id)
|
|
1441
|
+
->Stream.runCollect
|
|
1442
|
+
->Effect.catchAll(_ => Effect.succeed([]))
|
|
1443
|
+
->Effect.runPromise
|
|
1444
|
+
items->Array.get(0)->Option.getOr(JSON.Encode.null)
|
|
1445
|
+
| None => JSON.Encode.null
|
|
1446
|
+
}
|
|
1447
|
+
})
|
|
1448
|
+
queryResolvers->Dict.set(listQueryField, async (_root, _args, _ctx): JSON.t => {
|
|
1449
|
+
let items = switch Bus.getQueryDbScan(pluginQueryDbName) {
|
|
1450
|
+
| Some(scanAll) => scanAll()
|
|
1451
|
+
| None => []
|
|
1452
|
+
}
|
|
1453
|
+
connectionResponse(items)
|
|
1454
|
+
})
|
|
1455
|
+
// Platform_ComponentDefinitions resolver — SDL is already stitched into baseParts via
|
|
1456
|
+
// AdminApi.baseFragment so we register only the resolver here. Encoder is shared
|
|
1457
|
+
// with the AWS adapter so responses are byte-identical.
|
|
1458
|
+
//
|
|
1459
|
+
// Filter by plugin lifecycle status to mirror the AWS DynamoDB filter in
|
|
1460
|
+
// Platform_ComponentDefinitions_Lambda.res (`contains(#status, :connected)`). Plugins
|
|
1461
|
+
// whose Plugin RM row is not Connected are hidden until reactivated. The built-in
|
|
1462
|
+
// Platform_Admin entry has no Plugin RM row — include it unconditionally since
|
|
1463
|
+
// it can't be deactivated.
|
|
1464
|
+
queryResolvers->Dict.set(
|
|
1465
|
+
"Platform_ComponentDefinitions",
|
|
1466
|
+
async (_root, _args, _ctx): JSON.t => {
|
|
1467
|
+
let pluginStatusById = {
|
|
1468
|
+
let dict = Dict.make()
|
|
1469
|
+
switch Bus.getQueryDbScan(pluginQueryDbName) {
|
|
1470
|
+
| Some(scanAll) =>
|
|
1471
|
+
scanAll()->Array.forEach(json =>
|
|
1472
|
+
switch json->S.convertOrThrow(ReventlessCore.PluginsReadModelSpec.stateSchema) {
|
|
1473
|
+
| state =>
|
|
1474
|
+
let id = state.name ++ "@" ++ state.version
|
|
1475
|
+
dict->Dict.set(id, state.status)
|
|
1476
|
+
| exception _ => ()
|
|
1477
|
+
}
|
|
1478
|
+
)
|
|
1479
|
+
| None => ()
|
|
1480
|
+
}
|
|
1481
|
+
dict
|
|
1482
|
+
}
|
|
1483
|
+
let adminPluginId = ReventlessCore.Platform_Admin_Structure.pluginId
|
|
1484
|
+
// One entry per plugin name: among Connected versions keep the highest
|
|
1485
|
+
// version, enforcing the deploy-time one-version-per-plugin invariant.
|
|
1486
|
+
// Without this, a lingering Connected older version (rolling deploy or a
|
|
1487
|
+
// missed retire) surfaces a full duplicate set of AutoUI menu entries —
|
|
1488
|
+
// mirrors the dedup in Platform_ComponentDefinitions_Lambda.res.
|
|
1489
|
+
let latestByName = Dict.make()
|
|
1490
|
+
pluginStructuresStore.contents
|
|
1491
|
+
->Dict.toArray
|
|
1492
|
+
->Array.forEach(((pluginId, def)) => {
|
|
1493
|
+
let connected =
|
|
1494
|
+
pluginId === adminPluginId ||
|
|
1495
|
+
switch pluginStatusById->Dict.get(pluginId) {
|
|
1496
|
+
| Some(Connected) => true
|
|
1497
|
+
| _ => false
|
|
1498
|
+
}
|
|
1499
|
+
if connected {
|
|
1500
|
+
let name = ReventlessCore.Plugin.name(pluginId)
|
|
1501
|
+
let version = ReventlessCore.Plugin.version(pluginId)
|
|
1502
|
+
switch latestByName->Dict.get(name) {
|
|
1503
|
+
| Some((prevVersion, _)) =>
|
|
1504
|
+
if ReventlessCore.Plugin.compareVersions(version, prevVersion) > 0 {
|
|
1505
|
+
latestByName->Dict.set(name, (version, (pluginId, def)))
|
|
1506
|
+
}
|
|
1507
|
+
| None => latestByName->Dict.set(name, (version, (pluginId, def)))
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
})
|
|
1511
|
+
latestByName
|
|
1512
|
+
->Dict.valuesToArray
|
|
1513
|
+
->Array.map(((_, (pluginId, def))) =>
|
|
1514
|
+
ReventlessCore.Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId, def)
|
|
1515
|
+
)
|
|
1516
|
+
->JSON.Encode.array
|
|
1517
|
+
},
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
// Platform_UIFragments resolver — reads the seeded UIFragmentRegistry QueryDb
|
|
1521
|
+
// (same store the admin UIFragment / UIFragments queries scan) and encodes via
|
|
1522
|
+
// the shared Platform_UIFragmentsApi encoder so AWS and in-memory return the
|
|
1523
|
+
// same JSON shape.
|
|
1524
|
+
queryResolvers->Dict.set(
|
|
1525
|
+
"Platform_UIFragments",
|
|
1526
|
+
async (_root, _args, _ctx): JSON.t => {
|
|
1527
|
+
let items = switch Bus.getQueryDbScan(uiFragmentQueryDbName) {
|
|
1528
|
+
| Some(scanAll) => scanAll()
|
|
1529
|
+
| None => []
|
|
1530
|
+
}
|
|
1531
|
+
// Collapse to one entry per plugin name (highest version). UIFragmentRegistry
|
|
1532
|
+
// accumulates a row per deployed version and has no lifecycle status of its
|
|
1533
|
+
// own, so without this a redeployed federation plugin surfaces duplicate
|
|
1534
|
+
// fragments — mirrors the dedup in Platform_UIFragments_Lambda.res.
|
|
1535
|
+
let latestByName = Dict.make()
|
|
1536
|
+
items->Array.forEach(item =>
|
|
1537
|
+
switch item->S.parseOrThrow(
|
|
1538
|
+
ReventlessCore.UIFragmentRegistryReadModelSpec.stateSchema,
|
|
1539
|
+
) {
|
|
1540
|
+
| state =>
|
|
1541
|
+
let name = ReventlessCore.Plugin.name(state.pluginId)
|
|
1542
|
+
let version = ReventlessCore.Plugin.version(state.pluginId)
|
|
1543
|
+
switch latestByName->Dict.get(name) {
|
|
1544
|
+
| Some((prevVersion, _)) =>
|
|
1545
|
+
if ReventlessCore.Plugin.compareVersions(version, prevVersion) > 0 {
|
|
1546
|
+
latestByName->Dict.set(name, (version, state))
|
|
1547
|
+
}
|
|
1548
|
+
| None => latestByName->Dict.set(name, (version, state))
|
|
1549
|
+
}
|
|
1550
|
+
| exception _ => ()
|
|
1551
|
+
}
|
|
1552
|
+
)
|
|
1553
|
+
latestByName
|
|
1554
|
+
->Dict.valuesToArray
|
|
1555
|
+
->Array.map(((_, state)) =>
|
|
1556
|
+
ReventlessCore.Platform_UIFragmentsApi.encodeUIFragmentEntry(state)
|
|
1557
|
+
)
|
|
1558
|
+
->JSON.Encode.array
|
|
1559
|
+
},
|
|
1560
|
+
)
|
|
1561
|
+
|
|
1562
|
+
// Composite-key / index admin query fields. Live loads from the seeded QueryDb stores.
|
|
1563
|
+
registerAdminItemsAndIndexResolvers(~queryResolvers, ~live=true)
|
|
1564
|
+
|
|
1565
|
+
platformGraphQL.registerQueries(~sdlFields=baseParts.queries, ~resolvers=queryResolvers)
|
|
1566
|
+
|
|
1567
|
+
// Admin Plugin lifecycle mutations (Activate / Deactivate / Retire) now flow
|
|
1568
|
+
// through the LocalPluginAggregate, mirroring AWS. The resolver looks up the
|
|
1569
|
+
// plugin's current version from the "Plugins" read model (the aggregate is
|
|
1570
|
+
// name-keyed but Activate/Deactivate/Retire target a specific version), then
|
|
1571
|
+
// dispatches the matching command. The aggregate emits VersionActivated /
|
|
1572
|
+
// VersionDeactivated / VersionRetired; PluginsProjection folds the new status
|
|
1573
|
+
// into the store and subscribeToPluginEvents fans onPluginStatusChange out to
|
|
1574
|
+
// live subscribers — so no direct QueryDb write or inline publish here.
|
|
1575
|
+
let argId = (args: JSON.t) =>
|
|
1576
|
+
args
|
|
1577
|
+
->JSON.Decode.object
|
|
1578
|
+
->Option.flatMap(d => d->Dict.get("id"))
|
|
1579
|
+
->Option.flatMap(JSON.Decode.string)
|
|
1580
|
+
->Option.getOr("")
|
|
1581
|
+
// Resolve the plugin's current version from its Plugins read-model row.
|
|
1582
|
+
let currentVersionOf = (pluginName: string): option<string> =>
|
|
1583
|
+
switch Bus.getQueryDbScan(pluginQueryDbName) {
|
|
1584
|
+
| Some(scanAll) =>
|
|
1585
|
+
scanAll()->Array.findMap(json =>
|
|
1586
|
+
switch json->S.convertOrThrow(ReventlessCore.PluginsReadModelSpec.stateSchema) {
|
|
1587
|
+
| state if state.name == pluginName => Some(state.version)
|
|
1588
|
+
| _ => None
|
|
1589
|
+
| exception _ => None
|
|
1590
|
+
}
|
|
1591
|
+
)
|
|
1592
|
+
| None => None
|
|
1593
|
+
}
|
|
1594
|
+
// The admin Plugin lifecycle SDL is `<field>(id: ID!, _0: String!)` — `id` is the
|
|
1595
|
+
// plugin name (aggregate id) and `_0` is the target version (the command payload,
|
|
1596
|
+
// e.g. `Activate(version)`). Use the caller-supplied `_0` when present; fall back
|
|
1597
|
+
// to the plugin's current version from the Plugins read model for callers that
|
|
1598
|
+
// omit it.
|
|
1599
|
+
let argVersion = (args: JSON.t) =>
|
|
1600
|
+
args
|
|
1601
|
+
->JSON.Decode.object
|
|
1602
|
+
->Option.flatMap(d => d->Dict.get("_0"))
|
|
1603
|
+
->Option.flatMap(JSON.Decode.string)
|
|
1604
|
+
let dispatchLifecycle = (
|
|
1605
|
+
~field: string,
|
|
1606
|
+
args: JSON.t,
|
|
1607
|
+
makeCommand: string => ReventlessCore.PluginSpec.command,
|
|
1608
|
+
): JSON.t => {
|
|
1609
|
+
let msgId = ReventlessCore.Message.uuid()
|
|
1610
|
+
let pluginName = argId(args)
|
|
1611
|
+
let version = switch argVersion(args) {
|
|
1612
|
+
| Some(v) if v != "" => Some(v)
|
|
1613
|
+
| _ => currentVersionOf(pluginName)
|
|
1614
|
+
}
|
|
1615
|
+
switch version {
|
|
1616
|
+
| Some(version) =>
|
|
1617
|
+
log.info(~comp="Admin", `${field}(${pluginName}@${version}): dispatching to aggregate`)
|
|
1618
|
+
let _ = dispatchPluginCommand(~pluginName, ~command=makeCommand(version))
|
|
1619
|
+
| None => log.warn(~comp="Admin", `${field}(${pluginName}): plugin not found`)
|
|
1620
|
+
}
|
|
1621
|
+
commandAccepted(~msgId, ~entityId=pluginName)
|
|
1622
|
+
}
|
|
1623
|
+
let activateField = ReventlessCore.Api_Naming.adminField(~name="Plugin_Activate")
|
|
1624
|
+
let deactivateField = ReventlessCore.Api_Naming.adminField(~name="Plugin_Deactivate")
|
|
1625
|
+
let retireField = ReventlessCore.Api_Naming.adminField(~name="Plugin_Retire")
|
|
1626
|
+
let mutationResolvers = Dict.make()
|
|
1627
|
+
mutationResolvers->Dict.set(activateField, async (_root, args, _ctx): JSON.t =>
|
|
1628
|
+
dispatchLifecycle(~field=activateField, args, v => ReventlessCore.PluginSpec.Activate(v))
|
|
1629
|
+
)
|
|
1630
|
+
mutationResolvers->Dict.set(deactivateField, async (_root, args, _ctx): JSON.t =>
|
|
1631
|
+
dispatchLifecycle(~field=deactivateField, args, v => ReventlessCore.PluginSpec.Deactivate(v))
|
|
1632
|
+
)
|
|
1633
|
+
mutationResolvers->Dict.set(retireField, async (_root, args, _ctx): JSON.t =>
|
|
1634
|
+
dispatchLifecycle(~field=retireField, args, v => ReventlessCore.PluginSpec.Retire(v))
|
|
1635
|
+
)
|
|
1636
|
+
// Remaining admin mutations (e.g., Clone) are no-ops in-memory.
|
|
1637
|
+
adminMutationFieldNames->Array.forEach(field =>
|
|
1638
|
+
if mutationResolvers->Dict.get(field)->Option.isNone {
|
|
1639
|
+
mutationResolvers->Dict.set(field, async (_root, _args, _ctx): JSON.t =>
|
|
1640
|
+
commandAccepted(~msgId=ReventlessCore.Message.uuid())
|
|
1641
|
+
)
|
|
1642
|
+
}
|
|
1643
|
+
)
|
|
1644
|
+
// UIFragment admin mutations — explicit admin-triggered fragment changes still
|
|
1645
|
+
// publish onUIFragmentChange directly (aggregate-driven fragment changes are
|
|
1646
|
+
// emitted by subscribeToPluginEvents). uiFragmentSubTopic is the shared topic.
|
|
1647
|
+
let makeUIEvent = (~pluginId, ~changeKind, ~manifest) =>
|
|
1648
|
+
JSON.Encode.object(
|
|
1649
|
+
Dict.fromArray([
|
|
1650
|
+
("pluginId", JSON.Encode.string(ReventlessCore.Plugin.name(pluginId))),
|
|
1651
|
+
("changeKind", JSON.Encode.string(changeKind)),
|
|
1652
|
+
("manifest", manifest),
|
|
1653
|
+
])
|
|
1654
|
+
)
|
|
1655
|
+
let addUIFragmentMutation = (fieldName, changeKind) =>
|
|
1656
|
+
mutationResolvers->Dict.set(fieldName, async (_root, args, _ctx): JSON.t => {
|
|
1657
|
+
let obj = args->JSON.Decode.object->Option.getOr(Dict.make())
|
|
1658
|
+
let pluginId =
|
|
1659
|
+
obj->Dict.get("pluginId")->Option.flatMap(JSON.Decode.string)->Option.getOr("")
|
|
1660
|
+
let manifest = obj->Dict.get("manifest")->Option.getOr(JSON.Encode.null)
|
|
1661
|
+
let event = makeUIEvent(~pluginId, ~changeKind, ~manifest)
|
|
1662
|
+
LocalGraphQL_SubscriptionResolvers.publish(uiFragmentSubTopic, event)
|
|
1663
|
+
event
|
|
1664
|
+
})
|
|
1665
|
+
addUIFragmentMutation(
|
|
1666
|
+
ReventlessCore.Api_Naming.adminField(~name="UIFragmentRegistered"),
|
|
1667
|
+
"Registered",
|
|
1668
|
+
)
|
|
1669
|
+
addUIFragmentMutation(
|
|
1670
|
+
ReventlessCore.Api_Naming.adminField(~name="UIFragmentUpdated"),
|
|
1671
|
+
"Updated",
|
|
1672
|
+
)
|
|
1673
|
+
addUIFragmentMutation(
|
|
1674
|
+
ReventlessCore.Api_Naming.adminField(~name="UIFragmentDeregistered"),
|
|
1675
|
+
"Deregistered",
|
|
1676
|
+
)
|
|
1677
|
+
CommandGeneratorResolvers_GraphQL.ensureCommandResultTypes(platformGraphQL)
|
|
1678
|
+
platformGraphQL.registerMutations(
|
|
1679
|
+
~sdlFields=adminMutationSdl(baseParts.mutations),
|
|
1680
|
+
~resolvers=mutationResolvers,
|
|
1681
|
+
)
|
|
1682
|
+
adminRegisteredServers.contents->Array.push(platformGraphQL)
|
|
1683
|
+
// Register onUIFragmentChange + onPluginStatusChange subscriptions (Source C).
|
|
1684
|
+
platformGraphQL.registerSubscriptions(
|
|
1685
|
+
~sdlFields=[
|
|
1686
|
+
" onUIFragmentChange: UIFragmentChangeEvent",
|
|
1687
|
+
" onPluginStatusChange: PluginStatusChangeEvent",
|
|
1688
|
+
],
|
|
1689
|
+
~resolvers=Dict.fromArray([
|
|
1690
|
+
(
|
|
1691
|
+
"onUIFragmentChange",
|
|
1692
|
+
LocalGraphQL_SubscriptionResolvers.makeFieldResolver(uiFragmentSubTopic),
|
|
1693
|
+
),
|
|
1694
|
+
(
|
|
1695
|
+
"onPluginStatusChange",
|
|
1696
|
+
LocalGraphQL_SubscriptionResolvers.makeFieldResolver(pluginStatusSubTopic),
|
|
1697
|
+
),
|
|
1698
|
+
]),
|
|
1699
|
+
)
|
|
1700
|
+
|
|
1701
|
+
// Register admin queries and mutations as MCP resources and tools.
|
|
1702
|
+
// Route by field name: Plugin → pluginQueryDbName. UIFragments has its
|
|
1703
|
+
// own explicit `Platform_UIFragments` flat-array resolver registered
|
|
1704
|
+
// above; it isn't exposed as an MCP resource because the auto-generated
|
|
1705
|
+
// single/list field shape was removed when the auto-generated query
|
|
1706
|
+
// entry was dropped in favour of that explicit field.
|
|
1707
|
+
let adminFieldToQueryDb = Dict.fromArray([
|
|
1708
|
+
(singleQueryField, pluginQueryDbName),
|
|
1709
|
+
(listQueryField, pluginQueryDbName),
|
|
1710
|
+
])
|
|
1711
|
+
platformMCP.registerResourcesFromEntries(
|
|
1712
|
+
~pluginName="Admin",
|
|
1713
|
+
~queryEntries=ReventlessCore.PluginBaseFragment.queryEntries,
|
|
1714
|
+
~queryHandler=async (resourceName, uri) => {
|
|
1715
|
+
let segments = uri->String.split("/")
|
|
1716
|
+
let id = segments->Array.at(-1)->Option.getOr("")
|
|
1717
|
+
let queryDbName =
|
|
1718
|
+
adminFieldToQueryDb->Dict.get(resourceName)->Option.getOr(pluginQueryDbName)
|
|
1719
|
+
switch Bus.getQueryDb(queryDbName) {
|
|
1720
|
+
| Some(ops) =>
|
|
1721
|
+
if id->String.length > 0 && id != resourceName {
|
|
1722
|
+
let items = await ops.loadStream(id)
|
|
1723
|
+
->Stream.runCollect
|
|
1724
|
+
->Effect.catchAll(_ => Effect.succeed([]))
|
|
1725
|
+
->Effect.runPromise
|
|
1726
|
+
items->Array.get(0)->Option.getOr(JSON.Encode.null)
|
|
1727
|
+
} else {
|
|
1728
|
+
switch Bus.getQueryDbScan(queryDbName) {
|
|
1729
|
+
| Some(scanAll) => scanAll()->JSON.Encode.array
|
|
1730
|
+
| None => []->JSON.Encode.array
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
| None => JSON.Encode.null
|
|
1734
|
+
}
|
|
1735
|
+
},
|
|
1736
|
+
)
|
|
1737
|
+
|
|
1738
|
+
// Register admin mutations as MCP tools using the same entry-based path as plugins.
|
|
1739
|
+
platformMCP.registerToolsFromEntries(
|
|
1740
|
+
~pluginName="Admin",
|
|
1741
|
+
~mutationEntries=adminMutationEntries,
|
|
1742
|
+
~commandHandler=async (toolName, args, _identity) => {
|
|
1743
|
+
switch platformGraphQL.getMutationResolver(toolName) {
|
|
1744
|
+
| Some(resolver) =>
|
|
1745
|
+
let result = await resolver(JSON.Encode.null, args, JSON.Encode.null)
|
|
1746
|
+
switch result->JSON.Decode.string {
|
|
1747
|
+
| Some(s) => s
|
|
1748
|
+
| None => result->JSON.stringify
|
|
1749
|
+
}
|
|
1750
|
+
| None => `error: no handler found for tool ${toolName}`
|
|
1751
|
+
}
|
|
1752
|
+
},
|
|
1753
|
+
)
|
|
1754
|
+
|
|
1755
|
+
// Always inject Relay base types (Node interface, PageInfo) and the node
|
|
1756
|
+
// query field into the domain GraphQL server so that:
|
|
1757
|
+
// 1. `type X implements Node { ... }` fragments compile (Node interface must exist)
|
|
1758
|
+
// 2. The node resolver registered by QueryDbResolvers_GraphQL.res has a
|
|
1759
|
+
// matching `node(id: ID!): Node` field in the Query type.
|
|
1760
|
+
DomainGraphQL_Server.registerTypes(~sdlTypes=ReventlessCore.GraphQL_Stitcher.relayBaseTypes)
|
|
1761
|
+
DomainGraphQL_Server.registerQueries(
|
|
1762
|
+
~sdlFields=ReventlessCore.GraphQL_Stitcher.relayBaseQueries,
|
|
1763
|
+
~resolvers=Dict.make(),
|
|
1764
|
+
)
|
|
1765
|
+
// In split mode, inject Relay base types (Node interface, PageInfo) into the platform
|
|
1766
|
+
// server so SDL fragments compile. The node(id) query is Domain-only — the Platform
|
|
1767
|
+
// API is consumed by admin tools and agents, not Relay clients.
|
|
1768
|
+
if Config.splitApi {
|
|
1769
|
+
PlatformGraphQL_Server.registerTypes(~sdlTypes=ReventlessCore.GraphQL_Stitcher.relayBaseTypes)
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// Reset deploy target to Domain after admin registration is done.
|
|
1773
|
+
currentDeployTarget.contents = Domain
|
|
1774
|
+
|
|
1775
|
+
// In split mode, start() is deferred to startServers() so all plugins
|
|
1776
|
+
// (domain and platform) can register their schema first.
|
|
1777
|
+
// In unified mode, start immediately (backwards-compatible).
|
|
1778
|
+
if !Config.splitApi {
|
|
1779
|
+
UserStore.autoLoadOnce()
|
|
1780
|
+
DomainGraphQL_Server.start()
|
|
1781
|
+
DomainMCP_Server.start()
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// In-memory ignores `~hostUiBundle` — the host shell is served by `vite dev`
|
|
1787
|
+
// against the running in-process GraphQL server, not from a CDN.
|
|
1788
|
+
type hostUiBundleConfig = {
|
|
1789
|
+
assetsDir: string,
|
|
1790
|
+
bundleVersion: string,
|
|
1791
|
+
}
|
|
1792
|
+
let deployPlatform = (~version, ~hostUiBundle as _: option<hostUiBundleConfig>=?) => {
|
|
1793
|
+
log.info(~comp="Platform", `deployPlatform v${version}`)
|
|
1794
|
+
let scheduler = makeScheduler()
|
|
1795
|
+
hooks.scheduler := Some(scheduler)
|
|
1796
|
+
hooks.api := Some({val: ()->Obj.magic})
|
|
1797
|
+
hooks.apiRole := Some({val: ()->Obj.magic})
|
|
1798
|
+
let _admin = Admin.construct(
|
|
1799
|
+
~version,
|
|
1800
|
+
~extensionPoints=[],
|
|
1801
|
+
~aggregates=[],
|
|
1802
|
+
~readModels=[],
|
|
1803
|
+
~scheduler,
|
|
1804
|
+
~resourceNaming=LocalPluginSpec.resourceNaming,
|
|
1805
|
+
~api=(),
|
|
1806
|
+
~apiRole=(),
|
|
1807
|
+
~stateChangeSlices=[],
|
|
1808
|
+
~stateViewSlices=[],
|
|
1809
|
+
~automationSlices=[],
|
|
1810
|
+
~outboundTranslationSlices=[],
|
|
1811
|
+
~inboundTranslationSlices=[],
|
|
1812
|
+
)
|
|
1813
|
+
|
|
1814
|
+
// Route admin schema to PlatformGraphQL_Server in split mode, DomainGraphQL_Server otherwise.
|
|
1815
|
+
currentDeployTarget.contents = Platform
|
|
1816
|
+
let adminGraphQL = resolveTargetGraphQL()
|
|
1817
|
+
let adminMCP = resolveTargetMCP()
|
|
1818
|
+
|
|
1819
|
+
let baseParts = ReventlessCore.GraphQL_Stitcher.decode(
|
|
1820
|
+
ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
|
|
1821
|
+
)
|
|
1822
|
+
adminGraphQL.registerTypes(~sdlTypes=baseParts.types)
|
|
1823
|
+
|
|
1824
|
+
// Minimal admin query resolvers (no plugin QueryDb seeding needed for platform-only).
|
|
1825
|
+
let queryResolvers = Dict.make()
|
|
1826
|
+
let adminQueryEntry = ReventlessCore.PluginBaseFragment.queryEntries->Array.getUnsafe(0)
|
|
1827
|
+
queryResolvers->Dict.set(adminQueryEntry.singleFieldName, async (_root, _args, _ctx): JSON.t =>
|
|
1828
|
+
JSON.Encode.null
|
|
1829
|
+
)
|
|
1830
|
+
queryResolvers->Dict.set(adminQueryEntry.listFieldName, async (_root, _args, _ctx): JSON.t =>
|
|
1831
|
+
connectionResponse([])
|
|
1832
|
+
)
|
|
1833
|
+
// Platform_UIFragments — empty in the platform-only path (no plugins
|
|
1834
|
+
// connected → no UI fragments registered). The SDL declares it as a
|
|
1835
|
+
// non-null array so we must register a resolver returning [].
|
|
1836
|
+
queryResolvers->Dict.set(
|
|
1837
|
+
"Platform_UIFragments",
|
|
1838
|
+
async (_root, _args, _ctx): JSON.t => JSON.Encode.array([]),
|
|
1839
|
+
)
|
|
1840
|
+
// Composite-key / index admin query fields — empty stubs in the platform-only
|
|
1841
|
+
// path (no plugins connected → no QueryDb seeded).
|
|
1842
|
+
registerAdminItemsAndIndexResolvers(~queryResolvers, ~live=false)
|
|
1843
|
+
adminGraphQL.registerQueries(~sdlFields=baseParts.queries, ~resolvers=queryResolvers)
|
|
1844
|
+
|
|
1845
|
+
let mutationResolvers = Dict.make()
|
|
1846
|
+
let adminMutationEntries = ReventlessCore.AdminApi.mutationEntries(~cloner=Config.cloner)
|
|
1847
|
+
let adminMutationFieldNames = adminMutationEntries->Array.flatMap(entry => entry.fieldNames)
|
|
1848
|
+
adminMutationFieldNames->Array.forEach(field =>
|
|
1849
|
+
mutationResolvers->Dict.set(field, async (_root, _args, _ctx): JSON.t =>
|
|
1850
|
+
commandAccepted(~msgId=ReventlessCore.Message.uuid())
|
|
1851
|
+
)
|
|
1852
|
+
)
|
|
1853
|
+
let dpSubTopic = "onUIFragmentChange"
|
|
1854
|
+
let addUIFragmentMutation2 = (fieldName, changeKind) =>
|
|
1855
|
+
mutationResolvers->Dict.set(fieldName, async (_root, args, _ctx): JSON.t => {
|
|
1856
|
+
let obj = args->JSON.Decode.object->Option.getOr(Dict.make())
|
|
1857
|
+
let pluginId =
|
|
1858
|
+
obj->Dict.get("pluginId")->Option.flatMap(JSON.Decode.string)->Option.getOr("")
|
|
1859
|
+
let manifest = obj->Dict.get("manifest")->Option.getOr(JSON.Encode.null)
|
|
1860
|
+
let event = JSON.Encode.object(
|
|
1861
|
+
Dict.fromArray([
|
|
1862
|
+
("pluginId", JSON.Encode.string(ReventlessCore.Plugin.name(pluginId))),
|
|
1863
|
+
("changeKind", JSON.Encode.string(changeKind)),
|
|
1864
|
+
("manifest", manifest),
|
|
1865
|
+
]),
|
|
1866
|
+
)
|
|
1867
|
+
LocalGraphQL_SubscriptionResolvers.publish(dpSubTopic, event)
|
|
1868
|
+
event
|
|
1869
|
+
})
|
|
1870
|
+
addUIFragmentMutation2(
|
|
1871
|
+
ReventlessCore.Api_Naming.adminField(~name="UIFragmentRegistered"),
|
|
1872
|
+
"Registered",
|
|
1873
|
+
)
|
|
1874
|
+
addUIFragmentMutation2(
|
|
1875
|
+
ReventlessCore.Api_Naming.adminField(~name="UIFragmentUpdated"),
|
|
1876
|
+
"Updated",
|
|
1877
|
+
)
|
|
1878
|
+
addUIFragmentMutation2(
|
|
1879
|
+
ReventlessCore.Api_Naming.adminField(~name="UIFragmentDeregistered"),
|
|
1880
|
+
"Deregistered",
|
|
1881
|
+
)
|
|
1882
|
+
CommandGeneratorResolvers_GraphQL.ensureCommandResultTypes(adminGraphQL)
|
|
1883
|
+
adminGraphQL.registerMutations(~sdlFields=adminMutationSdl(baseParts.mutations), ~resolvers=mutationResolvers)
|
|
1884
|
+
adminGraphQL.registerSubscriptions(
|
|
1885
|
+
~sdlFields=[
|
|
1886
|
+
" onUIFragmentChange: UIFragmentChangeEvent",
|
|
1887
|
+
" onPluginStatusChange: PluginStatusChangeEvent",
|
|
1888
|
+
],
|
|
1889
|
+
~resolvers=Dict.fromArray([
|
|
1890
|
+
(
|
|
1891
|
+
"onUIFragmentChange",
|
|
1892
|
+
LocalGraphQL_SubscriptionResolvers.makeFieldResolver(dpSubTopic),
|
|
1893
|
+
),
|
|
1894
|
+
(
|
|
1895
|
+
"onPluginStatusChange",
|
|
1896
|
+
LocalGraphQL_SubscriptionResolvers.makeFieldResolver("onPluginStatusChange"),
|
|
1897
|
+
),
|
|
1898
|
+
]),
|
|
1899
|
+
)
|
|
1900
|
+
|
|
1901
|
+
currentDeployTarget.contents = Domain
|
|
1902
|
+
|
|
1903
|
+
// Start servers immediately (deployPlatform is always called standalone).
|
|
1904
|
+
// Wire `Auth_GraphqlContext.buildAuthContext` so the admin yoga server
|
|
1905
|
+
// extracts identity from the bearer token, same as the Domain server.
|
|
1906
|
+
// Without this, Platform_* queries / mutations run as `anonymous` and
|
|
1907
|
+
// skip the group authorization that AppSync would enforce via
|
|
1908
|
+
// `@aws_auth(cognito_groups: ["Admin"])` in production. The Domain
|
|
1909
|
+
// server's `asInterface.start` accepts but ignores `~contextFactory`
|
|
1910
|
+
// because it always wires its own internal auth context.
|
|
1911
|
+
adminGraphQL.start(
|
|
1912
|
+
~port=if Config.splitApi { 4001 } else { 4000 },
|
|
1913
|
+
~contextFactory=Auth_GraphqlContext.buildAuthContext,
|
|
1914
|
+
(),
|
|
1915
|
+
)
|
|
1916
|
+
adminMCP.start(~port=if Config.splitApi { 3002 } else { 3001 }, ())
|
|
1917
|
+
if Config.splitApi {
|
|
1918
|
+
DomainGraphQL_Server.start()
|
|
1919
|
+
DomainMCP_Server.start()
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// Fire onPlatformDeployed hook with local platform metadata.
|
|
1923
|
+
ReventlessCore.Plugin_Helpers.firePlatformDeployedHook({
|
|
1924
|
+
name: "local",
|
|
1925
|
+
environment: "local",
|
|
1926
|
+
region: "local",
|
|
1927
|
+
domainApiEndpoint: "http://localhost:4000/graphql",
|
|
1928
|
+
domainApiRoleArn: "local",
|
|
1929
|
+
platformApiEndpoint: if Config.splitApi {
|
|
1930
|
+
"http://localhost:4001/graphql"
|
|
1931
|
+
} else {
|
|
1932
|
+
"http://localhost:4000/graphql"
|
|
1933
|
+
},
|
|
1934
|
+
platformApiRoleArn: "local",
|
|
1935
|
+
adminResources: [],
|
|
1936
|
+
})
|
|
1937
|
+
Dict.make()
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
let deployPlugin = (~plugin: module(PluginMaker), ~apiTarget=Domain) => {
|
|
1941
|
+
log.info(~comp="Platform", `deployPlugin target=${switch apiTarget { | Domain => "Domain" | Platform => "Platform" }}`)
|
|
1942
|
+
|
|
1943
|
+
// Set the active deploy target so resolveTargetGraphQL/MCP() and QueryDb serverRef/relayRef
|
|
1944
|
+
// route registrations to the correct server. Mirrors AWS resolveTargetApi() pattern.
|
|
1945
|
+
currentDeployTarget.contents = apiTarget
|
|
1946
|
+
// Update QueryDb server/relay refs on the shared StateViewSliceMaker.QueryDbResolvers instance.
|
|
1947
|
+
// Do NOT create a new QueryDbResolvers_GraphQL.Make(Bus) here — that would be a different
|
|
1948
|
+
// module instance from the one StateViewSlice_Builder captured, leaving its refs unchanged.
|
|
1949
|
+
StateViewSliceMaker.QueryDbResolvers.serverRef.contents = resolveTargetGraphQL()
|
|
1950
|
+
StateViewSliceMaker.QueryDbResolvers.relayRef.contents = switch apiTarget {
|
|
1951
|
+
| Domain => Some(domainRelaySupport)
|
|
1952
|
+
| Platform => None
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// Each plugin creates its own scheduler (mirrors AWS behaviour).
|
|
1956
|
+
let scheduler = makeScheduler()
|
|
1957
|
+
hooks.scheduler := Some(scheduler)
|
|
1958
|
+
hooks.api := Some({val: ()->Obj.magic})
|
|
1959
|
+
hooks.apiRole := Some({val: ()->Obj.magic})
|
|
1960
|
+
|
|
1961
|
+
// Admin components are needed in-memory even for single-plugin deploy.
|
|
1962
|
+
// Admin.construct ignores ~version (it's `~version as _` in Platform_Admin)
|
|
1963
|
+
// so the empty placeholder doesn't affect behavior.
|
|
1964
|
+
let admin = Admin.construct(
|
|
1965
|
+
~version="",
|
|
1966
|
+
~extensionPoints=[],
|
|
1967
|
+
~aggregates=[module(LocalPluginAggregate)],
|
|
1968
|
+
~readModels=[module(PluginsReadModel)],
|
|
1969
|
+
~scheduler,
|
|
1970
|
+
~resourceNaming=LocalPluginSpec.resourceNaming,
|
|
1971
|
+
~api=(),
|
|
1972
|
+
~apiRole=(),
|
|
1973
|
+
~stateChangeSlices=[],
|
|
1974
|
+
~stateViewSlices=[],
|
|
1975
|
+
~automationSlices=[],
|
|
1976
|
+
~outboundTranslationSlices=[],
|
|
1977
|
+
~inboundTranslationSlices=[],
|
|
1978
|
+
)
|
|
1979
|
+
hooks.adminExtensionPoints :=
|
|
1980
|
+
admin.extensionPointsOutputs->Pulumi.Output.apply(eps =>
|
|
1981
|
+
eps->Array.map(ep => (ep.name, ep))->Dict.fromArray
|
|
1982
|
+
)
|
|
1983
|
+
|
|
1984
|
+
// Intercept onPluginBuiltHook so we can fire onPluginDeployed after build.
|
|
1985
|
+
let existingBuiltHook = ReventlessCore.Plugin_Helpers.onPluginBuiltHook.contents
|
|
1986
|
+
let builtInfos: ref<array<ReventlessCore.Plugin_Helpers.pluginBuiltInfo>> = ref([])
|
|
1987
|
+
ReventlessCore.Plugin_Helpers.registerOnPluginBuilt(info => {
|
|
1988
|
+
existingBuiltHook->Option.forEach(h => h(info))
|
|
1989
|
+
builtInfos.contents->Array.push(info)
|
|
1990
|
+
})
|
|
1991
|
+
|
|
1992
|
+
module P = unpack(plugin)
|
|
1993
|
+
let pluginComponent = P.make()
|
|
1994
|
+
|
|
1995
|
+
ReventlessCore.Plugin_Helpers.onPluginBuiltHook.contents = existingBuiltHook
|
|
1996
|
+
|
|
1997
|
+
// Register admin schema to the correct target (domain or platform).
|
|
1998
|
+
// Skip if already registered by makePlatform (both target the same server).
|
|
1999
|
+
let adminGraphQL = resolveTargetGraphQL()
|
|
2000
|
+
if !(adminRegisteredServers.contents->Array.some(s => s === adminGraphQL)) {
|
|
2001
|
+
let baseParts = ReventlessCore.GraphQL_Stitcher.decode(
|
|
2002
|
+
ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
|
|
2003
|
+
)
|
|
2004
|
+
adminGraphQL.registerTypes(~sdlTypes=baseParts.types)
|
|
2005
|
+
|
|
2006
|
+
let queryResolvers = Dict.make()
|
|
2007
|
+
let adminQueryEntry = ReventlessCore.PluginBaseFragment.queryEntries->Array.getUnsafe(0)
|
|
2008
|
+
queryResolvers->Dict.set(adminQueryEntry.singleFieldName, async (_root, _args, _ctx): JSON.t =>
|
|
2009
|
+
JSON.Encode.null
|
|
2010
|
+
)
|
|
2011
|
+
queryResolvers->Dict.set(adminQueryEntry.listFieldName, async (_root, _args, _ctx): JSON.t =>
|
|
2012
|
+
connectionResponse([])
|
|
2013
|
+
)
|
|
2014
|
+
// Platform_UIFragments — single-plugin path. Seeded by
|
|
2015
|
+
// seedUIFragmentRegistryQueryDb below if the plugin component carries
|
|
2016
|
+
// a uiFragments manifest, otherwise scans an empty store.
|
|
2017
|
+
queryResolvers->Dict.set(
|
|
2018
|
+
"Platform_UIFragments",
|
|
2019
|
+
async (_root, _args, _ctx): JSON.t => {
|
|
2020
|
+
let items = switch Bus.getQueryDbScan(
|
|
2021
|
+
ReventlessCore.UIFragmentRegistryReadModelSpec.name,
|
|
2022
|
+
) {
|
|
2023
|
+
| Some(scanAll) => scanAll()
|
|
2024
|
+
| None => []
|
|
2025
|
+
}
|
|
2026
|
+
items
|
|
2027
|
+
->Array.filterMap(item =>
|
|
2028
|
+
switch item->S.parseOrThrow(
|
|
2029
|
+
ReventlessCore.UIFragmentRegistryReadModelSpec.stateSchema,
|
|
2030
|
+
) {
|
|
2031
|
+
| state =>
|
|
2032
|
+
Some(ReventlessCore.Platform_UIFragmentsApi.encodeUIFragmentEntry(state))
|
|
2033
|
+
| exception _ => None
|
|
2034
|
+
}
|
|
2035
|
+
)
|
|
2036
|
+
->JSON.Encode.array
|
|
2037
|
+
},
|
|
2038
|
+
)
|
|
2039
|
+
// Platform_ComponentDefinitions resolver — SDL is already stitched into baseParts via
|
|
2040
|
+
// AdminApi.baseFragment so we register only the resolver here. Uses the shared
|
|
2041
|
+
// encoder so the dynamic-plugin admin server emits the same canonical shape as
|
|
2042
|
+
// the main platform server (and as AWS).
|
|
2043
|
+
queryResolvers->Dict.set(
|
|
2044
|
+
"Platform_ComponentDefinitions",
|
|
2045
|
+
async (_root, _args, _ctx): JSON.t =>
|
|
2046
|
+
pluginStructuresStore.contents
|
|
2047
|
+
->Dict.toArray
|
|
2048
|
+
->Array.map(((pluginId, def)) =>
|
|
2049
|
+
ReventlessCore.Platform_ComponentDefinitionsApi.encodePluginStructureEntry(~pluginId, def)
|
|
2050
|
+
)
|
|
2051
|
+
->JSON.Encode.array,
|
|
2052
|
+
)
|
|
2053
|
+
// Composite-key / index admin query fields.
|
|
2054
|
+
// Live loads from the seeded QueryDb stores (empty when not yet seeded).
|
|
2055
|
+
registerAdminItemsAndIndexResolvers(~queryResolvers, ~live=true)
|
|
2056
|
+
adminGraphQL.registerQueries(~sdlFields=baseParts.queries, ~resolvers=queryResolvers)
|
|
2057
|
+
|
|
2058
|
+
let mutationResolvers = Dict.make()
|
|
2059
|
+
let adminMutationEntries = ReventlessCore.AdminApi.mutationEntries(~cloner=Config.cloner)
|
|
2060
|
+
let adminMutationFieldNames = adminMutationEntries->Array.flatMap(entry => entry.fieldNames)
|
|
2061
|
+
adminMutationFieldNames->Array.forEach(field =>
|
|
2062
|
+
mutationResolvers->Dict.set(field, async (_root, _args, _ctx): JSON.t =>
|
|
2063
|
+
commandAccepted(~msgId=ReventlessCore.Message.uuid())
|
|
2064
|
+
)
|
|
2065
|
+
)
|
|
2066
|
+
let dpSubTopic2 = "onUIFragmentChange"
|
|
2067
|
+
let addUIFragmentMutation3 = (fieldName, changeKind) =>
|
|
2068
|
+
mutationResolvers->Dict.set(fieldName, async (_root, args, _ctx): JSON.t => {
|
|
2069
|
+
let obj = args->JSON.Decode.object->Option.getOr(Dict.make())
|
|
2070
|
+
let pluginId =
|
|
2071
|
+
obj->Dict.get("pluginId")->Option.flatMap(JSON.Decode.string)->Option.getOr("")
|
|
2072
|
+
let manifest = obj->Dict.get("manifest")->Option.getOr(JSON.Encode.null)
|
|
2073
|
+
let event = JSON.Encode.object(
|
|
2074
|
+
Dict.fromArray([
|
|
2075
|
+
("pluginId", JSON.Encode.string(ReventlessCore.Plugin.name(pluginId))),
|
|
2076
|
+
("changeKind", JSON.Encode.string(changeKind)),
|
|
2077
|
+
("manifest", manifest),
|
|
2078
|
+
]),
|
|
2079
|
+
)
|
|
2080
|
+
LocalGraphQL_SubscriptionResolvers.publish(dpSubTopic2, event)
|
|
2081
|
+
event
|
|
2082
|
+
})
|
|
2083
|
+
addUIFragmentMutation3(
|
|
2084
|
+
ReventlessCore.Api_Naming.adminField(~name="UIFragmentRegistered"),
|
|
2085
|
+
"Registered",
|
|
2086
|
+
)
|
|
2087
|
+
addUIFragmentMutation3(
|
|
2088
|
+
ReventlessCore.Api_Naming.adminField(~name="UIFragmentUpdated"),
|
|
2089
|
+
"Updated",
|
|
2090
|
+
)
|
|
2091
|
+
addUIFragmentMutation3(
|
|
2092
|
+
ReventlessCore.Api_Naming.adminField(~name="UIFragmentDeregistered"),
|
|
2093
|
+
"Deregistered",
|
|
2094
|
+
)
|
|
2095
|
+
CommandGeneratorResolvers_GraphQL.ensureCommandResultTypes(adminGraphQL)
|
|
2096
|
+
adminGraphQL.registerMutations(~sdlFields=adminMutationSdl(baseParts.mutations), ~resolvers=mutationResolvers)
|
|
2097
|
+
adminGraphQL.registerSubscriptions(
|
|
2098
|
+
~sdlFields=[" onUIFragmentChange: UIFragmentChangeEvent"],
|
|
2099
|
+
~resolvers=Dict.fromArray([
|
|
2100
|
+
(
|
|
2101
|
+
"onUIFragmentChange",
|
|
2102
|
+
LocalGraphQL_SubscriptionResolvers.makeFieldResolver(dpSubTopic2),
|
|
2103
|
+
),
|
|
2104
|
+
]),
|
|
2105
|
+
)
|
|
2106
|
+
adminRegisteredServers.contents->Array.push(adminGraphQL)
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// Reset deploy target back to Domain for subsequent calls.
|
|
2110
|
+
currentDeployTarget.contents = Domain
|
|
2111
|
+
StateViewSliceMaker.QueryDbResolvers.serverRef.contents = DomainGraphQL_Server.asInterface
|
|
2112
|
+
StateViewSliceMaker.QueryDbResolvers.relayRef.contents = Some(domainRelaySupport)
|
|
2113
|
+
|
|
2114
|
+
// Subscribe to the Plugin aggregate event topic before dispatching Connect.
|
|
2115
|
+
subscribeToPluginEvents()
|
|
2116
|
+
// Drive the admin Plugin read models via synthetic Connect dispatch through
|
|
2117
|
+
// LocalPluginAggregate (replaces the old direct-write seed).
|
|
2118
|
+
connectPlugin(~pluginComponents=[pluginComponent])
|
|
2119
|
+
seedUIFragmentRegistryQueryDb(~pluginComponents=[pluginComponent])
|
|
2120
|
+
seedPluginStructuresStore(~pluginComponents=[pluginComponent])
|
|
2121
|
+
|
|
2122
|
+
// Fire onPluginDeployed hooks so subscribers learn about this plugin.
|
|
2123
|
+
let deployedPluginOutputs = [(ReventlessCore.Component.outputs(pluginComponent): ReventlessInfra.Plugin.outputs)]
|
|
2124
|
+
firePluginDeployedHooks(~builtInfos=builtInfos.contents, ~pluginOutputs=deployedPluginOutputs)
|
|
2125
|
+
|
|
2126
|
+
// Late-deployed plugins may register an onPlatformDeployed hook that missed
|
|
2127
|
+
// the original firing in makePlatform. Replay the stored info so they
|
|
2128
|
+
// receive the platform metadata they need.
|
|
2129
|
+
ReventlessCore.Plugin_Helpers.replayPlatformDeployedHook()
|
|
2130
|
+
|
|
2131
|
+
// In unified mode, start servers immediately (backwards-compatible).
|
|
2132
|
+
// In split mode, start() is deferred to startServers().
|
|
2133
|
+
if !Config.splitApi {
|
|
2134
|
+
switch apiTarget {
|
|
2135
|
+
| Domain =>
|
|
2136
|
+
DomainGraphQL_Server.start()
|
|
2137
|
+
DomainMCP_Server.start()
|
|
2138
|
+
| Platform =>
|
|
2139
|
+
PlatformGraphQL_Server.start(
|
|
2140
|
+
~port=4001,
|
|
2141
|
+
~contextFactory=Auth_GraphqlContext.buildAuthContext,
|
|
2142
|
+
(),
|
|
2143
|
+
)
|
|
2144
|
+
PlatformMCP_Server.start(~port=3002, ())
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
Dict.make()
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// Default platform — diagnostic warnings enabled, split API (admin on separate ports), no cloner.
|
|
2153
|
+
// Backend defaults to Memory unless REVENTLESS_LOCAL_BACKEND is set.
|
|
2154
|
+
module Make = (): (ReventlessInfra.Platform.T with type api = unit and type role = unit) => {
|
|
2155
|
+
include MakeWithConfig({
|
|
2156
|
+
let silent = false
|
|
2157
|
+
let splitApi = true
|
|
2158
|
+
let cloner = false
|
|
2159
|
+
let backend = Backend.fromEnv()
|
|
2160
|
+
let commandHandlerConfig: ReventlessCore.Runtime.commandHandlerConfigs = {}
|
|
2161
|
+
})
|
|
2162
|
+
}
|