@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.
Files changed (334) hide show
  1. package/CHANGELOG.md +1197 -0
  2. package/LICENSE +202 -0
  3. package/__mocks__/emptyModule.js +2 -0
  4. package/__mocks__/nodeSqlite.mjs +6 -0
  5. package/package.json +67 -0
  6. package/rescript.json +39 -0
  7. package/src/Platform.res +2162 -0
  8. package/src/Platform.res.mjs +3389 -0
  9. package/src/adapter/Api/LocalGraphQL_Adapter.res +29 -0
  10. package/src/adapter/Api/LocalGraphQL_Adapter.res.mjs +26 -0
  11. package/src/adapter/Api/LocalGraphQL_SubscriptionResolvers.res +146 -0
  12. package/src/adapter/Api/LocalGraphQL_SubscriptionResolvers.res.mjs +88 -0
  13. package/src/adapter/Auth/Auth_GraphqlContext.res +83 -0
  14. package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +76 -0
  15. package/src/adapter/Auth/LocalAuth.res +239 -0
  16. package/src/adapter/Auth/LocalAuth.res.mjs +247 -0
  17. package/src/adapter/Auth/UserStore.res +134 -0
  18. package/src/adapter/Auth/UserStore.res.mjs +146 -0
  19. package/src/adapter/Backend.res +46 -0
  20. package/src/adapter/Backend.res.mjs +66 -0
  21. package/src/adapter/BackendState.res +34 -0
  22. package/src/adapter/BackendState.res.mjs +42 -0
  23. package/src/adapter/Cloner/LocalClonerRunner.res +13 -0
  24. package/src/adapter/Cloner/LocalClonerRunner.res.mjs +12 -0
  25. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +329 -0
  26. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +301 -0
  27. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +76 -0
  28. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs +57 -0
  29. package/src/adapter/CommandGenerator/LocalCommandGeneratorResolvers.res +19 -0
  30. package/src/adapter/CommandGenerator/LocalCommandGeneratorResolvers.res.mjs +19 -0
  31. package/src/adapter/CommandTopic/LocalCommandTopicChannel.res +104 -0
  32. package/src/adapter/CommandTopic/LocalCommandTopicChannel.res.mjs +82 -0
  33. package/src/adapter/CommandTopic/LocalCommandTopicRemoteChannel.res +32 -0
  34. package/src/adapter/CommandTopic/LocalCommandTopicRemoteChannel.res.mjs +38 -0
  35. package/src/adapter/Counter/LocalCounterHandler.res +42 -0
  36. package/src/adapter/Counter/LocalCounterHandler.res.mjs +45 -0
  37. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +131 -0
  38. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +140 -0
  39. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +334 -0
  40. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +299 -0
  41. package/src/adapter/DomainGraphQL_Server.res +565 -0
  42. package/src/adapter/DomainGraphQL_Server.res.mjs +576 -0
  43. package/src/adapter/DomainMCP_Server.res +369 -0
  44. package/src/adapter/DomainMCP_Server.res.mjs +358 -0
  45. package/src/adapter/EventCollector/LocalEventCollectorChannel.res +87 -0
  46. package/src/adapter/EventCollector/LocalEventCollectorChannel.res.mjs +45 -0
  47. package/src/adapter/EventLog/EventLogStorage_InMemory.res +105 -0
  48. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +117 -0
  49. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +145 -0
  50. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +155 -0
  51. package/src/adapter/EventTopic/LocalEventTopicPublisher.res +36 -0
  52. package/src/adapter/EventTopic/LocalEventTopicPublisher.res.mjs +31 -0
  53. package/src/adapter/GraphQL_Server.res +6 -0
  54. package/src/adapter/GraphQL_Server.res.mjs +154 -0
  55. package/src/adapter/GraphQL_ServerInstance.res +310 -0
  56. package/src/adapter/GraphQL_ServerInstance.res.mjs +257 -0
  57. package/src/adapter/Heartbeat/LocalHeartbeatRunner.res +57 -0
  58. package/src/adapter/Heartbeat/LocalHeartbeatRunner.res.mjs +34 -0
  59. package/src/adapter/LocalBus.res +498 -0
  60. package/src/adapter/LocalBus.res.mjs +1011 -0
  61. package/src/adapter/LocalPluginSpec.res +16 -0
  62. package/src/adapter/LocalPluginSpec.res.mjs +33 -0
  63. package/src/adapter/LocalSideEffectHandler.res +44 -0
  64. package/src/adapter/LocalSideEffectHandler.res.mjs +34 -0
  65. package/src/adapter/MCP_Server.res +4 -0
  66. package/src/adapter/MCP_Server.res.mjs +67 -0
  67. package/src/adapter/MCP_ServerInstance.res +362 -0
  68. package/src/adapter/MCP_ServerInstance.res.mjs +313 -0
  69. package/src/adapter/PlatformGraphQL_Server.res +75 -0
  70. package/src/adapter/PlatformGraphQL_Server.res.mjs +84 -0
  71. package/src/adapter/PlatformMCP_Server.res +23 -0
  72. package/src/adapter/PlatformMCP_Server.res.mjs +46 -0
  73. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +793 -0
  74. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +569 -0
  75. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +242 -0
  76. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +241 -0
  77. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +339 -0
  78. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +318 -0
  79. package/src/adapter/QueryDb/SortKey_Filter.res +48 -0
  80. package/src/adapter/QueryDb/SortKey_Filter.res.mjs +46 -0
  81. package/src/adapter/QueryEngine/LocalQueryEngine.res +57 -0
  82. package/src/adapter/QueryEngine/LocalQueryEngine.res.mjs +56 -0
  83. package/src/adapter/Runtime/LocalAggregateRuntime_Builder.res +91 -0
  84. package/src/adapter/Runtime/LocalAggregateRuntime_Builder.res.mjs +50 -0
  85. package/src/adapter/Runtime/LocalEventCollectorRuntime_Builder.res +47 -0
  86. package/src/adapter/Runtime/LocalEventCollectorRuntime_Builder.res.mjs +37 -0
  87. package/src/adapter/Runtime/LocalPluginRuntime_Builder.res +11 -0
  88. package/src/adapter/Runtime/LocalPluginRuntime_Builder.res.mjs +30 -0
  89. package/src/adapter/Runtime/LocalRuntimeEnvironment.res +54 -0
  90. package/src/adapter/Runtime/LocalRuntimeEnvironment.res.mjs +40 -0
  91. package/src/adapter/Scheduler/LocalScheduledPublisher.res +102 -0
  92. package/src/adapter/Scheduler/LocalScheduledPublisher.res.mjs +84 -0
  93. package/src/adapter/SqliteDriver.res +52 -0
  94. package/src/adapter/SqliteDriver.res.mjs +76 -0
  95. package/src/adapter/SqliteDriver.resi +31 -0
  96. package/src/adapter/Task/LocalTaskBucket.res +75 -0
  97. package/src/adapter/Task/LocalTaskBucket.res.mjs +48 -0
  98. package/src/adapter/Task/TaskBucket_Sqlite.res +47 -0
  99. package/src/adapter/Task/TaskBucket_Sqlite.res.mjs +40 -0
  100. package/src/components/Aggregate_Builder.res +55 -0
  101. package/src/components/Aggregate_Builder.res.mjs +140 -0
  102. package/src/components/AutomationSlice_Builder.res +41 -0
  103. package/src/components/AutomationSlice_Builder.res.mjs +57 -0
  104. package/src/components/Counter_Builder.res +14 -0
  105. package/src/components/Counter_Builder.res.mjs +32 -0
  106. package/src/components/DcbEventLog_Builder.res +18 -0
  107. package/src/components/DcbEventLog_Builder.res.mjs +24 -0
  108. package/src/components/ExtensionPoint_Builder.res +25 -0
  109. package/src/components/ExtensionPoint_Builder.res.mjs +44 -0
  110. package/src/components/InboundTranslationSlice_Builder.res +34 -0
  111. package/src/components/InboundTranslationSlice_Builder.res.mjs +45 -0
  112. package/src/components/OutboundTranslationSlice_Builder.res +43 -0
  113. package/src/components/OutboundTranslationSlice_Builder.res.mjs +59 -0
  114. package/src/components/Plugin_Builder.res +37 -0
  115. package/src/components/Plugin_Builder.res.mjs +67 -0
  116. package/src/components/ReadModel_Builder.res +47 -0
  117. package/src/components/ReadModel_Builder.res.mjs +54 -0
  118. package/src/components/StateChangeSlice_Builder.res +19 -0
  119. package/src/components/StateChangeSlice_Builder.res.mjs +21 -0
  120. package/src/components/StateViewSlice_Builder.res +42 -0
  121. package/src/components/StateViewSlice_Builder.res.mjs +58 -0
  122. package/src/components/Task_Builder.res +28 -0
  123. package/src/components/Task_Builder.res.mjs +41 -0
  124. package/src/reexport/CommandTopic.res +3 -0
  125. package/src/reexport/CommandTopic.res.mjs +55 -0
  126. package/src/test/Mocks/MockDcbEventLogStorage.res +136 -0
  127. package/src/test/Mocks/MockDcbEventLogStorage.res.mjs +146 -0
  128. package/src/test/Mocks/MockEventLogStorage.res +57 -0
  129. package/src/test/Mocks/MockEventLogStorage.res.mjs +65 -0
  130. package/src/test/Mocks/MockPublisher.res +29 -0
  131. package/src/test/Mocks/MockPublisher.res.mjs +28 -0
  132. package/src/test/Mocks/MockQueryDbStorage.res +104 -0
  133. package/src/test/Mocks/MockQueryDbStorage.res.mjs +136 -0
  134. package/src/test/TestRunner.res +96 -0
  135. package/src/test/TestRunner.res.mjs +71 -0
  136. package/tests/SplitApiFixtures.res +84 -0
  137. package/tests/SplitApiFixtures.res.mjs +88 -0
  138. package/tests/SplitApiTest.res +102 -0
  139. package/tests/SplitApiTest.res.mjs +82 -0
  140. package/tests/TestFixtures.res +54 -0
  141. package/tests/TestFixtures.res.mjs +65 -0
  142. package/tests/adapter/BackendParityTest.res +94 -0
  143. package/tests/adapter/BackendParityTest.res.mjs +96 -0
  144. package/tests/adapter/CollectNEventsTest.res +82 -0
  145. package/tests/adapter/CollectNEventsTest.res.mjs +83 -0
  146. package/tests/adapter/CommandAuthorizationTest.res +200 -0
  147. package/tests/adapter/CommandAuthorizationTest.res.mjs +190 -0
  148. package/tests/adapter/CommandTopicChannelTest.res +174 -0
  149. package/tests/adapter/CommandTopicChannelTest.res.mjs +170 -0
  150. package/tests/adapter/CounterHandlerTest.res +113 -0
  151. package/tests/adapter/CounterHandlerTest.res.mjs +96 -0
  152. package/tests/adapter/DcbEventLogStorageSqliteTest.res +157 -0
  153. package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +165 -0
  154. package/tests/adapter/DcbEventLogStorageTest.res +187 -0
  155. package/tests/adapter/DcbEventLogStorageTest.res.mjs +195 -0
  156. package/tests/adapter/EventCollectorChannelTest.res +166 -0
  157. package/tests/adapter/EventCollectorChannelTest.res.mjs +178 -0
  158. package/tests/adapter/EventLogStorageSqliteTest.res +105 -0
  159. package/tests/adapter/EventLogStorageSqliteTest.res.mjs +116 -0
  160. package/tests/adapter/EventTapTest.res +102 -0
  161. package/tests/adapter/EventTapTest.res.mjs +94 -0
  162. package/tests/adapter/EventTopicPublisherTest.res +76 -0
  163. package/tests/adapter/EventTopicPublisherTest.res.mjs +86 -0
  164. package/tests/adapter/GraphQL_SchemaInspectorTest.res +763 -0
  165. package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +578 -0
  166. package/tests/adapter/GraphQL_SubscriptionResolversTest.res +161 -0
  167. package/tests/adapter/GraphQL_SubscriptionResolversTest.res.mjs +134 -0
  168. package/tests/adapter/HeartbeatRunnerTest.res +136 -0
  169. package/tests/adapter/HeartbeatRunnerTest.res.mjs +96 -0
  170. package/tests/adapter/LocalAuthHttpTest.res +224 -0
  171. package/tests/adapter/LocalAuthHttpTest.res.mjs +237 -0
  172. package/tests/adapter/LocalAuthLoginTest.res +180 -0
  173. package/tests/adapter/LocalAuthLoginTest.res.mjs +207 -0
  174. package/tests/adapter/LocalAuthTest.res +106 -0
  175. package/tests/adapter/LocalAuthTest.res.mjs +145 -0
  176. package/tests/adapter/LocalAuthUserStoreTest.res +172 -0
  177. package/tests/adapter/LocalAuthUserStoreTest.res.mjs +208 -0
  178. package/tests/adapter/LocalBusBoundedTest.res +157 -0
  179. package/tests/adapter/LocalBusBoundedTest.res.mjs +168 -0
  180. package/tests/adapter/LocalBusPubSubTest.res +81 -0
  181. package/tests/adapter/LocalBusPubSubTest.res.mjs +81 -0
  182. package/tests/adapter/QueryDbGsiTtlTest.res +268 -0
  183. package/tests/adapter/QueryDbGsiTtlTest.res.mjs +209 -0
  184. package/tests/adapter/QueryDbListResolverTest.res +361 -0
  185. package/tests/adapter/QueryDbListResolverTest.res.mjs +426 -0
  186. package/tests/adapter/QueryDbStorageSqliteTest.res +159 -0
  187. package/tests/adapter/QueryDbStorageSqliteTest.res.mjs +126 -0
  188. package/tests/adapter/QueryDbStorageTest.res +171 -0
  189. package/tests/adapter/QueryDbStorageTest.res.mjs +170 -0
  190. package/tests/adapter/QueryEngineTest.res +152 -0
  191. package/tests/adapter/QueryEngineTest.res.mjs +148 -0
  192. package/tests/adapter/ScheduledPublisherTest.res +199 -0
  193. package/tests/adapter/ScheduledPublisherTest.res.mjs +203 -0
  194. package/tests/adapter/SortKey_FilterTest.res +103 -0
  195. package/tests/adapter/SortKey_FilterTest.res.mjs +135 -0
  196. package/tests/adapter/SqliteDriverTest.res +86 -0
  197. package/tests/adapter/SqliteDriverTest.res.mjs +101 -0
  198. package/tests/adapter/TaskBucketSqliteTest.res +44 -0
  199. package/tests/adapter/TaskBucketSqliteTest.res.mjs +40 -0
  200. package/tests/adapter/TaskBucketTest.res +106 -0
  201. package/tests/adapter/TaskBucketTest.res.mjs +118 -0
  202. package/tests/components/aggregate/AggregateFixtures.res +78 -0
  203. package/tests/components/aggregate/AggregateFixtures.res.mjs +109 -0
  204. package/tests/components/aggregate/AggregateTest.res +129 -0
  205. package/tests/components/aggregate/AggregateTest.res.mjs +129 -0
  206. package/tests/components/automationslice/AutomationSliceCallbackTest.res +212 -0
  207. package/tests/components/automationslice/AutomationSliceCallbackTest.res.mjs +233 -0
  208. package/tests/components/automationslice/AutomationSliceFixtures.res +189 -0
  209. package/tests/components/automationslice/AutomationSliceFixtures.res.mjs +276 -0
  210. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +289 -0
  211. package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +453 -0
  212. package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res +47 -0
  213. package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res.mjs +31 -0
  214. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res +213 -0
  215. package/tests/components/automationslice/MixedSourceAutomationSliceFixtures.res.mjs +268 -0
  216. package/tests/components/automationslice/MixedSourceAutomationSliceTest.res +118 -0
  217. package/tests/components/automationslice/MixedSourceAutomationSliceTest.res.mjs +99 -0
  218. package/tests/components/commandgenerator/CommandGeneratorFixtures.res +73 -0
  219. package/tests/components/commandgenerator/CommandGeneratorFixtures.res.mjs +109 -0
  220. package/tests/components/commandgenerator/CommandGeneratorTest.res +43 -0
  221. package/tests/components/commandgenerator/CommandGeneratorTest.res.mjs +46 -0
  222. package/tests/components/commandtopic/CommandTopicFixtures.res +37 -0
  223. package/tests/components/commandtopic/CommandTopicFixtures.res.mjs +68 -0
  224. package/tests/components/commandtopic/CommandTopicStreamFixtures.res +43 -0
  225. package/tests/components/commandtopic/CommandTopicStreamFixtures.res.mjs +91 -0
  226. package/tests/components/commandtopic/CommandTopicStreamTest.res +30 -0
  227. package/tests/components/commandtopic/CommandTopicStreamTest.res.mjs +35 -0
  228. package/tests/components/commandtopic/CommandTopicTest.res +55 -0
  229. package/tests/components/commandtopic/CommandTopicTest.res.mjs +62 -0
  230. package/tests/components/counter/CounterFixtures.res +42 -0
  231. package/tests/components/counter/CounterFixtures.res.mjs +34 -0
  232. package/tests/components/counter/CounterTest.res +46 -0
  233. package/tests/components/counter/CounterTest.res.mjs +61 -0
  234. package/tests/components/dcb/DcbCrossPartitionFixtures.res +170 -0
  235. package/tests/components/dcb/DcbCrossPartitionFixtures.res.mjs +276 -0
  236. package/tests/components/dcb/DcbCrossPartitionReadTest.res +68 -0
  237. package/tests/components/dcb/DcbCrossPartitionReadTest.res.mjs +78 -0
  238. package/tests/components/dcb/DcbEventLogAppendStreamTest.res +72 -0
  239. package/tests/components/dcb/DcbEventLogAppendStreamTest.res.mjs +82 -0
  240. package/tests/components/dcb/DcbEventLogStreamTest.res +103 -0
  241. package/tests/components/dcb/DcbEventLogStreamTest.res.mjs +91 -0
  242. package/tests/components/dcb/DcbFixtures.res +145 -0
  243. package/tests/components/dcb/DcbFixtures.res.mjs +221 -0
  244. package/tests/components/dcb/DcbTest.res +55 -0
  245. package/tests/components/dcb/DcbTest.res.mjs +55 -0
  246. package/tests/components/eventcollector/EventCollectorFixtures.res +42 -0
  247. package/tests/components/eventcollector/EventCollectorFixtures.res.mjs +49 -0
  248. package/tests/components/eventcollector/EventCollectorTest.res +37 -0
  249. package/tests/components/eventcollector/EventCollectorTest.res.mjs +36 -0
  250. package/tests/components/eventlog/EventLogAppendStreamTest.res +93 -0
  251. package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +113 -0
  252. package/tests/components/eventlog/EventLogConflictTest.res +86 -0
  253. package/tests/components/eventlog/EventLogConflictTest.res.mjs +81 -0
  254. package/tests/components/eventlog/EventLogFixtures.res +52 -0
  255. package/tests/components/eventlog/EventLogFixtures.res.mjs +80 -0
  256. package/tests/components/eventlog/EventLogStreamTest.res +98 -0
  257. package/tests/components/eventlog/EventLogStreamTest.res.mjs +142 -0
  258. package/tests/components/eventlog/EventLogTest.res +53 -0
  259. package/tests/components/eventlog/EventLogTest.res.mjs +74 -0
  260. package/tests/components/eventtopic/EventTopicFixtures.res +47 -0
  261. package/tests/components/eventtopic/EventTopicFixtures.res.mjs +77 -0
  262. package/tests/components/eventtopic/EventTopicStreamFixtures.res +27 -0
  263. package/tests/components/eventtopic/EventTopicStreamFixtures.res.mjs +62 -0
  264. package/tests/components/eventtopic/EventTopicStreamTest.res +35 -0
  265. package/tests/components/eventtopic/EventTopicStreamTest.res.mjs +44 -0
  266. package/tests/components/eventtopic/EventTopicTest.res +35 -0
  267. package/tests/components/eventtopic/EventTopicTest.res.mjs +43 -0
  268. package/tests/components/extensionpoint/ExtensionPointFixtures.res +145 -0
  269. package/tests/components/extensionpoint/ExtensionPointFixtures.res.mjs +188 -0
  270. package/tests/components/extensionpoint/ExtensionPointTest.res +62 -0
  271. package/tests/components/extensionpoint/ExtensionPointTest.res.mjs +55 -0
  272. package/tests/components/heartbeat/HeartbeatFixtures.res +48 -0
  273. package/tests/components/heartbeat/HeartbeatFixtures.res.mjs +46 -0
  274. package/tests/components/heartbeat/HeartbeatTest.res +67 -0
  275. package/tests/components/heartbeat/HeartbeatTest.res.mjs +65 -0
  276. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +241 -0
  277. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +239 -0
  278. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res +36 -0
  279. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +73 -0
  280. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res +191 -0
  281. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +297 -0
  282. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res +78 -0
  283. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res.mjs +137 -0
  284. package/tests/components/querydb/QueryDbFixtures.res +39 -0
  285. package/tests/components/querydb/QueryDbFixtures.res.mjs +73 -0
  286. package/tests/components/querydb/QueryDbSubIdFixtures.res +44 -0
  287. package/tests/components/querydb/QueryDbSubIdFixtures.res.mjs +80 -0
  288. package/tests/components/querydb/QueryDbSubIdTest.res +104 -0
  289. package/tests/components/querydb/QueryDbSubIdTest.res.mjs +104 -0
  290. package/tests/components/querydb/QueryDbTest.res +81 -0
  291. package/tests/components/querydb/QueryDbTest.res.mjs +126 -0
  292. package/tests/components/readmodel/DcbReadModelE2EFixtures.res +215 -0
  293. package/tests/components/readmodel/DcbReadModelE2EFixtures.res.mjs +308 -0
  294. package/tests/components/readmodel/DcbReadModelE2ETest.res +45 -0
  295. package/tests/components/readmodel/DcbReadModelE2ETest.res.mjs +37 -0
  296. package/tests/components/readmodel/DcbReadModelTypoFailFastTest.res +88 -0
  297. package/tests/components/readmodel/DcbReadModelTypoFailFastTest.res.mjs +152 -0
  298. package/tests/components/readmodel/MixedSourceReadModelFixtures.res +181 -0
  299. package/tests/components/readmodel/MixedSourceReadModelFixtures.res.mjs +277 -0
  300. package/tests/components/readmodel/MixedSourceReadModelTest.res +61 -0
  301. package/tests/components/readmodel/MixedSourceReadModelTest.res.mjs +68 -0
  302. package/tests/components/readmodel/ReadModelFixtures.res +138 -0
  303. package/tests/components/readmodel/ReadModelFixtures.res.mjs +169 -0
  304. package/tests/components/readmodel/ReadModelTest.res +46 -0
  305. package/tests/components/readmodel/ReadModelTest.res.mjs +36 -0
  306. package/tests/components/scheduler/SchedulerFixtures.res +59 -0
  307. package/tests/components/scheduler/SchedulerFixtures.res.mjs +63 -0
  308. package/tests/components/scheduler/SchedulerTest.res +76 -0
  309. package/tests/components/scheduler/SchedulerTest.res.mjs +86 -0
  310. package/tests/components/sideeffecthandler/SideEffectHandlerFixtures.res +133 -0
  311. package/tests/components/sideeffecthandler/SideEffectHandlerFixtures.res.mjs +149 -0
  312. package/tests/components/sideeffecthandler/SideEffectHandlerTest.res +39 -0
  313. package/tests/components/sideeffecthandler/SideEffectHandlerTest.res.mjs +28 -0
  314. package/tests/components/stateviewslice/StateViewSliceFixtures.res +117 -0
  315. package/tests/components/stateviewslice/StateViewSliceFixtures.res.mjs +188 -0
  316. package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res +117 -0
  317. package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res.mjs +183 -0
  318. package/tests/components/stateviewslice/StateViewSliceSubIdTest.res +78 -0
  319. package/tests/components/stateviewslice/StateViewSliceSubIdTest.res.mjs +107 -0
  320. package/tests/components/stateviewslice/StateViewSliceTest.res +59 -0
  321. package/tests/components/stateviewslice/StateViewSliceTest.res.mjs +72 -0
  322. package/tests/components/task/TaskFixtures.res +87 -0
  323. package/tests/components/task/TaskFixtures.res.mjs +82 -0
  324. package/tests/components/task/TaskTest.res +98 -0
  325. package/tests/components/task/TaskTest.res.mjs +46 -0
  326. package/tests/plugin/PluginBehavior_GWT.res +146 -0
  327. package/tests/plugin/PluginBehavior_GWT.res.mjs +281 -0
  328. package/tests/plugin/Plugin_Fixtures.res +61 -0
  329. package/tests/plugin/Plugin_Fixtures.res.mjs +105 -0
  330. package/tests/plugin/PluginsProjection_GWT.res +95 -0
  331. package/tests/plugin/PluginsProjection_GWT.res.mjs +120 -0
  332. package/tests/plugin/UIFragmentRegistryProjection_GWT.res +72 -0
  333. package/tests/plugin/UIFragmentRegistryProjection_GWT.res.mjs +117 -0
  334. package/tests/setup/sqliteGlobal.cjs +12 -0
@@ -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
+ }