@reventlessdev/reventless-aws 3.0.0-alpha.344 → 3.0.0-alpha.345
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 +8 -0
- package/package.json +11 -11
- package/scripts/ProvisionIdentity.res +8 -3
- package/src/Platform.res +327 -328
- package/src/Platform.res.mjs +4 -2
- package/src/Platform_Casts.res +13 -4
- package/src/adapter/Api/AppSyncEventsSigner_Ops.res +8 -4
- package/src/adapter/Api/AppSync_EventsApi.res +12 -14
- package/src/adapter/Api/AppSync_Resolver_Native.res +3 -4
- package/src/adapter/Api/AppSync_Resolver_Retrying.res +78 -70
- package/src/adapter/Api/AppSync_SourceApiAssociation_Retrying.res +11 -8
- package/src/adapter/Api/Platform_AdminScan_Ops.res +15 -14
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +10 -2
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +40 -50
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +39 -32
- package/src/adapter/Api/Platform_UIFragments_Lambda_Ops.res +5 -2
- package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +0 -1
- package/src/adapter/Auth/Auth_ActiveRoleStore.res +1 -2
- package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res +1 -4
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +5 -2
- package/src/adapter/Auth/Auth_Cognito.res +3 -9
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync.res +1 -4
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync_NoOp.res +3 -1
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_AppSync.res +1 -4
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS.res +7 -4
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Async.res +6 -1
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_FIFO.res +7 -4
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Runtime.res +24 -18
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Sync.res +9 -4
- package/src/adapter/CommandTopic/CommandTopicRegistry.res +1 -4
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +15 -15
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb.res +9 -5
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +179 -213
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res +5 -5
- package/src/adapter/EventCollector/EventCollectorChannel_Helpers.res +6 -2
- package/src/adapter/EventCollector/EventCollectorChannel_SQS.res +14 -7
- package/src/adapter/EventCollector/EventCollectorChannel_SQS_FIFO.res +14 -7
- package/src/adapter/EventCollector/EventCollectorChannel_SQS_Runtime.res +6 -3
- package/src/adapter/EventLog/EventLogStorage_DynamoDbStream.res +6 -1
- package/src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res +13 -17
- package/src/adapter/EventLog/EventLogStorage_Postgres.res +7 -4
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +12 -7
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync_Ops.res +4 -2
- package/src/adapter/EventTopic/EventTopicPublisher_DynamoDbStream.res +3 -2
- package/src/adapter/EventTopic/EventTopicPublisher_SNS.res +20 -12
- package/src/adapter/EventTopic/EventTopicPublisher_SNS_FIFO.res +21 -9
- package/src/adapter/EventTopic/EventTopicPublisher_SNS_Runtime.res +5 -3
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res +6 -3
- package/src/adapter/Mcp/MCP_Lambda.res +31 -46
- package/src/adapter/Messaging/Messaging_Ses_Backend.res +11 -7
- package/src/adapter/Messaging/Messaging_Ses_Backend.res.mjs +2 -4
- package/src/adapter/Monitoring/Monitoring_CloudWatch.res +16 -16
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +5 -10
- package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +4 -2
- package/src/adapter/Postgres/PgConnection.res +6 -5
- package/src/adapter/Postgres/PgMigration_Builder.res +4 -8
- package/src/adapter/Postgres/PgRuntime.res +4 -7
- package/src/adapter/QueryDb/PgQueryResolver_Builder.res +9 -11
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +22 -21
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +114 -111
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +1 -7
- package/src/adapter/QueryDb/QueryDbStorage.res +4 -2
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res +23 -24
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDbStream.res +8 -2
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb_Runtime.res +71 -73
- package/src/adapter/QueryDb/QueryDbStorage_Postgres.res +5 -2
- package/src/adapter/QueryDb/QueryInterceptor_Provisioning.res +1 -4
- package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res +8 -2
- package/src/adapter/Runtime/AggregateRuntime_Builder_Micro.res +73 -81
- package/src/adapter/Runtime/AggregateRuntime_Builder_Micro_Async.res +72 -78
- package/src/adapter/Runtime/AggregateRuntime_Builder_PerAggregate.res +44 -67
- package/src/adapter/Runtime/AggregateRuntime_Builder_PerAggregate_Async.res +44 -67
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +53 -82
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +47 -77
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +18 -11
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +80 -97
- package/src/adapter/Runtime/CounterEntryPoint_Ops.res +2 -7
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +16 -10
- package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res +24 -15
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_PerEventCollector.res +17 -37
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +31 -57
- package/src/adapter/Runtime/EventMapperEntryPoint_Ops.res +18 -24
- package/src/adapter/Runtime/ExtensionPointEntryPoint_Ops.res +1 -5
- package/src/adapter/Runtime/ExtensionPointRuntime_Builder_PerExtensionPoint.res +4 -3
- package/src/adapter/Runtime/HeartbeatEntryPoint.res +0 -1
- package/src/adapter/Runtime/PgChangeFeedRelayEntryPoint.res +4 -2
- package/src/adapter/Runtime/PgMigrationEntryPoint.res +0 -1
- package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res +3 -2
- package/src/adapter/Runtime/PluginExtensionPointEntryPoint.res +1 -2
- package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res +4 -5
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +22 -10
- package/src/adapter/Runtime/RuntimeExtensionEntryPoint_Ops.res +8 -2
- package/src/adapter/Runtime/SideEffectHandlerRuntime_Builder_Single.res +14 -31
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +16 -17
- package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res +10 -9
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +46 -52
- package/src/adapter/Runtime/StreamRoutedEntryPoint_Ops.res +36 -41
- package/src/adapter/Runtime/TaskBucketEntryPoint_Ops.res +31 -37
- package/src/adapter/Runtime/TaskRuntime_Builder_PerBucket.res +6 -7
- package/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents.res +4 -6
- package/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents_Runtime.res +2 -6
- package/src/adapter/StateTopic/StateTopic_AppSync.res +21 -16
- package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res +36 -32
- package/src/adapter/Task/TaskBucket_S3.res +6 -1
- package/src/adapter/Upload/Upload_Claim_S3.res +17 -20
- package/src/adapter/Upload/Upload_Claim_S3_Ops.res +5 -2
- package/src/adapter/Upload/Upload_Presign_S3.res +51 -49
- package/src/capability/Capability_Geocoding_AwsLocation.res +4 -1
- package/src/capability/Capability_Messaging.res +4 -5
- package/src/capability/Capability_Messaging_Ses.res +3 -1
- package/src/capability/Capability_ObjectStore_S3.res +1 -7
- package/src/components/Api/AppSync_Adapter.res +28 -41
- package/src/components/Api/AppSync_SdlDecorate.res +38 -30
- package/src/components/AutomationSlice_Builder.res +6 -4
- package/src/components/ExtensionPoint_Builder.res +9 -10
- package/src/components/InboundTranslationSlice_Builder.res +7 -8
- package/src/components/OutboundTranslationSlice_Builder.res +14 -8
- package/src/components/OutboundTranslationSlice_Builder.res.mjs +1 -1
- package/src/components/SideEffectHandler_Single.res +4 -4
- package/src/components/StateViewSlice_Builder.res +6 -4
- package/src/components/StateViewSlice_Builder_Stream.res +6 -4
- package/src/components/Task_Builder_PerBucket.res +10 -11
- package/src/plugin/cloner/ClonerRunner_Fargate.res +3 -10
- package/src/plugin/cloner/ClonerRunner_Fargate_Runtime.res +1 -4
- package/src/plugin/heartbeat/HeartbeatRunner_CloudWatchEvents.res +5 -5
- package/src/plugin/runtime/PluginExtensionPointRuntime_Builder.res +19 -21
- package/src/plugin/runtime/PluginRuntime_Builder.res +167 -188
- package/src/plugin/runtime/Util_PluginMessage_Runtime.res +6 -5
- package/src/plugin/stack/Plugin_Stack.res +104 -103
- package/src/util/Util_AlarmSpec.res +2 -1
- package/src/util/Util_AppSync.res +8 -2
- package/src/util/Util_AppSync_Caller.res +15 -14
- package/src/util/Util_Bundle.res +46 -42
- package/src/util/Util_CognitoGroupUser_Runtime.res +32 -34
- package/src/util/Util_Cognito_Runtime.res +11 -7
- package/src/util/Util_DeadLetterQueue.res +12 -2
- package/src/util/Util_DynamoDb.res +5 -3
- package/src/util/Util_DynamoDbStream.res +7 -3
- package/src/util/Util_DynamoDbStream_Runtime.res +1 -2
- package/src/util/Util_DynamoDb_Runtime.res +23 -41
- package/src/util/Util_HostUiDomain.res +6 -1
- package/src/util/Util_Kinesis_Runtime.res +10 -11
- package/src/util/Util_Lambda.res +12 -5
- package/src/util/Util_LambdaEnvBudget.res +5 -5
- package/src/util/Util_LambdaLogging.res +1 -2
- package/src/util/Util_LogGroup_Adopting.res +1 -4
- package/src/util/Util_ResourceNaming.res +1 -2
- package/src/util/Util_SNS.res +8 -3
- package/src/util/Util_SNS_FIFO.res +1 -1
- package/src/util/Util_SNS_Runtime.res +11 -10
- package/src/util/Util_SQS.res +4 -2
- package/src/util/Util_SQS_FIFO.res +5 -2
- package/src/util/Util_SQS_Runtime.res +37 -36
- package/src/util/Util_ShellConfig.res +3 -2
- package/src/util/Util_StaticBundle.res +1 -3
- package/src/util/Util_StoreLayout.res +4 -1
- package/tests/AWS_TagsTest.res +16 -5
- package/tests/AppSyncEventsSigner_OpsTest.res +11 -2
- package/tests/AppSync_AdapterTest.res +203 -224
- package/tests/AppSync_Resolver_RetryingTest.res +64 -44
- package/tests/AppSync_RetirementNarrowingTest.res +59 -46
- package/tests/AppSync_SdlDecorateTest.res +22 -20
- package/tests/AppSync_SourceApiAssociation_RetryingTest.res +40 -22
- package/tests/Auth_ActiveRolePoolAttachmentTest.res +114 -107
- package/tests/Auth_ActiveRoleStoreTest.res +12 -9
- package/tests/Auth_ActiveRoleStore_SchemaTest.res +2 -1
- package/tests/Auth_ActiveRoleTrigger_OpsTest.res +20 -16
- package/tests/Auth_CognitoTest.res +31 -46
- package/tests/AutomationSliceEntryPoint_OpsTest.res +69 -67
- package/tests/BakeConvergenceTest.res +22 -25
- package/tests/Capability_MessagingTest.res +28 -12
- package/tests/CommandTopicQueuePolicyTest.res +1 -2
- package/tests/CounterEntryPoint_OpsTest.res +3 -7
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +306 -210
- package/tests/EventCollectorConnectLambdaPreviewTest.res +14 -7
- package/tests/EventCollectorEntryPoint_OpsTest.res +12 -4
- package/tests/EventCollectorPluginDefinitionAssetTest.res +18 -11
- package/tests/EventCollectorReadyGateTest.res +5 -4
- package/tests/EventLogStorage_DynamoDb_RuntimeTest.res +1 -2
- package/tests/EventMapperEntryPoint_OpsTest.res +1 -3
- package/tests/ExtensionPointEntryPoint_OpsTest.res +1 -4
- package/tests/HandlerFactoryDispatchTest.res +97 -70
- package/tests/MCP_LambdaTest.res +16 -27
- package/tests/PgChangeFeedRelay_IntegrationTest.res +123 -93
- package/tests/PgChangeFeedRelay_RuntimeTest.res +13 -10
- package/tests/PgMigrationEntryPoint_IntegrationTest.res +0 -1
- package/tests/PgPipeline_IntegrationTest.res +80 -66
- package/tests/PgQueryDbUnion_IntegrationTest.res +1 -2
- package/tests/PgQueryResolverEntryPoint_OpsTest.res +2 -6
- package/tests/PgQueryResolver_LambdaTest.res +41 -24
- package/tests/Platform_AdminScan_OpsTest.res +4 -1
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res +137 -129
- package/tests/ProjectionEntryPoint_DisplayNameTest.res +7 -3
- package/tests/ProjectionEntryPoint_UnionStampTest.res +3 -2
- package/tests/ProvisionIdentityTest.res +2 -6
- package/tests/QueryDbOwnerIndexTableTest.res +6 -4
- package/tests/QueryDbResolvers_AppSyncTest.res +23 -16
- package/tests/QueryInterceptor_LambdaTest.res +11 -13
- package/tests/ReadModelEntryPoint_OpsTest.res +43 -41
- package/tests/RuntimeExtensionEntryPoint_OpsTest.res +4 -3
- package/tests/SideEffectEntryPoint_OpsTest.res +23 -18
- package/tests/StateChangeDescriptorParityTest.res +28 -26
- package/tests/StateTopicKeySchemaTest.res +18 -15
- package/tests/StateViewSliceEntryPoint_OpsTest.res +55 -44
- package/tests/TaskBucketEntryPoint_OpsTest.res +13 -13
- package/tests/Upload_ClaimTest.res +3 -3
- package/tests/Util_AwsErrorTest.res +4 -7
- package/tests/Util_BundleDependencyClosureTest.res +15 -3
- package/tests/Util_BundleEsmLoaderTest.res +3 -3
- package/tests/Util_BundleResolveTest.res +4 -6
- package/tests/Util_BundleRuntimeExtensionTest.res +17 -18
- package/tests/Util_DcbMetricsTest.res +6 -6
- package/tests/Util_DynamoDbStreamTest.res +4 -5
- package/tests/Util_DynamoDbTest.res +3 -2
- package/tests/Util_LambdaEnvBudgetTest.res +22 -18
- package/tests/Util_LambdaLoggingTest.res +9 -8
- package/tests/Util_LogGroup_AdoptingTest.res +10 -5
- package/tests/Util_LogRetentionTest.res +6 -2
- package/tests/Util_OwnerScopeEnvTest.res +10 -8
- package/tests/Util_SQS_RuntimeTest.res +5 -7
- package/tests/Util_ShellConfigTest.res +7 -17
- package/tests/Util_StaticBundleTest.res +3 -8
- package/tests/Util_StoreLayoutTest.res +17 -12
- package/tests/integration/AggregateEntryPoint_IntegrationTest.res +1 -4
- package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res +15 -15
- package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res +296 -232
- package/tests/integration/DcbIntegrationHarness.res +8 -8
- package/tests/integration/EpCompositeSlice.res +16 -8
- package/tests/integration/QueryDbUnion_DynamoDb_IntegrationTest.res +8 -10
- package/tests/integration/StateViewSliceEntryPoint_IntegrationTest.res +10 -10
- package/tests/integration/SvsTestSlice_Projection.res +1 -3
- package/tests/util/Util_AlarmSpecTest.res +62 -49
|
@@ -64,104 +64,134 @@ describe("DCB DynamoDb integration — fence-scope = read-scope (Issue 1 regress
|
|
|
64
64
|
|
|
65
65
|
// Place an order: read (orderId ∪ productId), then append OrderPlaced.
|
|
66
66
|
let placeOrder = async (table, ~orderId, ~customerId) => {
|
|
67
|
-
let query: Reventless.DcbTag.query = [
|
|
68
|
-
{tags: [tag("orderId", orderId)]},
|
|
69
|
-
{tags: [productTag]},
|
|
70
|
-
]
|
|
67
|
+
let query: Reventless.DcbTag.query = [{tags: [tag("orderId", orderId)]}, {tags: [productTag]}]
|
|
71
68
|
let after = await readAfter(table, query)
|
|
72
69
|
await Runtime.appendConditional(
|
|
73
70
|
table,
|
|
74
71
|
[event("OrderPlaced", [tag("orderId", orderId), tag("customerId", customerId), productTag])],
|
|
75
|
-
{query,
|
|
72
|
+
{query, ?after},
|
|
76
73
|
~partitionTag=simple("orderId"),
|
|
77
74
|
)
|
|
78
75
|
}
|
|
79
76
|
|
|
80
|
-
testAsync(
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
testAsync(
|
|
78
|
+
"a second order of the same product (different order + customer) succeeds",
|
|
79
|
+
async () => {
|
|
80
|
+
let table = await H.freshTable()
|
|
81
|
+
let _ = await seed(
|
|
82
|
+
table,
|
|
83
|
+
event("CatalogProductSynced", [productTag]),
|
|
84
|
+
~partitionTag=simple("productId"),
|
|
85
|
+
)
|
|
83
86
|
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
let r1 = await placeOrder(table, ~orderId="O1", ~customerId="C1")
|
|
88
|
+
let r2 = await placeOrder(table, ~orderId="O2", ~customerId="C2")
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
expect(isOk(r1))->toBe(true)
|
|
91
|
+
expect(isOk(r2))->toBe(true)
|
|
92
|
+
},
|
|
93
|
+
)
|
|
90
94
|
|
|
91
|
-
testAsync(
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
testAsync(
|
|
96
|
+
"two concurrent orders of the same product both succeed (no productId contention)",
|
|
97
|
+
async () => {
|
|
98
|
+
let table = await H.freshTable()
|
|
99
|
+
let _ = await seed(
|
|
100
|
+
table,
|
|
101
|
+
event("CatalogProductSynced", [productTag]),
|
|
102
|
+
~partitionTag=simple("productId"),
|
|
103
|
+
)
|
|
94
104
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
let results = await Promise.all([
|
|
106
|
+
placeOrder(table, ~orderId="OA", ~customerId="CA"),
|
|
107
|
+
placeOrder(table, ~orderId="OB", ~customerId="CB"),
|
|
108
|
+
])
|
|
99
109
|
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
expect(results->Array.every(isOk))->toBe(true)
|
|
111
|
+
},
|
|
112
|
+
)
|
|
102
113
|
|
|
103
|
-
testAsync(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
{tags: [tag("orderId", "O1")], eventTypes: ["OrderPlaced"]},
|
|
113
|
-
{tags: [productTag], eventTypes: ["CatalogProductSynced"]},
|
|
114
|
-
]
|
|
115
|
-
let after = (await readAfter(table, query))->Option.getOr("")
|
|
116
|
-
expect(after == "")->toBe(false)
|
|
117
|
-
|
|
118
|
-
// …a concurrent re-sync advances fence#productId:P5's pos#CatalogProductSynced
|
|
119
|
-
// past `after`. We set the fence directly (rather than racing a second
|
|
120
|
-
// appendUnconditional) so the advance is deterministically > after —
|
|
121
|
-
// same-millisecond writers can otherwise tie on UUID order (analysis Issue 7).
|
|
122
|
-
// Appending "z" stays lexically greater than any real position.
|
|
123
|
-
let _ = await H.setFence(table, productTag, ~eventTypes=["CatalogProductSynced"], ~position=after ++ "z")
|
|
124
|
-
|
|
125
|
-
// …so the now-stale order append must conflict on the productId fence.
|
|
126
|
-
let r = await Runtime.appendConditional(
|
|
127
|
-
table,
|
|
128
|
-
[event("OrderPlaced", [tag("orderId", "O1"), tag("customerId", "C1"), productTag])],
|
|
129
|
-
{query, after: ?Some(after)},
|
|
130
|
-
~partitionTag=simple("orderId"),
|
|
131
|
-
)
|
|
132
|
-
expect(isConflict(r))->toBe(true)
|
|
133
|
-
})
|
|
114
|
+
testAsync(
|
|
115
|
+
"a concurrent product re-sync after the order's read still conflicts (OCC preserved)",
|
|
116
|
+
async () => {
|
|
117
|
+
let table = await H.freshTable()
|
|
118
|
+
let _ = await seed(
|
|
119
|
+
table,
|
|
120
|
+
event("CatalogProductSynced", [productTag]),
|
|
121
|
+
~partitionTag=simple("productId"),
|
|
122
|
+
)
|
|
134
123
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
124
|
+
// Order reads its decision model (after = the sync's position). Each clause
|
|
125
|
+
// names the event type it reads — the per-type fence model checks the
|
|
126
|
+
// consumed type's `pos#<type>`, so a tag-only clause would produce no fence
|
|
127
|
+
// check at all.
|
|
128
|
+
let query: Reventless.DcbTag.query = [
|
|
129
|
+
{tags: [tag("orderId", "O1")], eventTypes: ["OrderPlaced"]},
|
|
130
|
+
{tags: [productTag], eventTypes: ["CatalogProductSynced"]},
|
|
131
|
+
]
|
|
132
|
+
let after = (await readAfter(table, query))->Option.getOr("")
|
|
133
|
+
expect(after == "")->toBe(false)
|
|
134
|
+
|
|
135
|
+
// …a concurrent re-sync advances fence#productId:P5's pos#CatalogProductSynced
|
|
136
|
+
// past `after`. We set the fence directly (rather than racing a second
|
|
137
|
+
// appendUnconditional) so the advance is deterministically > after —
|
|
138
|
+
// same-millisecond writers can otherwise tie on UUID order (analysis Issue 7).
|
|
139
|
+
// Appending "z" stays lexically greater than any real position.
|
|
140
|
+
let _ = await H.setFence(
|
|
141
|
+
table,
|
|
142
|
+
productTag,
|
|
143
|
+
~eventTypes=["CatalogProductSynced"],
|
|
144
|
+
~position=after ++ "z",
|
|
145
|
+
)
|
|
138
146
|
|
|
139
|
-
|
|
140
|
-
|
|
147
|
+
// …so the now-stale order append must conflict on the productId fence.
|
|
148
|
+
let r = await Runtime.appendConditional(
|
|
149
|
+
table,
|
|
150
|
+
[event("OrderPlaced", [tag("orderId", "O1"), tag("customerId", "C1"), productTag])],
|
|
151
|
+
{query, after: ?Some(after)},
|
|
152
|
+
~partitionTag=simple("orderId"),
|
|
153
|
+
)
|
|
154
|
+
expect(isConflict(r))->toBe(true)
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
testAsync(
|
|
159
|
+
"placing an order does not poison the customer slice (secondary customerId not fenced)",
|
|
160
|
+
async () => {
|
|
161
|
+
let table = await H.freshTable()
|
|
162
|
+
let customerTag = tag("customerId", "C1")
|
|
163
|
+
|
|
164
|
+
// Customer exists (partitioned by customerId) → fence#customerId:C1 seeded.
|
|
165
|
+
let _ = await seed(
|
|
166
|
+
table,
|
|
167
|
+
event("CustomerRegistered", [customerTag]),
|
|
168
|
+
~partitionTag=simple("customerId"),
|
|
169
|
+
)
|
|
141
170
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
171
|
+
// Order for C1 carries customerId as a SECONDARY tag (partitioned by orderId).
|
|
172
|
+
let orderQuery: Reventless.DcbTag.query = [{tags: [tag("orderId", "O1")]}]
|
|
173
|
+
let orderAfter = await readAfter(table, orderQuery)
|
|
174
|
+
let _ = await Runtime.appendConditional(
|
|
175
|
+
table,
|
|
176
|
+
[event("OrderPlaced", [tag("orderId", "O1"), customerTag, productTag])],
|
|
177
|
+
{query: orderQuery, after: ?orderAfter},
|
|
178
|
+
~partitionTag=simple("orderId"),
|
|
179
|
+
)
|
|
151
180
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
181
|
+
// The customer slice reads its own partition (sees only CustomerRegistered)
|
|
182
|
+
// and changes the email. Pre-fix the order would have bumped
|
|
183
|
+
// fence#customerId:C1, falsely conflicting this append.
|
|
184
|
+
let customerQuery: Reventless.DcbTag.query = [{tags: [customerTag]}]
|
|
185
|
+
let customerAfter = await readAfter(table, customerQuery)
|
|
186
|
+
let r = await Runtime.appendConditional(
|
|
187
|
+
table,
|
|
188
|
+
[event("EmailChanged", [customerTag])],
|
|
189
|
+
{query: customerQuery, after: ?customerAfter},
|
|
190
|
+
~partitionTag=simple("customerId"),
|
|
191
|
+
)
|
|
192
|
+
expect(isOk(r))->toBe(true)
|
|
193
|
+
},
|
|
194
|
+
)
|
|
165
195
|
})
|
|
166
196
|
|
|
167
197
|
describe("DCB DynamoDb integration — optimistic concurrency primitives", () => {
|
|
@@ -184,7 +214,11 @@ describe("DCB DynamoDb integration — optimistic concurrency primitives", () =>
|
|
|
184
214
|
testAsync("two concurrent commits at the same after never both win (no duplicate)", async () => {
|
|
185
215
|
let table = await H.freshTable()
|
|
186
216
|
let counterTag = tag("counterId", "K1")
|
|
187
|
-
let _ = await seed(
|
|
217
|
+
let _ = await seed(
|
|
218
|
+
table,
|
|
219
|
+
event("CounterStarted", [counterTag]),
|
|
220
|
+
~partitionTag=simple("counterId"),
|
|
221
|
+
)
|
|
188
222
|
|
|
189
223
|
let query: Reventless.DcbTag.query = [{tags: [counterTag]}]
|
|
190
224
|
let after = await readAfter(table, query)
|
|
@@ -193,7 +227,7 @@ describe("DCB DynamoDb integration — optimistic concurrency primitives", () =>
|
|
|
193
227
|
Runtime.appendConditional(
|
|
194
228
|
table,
|
|
195
229
|
[event("CounterIncremented", [counterTag])],
|
|
196
|
-
{query,
|
|
230
|
+
{query, ?after},
|
|
197
231
|
~partitionTag=simple("counterId"),
|
|
198
232
|
)
|
|
199
233
|
|
|
@@ -209,62 +243,72 @@ describe("DCB DynamoDb integration — optimistic concurrency primitives", () =>
|
|
|
209
243
|
expect(conflicts >= 1)->toBe(true)
|
|
210
244
|
})
|
|
211
245
|
|
|
212
|
-
testAsync(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
246
|
+
testAsync(
|
|
247
|
+
"two concurrent first-writers to the same entity — exactly one creates it (Issue 2)",
|
|
248
|
+
async () => {
|
|
249
|
+
let table = await H.freshTable()
|
|
250
|
+
// Both writers read an empty decision model (after=None) and try to create
|
|
251
|
+
// the same order. The per-(eventType, partition) create guard must let only
|
|
252
|
+
// one through.
|
|
253
|
+
let create = () =>
|
|
254
|
+
Runtime.appendConditional(
|
|
255
|
+
table,
|
|
256
|
+
[event("OrderCreated", [tag("orderId", "O1")])],
|
|
257
|
+
{query: [{tags: [tag("orderId", "O1")]}], after: ?None},
|
|
258
|
+
~partitionTag=simple("orderId"),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
let results = await Promise.all([create(), create()])
|
|
262
|
+
let oks = results->Array.filter(isOk)->Array.length
|
|
263
|
+
// Safety: the two first-writers must NOT both create the entity. At most one
|
|
264
|
+
// commits; every non-winner surfaces a Conflict. (DynamoDB may cancel both
|
|
265
|
+
// with a mutual TransactionConflict — the slice callback's retry loop then
|
|
266
|
+
// makes exactly one win. Pre-fix this asserted-2: both committed = duplicate.)
|
|
267
|
+
expect(oks <= 1)->toBe(true)
|
|
268
|
+
expect(oks + results->Array.filter(isConflict)->Array.length)->toBe(2)
|
|
269
|
+
|
|
270
|
+
// No duplicate landed — persisted OrderCreated count matches the winners.
|
|
271
|
+
let readResult = await Runtime.read(table)(~query=[{tags: [tag("orderId", "O1")]}])
|
|
272
|
+
expect(readResult.events->Array.length)->toBe(oks)
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
testAsync(
|
|
277
|
+
"the create guard does not false-conflict a subset-event-type writer (Issue 4)",
|
|
278
|
+
async () => {
|
|
279
|
+
let table = await H.freshTable()
|
|
280
|
+
// A productId partition already holds a ProductAdded (its own create guard +
|
|
281
|
+
// fence). A different slice reads only NameChanged on that partition, sees
|
|
282
|
+
// nothing (after=None), and appends the first NameChanged. Its guard is
|
|
283
|
+
// create#NameChanged#productId:P — distinct from ProductAdded's — so it must
|
|
284
|
+
// NOT conflict with the pre-existing partition state.
|
|
285
|
+
let productTag = tag("productId", "P")
|
|
286
|
+
let r1 = await Runtime.appendConditional(
|
|
219
287
|
table,
|
|
220
|
-
[event("
|
|
221
|
-
{query: [{tags: [
|
|
222
|
-
~partitionTag=simple("
|
|
288
|
+
[event("ProductAdded", [productTag])],
|
|
289
|
+
{query: [{tags: [productTag]}], after: ?None},
|
|
290
|
+
~partitionTag=simple("productId"),
|
|
223
291
|
)
|
|
292
|
+
expect(isOk(r1))->toBe(true)
|
|
224
293
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// No duplicate landed — persisted OrderCreated count matches the winners.
|
|
235
|
-
let readResult = await Runtime.read(table)(~query=[{tags: [tag("orderId", "O1")]}])
|
|
236
|
-
expect(readResult.events->Array.length)->toBe(oks)
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
testAsync("the create guard does not false-conflict a subset-event-type writer (Issue 4)", async () => {
|
|
240
|
-
let table = await H.freshTable()
|
|
241
|
-
// A productId partition already holds a ProductAdded (its own create guard +
|
|
242
|
-
// fence). A different slice reads only NameChanged on that partition, sees
|
|
243
|
-
// nothing (after=None), and appends the first NameChanged. Its guard is
|
|
244
|
-
// create#NameChanged#productId:P — distinct from ProductAdded's — so it must
|
|
245
|
-
// NOT conflict with the pre-existing partition state.
|
|
246
|
-
let productTag = tag("productId", "P")
|
|
247
|
-
let r1 = await Runtime.appendConditional(
|
|
248
|
-
table,
|
|
249
|
-
[event("ProductAdded", [productTag])],
|
|
250
|
-
{query: [{tags: [productTag]}], after: ?None},
|
|
251
|
-
~partitionTag=simple("productId"),
|
|
252
|
-
)
|
|
253
|
-
expect(isOk(r1))->toBe(true)
|
|
254
|
-
|
|
255
|
-
let r2 = await Runtime.appendConditional(
|
|
256
|
-
table,
|
|
257
|
-
[event("NameChanged", [productTag])],
|
|
258
|
-
{query: [{tags: [productTag]}], after: ?None},
|
|
259
|
-
~partitionTag=simple("productId"),
|
|
260
|
-
)
|
|
261
|
-
expect(isOk(r2))->toBe(true)
|
|
262
|
-
})
|
|
294
|
+
let r2 = await Runtime.appendConditional(
|
|
295
|
+
table,
|
|
296
|
+
[event("NameChanged", [productTag])],
|
|
297
|
+
{query: [{tags: [productTag]}], after: ?None},
|
|
298
|
+
~partitionTag=simple("productId"),
|
|
299
|
+
)
|
|
300
|
+
expect(isOk(r2))->toBe(true)
|
|
301
|
+
},
|
|
302
|
+
)
|
|
263
303
|
|
|
264
304
|
testAsync("a chain of compatible commits all succeed", async () => {
|
|
265
305
|
let table = await H.freshTable()
|
|
266
306
|
let counterTag = tag("counterId", "K2")
|
|
267
|
-
let _ = await seed(
|
|
307
|
+
let _ = await seed(
|
|
308
|
+
table,
|
|
309
|
+
event("CounterStarted", [counterTag]),
|
|
310
|
+
~partitionTag=simple("counterId"),
|
|
311
|
+
)
|
|
268
312
|
let query: Reventless.DcbTag.query = [{tags: [counterTag]}]
|
|
269
313
|
|
|
270
314
|
let step = async () => {
|
|
@@ -272,7 +316,7 @@ describe("DCB DynamoDb integration — optimistic concurrency primitives", () =>
|
|
|
272
316
|
await Runtime.appendConditional(
|
|
273
317
|
table,
|
|
274
318
|
[event("CounterIncremented", [counterTag])],
|
|
275
|
-
{query,
|
|
319
|
+
{query, ?after},
|
|
276
320
|
~partitionTag=simple("counterId"),
|
|
277
321
|
)
|
|
278
322
|
}
|
|
@@ -283,48 +327,51 @@ describe("DCB DynamoDb integration — optimistic concurrency primitives", () =>
|
|
|
283
327
|
expect([r1, r2, r3]->Array.every(isOk))->toBe(true)
|
|
284
328
|
})
|
|
285
329
|
|
|
286
|
-
testAsync(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
expect(after == "")->toBe(false)
|
|
330
|
+
testAsync(
|
|
331
|
+
"a failed fence condition aborts the whole transaction (multi-tag atomicity)",
|
|
332
|
+
async () => {
|
|
333
|
+
let table = await H.freshTable()
|
|
334
|
+
// Composite slice: one multi-tag clause → both tags get check+bump. The
|
|
335
|
+
// clause names the consumed type so the per-type fence check has a `pos#<type>`
|
|
336
|
+
// to assert against.
|
|
337
|
+
let tags = [tag("productId", "p1"), tag("orderId", "o1")]
|
|
338
|
+
let query: Reventless.DcbTag.query = [{tags, eventTypes: ["ProductDemandRecorded"]}]
|
|
339
|
+
|
|
340
|
+
// Seed one event so the next append rides after=Some.
|
|
341
|
+
let seedRes = await Runtime.appendConditional(
|
|
342
|
+
table,
|
|
343
|
+
[event("ProductDemandRecorded", tags)],
|
|
344
|
+
{query, after: ?None},
|
|
345
|
+
~partitionTag=simple("productId"),
|
|
346
|
+
)
|
|
347
|
+
expect(isOk(seedRes))->toBe(true)
|
|
305
348
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
let _ = await H.setFence(
|
|
309
|
-
table,
|
|
310
|
-
tag("productId", "p1"),
|
|
311
|
-
~eventTypes=["ProductDemandRecorded"],
|
|
312
|
-
~position=after ++ "z",
|
|
313
|
-
)
|
|
349
|
+
let after = (await readAfter(table, query))->Option.getOr("")
|
|
350
|
+
expect(after == "")->toBe(false)
|
|
314
351
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
352
|
+
// A concurrent writer advances ONLY the productId fence's pos#ProductDemandRecorded
|
|
353
|
+
// past `after`. Appending "z" keeps the position lexically greater than any real one.
|
|
354
|
+
let _ = await H.setFence(
|
|
355
|
+
table,
|
|
356
|
+
tag("productId", "p1"),
|
|
357
|
+
~eventTypes=["ProductDemandRecorded"],
|
|
358
|
+
~position=after ++ "z",
|
|
359
|
+
)
|
|
322
360
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
361
|
+
let r = await Runtime.appendConditional(
|
|
362
|
+
table,
|
|
363
|
+
[event("ProductDemandRecorded", tags)],
|
|
364
|
+
{query, after: ?Some(after)},
|
|
365
|
+
~partitionTag=simple("productId"),
|
|
366
|
+
)
|
|
367
|
+
expect(isConflict(r))->toBe(true)
|
|
368
|
+
|
|
369
|
+
// Atomic rollback: the second event was NOT written — the composite read
|
|
370
|
+
// still returns exactly the one seeded event.
|
|
371
|
+
let readResult = await Runtime.read(table)(~query)
|
|
372
|
+
expect(readResult.events->Array.length)->toBe(1)
|
|
373
|
+
},
|
|
374
|
+
)
|
|
328
375
|
})
|
|
329
376
|
|
|
330
377
|
// A `@compositePartitionTag` slice (platform-inspector's SyncResource shape) fences
|
|
@@ -367,27 +414,30 @@ describe("DCB DynamoDb integration — composite partition hot-fence fix", () =>
|
|
|
367
414
|
expect(isOk(rB))->toBe(true)
|
|
368
415
|
})
|
|
369
416
|
|
|
370
|
-
testAsync(
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
417
|
+
testAsync(
|
|
418
|
+
"two first-writers of the SAME composite key still serialize (OCC preserved)",
|
|
419
|
+
async () => {
|
|
420
|
+
let table = await H.freshTable()
|
|
421
|
+
let tags = members("resA")
|
|
422
|
+
let query: Reventless.DcbTag.query = [{tags: tags}]
|
|
423
|
+
let r1 = await Runtime.appendConditional(
|
|
424
|
+
table,
|
|
425
|
+
[event("ResourceAdded", tags)],
|
|
426
|
+
{query, after: ?None},
|
|
427
|
+
~partitionTag=pt,
|
|
428
|
+
)
|
|
429
|
+
let r2 = await Runtime.appendConditional(
|
|
430
|
+
table,
|
|
431
|
+
[event("ResourceAdded", tags)],
|
|
432
|
+
{query, after: ?None},
|
|
433
|
+
~partitionTag=pt,
|
|
434
|
+
)
|
|
435
|
+
expect(isOk(r1))->toBe(true)
|
|
436
|
+
// The folded create guard (attribute_not_exists on the composite fence) rejects
|
|
437
|
+
// the second first-write of the same entity.
|
|
438
|
+
expect(isConflict(r2))->toBe(true)
|
|
439
|
+
},
|
|
440
|
+
)
|
|
391
441
|
})
|
|
392
442
|
|
|
393
443
|
// The fence is PER event type (`pos#<eventType>` attributes), not a single scalar
|
|
@@ -408,55 +458,69 @@ describe("DCB DynamoDb integration — per-type fence granularity", () => {
|
|
|
408
458
|
await Runtime.appendConditional(
|
|
409
459
|
table,
|
|
410
460
|
[event(eventType, [productTag])],
|
|
411
|
-
{query,
|
|
461
|
+
{query, ?after},
|
|
412
462
|
~partitionTag=simple("productId"),
|
|
413
463
|
)
|
|
414
464
|
}
|
|
415
465
|
|
|
416
|
-
testAsync(
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
await change(table, ~eventType="NameChanged"),
|
|
426
|
-
await change(table, ~eventType="DescriptionChanged"),
|
|
427
|
-
await change(table, ~eventType="PriceChanged"),
|
|
428
|
-
await change(table, ~eventType="NameChanged"),
|
|
429
|
-
]
|
|
430
|
-
expect(results->Array.every(isOk))->toBe(true)
|
|
431
|
-
})
|
|
432
|
-
|
|
433
|
-
testAsync("two concurrent SAME-type changes still serialize (per-type OCC preserved)", async () => {
|
|
434
|
-
let table = await H.freshTable()
|
|
435
|
-
let _ = await seed(table, event("ProductAdded", [productTag]), ~partitionTag=simple("productId"))
|
|
436
|
-
// Seed one NameChanged so both racers read the same after=Some head.
|
|
437
|
-
let _ = await change(table, ~eventType="NameChanged")
|
|
466
|
+
testAsync(
|
|
467
|
+
"interleaved distinct-type changes on one product all succeed (never wedges)",
|
|
468
|
+
async () => {
|
|
469
|
+
let table = await H.freshTable()
|
|
470
|
+
let _ = await seed(
|
|
471
|
+
table,
|
|
472
|
+
event("ProductAdded", [productTag]),
|
|
473
|
+
~partitionTag=simple("productId"),
|
|
474
|
+
)
|
|
438
475
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
476
|
+
// Two rounds of interleaved price/name/description changes. Pre-fix, the first
|
|
477
|
+
// PriceChanged bumped the single partition fence, so the following NameChanged
|
|
478
|
+
// read a stale head and conflicted PERMANENTLY — the entity wedged after one edit.
|
|
479
|
+
let results = [
|
|
480
|
+
await change(table, ~eventType="PriceChanged"),
|
|
481
|
+
await change(table, ~eventType="NameChanged"),
|
|
482
|
+
await change(table, ~eventType="DescriptionChanged"),
|
|
483
|
+
await change(table, ~eventType="PriceChanged"),
|
|
484
|
+
await change(table, ~eventType="NameChanged"),
|
|
485
|
+
]
|
|
486
|
+
expect(results->Array.every(isOk))->toBe(true)
|
|
487
|
+
},
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
testAsync(
|
|
491
|
+
"two concurrent SAME-type changes still serialize (per-type OCC preserved)",
|
|
492
|
+
async () => {
|
|
493
|
+
let table = await H.freshTable()
|
|
494
|
+
let _ = await seed(
|
|
443
495
|
table,
|
|
444
|
-
|
|
445
|
-
{query, after: ?after},
|
|
496
|
+
event("ProductAdded", [productTag]),
|
|
446
497
|
~partitionTag=simple("productId"),
|
|
447
498
|
)
|
|
499
|
+
// Seed one NameChanged so both racers read the same after=Some head.
|
|
500
|
+
let _ = await change(table, ~eventType="NameChanged")
|
|
448
501
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
502
|
+
let query: Reventless.DcbTag.query = [{tags: [productTag], eventTypes: ["NameChanged"]}]
|
|
503
|
+
let after = await readAfter(table, query)
|
|
504
|
+
let rename = () =>
|
|
505
|
+
Runtime.appendConditional(
|
|
506
|
+
table,
|
|
507
|
+
[event("NameChanged", [productTag])],
|
|
508
|
+
{query, ?after},
|
|
509
|
+
~partitionTag=simple("productId"),
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
// Per-type granularity must NOT weaken same-type OCC: two concurrent NameChanged
|
|
513
|
+
// at the same `pos#NameChanged` head → at most one commits, every non-winner
|
|
514
|
+
// conflicts. (DynamoDB may cancel both with a mutual TransactionConflict; the
|
|
515
|
+
// slice callback's retry loop makes exactly one win in prod.)
|
|
516
|
+
let results = await Promise.all([rename(), rename()])
|
|
517
|
+
let oks = results->Array.filter(isOk)->Array.length
|
|
518
|
+
let conflicts = results->Array.filter(isConflict)->Array.length
|
|
519
|
+
expect(oks <= 1)->toBe(true)
|
|
520
|
+
expect(oks + conflicts)->toBe(2)
|
|
521
|
+
expect(conflicts >= 1)->toBe(true)
|
|
522
|
+
},
|
|
523
|
+
)
|
|
460
524
|
})
|
|
461
525
|
|
|
462
526
|
// An empty tag value is a legitimate model state (an absent member of a composite
|