@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,71 @@
1
+ // Unit tests for LocalHost's pure helpers — plugin-name derivation + discovery.
2
+ // These touch only the filesystem (no reventless-local import), so they run under
3
+ // the normal Jest suite. The cold-load integration (`loadGraph`, which instantiates
4
+ // reventless-local's Platform) can't run under Jest's experimental-vm-modules — see
5
+ // tests/SplitApiFixtures.res — and lives in test/LocalHostHostTest.res
6
+ // (`pnpm run test:host`, run via `node --test`).
7
+ //
8
+ // Fixtures are a throwaway temp tree (same pattern as DiscoveryTest) so the test is
9
+ // hermetic and doesn't depend on the example plugins being compiled.
10
+
11
+ open JestGlobals
12
+
13
+ @module("node:os") external tmpdir: unit => string = "tmpdir"
14
+ @module("node:path") external join: (string, string) => string = "join"
15
+
16
+ type mkdirOpts = {recursive: bool}
17
+ @module("node:fs/promises")
18
+ external mkdir: (string, mkdirOpts) => promise<Nullable.t<string>> = "mkdir"
19
+ @module("node:fs/promises") external writeFile: (string, string) => promise<unit> = "writeFile"
20
+ type rmOpts = {recursive: bool, force: bool}
21
+ @module("node:fs/promises") external rm: (string, rmOpts) => promise<unit> = "rm"
22
+
23
+ describe("LocalHost.packageNameToPluginName", () => {
24
+ testPromise("PascalCases scoped / dashed / underscored package names", async () => {
25
+ expect(LocalHost.packageNameToPluginName("@scope/my-catalog"))->toBe("MyCatalog")
26
+ expect(LocalHost.packageNameToPluginName("online-shop"))->toBe("OnlineShop")
27
+ expect(
28
+ LocalHost.packageNameToPluginName("online-shop-aggregates-catalog"),
29
+ )->toBe("OnlineShopAggregatesCatalog")
30
+ })
31
+ })
32
+
33
+ describe("LocalHost name derivation + discovery", () => {
34
+ testPromise(
35
+ "derivePluginName prefers plugin.json then PascalCase(package.json); discover skips non-plugins",
36
+ async () => {
37
+ let root = join(tmpdir(), "reventless-localhost-test")
38
+ let _ = await rm(root, {recursive: true, force: true})
39
+
40
+ // a: explicit plugin.json name + a compiled composition root.
41
+ let a = join(root, "a")
42
+ let aSrc = join(a, "src")
43
+ let _ = await mkdir(aSrc, {recursive: true})
44
+ let _ = await writeFile(join(a, "package.json"), `{"name":"@x/a-pkg"}`)
45
+ let _ = await writeFile(join(aSrc, "plugin.json"), `{"name":"Catalog"}`)
46
+ let _ = await writeFile(join(aSrc, "Plugin.res.mjs"), "")
47
+
48
+ // b: no plugin.json → name falls back to PascalCase(package.json name).
49
+ let b = join(root, "b")
50
+ let bSrc = join(b, "src")
51
+ let _ = await mkdir(bSrc, {recursive: true})
52
+ let _ = await writeFile(join(b, "package.json"), `{"name":"@scope/my-ordering"}`)
53
+ let _ = await writeFile(join(bSrc, "Plugin.res.mjs"), "")
54
+
55
+ // c: has src/ but no Plugin.res.mjs → discover must skip it.
56
+ let c = join(root, "c")
57
+ let _ = await mkdir(join(c, "src"), {recursive: true})
58
+
59
+ expect(LocalHost.derivePluginName(~pluginSrcDir=aSrc))->toBe("Catalog")
60
+ expect(LocalHost.derivePluginName(~pluginSrcDir=bSrc))->toBe("MyOrdering")
61
+
62
+ let refs = LocalHost.discover(~packageDirs=[a, b, c])
63
+ expect(refs->Array.map((r: LocalHost.pluginRef) => r.name))->toEqual(["Catalog", "MyOrdering"])
64
+ let first: LocalHost.pluginRef = refs->Array.getUnsafe(0)
65
+ expect(first.modulePath->String.endsWith("Plugin.res.mjs"))->toBe(true)
66
+ expect(first.packageDir)->toBe(a)
67
+
68
+ let _ = await rm(root, {recursive: true, force: true})
69
+ },
70
+ )
71
+ })
@@ -0,0 +1,63 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodeos from "node:os";
4
+ import * as Nodepath from "node:path";
5
+ import * as Promises from "node:fs/promises";
6
+ import * as LocalHost$ReventlessGwt from "../src/LocalHost.res.mjs";
7
+
8
+ globalThis.describe("LocalHost.packageNameToPluginName", () => {
9
+ globalThis.test("PascalCases scoped / dashed / underscored package names", async () => {
10
+ globalThis.expect(LocalHost$ReventlessGwt.packageNameToPluginName("@scope/my-catalog")).toBe("MyCatalog");
11
+ globalThis.expect(LocalHost$ReventlessGwt.packageNameToPluginName("online-shop")).toBe("OnlineShop");
12
+ globalThis.expect(LocalHost$ReventlessGwt.packageNameToPluginName("online-shop-aggregates-catalog")).toBe("OnlineShopAggregatesCatalog");
13
+ });
14
+ });
15
+
16
+ globalThis.describe("LocalHost name derivation + discovery", () => {
17
+ globalThis.test("derivePluginName prefers plugin.json then PascalCase(package.json); discover skips non-plugins", async () => {
18
+ let root = Nodepath.join(Nodeos.tmpdir(), "reventless-localhost-test");
19
+ await Promises.rm(root, {
20
+ recursive: true,
21
+ force: true
22
+ });
23
+ let a = Nodepath.join(root, "a");
24
+ let aSrc = Nodepath.join(a, "src");
25
+ await Promises.mkdir(aSrc, {
26
+ recursive: true
27
+ });
28
+ await Promises.writeFile(Nodepath.join(a, "package.json"), `{"name":"@x/a-pkg"}`);
29
+ await Promises.writeFile(Nodepath.join(aSrc, "plugin.json"), `{"name":"Catalog"}`);
30
+ await Promises.writeFile(Nodepath.join(aSrc, "Plugin.res.mjs"), "");
31
+ let b = Nodepath.join(root, "b");
32
+ let bSrc = Nodepath.join(b, "src");
33
+ await Promises.mkdir(bSrc, {
34
+ recursive: true
35
+ });
36
+ await Promises.writeFile(Nodepath.join(b, "package.json"), `{"name":"@scope/my-ordering"}`);
37
+ await Promises.writeFile(Nodepath.join(bSrc, "Plugin.res.mjs"), "");
38
+ let c = Nodepath.join(root, "c");
39
+ await Promises.mkdir(Nodepath.join(c, "src"), {
40
+ recursive: true
41
+ });
42
+ globalThis.expect(LocalHost$ReventlessGwt.derivePluginName(aSrc)).toBe("Catalog");
43
+ globalThis.expect(LocalHost$ReventlessGwt.derivePluginName(bSrc)).toBe("MyOrdering");
44
+ let refs = LocalHost$ReventlessGwt.discover([
45
+ a,
46
+ b,
47
+ c
48
+ ]);
49
+ globalThis.expect(refs.map(r => r.name)).toEqual([
50
+ "Catalog",
51
+ "MyOrdering"
52
+ ]);
53
+ let first = refs[0];
54
+ globalThis.expect(first.modulePath.endsWith("Plugin.res.mjs")).toBe(true);
55
+ globalThis.expect(first.packageDir).toBe(a);
56
+ await Promises.rm(root, {
57
+ recursive: true,
58
+ force: true
59
+ });
60
+ });
61
+ });
62
+
63
+ /* Not a pure module */
@@ -0,0 +1,350 @@
1
+ // Worked examples for Mapping_GWT — one for each producer/consumer combination
2
+ // (Aggr→Aggr, Aggr→DCB, DCB→Aggr, DCB→DCB). See
3
+ // `docs/analysis/event-source-connection-matrix.md` for the connection matrix
4
+ // and `docs/plans/reventless-gwt.md` Stage 5 for the plan context.
5
+ //
6
+ // Each test exercises the full source-command → mapping → target-command →
7
+ // target-decide pipeline end-to-end against the unified `GwtSource`/`GwtTarget`
8
+ // module types.
9
+
10
+ S.enableJson()
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Shared Aggregate specs + behaviours used by the Aggr→* cases below.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ module CategorySpec = {
17
+ module Id = Reventless.Id.StringPure
18
+ let name = "Category"
19
+
20
+ @schema
21
+ type command = AddCategory({categoryId: string, name: string})
22
+
23
+ @schema
24
+ type event = CategoryAdded({categoryId: string, name: string})
25
+
26
+ @schema
27
+ type error = CategoryAlreadyExists
28
+
29
+ let moduleUrl = ""
30
+ }
31
+
32
+ module CategoryBehavior = {
33
+ module Spec = CategorySpec
34
+ type state = NotCreated | Created
35
+ let initialState = NotCreated
36
+ let snapshot = None
37
+
38
+ let evolve = (_state, event: CategorySpec.event) =>
39
+ switch event {
40
+ | CategoryAdded(_) => Created
41
+ }
42
+
43
+ let decide = (state, cmd: CategorySpec.command) =>
44
+ switch (state, cmd) {
45
+ | (NotCreated, AddCategory({categoryId, name})) =>
46
+ Ok([CategorySpec.CategoryAdded({categoryId, name})])
47
+ | (Created, AddCategory(_)) => Error(CategorySpec.CategoryAlreadyExists)
48
+ }
49
+
50
+ let moduleUrl = ""
51
+ }
52
+
53
+ // Target aggregate for Aggr→Aggr and DCB→Aggr cases.
54
+ module ProductSpec = {
55
+ module Id = Reventless.Id.StringPure
56
+ let name = "Product"
57
+
58
+ @schema
59
+ type command = MirrorCategory({productId: string, categoryId: string, name: string})
60
+
61
+ @schema
62
+ type event = ProductCategoryMirrored({productId: string, categoryId: string, name: string})
63
+
64
+ @schema
65
+ type error = ProductAlreadyMirrored
66
+
67
+ let moduleUrl = ""
68
+ }
69
+
70
+ module ProductBehavior = {
71
+ module Spec = ProductSpec
72
+ type state = Pristine | Mirrored
73
+ let initialState = Pristine
74
+ let snapshot = None
75
+
76
+ let evolve = (_state, event: ProductSpec.event) =>
77
+ switch event {
78
+ | ProductCategoryMirrored(_) => Mirrored
79
+ }
80
+
81
+ let decide = (state, cmd: ProductSpec.command) =>
82
+ switch (state, cmd) {
83
+ | (Pristine, MirrorCategory({productId, categoryId, name})) =>
84
+ Ok([ProductSpec.ProductCategoryMirrored({productId, categoryId, name})])
85
+ | (Mirrored, MirrorCategory(_)) => Error(ProductSpec.ProductAlreadyMirrored)
86
+ }
87
+
88
+ let moduleUrl = ""
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Shared StateChangeSlice specs used by the *→DCB and DCB→* cases.
93
+ // ---------------------------------------------------------------------------
94
+
95
+ module NotificationSlice = {
96
+ let name = "Notification"
97
+
98
+ type state = {sent: bool}
99
+ let initialState = {sent: false}
100
+
101
+ @schema
102
+ type consumedEvent =
103
+ | NotificationSent({
104
+ notificationId: @s.matches(Reventless.DcbTag.string) string,
105
+ })
106
+
107
+ let evolve = (_state, event) =>
108
+ switch event {
109
+ | NotificationSent(_) => {sent: true}
110
+ }
111
+
112
+ @schema
113
+ type command =
114
+ | SendNotification({
115
+ notificationId: @s.matches(Reventless.DcbTag.string) string,
116
+ message: string,
117
+ })
118
+
119
+ @schema
120
+ type error = NotificationAlreadySent
121
+
122
+ @schema
123
+ type event =
124
+ | NotificationSent({
125
+ notificationId: @s.matches(Reventless.DcbTag.string) string,
126
+ message: string,
127
+ })
128
+
129
+ let decide = (state, command) =>
130
+ switch command {
131
+ | SendNotification({notificationId, message}) =>
132
+ state.sent
133
+ ? Error(NotificationAlreadySent)
134
+ : Ok([NotificationSent({notificationId, message})])
135
+ }
136
+ }
137
+
138
+ module InventorySlice = {
139
+ let name = "Inventory"
140
+
141
+ type state = {reserved: bool}
142
+ let initialState = {reserved: false}
143
+
144
+ @schema
145
+ type consumedEvent =
146
+ | StockReserved({itemId: @s.matches(Reventless.DcbTag.string) string})
147
+
148
+ let evolve = (_state, event) =>
149
+ switch event {
150
+ | StockReserved(_) => {reserved: true}
151
+ }
152
+
153
+ @schema
154
+ type command =
155
+ | ReserveStock({
156
+ itemId: @s.matches(Reventless.DcbTag.string) string,
157
+ quantity: int,
158
+ })
159
+
160
+ @schema
161
+ type error = OutOfStock
162
+
163
+ @schema
164
+ type event =
165
+ | StockReserved({
166
+ itemId: @s.matches(Reventless.DcbTag.string) string,
167
+ quantity: int,
168
+ })
169
+
170
+ let decide = (state, command) =>
171
+ switch command {
172
+ | ReserveStock({itemId, quantity}) =>
173
+ state.reserved ? Error(OutOfStock) : Ok([StockReserved({itemId, quantity})])
174
+ }
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Adapter aliases — built once, shared across mappings.
179
+ // ---------------------------------------------------------------------------
180
+
181
+ module CategorySource = Mapping_GWT.FromBehavior(CategorySpec, CategoryBehavior)
182
+ module ProductTarget = Mapping_GWT.FromBehavior(ProductSpec, ProductBehavior)
183
+ module NotificationSource = Mapping_GWT.FromStateChangeSlice(NotificationSlice)
184
+ module NotificationTarget = Mapping_GWT.FromStateChangeSlice(NotificationSlice)
185
+ module InventoryTarget = Mapping_GWT.FromStateChangeSlice(InventorySlice)
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Case 1 — Aggr → Aggr: CategoryAdded triggers MirrorCategory on Product.
189
+ // ---------------------------------------------------------------------------
190
+
191
+ module AggrToAggrMapping = {
192
+ module Source = CategorySource
193
+ module Target = ProductTarget
194
+
195
+ let map = (_sourceId, event: CategorySpec.event, _q) =>
196
+ switch event {
197
+ | CategoryAdded({categoryId, name}) => [
198
+ Reventless.EventMapping.Publish(
199
+ ("prod-for-" ++ categoryId)->ProductSpec.Id.makeFromString,
200
+ ProductSpec.MirrorCategory({productId: "prod-for-" ++ categoryId, categoryId, name}),
201
+ ),
202
+ ]
203
+ }
204
+ }
205
+
206
+ module AggrToAggrGwt = Mapping_GWT.Make(AggrToAggrMapping)
207
+
208
+ AggrToAggrGwt.describe("Category → Product (Aggr → Aggr)", () =>
209
+ AggrToAggrGwt.test("AddCategory produces ProductCategoryMirrored on product", () =>
210
+ AggrToAggrGwt.givenSourceEvents([])
211
+ ->AggrToAggrGwt.andTargetEvents([])
212
+ ->AggrToAggrGwt.whenSourceCmd("c1", AddCategory({categoryId: "c1", name: "Books"}))
213
+ ->AggrToAggrGwt.thenTargetEvent(
214
+ "prod-for-c1",
215
+ ProductCategoryMirrored({productId: "prod-for-c1", categoryId: "c1", name: "Books"}),
216
+ )
217
+ )
218
+ )
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Case 2 — Aggr → DCB: CategoryAdded triggers SendNotification on Notification slice.
222
+ // ---------------------------------------------------------------------------
223
+
224
+ module AggrToDcbMapping = {
225
+ module Source = CategorySource
226
+ module Target = NotificationTarget
227
+
228
+ let map = (_sourceId, event: CategorySpec.event, _q) =>
229
+ switch event {
230
+ | CategoryAdded({categoryId, name}) => [
231
+ Reventless.EventMapping.Publish(
232
+ ("notif-" ++ categoryId)->NotificationTarget.Id.makeFromString,
233
+ NotificationSlice.SendNotification({
234
+ notificationId: "notif-" ++ categoryId,
235
+ message: `Category ${name} was created`,
236
+ }),
237
+ ),
238
+ ]
239
+ }
240
+ }
241
+
242
+ module AggrToDcbGwt = Mapping_GWT.Make(AggrToDcbMapping)
243
+
244
+ AggrToDcbGwt.describe("Category → Notification (Aggr → DCB)", () =>
245
+ AggrToDcbGwt.test("AddCategory triggers NotificationSent on the DCB slice", () =>
246
+ AggrToDcbGwt.givenSourceEvents([])
247
+ ->AggrToDcbGwt.andTargetEvents([])
248
+ ->AggrToDcbGwt.whenSourceCmd("c2", AddCategory({categoryId: "c2", name: "Media"}))
249
+ ->AggrToDcbGwt.thenTargetEvent(
250
+ "notif-c2",
251
+ NotificationSlice.NotificationSent({
252
+ notificationId: "notif-c2",
253
+ message: "Category Media was created",
254
+ }),
255
+ )
256
+ )
257
+ )
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Case 3 — DCB → Aggr: Inventory StockReserved triggers MirrorCategory on Product.
261
+ // ---------------------------------------------------------------------------
262
+
263
+ module InventorySource = Mapping_GWT.FromStateChangeSlice(InventorySlice)
264
+
265
+ module DcbToAggrMapping = {
266
+ module Source = InventorySource
267
+ module Target = ProductTarget
268
+
269
+ let map = (_sourceId, event: InventorySlice.event, _q) =>
270
+ switch event {
271
+ | StockReserved({itemId, quantity: _}) => [
272
+ Reventless.EventMapping.Publish(
273
+ ("prod-" ++ itemId)->ProductSpec.Id.makeFromString,
274
+ ProductSpec.MirrorCategory({
275
+ productId: "prod-" ++ itemId,
276
+ categoryId: itemId,
277
+ name: itemId,
278
+ }),
279
+ ),
280
+ ]
281
+ }
282
+ }
283
+
284
+ module DcbToAggrGwt = Mapping_GWT.Make(DcbToAggrMapping)
285
+
286
+ DcbToAggrGwt.describe("Inventory → Product (DCB → Aggr)", () =>
287
+ DcbToAggrGwt.test("ReserveStock triggers ProductCategoryMirrored", () =>
288
+ DcbToAggrGwt.givenSourceEvents([])
289
+ ->DcbToAggrGwt.andTargetEvents([])
290
+ ->DcbToAggrGwt.whenSourceCmd("i1", ReserveStock({itemId: "i1", quantity: 5}))
291
+ ->DcbToAggrGwt.thenTargetEvent(
292
+ "prod-i1",
293
+ ProductCategoryMirrored({productId: "prod-i1", categoryId: "i1", name: "i1"}),
294
+ )
295
+ )
296
+ )
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Case 4 — DCB → DCB: Inventory StockReserved triggers SendNotification on Notification slice.
300
+ // ---------------------------------------------------------------------------
301
+
302
+ module DcbToDcbMapping = {
303
+ module Source = InventorySource
304
+ module Target = NotificationTarget
305
+
306
+ let map = (_sourceId, event: InventorySlice.event, _q) =>
307
+ switch event {
308
+ | StockReserved({itemId, quantity}) => [
309
+ Reventless.EventMapping.Publish(
310
+ ("reserved-" ++ itemId)->NotificationTarget.Id.makeFromString,
311
+ NotificationSlice.SendNotification({
312
+ notificationId: "reserved-" ++ itemId,
313
+ message: `Reserved ${quantity->Int.toString} of ${itemId}`,
314
+ }),
315
+ ),
316
+ ]
317
+ }
318
+ }
319
+
320
+ module DcbToDcbGwt = Mapping_GWT.Make(DcbToDcbMapping)
321
+
322
+ DcbToDcbGwt.describe("Inventory → Notification (DCB → DCB)", () => {
323
+ DcbToDcbGwt.test("ReserveStock triggers NotificationSent", () =>
324
+ DcbToDcbGwt.givenSourceEvents([])
325
+ ->DcbToDcbGwt.andTargetEvents([])
326
+ ->DcbToDcbGwt.whenSourceCmd("i7", ReserveStock({itemId: "i7", quantity: 2}))
327
+ ->DcbToDcbGwt.thenTargetEvent(
328
+ "reserved-i7",
329
+ NotificationSlice.NotificationSent({
330
+ notificationId: "reserved-i7",
331
+ message: "Reserved 2 of i7",
332
+ }),
333
+ )
334
+ )
335
+
336
+ // Target-error coverage: if the notification was already sent, SendNotification
337
+ // fails with `NotificationAlreadySent`. `thenTargetError` surfaces the target's
338
+ // decide error without requiring the source to produce no events.
339
+ DcbToDcbGwt.test("existing NotificationSent causes target decide to reject", () =>
340
+ DcbToDcbGwt.givenSourceEvents([])
341
+ ->DcbToDcbGwt.andTargetEvents([
342
+ (
343
+ "reserved-i7",
344
+ [NotificationSlice.NotificationSent({notificationId: "reserved-i7"})],
345
+ ),
346
+ ])
347
+ ->DcbToDcbGwt.whenSourceCmd("i7", ReserveStock({itemId: "i7", quantity: 2}))
348
+ ->DcbToDcbGwt.thenTargetError(NotificationAlreadySent)
349
+ )
350
+ })