@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,664 @@
1
+ open ReventlessCore
2
+
3
+ // Flow_GWT — cross-slice / end-to-end GWT.
4
+ //
5
+ // Threads ONE JSON-erased event log through a chain of slices, so a single
6
+ // declarative test verifies that the tiles of an Event Modeling board connect
7
+ // the way the diagram says: a command lands, an event is recorded, an
8
+ // automation reacts and issues the next command, a read model updates, an
9
+ // outbound effect fires. Where every other GWT kind verifies one tile in
10
+ // isolation, this one verifies the wiring between them.
11
+ //
12
+ // Slices are pure, so the flow runs with no bus, no scheduler and no timers:
13
+ // each step folds the slice's own `decide`/`evolve`/`collect`/`project` over
14
+ // the shared log. The log is JSON-erased (each step (de)serialises through its
15
+ // own spec's schema, exactly as the runtime routes a DCB event log), and each
16
+ // command step honours DCB tag filtering so downstream deciders never see
17
+ // phantom cross-entity events.
18
+ //
19
+ // The chain is built from per-step functors (one per slice), threaded with
20
+ // pipe-first so it reads top-to-bottom:
21
+ //
22
+ // module Place = Flow_GWT.CommandStep(PlaceOrder, PlaceOrder_Behavior)
23
+ // module Auto = Flow_GWT.AutomationStep(AutoShipOrderSlice)
24
+ // module Ship = Flow_GWT.CommandStep(ShipOrder, ShipOrder_Behavior)
25
+ // module View = Flow_GWT.ViewStep(Orders, Orders_Projection)
26
+ //
27
+ // start
28
+ // ->Sync.givenEvents([CatalogProductSynced({...})])
29
+ // ->Place.whenCommand(PlaceOrder({...}))->Place.thenEvents([OrderPlaced({...})])
30
+ // ->Auto.whenReacts->Auto.thenIssuesCommand(ShipOrder({...}))
31
+ // ->Ship.whenCommand(ShipOrder({...}))->Ship.thenEvents([OrderShipped({...})])
32
+ // ->View.thenViewState("o1", {...})
33
+ //
34
+ // Cross-plugin flows (Phase 3) add two boundary steps — `ExtensionPointStep`
35
+ // and `ExtensionStep` — that continue a flow from one plugin into the next
36
+ // through the ExtensionPoint mapping / Extension delegate, exactly as the
37
+ // runtime routes it across the plugin boundary.
38
+ //
39
+ // See `docs/plans/done/gwt-flow-and-extension-test-kinds.md` Phases 2–3.
40
+
41
+ module EPMapping = ReventlessInfra.ExtensionPointMapping
42
+ module ExtMapping = ReventlessInfra.ExtensionMapping
43
+
44
+ // -- Shared log + flow state -------------------------------------------------
45
+
46
+ // One JSON-erased entry on the shared log: the variant name, the DCB tags
47
+ // extracted from the producing schema (so downstream tag filtering matches the
48
+ // runtime), the encoded event JSON, and — for events emitted by an aggregate
49
+ // step — the producing aggregate-ID. DCB step entries leave `aggregateId` as
50
+ // `None`; aggregate step entries set it to `Some(id)` so the matching
51
+ // `AggregateCommandStep` can rebuild per-aggregate history without DCB tag
52
+ // inference. Filtering by `aggregateId` and filtering by DCB tags are mutually
53
+ // exclusive: DCB queries look only at `eventType` + `tags`, and the aggregate
54
+ // decoder looks only at `aggregateId` + `eventType`.
55
+ type logEntry = {
56
+ eventType: string,
57
+ tags: array<Reventless.DcbTag.tag>,
58
+ json: JSON.t,
59
+ aggregateId: option<string>,
60
+ }
61
+
62
+ // The flow state threaded through every step. `outcome` accumulates assertions
63
+ // (first failure wins); the `last*` fields carry the most recent `when*`
64
+ // result for the matching `then*` to read.
65
+ type flowState = {
66
+ log: array<logEntry>,
67
+ outcome: Outcome.outcome,
68
+ lastEvents: array<JSON.t>, // encoded events from the last command step
69
+ lastError: option<JSON.t>, // encoded error from the last command step
70
+ lastCommands: array<JSON.t>, // encoded commands from the last automation / extension reaction
71
+ lastPublic: array<JSON.t>, // encoded public EP events from the last ExtensionPoint step
72
+ // The producing aggregate-ID for `lastEvents`, when the producer was an
73
+ // `AggregateCommandStep`. Aggregate-style EP mappings reference the source
74
+ // aggregate-ID inside `mapOutgoingEvent` (e.g. the catalog Product EP uses
75
+ // it as `productId`); the next `ExtensionPointStep` reads this field and
76
+ // threads it through. DCB `CommandStep` events leave it `None` and the EP
77
+ // step falls back to the synthetic `gwt-id` so the existing DCB Flow tests
78
+ // are unchanged.
79
+ lastAggregateId: option<string>,
80
+ }
81
+
82
+ type flow = promise<flowState>
83
+
84
+ S.enableJson()
85
+
86
+ let emptyState = {
87
+ log: [],
88
+ outcome: Outcome.pass,
89
+ lastEvents: [],
90
+ lastError: None,
91
+ lastCommands: [],
92
+ lastPublic: [],
93
+ lastAggregateId: None,
94
+ }
95
+
96
+ // Entry point for a chain — an empty flow with no prior history.
97
+ let start: flow = Promise.resolve(emptyState)
98
+
99
+ // First failure wins: once the flow has recorded a mismatch, later assertions
100
+ // keep it rather than overwrite with a (possibly spurious) downstream result.
101
+ let recordOutcome = (s: flowState, o: Outcome.outcome): flowState =>
102
+ switch s.outcome {
103
+ | Error(_) => s
104
+ | Ok() => {...s, outcome: o}
105
+ }
106
+
107
+ // Mirror the in-memory DCB event log's `matchesQuery`: an empty query matches
108
+ // everything; otherwise an entry matches if ANY clause matches (its event type
109
+ // is in the clause's `eventTypes` AND it carries every tag the clause lists).
110
+ let entryMatchesQuery = (entry: logEntry, query: Reventless.DcbTag.query) =>
111
+ if query->Array.length == 0 {
112
+ true
113
+ } else {
114
+ query->Array.some(qi => {
115
+ let typeMatch = switch qi.eventTypes {
116
+ | Some(types) => types->Array.includes(entry.eventType)
117
+ | None => true
118
+ }
119
+ let tagMatch = switch qi.tags {
120
+ | Some(tags) =>
121
+ tags->Array.every(tag =>
122
+ entry.tags->Array.some(et => et.key == tag.key && et.value == tag.value)
123
+ )
124
+ | None => true
125
+ }
126
+ typeMatch && tagMatch
127
+ })
128
+ }
129
+
130
+ // Encode + tag a batch of typed events and append them to the log.
131
+ let appendEvents = (log: array<logEntry>, events: array<'e>, schema: S.t<'e>): array<logEntry> =>
132
+ Array.concat(
133
+ log,
134
+ events->Array.map(ev => {
135
+ let json = ev->Message.encode(schema)
136
+ {
137
+ eventType: json->Reventless.Message.variantNameOfJson,
138
+ tags: Reventless.DcbTag.extractTagsExpanded(schema, ev),
139
+ json,
140
+ aggregateId: None,
141
+ }
142
+ }),
143
+ )
144
+
145
+ // Aggregate-style append: encode + tag with the producing aggregate-ID so
146
+ // `decodeAggregateMatching` can rebuild that aggregate's history later. DCB
147
+ // tags are intentionally still extracted (cheap and lets a downstream DCB
148
+ // step pick the events up if any are tagged), but the `aggregateId` is the
149
+ // authoritative filter for aggregate consumers.
150
+ let appendAggregateEvents = (
151
+ log: array<logEntry>,
152
+ ~id: string,
153
+ events: array<'e>,
154
+ schema: S.t<'e>,
155
+ ): array<logEntry> =>
156
+ Array.concat(
157
+ log,
158
+ events->Array.map(ev => {
159
+ let json = ev->Message.encode(schema)
160
+ {
161
+ eventType: json->Reventless.Message.variantNameOfJson,
162
+ tags: Reventless.DcbTag.extractTagsExpanded(schema, ev),
163
+ json,
164
+ aggregateId: Some(id),
165
+ }
166
+ }),
167
+ )
168
+
169
+ // Decode the log entries a consumer cares about. `query` filters by event type
170
+ // and DCB tags (use `[]` to take all of the consumer's event types); the
171
+ // decoder drops entries whose type the consumer does not declare.
172
+ let decodeMatching = (
173
+ log: array<logEntry>,
174
+ decoder: Reventless.DcbDecode.makeDecoderResult<'c>,
175
+ query: Reventless.DcbTag.query,
176
+ ): array<'c> =>
177
+ log->Array.filterMap(entry =>
178
+ if entry->entryMatchesQuery(query) {
179
+ let data = switch entry.json {
180
+ | Object(d) => d
181
+ | _ => Dict.make()
182
+ }
183
+ decoder.decode(~eventType=entry.eventType, ~data)
184
+ } else {
185
+ None
186
+ }
187
+ )
188
+
189
+ // Aggregate-style decode: filter the log to entries this aggregate-step
190
+ // emitted for `id`, then decode them with the aggregate's event schema. No
191
+ // DCB tag inference — partitioning is by `aggregateId` only, matching the
192
+ // way `Reventless.Aggregate` folds events at runtime.
193
+ let decodeAggregateMatching = (
194
+ log: array<logEntry>,
195
+ ~id: string,
196
+ decoder: Reventless.DcbDecode.makeDecoderResult<'c>,
197
+ ): array<'c> =>
198
+ log->Array.filterMap(entry =>
199
+ switch entry.aggregateId {
200
+ | Some(eid) if eid == id =>
201
+ let data = switch entry.json {
202
+ | Object(d) => d
203
+ | _ => Dict.make()
204
+ }
205
+ decoder.decode(~eventType=entry.eventType, ~data)
206
+ | _ => None
207
+ }
208
+ )
209
+
210
+ // -- Top-level test registration ---------------------------------------------
211
+
212
+ let describe = JestBind.describe
213
+
214
+ // The chain returns a `flow`; `test` awaits it and reports the accumulated
215
+ // outcome. Bodies read top-to-bottom and end on the last `then*`.
216
+ let test = (name, ~timeout=?, body: unit => flow) =>
217
+ JestBind.testPromise(~slice="Flow", name, ~timeout?, async () => {
218
+ let s = await body()
219
+ s.outcome
220
+ })
221
+
222
+ // -- CommandStep: a StateChangeSlice / Aggregate decider ---------------------
223
+
224
+ module CommandStep = (
225
+ Spec: Behavior_GWT.BehaviorSpec,
226
+ Behavior: Behavior_GWT.Behavior with module Spec := Spec,
227
+ ) => {
228
+ let consumedDecoder = Reventless.DcbDecode.makeDecoder(Spec.consumedEventSchema)
229
+ let consumedEventTypes = consumedDecoder.eventTypes
230
+
231
+ // Seed prior history with events this slice can emit.
232
+ let givenEvents = async (flowP: flow, events: array<Spec.event>) => {
233
+ let s = await flowP
234
+ {...s, log: s.log->appendEvents(events, Spec.eventSchema)}
235
+ }
236
+
237
+ // Filter the log by the command's DCB tags, fold `evolve`, run `decide`, and
238
+ // append the emitted events back onto the shared log.
239
+ // Scope derived from this slice's schemas, mirroring the StateChangeSlice runtime:
240
+ // cross-partition keys (explicit @crossPartition ∪ per-slice inference) fan a
241
+ // foreign reference into its own clause, and the per-event indexed-tag map narrows
242
+ // a foreign reference on this slice's own emitted event out of that clause (so the
243
+ // real tagged log here does not return sibling entities).
244
+ let scopeShape = Reventless.DcbTag.sliceShapeFromSchemas(
245
+ ~name="",
246
+ ~commandSchema=Spec.commandSchema,
247
+ ~consumedEventSchema=Spec.consumedEventSchema,
248
+ ~eventSchema=Spec.eventSchema,
249
+ )
250
+ let crossPartitionTagKeys = {
251
+ let set = Set.make()
252
+ Reventless.DcbTag.extractCrossPartitionTagKeys(Spec.eventSchema)->Array.forEach(k =>
253
+ set->Set.add(k)
254
+ )
255
+ Reventless.DcbScopeInference.crossPartitionForSlice(scopeShape)->Array.forEach(k =>
256
+ set->Set.add(k)
257
+ )
258
+ Array.fromIterator(set->Set.values)->Array.toSorted((a, b) => String.compare(a, b))
259
+ }
260
+ let tagKeysByEventType =
261
+ Reventless.DcbScopeInference.infer([scopeShape]).tagKeysByEventType
262
+
263
+ let whenCommand = async (flowP: flow, command: Spec.command) => {
264
+ let s = await flowP
265
+ let query = Reventless.DcbTag.buildQueryFromCommand(
266
+ ~eventTypes=consumedEventTypes,
267
+ ~schema=Spec.commandSchema,
268
+ ~value=command,
269
+ ~tagKeysByEventType,
270
+ ~crossPartitionTagKeys,
271
+ )
272
+ let history = decodeMatching(s.log, consumedDecoder, query)
273
+ let state = history->Array.reduce(Behavior.initialState, Behavior.evolve)
274
+ switch Behavior.decide(state, command) {
275
+ | Ok(events) => {
276
+ ...s,
277
+ log: s.log->appendEvents(events, Spec.eventSchema),
278
+ lastEvents: events->Array.map(e => e->Message.encode(Spec.eventSchema)),
279
+ lastError: None,
280
+ lastAggregateId: None,
281
+ }
282
+ | Error(error) => {
283
+ ...s,
284
+ lastEvents: [],
285
+ lastError: Some(error->Message.encode(Spec.errorSchema)),
286
+ lastAggregateId: None,
287
+ }
288
+ }
289
+ }
290
+
291
+ let thenEvents = async (flowP: flow, expected: array<Spec.event>) => {
292
+ let s = await flowP
293
+ let expectedJson = expected->Array.map(e => e->Message.encode(Spec.eventSchema))
294
+ let o = switch s.lastError {
295
+ | Some(err) =>
296
+ Outcome.fail(
297
+ ErrorMismatch({expected: JSON.Encode.null, actual: Some(err), actualEvents: s.lastEvents}),
298
+ )
299
+ | None =>
300
+ s.lastEvents == expectedJson
301
+ ? Outcome.pass
302
+ : Outcome.fail(EventsMismatch({expected: expectedJson, actual: s.lastEvents}))
303
+ }
304
+ s->recordOutcome(o)
305
+ }
306
+
307
+ let thenEvent = (flowP, event) => thenEvents(flowP, [event])
308
+
309
+ let thenError = async (flowP: flow, expected: Spec.error) => {
310
+ let s = await flowP
311
+ let expJson = expected->Message.encode(Spec.errorSchema)
312
+ let o = switch s.lastError {
313
+ | None =>
314
+ Outcome.fail(ErrorMismatch({expected: expJson, actual: None, actualEvents: s.lastEvents}))
315
+ | Some(actual) =>
316
+ actual == expJson
317
+ ? Outcome.pass
318
+ : Outcome.fail(
319
+ ErrorMismatch({expected: expJson, actual: Some(actual), actualEvents: s.lastEvents}),
320
+ )
321
+ }
322
+ s->recordOutcome(o)
323
+ }
324
+ }
325
+
326
+ // -- AggregateCommandStep: an Aggregate decider partitioned by aggregate-ID --
327
+ //
328
+ // Mirror of `CommandStep` for `Reventless.Behavior.T` (the aggregate-flavor
329
+ // behavior). Differences from the slice form:
330
+ // * No `consumedEvent` distinction — aggregates fold their own `event`
331
+ // stream; the decoder uses `Spec.eventSchema`.
332
+ // * No DCB tag inference — `~id` partitions the history. `givenEvents` and
333
+ // `whenCommand` both take `~id`, and the emitted events are stamped with
334
+ // the same `id` on the shared log so a subsequent step can read them
335
+ // back.
336
+ //
337
+ // Sequencing two aggregate steps for the same `~id` rebuilds the running
338
+ // state across the chain; sequencing them for different `~id` keeps their
339
+ // histories isolated (a `Place` on `o2` does not see the events of `o1`).
340
+
341
+ module AggregateCommandStep = (
342
+ Spec: Behavior_GWT.AggregateSpec,
343
+ Behavior: Reventless.Behavior.T with module Spec = Spec,
344
+ ) => {
345
+ let aggregateDecoder = Reventless.DcbDecode.makeDecoder(Spec.eventSchema)
346
+
347
+ // Seed `id`'s history before the first `whenCommand`. Useful when a flow
348
+ // starts mid-stream (e.g. the order is already Placed when the test begins).
349
+ let givenEvents = async (flowP: flow, ~id: string, events: array<Spec.event>) => {
350
+ let s = await flowP
351
+ {...s, log: s.log->appendAggregateEvents(~id, events, Spec.eventSchema)}
352
+ }
353
+
354
+ // Rebuild `id`'s state from the log, run `decide`, then either append the
355
+ // emitted events (still tagged with `~id`) or remember the error for the
356
+ // next `thenError`.
357
+ let whenCommand = async (flowP: flow, ~id: string, command: Spec.command) => {
358
+ let s = await flowP
359
+ let history = decodeAggregateMatching(s.log, ~id, aggregateDecoder)
360
+ let state = history->Array.reduce(Behavior.initialState, Behavior.evolve)
361
+ switch Behavior.decide(state, command) {
362
+ | Ok(events) => {
363
+ ...s,
364
+ log: s.log->appendAggregateEvents(~id, events, Spec.eventSchema),
365
+ lastEvents: events->Array.map(e => e->Message.encode(Spec.eventSchema)),
366
+ lastError: None,
367
+ lastAggregateId: Some(id),
368
+ }
369
+ | Error(error) => {
370
+ ...s,
371
+ lastEvents: [],
372
+ lastError: Some(error->Message.encode(Spec.errorSchema)),
373
+ lastAggregateId: Some(id),
374
+ }
375
+ }
376
+ }
377
+
378
+ let thenEvents = async (flowP: flow, expected: array<Spec.event>) => {
379
+ let s = await flowP
380
+ let expectedJson = expected->Array.map(e => e->Message.encode(Spec.eventSchema))
381
+ let o = switch s.lastError {
382
+ | Some(err) =>
383
+ Outcome.fail(
384
+ ErrorMismatch({expected: JSON.Encode.null, actual: Some(err), actualEvents: s.lastEvents}),
385
+ )
386
+ | None =>
387
+ s.lastEvents == expectedJson
388
+ ? Outcome.pass
389
+ : Outcome.fail(EventsMismatch({expected: expectedJson, actual: s.lastEvents}))
390
+ }
391
+ s->recordOutcome(o)
392
+ }
393
+
394
+ let thenEvent = (flowP, event) => thenEvents(flowP, [event])
395
+
396
+ let thenNoEvent = async (flowP: flow) => {
397
+ let s = await flowP
398
+ let o = switch s.lastError {
399
+ | Some(err) =>
400
+ Outcome.fail(
401
+ ErrorMismatch({expected: JSON.Encode.null, actual: Some(err), actualEvents: s.lastEvents}),
402
+ )
403
+ | None =>
404
+ s.lastEvents->Array.length == 0
405
+ ? Outcome.pass
406
+ : Outcome.fail(NoEventExpected({actual: s.lastEvents}))
407
+ }
408
+ s->recordOutcome(o)
409
+ }
410
+
411
+ let thenError = async (flowP: flow, expected: Spec.error) => {
412
+ let s = await flowP
413
+ let expJson = expected->Message.encode(Spec.errorSchema)
414
+ let o = switch s.lastError {
415
+ | None =>
416
+ Outcome.fail(ErrorMismatch({expected: expJson, actual: None, actualEvents: s.lastEvents}))
417
+ | Some(actual) =>
418
+ actual == expJson
419
+ ? Outcome.pass
420
+ : Outcome.fail(
421
+ ErrorMismatch({expected: expJson, actual: Some(actual), actualEvents: s.lastEvents}),
422
+ )
423
+ }
424
+ s->recordOutcome(o)
425
+ }
426
+ }
427
+
428
+ // -- AutomationStep: a policy that reacts to log events and issues a command --
429
+
430
+ module AutomationStep = (Spec: Automation_GWT.SliceSpec) => {
431
+ let consumedDecoder = Reventless.DcbDecode.makeDecoder(Spec.consumedEventSchema)
432
+
433
+ // Sweep the log: collect todos, drop the ones resolved within the same
434
+ // stream, process the remainder into commands.
435
+ let whenReacts = async (flowP: flow) => {
436
+ let s = await flowP
437
+ let events = decodeMatching(s.log, consumedDecoder, [])
438
+ let collected = events->Array.map(e => e->Spec.collect)->Array.flat
439
+ let resolvedIds = events->Array.filterMap(e => e->Spec.resolve)
440
+ let pending = collected->Array.filter(((id, _)) => !(resolvedIds->Array.includes(id)))
441
+ let commands = pending->Array.filterMap(((id, todo)) => Spec.process(id, todo))
442
+ {...s, lastCommands: commands->Array.map(((_id, cmd)) => cmd->Message.encode(Spec.commandSchema))}
443
+ }
444
+
445
+ let thenIssuesCommands = async (flowP: flow, expected: array<Spec.command>) => {
446
+ let s = await flowP
447
+ let expJson = expected->Array.map(c => c->Message.encode(Spec.commandSchema))
448
+ let o =
449
+ s.lastCommands == expJson
450
+ ? Outcome.pass
451
+ : Outcome.fail(EventsMismatch({expected: expJson, actual: s.lastCommands}))
452
+ s->recordOutcome(o)
453
+ }
454
+
455
+ let thenIssuesCommand = (flowP, command) => thenIssuesCommands(flowP, [command])
456
+ let thenIssuesNoCommand = flowP => thenIssuesCommands(flowP, [])
457
+ }
458
+
459
+ // -- ViewStep: a StateViewSlice / projection over the log --------------------
460
+
461
+ module ViewStep = (
462
+ Spec: Projection_GWT.Spec,
463
+ Projection: Projection_GWT.Projection with module Spec := Spec,
464
+ ) => {
465
+ module P = Projection_GWT.Make(Spec, Projection)
466
+ let consumedDecoder = Reventless.DcbDecode.makeDecoder(Spec.consumedEventSchema)
467
+
468
+ let encState = (st: Spec.state) => st->Message.encode(Spec.stateSchema)
469
+
470
+ // Fold the projection over every consumed event in the log and assert the
471
+ // row(s) for `id`.
472
+ let thenViewStates = async (flowP: flow, id, expected: array<Spec.state>) => {
473
+ let s = await flowP
474
+ let events = decodeMatching(s.log, consumedDecoder, [])
475
+ let store = await P.givenEvents(events)
476
+ let actual = store->Dict.get(id)->Option.getOr([])->Array.map(encState)
477
+ let expectedJson = expected->Array.map(encState)
478
+ let o =
479
+ actual == expectedJson
480
+ ? Outcome.pass
481
+ : Outcome.fail(
482
+ StateMismatch({
483
+ key: id,
484
+ expected: Some(JSON.Encode.array(expectedJson)),
485
+ actual: Some(JSON.Encode.array(actual)),
486
+ }),
487
+ )
488
+ s->recordOutcome(o)
489
+ }
490
+
491
+ let thenViewState = (flowP, id, state) => thenViewStates(flowP, id, [state])
492
+ }
493
+
494
+ // -- OutboundStep: an OutboundTranslationSlice's `collect` over the log -------
495
+
496
+ module OutboundStep = (Spec: OutboundTranslation_GWT.SliceSpec) => {
497
+ let consumedDecoder = Reventless.DcbDecode.makeDecoder(Spec.consumedEventSchema)
498
+
499
+ let encItems = (arr: array<(string, Spec.outboundItem)>) =>
500
+ arr->Array.map(((id, item)) => (id, item->Message.encode(Spec.outboundItemSchema)))
501
+
502
+ // Assert the outbound effects the slice would fire — the `collect` result.
503
+ // (The async `translate` leg hits an external service, so it is exercised
504
+ // only by the dedicated `OutboundTranslation_GWT`, not the flow.)
505
+ let thenOutbound = async (flowP: flow, expected: array<(string, Spec.outboundItem)>) => {
506
+ let s = await flowP
507
+ let events = decodeMatching(s.log, consumedDecoder, [])
508
+ let collected = events->Array.map(e => e->Spec.collect)->Array.flat
509
+ let actual = encItems(collected)
510
+ let expectedJson = encItems(expected)
511
+ let o =
512
+ actual == expectedJson
513
+ ? Outcome.pass
514
+ : Outcome.fail(TodoMismatch({expected: expectedJson, actual}))
515
+ s->recordOutcome(o)
516
+ }
517
+
518
+ let thenOutboundNothing = flowP => thenOutbound(flowP, [])
519
+ }
520
+
521
+ // -- Cross-plugin boundary steps (Phase 3) -----------------------------------
522
+
523
+ // Order-independent comparison for published actions — fan-out order is
524
+ // non-deterministic, so compare sorted by stringified JSON.
525
+ let sortJson = (arr: array<JSON.t>) =>
526
+ arr->Array.toSorted((a, b) => String.compare(JSON.stringify(a), JSON.stringify(b)))
527
+
528
+ // ExtensionPointStep: runs an EP mapping's `mapOutgoingEvent` over the events
529
+ // the last command step produced, turning internal events into the public
530
+ // extension-point events the next plugin subscribes to (handles one-to-many
531
+ // fan-out). The public events are appended to the shared log and remembered
532
+ // for `thenPublicEvent(s)` / the following `ExtensionStep`.
533
+ module ExtensionPointStep = (M: EPMapping.Mapping) => {
534
+ let delegateDecoder = Reventless.DcbDecode.makeDecoder(M.Delegate.eventSchema)
535
+
536
+ // Aggregate-style EP mappings address the source aggregate's identity via
537
+ // the first arg (e.g. catalog Product → ProductBecameAvailable uses it as
538
+ // `productId`). DCB upstreams don't set `lastAggregateId` and fall back to
539
+ // the synthetic `gwt-id` so existing DCB Flow tests keep their behaviour.
540
+ let runMapping = async (
541
+ sourceId: string,
542
+ events: array<M.Delegate.event>,
543
+ ): array<M.ExtensionPoint.event> =>
544
+ switch M.mapOutgoingEvent {
545
+ | None => []
546
+ | Some(f) =>
547
+ let actions =
548
+ events
549
+ ->Array.map(ev => f(sourceId, ev, StubRuntime.meta, StubRuntime.queryEngine))
550
+ ->Array.flat
551
+ let nested =
552
+ await actions
553
+ ->Array.map(async action =>
554
+ switch action {
555
+ | EPMapping.PublishEvent(_id, e) => [e]
556
+ | EPMapping.PublishEventAsync(p) =>
557
+ let (_id, e) = await p
558
+ [e]
559
+ | EPMapping.HandleDirective(_, _) => []
560
+ }
561
+ )
562
+ ->Promise.all
563
+ nested->Array.flat
564
+ }
565
+
566
+ let whenPublishedThrough = async (flowP: flow) => {
567
+ let s = await flowP
568
+ let delegateEvents =
569
+ s.lastEvents->Array.filterMap(json => {
570
+ let data = switch json {
571
+ | Object(d) => d
572
+ | _ => Dict.make()
573
+ }
574
+ delegateDecoder.decode(~eventType=json->Reventless.Message.variantNameOfJson, ~data)
575
+ })
576
+ let sourceId = s.lastAggregateId->Option.getOr("gwt-id")
577
+ let publicEvents = await runMapping(sourceId, delegateEvents)
578
+ {
579
+ ...s,
580
+ log: s.log->appendEvents(publicEvents, M.ExtensionPoint.eventSchema),
581
+ lastPublic: publicEvents->Array.map(e => e->Message.encode(M.ExtensionPoint.eventSchema)),
582
+ }
583
+ }
584
+
585
+ let thenPublicEvents = async (flowP: flow, expected: array<M.ExtensionPoint.event>) => {
586
+ let s = await flowP
587
+ let expJson = expected->Array.map(e => e->Message.encode(M.ExtensionPoint.eventSchema))
588
+ let o =
589
+ sortJson(s.lastPublic) == sortJson(expJson)
590
+ ? Outcome.pass
591
+ : Outcome.fail(PublishedActionsMismatch({expected: expJson, actual: s.lastPublic}))
592
+ s->recordOutcome(o)
593
+ }
594
+
595
+ let thenPublicEvent = (flowP, event) => thenPublicEvents(flowP, [event])
596
+ }
597
+
598
+ // ExtensionStep: runs an Extension delegate's `mapIncomingEvent` over the public
599
+ // EP events the last ExtensionPoint step produced, surfacing the commands the
600
+ // extension issues onto its own plugin's slices. The next `CommandStep` then
601
+ // executes the asserted command.
602
+ module ExtensionStep = (M: ExtMapping.Mapping) => {
603
+ let epDecoder = Reventless.DcbDecode.makeDecoder(M.ExtensionPoint.eventSchema)
604
+ let encCmd = (c: M.Delegate.command) => c->Message.encode(M.Delegate.commandSchema)
605
+
606
+ let whenExtensionReacts = async (flowP: flow) => {
607
+ let s = await flowP
608
+ let epEvents =
609
+ s.lastPublic->Array.filterMap(json => {
610
+ let data = switch json {
611
+ | Object(d) => d
612
+ | _ => Dict.make()
613
+ }
614
+ epDecoder.decode(~eventType=json->Reventless.Message.variantNameOfJson, ~data)
615
+ })
616
+ let actions =
617
+ epEvents
618
+ ->Array.map(ev =>
619
+ M.mapIncomingEvent(
620
+ "gwt-id",
621
+ ev,
622
+ StubRuntime.meta,
623
+ StubRuntime.pluginDefinition,
624
+ StubRuntime.queryEngine,
625
+ )
626
+ )
627
+ ->Array.flat
628
+ let nested =
629
+ await actions
630
+ ->Array.map(async action =>
631
+ switch action {
632
+ | ExtMapping.PublishStateChangeSliceCommand(cmd) => [encCmd(cmd)]
633
+ | ExtMapping.PublishStateChangeSliceCommandAsync(p) =>
634
+ let cmd = await p
635
+ [encCmd(cmd)]
636
+ | ExtMapping.PublishStateChangeSliceCommandsAsync(p) => (await p)->Array.map(encCmd)
637
+ | ExtMapping.PublishAggregateCommand(_id, cmd) => [encCmd(cmd)]
638
+ | ExtMapping.PublishAggregateCommandAsync(p) =>
639
+ let (_id, cmd) = await p
640
+ [encCmd(cmd)]
641
+ | ExtMapping.PublishAggregateCommandsAsync(p) =>
642
+ (await p)->Array.map(((_id, cmd)) => encCmd(cmd))
643
+ | ExtMapping.PublishExtensionPointCommand(_, _)
644
+ | ExtMapping.ForwardCommand(_)
645
+ | ExtMapping.HandleDirective(_, _) => []
646
+ }
647
+ )
648
+ ->Promise.all
649
+ {...s, lastCommands: nested->Array.flat}
650
+ }
651
+
652
+ let thenIssuesCommands = async (flowP: flow, expected: array<M.Delegate.command>) => {
653
+ let s = await flowP
654
+ let expJson = expected->Array.map(encCmd)
655
+ let o =
656
+ sortJson(s.lastCommands) == sortJson(expJson)
657
+ ? Outcome.pass
658
+ : Outcome.fail(PublishedActionsMismatch({expected: expJson, actual: s.lastCommands}))
659
+ s->recordOutcome(o)
660
+ }
661
+
662
+ let thenIssuesCommand = (flowP, command) => thenIssuesCommands(flowP, [command])
663
+ let thenIssuesNoCommand = flowP => thenIssuesCommands(flowP, [])
664
+ }