@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.
Files changed (184) hide show
  1. package/CHANGELOG.md +916 -0
  2. package/LICENSE +202 -0
  3. package/bin/reventless-dev.mjs +21 -0
  4. package/package.json +54 -0
  5. package/rescript.json +36 -0
  6. package/src/.gwtignore +10 -0
  7. package/src/Automation_GWT.res +207 -0
  8. package/src/Automation_GWT.res.mjs +160 -0
  9. package/src/Behavior_GWT.res +569 -0
  10. package/src/Behavior_GWT.res.mjs +523 -0
  11. package/src/Bind.res +29 -0
  12. package/src/Bind.res.mjs +22 -0
  13. package/src/BuildClassifier.res +147 -0
  14. package/src/BuildClassifier.res.mjs +136 -0
  15. package/src/Cancellation.res +37 -0
  16. package/src/Cancellation.res.mjs +46 -0
  17. package/src/ChildProcess.res +102 -0
  18. package/src/ChildProcess.res.mjs +107 -0
  19. package/src/Cli.res +905 -0
  20. package/src/Cli.res.mjs +952 -0
  21. package/src/Collector.res +212 -0
  22. package/src/Collector.res.mjs +218 -0
  23. package/src/ComponentMeta.res +77 -0
  24. package/src/ComponentMeta.res.mjs +91 -0
  25. package/src/ComponentScan.res +103 -0
  26. package/src/ComponentScan.res.mjs +105 -0
  27. package/src/Delegate_GWT.res +303 -0
  28. package/src/Delegate_GWT.res.mjs +372 -0
  29. package/src/Diff.res +91 -0
  30. package/src/Diff.res.mjs +98 -0
  31. package/src/Discovery.res +105 -0
  32. package/src/Discovery.res.mjs +99 -0
  33. package/src/DomainDeadCode.res +98 -0
  34. package/src/DomainDeadCode.res.mjs +72 -0
  35. package/src/DomainGraph.res +0 -0
  36. package/src/DomainGraph.res.mjs +0 -0
  37. package/src/EventMapping_GWT.res +29 -0
  38. package/src/EventMapping_GWT.res.mjs +44 -0
  39. package/src/Filter.res +45 -0
  40. package/src/Filter.res.mjs +47 -0
  41. package/src/Flow_GWT.res +664 -0
  42. package/src/Flow_GWT.res.mjs +604 -0
  43. package/src/FormatterHuman.res +109 -0
  44. package/src/FormatterHuman.res.mjs +115 -0
  45. package/src/FormatterJson.res +264 -0
  46. package/src/FormatterJson.res.mjs +322 -0
  47. package/src/FormatterJunit.res +93 -0
  48. package/src/FormatterJunit.res.mjs +90 -0
  49. package/src/FormatterTap.res +100 -0
  50. package/src/FormatterTap.res.mjs +131 -0
  51. package/src/FormatterVsCode.res +352 -0
  52. package/src/FormatterVsCode.res.mjs +546 -0
  53. package/src/Hint.res +119 -0
  54. package/src/Hint.res.mjs +124 -0
  55. package/src/InboundTranslation_GWT.res +83 -0
  56. package/src/InboundTranslation_GWT.res.mjs +94 -0
  57. package/src/JestBind.res +70 -0
  58. package/src/JestBind.res.mjs +63 -0
  59. package/src/Loader.res +24 -0
  60. package/src/Loader.res.mjs +23 -0
  61. package/src/LocalHost.res +119 -0
  62. package/src/LocalHost.res.mjs +99 -0
  63. package/src/Mapping_GWT.res +474 -0
  64. package/src/Mapping_GWT.res.mjs +322 -0
  65. package/src/MismatchRender.res +72 -0
  66. package/src/MismatchRender.res.mjs +127 -0
  67. package/src/MultiSourceProjection_GWT.res +316 -0
  68. package/src/MultiSourceProjection_GWT.res.mjs +354 -0
  69. package/src/OutboundTranslation_GWT.res +238 -0
  70. package/src/OutboundTranslation_GWT.res.mjs +186 -0
  71. package/src/Outcome.res +97 -0
  72. package/src/Outcome.res.mjs +93 -0
  73. package/src/PackageScan.res +80 -0
  74. package/src/PackageScan.res.mjs +81 -0
  75. package/src/PlatformRunner.res +181 -0
  76. package/src/PlatformRunner.res.mjs +176 -0
  77. package/src/PlatformScan.res +142 -0
  78. package/src/PlatformScan.res.mjs +132 -0
  79. package/src/ProcessManager.res +135 -0
  80. package/src/ProcessManager.res.mjs +119 -0
  81. package/src/Projection_GWT.res +312 -0
  82. package/src/Projection_GWT.res.mjs +332 -0
  83. package/src/Query_GWT.res +379 -0
  84. package/src/Query_GWT.res.mjs +367 -0
  85. package/src/RenderRescript.res +125 -0
  86. package/src/RenderRescript.res.mjs +141 -0
  87. package/src/RunWorker.res +24 -0
  88. package/src/RunWorker.res.mjs +16 -0
  89. package/src/RunnerTypes.res +68 -0
  90. package/src/RunnerTypes.res.mjs +60 -0
  91. package/src/SideEffect_GWT.res +157 -0
  92. package/src/SideEffect_GWT.res.mjs +113 -0
  93. package/src/StateChange/ExternalAddCategorySlice.res +25 -0
  94. package/src/StateChange/ExternalAddCategorySlice.res.mjs +34 -0
  95. package/src/StateChange/ExternalAddCategorySlice_Behavior.res +19 -0
  96. package/src/StateChange/ExternalAddCategorySlice_Behavior.res.mjs +46 -0
  97. package/src/StateChange/WithFixtures.res +22 -0
  98. package/src/StateChange/WithFixtures.res.mjs +31 -0
  99. package/src/StateChange/WithFixtures_Behavior.res +17 -0
  100. package/src/StateChange/WithFixtures_Behavior.res.mjs +37 -0
  101. package/src/StateChange/WithManualOpen.res +23 -0
  102. package/src/StateChange/WithManualOpen.res.mjs +31 -0
  103. package/src/StateChange/WithManualOpen_Behavior.res +17 -0
  104. package/src/StateChange/WithManualOpen_Behavior.res.mjs +37 -0
  105. package/src/StubRuntime.res +54 -0
  106. package/src/StubRuntime.res.mjs +55 -0
  107. package/src/TestFixtures.res +16 -0
  108. package/src/TestFixtures.res.mjs +34 -0
  109. package/src/Watch.res +108 -0
  110. package/src/Watch.res.mjs +85 -0
  111. package/src/WatcherProbe.res +56 -0
  112. package/src/WatcherProbe.res.mjs +59 -0
  113. package/src/Worker.res +24 -0
  114. package/src/Worker.res.mjs +9 -0
  115. package/tests/AutomationGwtTest.res +71 -0
  116. package/tests/AutomationGwtTest.res.mjs +189 -0
  117. package/tests/BuildClassifierTest.res +133 -0
  118. package/tests/BuildClassifierTest.res.mjs +176 -0
  119. package/tests/CliRunEntryTest.res +57 -0
  120. package/tests/CliRunEntryTest.res.mjs +65 -0
  121. package/tests/CliWatchScopeTest.res +62 -0
  122. package/tests/CliWatchScopeTest.res.mjs +95 -0
  123. package/tests/ComponentMetaTest.res +67 -0
  124. package/tests/ComponentMetaTest.res.mjs +71 -0
  125. package/tests/DelegateGwtTest.res +487 -0
  126. package/tests/DelegateGwtTest.res.mjs +891 -0
  127. package/tests/DiscoveryTest.res +42 -0
  128. package/tests/DiscoveryTest.res.mjs +43 -0
  129. package/tests/DomainDeadCodeTest.res +119 -0
  130. package/tests/DomainDeadCodeTest.res.mjs +158 -0
  131. package/tests/DomainGraphTest.res +448 -0
  132. package/tests/DomainGraphTest.res.mjs +505 -0
  133. package/tests/FlowAggregateGwtTest.res +171 -0
  134. package/tests/FlowAggregateGwtTest.res.mjs +273 -0
  135. package/tests/FlowCrossPluginGwtTest.res +340 -0
  136. package/tests/FlowCrossPluginGwtTest.res.mjs +798 -0
  137. package/tests/FlowGwtTest.res +222 -0
  138. package/tests/FlowGwtTest.res.mjs +433 -0
  139. package/tests/FormatterGoldenTest.res +125 -0
  140. package/tests/FormatterGoldenTest.res.mjs +162 -0
  141. package/tests/FormatterVsCodeMessageTest.res +41 -0
  142. package/tests/FormatterVsCodeMessageTest.res.mjs +42 -0
  143. package/tests/InboundTranslationGwtTest.res +34 -0
  144. package/tests/InboundTranslationGwtTest.res.mjs +99 -0
  145. package/tests/LocalHostIntegration.res +70 -0
  146. package/tests/LocalHostIntegration.res.mjs +64 -0
  147. package/tests/LocalHostTest.res +71 -0
  148. package/tests/LocalHostTest.res.mjs +63 -0
  149. package/tests/MappingGwtTest.res +350 -0
  150. package/tests/MappingGwtTest.res.mjs +596 -0
  151. package/tests/OutboundTranslationGwtTest.res +59 -0
  152. package/tests/OutboundTranslationGwtTest.res.mjs +139 -0
  153. package/tests/PlatformRunnerTest.res +66 -0
  154. package/tests/PlatformRunnerTest.res.mjs +85 -0
  155. package/tests/PlatformScanTest.res +61 -0
  156. package/tests/PlatformScanTest.res.mjs +56 -0
  157. package/tests/QueryGwtTest.res +161 -0
  158. package/tests/QueryGwtTest.res.mjs +314 -0
  159. package/tests/QueryResolverGwtTest.res +71 -0
  160. package/tests/QueryResolverGwtTest.res.mjs +136 -0
  161. package/tests/RunnerUnitTest.res +219 -0
  162. package/tests/RunnerUnitTest.res.mjs +244 -0
  163. package/tests/SideEffect/FakeOrderNotification.res +23 -0
  164. package/tests/SideEffect/FakeOrderNotification.res.mjs +36 -0
  165. package/tests/SideEffect/FakeOrderNotificationGwtTest.res +35 -0
  166. package/tests/SideEffect/FakeOrderNotificationGwtTest.res.mjs +73 -0
  167. package/tests/SideEffect/MockEmail.res +18 -0
  168. package/tests/SideEffect/MockEmail.res.mjs +47 -0
  169. package/tests/StateChange/ExternalAddCategorySlice_GWT.res +27 -0
  170. package/tests/StateChange/ExternalAddCategorySlice_GWT.res.mjs +70 -0
  171. package/tests/StateChange/WithFixtures_Fixtures.res +9 -0
  172. package/tests/StateChange/WithFixtures_Fixtures.res.mjs +20 -0
  173. package/tests/StateChange/WithFixtures_GWT.res +23 -0
  174. package/tests/StateChange/WithFixtures_GWT.res.mjs +56 -0
  175. package/tests/StateChange/WithManualOpen_Fixtures.res +7 -0
  176. package/tests/StateChange/WithManualOpen_Fixtures.res.mjs +20 -0
  177. package/tests/StateChange/WithManualOpen_GWT.res +17 -0
  178. package/tests/StateChange/WithManualOpen_GWT.res.mjs +56 -0
  179. package/tests/StateChangeSliceGwtTest.res +160 -0
  180. package/tests/StateChangeSliceGwtTest.res.mjs +280 -0
  181. package/tests/StateViewSliceGwtTest.res +64 -0
  182. package/tests/StateViewSliceGwtTest.res.mjs +161 -0
  183. package/tests/WatchDebounceTest.res +106 -0
  184. 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
+ }