@reventlessdev/reventless-aws 3.0.0-alpha.167

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 (394) hide show
  1. package/CHANGELOG.md +1564 -0
  2. package/README.md +62 -0
  3. package/__mocks__/emptyModule.js +1 -0
  4. package/all-licenses.csv +565 -0
  5. package/docker-compose.dynamodb-local.yml +12 -0
  6. package/jest.setup.cjs +5 -0
  7. package/licenses-analysis.ods +0 -0
  8. package/package.json +73 -0
  9. package/rescript.json +39 -0
  10. package/reventless-licenses-analysis.xls +0 -0
  11. package/scripts/run-integration-tests.sh +43 -0
  12. package/src/Platform.res +1940 -0
  13. package/src/Platform.res.mjs +2281 -0
  14. package/src/Platform_Casts.res +106 -0
  15. package/src/Platform_Casts.res.mjs +13 -0
  16. package/src/Platform_Stack.res +153 -0
  17. package/src/Platform_Stack.res.mjs +121 -0
  18. package/src/Types.res +39 -0
  19. package/src/Types.res.mjs +21 -0
  20. package/src/adapter/AWS.res +96 -0
  21. package/src/adapter/AWS.res.mjs +186 -0
  22. package/src/adapter/AWS_Tags.res +16 -0
  23. package/src/adapter/AWS_Tags.res.mjs +48 -0
  24. package/src/adapter/Adapter_Helpers.res +19 -0
  25. package/src/adapter/Adapter_Helpers.res.mjs +37 -0
  26. package/src/adapter/Api/AppSync_EventsApi.res +172 -0
  27. package/src/adapter/Api/AppSync_EventsApi.res.mjs +96 -0
  28. package/src/adapter/Api/AppSync_Resolver_Native.res +93 -0
  29. package/src/adapter/Api/AppSync_Resolver_Native.res.mjs +67 -0
  30. package/src/adapter/Api/AppSync_Resolver_Retrying.res +653 -0
  31. package/src/adapter/Api/AppSync_Resolver_Retrying.res.mjs +462 -0
  32. package/src/adapter/Api/CommandSubscriptionResolvers_AppSync.res +29 -0
  33. package/src/adapter/Api/CommandSubscriptionResolvers_AppSync.res.mjs +15 -0
  34. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +238 -0
  35. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +179 -0
  36. package/src/adapter/Api/Platform_UIFragments_Lambda.res +229 -0
  37. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +245 -0
  38. package/src/adapter/Auth/Auth_Cognito.res +209 -0
  39. package/src/adapter/Auth/Auth_Cognito.res.mjs +135 -0
  40. package/src/adapter/CommandGenerator/CommandGeneratorResolvers.res +2 -0
  41. package/src/adapter/CommandGenerator/CommandGeneratorResolvers.res.mjs +12 -0
  42. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync.res +251 -0
  43. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync.res.mjs +126 -0
  44. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync_NoOp.res +20 -0
  45. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync_NoOp.res.mjs +23 -0
  46. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync_Runtime.res +1 -0
  47. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync_Runtime.res.mjs +11 -0
  48. package/src/adapter/CommandGenerator/InboundTranslationResolvers_AppSync.res +95 -0
  49. package/src/adapter/CommandGenerator/InboundTranslationResolvers_AppSync.res.mjs +50 -0
  50. package/src/adapter/CommandTopic/CommandTopicChannel.res +6 -0
  51. package/src/adapter/CommandTopic/CommandTopicChannel.res.mjs +21 -0
  52. package/src/adapter/CommandTopic/CommandTopicChannel_Helpers.res +196 -0
  53. package/src/adapter/CommandTopic/CommandTopicChannel_Helpers.res.mjs +145 -0
  54. package/src/adapter/CommandTopic/CommandTopicChannel_SQS.res +91 -0
  55. package/src/adapter/CommandTopic/CommandTopicChannel_SQS.res.mjs +74 -0
  56. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Async.res +69 -0
  57. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Async.res.mjs +62 -0
  58. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_FIFO.res +60 -0
  59. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_FIFO.res.mjs +56 -0
  60. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Runtime.res +73 -0
  61. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Runtime.res.mjs +65 -0
  62. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Sync.res +88 -0
  63. package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Sync.res.mjs +76 -0
  64. package/src/adapter/CommandTopic/CommandTopicRemoteChannel.res +1 -0
  65. package/src/adapter/CommandTopic/CommandTopicRemoteChannel.res.mjs +9 -0
  66. package/src/adapter/CommandTopic/CommandTopicRemoteChannel_SQS.res +7 -0
  67. package/src/adapter/CommandTopic/CommandTopicRemoteChannel_SQS.res.mjs +17 -0
  68. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +103 -0
  69. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +68 -0
  70. package/src/adapter/Counter/CounterHandler_DynamoDbStream_Runtime.res +107 -0
  71. package/src/adapter/Counter/CounterHandler_DynamoDbStream_Runtime.res.mjs +145 -0
  72. package/src/adapter/DcbEventLog/DcbEventLogStorage.res +1 -0
  73. package/src/adapter/DcbEventLog/DcbEventLogStorage.res.mjs +9 -0
  74. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb.res +84 -0
  75. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb.res.mjs +52 -0
  76. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +1436 -0
  77. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +1143 -0
  78. package/src/adapter/EventCollector/EventCollectorChannel.res +6 -0
  79. package/src/adapter/EventCollector/EventCollectorChannel.res.mjs +21 -0
  80. package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res +63 -0
  81. package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res.mjs +56 -0
  82. package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res +20 -0
  83. package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res.mjs +32 -0
  84. package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res +266 -0
  85. package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res.mjs +166 -0
  86. package/src/adapter/EventCollector/EventCollectorChannel_SQS.res +81 -0
  87. package/src/adapter/EventCollector/EventCollectorChannel_SQS.res.mjs +54 -0
  88. package/src/adapter/EventCollector/EventCollectorChannel_SQS_FIFO.res +59 -0
  89. package/src/adapter/EventCollector/EventCollectorChannel_SQS_FIFO.res.mjs +45 -0
  90. package/src/adapter/EventCollector/EventCollectorChannel_SQS_Runtime.res +91 -0
  91. package/src/adapter/EventCollector/EventCollectorChannel_SQS_Runtime.res.mjs +89 -0
  92. package/src/adapter/EventLog/EventLogStorage.res +3 -0
  93. package/src/adapter/EventLog/EventLogStorage.res.mjs +15 -0
  94. package/src/adapter/EventLog/EventLogStorage_DynamoDb.res +25 -0
  95. package/src/adapter/EventLog/EventLogStorage_DynamoDb.res.mjs +34 -0
  96. package/src/adapter/EventLog/EventLogStorage_DynamoDbStream.res +26 -0
  97. package/src/adapter/EventLog/EventLogStorage_DynamoDbStream.res.mjs +35 -0
  98. package/src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res +121 -0
  99. package/src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res.mjs +137 -0
  100. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +265 -0
  101. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +185 -0
  102. package/src/adapter/EventTopic/EventTopicPublisher.res +6 -0
  103. package/src/adapter/EventTopic/EventTopicPublisher.res.mjs +21 -0
  104. package/src/adapter/EventTopic/EventTopicPublisher_DynamoDbStream.res +25 -0
  105. package/src/adapter/EventTopic/EventTopicPublisher_DynamoDbStream.res.mjs +28 -0
  106. package/src/adapter/EventTopic/EventTopicPublisher_SNS.res +40 -0
  107. package/src/adapter/EventTopic/EventTopicPublisher_SNS.res.mjs +39 -0
  108. package/src/adapter/EventTopic/EventTopicPublisher_SNS_FIFO.res +39 -0
  109. package/src/adapter/EventTopic/EventTopicPublisher_SNS_FIFO.res.mjs +38 -0
  110. package/src/adapter/EventTopic/EventTopicPublisher_SNS_Runtime.res +5 -0
  111. package/src/adapter/EventTopic/EventTopicPublisher_SNS_Runtime.res.mjs +18 -0
  112. package/src/adapter/Mcp/MCP_Lambda.res +502 -0
  113. package/src/adapter/Mcp/MCP_Lambda.res.mjs +289 -0
  114. package/src/adapter/QueryDb/NodeResolver_AppSync.res +71 -0
  115. package/src/adapter/QueryDb/NodeResolver_AppSync.res.mjs +44 -0
  116. package/src/adapter/QueryDb/QueryDbResolvers.res +2 -0
  117. package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +12 -0
  118. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +434 -0
  119. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +243 -0
  120. package/src/adapter/QueryDb/QueryDbResolvers_AppSync_NoOp.res +21 -0
  121. package/src/adapter/QueryDb/QueryDbResolvers_AppSync_NoOp.res.mjs +14 -0
  122. package/src/adapter/QueryDb/QueryDbResolvers_NoOp.res +21 -0
  123. package/src/adapter/QueryDb/QueryDbResolvers_NoOp.res.mjs +14 -0
  124. package/src/adapter/QueryDb/QueryDbStorage.res +5 -0
  125. package/src/adapter/QueryDb/QueryDbStorage.res.mjs +18 -0
  126. package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res +115 -0
  127. package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs +100 -0
  128. package/src/adapter/QueryDb/QueryDbStorage_DynamoDbStream.res +52 -0
  129. package/src/adapter/QueryDb/QueryDbStorage_DynamoDbStream.res.mjs +38 -0
  130. package/src/adapter/QueryDb/QueryDbStorage_DynamoDb_Runtime.res +271 -0
  131. package/src/adapter/QueryDb/QueryDbStorage_DynamoDb_Runtime.res.mjs +306 -0
  132. package/src/adapter/QueryDb/QueryInterceptor_Lambda.res +20 -0
  133. package/src/adapter/QueryDb/QueryInterceptor_Lambda.res.mjs +22 -0
  134. package/src/adapter/QueryEngine/QueryEngine.res +3 -0
  135. package/src/adapter/QueryEngine/QueryEngine.res.mjs +12 -0
  136. package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res +184 -0
  137. package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res.mjs +221 -0
  138. package/src/adapter/Runtime/AdminEventCollectorEntryPoint.mjs +879 -0
  139. package/src/adapter/Runtime/AggregateEntryPoint.mjs +313 -0
  140. package/src/adapter/Runtime/AggregateRuntime_Builder_Micro.res +327 -0
  141. package/src/adapter/Runtime/AggregateRuntime_Builder_Micro.res.mjs +239 -0
  142. package/src/adapter/Runtime/AggregateRuntime_Builder_Micro_Async.res +331 -0
  143. package/src/adapter/Runtime/AggregateRuntime_Builder_Micro_Async.res.mjs +240 -0
  144. package/src/adapter/Runtime/AggregateRuntime_Builder_PerAggregate.res +247 -0
  145. package/src/adapter/Runtime/AggregateRuntime_Builder_PerAggregate.res.mjs +185 -0
  146. package/src/adapter/Runtime/AggregateRuntime_Builder_PerAggregate_Async.res +244 -0
  147. package/src/adapter/Runtime/AggregateRuntime_Builder_PerAggregate_Async.res.mjs +185 -0
  148. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +347 -0
  149. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +244 -0
  150. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +285 -0
  151. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +212 -0
  152. package/src/adapter/Runtime/AutomationSliceEntryPoint.mjs +124 -0
  153. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +187 -0
  154. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +147 -0
  155. package/src/adapter/Runtime/CounterEntryPoint.mjs +91 -0
  156. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +265 -0
  157. package/src/adapter/Runtime/EventCollectorRuntime_Builder_PerEventCollector.res +122 -0
  158. package/src/adapter/Runtime/EventCollectorRuntime_Builder_PerEventCollector.res.mjs +89 -0
  159. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +193 -0
  160. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +137 -0
  161. package/src/adapter/Runtime/EventMapperEntryPoint.mjs +97 -0
  162. package/src/adapter/Runtime/ExtensionPointEntryPoint.mjs +101 -0
  163. package/src/adapter/Runtime/ExtensionPointRuntime_Builder_PerExtensionPoint.res +83 -0
  164. package/src/adapter/Runtime/ExtensionPointRuntime_Builder_PerExtensionPoint.res.mjs +59 -0
  165. package/src/adapter/Runtime/HandlerFactoryHelpers.mjs +142 -0
  166. package/src/adapter/Runtime/HeartbeatEntryPoint.mjs +38 -0
  167. package/src/adapter/Runtime/PluginExtensionPointEntryPoint.mjs +137 -0
  168. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +143 -0
  169. package/src/adapter/Runtime/RuntimeEnvironment.res +1 -0
  170. package/src/adapter/Runtime/RuntimeEnvironment.res.mjs +9 -0
  171. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +254 -0
  172. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +169 -0
  173. package/src/adapter/Runtime/SideEffectEntryPoint.mjs +81 -0
  174. package/src/adapter/Runtime/SideEffectHandlerRuntime_Builder_Single.res +178 -0
  175. package/src/adapter/Runtime/SideEffectHandlerRuntime_Builder_Single.res.mjs +129 -0
  176. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +166 -0
  177. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +87 -0
  178. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +136 -0
  179. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +344 -0
  180. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +242 -0
  181. package/src/adapter/Runtime/TaskBucketEntryPoint.mjs +150 -0
  182. package/src/adapter/Runtime/TaskRuntime_Builder_PerBucket.res +123 -0
  183. package/src/adapter/Runtime/TaskRuntime_Builder_PerBucket.res.mjs +74 -0
  184. package/src/adapter/ScheduledPublisher/ScheduledPublisher.res +2 -0
  185. package/src/adapter/ScheduledPublisher/ScheduledPublisher.res.mjs +12 -0
  186. package/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents.res +54 -0
  187. package/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents.res.mjs +41 -0
  188. package/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents_Runtime.res +97 -0
  189. package/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents_Runtime.res.mjs +85 -0
  190. package/src/adapter/StateTopic/StateTopic_AppSync.res +418 -0
  191. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +295 -0
  192. package/src/adapter/Task/TaskBucket.res +1 -0
  193. package/src/adapter/Task/TaskBucket.res.mjs +9 -0
  194. package/src/adapter/Task/TaskBucket_S3.res +157 -0
  195. package/src/adapter/Task/TaskBucket_S3.res.mjs +108 -0
  196. package/src/adapter/Task/TaskBucket_S3_Runtime.res +11 -0
  197. package/src/adapter/Task/TaskBucket_S3_Runtime.res.mjs +15 -0
  198. package/src/components/Aggregate_Builder.res +1 -0
  199. package/src/components/Aggregate_Builder.res.mjs +25 -0
  200. package/src/components/Aggregate_Builder_Micro.res +63 -0
  201. package/src/components/Aggregate_Builder_Micro.res.mjs +80 -0
  202. package/src/components/Aggregate_Builder_NoResolver.res +61 -0
  203. package/src/components/Aggregate_Builder_NoResolver.res.mjs +80 -0
  204. package/src/components/Aggregate_Builder_PerAggregate.res +57 -0
  205. package/src/components/Aggregate_Builder_PerAggregate.res.mjs +80 -0
  206. package/src/components/Aggregate_Builder_Single.res +59 -0
  207. package/src/components/Aggregate_Builder_Single.res.mjs +80 -0
  208. package/src/components/Aggregate_Builder_Single_Async.res +59 -0
  209. package/src/components/Aggregate_Builder_Single_Async.res.mjs +80 -0
  210. package/src/components/Api/AppSync_Adapter.res +500 -0
  211. package/src/components/Api/AppSync_Adapter.res.mjs +396 -0
  212. package/src/components/AutomationSlice_Builder.res +52 -0
  213. package/src/components/AutomationSlice_Builder.res.mjs +72 -0
  214. package/src/components/AutomationSlice_Builder_Bundled.res.mjs +70 -0
  215. package/src/components/Counter_Builder.res +10 -0
  216. package/src/components/Counter_Builder.res.mjs +16 -0
  217. package/src/components/DataCleaner.res +170 -0
  218. package/src/components/DataCleaner.res.mjs +2 -0
  219. package/src/components/DcbEventLog_Builder.res +8 -0
  220. package/src/components/DcbEventLog_Builder.res.mjs +14 -0
  221. package/src/components/ExtensionPoint_Builder.res +16 -0
  222. package/src/components/ExtensionPoint_Builder.res.mjs +37 -0
  223. package/src/components/ForeignReadModel_Builder.res +19 -0
  224. package/src/components/ForeignReadModel_Builder.res.mjs +48 -0
  225. package/src/components/InboundTranslationSlice_Builder.res +25 -0
  226. package/src/components/InboundTranslationSlice_Builder.res.mjs +31 -0
  227. package/src/components/OutboundTranslationSlice_Builder.res +60 -0
  228. package/src/components/OutboundTranslationSlice_Builder.res.mjs +76 -0
  229. package/src/components/OutboundTranslationSlice_Builder_Bundled.res.mjs +75 -0
  230. package/src/components/Plugin.res +29 -0
  231. package/src/components/Plugin.res.mjs +63 -0
  232. package/src/components/ReadModel_Builder.res +1 -0
  233. package/src/components/ReadModel_Builder.res.mjs +19 -0
  234. package/src/components/ReadModel_Builder_NoResolver.res +55 -0
  235. package/src/components/ReadModel_Builder_NoResolver.res.mjs +64 -0
  236. package/src/components/ReadModel_Builder_NoResolver_Stream.res +60 -0
  237. package/src/components/ReadModel_Builder_NoResolver_Stream.res.mjs +64 -0
  238. package/src/components/ReadModel_Builder_PerReadModel.res +53 -0
  239. package/src/components/ReadModel_Builder_PerReadModel.res.mjs +66 -0
  240. package/src/components/ReadModel_Builder_Single.res +52 -0
  241. package/src/components/ReadModel_Builder_Single.res.mjs +66 -0
  242. package/src/components/ReadModel_Builder_Single_Stream.res +60 -0
  243. package/src/components/ReadModel_Builder_Single_Stream.res.mjs +66 -0
  244. package/src/components/Scheduler.res +1 -0
  245. package/src/components/Scheduler.res.mjs +13 -0
  246. package/src/components/SideEffectHandler.res +1 -0
  247. package/src/components/SideEffectHandler.res.mjs +19 -0
  248. package/src/components/SideEffectHandler_PerSideEffectHandler.res +13 -0
  249. package/src/components/SideEffectHandler_PerSideEffectHandler.res.mjs +52 -0
  250. package/src/components/SideEffectHandler_Single.res +54 -0
  251. package/src/components/SideEffectHandler_Single.res.mjs +60 -0
  252. package/src/components/StateChangeSlice_Builder.res +17 -0
  253. package/src/components/StateChangeSlice_Builder.res.mjs +23 -0
  254. package/src/components/StateViewSlice_Builder.res +46 -0
  255. package/src/components/StateViewSlice_Builder.res.mjs +68 -0
  256. package/src/components/StateViewSlice_Builder_Bundled.res.mjs +69 -0
  257. package/src/components/StateViewSlice_Builder_Stream.res +50 -0
  258. package/src/components/StateViewSlice_Builder_Stream.res.mjs +68 -0
  259. package/src/components/Task_Builder.res +1 -0
  260. package/src/components/Task_Builder.res.mjs +28 -0
  261. package/src/components/Task_Builder_PerBucket.res +22 -0
  262. package/src/components/Task_Builder_PerBucket.res.mjs +63 -0
  263. package/src/components/Vpc.res +214 -0
  264. package/src/components/Vpc.res.mjs +117 -0
  265. package/src/components/Vpc.resi +22 -0
  266. package/src/errors/AppSync_Error.res +36 -0
  267. package/src/errors/AppSync_Error.res.mjs +38 -0
  268. package/src/errors/Cognito_Error.res +36 -0
  269. package/src/errors/Cognito_Error.res.mjs +38 -0
  270. package/src/errors/DynamoDb_Error.res +75 -0
  271. package/src/errors/DynamoDb_Error.res.mjs +86 -0
  272. package/src/errors/Kinesis_Error.res +36 -0
  273. package/src/errors/Kinesis_Error.res.mjs +38 -0
  274. package/src/errors/SNS_Error.res +34 -0
  275. package/src/errors/SNS_Error.res.mjs +38 -0
  276. package/src/errors/SQS_Error.res +40 -0
  277. package/src/errors/SQS_Error.res.mjs +41 -0
  278. package/src/plugin/cloner/ClonerRunner.res +1 -0
  279. package/src/plugin/cloner/ClonerRunner.res.mjs +9 -0
  280. package/src/plugin/cloner/ClonerRunner_Fargate.res +362 -0
  281. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +246 -0
  282. package/src/plugin/cloner/ClonerRunner_Fargate_Runtime.res +42 -0
  283. package/src/plugin/cloner/ClonerRunner_Fargate_Runtime.res.mjs +45 -0
  284. package/src/plugin/heartbeat/HeartbeatRunner.res +1 -0
  285. package/src/plugin/heartbeat/HeartbeatRunner.res.mjs +9 -0
  286. package/src/plugin/heartbeat/HeartbeatRunner_CloudWatchEvents.res +96 -0
  287. package/src/plugin/heartbeat/HeartbeatRunner_CloudWatchEvents.res.mjs +69 -0
  288. package/src/plugin/runtime/PluginExtensionPointRuntime_Builder.res +115 -0
  289. package/src/plugin/runtime/PluginExtensionPointRuntime_Builder.res.mjs +88 -0
  290. package/src/plugin/runtime/PluginRuntimeOperations.res +5 -0
  291. package/src/plugin/runtime/PluginRuntimeOperations.res.mjs +14 -0
  292. package/src/plugin/runtime/PluginRuntime_Builder.res +769 -0
  293. package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +464 -0
  294. package/src/plugin/runtime/Util_PluginMessage_Runtime.res +13 -0
  295. package/src/plugin/runtime/Util_PluginMessage_Runtime.res.mjs +19 -0
  296. package/src/plugin/stack/Plugin_ExtensionPoint_Builder.res +28 -0
  297. package/src/plugin/stack/Plugin_ExtensionPoint_Builder.res.mjs +67 -0
  298. package/src/plugin/stack/Plugin_Stack.res +439 -0
  299. package/src/plugin/stack/Plugin_Stack.res.mjs +263 -0
  300. package/src/util/Util.res +29 -0
  301. package/src/util/Util.res.mjs +93 -0
  302. package/src/util/Util_AppSync.res +36 -0
  303. package/src/util/Util_AppSync.res.mjs +35 -0
  304. package/src/util/Util_AppSync_Caller.res +213 -0
  305. package/src/util/Util_AppSync_Caller.res.mjs +170 -0
  306. package/src/util/Util_Bundle.res +255 -0
  307. package/src/util/Util_Bundle.res.mjs +179 -0
  308. package/src/util/Util_Cloudwatch.res +14 -0
  309. package/src/util/Util_Cloudwatch.res.mjs +19 -0
  310. package/src/util/Util_CognitoGroupUser.res +39 -0
  311. package/src/util/Util_CognitoGroupUser.res.mjs +32 -0
  312. package/src/util/Util_CognitoGroupUser_Runtime.res +65 -0
  313. package/src/util/Util_CognitoGroupUser_Runtime.res.mjs +50 -0
  314. package/src/util/Util_Cognito_Runtime.res +52 -0
  315. package/src/util/Util_Cognito_Runtime.res.mjs +35 -0
  316. package/src/util/Util_DeadLetterQueue.res +154 -0
  317. package/src/util/Util_DeadLetterQueue.res.mjs +158 -0
  318. package/src/util/Util_DynamoDb.res +210 -0
  319. package/src/util/Util_DynamoDb.res.mjs +218 -0
  320. package/src/util/Util_DynamoDbStream.res +138 -0
  321. package/src/util/Util_DynamoDbStream.res.mjs +134 -0
  322. package/src/util/Util_DynamoDbStream_Runtime.res +94 -0
  323. package/src/util/Util_DynamoDbStream_Runtime.res.mjs +131 -0
  324. package/src/util/Util_DynamoDb_Runtime.res +216 -0
  325. package/src/util/Util_DynamoDb_Runtime.res.mjs +257 -0
  326. package/src/util/Util_DynamoDb_TableManager.res +23 -0
  327. package/src/util/Util_DynamoDb_TableManager.res.mjs +35 -0
  328. package/src/util/Util_DynamoDb_TableManager.resi +3 -0
  329. package/src/util/Util_Env.res +5 -0
  330. package/src/util/Util_Env.res.mjs +2 -0
  331. package/src/util/Util_EventSourceMapping.res +49 -0
  332. package/src/util/Util_EventSourceMapping.res.mjs +32 -0
  333. package/src/util/Util_HostUiDomain.res +28 -0
  334. package/src/util/Util_HostUiDomain.res.mjs +26 -0
  335. package/src/util/Util_IAM_Role.res +14 -0
  336. package/src/util/Util_IAM_Role.res.mjs +28 -0
  337. package/src/util/Util_Kinesis_Runtime.res +20 -0
  338. package/src/util/Util_Kinesis_Runtime.res.mjs +24 -0
  339. package/src/util/Util_Lambda.res +39 -0
  340. package/src/util/Util_Lambda.res.mjs +45 -0
  341. package/src/util/Util_LocalConfig.res +119 -0
  342. package/src/util/Util_LocalConfig.res.mjs +112 -0
  343. package/src/util/Util_ResourceNaming.res +15 -0
  344. package/src/util/Util_ResourceNaming.res.mjs +26 -0
  345. package/src/util/Util_S3.res +17 -0
  346. package/src/util/Util_S3.res.mjs +30 -0
  347. package/src/util/Util_S3_Runtime.res +11 -0
  348. package/src/util/Util_S3_Runtime.res.mjs +15 -0
  349. package/src/util/Util_SNS.res +33 -0
  350. package/src/util/Util_SNS.res.mjs +50 -0
  351. package/src/util/Util_SNS_FIFO.res +21 -0
  352. package/src/util/Util_SNS_FIFO.res.mjs +27 -0
  353. package/src/util/Util_SNS_Runtime.res +33 -0
  354. package/src/util/Util_SNS_Runtime.res.mjs +27 -0
  355. package/src/util/Util_SQS.res +70 -0
  356. package/src/util/Util_SQS.res.mjs +83 -0
  357. package/src/util/Util_SQS_FIFO.res +16 -0
  358. package/src/util/Util_SQS_FIFO.res.mjs +28 -0
  359. package/src/util/Util_SQS_Runtime.res +169 -0
  360. package/src/util/Util_SQS_Runtime.res.mjs +150 -0
  361. package/src/util/Util_SesPolicy_Runtime.res +54 -0
  362. package/src/util/Util_SesPolicy_Runtime.res.mjs +55 -0
  363. package/src/util/Util_StaticBundle.res +124 -0
  364. package/src/util/Util_StaticBundle.res.mjs +132 -0
  365. package/src/util/Util_Vpc.res +22 -0
  366. package/src/util/Util_Vpc.res.mjs +27 -0
  367. package/tests/AppSync_AdapterTest.res +396 -0
  368. package/tests/AppSync_AdapterTest.res.mjs +326 -0
  369. package/tests/AppSync_Resolver_RetryingTest.res +59 -0
  370. package/tests/AppSync_Resolver_RetryingTest.res.mjs +66 -0
  371. package/tests/Auth_CognitoTest.res +203 -0
  372. package/tests/Auth_CognitoTest.res.mjs +248 -0
  373. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +602 -0
  374. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +742 -0
  375. package/tests/EventLogStorage_DynamoDb_RuntimeTest.res +57 -0
  376. package/tests/EventLogStorage_DynamoDb_RuntimeTest.res.mjs +68 -0
  377. package/tests/MCP_LambdaTest.res +101 -0
  378. package/tests/MCP_LambdaTest.res.mjs +56 -0
  379. package/tests/Util_HostUiDomainTest.res +90 -0
  380. package/tests/Util_HostUiDomainTest.res.mjs +50 -0
  381. package/tests/Util_SQS_RuntimeTest.res +46 -0
  382. package/tests/Util_SQS_RuntimeTest.res.mjs +45 -0
  383. package/tests/Util_StaticBundleTest.res +136 -0
  384. package/tests/Util_StaticBundleTest.res.mjs +149 -0
  385. package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res +101 -0
  386. package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res.mjs +91 -0
  387. package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res +315 -0
  388. package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res.mjs +435 -0
  389. package/tests/integration/DcbIntegrationHarness.res +102 -0
  390. package/tests/integration/DcbIntegrationHarness.res.mjs +163 -0
  391. package/tests/integration/EpTestSlice.res +28 -0
  392. package/tests/integration/EpTestSlice.res.mjs +43 -0
  393. package/tests/integration/EpTestSliceBehavior.res +18 -0
  394. package/tests/integration/EpTestSliceBehavior.res.mjs +35 -0
