@reventlessdev/reventless-gwt 1.0.0-alpha.100
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 +916 -0
- package/LICENSE +202 -0
- package/bin/reventless-dev.mjs +21 -0
- package/package.json +54 -0
- package/rescript.json +36 -0
- package/src/.gwtignore +10 -0
- package/src/Automation_GWT.res +207 -0
- package/src/Automation_GWT.res.mjs +160 -0
- package/src/Behavior_GWT.res +569 -0
- package/src/Behavior_GWT.res.mjs +523 -0
- package/src/Bind.res +29 -0
- package/src/Bind.res.mjs +22 -0
- package/src/BuildClassifier.res +147 -0
- package/src/BuildClassifier.res.mjs +136 -0
- package/src/Cancellation.res +37 -0
- package/src/Cancellation.res.mjs +46 -0
- package/src/ChildProcess.res +102 -0
- package/src/ChildProcess.res.mjs +107 -0
- package/src/Cli.res +905 -0
- package/src/Cli.res.mjs +952 -0
- package/src/Collector.res +212 -0
- package/src/Collector.res.mjs +218 -0
- package/src/ComponentMeta.res +77 -0
- package/src/ComponentMeta.res.mjs +91 -0
- package/src/ComponentScan.res +103 -0
- package/src/ComponentScan.res.mjs +105 -0
- package/src/Delegate_GWT.res +303 -0
- package/src/Delegate_GWT.res.mjs +372 -0
- package/src/Diff.res +91 -0
- package/src/Diff.res.mjs +98 -0
- package/src/Discovery.res +105 -0
- package/src/Discovery.res.mjs +99 -0
- package/src/DomainDeadCode.res +98 -0
- package/src/DomainDeadCode.res.mjs +72 -0
- package/src/DomainGraph.res +0 -0
- package/src/DomainGraph.res.mjs +0 -0
- package/src/EventMapping_GWT.res +29 -0
- package/src/EventMapping_GWT.res.mjs +44 -0
- package/src/Filter.res +45 -0
- package/src/Filter.res.mjs +47 -0
- package/src/Flow_GWT.res +664 -0
- package/src/Flow_GWT.res.mjs +604 -0
- package/src/FormatterHuman.res +109 -0
- package/src/FormatterHuman.res.mjs +115 -0
- package/src/FormatterJson.res +264 -0
- package/src/FormatterJson.res.mjs +322 -0
- package/src/FormatterJunit.res +93 -0
- package/src/FormatterJunit.res.mjs +90 -0
- package/src/FormatterTap.res +100 -0
- package/src/FormatterTap.res.mjs +131 -0
- package/src/FormatterVsCode.res +352 -0
- package/src/FormatterVsCode.res.mjs +546 -0
- package/src/Hint.res +119 -0
- package/src/Hint.res.mjs +124 -0
- package/src/InboundTranslation_GWT.res +83 -0
- package/src/InboundTranslation_GWT.res.mjs +94 -0
- package/src/JestBind.res +70 -0
- package/src/JestBind.res.mjs +63 -0
- package/src/Loader.res +24 -0
- package/src/Loader.res.mjs +23 -0
- package/src/LocalHost.res +119 -0
- package/src/LocalHost.res.mjs +99 -0
- package/src/Mapping_GWT.res +474 -0
- package/src/Mapping_GWT.res.mjs +322 -0
- package/src/MismatchRender.res +72 -0
- package/src/MismatchRender.res.mjs +127 -0
- package/src/MultiSourceProjection_GWT.res +316 -0
- package/src/MultiSourceProjection_GWT.res.mjs +354 -0
- package/src/OutboundTranslation_GWT.res +238 -0
- package/src/OutboundTranslation_GWT.res.mjs +186 -0
- package/src/Outcome.res +97 -0
- package/src/Outcome.res.mjs +93 -0
- package/src/PackageScan.res +80 -0
- package/src/PackageScan.res.mjs +81 -0
- package/src/PlatformRunner.res +181 -0
- package/src/PlatformRunner.res.mjs +176 -0
- package/src/PlatformScan.res +142 -0
- package/src/PlatformScan.res.mjs +132 -0
- package/src/ProcessManager.res +135 -0
- package/src/ProcessManager.res.mjs +119 -0
- package/src/Projection_GWT.res +312 -0
- package/src/Projection_GWT.res.mjs +332 -0
- package/src/Query_GWT.res +379 -0
- package/src/Query_GWT.res.mjs +367 -0
- package/src/RenderRescript.res +125 -0
- package/src/RenderRescript.res.mjs +141 -0
- package/src/RunWorker.res +24 -0
- package/src/RunWorker.res.mjs +16 -0
- package/src/RunnerTypes.res +68 -0
- package/src/RunnerTypes.res.mjs +60 -0
- package/src/SideEffect_GWT.res +157 -0
- package/src/SideEffect_GWT.res.mjs +113 -0
- package/src/StateChange/ExternalAddCategorySlice.res +25 -0
- package/src/StateChange/ExternalAddCategorySlice.res.mjs +34 -0
- package/src/StateChange/ExternalAddCategorySlice_Behavior.res +19 -0
- package/src/StateChange/ExternalAddCategorySlice_Behavior.res.mjs +46 -0
- package/src/StateChange/WithFixtures.res +22 -0
- package/src/StateChange/WithFixtures.res.mjs +31 -0
- package/src/StateChange/WithFixtures_Behavior.res +17 -0
- package/src/StateChange/WithFixtures_Behavior.res.mjs +37 -0
- package/src/StateChange/WithManualOpen.res +23 -0
- package/src/StateChange/WithManualOpen.res.mjs +31 -0
- package/src/StateChange/WithManualOpen_Behavior.res +17 -0
- package/src/StateChange/WithManualOpen_Behavior.res.mjs +37 -0
- package/src/StubRuntime.res +54 -0
- package/src/StubRuntime.res.mjs +55 -0
- package/src/TestFixtures.res +16 -0
- package/src/TestFixtures.res.mjs +34 -0
- package/src/Watch.res +108 -0
- package/src/Watch.res.mjs +85 -0
- package/src/WatcherProbe.res +56 -0
- package/src/WatcherProbe.res.mjs +59 -0
- package/src/Worker.res +24 -0
- package/src/Worker.res.mjs +9 -0
- package/tests/AutomationGwtTest.res +71 -0
- package/tests/AutomationGwtTest.res.mjs +189 -0
- package/tests/BuildClassifierTest.res +133 -0
- package/tests/BuildClassifierTest.res.mjs +176 -0
- package/tests/CliRunEntryTest.res +57 -0
- package/tests/CliRunEntryTest.res.mjs +65 -0
- package/tests/CliWatchScopeTest.res +62 -0
- package/tests/CliWatchScopeTest.res.mjs +95 -0
- package/tests/ComponentMetaTest.res +67 -0
- package/tests/ComponentMetaTest.res.mjs +71 -0
- package/tests/DelegateGwtTest.res +487 -0
- package/tests/DelegateGwtTest.res.mjs +891 -0
- package/tests/DiscoveryTest.res +42 -0
- package/tests/DiscoveryTest.res.mjs +43 -0
- package/tests/DomainDeadCodeTest.res +119 -0
- package/tests/DomainDeadCodeTest.res.mjs +158 -0
- package/tests/DomainGraphTest.res +448 -0
- package/tests/DomainGraphTest.res.mjs +505 -0
- package/tests/FlowAggregateGwtTest.res +171 -0
- package/tests/FlowAggregateGwtTest.res.mjs +273 -0
- package/tests/FlowCrossPluginGwtTest.res +340 -0
- package/tests/FlowCrossPluginGwtTest.res.mjs +798 -0
- package/tests/FlowGwtTest.res +222 -0
- package/tests/FlowGwtTest.res.mjs +433 -0
- package/tests/FormatterGoldenTest.res +125 -0
- package/tests/FormatterGoldenTest.res.mjs +162 -0
- package/tests/FormatterVsCodeMessageTest.res +41 -0
- package/tests/FormatterVsCodeMessageTest.res.mjs +42 -0
- package/tests/InboundTranslationGwtTest.res +34 -0
- package/tests/InboundTranslationGwtTest.res.mjs +99 -0
- package/tests/LocalHostIntegration.res +70 -0
- package/tests/LocalHostIntegration.res.mjs +64 -0
- package/tests/LocalHostTest.res +71 -0
- package/tests/LocalHostTest.res.mjs +63 -0
- package/tests/MappingGwtTest.res +350 -0
- package/tests/MappingGwtTest.res.mjs +596 -0
- package/tests/OutboundTranslationGwtTest.res +59 -0
- package/tests/OutboundTranslationGwtTest.res.mjs +139 -0
- package/tests/PlatformRunnerTest.res +66 -0
- package/tests/PlatformRunnerTest.res.mjs +85 -0
- package/tests/PlatformScanTest.res +61 -0
- package/tests/PlatformScanTest.res.mjs +56 -0
- package/tests/QueryGwtTest.res +161 -0
- package/tests/QueryGwtTest.res.mjs +314 -0
- package/tests/QueryResolverGwtTest.res +71 -0
- package/tests/QueryResolverGwtTest.res.mjs +136 -0
- package/tests/RunnerUnitTest.res +219 -0
- package/tests/RunnerUnitTest.res.mjs +244 -0
- package/tests/SideEffect/FakeOrderNotification.res +23 -0
- package/tests/SideEffect/FakeOrderNotification.res.mjs +36 -0
- package/tests/SideEffect/FakeOrderNotificationGwtTest.res +35 -0
- package/tests/SideEffect/FakeOrderNotificationGwtTest.res.mjs +73 -0
- package/tests/SideEffect/MockEmail.res +18 -0
- package/tests/SideEffect/MockEmail.res.mjs +47 -0
- package/tests/StateChange/ExternalAddCategorySlice_GWT.res +27 -0
- package/tests/StateChange/ExternalAddCategorySlice_GWT.res.mjs +70 -0
- package/tests/StateChange/WithFixtures_Fixtures.res +9 -0
- package/tests/StateChange/WithFixtures_Fixtures.res.mjs +20 -0
- package/tests/StateChange/WithFixtures_GWT.res +23 -0
- package/tests/StateChange/WithFixtures_GWT.res.mjs +56 -0
- package/tests/StateChange/WithManualOpen_Fixtures.res +7 -0
- package/tests/StateChange/WithManualOpen_Fixtures.res.mjs +20 -0
- package/tests/StateChange/WithManualOpen_GWT.res +17 -0
- package/tests/StateChange/WithManualOpen_GWT.res.mjs +56 -0
- package/tests/StateChangeSliceGwtTest.res +160 -0
- package/tests/StateChangeSliceGwtTest.res.mjs +280 -0
- package/tests/StateViewSliceGwtTest.res +64 -0
- package/tests/StateViewSliceGwtTest.res.mjs +161 -0
- package/tests/WatchDebounceTest.res +106 -0
- package/tests/WatchDebounceTest.res.mjs +128 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
open ReventlessCore
|
|
2
|
+
|
|
3
|
+
// Plan 02 Phase 6b — unified Behavior GWT DSL covering both Aggregate and
|
|
4
|
+
// StateChangeSlice. Two entry points:
|
|
5
|
+
//
|
|
6
|
+
// * [Make(Spec, Behavior)] — StateChangeSlice form. [Spec] carries
|
|
7
|
+
// [consumedEvent]; [Behavior.evolve]
|
|
8
|
+
// consumes it. DCB append-condition
|
|
9
|
+
// checks are wired in.
|
|
10
|
+
// * [MakeFromAggregate(Spec, Behavior)] — Aggregate adapter. [Spec] is
|
|
11
|
+
// [Reventless.Aggregate.Spec],
|
|
12
|
+
// [Behavior] is [Reventless.Behavior.T].
|
|
13
|
+
// No DCB semantics; the simpler
|
|
14
|
+
// [AggregateT] surface is returned.
|
|
15
|
+
//
|
|
16
|
+
// Both produce the same [describe / test / givenEvents / whenCmd / then*]
|
|
17
|
+
// triple-A surface. Slice form additionally exposes
|
|
18
|
+
// [thenAppendsConditionedOn / thenAppendsConditionedOnExactly] for DCB
|
|
19
|
+
// optimistic-concurrency assertions.
|
|
20
|
+
//
|
|
21
|
+
// Aggregate users *cannot* use [Make] (their Spec doesn't carry [consumedEvent])
|
|
22
|
+
// and slice users *cannot* use [MakeFromAggregate] (their Spec/Behavior shape
|
|
23
|
+
// differs). Pick the matching entry point.
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Slice form — replaces the legacy `StateChangeSlice_GWT`.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
module type BehaviorSpec = {
|
|
30
|
+
let name: string
|
|
31
|
+
|
|
32
|
+
@schema
|
|
33
|
+
type consumedEvent
|
|
34
|
+
|
|
35
|
+
@schema
|
|
36
|
+
type command
|
|
37
|
+
|
|
38
|
+
@schema
|
|
39
|
+
type error
|
|
40
|
+
|
|
41
|
+
@schema
|
|
42
|
+
type event
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module type Behavior = {
|
|
46
|
+
module Spec: BehaviorSpec
|
|
47
|
+
|
|
48
|
+
type state
|
|
49
|
+
let initialState: state
|
|
50
|
+
let evolve: (state, Spec.consumedEvent) => state
|
|
51
|
+
let decide: (state, Spec.command) => result<array<Spec.event>, Spec.error>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module type T = {
|
|
55
|
+
module Spec: BehaviorSpec
|
|
56
|
+
|
|
57
|
+
let describe: (string, unit => unit) => unit
|
|
58
|
+
let todo: string => unit
|
|
59
|
+
let test: (string, unit => Outcome.outcome) => unit
|
|
60
|
+
|
|
61
|
+
let givenEvents: array<Spec.consumedEvent> => array<Spec.consumedEvent>
|
|
62
|
+
|
|
63
|
+
let whenCmd: (array<Spec.consumedEvent>, Spec.command) => array<Spec.event>
|
|
64
|
+
|
|
65
|
+
let thenEvent: (array<Spec.event>, Spec.event) => Outcome.outcome
|
|
66
|
+
let thenEvents: (array<Spec.event>, array<Spec.event>) => Outcome.outcome
|
|
67
|
+
let thenNoEvent: array<Spec.event> => Outcome.outcome
|
|
68
|
+
let thenEventWithError: (array<Spec.event>, Spec.event, Spec.error) => Outcome.outcome
|
|
69
|
+
let thenEventsWithError: (array<Spec.event>, array<Spec.event>, Spec.error) => Outcome.outcome
|
|
70
|
+
let thenError: (array<Spec.event>, Spec.error) => Outcome.outcome
|
|
71
|
+
|
|
72
|
+
// DCB optimistic-concurrency assertions.
|
|
73
|
+
let thenAppendsConditionedOn: (
|
|
74
|
+
array<Spec.event>,
|
|
75
|
+
Reventless.DcbTag.query,
|
|
76
|
+
) => Outcome.outcome
|
|
77
|
+
let thenAppendsConditionedOnExactly: (
|
|
78
|
+
array<Spec.event>,
|
|
79
|
+
Reventless.DcbTag.appendCondition,
|
|
80
|
+
) => Outcome.outcome
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// JSON encoder for [Reventless.DcbTag.appendCondition]. The framework
|
|
84
|
+
// doesn't ship a sury schema for it; encoding structurally keeps the
|
|
85
|
+
// Outcome algebra's JSON payload closed.
|
|
86
|
+
let encodeTag = (t: Reventless.DcbTag.tag): JSON.t => {
|
|
87
|
+
let d = Dict.make()
|
|
88
|
+
d->Dict.set("key", JSON.Encode.string(t.key))
|
|
89
|
+
d->Dict.set("value", JSON.Encode.string(t.value))
|
|
90
|
+
JSON.Encode.object(d)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let encodeQueryItem = (qi: Reventless.DcbTag.queryItem): JSON.t => {
|
|
94
|
+
let d = Dict.make()
|
|
95
|
+
switch qi.eventTypes {
|
|
96
|
+
| Some(types) =>
|
|
97
|
+
d->Dict.set("eventTypes", types->Array.map(JSON.Encode.string)->JSON.Encode.array)
|
|
98
|
+
| None => ()
|
|
99
|
+
}
|
|
100
|
+
switch qi.tags {
|
|
101
|
+
| Some(tags) => d->Dict.set("tags", tags->Array.map(encodeTag)->JSON.Encode.array)
|
|
102
|
+
| None => ()
|
|
103
|
+
}
|
|
104
|
+
JSON.Encode.object(d)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let encodeQuery = (q: Reventless.DcbTag.query): JSON.t =>
|
|
108
|
+
q->Array.map(encodeQueryItem)->JSON.Encode.array
|
|
109
|
+
|
|
110
|
+
let encodeAppendCondition = (c: Reventless.DcbTag.appendCondition): JSON.t => {
|
|
111
|
+
let d = Dict.make()
|
|
112
|
+
d->Dict.set("query", encodeQuery(c.query))
|
|
113
|
+
switch c.after {
|
|
114
|
+
| Some(pos) => d->Dict.set("after", JSON.Encode.string(pos))
|
|
115
|
+
| None => ()
|
|
116
|
+
}
|
|
117
|
+
JSON.Encode.object(d)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Shared assertion core for both Behavior GWT flavours.
|
|
122
|
+
//
|
|
123
|
+
// `Make` (slice/DCB) and `MakeFromAggregate` encode produced events / errors
|
|
124
|
+
// identically and compare them against the expectation the same way — only the
|
|
125
|
+
// DCB append-condition footgun that `Make` layers on top, and the `thenCompare*`
|
|
126
|
+
// custom-equality arms the aggregate flavour adds, differ. Factoring the
|
|
127
|
+
// comparison core out (mirroring `Delegate_GWT`'s `Make`) keeps the two from
|
|
128
|
+
// silently drifting. The `errors` ref is owned here and set by each outer
|
|
129
|
+
// functor's `exec`.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
module type CoreSpec = {
|
|
132
|
+
@schema
|
|
133
|
+
type event
|
|
134
|
+
@schema
|
|
135
|
+
type error
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module AssertionCore = (Spec: CoreSpec) => {
|
|
139
|
+
let errors: ref<array<Spec.error>> = ref([])
|
|
140
|
+
|
|
141
|
+
let encEvent = (e: Spec.event) => e->Message.encode(Spec.eventSchema)
|
|
142
|
+
let encEvents = evs => evs->Array.map(encEvent)
|
|
143
|
+
let encError = (err: Spec.error) => err->Message.encode(Spec.errorSchema)
|
|
144
|
+
|
|
145
|
+
let unexpectedError = (events: array<Spec.event>): Outcome.outcome => {
|
|
146
|
+
let actual = errors.contents->Array.get(0)->Option.map(encError)
|
|
147
|
+
Outcome.fail(
|
|
148
|
+
ErrorMismatch({
|
|
149
|
+
expected: JSON.Encode.null,
|
|
150
|
+
actual,
|
|
151
|
+
actualEvents: events->encEvents,
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let compareEvents = (events, expectedEvents) =>
|
|
157
|
+
if errors.contents->Array.length > 0 {
|
|
158
|
+
unexpectedError(events)
|
|
159
|
+
} else if events == expectedEvents {
|
|
160
|
+
Outcome.pass
|
|
161
|
+
} else {
|
|
162
|
+
Outcome.fail(
|
|
163
|
+
EventsMismatch({expected: expectedEvents->encEvents, actual: events->encEvents}),
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let compareEventsWith = (events, expectedEvents, cmp) =>
|
|
168
|
+
if errors.contents->Array.length > 0 {
|
|
169
|
+
unexpectedError(events)
|
|
170
|
+
} else if (
|
|
171
|
+
events->Array.length == expectedEvents->Array.length &&
|
|
172
|
+
Array.zip(events, expectedEvents)->Array.every(((e1, e2)) => cmp(e1, e2))
|
|
173
|
+
) {
|
|
174
|
+
Outcome.pass
|
|
175
|
+
} else {
|
|
176
|
+
Outcome.fail(
|
|
177
|
+
EventsMismatch({expected: expectedEvents->encEvents, actual: events->encEvents}),
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let compareNoEvent = events =>
|
|
182
|
+
if errors.contents->Array.length > 0 {
|
|
183
|
+
unexpectedError(events)
|
|
184
|
+
} else if events->Array.length == 0 {
|
|
185
|
+
Outcome.pass
|
|
186
|
+
} else {
|
|
187
|
+
Outcome.fail(NoEventExpected({actual: events->encEvents}))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let matchesError = (
|
|
191
|
+
events: array<Spec.event>,
|
|
192
|
+
expectedEvents: array<Spec.event>,
|
|
193
|
+
expectedError: Spec.error,
|
|
194
|
+
): Outcome.outcome => {
|
|
195
|
+
let expectedErrorJson = encError(expectedError)
|
|
196
|
+
switch errors.contents->Array.get(0) {
|
|
197
|
+
| None =>
|
|
198
|
+
Outcome.fail(
|
|
199
|
+
ErrorMismatch({
|
|
200
|
+
expected: expectedErrorJson,
|
|
201
|
+
actual: None,
|
|
202
|
+
actualEvents: events->encEvents,
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
| Some(actual) if actual != expectedError =>
|
|
206
|
+
Outcome.fail(
|
|
207
|
+
ErrorMismatch({
|
|
208
|
+
expected: expectedErrorJson,
|
|
209
|
+
actual: Some(encError(actual)),
|
|
210
|
+
actualEvents: events->encEvents,
|
|
211
|
+
}),
|
|
212
|
+
)
|
|
213
|
+
| Some(_) =>
|
|
214
|
+
if events == expectedEvents {
|
|
215
|
+
Outcome.pass
|
|
216
|
+
} else {
|
|
217
|
+
Outcome.fail(
|
|
218
|
+
EventsMismatch({expected: expectedEvents->encEvents, actual: events->encEvents}),
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module Make = (
|
|
226
|
+
Spec: BehaviorSpec,
|
|
227
|
+
Behavior: Behavior with module Spec := Spec,
|
|
228
|
+
): (T with module Spec = Spec) => {
|
|
229
|
+
module Spec = Spec
|
|
230
|
+
|
|
231
|
+
S.enableJson()
|
|
232
|
+
|
|
233
|
+
let describe = JestBind.describe
|
|
234
|
+
let todo = JestBind.todo
|
|
235
|
+
let test = (name, body) => JestBind.test(~slice=Spec.name, name, body)
|
|
236
|
+
|
|
237
|
+
let currentState = consumed =>
|
|
238
|
+
consumed->Array.reduce(Behavior.initialState, Behavior.evolve)
|
|
239
|
+
|
|
240
|
+
module Core = AssertionCore(Spec)
|
|
241
|
+
let errors = Core.errors
|
|
242
|
+
|
|
243
|
+
// DCB append-condition derived inside [whenCmd]; [None] until first call.
|
|
244
|
+
let derivedCondition: ref<option<Reventless.DcbTag.appendCondition>> = ref(None)
|
|
245
|
+
|
|
246
|
+
// Implicit pending failure raised when the derived query carries zero tags
|
|
247
|
+
// across every clause AND the slice consumes at least one event type — a
|
|
248
|
+
// strong signal that a [@s.matches(DcbTag.string)] annotation is missing
|
|
249
|
+
// from the command schema. Regular [then*] combinators surface it before
|
|
250
|
+
// their own checks; [thenAppends*] bypass it.
|
|
251
|
+
let appendConditionFailure: ref<option<Outcome.mismatch>> = ref(None)
|
|
252
|
+
|
|
253
|
+
let consumedEventTypes =
|
|
254
|
+
Reventless.DcbDecode.makeDecoder(Spec.consumedEventSchema).eventTypes
|
|
255
|
+
|
|
256
|
+
// Scope derived from this slice's schemas, mirroring the StateChangeSlice runtime.
|
|
257
|
+
let scopeShape = Reventless.DcbTag.sliceShapeFromSchemas(
|
|
258
|
+
~name="",
|
|
259
|
+
~commandSchema=Spec.commandSchema,
|
|
260
|
+
~consumedEventSchema=Spec.consumedEventSchema,
|
|
261
|
+
~eventSchema=Spec.eventSchema,
|
|
262
|
+
)
|
|
263
|
+
// Cross-partition tag keys fan a foreign-entity reference into its own single-tag
|
|
264
|
+
// clause instead of being AND-ed with the partition. Unioned: any explicit
|
|
265
|
+
// `@crossPartition` annotation (capacity / escape-hatch reads) plus the per-slice
|
|
266
|
+
// inference (a foreign consumed key that is not this slice's partition), so a slice
|
|
267
|
+
// with *no* annotation still reads correctly.
|
|
268
|
+
let crossPartitionTagKeys = {
|
|
269
|
+
let set = Set.make()
|
|
270
|
+
Reventless.DcbTag.extractCrossPartitionTagKeys(Spec.eventSchema)->Array.forEach(k =>
|
|
271
|
+
set->Set.add(k)
|
|
272
|
+
)
|
|
273
|
+
Reventless.DcbScopeInference.crossPartitionForSlice(scopeShape)->Array.forEach(k =>
|
|
274
|
+
set->Set.add(k)
|
|
275
|
+
)
|
|
276
|
+
Array.fromIterator(set->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
|
|
277
|
+
}
|
|
278
|
+
// Per-event indexed-tag map so a foreign reference key on this slice's *own*
|
|
279
|
+
// emitted event (payload, not a read key) is narrowed out of a cross-partition
|
|
280
|
+
// clause — the same dead-clause removal the runtime threads.
|
|
281
|
+
let tagKeysByEventType =
|
|
282
|
+
Reventless.DcbScopeInference.infer([scopeShape]).tagKeysByEventType
|
|
283
|
+
|
|
284
|
+
// Reachability guard inputs. The event types this slice *emits* are its own
|
|
285
|
+
// history — read with composite AND clauses, which is fine. A *foreign* event
|
|
286
|
+
// type (consumed from a sibling slice) is a cross-entity read: if the command
|
|
287
|
+
// filters it by a tag key that appears in the decision query, yet the event
|
|
288
|
+
// cannot satisfy any whole clause, it can never reach `decide` — the classic
|
|
289
|
+
// "forgot @crossPartition on a cross-entity reference" bug (the key is AND-ed
|
|
290
|
+
// with the partition instead of fanning into its own clause). Tag keys the
|
|
291
|
+
// event carries but the command does NOT query are payload, not read keys, so
|
|
292
|
+
// they are ignored (e.g. CancelOrder reads OrderPlaced by orderId and merely
|
|
293
|
+
// carries its productIds over to the cancel event). Flow_GWT catches this
|
|
294
|
+
// because it threads a real tagged log; this lets the per-slice GWT catch it
|
|
295
|
+
// too, rather than folding an unreachable event straight through.
|
|
296
|
+
let emittedEventTypes =
|
|
297
|
+
Reventless.DcbDecode.makeDecoder(Spec.eventSchema).eventTypes
|
|
298
|
+
let consumedTagKeysByType =
|
|
299
|
+
Reventless.DcbTag.extractTagKeysByEventType(Spec.consumedEventSchema)
|
|
300
|
+
|
|
301
|
+
let queryTagKeys = (query: Reventless.DcbTag.query): array<string> =>
|
|
302
|
+
query->Array.flatMap(qi => qi.tags->Option.mapOr([], tags => tags->Array.map(t => t.key)))
|
|
303
|
+
|
|
304
|
+
// True when some clause's tags are all carried by `declaredKeys` (so an event
|
|
305
|
+
// declaring exactly those keys would match it). An empty query selects all.
|
|
306
|
+
let selectableBy = (query: Reventless.DcbTag.query, declaredKeys: array<string>): bool =>
|
|
307
|
+
query->Array.length == 0 ||
|
|
308
|
+
query->Array.some(qi =>
|
|
309
|
+
switch qi.tags {
|
|
310
|
+
| None | Some([]) => true
|
|
311
|
+
| Some(tags) => tags->Array.every(t => declaredKeys->Array.includes(t.key))
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
// Foreign consumed event types the command filters by a query key yet cannot
|
|
316
|
+
// select. Returns (eventType, offendingKey).
|
|
317
|
+
let unreachableForeignReads = (query: Reventless.DcbTag.query): array<(string, string)> => {
|
|
318
|
+
let qKeys = queryTagKeys(query)
|
|
319
|
+
consumedTagKeysByType
|
|
320
|
+
->Dict.toArray
|
|
321
|
+
->Array.filterMap(((evType, keys)) =>
|
|
322
|
+
switch keys->Array.find(k => qKeys->Array.includes(k)) {
|
|
323
|
+
| Some(key) if !(emittedEventTypes->Array.includes(evType)) && !selectableBy(query, keys) =>
|
|
324
|
+
Some((evType, key))
|
|
325
|
+
| _ => None
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let queryTagsTotal = (q: Reventless.DcbTag.query): int =>
|
|
331
|
+
q->Array.reduce(0, (acc, qi) =>
|
|
332
|
+
acc + qi.tags->Option.mapOr(0, t => t->Array.length)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
let exec = (history, command): array<Spec.event> => {
|
|
336
|
+
errors := []
|
|
337
|
+
appendConditionFailure := None
|
|
338
|
+
|
|
339
|
+
let query = Reventless.DcbTag.buildQueryFromCommand(
|
|
340
|
+
~eventTypes=consumedEventTypes,
|
|
341
|
+
~schema=Spec.commandSchema,
|
|
342
|
+
~value=command,
|
|
343
|
+
~tagKeysByEventType,
|
|
344
|
+
~crossPartitionTagKeys,
|
|
345
|
+
)
|
|
346
|
+
let condition: Reventless.DcbTag.appendCondition = {query: query}
|
|
347
|
+
derivedCondition := Some(condition)
|
|
348
|
+
|
|
349
|
+
if consumedEventTypes->Array.length > 0 && queryTagsTotal(query) == 0 {
|
|
350
|
+
appendConditionFailure :=
|
|
351
|
+
Some(
|
|
352
|
+
Outcome.AppendConditionMismatch({
|
|
353
|
+
expected: JSON.Encode.string(
|
|
354
|
+
"a non-empty tag set derived from @s.matches(DcbTag.string) fields on the command",
|
|
355
|
+
),
|
|
356
|
+
actual: encodeAppendCondition(condition),
|
|
357
|
+
}),
|
|
358
|
+
)
|
|
359
|
+
} else {
|
|
360
|
+
// Surfaced through the same pending-failure channel as the zero-tags
|
|
361
|
+
// footgun above (regular `then*` combinators check it first).
|
|
362
|
+
switch unreachableForeignReads(query)->Array.get(0) {
|
|
363
|
+
| Some((evType, key)) =>
|
|
364
|
+
appendConditionFailure :=
|
|
365
|
+
Some(
|
|
366
|
+
Outcome.AppendConditionMismatch({
|
|
367
|
+
expected: JSON.Encode.string(
|
|
368
|
+
`a decision-query clause that selects '${evType}' on tag '${key}' alone — this is a cross-entity read, so mark '${key}' @crossPartition (or model it as a tagged array) and it will fan into its own clause; otherwise the decision model can never see '${evType}'`,
|
|
369
|
+
),
|
|
370
|
+
actual: encodeAppendCondition(condition),
|
|
371
|
+
}),
|
|
372
|
+
)
|
|
373
|
+
| None => ()
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let state = currentState(history)
|
|
378
|
+
switch Behavior.decide(state, command) {
|
|
379
|
+
| Ok(events) => events
|
|
380
|
+
| Error(error) =>
|
|
381
|
+
errors := [error]
|
|
382
|
+
[]
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let givenEvents = consumed => consumed
|
|
387
|
+
let whenCmd = (history, cmd) => history->exec(cmd)
|
|
388
|
+
|
|
389
|
+
let checkAppendCondition = (): option<Outcome.outcome> =>
|
|
390
|
+
appendConditionFailure.contents->Option.map(m => Outcome.fail(m))
|
|
391
|
+
|
|
392
|
+
// The append-condition footgun is surfaced before the shared comparison core
|
|
393
|
+
// runs; `thenAppends*` below bypass it deliberately.
|
|
394
|
+
let thenEvents = (events, expectedEvents) =>
|
|
395
|
+
switch checkAppendCondition() {
|
|
396
|
+
| Some(o) => o
|
|
397
|
+
| None => Core.compareEvents(events, expectedEvents)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let thenEvent = (events, expectedEvent) => thenEvents(events, [expectedEvent])
|
|
401
|
+
|
|
402
|
+
let thenNoEvent = events =>
|
|
403
|
+
switch checkAppendCondition() {
|
|
404
|
+
| Some(o) => o
|
|
405
|
+
| None => Core.compareNoEvent(events)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let thenError = (events, expectedError) =>
|
|
409
|
+
switch checkAppendCondition() {
|
|
410
|
+
| Some(o) => o
|
|
411
|
+
| None => Core.matchesError(events, [], expectedError)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let thenEventWithError = (events, expectedEvent, expectedError) =>
|
|
415
|
+
switch checkAppendCondition() {
|
|
416
|
+
| Some(o) => o
|
|
417
|
+
| None => Core.matchesError(events, [expectedEvent], expectedError)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let thenEventsWithError = (events, expectedEvents, expectedError) =>
|
|
421
|
+
switch checkAppendCondition() {
|
|
422
|
+
| Some(o) => o
|
|
423
|
+
| None => Core.matchesError(events, expectedEvents, expectedError)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let thenAppendsConditionedOn = (_events, expectedQuery: Reventless.DcbTag.query) =>
|
|
427
|
+
switch derivedCondition.contents {
|
|
428
|
+
| None =>
|
|
429
|
+
Outcome.fail(
|
|
430
|
+
Throw({
|
|
431
|
+
error: "thenAppendsConditionedOn: whenCmd must be called before this assertion",
|
|
432
|
+
stack: "",
|
|
433
|
+
}),
|
|
434
|
+
)
|
|
435
|
+
| Some(cond) =>
|
|
436
|
+
if cond.query == expectedQuery {
|
|
437
|
+
Outcome.pass
|
|
438
|
+
} else {
|
|
439
|
+
Outcome.fail(
|
|
440
|
+
AppendConditionMismatch({
|
|
441
|
+
expected: encodeAppendCondition({query: expectedQuery}),
|
|
442
|
+
actual: encodeAppendCondition(cond),
|
|
443
|
+
}),
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let thenAppendsConditionedOnExactly = (
|
|
449
|
+
_events,
|
|
450
|
+
expectedCondition: Reventless.DcbTag.appendCondition,
|
|
451
|
+
) =>
|
|
452
|
+
switch derivedCondition.contents {
|
|
453
|
+
| None =>
|
|
454
|
+
Outcome.fail(
|
|
455
|
+
Throw({
|
|
456
|
+
error: "thenAppendsConditionedOnExactly: whenCmd must be called before this assertion",
|
|
457
|
+
stack: "",
|
|
458
|
+
}),
|
|
459
|
+
)
|
|
460
|
+
| Some(cond) =>
|
|
461
|
+
if cond == expectedCondition {
|
|
462
|
+
Outcome.pass
|
|
463
|
+
} else {
|
|
464
|
+
Outcome.fail(
|
|
465
|
+
AppendConditionMismatch({
|
|
466
|
+
expected: encodeAppendCondition(expectedCondition),
|
|
467
|
+
actual: encodeAppendCondition(cond),
|
|
468
|
+
}),
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Aggregate adapter — preserves the legacy Aggregate-flavor surface.
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
// Minimal aggregate spec the DSL requires. Declared inline (rather than
|
|
479
|
+
// aliasing `Reventless.Behavior.T`'s inner Spec) because that inner Spec
|
|
480
|
+
// only carries the three @schema types — no `name` — and ReScript's
|
|
481
|
+
// `with module Spec = X` can only equate the inner Spec to a concrete
|
|
482
|
+
// module, not constrain it to a module type that adds `name`.
|
|
483
|
+
module type AggregateSpec = {
|
|
484
|
+
let name: string
|
|
485
|
+
@schema
|
|
486
|
+
type command
|
|
487
|
+
@schema
|
|
488
|
+
type event
|
|
489
|
+
@schema
|
|
490
|
+
type error
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
module type AggregateT = {
|
|
494
|
+
module Spec: AggregateSpec
|
|
495
|
+
|
|
496
|
+
let describe: (string, unit => unit) => unit
|
|
497
|
+
let test: (string, unit => Outcome.outcome) => unit
|
|
498
|
+
|
|
499
|
+
let givenEvents: array<Spec.event> => array<Spec.event>
|
|
500
|
+
|
|
501
|
+
let whenCmd: (array<Spec.event>, Spec.command) => array<Spec.event>
|
|
502
|
+
|
|
503
|
+
let thenEvent: (array<Spec.event>, Spec.event) => Outcome.outcome
|
|
504
|
+
let thenCompareEvent: (
|
|
505
|
+
array<Spec.event>,
|
|
506
|
+
Spec.event,
|
|
507
|
+
(Spec.event, Spec.event) => bool,
|
|
508
|
+
) => Outcome.outcome
|
|
509
|
+
let thenNoEvent: array<Spec.event> => Outcome.outcome
|
|
510
|
+
let thenEventWithError: (array<Spec.event>, Spec.event, Spec.error) => Outcome.outcome
|
|
511
|
+
let thenEvents: (array<Spec.event>, array<Spec.event>) => Outcome.outcome
|
|
512
|
+
let thenCompareEvents: (
|
|
513
|
+
array<Spec.event>,
|
|
514
|
+
array<Spec.event>,
|
|
515
|
+
(Spec.event, Spec.event) => bool,
|
|
516
|
+
) => Outcome.outcome
|
|
517
|
+
let thenEventsWithError: (array<Spec.event>, array<Spec.event>, Spec.error) => Outcome.outcome
|
|
518
|
+
let thenError: (array<Spec.event>, Spec.error) => Outcome.outcome
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
module MakeFromAggregate = (
|
|
522
|
+
Spec: AggregateSpec,
|
|
523
|
+
Behavior: Behavior.T with module Spec = Spec,
|
|
524
|
+
): (AggregateT with module Spec = Spec) => {
|
|
525
|
+
module Spec = Spec
|
|
526
|
+
|
|
527
|
+
S.enableJson()
|
|
528
|
+
|
|
529
|
+
let describe = JestBind.describe
|
|
530
|
+
let test = (name, body) => JestBind.test(~slice=Spec.name, name, body)
|
|
531
|
+
|
|
532
|
+
let currentState = events =>
|
|
533
|
+
events->Array.reduce(Behavior.initialState, Behavior.evolve)
|
|
534
|
+
|
|
535
|
+
module Core = AssertionCore(Spec)
|
|
536
|
+
let errors = Core.errors
|
|
537
|
+
|
|
538
|
+
let exec = (history, command): array<Spec.event> => {
|
|
539
|
+
errors := []
|
|
540
|
+
let state = currentState(history)
|
|
541
|
+
switch Behavior.decide(state, command) {
|
|
542
|
+
| Ok(events) => events
|
|
543
|
+
| Error(error) =>
|
|
544
|
+
errors := [error]
|
|
545
|
+
[]
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let givenEvents = events => events
|
|
550
|
+
let whenCmd = (history, cmd) => history->exec(cmd)
|
|
551
|
+
|
|
552
|
+
// The aggregate flavour has no append-condition footgun, so the shared core's
|
|
553
|
+
// comparisons are exposed directly; only `thenCompare*` (custom equality) is
|
|
554
|
+
// specific to this surface.
|
|
555
|
+
let thenEvents = Core.compareEvents
|
|
556
|
+
let thenCompareEvents = Core.compareEventsWith
|
|
557
|
+
let thenEvent = (events, expectedEvent) => thenEvents(events, [expectedEvent])
|
|
558
|
+
let thenCompareEvent = (events, expectedEvent, cmp) =>
|
|
559
|
+
thenCompareEvents(events, [expectedEvent], cmp)
|
|
560
|
+
let thenNoEvent = Core.compareNoEvent
|
|
561
|
+
|
|
562
|
+
let thenError = (events, expectedError) => Core.matchesError(events, [], expectedError)
|
|
563
|
+
|
|
564
|
+
let thenEventWithError = (events, expectedEvent, expectedError) =>
|
|
565
|
+
Core.matchesError(events, [expectedEvent], expectedError)
|
|
566
|
+
|
|
567
|
+
let thenEventsWithError = (events, expectedEvents, expectedError) =>
|
|
568
|
+
Core.matchesError(events, expectedEvents, expectedError)
|
|
569
|
+
}
|