@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.
- package/CHANGELOG.md +1564 -0
- package/README.md +62 -0
- package/__mocks__/emptyModule.js +1 -0
- package/all-licenses.csv +565 -0
- package/docker-compose.dynamodb-local.yml +12 -0
- package/jest.setup.cjs +5 -0
- package/licenses-analysis.ods +0 -0
- package/package.json +73 -0
- package/rescript.json +39 -0
- package/reventless-licenses-analysis.xls +0 -0
- package/scripts/run-integration-tests.sh +43 -0
- package/src/Platform.res +1940 -0
- package/src/Platform.res.mjs +2281 -0
- package/src/Platform_Casts.res +106 -0
- package/src/Platform_Casts.res.mjs +13 -0
- package/src/Platform_Stack.res +153 -0
- package/src/Platform_Stack.res.mjs +121 -0
- package/src/Types.res +39 -0
- package/src/Types.res.mjs +21 -0
- package/src/adapter/AWS.res +96 -0
- package/src/adapter/AWS.res.mjs +186 -0
- package/src/adapter/AWS_Tags.res +16 -0
- package/src/adapter/AWS_Tags.res.mjs +48 -0
- package/src/adapter/Adapter_Helpers.res +19 -0
- package/src/adapter/Adapter_Helpers.res.mjs +37 -0
- package/src/adapter/Api/AppSync_EventsApi.res +172 -0
- package/src/adapter/Api/AppSync_EventsApi.res.mjs +96 -0
- package/src/adapter/Api/AppSync_Resolver_Native.res +93 -0
- package/src/adapter/Api/AppSync_Resolver_Native.res.mjs +67 -0
- package/src/adapter/Api/AppSync_Resolver_Retrying.res +653 -0
- package/src/adapter/Api/AppSync_Resolver_Retrying.res.mjs +462 -0
- package/src/adapter/Api/CommandSubscriptionResolvers_AppSync.res +29 -0
- package/src/adapter/Api/CommandSubscriptionResolvers_AppSync.res.mjs +15 -0
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +238 -0
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +179 -0
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +229 -0
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +245 -0
- package/src/adapter/Auth/Auth_Cognito.res +209 -0
- package/src/adapter/Auth/Auth_Cognito.res.mjs +135 -0
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers.res +2 -0
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers.res.mjs +12 -0
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync.res +251 -0
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync.res.mjs +126 -0
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync_NoOp.res +20 -0
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync_NoOp.res.mjs +23 -0
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync_Runtime.res +1 -0
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync_Runtime.res.mjs +11 -0
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_AppSync.res +95 -0
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_AppSync.res.mjs +50 -0
- package/src/adapter/CommandTopic/CommandTopicChannel.res +6 -0
- package/src/adapter/CommandTopic/CommandTopicChannel.res.mjs +21 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_Helpers.res +196 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_Helpers.res.mjs +145 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS.res +91 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS.res.mjs +74 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Async.res +69 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Async.res.mjs +62 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_FIFO.res +60 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_FIFO.res.mjs +56 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Runtime.res +73 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Runtime.res.mjs +65 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Sync.res +88 -0
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Sync.res.mjs +76 -0
- package/src/adapter/CommandTopic/CommandTopicRemoteChannel.res +1 -0
- package/src/adapter/CommandTopic/CommandTopicRemoteChannel.res.mjs +9 -0
- package/src/adapter/CommandTopic/CommandTopicRemoteChannel_SQS.res +7 -0
- package/src/adapter/CommandTopic/CommandTopicRemoteChannel_SQS.res.mjs +17 -0
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +103 -0
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +68 -0
- package/src/adapter/Counter/CounterHandler_DynamoDbStream_Runtime.res +107 -0
- package/src/adapter/Counter/CounterHandler_DynamoDbStream_Runtime.res.mjs +145 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage.res +1 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage.res.mjs +9 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb.res +84 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb.res.mjs +52 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +1436 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +1143 -0
- package/src/adapter/EventCollector/EventCollectorChannel.res +6 -0
- package/src/adapter/EventCollector/EventCollectorChannel.res.mjs +21 -0
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res +63 -0
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res.mjs +56 -0
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res +20 -0
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res.mjs +32 -0
- package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res +266 -0
- package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res.mjs +166 -0
- package/src/adapter/EventCollector/EventCollectorChannel_SQS.res +81 -0
- package/src/adapter/EventCollector/EventCollectorChannel_SQS.res.mjs +54 -0
- package/src/adapter/EventCollector/EventCollectorChannel_SQS_FIFO.res +59 -0
- package/src/adapter/EventCollector/EventCollectorChannel_SQS_FIFO.res.mjs +45 -0
- package/src/adapter/EventCollector/EventCollectorChannel_SQS_Runtime.res +91 -0
- package/src/adapter/EventCollector/EventCollectorChannel_SQS_Runtime.res.mjs +89 -0
- package/src/adapter/EventLog/EventLogStorage.res +3 -0
- package/src/adapter/EventLog/EventLogStorage.res.mjs +15 -0
- package/src/adapter/EventLog/EventLogStorage_DynamoDb.res +25 -0
- package/src/adapter/EventLog/EventLogStorage_DynamoDb.res.mjs +34 -0
- package/src/adapter/EventLog/EventLogStorage_DynamoDbStream.res +26 -0
- package/src/adapter/EventLog/EventLogStorage_DynamoDbStream.res.mjs +35 -0
- package/src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res +121 -0
- package/src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res.mjs +137 -0
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +265 -0
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +185 -0
- package/src/adapter/EventTopic/EventTopicPublisher.res +6 -0
- package/src/adapter/EventTopic/EventTopicPublisher.res.mjs +21 -0
- package/src/adapter/EventTopic/EventTopicPublisher_DynamoDbStream.res +25 -0
- package/src/adapter/EventTopic/EventTopicPublisher_DynamoDbStream.res.mjs +28 -0
- package/src/adapter/EventTopic/EventTopicPublisher_SNS.res +40 -0
- package/src/adapter/EventTopic/EventTopicPublisher_SNS.res.mjs +39 -0
- package/src/adapter/EventTopic/EventTopicPublisher_SNS_FIFO.res +39 -0
- package/src/adapter/EventTopic/EventTopicPublisher_SNS_FIFO.res.mjs +38 -0
- package/src/adapter/EventTopic/EventTopicPublisher_SNS_Runtime.res +5 -0
- package/src/adapter/EventTopic/EventTopicPublisher_SNS_Runtime.res.mjs +18 -0
- package/src/adapter/Mcp/MCP_Lambda.res +502 -0
- package/src/adapter/Mcp/MCP_Lambda.res.mjs +289 -0
- package/src/adapter/QueryDb/NodeResolver_AppSync.res +71 -0
- package/src/adapter/QueryDb/NodeResolver_AppSync.res.mjs +44 -0
- package/src/adapter/QueryDb/QueryDbResolvers.res +2 -0
- package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +12 -0
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +434 -0
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +243 -0
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync_NoOp.res +21 -0
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync_NoOp.res.mjs +14 -0
- package/src/adapter/QueryDb/QueryDbResolvers_NoOp.res +21 -0
- package/src/adapter/QueryDb/QueryDbResolvers_NoOp.res.mjs +14 -0
- package/src/adapter/QueryDb/QueryDbStorage.res +5 -0
- package/src/adapter/QueryDb/QueryDbStorage.res.mjs +18 -0
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res +115 -0
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs +100 -0
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDbStream.res +52 -0
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDbStream.res.mjs +38 -0
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb_Runtime.res +271 -0
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb_Runtime.res.mjs +306 -0
- package/src/adapter/QueryDb/QueryInterceptor_Lambda.res +20 -0
- package/src/adapter/QueryDb/QueryInterceptor_Lambda.res.mjs +22 -0
- package/src/adapter/QueryEngine/QueryEngine.res +3 -0
- package/src/adapter/QueryEngine/QueryEngine.res.mjs +12 -0
- package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res +184 -0
- package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res.mjs +221 -0
- package/src/adapter/Runtime/AdminEventCollectorEntryPoint.mjs +879 -0
- package/src/adapter/Runtime/AggregateEntryPoint.mjs +313 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Micro.res +327 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Micro.res.mjs +239 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Micro_Async.res +331 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Micro_Async.res.mjs +240 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_PerAggregate.res +247 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_PerAggregate.res.mjs +185 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_PerAggregate_Async.res +244 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_PerAggregate_Async.res.mjs +185 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +347 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +244 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +285 -0
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +212 -0
- package/src/adapter/Runtime/AutomationSliceEntryPoint.mjs +124 -0
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +187 -0
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +147 -0
- package/src/adapter/Runtime/CounterEntryPoint.mjs +91 -0
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +265 -0
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_PerEventCollector.res +122 -0
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_PerEventCollector.res.mjs +89 -0
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +193 -0
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +137 -0
- package/src/adapter/Runtime/EventMapperEntryPoint.mjs +97 -0
- package/src/adapter/Runtime/ExtensionPointEntryPoint.mjs +101 -0
- package/src/adapter/Runtime/ExtensionPointRuntime_Builder_PerExtensionPoint.res +83 -0
- package/src/adapter/Runtime/ExtensionPointRuntime_Builder_PerExtensionPoint.res.mjs +59 -0
- package/src/adapter/Runtime/HandlerFactoryHelpers.mjs +142 -0
- package/src/adapter/Runtime/HeartbeatEntryPoint.mjs +38 -0
- package/src/adapter/Runtime/PluginExtensionPointEntryPoint.mjs +137 -0
- package/src/adapter/Runtime/ReadModelEntryPoint.mjs +143 -0
- package/src/adapter/Runtime/RuntimeEnvironment.res +1 -0
- package/src/adapter/Runtime/RuntimeEnvironment.res.mjs +9 -0
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +254 -0
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +169 -0
- package/src/adapter/Runtime/SideEffectEntryPoint.mjs +81 -0
- package/src/adapter/Runtime/SideEffectHandlerRuntime_Builder_Single.res +178 -0
- package/src/adapter/Runtime/SideEffectHandlerRuntime_Builder_Single.res.mjs +129 -0
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +166 -0
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +87 -0
- package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +136 -0
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +344 -0
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +242 -0
- package/src/adapter/Runtime/TaskBucketEntryPoint.mjs +150 -0
- package/src/adapter/Runtime/TaskRuntime_Builder_PerBucket.res +123 -0
- package/src/adapter/Runtime/TaskRuntime_Builder_PerBucket.res.mjs +74 -0
- package/src/adapter/ScheduledPublisher/ScheduledPublisher.res +2 -0
- package/src/adapter/ScheduledPublisher/ScheduledPublisher.res.mjs +12 -0
- package/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents.res +54 -0
- package/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents.res.mjs +41 -0
- package/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents_Runtime.res +97 -0
- package/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents_Runtime.res.mjs +85 -0
- package/src/adapter/StateTopic/StateTopic_AppSync.res +418 -0
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +295 -0
- package/src/adapter/Task/TaskBucket.res +1 -0
- package/src/adapter/Task/TaskBucket.res.mjs +9 -0
- package/src/adapter/Task/TaskBucket_S3.res +157 -0
- package/src/adapter/Task/TaskBucket_S3.res.mjs +108 -0
- package/src/adapter/Task/TaskBucket_S3_Runtime.res +11 -0
- package/src/adapter/Task/TaskBucket_S3_Runtime.res.mjs +15 -0
- package/src/components/Aggregate_Builder.res +1 -0
- package/src/components/Aggregate_Builder.res.mjs +25 -0
- package/src/components/Aggregate_Builder_Micro.res +63 -0
- package/src/components/Aggregate_Builder_Micro.res.mjs +80 -0
- package/src/components/Aggregate_Builder_NoResolver.res +61 -0
- package/src/components/Aggregate_Builder_NoResolver.res.mjs +80 -0
- package/src/components/Aggregate_Builder_PerAggregate.res +57 -0
- package/src/components/Aggregate_Builder_PerAggregate.res.mjs +80 -0
- package/src/components/Aggregate_Builder_Single.res +59 -0
- package/src/components/Aggregate_Builder_Single.res.mjs +80 -0
- package/src/components/Aggregate_Builder_Single_Async.res +59 -0
- package/src/components/Aggregate_Builder_Single_Async.res.mjs +80 -0
- package/src/components/Api/AppSync_Adapter.res +500 -0
- package/src/components/Api/AppSync_Adapter.res.mjs +396 -0
- package/src/components/AutomationSlice_Builder.res +52 -0
- package/src/components/AutomationSlice_Builder.res.mjs +72 -0
- package/src/components/AutomationSlice_Builder_Bundled.res.mjs +70 -0
- package/src/components/Counter_Builder.res +10 -0
- package/src/components/Counter_Builder.res.mjs +16 -0
- package/src/components/DataCleaner.res +170 -0
- package/src/components/DataCleaner.res.mjs +2 -0
- package/src/components/DcbEventLog_Builder.res +8 -0
- package/src/components/DcbEventLog_Builder.res.mjs +14 -0
- package/src/components/ExtensionPoint_Builder.res +16 -0
- package/src/components/ExtensionPoint_Builder.res.mjs +37 -0
- package/src/components/ForeignReadModel_Builder.res +19 -0
- package/src/components/ForeignReadModel_Builder.res.mjs +48 -0
- package/src/components/InboundTranslationSlice_Builder.res +25 -0
- package/src/components/InboundTranslationSlice_Builder.res.mjs +31 -0
- package/src/components/OutboundTranslationSlice_Builder.res +60 -0
- package/src/components/OutboundTranslationSlice_Builder.res.mjs +76 -0
- package/src/components/OutboundTranslationSlice_Builder_Bundled.res.mjs +75 -0
- package/src/components/Plugin.res +29 -0
- package/src/components/Plugin.res.mjs +63 -0
- package/src/components/ReadModel_Builder.res +1 -0
- package/src/components/ReadModel_Builder.res.mjs +19 -0
- package/src/components/ReadModel_Builder_NoResolver.res +55 -0
- package/src/components/ReadModel_Builder_NoResolver.res.mjs +64 -0
- package/src/components/ReadModel_Builder_NoResolver_Stream.res +60 -0
- package/src/components/ReadModel_Builder_NoResolver_Stream.res.mjs +64 -0
- package/src/components/ReadModel_Builder_PerReadModel.res +53 -0
- package/src/components/ReadModel_Builder_PerReadModel.res.mjs +66 -0
- package/src/components/ReadModel_Builder_Single.res +52 -0
- package/src/components/ReadModel_Builder_Single.res.mjs +66 -0
- package/src/components/ReadModel_Builder_Single_Stream.res +60 -0
- package/src/components/ReadModel_Builder_Single_Stream.res.mjs +66 -0
- package/src/components/Scheduler.res +1 -0
- package/src/components/Scheduler.res.mjs +13 -0
- package/src/components/SideEffectHandler.res +1 -0
- package/src/components/SideEffectHandler.res.mjs +19 -0
- package/src/components/SideEffectHandler_PerSideEffectHandler.res +13 -0
- package/src/components/SideEffectHandler_PerSideEffectHandler.res.mjs +52 -0
- package/src/components/SideEffectHandler_Single.res +54 -0
- package/src/components/SideEffectHandler_Single.res.mjs +60 -0
- package/src/components/StateChangeSlice_Builder.res +17 -0
- package/src/components/StateChangeSlice_Builder.res.mjs +23 -0
- package/src/components/StateViewSlice_Builder.res +46 -0
- package/src/components/StateViewSlice_Builder.res.mjs +68 -0
- package/src/components/StateViewSlice_Builder_Bundled.res.mjs +69 -0
- package/src/components/StateViewSlice_Builder_Stream.res +50 -0
- package/src/components/StateViewSlice_Builder_Stream.res.mjs +68 -0
- package/src/components/Task_Builder.res +1 -0
- package/src/components/Task_Builder.res.mjs +28 -0
- package/src/components/Task_Builder_PerBucket.res +22 -0
- package/src/components/Task_Builder_PerBucket.res.mjs +63 -0
- package/src/components/Vpc.res +214 -0
- package/src/components/Vpc.res.mjs +117 -0
- package/src/components/Vpc.resi +22 -0
- package/src/errors/AppSync_Error.res +36 -0
- package/src/errors/AppSync_Error.res.mjs +38 -0
- package/src/errors/Cognito_Error.res +36 -0
- package/src/errors/Cognito_Error.res.mjs +38 -0
- package/src/errors/DynamoDb_Error.res +75 -0
- package/src/errors/DynamoDb_Error.res.mjs +86 -0
- package/src/errors/Kinesis_Error.res +36 -0
- package/src/errors/Kinesis_Error.res.mjs +38 -0
- package/src/errors/SNS_Error.res +34 -0
- package/src/errors/SNS_Error.res.mjs +38 -0
- package/src/errors/SQS_Error.res +40 -0
- package/src/errors/SQS_Error.res.mjs +41 -0
- package/src/plugin/cloner/ClonerRunner.res +1 -0
- package/src/plugin/cloner/ClonerRunner.res.mjs +9 -0
- package/src/plugin/cloner/ClonerRunner_Fargate.res +362 -0
- package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +246 -0
- package/src/plugin/cloner/ClonerRunner_Fargate_Runtime.res +42 -0
- package/src/plugin/cloner/ClonerRunner_Fargate_Runtime.res.mjs +45 -0
- package/src/plugin/heartbeat/HeartbeatRunner.res +1 -0
- package/src/plugin/heartbeat/HeartbeatRunner.res.mjs +9 -0
- package/src/plugin/heartbeat/HeartbeatRunner_CloudWatchEvents.res +96 -0
- package/src/plugin/heartbeat/HeartbeatRunner_CloudWatchEvents.res.mjs +69 -0
- package/src/plugin/runtime/PluginExtensionPointRuntime_Builder.res +115 -0
- package/src/plugin/runtime/PluginExtensionPointRuntime_Builder.res.mjs +88 -0
- package/src/plugin/runtime/PluginRuntimeOperations.res +5 -0
- package/src/plugin/runtime/PluginRuntimeOperations.res.mjs +14 -0
- package/src/plugin/runtime/PluginRuntime_Builder.res +769 -0
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +464 -0
- package/src/plugin/runtime/Util_PluginMessage_Runtime.res +13 -0
- package/src/plugin/runtime/Util_PluginMessage_Runtime.res.mjs +19 -0
- package/src/plugin/stack/Plugin_ExtensionPoint_Builder.res +28 -0
- package/src/plugin/stack/Plugin_ExtensionPoint_Builder.res.mjs +67 -0
- package/src/plugin/stack/Plugin_Stack.res +439 -0
- package/src/plugin/stack/Plugin_Stack.res.mjs +263 -0
- package/src/util/Util.res +29 -0
- package/src/util/Util.res.mjs +93 -0
- package/src/util/Util_AppSync.res +36 -0
- package/src/util/Util_AppSync.res.mjs +35 -0
- package/src/util/Util_AppSync_Caller.res +213 -0
- package/src/util/Util_AppSync_Caller.res.mjs +170 -0
- package/src/util/Util_Bundle.res +255 -0
- package/src/util/Util_Bundle.res.mjs +179 -0
- package/src/util/Util_Cloudwatch.res +14 -0
- package/src/util/Util_Cloudwatch.res.mjs +19 -0
- package/src/util/Util_CognitoGroupUser.res +39 -0
- package/src/util/Util_CognitoGroupUser.res.mjs +32 -0
- package/src/util/Util_CognitoGroupUser_Runtime.res +65 -0
- package/src/util/Util_CognitoGroupUser_Runtime.res.mjs +50 -0
- package/src/util/Util_Cognito_Runtime.res +52 -0
- package/src/util/Util_Cognito_Runtime.res.mjs +35 -0
- package/src/util/Util_DeadLetterQueue.res +154 -0
- package/src/util/Util_DeadLetterQueue.res.mjs +158 -0
- package/src/util/Util_DynamoDb.res +210 -0
- package/src/util/Util_DynamoDb.res.mjs +218 -0
- package/src/util/Util_DynamoDbStream.res +138 -0
- package/src/util/Util_DynamoDbStream.res.mjs +134 -0
- package/src/util/Util_DynamoDbStream_Runtime.res +94 -0
- package/src/util/Util_DynamoDbStream_Runtime.res.mjs +131 -0
- package/src/util/Util_DynamoDb_Runtime.res +216 -0
- package/src/util/Util_DynamoDb_Runtime.res.mjs +257 -0
- package/src/util/Util_DynamoDb_TableManager.res +23 -0
- package/src/util/Util_DynamoDb_TableManager.res.mjs +35 -0
- package/src/util/Util_DynamoDb_TableManager.resi +3 -0
- package/src/util/Util_Env.res +5 -0
- package/src/util/Util_Env.res.mjs +2 -0
- package/src/util/Util_EventSourceMapping.res +49 -0
- package/src/util/Util_EventSourceMapping.res.mjs +32 -0
- package/src/util/Util_HostUiDomain.res +28 -0
- package/src/util/Util_HostUiDomain.res.mjs +26 -0
- package/src/util/Util_IAM_Role.res +14 -0
- package/src/util/Util_IAM_Role.res.mjs +28 -0
- package/src/util/Util_Kinesis_Runtime.res +20 -0
- package/src/util/Util_Kinesis_Runtime.res.mjs +24 -0
- package/src/util/Util_Lambda.res +39 -0
- package/src/util/Util_Lambda.res.mjs +45 -0
- package/src/util/Util_LocalConfig.res +119 -0
- package/src/util/Util_LocalConfig.res.mjs +112 -0
- package/src/util/Util_ResourceNaming.res +15 -0
- package/src/util/Util_ResourceNaming.res.mjs +26 -0
- package/src/util/Util_S3.res +17 -0
- package/src/util/Util_S3.res.mjs +30 -0
- package/src/util/Util_S3_Runtime.res +11 -0
- package/src/util/Util_S3_Runtime.res.mjs +15 -0
- package/src/util/Util_SNS.res +33 -0
- package/src/util/Util_SNS.res.mjs +50 -0
- package/src/util/Util_SNS_FIFO.res +21 -0
- package/src/util/Util_SNS_FIFO.res.mjs +27 -0
- package/src/util/Util_SNS_Runtime.res +33 -0
- package/src/util/Util_SNS_Runtime.res.mjs +27 -0
- package/src/util/Util_SQS.res +70 -0
- package/src/util/Util_SQS.res.mjs +83 -0
- package/src/util/Util_SQS_FIFO.res +16 -0
- package/src/util/Util_SQS_FIFO.res.mjs +28 -0
- package/src/util/Util_SQS_Runtime.res +169 -0
- package/src/util/Util_SQS_Runtime.res.mjs +150 -0
- package/src/util/Util_SesPolicy_Runtime.res +54 -0
- package/src/util/Util_SesPolicy_Runtime.res.mjs +55 -0
- package/src/util/Util_StaticBundle.res +124 -0
- package/src/util/Util_StaticBundle.res.mjs +132 -0
- package/src/util/Util_Vpc.res +22 -0
- package/src/util/Util_Vpc.res.mjs +27 -0
- package/tests/AppSync_AdapterTest.res +396 -0
- package/tests/AppSync_AdapterTest.res.mjs +326 -0
- package/tests/AppSync_Resolver_RetryingTest.res +59 -0
- package/tests/AppSync_Resolver_RetryingTest.res.mjs +66 -0
- package/tests/Auth_CognitoTest.res +203 -0
- package/tests/Auth_CognitoTest.res.mjs +248 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +602 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +742 -0
- package/tests/EventLogStorage_DynamoDb_RuntimeTest.res +57 -0
- package/tests/EventLogStorage_DynamoDb_RuntimeTest.res.mjs +68 -0
- package/tests/MCP_LambdaTest.res +101 -0
- package/tests/MCP_LambdaTest.res.mjs +56 -0
- package/tests/Util_HostUiDomainTest.res +90 -0
- package/tests/Util_HostUiDomainTest.res.mjs +50 -0
- package/tests/Util_SQS_RuntimeTest.res +46 -0
- package/tests/Util_SQS_RuntimeTest.res.mjs +45 -0
- package/tests/Util_StaticBundleTest.res +136 -0
- package/tests/Util_StaticBundleTest.res.mjs +149 -0
- package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res +101 -0
- package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res.mjs +91 -0
- package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res +315 -0
- package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res.mjs +435 -0
- package/tests/integration/DcbIntegrationHarness.res +102 -0
- package/tests/integration/DcbIntegrationHarness.res.mjs +163 -0
- package/tests/integration/EpTestSlice.res +28 -0
- package/tests/integration/EpTestSlice.res.mjs +43 -0
- package/tests/integration/EpTestSliceBehavior.res +18 -0
- 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
|
+
}
|