@@ -0,0 +1,1436 @@
1
+ open Util_DynamoDb_Runtime
2
+ open AwsSdk.DynamoDb.DocumentClient
3
+
4
+ // --- Position Generation ---
5
+
6
+ let generatePosition = () => {
7
+ let timestamp = Date.make()->Date.getTime->Float.toString
8
+ let uuid = Uuid.v4()
9
+ `${timestamp}-${uuid}`
10
+ }
11
+
12
+ let generatePositionForBatch = (basePosition, index) => {
13
+ if index == 0 {
14
+ basePosition
15
+ } else {
16
+ `${basePosition}-${index->Int.toString->String.padStart(3, "0")}`
17
+ }
18
+ }
19
+
20
+ // --- Tag Utilities ---
21
+
22
+ let tagToAttributeName = (tagKey: string) => `tag_${tagKey}`
23
+
24
+ // GSI projection policy (consumed by the deploy-time `make`): only the
25
+ // composite-tag GSI keeps a full (`ALL`) projection, because composite decision
26
+ // reads resolve events directly from it. The per-tag `tag_<key>` GSIs are
27
+ // `KEYS_ONLY` — no reader today, and Phase 7's cross-partition read goes
28
+ // `Query`(keys) → `BatchGetItem`(payloads). Pure predicate so it is unit-testable
29
+ // without the Pulumi deploy layer. See docs/plans/dcb-consistency-hardening.md Phase 3.
30
+ let compositeIndexName = "tag_composite"
31
+ let indexKeepsFullProjection = (indexName: string): bool => indexName == compositeIndexName
32
+
33
+ let compositeTagKey = (tags: array<Reventless.DcbTag.tag>) =>
34
+ tags
35
+ ->Array.toSorted((a, b) => String.compare(a.key, b.key))
36
+ ->Array.map(t => `${t.key}:${t.value}`)
37
+ ->Array.join("#")
38
+
39
+ // --- Partition Key Derivation ---
40
+
41
+ let derivePartitionKey = (
42
+ ~partitionTag: option<Reventless.DcbTag.derivedPartitionTag>=?,
43
+ tags: array<Reventless.DcbTag.tag>,
44
+ ): string => {
45
+ if tags->Array.length == 0 {
46
+ "dcb"
47
+ } else {
48
+ switch partitionTag {
49
+ | None =>
50
+ let tag = tags->Array.getUnsafe(0)
51
+ `${tag.key}:${tag.value}`
52
+ | Some(Simple(pt)) =>
53
+ let tag = switch tags->Array.find(t => t.key == pt.key) {
54
+ | Some(t) => t
55
+ | None => tags->Array.getUnsafe(0)
56
+ }
57
+ `${tag.key}:${tag.value}`
58
+ | Some(Composite(spec)) =>
59
+ Reventless.DcbTag.getCompositePartitionKeyValue(tags, spec)
60
+ }
61
+ }
62
+ }
63
+
64
+ // --- Item Conversion ---
65
+
66
+ let toItem = (
67
+ position: string,
68
+ event: ReventlessCore.DcbEventLog_Adapter.rawStoredEvent,
69
+ ~partitionTag=?,
70
+ ~recordedAt: string,
71
+ ): JSON.t => {
72
+ // Create base item
73
+ let item = Dict.make()
74
+ item->Dict.set("id", derivePartitionKey(~partitionTag?, event.tags)->JSON.Encode.string)
75
+ item->Dict.set("position", position->JSON.Encode.string)
76
+ item->Dict.set("event", event.eventType->JSON.Encode.string)
77
+ item->Dict.set("data", event.data)
78
+ item->Dict.set("recordedAt", recordedAt->JSON.Encode.string)
79
+
80
+ // Add tags array
81
+ let tagsJson = event.tags->Array.map(tag =>
82
+ [
83
+ ("key", tag.key->JSON.Encode.string),
84
+ ("value", tag.value->JSON.Encode.string),
85
+ ]
86
+ ->Dict.fromArray
87
+ ->JSON.Encode.object
88
+ )
89
+ item->Dict.set("tags", tagsJson->JSON.Encode.array)
90
+
91
+ // Flatten meta.* to top-level item attributes so individual meta keys stay
92
+ // GSI-projectable (matches the EventLog adapter behaviour). The DCB read
93
+ // path reassembles meta via `Reventless.Message.composeMeta`.
94
+ event.meta
95
+ ->ReventlessCore.Message.decomposeMeta
96
+ ->Array.forEach(((k, v)) => item->Dict.set(k, v))
97
+
98
+ // Add individual tag attributes for GSI queries
99
+ event.tags->Array.forEach(tag => {
100
+ let attrName = tagToAttributeName(tag.key)
101
+ item->Dict.set(attrName, tag.value->JSON.Encode.string)
102
+ })
103
+
104
+ // Add composite tag attribute if multiple tags
105
+ if event.tags->Array.length > 1 {
106
+ let composite = compositeTagKey(event.tags)
107
+ item->Dict.set("tag_composite", composite->JSON.Encode.string)
108
+ }
109
+
110
+ item->JSON.Encode.object
111
+ }
112
+
113
+ let fromItem = (item: JSON.t): ReventlessCore.DcbEventLog_Adapter.rawSequencedEvent => {
114
+ switch item->JSON.Decode.object {
115
+ | None => JsError.throwWithMessage("Invalid DcbEventLog item: not an object")
116
+ | Some(obj) =>
117
+ let position = obj
118
+ ->Dict.get("position")
119
+ ->Option.flatMap(JSON.Decode.string)
120
+ ->Option.getOrThrow
121
+
122
+ let eventType = obj
123
+ ->Dict.get("event")
124
+ ->Option.flatMap(JSON.Decode.string)
125
+ ->Option.getOrThrow
126
+
127
+ let data = obj->Dict.get("data")->Option.getOrThrow
128
+
129
+ let tags = obj
130
+ ->Dict.get("tags")
131
+ ->Option.flatMap(JSON.Decode.array)
132
+ ->Option.mapOr([], tagArray =>
133
+ tagArray->Array.filterMap(tagJson =>
134
+ switch tagJson->JSON.Decode.object {
135
+ | None => None
136
+ | Some(tagObj) => {
137
+ let key = tagObj->Dict.get("key")->Option.flatMap(JSON.Decode.string)
138
+ let value = tagObj->Dict.get("value")->Option.flatMap(JSON.Decode.string)
139
+ switch (key, value) {
140
+ | (Some(k), Some(v)) => Some({Reventless.DcbTag.key: k, value: v})
141
+ | _ => None
142
+ }
143
+ }
144
+ }
145
+ )
146
+ )
147
+
148
+ let recordedAt =
149
+ obj
150
+ ->Dict.get("recordedAt")
151
+ ->Option.flatMap(JSON.Decode.string)
152
+ ->Option.getOr("")
153
+
154
+ let meta =
155
+ obj
156
+ ->ReventlessCore.Message.composeMeta
157
+ ->S.parseJsonOrThrow(Reventless.Message.metaSchema)
158
+
159
+ {
160
+ position,
161
+ eventType,
162
+ data,
163
+ tags,
164
+ meta,
165
+ recordedAt,
166
+ }
167
+ }
168
+ }
169
+
170
+ // --- Query Operations ---
171
+
172
+ let queryByCompositeTags = async (
173
+ table: resolvedTable,
174
+ tags: array<Reventless.DcbTag.tag>,
175
+ ~after: option<string>=?,
176
+ ) => {
177
+ let composite = compositeTagKey(tags)
178
+ let indexName = "tag_composite"
179
+
180
+ let expressionAttributeValues = Dict.fromArray([
181
+ (":composite", composite->JSON.Encode.string),
182
+ ])
183
+
184
+ let (keyConditionExpression, expressionAttributeNames) = switch after {
185
+ | None => ("tag_composite = :composite", None)
186
+ | Some(afterPos) => {
187
+ expressionAttributeValues->Dict.set(":after", afterPos->JSON.Encode.string)
188
+ ("tag_composite = :composite AND #pos > :after", Some(Dict.fromArray([("#pos", "position")])))
189
+ }
190
+ }
191
+
192
+ let queryParams: QueryCommand.input = {
193
+ tableName: table.name,
194
+ indexName: indexName,
195
+ keyConditionExpression: keyConditionExpression,
196
+ expressionAttributeValues: expressionAttributeValues,
197
+ ?expressionAttributeNames,
198
+ }
199
+
200
+ await queryStream(queryParams)->Stream.runCollect->Effect.runPromise
201
+ }
202
+
203
+ // Filter clause shared by both scan paths.
204
+ type scanFilter = {
205
+ filterExpression: option<string>,
206
+ expressionAttributeNames: option<Dict.t<string>>,
207
+ expressionAttributeValues: option<Dict.t<JSON.t>>,
208
+ }
209
+
210
+ // Builds the scan FilterExpression. Always asserts `attribute_exists(event)` so
211
+ // `fence#…` sentinels — which carry no `event`/`data` attributes and would make
212
+ // `fromItem` throw — are never returned, regardless of whether an event-type
213
+ // filter is present (Issue 12). A non-empty `eventTypes` adds the usual type
214
+ // disjunction on top; an absent or empty list leaves just the fence guard
215
+ // (avoids emitting a degenerate empty `()` group).
216
+ let buildScanFilter = (~eventTypes: option<array<string>>=?): scanFilter => {
217
+ let expressionAttributeValues = Dict.make()
218
+ let expressionAttributeNames = Dict.fromArray([("#evt", "event")])
219
+ let filterParts = ["attribute_exists(#evt)"]
220
+
221
+ switch eventTypes {
222
+ | None | Some([]) => ()
223
+ | Some(types) =>
224
+ let typeConditions = types->Array.mapWithIndex((typ, idx) => {
225
+ let placeholder = `:type${idx->Int.toString}`
226
+ expressionAttributeValues->Dict.set(placeholder, typ->JSON.Encode.string)
227
+ `#evt = ${placeholder}`
228
+ })
229
+ filterParts->Array.push(`(${typeConditions->Array.join(" OR ")})`)
230
+ }
231
+
232
+ let hasAttributeValues = expressionAttributeValues->Dict.keysToArray->Array.length > 0
233
+ {
234
+ filterExpression: Some(filterParts->Array.join(" AND ")),
235
+ expressionAttributeNames: Some(expressionAttributeNames),
236
+ expressionAttributeValues: hasAttributeValues ? Some(expressionAttributeValues) : None,
237
+ }
238
+ }
239
+
240
+ let scanWithFilter = async (
241
+ table: resolvedTable,
242
+ ~eventTypes: option<array<string>>=?,
243
+ ~after: option<string>=?,
244
+ ) => {
245
+ let filter = buildScanFilter(~eventTypes?)
246
+
247
+ let scanParams: ScanCommand.input = {
248
+ tableName: table.name,
249
+ filterExpression: ?filter.filterExpression,
250
+ expressionAttributeValues: ?filter.expressionAttributeValues,
251
+ expressionAttributeNames: ?filter.expressionAttributeNames,
252
+ }
253
+
254
+ // position cannot appear in FilterExpression (it is the table's sort key).
255
+ // Collect all items first, then filter by position in application code.
256
+ let rawItems = await scanStream(scanParams)->Stream.runCollect->Effect.runPromise
257
+ switch after {
258
+ | None => rawItems
259
+ | Some(afterPos) =>
260
+ rawItems->Array.filter(item =>
261
+ item
262
+ ->JSON.Decode.object
263
+ ->Option.flatMap(obj => obj->Dict.get("position"))
264
+ ->Option.flatMap(JSON.Decode.string)
265
+ ->Option.mapOr(false, pos => String.compare(pos, afterPos) > 0.)
266
+ )
267
+ }
268
+ }
269
+
270
+ // --- Partition Key Query (direct table query, no GSI) ---
271
+
272
+ // Builds the QueryCommand.input for a single-partition lookup against the base
273
+ // table. Strong consistency is supported here because base-table reads can
274
+ // opt in (GSIs cannot — fundamental DynamoDB constraint), so this helper
275
+ // accepts an optional ~strongConsistency flag.
276
+ let buildQueryByPartitionKeyInput = (
277
+ table: resolvedTable,
278
+ partitionKey: string,
279
+ ~after: option<string>=?,
280
+ ~strongConsistency: bool=false,
281
+ ): QueryCommand.input => {
282
+ let expressionAttributeValues = Dict.fromArray([
283
+ (":pk", partitionKey->JSON.Encode.string),
284
+ ])
285
+ let (keyConditionExpression, expressionAttributeNames) = switch after {
286
+ | None => ("id = :pk", None)
287
+ | Some(afterPos) => {
288
+ expressionAttributeValues->Dict.set(":after", afterPos->JSON.Encode.string)
289
+ ("id = :pk AND #pos > :after", Some(Dict.fromArray([("#pos", "position")])))
290
+ }
291
+ }
292
+ let consistentRead = strongConsistency ? Some(true) : None
293
+ {
294
+ tableName: table.name,
295
+ ?consistentRead,
296
+ keyConditionExpression,
297
+ expressionAttributeValues,
298
+ ?expressionAttributeNames,
299
+ }
300
+ }
301
+
302
+ let queryByPartitionKey = async (
303
+ table: resolvedTable,
304
+ partitionKey: string,
305
+ ~after: option<string>=?,
306
+ ) => {
307
+ let queryParams = buildQueryByPartitionKeyInput(table, partitionKey, ~after?)
308
+ await queryStream(queryParams)->Stream.runCollect->Effect.runPromise
309
+ }
310
+
311
+ // --- Cross-partition (secondary-tag) read ---
312
+ //
313
+ // A `@crossPartition` tag is read across ALL partitions carrying it, not just
314
+ // the partition it keys. The per-tag `tag_<key>` GSI indexes the tag across
315
+ // every partition; Phase 3 down-projected it to `KEYS_ONLY`, so the read is a
316
+ // two-phase `Query`(keys: id, position) → per-key `GetItem`(base table) for the
317
+ // full event. GSI reads are eventually consistent (DynamoDB constraint) — the
318
+ // strongly-consistent append fence catches any staleness at commit (analysis
319
+ // Issue 13). The GSI returns keys in ascending `position` order (rangeKey =
320
+ // "position"), and `Stream.mapEffect` preserves order, so the resolved stream
321
+ // stays position-ordered for the k-way merge in `readStream`.
322
+
323
+ // Resolves a KEYS_ONLY GSI key-item ({id, position}) to its full base-table item.
324
+ let resolveBaseItem = (table: resolvedTable, keyItem: JSON.t): Effect.t<
325
+ option<JSON.t>,
326
+ string,
327
+ unit,
328
+ > =>
329
+ switch keyItem->JSON.Decode.object {
330
+ | None => Effect.succeed(None)
331
+ | Some(obj) =>
332
+ switch (obj->Dict.get("id"), obj->Dict.get("position")) {
333
+ | (Some(id), Some(position)) =>
334
+ let key = Dict.fromArray([("id", id), ("position", position)])
335
+ Effect.tryPromise(~catch=DynamoDb_Error.classify, () =>
336
+ GetCommand.send(GetCommand.make({tableName: table.name, key}))
337
+ )
338
+ ->Effect.retry(DynamoDb_Error.retrySchedule)
339
+ ->Effect.catchAll(err => Effect.fail(DynamoDb_Error.message(err)))
340
+ ->Effect.map(result => result.item)
341
+ | _ => Effect.succeed(None)
342
+ }
343
+ }
344
+
345
+ let queryBySingleTagCrossPartitionStream = (
346
+ table: resolvedTable,
347
+ tag: Reventless.DcbTag.tag,
348
+ ~after: option<string>=?,
349
+ ) => {
350
+ let indexName = tagToAttributeName(tag.key)
351
+ let expressionAttributeValues = Dict.fromArray([(":val", tag.value->JSON.Encode.string)])
352
+ let expressionAttributeNames = Dict.fromArray([("#tag", indexName)])
353
+ let keyConditionExpression = switch after {
354
+ | None => "#tag = :val"
355
+ | Some(afterPos) =>
356
+ expressionAttributeValues->Dict.set(":after", afterPos->JSON.Encode.string)
357
+ expressionAttributeNames->Dict.set("#pos", "position")
358
+ "#tag = :val AND #pos > :after"
359
+ }
360
+ let baseParams: QueryCommand.input = {
361
+ tableName: table.name,
362
+ indexName,
363
+ keyConditionExpression,
364
+ expressionAttributeValues,
365
+ expressionAttributeNames,
366
+ }
367
+ Stream.paginateEffect((None: option<dict<JSON.t>>), cursor =>
368
+ Effect.tryPromise(
369
+ ~catch=DynamoDb_Error.classify,
370
+ () => {
371
+ let params = switch cursor {
372
+ | None => baseParams
373
+ | Some(key) => {...baseParams, exclusiveStartKey: key}
374
+ }
375
+ QueryCommand.send(params->QueryCommand.make)
376
+ },
377
+ )
378
+ ->Effect.retry(DynamoDb_Error.retrySchedule)
379
+ ->Effect.catchAll(err => Effect.fail(DynamoDb_Error.message(err)))
380
+ ->Effect.map(result => (
381
+ result.items->Option.getOr([]),
382
+ result.lastEvaluatedKey->Option.map(key => Some(key)),
383
+ ))
384
+ )
385
+ ->Stream.mapEffect(keyItem => resolveBaseItem(table, keyItem))
386
+ ->Stream.flatMap(opt =>
387
+ switch opt {
388
+ | Some(item) => Stream.fromIterable([item])
389
+ | None => Stream.empty
390
+ }
391
+ )
392
+ }
393
+
394
+ // --- Query Item Execution ---
395
+
396
+ let executeQueryItem = async (
397
+ table: resolvedTable,
398
+ queryItem: Reventless.DcbTag.queryItem,
399
+ ~after: option<string>=?,
400
+ ~crossPartitionTagKeys: array<string>=[],
401
+ ) => {
402
+ switch queryItem.tags {
403
+ // Single tag: a `@crossPartition` key reads across partitions via the per-tag
404
+ // GSI (KEYS_ONLY → per-key GetItem, collected); otherwise a base-table
405
+ // partition lookup.
406
+ | Some([tag]) if crossPartitionTagKeys->Array.includes(tag.key) =>
407
+ await queryBySingleTagCrossPartitionStream(table, tag, ~after?)
408
+ ->Stream.runCollect
409
+ ->Effect.runPromise
410
+ | Some([tag]) =>
411
+ await queryByPartitionKey(table, `${tag.key}:${tag.value}`, ~after?)
412
+
413
+ // Multiple tags: use composite GSI
414
+ | Some(tags) if tags->Array.length > 1 =>
415
+ await queryByCompositeTags(table, tags, ~after?)
416
+
417
+ // No tags (or empty): fall back to scan
418
+ | None | Some([]) | Some(_) =>
419
+ switch queryItem.eventTypes {
420
+ | Some(eventTypes) => await scanWithFilter(table, ~eventTypes, ~after?)
421
+ | None => await scanWithFilter(table, ~after?)
422
+ }
423
+ }
424
+ }
425
+
426
+ // --- Deduplication ---
427
+
428
+ let deduplicateByPosition = (
429
+ events: array<ReventlessCore.DcbEventLog_Adapter.rawSequencedEvent>,
430
+ ): array<ReventlessCore.DcbEventLog_Adapter.rawSequencedEvent> => {
431
+ let seen = Set.make()
432
+ events->Array.filter(event => {
433
+ if seen->Set.has(event.position) {
434
+ false
435
+ } else {
436
+ seen->Set.add(event.position)
437
+ true
438
+ }
439
+ })
440
+ }
441
+
442
+ // --- K-way Merge (lazy, position-ordered) ---
443
+ //
444
+ // Used by readStream when all queryItems are tag-based (GSI queries). Each
445
+ // GSI sub-stream returns items in ascending `position` order (rangeKey = "position"),
446
+ // so an N-way merge-sort produces a globally-sorted output stream without
447
+ // materialising any sub-stream upfront.
448
+ //
449
+ // Each sub-stream is driven by a dedicated fiber pumping into a bounded Queue(1).
450
+ // The merge loop advances only the sub-stream with the current minimum position,
451
+ // so DynamoDB pages are fetched lazily — Stream.take(n) short-circuits pagination.
452
+
453
+ type mergeSlot = {
454
+ queue: Queue.t<option<ReventlessCore.DcbEventLog_Adapter.rawSequencedEvent>>,
455
+ head: option<ReventlessCore.DcbEventLog_Adapter.rawSequencedEvent>,
456
+ }
457
+
458
+ // Wraps a sub-stream in a Queue(1) producer fiber and pulls the first element.
459
+ let initSlot = (
460
+ stream: Stream.t<ReventlessCore.DcbEventLog_Adapter.rawSequencedEvent, string, unit>,
461
+ ): Effect.t<mergeSlot, string, unit> =>
462
+ Queue.bounded(1)->Effect.flatMap(queue => {
463
+ let producer =
464
+ stream
465
+ ->Stream.runForEach(event =>
466
+ Queue.offer(queue, Some(event))->Effect.map(_ => ())
467
+ )
468
+ // Always push the None sentinel so the merge loop can detect exhaustion,
469
+ // even if the stream fails (stream error is suppressed to prevent an
470
+ // unhandled-fiber-failure warning).
471
+ ->Effect.ensuring(Queue.offer(queue, None)->Effect.map(_ => ()))
472
+ ->Effect.catchAll(_ => Effect.succeed(()))
473
+ Effect.fork(producer)->Effect.flatMap(_ =>
474
+ Queue.take(queue)->Effect.map(head => {queue, head})
475
+ )
476
+ })
477
+
478
+ // Returns the index of the slot with the smallest position, or None if all exhausted.
479
+ let findMinSlotIdx = (slots: array<mergeSlot>) =>
480
+ slots->Array.reduceWithIndex(None, (minOpt, slot, idx) =>
481
+ switch (minOpt, slot.head) {
482
+ | (_, None) => minOpt
483
+ | (None, Some(_)) => Some(idx)
484
+ | (Some(minIdx), Some(head)) =>
485
+ let minSlot = slots->Array.getUnsafe(minIdx)
486
+ switch minSlot.head {
487
+ | None => Some(idx)
488
+ | Some(minHead) =>
489
+ String.compare(head.position, minHead.position) < 0. ? Some(idx) : minOpt
490
+ }
491
+ }
492
+ )
493
+
494
+ // Drops adjacent events with the same position (safe because the merged stream
495
+ // is already position-ordered — duplicates are always consecutive).
496
+ let dedupByPosition = (
497
+ stream: Stream.t<ReventlessCore.DcbEventLog_Adapter.rawSequencedEvent, string, unit>,
498
+ ) => {
499
+ let lastPos: ref<option<string>> = ref(None)
500
+ stream->Stream.filter(event =>
501
+ switch lastPos.contents {
502
+ | Some(pos) when pos == event.position => false
503
+ | _ =>
504
+ lastPos := Some(event.position)
505
+ true
506
+ }
507
+ )
508
+ }
509
+
510
+ // Merges N position-sorted streams into one position-sorted stream.
511
+ // Requires that every input stream emits events in ascending position order.
512
+ let mergeSortedEvents = (
513
+ streams: array<Stream.t<ReventlessCore.DcbEventLog_Adapter.rawSequencedEvent, string, unit>>,
514
+ ): Stream.t<ReventlessCore.DcbEventLog_Adapter.rawSequencedEvent, string, unit> =>
515
+ streams
516
+ ->Array.map(initSlot)
517
+ ->Effect.all({"concurrency": "unbounded"})
518
+ ->Stream.fromEffect
519
+ ->Stream.flatMap(initialSlots =>
520
+ Stream.paginateEffect(initialSlots, slots =>
521
+ switch findMinSlotIdx(slots) {
522
+ | None =>
523
+ // All sub-streams exhausted — terminate with an empty page.
524
+ Effect.succeed(([], None))
525
+ | Some(idx) =>
526
+ let slot = slots->Array.getUnsafe(idx)
527
+ let emitEvent = slot.head->Option.getOrThrow
528
+ Queue.take(slot.queue)->Effect.map(nextHead => {
529
+ let newSlots = slots->Array.mapWithIndex((s, i) =>
530
+ if i == idx {
531
+ {...s, head: nextHead}
532
+ } else {
533
+ s
534
+ }
535
+ )
536
+ let hasActive = newSlots->Array.some(s => s.head->Option.isSome)
537
+ ([emitEvent], hasActive ? Some(newSlots) : None)
538
+ })
539
+ }
540
+ )
541
+ )
542
+
543
+ // --- Main Operations ---
544
+
545
+ let read = (table: resolvedTable, ~crossPartitionTagKeys: array<string>=[]) =>
546
+ async (
547
+ ~query: Reventless.DcbTag.query,
548
+ ~after=?,
549
+ ) => {
550
+ // Execute queries for each queryItem
551
+ let queryResults = await query
552
+ ->Array.map(queryItem => executeQueryItem(table, queryItem, ~after?, ~crossPartitionTagKeys))
553
+ ->Promise.all
554
+
555
+ // Merge and deduplicate results
556
+ let allItems = queryResults->Array.flat
557
+ let allEvents = allItems->Array.map(fromItem)
558
+ let deduplicatedEvents = deduplicateByPosition(allEvents)
559
+
560
+ // Sort by position
561
+ let sortedEvents = deduplicatedEvents->Array.toSorted((a, b) =>
562
+ String.compare(a.position, b.position)
563
+ )
564
+
565
+ // Get head position (latest position)
566
+ let headPosition = sortedEvents
567
+ ->Array.toReversed
568
+ ->Array.at(0)
569
+ ->Option.map(event => event.position)
570
+
571
+ {
572
+ ReventlessCore.DcbEventLog_Adapter.events: sortedEvents,
573
+ ?headPosition,
574
+ }
575
+ }
576
+
577
+ // --- DCB consistency fences ---
578
+ //
579
+ // A fence is a sentinel item in the same table holding one position attribute PER
580
+ // EVENT TYPE — `pos#<eventType>` — instead of a single scalar. Each tag value
581
+ // (`<key>:<value>`) gets its own fence at `id = "fence#<key>:<value>", position =
582
+ // "FENCE"`. The id prefix is distinct from event partition keys (`<key>:<value>`),
583
+ // so event reads never see fence items.
584
+ //
585
+ // On conditional append, each query tag becomes a per-type `ConditionalUpdate` or
586
+ // read-only `ConditionCheck`: it asserts that no event OF A TYPE THE SLICE READS
587
+ // (`pos#<consumedType>`) has advanced past `after` since the decision-model read,
588
+ // and advances `pos#<producedType>` for the types it writes. Because the check is
589
+ // scoped to the consumed types, a slice reading only a SUBSET of a partition's
590
+ // event types no longer false-conflicts when a sibling type advances (analysis
591
+ // Issue 4); the OCC now mirrors the local backends' true query semantics. At
592
+ // `after=None` the partition-tag Update is gated on `attribute_not_exists(pos#…)`
593
+ // over the consumed ∪ produced types — the FOLDED CREATE GUARD that serializes
594
+ // concurrent first-writers (replacing the old `create#…` sentinel rows).
595
+ //
596
+ // All Puts and Updates ride a single `TransactWriteItems` call — atomic by
597
+ // DynamoDB. Conflicts surface as `TransactionCanceledException` →
598
+ // `DynamoDb_Error.StaleState` → `Error("Conflict: …")`.
599
+
600
+ let fenceSortKey = "FENCE"
601
+
602
+ let fencePartitionKey = (tag: Reventless.DcbTag.tag) => `fence#${tag.key}:${tag.value}`
603
+
604
+ let fenceKey = (tag: Reventless.DcbTag.tag): dict<JSON.t> =>
605
+ Dict.fromArray([
606
+ ("id", fencePartitionKey(tag)->JSON.Encode.string),
607
+ ("position", fenceSortKey->JSON.Encode.string),
608
+ ])
609
+
610
+ // Collect every distinct tag from a query. Each queryItem's tags are AND'd,
611
+ // across queryItems they're OR'd — both contribute to the fence set.
612
+ let collectQueryTags = (query: Reventless.DcbTag.query): array<Reventless.DcbTag.tag> => {
613
+ let seen = Set.make()
614
+ let acc = []
615
+ query->Array.forEach(qi =>
616
+ switch qi.tags {
617
+ | None => ()
618
+ | Some(tags) =>
619
+ tags->Array.forEach(tag => {
620
+ let k = `${tag.key}:${tag.value}`
621
+ if !(seen->Set.has(k)) {
622
+ seen->Set.add(k)
623
+ acc->Array.push(tag)
624
+ }
625
+ })
626
+ }
627
+ )
628
+ acc
629
+ }
630
+
631
+ let collectEventTags = (
632
+ events: array<ReventlessCore.DcbEventLog_Adapter.rawStoredEvent>,
633
+ ): array<Reventless.DcbTag.tag> => {
634
+ let seen = Set.make()
635
+ let acc = []
636
+ events->Array.forEach(event =>
637
+ event.tags->Array.forEach(tag => {
638
+ let k = `${tag.key}:${tag.value}`
639
+ if !(seen->Set.has(k)) {
640
+ seen->Set.add(k)
641
+ acc->Array.push(tag)
642
+ }
643
+ })
644
+ )
645
+ acc
646
+ }
647
+
648
+ // Per-event-type fence attributes. The fence sentinel holds one position
649
+ // attribute per event type — `pos#<eventType>` — instead of a single scalar
650
+ // `lastPosition`. This lets the OCC check mirror the read query's event-type
651
+ // filter: a slice reading only a subset of a partition's event types conflicts
652
+ // only when an event *of a type it reads* advances past its `after`, matching the
653
+ // local backends' true DCB query semantics (and fixing analysis Issue 4 — the
654
+ // one-event-type-per-attribute deadlock). It also folds the create guard into the
655
+ // fence (see `buildConditionalFenceUpdate`), retiring the `create#` rows.
656
+ // Plan: docs/plans/dcb-fence-event-type-granularity.md.
657
+ let fenceTypeAttr = (eventType: string) => `pos#${eventType}`
658
+
659
+ let dedupStrings = (xs: array<string>): array<string> => {
660
+ let seen = Set.make()
661
+ xs->Array.filter(x =>
662
+ if seen->Set.has(x) {
663
+ false
664
+ } else {
665
+ seen->Set.add(x)
666
+ true
667
+ }
668
+ )
669
+ }
670
+
671
+ // tagKey ("key:value") -> distinct event types among `events` that CARRY the tag.
672
+ // Used to advance the fences of composite query tags and cross-partition tags
673
+ // (bumped by every carrier), and by `appendUnconditional`.
674
+ let carriedTypesByTag = (
675
+ events: array<ReventlessCore.DcbEventLog_Adapter.rawStoredEvent>,
676
+ ): Dict.t<array<string>> => {
677
+ let d = Dict.make()
678
+ events->Array.forEach(e =>
679
+ e.tags->Array.forEach((t: Reventless.DcbTag.tag) => {
680
+ let k = `${t.key}:${t.value}`
681
+ let cur = d->Dict.get(k)->Option.getOr([])
682
+ if !(cur->Array.includes(e.eventType)) {
683
+ d->Dict.set(k, cur->Array.concat([e.eventType]))
684
+ }
685
+ })
686
+ )
687
+ d
688
+ }
689
+
690
+ // Conditional fence update with per-type granularity AND a folded create guard:
691
+ // - `producedTypes`: event types this append writes into the tag's partition; each
692
+ // `pos#<type>` is advanced to `newPosition`.
693
+ // - `consumedTypes`: event types the slice reads on this tag — the OCC check scope.
694
+ // - `after = Some(p)`: assert each consumed type's position has not advanced past
695
+ // `p` (`attribute_not_exists(pos#C) OR pos#C <= :after`).
696
+ // - `after = None`: the folded create guard — assert `attribute_not_exists` over
697
+ // every consumed AND produced type, so two concurrent first-writers of the same
698
+ // `(producedType, partition)` collide and only one commits. The produced arm
699
+ // guards against a double-create even when the produced type is not consumed and
700
+ // must NEVER be dropped (it replaces the old `create#` sentinel row).
701
+ let buildConditionalFenceUpdate = (
702
+ tableName: string,
703
+ tag: Reventless.DcbTag.tag,
704
+ ~consumedTypes: array<string>,
705
+ ~producedTypes: array<string>,
706
+ ~newPosition: string,
707
+ ~after: option<string>,
708
+ ): TransactWriteCommand.update => {
709
+ let names = Dict.make()
710
+ let values = Dict.fromArray([(":new", newPosition->JSON.Encode.string)])
711
+
712
+ let setClauses = producedTypes->Array.mapWithIndex((pt, i) => {
713
+ let ph = `#p${i->Int.toString}`
714
+ names->Dict.set(ph, fenceTypeAttr(pt))
715
+ `${ph} = :new`
716
+ })
717
+
718
+ let conditionExpression = switch after {
719
+ | None =>
720
+ dedupStrings(Array.concat(consumedTypes, producedTypes))
721
+ ->Array.mapWithIndex((t, i) => {
722
+ let ph = `#c${i->Int.toString}`
723
+ names->Dict.set(ph, fenceTypeAttr(t))
724
+ `attribute_not_exists(${ph})`
725
+ })
726
+ ->Array.join(" AND ")
727
+ | Some(pos) =>
728
+ values->Dict.set(":after", pos->JSON.Encode.string)
729
+ // A real append condition always carries the consumed types (buildQueryFromCommand
730
+ // sets them). Fall back to the produced types if a caller omits them, so the
731
+ // conditional Update never degenerates to an empty condition expression.
732
+ let checkTypes = consumedTypes->Array.length == 0 ? producedTypes : consumedTypes
733
+ checkTypes
734
+ ->Array.mapWithIndex((t, i) => {
735
+ let ph = `#c${i->Int.toString}`
736
+ names->Dict.set(ph, fenceTypeAttr(t))
737
+ `(attribute_not_exists(${ph}) OR ${ph} <= :after)`
738
+ })
739
+ ->Array.join(" AND ")
740
+ }
741
+
742
+ {
743
+ TransactWriteCommand.key: fenceKey(tag),
744
+ tableName,
745
+ updateExpression: `SET ${setClauses->Array.join(", ")}`,
746
+ conditionExpression,
747
+ expressionAttributeNames: names,
748
+ expressionAttributeValues: values,
749
+ }
750
+ }
751
+
752
+ // Unconditional per-type bump — used for tags appearing in new events but not gated
753
+ // by this append (composite query tags, cross-partition carriers, replay/seed). Each
754
+ // produced type's `pos#<type>` is advanced so future readers querying that tag detect
755
+ // the commit and conflict against any concurrent writer; no condition.
756
+ let buildUnconditionalFenceUpdate = (
757
+ tableName: string,
758
+ tag: Reventless.DcbTag.tag,
759
+ ~producedTypes: array<string>,
760
+ ~newPosition: string,
761
+ ): TransactWriteCommand.update => {
762
+ let names = Dict.make()
763
+ let values = Dict.fromArray([(":new", newPosition->JSON.Encode.string)])
764
+ let setClauses = producedTypes->Array.mapWithIndex((pt, i) => {
765
+ let ph = `#p${i->Int.toString}`
766
+ names->Dict.set(ph, fenceTypeAttr(pt))
767
+ `${ph} = :new`
768
+ })
769
+ {
770
+ TransactWriteCommand.key: fenceKey(tag),
771
+ tableName,
772
+ updateExpression: `SET ${setClauses->Array.join(", ")}`,
773
+ expressionAttributeNames: names,
774
+ expressionAttributeValues: values,
775
+ }
776
+ }
777
+
778
+ // Read-only fence assertion: verifies no writer bumped the fence past `after`
779
+ // WITHOUT advancing it. Used for query tags the slice *reads* but does not
780
+ // *partition* by — the read is partition-scoped, so bumping such a tag's fence
781
+ // here would falsely conflict every later writer that merely shares the tag
782
+ // value (e.g. a second order of the same product). See analysis
783
+ // `docs/analysis/dcb-consistency-check-issues.md`.
784
+ let buildFenceConditionCheck = (
785
+ tableName: string,
786
+ tag: Reventless.DcbTag.tag,
787
+ ~consumedTypes: array<string>,
788
+ ~after: option<string>,
789
+ ): TransactWriteCommand.conditionCheck => {
790
+ let names = Dict.make()
791
+ switch after {
792
+ | None =>
793
+ let conditionExpression =
794
+ consumedTypes
795
+ ->Array.mapWithIndex((t, i) => {
796
+ let ph = `#c${i->Int.toString}`
797
+ names->Dict.set(ph, fenceTypeAttr(t))
798
+ `attribute_not_exists(${ph})`
799
+ })
800
+ ->Array.join(" AND ")
801
+ {
802
+ TransactWriteCommand.key: fenceKey(tag),
803
+ tableName,
804
+ conditionExpression,
805
+ expressionAttributeNames: names,
806
+ }
807
+ | Some(pos) =>
808
+ let conditionExpression =
809
+ consumedTypes
810
+ ->Array.mapWithIndex((t, i) => {
811
+ let ph = `#c${i->Int.toString}`
812
+ names->Dict.set(ph, fenceTypeAttr(t))
813
+ `(attribute_not_exists(${ph}) OR ${ph} <= :after)`
814
+ })
815
+ ->Array.join(" AND ")
816
+ {
817
+ TransactWriteCommand.key: fenceKey(tag),
818
+ tableName,
819
+ conditionExpression,
820
+ expressionAttributeNames: names,
821
+ expressionAttributeValues: Dict.fromArray([(":after", pos->JSON.Encode.string)]),
822
+ }
823
+ }
824
+ }
825
+
826
+ // The create guard is now FOLDED INTO THE FENCE (see `buildConditionalFenceUpdate`).
827
+ // At `after=None` the partition-tag fence is a conditional Update gated on
828
+ // `attribute_not_exists(pos#<producedType>)`, which serializes concurrent
829
+ // first-writers of the same `(producedType, partition)` without a separate sentinel
830
+ // row. The old per-(eventType, partition) `create#…` rows are retired — per-type
831
+ // `pos#` attributes are type-scoped, so they don't false-conflict a subset-type
832
+ // slice the way a scalar `lastPosition` did (analysis Issues 2 & 4).
833
+
834
+ // `TransactWriteItems` is capped at 100 items per call. We surface a clear
835
+ // error before calling AWS rather than leaking ValidationException upstream.
836
+ let transactWriteItemsLimit = 100
837
+
838
+ let buildEventPuts = (
839
+ table: resolvedTable,
840
+ events: array<ReventlessCore.DcbEventLog_Adapter.rawStoredEvent>,
841
+ basePosition: string,
842
+ ~partitionTag=?,
843
+ ) =>
844
+ events->Array.mapWithIndex((event, idx) => {
845
+ let position = generatePositionForBatch(basePosition, idx)
846
+ let recordedAt = ReventlessCore.Message.nowAsISOString()
847
+ let item = toItem(position, event, ~partitionTag?, ~recordedAt)
848
+ let put: TransactWriteCommand.put = {
849
+ TransactWriteCommand.item: item,
850
+ tableName: table.name,
851
+ }
852
+ {TransactWriteCommand.put: put}
853
+ })
854
+
855
+ let runTransactWrite = async (
856
+ input: TransactWriteCommand.input,
857
+ basePosition: string,
858
+ ~errorPrefix: string,
859
+ ) =>
860
+ await Effect.tryPromise(~catch=DynamoDb_Error.classify, () => input->TransactWriteCommand.make->TransactWriteCommand.send)
861
+ ->Effect.map(_ => Ok(basePosition))
862
+ ->Effect.catchAll(err =>
863
+ switch err {
864
+ | DynamoDb_Error.StaleState(msg) => Effect.succeed(Error(`Conflict: ${msg}`))
865
+ | Transient(msg) | Permanent(msg) =>
866
+ Effect.succeed(Error(`${errorPrefix}: ${msg}`))
867
+ }
868
+ )
869
+ ->Effect.runPromise
870
+
871
+ // Writes events without a consistency check but still bumps every event-tag
872
+ // fence so concurrent conditional writers cannot miss this commit. The fence
873
+ // bumps are unconditional — there is no `after` to check against — but they
874
+ // share a single `TransactWriteItems` with the event Puts so the operation is
875
+ // atomic. Use this path for imports, seeding, and replay tooling that don't
876
+ // need optimistic concurrency but must remain visible to DCB readers.
877
+ let appendUnconditional = async (
878
+ table: resolvedTable,
879
+ events: array<ReventlessCore.DcbEventLog_Adapter.rawStoredEvent>,
880
+ ~partitionTag=?,
881
+ ) => {
882
+ let eventTags = collectEventTags(events)
883
+ let totalItems = events->Array.length + eventTags->Array.length
884
+
885
+ if events->Array.length == 0 {
886
+ Ok(generatePosition())
887
+ } else if totalItems > transactWriteItemsLimit {
888
+ Error(
889
+ `DCB append: TransactWriteItems limit exceeded (${totalItems->Int.toString} > ${transactWriteItemsLimit->Int.toString}); reduce events or distinct tag values per command`,
890
+ )
891
+ } else {
892
+ let basePosition = generatePosition()
893
+ let carriedMap = carriedTypesByTag(events)
894
+ let putItems = buildEventPuts(table, events, basePosition, ~partitionTag?)
895
+ let updateItems =
896
+ eventTags->Array.filterMap(tag => {
897
+ let producedTypes = carriedMap->Dict.get(`${tag.key}:${tag.value}`)->Option.getOr([])
898
+ producedTypes->Array.length == 0
899
+ ? None
900
+ : Some({
901
+ TransactWriteCommand.update: buildUnconditionalFenceUpdate(
902
+ table.name,
903
+ tag,
904
+ ~producedTypes,
905
+ ~newPosition=basePosition,
906
+ ),
907
+ })
908
+ })
909
+ let input: TransactWriteCommand.input = {
910
+ transactItems: Array.concat(putItems, updateItems),
911
+ }
912
+ await runTransactWrite(input, basePosition, ~errorPrefix="DCB append failed")
913
+ }
914
+ }
915
+
916
+ // The partition tag(s) of a written event — the ONLY fences an append may BUMP.
917
+ // A tag's fence must track exactly the partition-scoped events a single-tag read
918
+ // of that tag observes (events are stored under `id="<partitionKey>:<value>"`),
919
+ // so only the partition tag may advance it. Mirrors `derivePartitionKey`.
920
+ //
921
+ // For a Composite partition tag there is no single fence key that represents the
922
+ // partition, so we keep the historical behaviour (treat every tag as a partition
923
+ // tag) rather than risk under-fencing composite-partition slices.
924
+ let eventPartitionTags = (
925
+ event: ReventlessCore.DcbEventLog_Adapter.rawStoredEvent,
926
+ ~partitionTag: option<Reventless.DcbTag.derivedPartitionTag>,
927
+ ): array<Reventless.DcbTag.tag> =>
928
+ switch partitionTag {
929
+ | Some(Composite(_)) => event.tags
930
+ | Some(Simple(pt)) =>
931
+ switch event.tags->Array.find(t => t.key == pt.key) {
932
+ | Some(t) => [t]
933
+ | None => switch event.tags->Array.get(0) {
934
+ | Some(t) => [t]
935
+ | None => []
936
+ }
937
+ }
938
+ | None =>
939
+ switch event.tags->Array.get(0) {
940
+ | Some(t) => [t]
941
+ | None => []
942
+ }
943
+ }
944
+
945
+ let collectEventPartitionTags = (
946
+ events: array<ReventlessCore.DcbEventLog_Adapter.rawStoredEvent>,
947
+ ~partitionTag: option<Reventless.DcbTag.derivedPartitionTag>,
948
+ ): array<Reventless.DcbTag.tag> => {
949
+ let seen = Set.make()
950
+ let acc = []
951
+ events->Array.forEach(event =>
952
+ eventPartitionTags(event, ~partitionTag)->Array.forEach(tag => {
953
+ let k = `${tag.key}:${tag.value}`
954
+ if !(seen->Set.has(k)) {
955
+ seen->Set.add(k)
956
+ acc->Array.push(tag)
957
+ }
958
+ })
959
+ )
960
+ acc
961
+ }
962
+
963
+ // tagKey ("key:value") -> distinct event types among `events` PARTITIONED by the
964
+ // tag. A partition-scoped fence advances only for events whose partition tag is this
965
+ // one (Issue 1), so this is the produced-type set for partition-tag bumps/updates.
966
+ let partitionTypesByTag = (
967
+ events: array<ReventlessCore.DcbEventLog_Adapter.rawStoredEvent>,
968
+ ~partitionTag: option<Reventless.DcbTag.derivedPartitionTag>,
969
+ ): Dict.t<array<string>> => {
970
+ let d = Dict.make()
971
+ events->Array.forEach(e =>
972
+ eventPartitionTags(e, ~partitionTag)->Array.forEach((t: Reventless.DcbTag.tag) => {
973
+ let k = `${t.key}:${t.value}`
974
+ let cur = d->Dict.get(k)->Option.getOr([])
975
+ if !(cur->Array.includes(e.eventType)) {
976
+ d->Dict.set(k, cur->Array.concat([e.eventType]))
977
+ }
978
+ })
979
+ )
980
+ d
981
+ }
982
+
983
+ // Builds the full ordered `TransactWriteItems` array for a conditional append:
984
+ // event Puts, then fence Updates (check + bump), then fence ConditionChecks
985
+ // (check only), then unconditional bumps. Pure — no IO — so the transaction shape
986
+ // is unit-testable without DynamoDB.
987
+ let buildConditionalTransactItems = (
988
+ table: resolvedTable,
989
+ events: array<ReventlessCore.DcbEventLog_Adapter.rawStoredEvent>,
990
+ cond: Reventless.DcbTag.appendCondition,
991
+ basePosition: string,
992
+ ~partitionTag: option<Reventless.DcbTag.derivedPartitionTag>=?,
993
+ ~crossPartitionTagKeys: array<string>=[],
994
+ ): array<TransactWriteCommand.transactWriteItem> => {
995
+ // The partitions this append writes into — the only fences it may BUMP
996
+ // (partition-scoped tags only; cross-partition tags below override this).
997
+ let partitionTags = collectEventPartitionTags(events, ~partitionTag)
998
+ let partitionKeySet = Set.make()
999
+ partitionTags->Array.forEach(t => partitionKeySet->Set.add(`${t.key}:${t.value}`))
1000
+ let isPartition = (t: Reventless.DcbTag.tag) => partitionKeySet->Set.has(`${t.key}:${t.value}`)
1001
+
1002
+ // Cross-partition tags this append must fence regardless of which partition it
1003
+ // writes into. A `@crossPartition` tag is read across ALL partitions (per-tag
1004
+ // GSI), so OCC requires EVERY carrier — primary or secondary — to bump its
1005
+ // fence; else a concurrent secondary-tag writer goes undetected. This reverts
1006
+ // Issue 1's narrowing, but only for tags declared cross-partition (analysis
1007
+ // Issue 13). Partition-scoped tags keep the narrow rule.
1008
+ let isCrossPartition = (t: Reventless.DcbTag.tag) => crossPartitionTagKeys->Array.includes(t.key)
1009
+ let crossPartitionEventTags = collectEventTags(events)->Array.filter(isCrossPartition)
1010
+
1011
+ // Per-type position maps: which event types this append advances for each tag.
1012
+ // Partition-scoped tags advance only for events partitioned by them (Issue 1);
1013
+ // composite query tags and cross-partition tags advance for every carrier.
1014
+ let carriedMap = carriedTypesByTag(events)
1015
+ let partitionMap = partitionTypesByTag(events, ~partitionTag)
1016
+ let producedTypesFor = (t: Reventless.DcbTag.tag) => {
1017
+ let k = `${t.key}:${t.value}`
1018
+ (isPartition(t) && !isCrossPartition(t) ? partitionMap : carriedMap)
1019
+ ->Dict.get(k)
1020
+ ->Option.getOr([])
1021
+ }
1022
+
1023
+ // Consumed event types per tag value, from the query clauses — the OCC check
1024
+ // scope. A clause with no `eventTypes` contributes nothing (vacuous — Issue 14).
1025
+ let consumedMap = Dict.make()
1026
+ cond.query->Array.forEach(qi =>
1027
+ switch (qi.tags, qi.eventTypes) {
1028
+ | (Some(tags), Some(ets)) =>
1029
+ tags->Array.forEach(t => {
1030
+ let k = `${t.key}:${t.value}`
1031
+ let cur = consumedMap->Dict.get(k)->Option.getOr([])
1032
+ consumedMap->Dict.set(k, dedupStrings(Array.concat(cur, ets)))
1033
+ })
1034
+ | _ => ()
1035
+ }
1036
+ )
1037
+ let consumedTypesFor = (t: Reventless.DcbTag.tag) =>
1038
+ consumedMap->Dict.get(`${t.key}:${t.value}`)->Option.getOr([])
1039
+
1040
+ // Distinct tags belonging to multi-tag (composite GSI) query clauses. A composite
1041
+ // read can match cross-partition events, so its fences keep the historical
1042
+ // check+bump treatment to stay correct.
1043
+ let compositeKeySet = Set.make()
1044
+ let compositeQueryTags = []
1045
+ cond.query->Array.forEach(qi =>
1046
+ switch qi.tags {
1047
+ | Some(tags) if tags->Array.length > 1 =>
1048
+ tags->Array.forEach(tag => {
1049
+ let k = `${tag.key}:${tag.value}`
1050
+ if !(compositeKeySet->Set.has(k)) {
1051
+ compositeKeySet->Set.add(k)
1052
+ compositeQueryTags->Array.push(tag)
1053
+ }
1054
+ })
1055
+ | _ => ()
1056
+ }
1057
+ )
1058
+ let isCompositeQueryTag = (t: Reventless.DcbTag.tag) =>
1059
+ compositeKeySet->Set.has(`${t.key}:${t.value}`)
1060
+
1061
+ // Classify the conditional fence items per query clause.
1062
+ let updateKeySet = Set.make()
1063
+ let conditionalUpdateTags = []
1064
+ let checkKeySet = Set.make()
1065
+ let conditionCheckTags = []
1066
+ let pushUpdate = (tag: Reventless.DcbTag.tag) => {
1067
+ let k = `${tag.key}:${tag.value}`
1068
+ if !(updateKeySet->Set.has(k)) {
1069
+ updateKeySet->Set.add(k)
1070
+ conditionalUpdateTags->Array.push(tag)
1071
+ }
1072
+ }
1073
+ let pushCheck = (tag: Reventless.DcbTag.tag) => {
1074
+ let k = `${tag.key}:${tag.value}`
1075
+ if !(checkKeySet->Set.has(k)) {
1076
+ checkKeySet->Set.add(k)
1077
+ conditionCheckTags->Array.push(tag)
1078
+ }
1079
+ }
1080
+
1081
+ cond.query->Array.forEach(qi =>
1082
+ switch qi.tags {
1083
+ // Single-tag clause. A `@crossPartition` tag is a cross-partition read, so
1084
+ // its fence is BUMPED (conditional Update) by every carrier. A partition-scoped
1085
+ // tag keeps the narrow rule: BUMP only when this append writes into that
1086
+ // partition (or it backs a composite read), else read-only ConditionCheck. At
1087
+ // after=None the partition tag becomes a conditional Update too — the FOLDED
1088
+ // CREATE GUARD (`attribute_not_exists(pos#<producedType>)`) serializing
1089
+ // first-writers; the per-type attribute keeps it from false-conflicting a
1090
+ // subset-type slice. Non-partition single tags at after=None do nothing.
1091
+ | Some([tag]) =>
1092
+ if isCrossPartition(tag) {
1093
+ pushUpdate(tag)
1094
+ } else {
1095
+ switch cond.after {
1096
+ | Some(_) =>
1097
+ if isPartition(tag) || isCompositeQueryTag(tag) {
1098
+ pushUpdate(tag)
1099
+ } else {
1100
+ pushCheck(tag)
1101
+ }
1102
+ | None =>
1103
+ if isPartition(tag) {
1104
+ pushUpdate(tag)
1105
+ }
1106
+ }
1107
+ }
1108
+ // Multi-tag clause = composite (GSI) read — keep check+bump on every tag at
1109
+ // after=Some; at after=None the composite tags fall through to bumps below.
1110
+ | Some(tags) if tags->Array.length > 1 =>
1111
+ switch cond.after {
1112
+ | Some(_) => tags->Array.forEach(pushUpdate)
1113
+ | None => ()
1114
+ }
1115
+ | _ => ()
1116
+ }
1117
+ )
1118
+
1119
+ // Folded create guard coverage: ensure EVERY event partition tag is a conditional
1120
+ // Update at after=None, even one that never appeared as its own single-tag query
1121
+ // clause (matches the old per-(eventType, partition) create guard's reach).
1122
+ // `pushUpdate` dedups, so a partition tag already routed above is untouched.
1123
+ switch cond.after {
1124
+ | None => partitionTags->Array.forEach(pushUpdate)
1125
+ | Some(_) => ()
1126
+ }
1127
+
1128
+ // Unconditional bumps. Advance fences for the partitions this append writes into
1129
+ // (so future partition-scoped readers detect it) plus composite query tags (so
1130
+ // composite readers detect it) plus every cross-partition tag this event carries
1131
+ // (primary OR secondary — a cross-partition reader of that tag must detect this
1132
+ // write even when this command does not read by it), minus anything a conditional
1133
+ // Update already bumps. Partition-scoped secondary event tags are still NEVER
1134
+ // bumped here — doing so was the source of the cross-partition false conflicts.
1135
+ let bumpSeen = Set.make()
1136
+ conditionalUpdateTags->Array.forEach(t => bumpSeen->Set.add(`${t.key}:${t.value}`))
1137
+ let bumpTags = []
1138
+ let candidateBumps = switch cond.after {
1139
+ | Some(_) => Array.concat(partitionTags, crossPartitionEventTags)
1140
+ | None => Array.concat(partitionTags, Array.concat(compositeQueryTags, crossPartitionEventTags))
1141
+ }
1142
+ candidateBumps->Array.forEach(tag => {
1143
+ let k = `${tag.key}:${tag.value}`
1144
+ if !(bumpSeen->Set.has(k)) {
1145
+ bumpSeen->Set.add(k)
1146
+ bumpTags->Array.push(tag)
1147
+ }
1148
+ })
1149
+
1150
+ let putItems = buildEventPuts(table, events, basePosition, ~partitionTag?)
1151
+ let updateItems =
1152
+ conditionalUpdateTags->Array.filterMap(tag => {
1153
+ let producedTypes = producedTypesFor(tag)
1154
+ // A conditional Update must advance ≥1 produced type; partition / composite /
1155
+ // cross-partition carriers always have one. If none (a misconfigured clause),
1156
+ // skip rather than emit an empty `SET`.
1157
+ producedTypes->Array.length == 0
1158
+ ? None
1159
+ : Some({
1160
+ TransactWriteCommand.update: buildConditionalFenceUpdate(
1161
+ table.name,
1162
+ tag,
1163
+ ~consumedTypes=consumedTypesFor(tag),
1164
+ ~producedTypes,
1165
+ ~newPosition=basePosition,
1166
+ ~after=cond.after,
1167
+ ),
1168
+ })
1169
+ })
1170
+ let checkItems =
1171
+ conditionCheckTags->Array.filterMap(tag => {
1172
+ let consumedTypes = consumedTypesFor(tag)
1173
+ // A vacuous clause (no consumed type carries the tag) matches nothing — skip.
1174
+ consumedTypes->Array.length == 0
1175
+ ? None
1176
+ : Some({
1177
+ TransactWriteCommand.conditionCheck: buildFenceConditionCheck(
1178
+ table.name,
1179
+ tag,
1180
+ ~consumedTypes,
1181
+ ~after=cond.after,
1182
+ ),
1183
+ })
1184
+ })
1185
+ let bumpItems =
1186
+ bumpTags->Array.filterMap(tag => {
1187
+ let producedTypes = producedTypesFor(tag)
1188
+ producedTypes->Array.length == 0
1189
+ ? None
1190
+ : Some({
1191
+ TransactWriteCommand.update: buildUnconditionalFenceUpdate(
1192
+ table.name,
1193
+ tag,
1194
+ ~producedTypes,
1195
+ ~newPosition=basePosition,
1196
+ ),
1197
+ })
1198
+ })
1199
+
1200
+ Array.concat(putItems, Array.concat(updateItems, Array.concat(checkItems, bumpItems)))
1201
+ }
1202
+
1203
+ let appendConditional = async (
1204
+ table: resolvedTable,
1205
+ events: array<ReventlessCore.DcbEventLog_Adapter.rawStoredEvent>,
1206
+ cond: Reventless.DcbTag.appendCondition,
1207
+ ~partitionTag=?,
1208
+ ~crossPartitionTagKeys: array<string>=[],
1209
+ ) => {
1210
+ let queryTags = collectQueryTags(cond.query)
1211
+
1212
+ if queryTags->Array.length == 0 {
1213
+ Error(
1214
+ "DCB append: tagless conditions are not supported on DynamoDB — every queryItem must have at least one tag",
1215
+ )
1216
+ } else {
1217
+ let basePosition = generatePosition()
1218
+ let transactItems = buildConditionalTransactItems(table, events, cond, basePosition, ~partitionTag?, ~crossPartitionTagKeys)
1219
+ let totalItems = transactItems->Array.length
1220
+ if totalItems > transactWriteItemsLimit {
1221
+ Error(
1222
+ `DCB append: TransactWriteItems limit exceeded (${totalItems->Int.toString} > ${transactWriteItemsLimit->Int.toString}); reduce events or distinct tag values per command`,
1223
+ )
1224
+ } else {
1225
+ let input: TransactWriteCommand.input = {transactItems: transactItems}
1226
+ await runTransactWrite(input, basePosition, ~errorPrefix="DCB append failed")
1227
+ }
1228
+ }
1229
+ }
1230
+
1231
+ let append = (table: resolvedTable, ~partitionTag=?, ~crossPartitionTagKeys: array<string>=[]) =>
1232
+ async (
1233
+ events: array<ReventlessCore.DcbEventLog_Adapter.rawStoredEvent>,
1234
+ ~condition=?,
1235
+ ) => {
1236
+ switch condition {
1237
+ | None => await appendUnconditional(table, events, ~partitionTag?)
1238
+ | Some(cond) => await appendConditional(table, events, cond, ~partitionTag?, ~crossPartitionTagKeys)
1239
+ }
1240
+ }
1241
+
1242
+ // --- Stream Query Operations (lazy pagination via Stream.paginateEffect) ---
1243
+
1244
+ let queryByPartitionKeyStream = (
1245
+ table: resolvedTable,
1246
+ partitionKey: string,
1247
+ ~after: option<string>=?,
1248
+ ~strongConsistency: bool=false,
1249
+ ) => {
1250
+ let baseParams = buildQueryByPartitionKeyInput(
1251
+ table,
1252
+ partitionKey,
1253
+ ~after?,
1254
+ ~strongConsistency,
1255
+ )
1256
+ Stream.paginateEffect((None: option<dict<JSON.t>>), cursor =>
1257
+ Effect.tryPromise(
1258
+ ~catch=DynamoDb_Error.classify,
1259
+ () => {
1260
+ let params = switch cursor {
1261
+ | None => baseParams
1262
+ | Some(key) => {...baseParams, exclusiveStartKey: key}
1263
+ }
1264
+ QueryCommand.send(params->QueryCommand.make)
1265
+ },
1266
+ )
1267
+ ->Effect.retry(DynamoDb_Error.retrySchedule)
1268
+ ->Effect.catchAll(err => Effect.fail(DynamoDb_Error.message(err)))
1269
+ ->Effect.map(result => (
1270
+ result.items->Option.getOr([]),
1271
+ result.lastEvaluatedKey->Option.map(key => Some(key)),
1272
+ ))
1273
+ )
1274
+ }
1275
+
1276
+ let queryByCompositeTagsStream = (
1277
+ table: resolvedTable,
1278
+ tags: array<Reventless.DcbTag.tag>,
1279
+ ~after: option<string>=?,
1280
+ ) => {
1281
+ let composite = compositeTagKey(tags)
1282
+ let expressionAttributeValues = Dict.fromArray([
1283
+ (":composite", composite->JSON.Encode.string),
1284
+ ])
1285
+ let (keyConditionExpression, expressionAttributeNames) = switch after {
1286
+ | None => ("tag_composite = :composite", None)
1287
+ | Some(afterPos) => {
1288
+ expressionAttributeValues->Dict.set(":after", afterPos->JSON.Encode.string)
1289
+ ("tag_composite = :composite AND #pos > :after", Some(Dict.fromArray([("#pos", "position")])))
1290
+ }
1291
+ }
1292
+ let baseParams: QueryCommand.input = {
1293
+ tableName: table.name,
1294
+ indexName: "tag_composite",
1295
+ keyConditionExpression: keyConditionExpression,
1296
+ expressionAttributeValues: expressionAttributeValues,
1297
+ ?expressionAttributeNames,
1298
+ }
1299
+ Stream.paginateEffect((None: option<dict<JSON.t>>), cursor =>
1300
+ Effect.tryPromise(
1301
+ ~catch=DynamoDb_Error.classify,
1302
+ () => {
1303
+ let params = switch cursor {
1304
+ | None => baseParams
1305
+ | Some(key) => {...baseParams, exclusiveStartKey: key}
1306
+ }
1307
+ QueryCommand.send(params->QueryCommand.make)
1308
+ },
1309
+ )
1310
+ ->Effect.retry(DynamoDb_Error.retrySchedule)
1311
+ ->Effect.catchAll(err => Effect.fail(DynamoDb_Error.message(err)))
1312
+ ->Effect.map(result => (
1313
+ result.items->Option.getOr([]),
1314
+ result.lastEvaluatedKey->Option.map(key => Some(key)),
1315
+ ))
1316
+ )
1317
+ }
1318
+
1319
+ let scanWithFilterStream = (
1320
+ table: resolvedTable,
1321
+ ~eventTypes: option<array<string>>=?,
1322
+ ~after: option<string>=?,
1323
+ ) => {
1324
+ let filter = buildScanFilter(~eventTypes?)
1325
+
1326
+ let baseParams: ScanCommand.input = {
1327
+ tableName: table.name,
1328
+ filterExpression: ?filter.filterExpression,
1329
+ expressionAttributeValues: ?filter.expressionAttributeValues,
1330
+ expressionAttributeNames: ?filter.expressionAttributeNames,
1331
+ }
1332
+
1333
+ // position cannot appear in FilterExpression (it is the table's sort key).
1334
+ // Filter by position in application code after each page is fetched.
1335
+ let baseStream = Stream.paginateEffect((None: option<dict<JSON.t>>), cursor =>
1336
+ Effect.tryPromise(
1337
+ ~catch=DynamoDb_Error.classify,
1338
+ () => {
1339
+ let params = switch cursor {
1340
+ | None => baseParams
1341
+ | Some(key) => {...baseParams, exclusiveStartKey: key}
1342
+ }
1343
+ ScanCommand.send(ScanCommand.make(params))
1344
+ },
1345
+ )
1346
+ ->Effect.retry(DynamoDb_Error.retrySchedule)
1347
+ ->Effect.catchAll(err => Effect.fail(DynamoDb_Error.message(err)))
1348
+ ->Effect.map(result => (
1349
+ result.items->Option.getOr([]),
1350
+ result.lastEvaluatedKey->Option.map(key => Some(key)),
1351
+ ))
1352
+ )
1353
+
1354
+ switch after {
1355
+ | None => baseStream
1356
+ | Some(afterPos) =>
1357
+ baseStream->Stream.filter(item =>
1358
+ item
1359
+ ->JSON.Decode.object
1360
+ ->Option.flatMap(obj => obj->Dict.get("position"))
1361
+ ->Option.flatMap(JSON.Decode.string)
1362
+ ->Option.mapOr(false, pos => String.compare(pos, afterPos) > 0.)
1363
+ )
1364
+ }
1365
+ }
1366
+
1367
+ let executeQueryItemStream = (
1368
+ table: resolvedTable,
1369
+ queryItem: Reventless.DcbTag.queryItem,
1370
+ ~after: option<string>=?,
1371
+ ~strongConsistency: bool=false,
1372
+ ~crossPartitionTagKeys: array<string>=[],
1373
+ ) => {
1374
+ switch queryItem.tags {
1375
+ // Single tag: a `@crossPartition` key reads every partition via the per-tag
1376
+ // GSI (eventually consistent — fence catches staleness); otherwise a direct
1377
+ // base-table partition lookup. Base-table reads can opt into strong
1378
+ // consistency; the slice callback defaults to eventual and escalates on retry.
1379
+ // GSI-backed branches (cross-partition, composite, scan) cannot opt in —
1380
+ // fundamental DynamoDB constraint — so they stay eventually consistent.
1381
+ | Some([tag]) if crossPartitionTagKeys->Array.includes(tag.key) =>
1382
+ queryBySingleTagCrossPartitionStream(table, tag, ~after?)
1383
+ | Some([tag]) =>
1384
+ queryByPartitionKeyStream(
1385
+ table,
1386
+ `${tag.key}:${tag.value}`,
1387
+ ~after?,
1388
+ ~strongConsistency,
1389
+ )
1390
+
1391
+ // Multiple tags: use composite GSI
1392
+ | Some(tags) if tags->Array.length > 1 =>
1393
+ queryByCompositeTagsStream(table, tags, ~after?)
1394
+
1395
+ // No tags (or empty): fall back to scan
1396
+ | None | Some([]) | Some(_) =>
1397
+ switch queryItem.eventTypes {
1398
+ | Some(eventTypes) => scanWithFilterStream(table, ~eventTypes, ~after?)
1399
+ | None => scanWithFilterStream(table, ~after?)
1400
+ }
1401
+ }
1402
+ }
1403
+
1404
+ let readStream = (table: resolvedTable, ~crossPartitionTagKeys: array<string>=[]) =>
1405
+ (~query: Reventless.DcbTag.query, ~after=?, ~strongConsistency=false) => {
1406
+ let streams =
1407
+ query->Array.map(qi =>
1408
+ executeQueryItemStream(table, qi, ~after?, ~strongConsistency, ~crossPartitionTagKeys)
1409
+ )
1410
+ switch streams->Array.length {
1411
+ | 0 => Stream.empty
1412
+ | 1 => (streams->Array.getUnsafe(0))->Stream.map(fromItem)
1413
+ | _ =>
1414
+ // Scan sub-queries (tags = None) return items in unspecified order, so
1415
+ // a lazy k-way merge is not possible. Fall back to eager collect + sort.
1416
+ let hasScan = query->Array.some(qi => qi.tags == None)
1417
+ if hasScan {
1418
+ streams
1419
+ ->Array.map(s => s->Stream.runCollect)
1420
+ ->Effect.all({"concurrency": 3})
1421
+ ->Effect.map(results => {
1422
+ let allItems = results->Array.flat->Array.map(fromItem)
1423
+ let deduped = deduplicateByPosition(allItems)
1424
+ deduped->Array.toSorted((a, b) => String.compare(a.position, b.position))
1425
+ })
1426
+ ->Stream.fromEffect
1427
+ ->Stream.flatMap(events => Stream.fromIterable(events))
1428
+ } else {
1429
+ // All sub-streams are tag-based GSI queries — items arrive in position
1430
+ // order (rangeKey = "position"). Merge lazily: DynamoDB pages are only
1431
+ // fetched as the consumer requests more elements.
1432
+ mergeSortedEvents(streams->Array.map(s => s->Stream.map(fromItem)))
1433
+ ->dedupByPosition
1434
+ }
1435
+ }
1436
+ }