@reventlessdev/reventless-gwt 1.0.0-alpha.207 → 1.0.0-alpha.209
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.
- package/CHANGELOG.md +16 -0
- package/package.json +6 -6
- package/src/Automation_GWT.res +7 -17
- package/src/Behavior_GWT.res +16 -35
- package/src/Delegate_GWT.res +51 -50
- package/src/Diff.res +14 -27
- package/src/EventMapping_GWT.res +1 -3
- package/src/Flow_GWT.res +53 -57
- package/src/Hint.res +1 -4
- package/src/InboundTranslation_GWT.res +4 -10
- package/src/LocalHost.res +13 -9
- package/src/Mapping_GWT.res +6 -23
- package/src/MismatchRender.res +8 -2
- package/src/MultiSourceProjection_GWT.res +6 -15
- package/src/OutboundTranslation_GWT.res +12 -28
- package/src/Outcome.res +15 -15
- package/src/Projection_GWT.res +9 -17
- package/src/Query_GWT.res +28 -19
- package/src/RenderRescript.res +1 -2
- package/src/SideEffect_GWT.res +0 -1
- package/src/StateChange/ExternalAddCategorySlice.res +2 -4
- package/src/StateChange/WithFixtures.res +3 -6
- package/src/StateChange/WithManualOpen.res +3 -6
- package/tests/AutomationGwtTest.res +9 -7
- package/tests/DelegateGwtTest.res +0 -1
- package/tests/FlowAggregateGwtTest.res +2 -5
- package/tests/FlowCrossPluginGwtTest.res +65 -40
- package/tests/FlowGwtTest.res +0 -1
- package/tests/InboundTranslationGwtTest.res +7 -4
- package/tests/LocalHostIntegration.res +29 -12
- package/tests/LocalHostTest.res +15 -8
- package/tests/MappingGwtTest.res +5 -20
- package/tests/OutboundTranslationGwtTest.res +7 -4
- package/tests/QueryGwtTest.res +10 -12
- package/tests/QueryResolverGwtTest.res +4 -4
- package/tests/SideEffect/FakeOrderNotification.res +1 -2
- package/tests/SideEffect/FakeOrderNotificationGwtTest.res +6 -10
- package/tests/StateChange/WithFixtures_GWT.res +0 -1
- package/tests/StateChangeSliceGwtTest.res +22 -21
- package/tests/StateViewSliceGwtTest.res +5 -17
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
// command on `o2` does not see `o1`'s prior events.
|
|
8
8
|
// - The `Error` branch flows through `thenError` like the slice form.
|
|
9
9
|
|
|
10
|
-
|
|
11
10
|
open Flow_GWT
|
|
12
11
|
|
|
13
12
|
// -- Aggregate: CatalogProduct (one event family, no error) -----------------
|
|
@@ -16,12 +15,10 @@ module CatalogProductAggregate = {
|
|
|
16
15
|
let name = "CatalogProduct"
|
|
17
16
|
|
|
18
17
|
@schema
|
|
19
|
-
type command =
|
|
20
|
-
| SyncNewProduct({productId: string, name: string})
|
|
18
|
+
type command = SyncNewProduct({productId: string, name: string})
|
|
21
19
|
|
|
22
20
|
@schema
|
|
23
|
-
type event =
|
|
24
|
-
| CatalogProductSynced({productId: string, name: string})
|
|
21
|
+
type event = CatalogProductSynced({productId: string, name: string})
|
|
25
22
|
|
|
26
23
|
@schema
|
|
27
24
|
type error = AlreadySynced
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
// order fans out into one demand command per product across the boundary.
|
|
16
16
|
// See `docs/plans/done/gwt-flow-and-extension-test-kinds.md` Phase 3.
|
|
17
17
|
|
|
18
|
-
|
|
19
18
|
open Flow_GWT
|
|
20
19
|
|
|
21
20
|
module EPM = ReventlessInfra.ExtensionPointMapping
|
|
@@ -40,7 +39,11 @@ module AddProductSlice = {
|
|
|
40
39
|
|
|
41
40
|
@schema
|
|
42
41
|
type event =
|
|
43
|
-
ProductAdded({
|
|
42
|
+
| ProductAdded({
|
|
43
|
+
productId: @s.matches(Reventless.DcbTag.string) string,
|
|
44
|
+
name: string,
|
|
45
|
+
price: float,
|
|
46
|
+
})
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
module AddProductBehavior = {
|
|
@@ -91,7 +94,10 @@ module ProductsEpMapping = {
|
|
|
91
94
|
(_id, event: ProductDelegate.event, _meta, _q) =>
|
|
92
95
|
switch event {
|
|
93
96
|
| ProductAdded({productId, name, price}) => [
|
|
94
|
-
EPM.PublishEvent(
|
|
97
|
+
EPM.PublishEvent(
|
|
98
|
+
productId,
|
|
99
|
+
ProductsEpSpec.ProductBecameAvailable({productId, name, price}),
|
|
100
|
+
),
|
|
95
101
|
]
|
|
96
102
|
},
|
|
97
103
|
)
|
|
@@ -127,9 +133,10 @@ module OrdersEpMapping = {
|
|
|
127
133
|
(_id, event: OrderDelegate.event, _meta, _q) =>
|
|
128
134
|
switch event {
|
|
129
135
|
| OrderPlaced({orderId, productIds}) =>
|
|
130
|
-
productIds->Array.map(pid =>
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
productIds->Array.map(pid => EPM.PublishEvent(
|
|
137
|
+
pid,
|
|
138
|
+
OrdersEpSpec.ItemOrdered({productId: pid, orderId}),
|
|
139
|
+
))
|
|
133
140
|
},
|
|
134
141
|
)
|
|
135
142
|
}
|
|
@@ -164,7 +171,8 @@ module RecordDemandBehavior = {
|
|
|
164
171
|
}
|
|
165
172
|
let decide = (_state, command: RecordDemandSlice.command) =>
|
|
166
173
|
switch command {
|
|
167
|
-
| RecordDemand({productId, orderId}) =>
|
|
174
|
+
| RecordDemand({productId, orderId}) =>
|
|
175
|
+
Ok([RecordDemandSlice.DemandRecorded({productId, orderId})])
|
|
168
176
|
}
|
|
169
177
|
}
|
|
170
178
|
|
|
@@ -194,11 +202,19 @@ module SyncProductSlice = {
|
|
|
194
202
|
type consumedEvent = ProductSynced({productId: @s.matches(Reventless.DcbTag.string) string})
|
|
195
203
|
@schema
|
|
196
204
|
type command =
|
|
197
|
-
SyncProduct({
|
|
205
|
+
| SyncProduct({
|
|
206
|
+
productId: @s.matches(Reventless.DcbTag.string) string,
|
|
207
|
+
name: string,
|
|
208
|
+
price: float,
|
|
209
|
+
})
|
|
198
210
|
@schema type error = NoError
|
|
199
211
|
@schema
|
|
200
212
|
type event =
|
|
201
|
-
ProductSynced({
|
|
213
|
+
| ProductSynced({
|
|
214
|
+
productId: @s.matches(Reventless.DcbTag.string) string,
|
|
215
|
+
name: string,
|
|
216
|
+
price: float,
|
|
217
|
+
})
|
|
202
218
|
let moduleUrl = ""
|
|
203
219
|
let commandAuthorization = (_: command): Reventless.Authorization.permission => AllowAuthenticated
|
|
204
220
|
type lifecycleState = unit
|
|
@@ -242,20 +258,20 @@ module PlaceOrderSlice = {
|
|
|
242
258
|
| ProductSynced({productId: @s.matches(Reventless.DcbTag.string) string})
|
|
243
259
|
@schema
|
|
244
260
|
type command =
|
|
245
|
-
PlaceOrder({
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
261
|
+
| PlaceOrder({
|
|
262
|
+
orderId: @s.matches(Reventless.DcbTag.string) string,
|
|
263
|
+
productIds: array<@s.matches(Reventless.DcbTag.stringForKey(~key="productId")) string>,
|
|
264
|
+
})
|
|
249
265
|
@schema
|
|
250
266
|
type error =
|
|
251
267
|
| OrderAlreadyPlaced
|
|
252
268
|
| ProductsNotAvailable({missing: array<string>})
|
|
253
269
|
@schema
|
|
254
270
|
type event =
|
|
255
|
-
OrderPlaced({
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
271
|
+
| OrderPlaced({
|
|
272
|
+
orderId: @s.matches(Reventless.DcbTag.string) string,
|
|
273
|
+
productIds: array<@s.matches(Reventless.DcbTag.stringForKey(~key="productId")) string>,
|
|
274
|
+
})
|
|
259
275
|
}
|
|
260
276
|
|
|
261
277
|
module PlaceOrderBehavior = {
|
|
@@ -265,7 +281,10 @@ module PlaceOrderBehavior = {
|
|
|
265
281
|
let evolve = (state, event: PlaceOrderSlice.consumedEvent) =>
|
|
266
282
|
switch event {
|
|
267
283
|
| OrderPlaced({orderId}) => {...state, placed: state.placed->Array.concat([orderId])}
|
|
268
|
-
| ProductSynced({productId}) => {
|
|
284
|
+
| ProductSynced({productId}) => {
|
|
285
|
+
...state,
|
|
286
|
+
available: state.available->Array.concat([productId]),
|
|
287
|
+
}
|
|
269
288
|
}
|
|
270
289
|
let decide = (state, command: PlaceOrderSlice.command) =>
|
|
271
290
|
switch command {
|
|
@@ -301,8 +320,12 @@ module Demand = CommandStep(RecordDemandSlice, RecordDemandBehavior)
|
|
|
301
320
|
describe("Cross-plugin flow", () => {
|
|
302
321
|
test("Tier 2 — a product added in Catalog becomes orderable in Ordering via sync", () =>
|
|
303
322
|
start
|
|
304
|
-
->AddProduct.whenCommand(
|
|
305
|
-
|
|
323
|
+
->AddProduct.whenCommand(
|
|
324
|
+
AddProductSlice.AddProduct({productId: "p1", name: "Book", price: 9.99}),
|
|
325
|
+
)
|
|
326
|
+
->AddProduct.thenEvent(
|
|
327
|
+
AddProductSlice.ProductAdded({productId: "p1", name: "Book", price: 9.99}),
|
|
328
|
+
)
|
|
306
329
|
->ProductsEp.whenPublishedThrough
|
|
307
330
|
->ProductsEp.thenPublicEvent(
|
|
308
331
|
ProductsEpSpec.ProductBecameAvailable({productId: "p1", name: "Book", price: 9.99}),
|
|
@@ -323,25 +346,27 @@ describe("Cross-plugin flow", () => {
|
|
|
323
346
|
->Place.thenError(ProductsNotAvailable({missing: ["p1"]}))
|
|
324
347
|
)
|
|
325
348
|
|
|
326
|
-
test(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
349
|
+
test(
|
|
350
|
+
"Tier 3 — a batch order fans out to one demand command per product across the boundary",
|
|
351
|
+
() =>
|
|
352
|
+
start
|
|
353
|
+
->Sync.givenEvents([
|
|
354
|
+
SyncProductSlice.ProductSynced({productId: "p1", name: "Book", price: 9.99}),
|
|
355
|
+
SyncProductSlice.ProductSynced({productId: "p2", name: "Pen", price: 1.5}),
|
|
356
|
+
])
|
|
357
|
+
->Place.whenCommand(PlaceOrderSlice.PlaceOrder({orderId: "o1", productIds: ["p1", "p2"]}))
|
|
358
|
+
->Place.thenEvent(PlaceOrderSlice.OrderPlaced({orderId: "o1", productIds: ["p1", "p2"]}))
|
|
359
|
+
->OrdersEp.whenPublishedThrough
|
|
360
|
+
->OrdersEp.thenPublicEvents([
|
|
361
|
+
OrdersEpSpec.ItemOrdered({productId: "p1", orderId: "o1"}),
|
|
362
|
+
OrdersEpSpec.ItemOrdered({productId: "p2", orderId: "o1"}),
|
|
363
|
+
])
|
|
364
|
+
->OrdersExt.whenExtensionReacts
|
|
365
|
+
->OrdersExt.thenIssuesCommands([
|
|
366
|
+
RecordDemandSlice.RecordDemand({productId: "p1", orderId: "o1"}),
|
|
367
|
+
RecordDemandSlice.RecordDemand({productId: "p2", orderId: "o1"}),
|
|
368
|
+
])
|
|
369
|
+
->Demand.whenCommand(RecordDemandSlice.RecordDemand({productId: "p1", orderId: "o1"}))
|
|
370
|
+
->Demand.thenEvent(RecordDemandSlice.DemandRecorded({productId: "p1", orderId: "o1"})),
|
|
346
371
|
)
|
|
347
372
|
})
|
package/tests/FlowGwtTest.res
CHANGED
|
@@ -23,12 +23,15 @@ module PaymentWebhookSlice = {
|
|
|
23
23
|
|
|
24
24
|
describe("PaymentWebhook InboundTranslationSlice", () => {
|
|
25
25
|
test("completed status emits ConfirmPayment", () =>
|
|
26
|
-
whenInput({paymentId: "p1", orderId: "o1", status: "completed"})
|
|
27
|
-
|
|
26
|
+
whenInput({paymentId: "p1", orderId: "o1", status: "completed"})->thenCommand(
|
|
27
|
+
"o1",
|
|
28
|
+
ConfirmPayment({orderId: "o1", paymentId: "p1"}),
|
|
29
|
+
)
|
|
28
30
|
)
|
|
29
31
|
|
|
30
32
|
test("unknown status surfaces translate error", () =>
|
|
31
|
-
whenInput({paymentId: "p1", orderId: "o1", status: "garbage"})
|
|
32
|
-
|
|
33
|
+
whenInput({paymentId: "p1", orderId: "o1", status: "garbage"})->thenTranslateError(
|
|
34
|
+
"Unknown payment status: garbage",
|
|
35
|
+
)
|
|
33
36
|
)
|
|
34
37
|
})
|
|
@@ -34,7 +34,10 @@ test("loadGraph cold-loads structures from the real plugins", async () => {
|
|
|
34
34
|
deepEqual(g.structures->Array.map(((n, _)) => n), ["Catalog", "Ordering"])
|
|
35
35
|
|
|
36
36
|
let catalog = structureFor(g, "Catalog")->Option.getOrThrow
|
|
37
|
-
let category =
|
|
37
|
+
let category =
|
|
38
|
+
catalog.aggregates
|
|
39
|
+
->Array.find((a: Reventless.Plugin.writableDef) => a.name == "Category")
|
|
40
|
+
->Option.getOrThrow
|
|
38
41
|
ok(category.producedEventTypes->Array.includes("Catalog.Added"))
|
|
39
42
|
|
|
40
43
|
// An aggregate's declared errors ride the structure beside its events: what the
|
|
@@ -45,7 +48,10 @@ test("loadGraph cold-loads structures from the real plugins", async () => {
|
|
|
45
48
|
)
|
|
46
49
|
|
|
47
50
|
let ordering = structureFor(g, "Ordering")->Option.getOrThrow
|
|
48
|
-
let order =
|
|
51
|
+
let order =
|
|
52
|
+
ordering.aggregates
|
|
53
|
+
->Array.find((a: Reventless.Plugin.writableDef) => a.name == "Order")
|
|
54
|
+
->Option.getOrThrow
|
|
49
55
|
ok(order.producedEventTypes->Array.includes("Ordering.Placed"))
|
|
50
56
|
|
|
51
57
|
// extensionPoints (producer side): Catalog owns Catalog.Products, fed by the
|
|
@@ -53,20 +59,31 @@ test("loadGraph cold-loads structures from the real plugins", async () => {
|
|
|
53
59
|
// subset of the producer's producedEventTypes, the link the event graph draws.
|
|
54
60
|
let catalogEps = catalog.extensionPoints->Option.getOr([])
|
|
55
61
|
let productsEp =
|
|
56
|
-
catalogEps
|
|
57
|
-
|
|
62
|
+
catalogEps
|
|
63
|
+
->Array.find((e: Reventless.Plugin.extensionPointDef) => e.name == "Catalog.Products")
|
|
64
|
+
->Option.getOrThrow
|
|
65
|
+
ok(
|
|
66
|
+
productsEp.delegateNames->Array.includes("Product"),
|
|
67
|
+
~message=productsEp.delegateNames->Array.join(","),
|
|
68
|
+
)
|
|
58
69
|
ok(
|
|
59
70
|
productsEp.sourceEventTypes->Array.includes("Catalog.Added"),
|
|
60
71
|
~message=productsEp.sourceEventTypes->Array.join(","),
|
|
61
72
|
)
|
|
62
|
-
let product =
|
|
73
|
+
let product =
|
|
74
|
+
catalog.aggregates
|
|
75
|
+
->Array.find((a: Reventless.Plugin.writableDef) => a.name == "Product")
|
|
76
|
+
->Option.getOrThrow
|
|
63
77
|
ok(productsEp.sourceEventTypes->Array.every(e => product.producedEventTypes->Array.includes(e)))
|
|
64
78
|
})
|
|
65
79
|
|
|
66
|
-
test(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
test(
|
|
81
|
+
"loadGraph is cold — a second call in the same process succeeds (cache-busting works)",
|
|
82
|
+
async () => {
|
|
83
|
+
let plugins = LocalHost.discover(~packageDirs=[catalogDir])
|
|
84
|
+
let g1 = await LocalHost.loadGraph(~platformModulePath=platformPath, ~plugins)
|
|
85
|
+
let g2 = await LocalHost.loadGraph(~platformModulePath=platformPath, ~plugins)
|
|
86
|
+
equal(g1.structures->Array.length, 1)
|
|
87
|
+
equal(g2.structures->Array.length, 1)
|
|
88
|
+
},
|
|
89
|
+
)
|
package/tests/LocalHostTest.res
CHANGED
|
@@ -14,9 +14,9 @@ describe("LocalHost.packageNameToPluginName", () => {
|
|
|
14
14
|
testPromise("PascalCases scoped / dashed / underscored package names", async () => {
|
|
15
15
|
expect(LocalHost.packageNameToPluginName("@scope/my-catalog"))->toBe("MyCatalog")
|
|
16
16
|
expect(LocalHost.packageNameToPluginName("online-shop"))->toBe("OnlineShop")
|
|
17
|
-
expect(
|
|
18
|
-
|
|
19
|
-
)
|
|
17
|
+
expect(LocalHost.packageNameToPluginName("online-shop-aggregates-catalog"))->toBe(
|
|
18
|
+
"OnlineShopAggregatesCatalog",
|
|
19
|
+
)
|
|
20
20
|
})
|
|
21
21
|
})
|
|
22
22
|
|
|
@@ -31,10 +31,14 @@ describe("LocalHost name derivation + discovery", () => {
|
|
|
31
31
|
let a = NodePath.join([root, "a"])
|
|
32
32
|
let aSrc = NodePath.join([a, "src"])
|
|
33
33
|
let _ = await NodeFs.Promises.mkdir(aSrc, {recursive: true})
|
|
34
|
-
let _ =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
let _ = await NodeFs.Promises.writeFile(
|
|
35
|
+
NodePath.join([a, "package.json"]),
|
|
36
|
+
`{"name":"@x/a-pkg"}`,
|
|
37
|
+
)
|
|
38
|
+
let _ = await NodeFs.Promises.writeFile(
|
|
39
|
+
NodePath.join([aSrc, "plugin.json"]),
|
|
40
|
+
`{"name":"Catalog"}`,
|
|
41
|
+
)
|
|
38
42
|
let _ = await NodeFs.Promises.writeFile(NodePath.join([aSrc, "Plugin.res.mjs"]), "")
|
|
39
43
|
|
|
40
44
|
// b: no plugin.json → name falls back to PascalCase(package.json name).
|
|
@@ -55,7 +59,10 @@ describe("LocalHost name derivation + discovery", () => {
|
|
|
55
59
|
expect(LocalHost.derivePluginName(~pluginSrcDir=bSrc))->toBe("MyOrdering")
|
|
56
60
|
|
|
57
61
|
let refs = LocalHost.discover(~packageDirs=[a, b, c])
|
|
58
|
-
expect(refs->Array.map((r: LocalHost.pluginRef) => r.name))->toEqual([
|
|
62
|
+
expect(refs->Array.map((r: LocalHost.pluginRef) => r.name))->toEqual([
|
|
63
|
+
"Catalog",
|
|
64
|
+
"MyOrdering",
|
|
65
|
+
])
|
|
59
66
|
let first: LocalHost.pluginRef = refs->Array.getUnsafe(0)
|
|
60
67
|
expect(first.modulePath->String.endsWith("Plugin.res.mjs"))->toBe(true)
|
|
61
68
|
expect(first.packageDir)->toBe(a)
|
package/tests/MappingGwtTest.res
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
// target-decide pipeline end-to-end against the unified `GwtSource`/`GwtTarget`
|
|
7
7
|
// module types.
|
|
8
8
|
|
|
9
|
-
|
|
10
9
|
// ---------------------------------------------------------------------------
|
|
11
10
|
// Shared Aggregate specs + behaviours used by the Aggr→* cases below.
|
|
12
11
|
// ---------------------------------------------------------------------------
|
|
@@ -98,9 +97,7 @@ module NotificationSlice = {
|
|
|
98
97
|
|
|
99
98
|
@schema
|
|
100
99
|
type consumedEvent =
|
|
101
|
-
|
|
102
|
-
notificationId: @s.matches(Reventless.DcbTag.string) string,
|
|
103
|
-
})
|
|
100
|
+
NotificationSent({notificationId: @s.matches(Reventless.DcbTag.string) string})
|
|
104
101
|
|
|
105
102
|
let evolve = (_state, event) =>
|
|
106
103
|
switch event {
|
|
@@ -140,8 +137,7 @@ module InventorySlice = {
|
|
|
140
137
|
let initialState = {reserved: false}
|
|
141
138
|
|
|
142
139
|
@schema
|
|
143
|
-
type consumedEvent =
|
|
144
|
-
| StockReserved({itemId: @s.matches(Reventless.DcbTag.string) string})
|
|
140
|
+
type consumedEvent = StockReserved({itemId: @s.matches(Reventless.DcbTag.string) string})
|
|
145
141
|
|
|
146
142
|
let evolve = (_state, event) =>
|
|
147
143
|
switch event {
|
|
@@ -149,21 +145,13 @@ module InventorySlice = {
|
|
|
149
145
|
}
|
|
150
146
|
|
|
151
147
|
@schema
|
|
152
|
-
type command =
|
|
153
|
-
| ReserveStock({
|
|
154
|
-
itemId: @s.matches(Reventless.DcbTag.string) string,
|
|
155
|
-
quantity: int,
|
|
156
|
-
})
|
|
148
|
+
type command = ReserveStock({itemId: @s.matches(Reventless.DcbTag.string) string, quantity: int})
|
|
157
149
|
|
|
158
150
|
@schema
|
|
159
151
|
type error = OutOfStock
|
|
160
152
|
|
|
161
153
|
@schema
|
|
162
|
-
type event =
|
|
163
|
-
| StockReserved({
|
|
164
|
-
itemId: @s.matches(Reventless.DcbTag.string) string,
|
|
165
|
-
quantity: int,
|
|
166
|
-
})
|
|
154
|
+
type event = StockReserved({itemId: @s.matches(Reventless.DcbTag.string) string, quantity: int})
|
|
167
155
|
|
|
168
156
|
let decide = (state, command) =>
|
|
169
157
|
switch command {
|
|
@@ -337,10 +325,7 @@ DcbToDcbGwt.describe("Inventory → Notification (DCB → DCB)", () => {
|
|
|
337
325
|
DcbToDcbGwt.test("existing NotificationSent causes target decide to reject", () =>
|
|
338
326
|
DcbToDcbGwt.givenSourceEvents([])
|
|
339
327
|
->DcbToDcbGwt.andTargetEvents([
|
|
340
|
-
(
|
|
341
|
-
"reserved-i7",
|
|
342
|
-
[NotificationSlice.NotificationSent({notificationId: "reserved-i7"})],
|
|
343
|
-
),
|
|
328
|
+
("reserved-i7", [NotificationSlice.NotificationSent({notificationId: "reserved-i7"})]),
|
|
344
329
|
])
|
|
345
330
|
->DcbToDcbGwt.whenSourceCmd("i7", ReserveStock({itemId: "i7", quantity: 2}))
|
|
346
331
|
->DcbToDcbGwt.thenTargetError(NotificationAlreadySent)
|
|
@@ -44,10 +44,13 @@ describe("SendTrackingEmail OutboundTranslationSlice", () => {
|
|
|
44
44
|
test("retrying translate succeeds on the third attempt → 2 retries recorded", () => {
|
|
45
45
|
let calls = ref(0)
|
|
46
46
|
givenTodo("o1", {orderId: "o1", email: "x@y"})
|
|
47
|
-
->whenTranslateRetrying(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
->whenTranslateRetrying(
|
|
48
|
+
~maxRetries=3,
|
|
49
|
+
(_id, _item) => {
|
|
50
|
+
calls := calls.contents + 1
|
|
51
|
+
Promise.resolve(calls.contents < 3 ? Error("smtp down") : Ok(None))
|
|
52
|
+
},
|
|
53
|
+
)
|
|
51
54
|
->thenRetryRecorded(2)
|
|
52
55
|
})
|
|
53
56
|
|
package/tests/QueryGwtTest.res
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// to prove the unified DSL works for either consumer type. Stage 6 of
|
|
3
3
|
// `docs/plans/done/reventless-gwt.md`.
|
|
4
4
|
|
|
5
|
-
|
|
6
5
|
// ---------------------------------------------------------------------------
|
|
7
6
|
// Case 1 — ReadModel: Categories.
|
|
8
7
|
// ---------------------------------------------------------------------------
|
|
@@ -51,9 +50,7 @@ CategoriesQuery.describe("Categories ReadModel queries", () => {
|
|
|
51
50
|
|
|
52
51
|
CategoriesQuery.test("missing index produces QueryRowsMismatch", () => {
|
|
53
52
|
let outcome =
|
|
54
|
-
CategoriesQuery.givenStore([
|
|
55
|
-
("c1", {categoryId: "c1", name: "Electronics", archived: false}),
|
|
56
|
-
])
|
|
53
|
+
CategoriesQuery.givenStore([("c1", {categoryId: "c1", name: "Electronics", archived: false})])
|
|
57
54
|
->CategoriesQuery.whenQuery({by: "name", value: "Electronics", index: "byArchived"})
|
|
58
55
|
->CategoriesQuery.thenRows([])
|
|
59
56
|
switch outcome {
|
|
@@ -82,11 +79,14 @@ CategoriesQuery.describe("Categories ReadModel queries", () => {
|
|
|
82
79
|
|
|
83
80
|
CategoriesQuery.test("limit truncates results and thenRowCount verifies", () =>
|
|
84
81
|
CategoriesQuery.givenStore(
|
|
85
|
-
Array.fromInitializer(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
Array.fromInitializer(
|
|
83
|
+
~length=10,
|
|
84
|
+
i => {
|
|
85
|
+
let id = "c" ++ i->Int.toString
|
|
86
|
+
let state: CategoriesReadModel.state = {categoryId: id, name: "Books", archived: false}
|
|
87
|
+
(id, state)
|
|
88
|
+
},
|
|
89
|
+
),
|
|
90
90
|
)
|
|
91
91
|
->CategoriesQuery.whenQuery({by: "name", value: "Books", index: "byName", limit: 3})
|
|
92
92
|
->CategoriesQuery.thenRowCount(3)
|
|
@@ -145,9 +145,7 @@ OrdersQuery.describe("OrdersView StateViewSlice queries", () => {
|
|
|
145
145
|
("ord-2", "cust-b", {orderId: "ord-2", customerId: "cust-b", total: 200}),
|
|
146
146
|
])
|
|
147
147
|
->OrdersQuery.whenQueryByCompositeId({id: "ord-1", subId: "cust-a"})
|
|
148
|
-
->OrdersQuery.thenRowFromComposite(
|
|
149
|
-
Some({orderId: "ord-1", customerId: "cust-a", total: 100}),
|
|
150
|
-
)
|
|
148
|
+
->OrdersQuery.thenRowFromComposite(Some({orderId: "ord-1", customerId: "cust-a", total: 100}))
|
|
151
149
|
)
|
|
152
150
|
|
|
153
151
|
OrdersQuery.test("composite-id lookup with wrong subId returns None", () =>
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// productId (and a productIds array) that resolve into a Products read model;
|
|
4
4
|
// the resolver DSL follows those keys across the spec boundary.
|
|
5
5
|
|
|
6
|
-
|
|
7
6
|
module RM = Reventless.ReadModel
|
|
8
7
|
|
|
9
8
|
module ProductRow = {
|
|
@@ -61,9 +60,10 @@ R.describe("Cross-spec resolvers (MakeResolver)", () => {
|
|
|
61
60
|
)
|
|
62
61
|
|
|
63
62
|
R.test("resolving an unknown foreign key returns no row", () =>
|
|
64
|
-
R.givenStores(
|
|
65
|
-
("
|
|
66
|
-
|
|
63
|
+
R.givenStores(
|
|
64
|
+
[("order2", {OrderRow.orderId: "order2", productId: "px", productIds: []})],
|
|
65
|
+
[("p1", book)],
|
|
66
|
+
)
|
|
67
67
|
->R.whenResolve(~field="productId", "order2")
|
|
68
68
|
->R.thenResolved(None)
|
|
69
69
|
)
|
|
@@ -17,7 +17,6 @@ let moduleUrl = "test://FakeOrderNotification"
|
|
|
17
17
|
|
|
18
18
|
let execute = async (id, _meta, event, _queryEngine) =>
|
|
19
19
|
switch event {
|
|
20
|
-
| Source.Placed({email}) =>
|
|
21
|
-
MockEmail.recordSend(~email, ~orderId=Source.Id.toString(id))
|
|
20
|
+
| Source.Placed({email}) => MockEmail.recordSend(~email, ~orderId=Source.Id.toString(id))
|
|
22
21
|
| Source.Shipped => ()
|
|
23
22
|
}
|
|
@@ -8,14 +8,9 @@ describe("FakeOrderNotification SideEffect", () => {
|
|
|
8
8
|
JestGlobals.beforeEach(MockEmail.reset)
|
|
9
9
|
|
|
10
10
|
test("Placed records a confirmation send", () =>
|
|
11
|
-
givenEventForId(
|
|
12
|
-
Source.Id.makeFromString("o1"),
|
|
13
|
-
Source.Placed({email: "alice@example.com"}),
|
|
14
|
-
)
|
|
11
|
+
givenEventForId(Source.Id.makeFromString("o1"), Source.Placed({email: "alice@example.com"}))
|
|
15
12
|
->whenExecuted(MockEmail.mock)
|
|
16
|
-
->thenExternalCalls([
|
|
17
|
-
MockEmail.SendConfirmation({email: "alice@example.com", orderId: "o1"}),
|
|
18
|
-
])
|
|
13
|
+
->thenExternalCalls([MockEmail.SendConfirmation({email: "alice@example.com", orderId: "o1"})])
|
|
19
14
|
)
|
|
20
15
|
|
|
21
16
|
test("Shipped is a no-op", () =>
|
|
@@ -25,9 +20,10 @@ describe("FakeOrderNotification SideEffect", () => {
|
|
|
25
20
|
)
|
|
26
21
|
|
|
27
22
|
test("Two distinct Placed events record two calls", async () => {
|
|
28
|
-
let _ =
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
let _ = await givenEventForId(
|
|
24
|
+
Source.Id.makeFromString("o1"),
|
|
25
|
+
Source.Placed({email: "a@a"}),
|
|
26
|
+
)->whenExecuted(MockEmail.mock)
|
|
31
27
|
await givenEventForId(Source.Id.makeFromString("o2"), Source.Placed({email: "b@b"}))
|
|
32
28
|
->whenExecuted(MockEmail.mock)
|
|
33
29
|
->thenExternalCallCount(2)
|
|
@@ -118,10 +118,7 @@ module MissingTagBehavior = {
|
|
|
118
118
|
|
|
119
119
|
let evolve = (state: state, _event: Spec.consumedEvent): state => state
|
|
120
120
|
|
|
121
|
-
let decide = (
|
|
122
|
-
_state: state,
|
|
123
|
-
cmd: Spec.command,
|
|
124
|
-
): result<array<Spec.event>, Spec.error> =>
|
|
121
|
+
let decide = (_state: state, cmd: Spec.command): result<array<Spec.event>, Spec.error> =>
|
|
125
122
|
switch cmd {
|
|
126
123
|
| Do({id}) => Ok([Spec.Done({id: id})])
|
|
127
124
|
}
|
|
@@ -130,23 +127,27 @@ module MissingTagBehavior = {
|
|
|
130
127
|
module MissingTagGwt = Behavior_GWT.Make(MissingTagSlice, MissingTagBehavior)
|
|
131
128
|
|
|
132
129
|
MissingTagGwt.describe("MissingTag slice implicit check", () => {
|
|
133
|
-
MissingTagGwt.test(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
Outcome.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
130
|
+
MissingTagGwt.test(
|
|
131
|
+
"thenEvent surfaces AppendConditionMismatch when command lacks DCB tag",
|
|
132
|
+
() => {
|
|
133
|
+
let outcome =
|
|
134
|
+
MissingTagGwt.givenEvents([])
|
|
135
|
+
->MissingTagGwt.whenCmd(Do({id: "x1"}))
|
|
136
|
+
->MissingTagGwt.thenEvent(Done({id: "x1"}))
|
|
137
|
+
switch outcome {
|
|
138
|
+
| Error(AppendConditionMismatch(_)) => Outcome.pass
|
|
139
|
+
| Error(other) =>
|
|
140
|
+
Outcome.fail(
|
|
141
|
+
Throw({
|
|
142
|
+
error: "expected AppendConditionMismatch, got: " ++ Outcome.kindName(other),
|
|
143
|
+
stack: "",
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
| Ok() =>
|
|
147
|
+
Outcome.fail(Throw({error: "expected AppendConditionMismatch, got pass", stack: ""}))
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
)
|
|
150
151
|
|
|
151
152
|
MissingTagGwt.test(
|
|
152
153
|
"thenAppendsConditionedOnExactly bypasses implicit check (still passes with derived)",
|
|
@@ -31,15 +31,9 @@ module CategoriesViewProjection = {
|
|
|
31
31
|
|
|
32
32
|
let project = ({event}: Reventless.StateViewSlice.consumed<consumedEvent>) =>
|
|
33
33
|
switch event {
|
|
34
|
-
| CategoryAdded({categoryId, name}) => [
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
| CategoryRenamed({categoryId, name}) => [
|
|
38
|
-
Update(categoryId, state => {...state, name}),
|
|
39
|
-
]
|
|
40
|
-
| CategoryArchived({categoryId}) => [
|
|
41
|
-
Update(categoryId, state => {...state, archived: true}),
|
|
42
|
-
]
|
|
34
|
+
| CategoryAdded({categoryId, name}) => [Set(categoryId, {categoryId, name, archived: false})]
|
|
35
|
+
| CategoryRenamed({categoryId, name}) => [Update(categoryId, state => {...state, name})]
|
|
36
|
+
| CategoryArchived({categoryId}) => [Update(categoryId, state => {...state, archived: true})]
|
|
43
37
|
}
|
|
44
38
|
}
|
|
45
39
|
|
|
@@ -47,18 +41,12 @@ describe("CategoriesView StateViewSlice", () => {
|
|
|
47
41
|
test("projects CategoryAdded into a new row", () =>
|
|
48
42
|
givenEvents([])
|
|
49
43
|
->whenEvent(CategoryAdded({categoryId: "c1", name: "Electronics"}))
|
|
50
|
-
->thenStateWithId(
|
|
51
|
-
"c1",
|
|
52
|
-
{categoryId: "c1", name: "Electronics", archived: false},
|
|
53
|
-
)
|
|
44
|
+
->thenStateWithId("c1", {categoryId: "c1", name: "Electronics", archived: false})
|
|
54
45
|
)
|
|
55
46
|
|
|
56
47
|
test("CategoryRenamed updates the name", () =>
|
|
57
48
|
givenEvents([CategoryAdded({categoryId: "c1", name: "Electronics"})])
|
|
58
49
|
->whenEvent(CategoryRenamed({categoryId: "c1", name: "Consumer Electronics"}))
|
|
59
|
-
->thenStateWithId(
|
|
60
|
-
"c1",
|
|
61
|
-
{categoryId: "c1", name: "Consumer Electronics", archived: false},
|
|
62
|
-
)
|
|
50
|
+
->thenStateWithId("c1", {categoryId: "c1", name: "Consumer Electronics", archived: false})
|
|
63
51
|
)
|
|
64
52
|
})
|