@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,365 @@
1
+ // Unit tests for DomainGraph.build — Event-Modeling node/edge assembly over
2
+ // synthetic pluginStructures. Pure (no reventless-local import), so it runs under
3
+ // Jest. The real end-to-end shape over the example plugins is exercised by the
4
+ // LocalHost integration; here hand-built fixtures pin the edge wiring.
5
+
6
+ open JestGlobals
7
+
8
+ let command = (~name, ~mutationField, ~apiExposed=None): Reventless.Plugin.commandDef => {
9
+ name,
10
+ schema: "",
11
+ level: Instance,
12
+ aggregateIdField: None,
13
+ mutationField,
14
+ references: [],
15
+ allowedStates: None,
16
+ apiExposed,
17
+ }
18
+
19
+ let evt = (~name): Reventless.Plugin.eventDef => {name, schema: "", references: []}
20
+
21
+ let writable = (~name, ~commands=[], ~produces=[], ~consumes=[], ~linkedViews=[], ~events=[]): Reventless.Plugin.writableDef => {
22
+ name,
23
+ commands,
24
+ producedEventTypes: produces,
25
+ consumedEventTypes: consumes,
26
+ linkedViews,
27
+ consistencyRead: None,
28
+ events,
29
+ }
30
+
31
+ let queryable = (~name, ~consumes=[], ~visibility=None): Reventless.Plugin.queryableDef => {
32
+ name,
33
+ queryField: "",
34
+ schema: "",
35
+ consumedEventTypes: consumes,
36
+ linkedWriteSide: [],
37
+ labelField: "id",
38
+ searchableFields: [],
39
+ statusField: None,
40
+ visibility,
41
+ }
42
+
43
+ let automation = (~name, ~consumes=[], ~produces=[], ~target=""): Reventless.Plugin.automationSliceDef => {
44
+ name,
45
+ consumedEventTypes: consumes,
46
+ producedCommandTypes: produces,
47
+ targetName: target,
48
+ }
49
+
50
+ let inbound = (~name, ~produces=[], ~target=""): Reventless.Plugin.inboundTranslationSliceDef => {
51
+ name,
52
+ commandTypes: produces,
53
+ targetName: target,
54
+ }
55
+
56
+ let outbound = (~name, ~consumes=[], ~produces=[], ~target=None): Reventless.Plugin.outboundTranslationSliceDef => {
57
+ name,
58
+ consumedEventTypes: consumes,
59
+ inboundCommandTypes: produces,
60
+ targetName: target,
61
+ }
62
+
63
+ let structure = (
64
+ ~aggregates=[],
65
+ ~readModels=[],
66
+ ~stateViewSlices=[],
67
+ ~stateChangeSlices=[],
68
+ ~automationSlices=[],
69
+ ~outboundTranslationSlices=[],
70
+ ~inboundTranslationSlices=[],
71
+ ~extensionPoints=[],
72
+ ): Reventless.Plugin.pluginStructure => {
73
+ readModels,
74
+ stateViewSlices,
75
+ stateChangeSlices,
76
+ aggregates,
77
+ automationSlices,
78
+ outboundTranslationSlices,
79
+ inboundTranslationSlices,
80
+ extensions: [],
81
+ extensionPoints: Some(extensionPoints),
82
+ }
83
+
84
+ let hasEdge = (g: DomainGraph.graph, from, to, kind) =>
85
+ g.edges->Array.some(e => e.from == from && e.to == to && e.kind == kind)
86
+ let nodeKind = (g: DomainGraph.graph, id) =>
87
+ g.nodes->Array.find(n => n.id == id)->Option.map(n => n.kind)
88
+
89
+ describe("DomainGraph.build", () => {
90
+ testPromise("Command → Aggregate → Event → ReadModel (DCB consumedEventTypes)", async () => {
91
+ let shop = structure(
92
+ ~aggregates=[
93
+ writable(
94
+ ~name="Order",
95
+ ~commands=[command(~name="Place", ~mutationField="Shop_Order_Place")],
96
+ ~produces=["Shop.Placed"],
97
+ ),
98
+ ],
99
+ ~stateViewSlices=[queryable(~name="OrderView", ~consumes=["Shop.Placed"])],
100
+ )
101
+ let g = DomainGraph.build(~structures=[("Shop", shop)])
102
+ expect(nodeKind(g, "Shop_Order_Place"))->toEqual(Some("Command"))
103
+ expect(nodeKind(g, "Shop:Order"))->toEqual(Some("Aggregate"))
104
+ expect(nodeKind(g, "Shop.Placed"))->toEqual(Some("Event"))
105
+ expect(nodeKind(g, "Shop:OrderView"))->toEqual(Some("StateViewSlice"))
106
+ expect(hasEdge(g, "Shop_Order_Place", "Shop:Order", "handles"))->toBe(true)
107
+ expect(hasEdge(g, "Shop:Order", "Shop.Placed", "emits"))->toBe(true)
108
+ expect(hasEdge(g, "Shop.Placed", "Shop:OrderView", "projects"))->toBe(true)
109
+ })
110
+
111
+ testPromise("payload-less events (in `events`, not producedEventTypes) still get an emitted node", async () => {
112
+ // Mirrors an aggregate whose `| Archived` variant is payload-less: DCB drops it from
113
+ // producedEventTypes, but the full `events` list carries it so the graph draws the
114
+ // emitted (orphan) event node. The payloaded `Added` appears in both lists and dedups.
115
+ let shop = structure(
116
+ ~aggregates=[
117
+ writable(
118
+ ~name="Category",
119
+ ~produces=["Catalog.Added"],
120
+ ~events=[evt(~name="Added"), evt(~name="Archived")],
121
+ ),
122
+ ],
123
+ )
124
+ let g = DomainGraph.build(~structures=[("Catalog", shop)])
125
+ expect(nodeKind(g, "Catalog.Added"))->toEqual(Some("Event"))
126
+ expect(nodeKind(g, "Catalog.Archived"))->toEqual(Some("Event"))
127
+ expect(hasEdge(g, "Catalog:Category", "Catalog.Added", "emits"))->toBe(true)
128
+ expect(hasEdge(g, "Catalog:Category", "Catalog.Archived", "emits"))->toBe(true)
129
+ })
130
+
131
+ testPromise("an Internal StateViewSlice still gets a node + projects edge (dev graph shows it)", async () => {
132
+ // Internal components are hidden from the deployed AutoUI but carried in pluginStructure
133
+ // so the developer-facing domain graph can render them — see Plugin_Structure.res /
134
+ // Visibility.res. Mirrors the AvailableProducts case (a consume-only Internal slice).
135
+ let shop = structure(
136
+ ~aggregates=[writable(~name="Order", ~produces=["Shop.Placed"])],
137
+ ~stateViewSlices=[
138
+ queryable(~name="AvailableView", ~consumes=["Shop.Placed"], ~visibility=Some("Internal")),
139
+ ],
140
+ )
141
+ let g = DomainGraph.build(~structures=[("Shop", shop)])
142
+ expect(nodeKind(g, "Shop:AvailableView"))->toEqual(Some("StateViewSlice"))
143
+ expect(hasEdge(g, "Shop.Placed", "Shop:AvailableView", "projects"))->toBe(true)
144
+ })
145
+
146
+ testPromise("classic linkedViews draws each produced event into the linked read model", async () => {
147
+ let shop = structure(
148
+ ~aggregates=[writable(~name="Order", ~produces=["Shop.Placed", "Shop.Shipped"], ~linkedViews=["Orders"])],
149
+ ~readModels=[queryable(~name="Orders")],
150
+ )
151
+ let g = DomainGraph.build(~structures=[("Shop", shop)])
152
+ expect(hasEdge(g, "Shop.Placed", "Shop:Orders", "projects"))->toBe(true)
153
+ expect(hasEdge(g, "Shop.Shipped", "Shop:Orders", "projects"))->toBe(true)
154
+ })
155
+
156
+ testPromise("automation slices are triggered by their consumed events", async () => {
157
+ let shop = structure(
158
+ ~aggregates=[writable(~name="Order", ~produces=["Shop.Placed"])],
159
+ ~automationSlices=[automation(~name="Notify", ~consumes=["Shop.Placed"])],
160
+ )
161
+ let g = DomainGraph.build(~structures=[("Shop", shop)])
162
+ expect(nodeKind(g, "Shop:Notify"))->toEqual(Some("AutomationSlice"))
163
+ expect(hasEdge(g, "Shop.Placed", "Shop:Notify", "triggers"))->toBe(true)
164
+ })
165
+
166
+ testPromise("an automation routes to the specific command it raises on its target", async () => {
167
+ // AutoShipOrder reacts to OrderPlaced and raises a ShipOrder command on the Ship
168
+ // write-side. The produce side is drawn slice → command "delegatesTo" (unified with
169
+ // Command → write-side "handles"); only the named command is linked, not Place too.
170
+ let shop = structure(
171
+ ~stateChangeSlices=[
172
+ writable(
173
+ ~name="Ship",
174
+ ~commands=[
175
+ command(~name="ShipOrder", ~mutationField="Shop_Ship_ShipOrder"),
176
+ command(~name="Place", ~mutationField="Shop_Ship_Place"),
177
+ ],
178
+ ~produces=["Shop.Shipped"],
179
+ ),
180
+ ],
181
+ ~automationSlices=[
182
+ automation(~name="AutoShip", ~consumes=["Shop.Placed"], ~produces=["AutoShip.ShipOrder"], ~target="Ship"),
183
+ ],
184
+ )
185
+ let g = DomainGraph.build(~structures=[("Shop", shop)])
186
+ expect(hasEdge(g, "Shop:AutoShip", "Shop_Ship_ShipOrder", "delegatesTo"))->toBe(true)
187
+ // Matched by name — the target's other command is not linked.
188
+ expect(hasEdge(g, "Shop:AutoShip", "Shop_Ship_Place", "delegatesTo"))->toBe(false)
189
+ })
190
+
191
+ testPromise("an inbound translation routes to the command it raises on its target", async () => {
192
+ // ImportProduct (external input → command) was a sink before; it now links to the
193
+ // Add command on its target write-side.
194
+ let shop = structure(
195
+ ~stateChangeSlices=[
196
+ writable(~name="Product", ~commands=[command(~name="Add", ~mutationField="Shop_Product_Add")]),
197
+ ],
198
+ ~inboundTranslationSlices=[
199
+ inbound(~name="ImportProduct", ~produces=["ImportProduct.Add"], ~target="Product"),
200
+ ],
201
+ )
202
+ let g = DomainGraph.build(~structures=[("Shop", shop)])
203
+ expect(nodeKind(g, "Shop:ImportProduct"))->toEqual(Some("InboundTranslationSlice"))
204
+ expect(hasEdge(g, "Shop:ImportProduct", "Shop_Product_Add", "delegatesTo"))->toBe(true)
205
+ })
206
+
207
+ testPromise("an outbound translation with a target routes to the command it raises", async () => {
208
+ let shop = structure(
209
+ ~stateChangeSlices=[
210
+ writable(~name="Product", ~commands=[command(~name="Sync", ~mutationField="Shop_Product_Sync")]),
211
+ ],
212
+ ~outboundTranslationSlices=[
213
+ outbound(~name="ExportProduct", ~consumes=["Shop.Added"], ~produces=["ExportProduct.Sync"], ~target=Some("Product")),
214
+ ],
215
+ )
216
+ let g = DomainGraph.build(~structures=[("Shop", shop)])
217
+ expect(hasEdge(g, "Shop:ExportProduct", "Shop_Product_Sync", "delegatesTo"))->toBe(true)
218
+ })
219
+
220
+ testPromise("an event produced by two write-sides is one node with two emit edges", async () => {
221
+ let shop = structure(
222
+ ~aggregates=[
223
+ writable(~name="A", ~produces=["Shop.Touched"]),
224
+ writable(~name="B", ~produces=["Shop.Touched"]),
225
+ ],
226
+ )
227
+ let g = DomainGraph.build(~structures=[("Shop", shop)])
228
+ expect(g.nodes->Array.filter(n => n.id == "Shop.Touched")->Array.length)->toBe(1)
229
+ expect(hasEdge(g, "Shop:A", "Shop.Touched", "emits"))->toBe(true)
230
+ expect(hasEdge(g, "Shop:B", "Shop.Touched", "emits"))->toBe(true)
231
+ })
232
+
233
+ testPromise("an extension renders the cross-plugin event flow EP→event→Extension→delegate", async () => {
234
+ // Catalog's ProductDemand handles the "Ordering.Orders" extension point's events.
235
+ let extension: Reventless.Plugin.extensionDef = {
236
+ name: "Ordering.Orders",
237
+ delegateNames: ["ProductDemand"],
238
+ eventTypes: ["Ordering.Orders.ItemOrdered"],
239
+ commandTypes: [],
240
+ }
241
+ let catalog = {
242
+ ...structure(~aggregates=[writable(~name="ProductDemand", ~produces=["Catalog.Recorded"])]),
243
+ extensions: [extension],
244
+ }
245
+ let g = DomainGraph.build(~structures=[("Catalog", catalog)])
246
+ // EP (owned by Ordering) publishes its event; the Catalog extension consumes it…
247
+ expect(nodeKind(g, "Ordering:ep:Ordering.Orders"))->toEqual(Some("ExtensionPoint"))
248
+ expect(hasEdge(g, "Ordering:ep:Ordering.Orders", "Ordering.Orders.ItemOrdered", "publishes"))->toBe(true)
249
+ expect(hasEdge(g, "Ordering.Orders.ItemOrdered", "Catalog:ext:Ordering.Orders", "consumes"))->toBe(true)
250
+ // …and delegates to ProductDemand.
251
+ expect(hasEdge(g, "Catalog:ext:Ordering.Orders", "Catalog:ProductDemand", "delegatesTo"))->toBe(true)
252
+ // The protocol event is top-level (empty plugin) so it sits between the boxes…
253
+ let protoEvent =
254
+ g.nodes->Array.find(n => n.id == "Ordering.Orders.ItemOrdered")->Option.getOrThrow
255
+ expect(protoEvent.plugin)->toBe("")
256
+ // …and the extension is named by its EP + consuming plugin.
257
+ let ext = g.nodes->Array.find(n => n.id == "Catalog:ext:Ordering.Orders")->Option.getOrThrow
258
+ expect(ext.label)->toBe("Ordering.Orders.Catalog")
259
+ })
260
+
261
+ testPromise("an extension wires to the command(s) it creates, not straight to the delegate", async () => {
262
+ // The extension issues the delegate's RecordDemand command (its own commandTypes are
263
+ // empty — the commands belong to the delegate), so the arrow lands on the command
264
+ // node, which the ProductDemand slice handles — not straight on the slice.
265
+ let extension: Reventless.Plugin.extensionDef = {
266
+ name: "Ordering.Orders",
267
+ delegateNames: ["ProductDemand"],
268
+ eventTypes: ["Ordering.Orders.ItemOrdered"],
269
+ commandTypes: [],
270
+ }
271
+ let catalog = {
272
+ ...structure(
273
+ ~stateChangeSlices=[
274
+ writable(
275
+ ~name="ProductDemand",
276
+ ~commands=[command(~name="RecordDemand", ~mutationField="Catalog_ProductDemand_RecordDemand")],
277
+ ~produces=["Catalog.Recorded"],
278
+ ),
279
+ ],
280
+ ),
281
+ extensions: [extension],
282
+ }
283
+ let g = DomainGraph.build(~structures=[("Catalog", catalog)])
284
+ // Extension → the command it creates …
285
+ expect(
286
+ hasEdge(g, "Catalog:ext:Ordering.Orders", "Catalog_ProductDemand_RecordDemand", "delegatesTo"),
287
+ )->toBe(true)
288
+ // … the command is handled by the slice (so the full path is Extension → Command → slice) …
289
+ expect(hasEdge(g, "Catalog_ProductDemand_RecordDemand", "Catalog:ProductDemand", "handles"))->toBe(true)
290
+ // … and the extension no longer points straight at the slice.
291
+ expect(hasEdge(g, "Catalog:ext:Ordering.Orders", "Catalog:ProductDemand", "delegatesTo"))->toBe(false)
292
+ })
293
+
294
+ testPromise("an extension with no commands falls back to delegating to the write-side", async () => {
295
+ let extension: Reventless.Plugin.extensionDef = {
296
+ name: "Ordering.Orders",
297
+ delegateNames: ["ProductDemand"],
298
+ eventTypes: ["Ordering.Orders.ItemOrdered"],
299
+ commandTypes: [],
300
+ }
301
+ let catalog = {
302
+ ...structure(~aggregates=[writable(~name="ProductDemand", ~produces=["Catalog.Recorded"])]),
303
+ extensions: [extension],
304
+ }
305
+ let g = DomainGraph.build(~structures=[("Catalog", catalog)])
306
+ expect(hasEdge(g, "Catalog:ext:Ordering.Orders", "Catalog:ProductDemand", "delegatesTo"))->toBe(true)
307
+ })
308
+
309
+ testPromise("an owned extension point is fed by the producers of its source events", async () => {
310
+ // Catalog owns the Catalog.Products EP; its source events are the internal
311
+ // Catalog events produced by AddProduct, so the producing write-side feeds
312
+ // the EP through those events.
313
+ let ep: Reventless.Plugin.extensionPointDef = {
314
+ name: "Catalog.Products",
315
+ delegateNames: ["CatalogDcbEventLog"],
316
+ sourceEventTypes: ["Catalog.ProductAdded"],
317
+ // `command = unit`: notification-only, no inbound command protocol.
318
+ commandTypes: Some([]),
319
+ }
320
+ let catalog = structure(
321
+ ~stateChangeSlices=[
322
+ writable(
323
+ ~name="AddProduct",
324
+ ~commands=[command(~name="AddProduct", ~mutationField="Catalog_AddProduct")],
325
+ ~produces=["Catalog.ProductAdded"],
326
+ ),
327
+ ],
328
+ ~extensionPoints=[ep],
329
+ )
330
+ let g = DomainGraph.build(~structures=[("Catalog", catalog)])
331
+ expect(nodeKind(g, "Catalog:ep:Catalog.Products"))->toEqual(Some("ExtensionPoint"))
332
+ // The producing write-side emits the source event…
333
+ expect(hasEdge(g, "Catalog:AddProduct", "Catalog.ProductAdded", "emits"))->toBe(true)
334
+ // …which feeds the extension point — so EP focus reaches the producer.
335
+ expect(hasEdge(g, "Catalog.ProductAdded", "Catalog:ep:Catalog.Products", "feeds"))->toBe(true)
336
+ // …but the EP has NO inbound command protocol (command = unit), so it routes
337
+ // nothing: no backwards EP → Command edge mislabelling the producer's own command.
338
+ expect(hasEdge(g, "Catalog:ep:Catalog.Products", "Catalog_AddProduct", "routesTo"))->toBe(false)
339
+ })
340
+
341
+ testPromise("an extension point with an inbound command protocol routes to its delegate's commands", async () => {
342
+ // The same shape, but the EP declares a non-unit `command` type — so it DOES
343
+ // accept commands inward and routes them to the write-side that produces its
344
+ // source events: EP → Command → write-side ("handles").
345
+ let ep: Reventless.Plugin.extensionPointDef = {
346
+ name: "Catalog.Products",
347
+ delegateNames: ["AddProduct"],
348
+ sourceEventTypes: ["Catalog.ProductAdded"],
349
+ commandTypes: Some(["Catalog.Products.RequestAddProduct"]),
350
+ }
351
+ let catalog = structure(
352
+ ~stateChangeSlices=[
353
+ writable(
354
+ ~name="AddProduct",
355
+ ~commands=[command(~name="AddProduct", ~mutationField="Catalog_AddProduct")],
356
+ ~produces=["Catalog.ProductAdded"],
357
+ ),
358
+ ],
359
+ ~extensionPoints=[ep],
360
+ )
361
+ let g = DomainGraph.build(~structures=[("Catalog", catalog)])
362
+ expect(hasEdge(g, "Catalog:ep:Catalog.Products", "Catalog_AddProduct", "routesTo"))->toBe(true)
363
+ expect(hasEdge(g, "Catalog_AddProduct", "Catalog:AddProduct", "handles"))->toBe(true)
364
+ })
365
+ })