@reventlessdev/reventless-gwt 1.0.0-alpha.74

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