@reventlessdev/reventless-core 3.0.0-alpha.261 → 3.0.0-alpha.263
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 +31 -0
- package/package.json +9 -9
- package/src/adapter/Secrets/Secrets_Node_Backend.res +60 -0
- package/src/adapter/Secrets/Secrets_Node_Backend.res.mjs +69 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +1 -1
- package/src/components/Aggregate/Aggregate_Builder.res.mjs +1 -1
- package/src/components/CommandGenerator/CommandGenerator.res +4 -4
- package/src/components/CommandGenerator/CommandGenerator_Callback.res +13 -20
- package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +3 -24
- package/src/components/Dcb/Dcb_Builder.res +45 -49
- package/src/components/Dcb/Dcb_Builder.res.mjs +31 -40
- package/src/components/StateChangeSlice/StateChangeSlice.res +2 -0
- package/src/components/StateChangeSlice/StateChangeSlice_Builder.res +6 -0
- package/src/components/StateChangeSlice/StateChangeSlice_Builder.res.mjs +4 -4
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +51 -22
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +47 -25
- package/src/plugin/component/Plugin_Structure.res +101 -4
- package/src/plugin/component/Plugin_Structure.res.mjs +82 -9
- package/tests/Secrets_Node_BackendTest.res +141 -0
- package/tests/Secrets_Node_BackendTest.res.mjs +152 -0
- package/tests/adapter/RuntimeExtensionHookReachTest.res.mjs +1 -1
- package/tests/admin/Platform_Admin_StructureTest.res +12 -3
- package/tests/admin/Platform_Admin_StructureTest.res.mjs +4 -3
- package/tests/commandgenerator/CommandGeneratorCallbackTest.res +1 -1
- package/tests/commandgenerator/CommandGeneratorCallbackTest.res.mjs +1 -1
- package/tests/commandgenerator/CommandGeneratorFixtures.res +2 -0
- package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +17 -3
- package/tests/commandgenerator/OwnerStampingTest.res.mjs +2 -2
- package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +1 -1
- package/tests/dcb/DcbFixtures.res +18 -1
- package/tests/dcb/DcbFixtures.res.mjs +14 -0
- package/tests/dcb/DcbScopeInferenceTest.res +234 -0
- package/tests/dcb/DcbScopeInferenceTest.res.mjs +425 -13
- package/tests/dcb/DcbStateChangeSliceTest.res.mjs +22 -22
- package/tests/dcb/DcbTagTest.res +81 -68
- package/tests/dcb/DcbTagTest.res.mjs +88 -89
- package/tests/plugin/PluginStructureAccessTest.res +1 -0
- package/tests/plugin/PluginStructureAccessTest.res.mjs +1 -1
- package/tests/plugin/PluginStructureTest.res +47 -0
- package/tests/plugin/PluginStructureTest.res.mjs +50 -10
|
@@ -206,14 +206,23 @@ describe("the internal commands declare the edges they move rows along", () => {
|
|
|
206
206
|
// was open to everyone and the server refused anyone outside `Admin`. The
|
|
207
207
|
// mismatch is invisible until a non-admin clicks the page.
|
|
208
208
|
describe("the Plugins view publishes the rule the server enforces", () => {
|
|
209
|
-
|
|
209
|
+
// 🚨 **This is also the only check `Reventless.AdminGroup.name` can carry.** A
|
|
210
|
+
// PPX annotation takes a literal, so `@@reventless.authorize(AllowGroups(
|
|
211
|
+
// ["Admin"]))` above cannot reference the constant — the constant's job is to
|
|
212
|
+
// make everything *else* agree with the annotation, and nothing in the compiler
|
|
213
|
+
// spans that gap. The actual side here comes from the expanded annotation and
|
|
214
|
+
// the expected side from the constant, so the two are compared rather than
|
|
215
|
+
// restated. Writing the literal on both sides would pass while they diverged.
|
|
216
|
+
testSync("the spec declares the group, and it is the one the constant names", () =>
|
|
210
217
|
expect(PluginsReadModelSpec.authorization)->toEqual(
|
|
211
|
-
Reventless.Authorization.AllowGroups([
|
|
218
|
+
Reventless.Authorization.AllowGroups([Reventless.AdminGroup.name]),
|
|
212
219
|
)
|
|
213
220
|
)
|
|
214
221
|
|
|
215
222
|
testSync("and the published access keys are derived from it", () =>
|
|
216
|
-
expect(Platform_Admin_Structure.pluginReadModel.requiredAccess)->toEqual(
|
|
223
|
+
expect(Platform_Admin_Structure.pluginReadModel.requiredAccess)->toEqual(
|
|
224
|
+
Some([Reventless.AdminGroup.name]),
|
|
225
|
+
)
|
|
217
226
|
)
|
|
218
227
|
|
|
219
228
|
// The API entry reads the same binding rather than restating it, so the two
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
6
|
+
import * as AdminGroup$Reventless from "@reventlessdev/reventless-spec/src/types/AdminGroup.res.mjs";
|
|
6
7
|
import * as Plugin_Structure$ReventlessCore from "../../src/plugin/component/Plugin_Structure.res.mjs";
|
|
7
8
|
import * as PluginBaseFragment$ReventlessCore from "../../src/plugin/api/PluginBaseFragment.res.mjs";
|
|
8
9
|
import * as PluginsReadModelSpec$ReventlessCore from "../../src/plugin/lifecycle/PluginsReadModelSpec.res.mjs";
|
|
@@ -154,14 +155,14 @@ globalThis.describe("the internal commands declare the edges they move rows alon
|
|
|
154
155
|
});
|
|
155
156
|
|
|
156
157
|
globalThis.describe("the Plugins view publishes the rule the server enforces", () => {
|
|
157
|
-
globalThis.test("the spec declares the group", () => {
|
|
158
|
+
globalThis.test("the spec declares the group, and it is the one the constant names", () => {
|
|
158
159
|
globalThis.expect(PluginsReadModelSpec$ReventlessCore.authorization).toEqual({
|
|
159
160
|
TAG: "AllowGroups",
|
|
160
|
-
_0: [
|
|
161
|
+
_0: [AdminGroup$Reventless.name]
|
|
161
162
|
});
|
|
162
163
|
});
|
|
163
164
|
globalThis.test("and the published access keys are derived from it", () => {
|
|
164
|
-
globalThis.expect(Platform_Admin_Structure$ReventlessCore.pluginReadModel.requiredAccess).toEqual([
|
|
165
|
+
globalThis.expect(Platform_Admin_Structure$ReventlessCore.pluginReadModel.requiredAccess).toEqual([AdminGroup$Reventless.name]);
|
|
165
166
|
});
|
|
166
167
|
globalThis.test("the query entry carries the spec's rule and no hand-written pair", () => {
|
|
167
168
|
let entry = PluginBaseFragment$ReventlessCore.queryEntries[0];
|
|
@@ -91,7 +91,7 @@ describe("CommandGenerator_Callback.generateCommand:", () => {
|
|
|
91
91
|
|
|
92
92
|
describe("StateChangeSlice envelope id derivation", () => {
|
|
93
93
|
testPromise(
|
|
94
|
-
"
|
|
94
|
+
"the slice's partition key: id derived from its field when args have no id",
|
|
95
95
|
async () => {
|
|
96
96
|
let payload = makeSlicePayload(
|
|
97
97
|
~command="CreateItem",
|
|
@@ -85,7 +85,7 @@ globalThis.describe("CommandGenerator_Callback.generateCommand:", () => {
|
|
|
85
85
|
});
|
|
86
86
|
});
|
|
87
87
|
globalThis.describe("StateChangeSlice envelope id derivation", () => {
|
|
88
|
-
globalThis.test("
|
|
88
|
+
globalThis.test("the slice's partition key: id derived from its field when args have no id", async () => {
|
|
89
89
|
let payload = CommandGeneratorFixtures$ReventlessCore.makeSlicePayload("CreateItem", Object.fromEntries([
|
|
90
90
|
[
|
|
91
91
|
"itemId",
|
|
@@ -107,6 +107,7 @@ let singleTagSliceGen = CommandGenerator_Callback.makeGenerateCommand(
|
|
|
107
107
|
~commandSchema=singleTagCommandSchema->S.castToUnknown,
|
|
108
108
|
~componentKind=CommandGenerator_Callback.StateChangeSlice,
|
|
109
109
|
~stripIdFromParams=false,
|
|
110
|
+
~partitionTag=Simple({key: "itemId"}),
|
|
110
111
|
)
|
|
111
112
|
|
|
112
113
|
let compositeTagSliceGen = CommandGenerator_Callback.makeGenerateCommand(
|
|
@@ -115,6 +116,7 @@ let compositeTagSliceGen = CommandGenerator_Callback.makeGenerateCommand(
|
|
|
115
116
|
~commandSchema=compositeTagCommandSchema->S.castToUnknown,
|
|
116
117
|
~componentKind=CommandGenerator_Callback.StateChangeSlice,
|
|
117
118
|
~stripIdFromParams=false,
|
|
119
|
+
~partitionTag=Composite({keys: ["environment", "service"], seps: ["-"]}),
|
|
118
120
|
)
|
|
119
121
|
|
|
120
122
|
// Build a payload for a StateChangeSlice command — args must NOT include `id`.
|
|
@@ -139,11 +139,25 @@ let optionalFieldCommandSchema = Sury.$schema(s => ({
|
|
|
139
139
|
note: s.m(Sury.$option(Sury.string))
|
|
140
140
|
}));
|
|
141
141
|
|
|
142
|
-
let optionalFieldSliceGen = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "OptionalFieldSlice", optionalFieldCommandSchema, "StateChangeSlice", false);
|
|
142
|
+
let optionalFieldSliceGen = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "OptionalFieldSlice", optionalFieldCommandSchema, "StateChangeSlice", false, undefined);
|
|
143
143
|
|
|
144
|
-
let singleTagSliceGen = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "SingleTagSlice", singleTagCommandSchema, "StateChangeSlice", false
|
|
144
|
+
let singleTagSliceGen = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "SingleTagSlice", singleTagCommandSchema, "StateChangeSlice", false, {
|
|
145
|
+
TAG: "Simple",
|
|
146
|
+
_0: {
|
|
147
|
+
key: "itemId"
|
|
148
|
+
}
|
|
149
|
+
});
|
|
145
150
|
|
|
146
|
-
let compositeTagSliceGen = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "CompositeTagSlice", compositeTagCommandSchema, "StateChangeSlice", false
|
|
151
|
+
let compositeTagSliceGen = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "CompositeTagSlice", compositeTagCommandSchema, "StateChangeSlice", false, {
|
|
152
|
+
TAG: "Composite",
|
|
153
|
+
_0: {
|
|
154
|
+
keys: [
|
|
155
|
+
"environment",
|
|
156
|
+
"service"
|
|
157
|
+
],
|
|
158
|
+
seps: ["-"]
|
|
159
|
+
}
|
|
160
|
+
});
|
|
147
161
|
|
|
148
162
|
function makeSlicePayload(command, args) {
|
|
149
163
|
return {
|
|
@@ -32,7 +32,7 @@ let published = {
|
|
|
32
32
|
|
|
33
33
|
let generate = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(async cmds => {
|
|
34
34
|
published.contents = published.contents.concat(cmds);
|
|
35
|
-
}, undefined, "Ordering", commandSchema, "StateChangeSlice", false);
|
|
35
|
+
}, undefined, "Ordering", commandSchema, "StateChangeSlice", false, undefined);
|
|
36
36
|
|
|
37
37
|
function cognito(userId, groups) {
|
|
38
38
|
return {
|
|
@@ -193,7 +193,7 @@ globalThis.describe("@owner stamping on the command path:", () => {
|
|
|
193
193
|
globalThis.describe("the schema a generator is built with must answer for its commands:", () => {
|
|
194
194
|
let permissive = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(async cmds => {
|
|
195
195
|
published.contents = published.contents.concat(cmds);
|
|
196
|
-
}, undefined, "Ordering", Sury.json, "StateChangeSlice", false);
|
|
196
|
+
}, undefined, "Ordering", Sury.json, "StateChangeSlice", false, undefined);
|
|
197
197
|
globalThis.test("a permissive schema silently stamps nothing — the defect, pinned", async () => {
|
|
198
198
|
await Effect.runPromise(permissive(placeOrder("cust-B", cognito("cust-A", ["User"]))));
|
|
199
199
|
globalThis.expect(fieldOf(published.contents[published.contents.length - 1 | 0], "customerId")).toEqual("cust-B");
|
|
@@ -82,7 +82,7 @@ function sliceItem(reference, command) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
function runSlice(reference, command) {
|
|
85
|
-
return Effect.runPromise(SliceHandler.handleCommands(undefined, undefined, dcbEventLog, Stream.fromIterable([sliceItem(reference, command)])));
|
|
85
|
+
return Effect.runPromise(SliceHandler.handleCommands(undefined, undefined, undefined, dcbEventLog, Stream.fromIterable([sliceItem(reference, command)])));
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
function runAgg(aggIdOpt, reference, command) {
|
|
@@ -375,7 +375,7 @@ type compositeMultiVariant =
|
|
|
375
375
|
count: int,
|
|
376
376
|
})
|
|
377
377
|
|
|
378
|
-
// Schema with @partitionTag (simple)
|
|
378
|
+
// Schema with @partitionTag (simple)
|
|
379
379
|
@schema
|
|
380
380
|
type simplePartitionEvent =
|
|
381
381
|
| OrderPlaced({
|
|
@@ -383,6 +383,23 @@ type simplePartitionEvent =
|
|
|
383
383
|
customerId: @s.matches(Reventless.DcbTag.string) string,
|
|
384
384
|
})
|
|
385
385
|
|
|
386
|
+
// A second entity in the same boundary, annotated on its own key. The partition
|
|
387
|
+
// key is declared after the reference, so a positional default would pick the wrong one.
|
|
388
|
+
@schema
|
|
389
|
+
type addressLinkedEvent =
|
|
390
|
+
| AddressLinked({
|
|
391
|
+
addressId: @s.matches(Reventless.DcbTag.string) string,
|
|
392
|
+
customerId: @s.matches(Reventless.DcbTag.partition) string,
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
// Two ids, no annotation and nothing read: inference cannot choose.
|
|
396
|
+
@schema
|
|
397
|
+
type unannotatedLinkEvent =
|
|
398
|
+
| CustomerAddressLinked({
|
|
399
|
+
customerId: @s.matches(Reventless.DcbTag.string) string,
|
|
400
|
+
addressId: @s.matches(Reventless.DcbTag.string) string,
|
|
401
|
+
})
|
|
402
|
+
|
|
386
403
|
// Schema that mixes composite and simple partition (should throw)
|
|
387
404
|
@schema
|
|
388
405
|
type mixedStrategyEvent =
|
|
@@ -473,6 +473,18 @@ let simplePartitionEventSchema = Sury.$schema(s => ({
|
|
|
473
473
|
customerId: s.m(DcbTag$Reventless.string)
|
|
474
474
|
}));
|
|
475
475
|
|
|
476
|
+
let addressLinkedEventSchema = Sury.$schema(s => ({
|
|
477
|
+
TAG: "AddressLinked",
|
|
478
|
+
addressId: s.m(DcbTag$Reventless.string),
|
|
479
|
+
customerId: s.m(DcbTag$Reventless.partition)
|
|
480
|
+
}));
|
|
481
|
+
|
|
482
|
+
let unannotatedLinkEventSchema = Sury.$schema(s => ({
|
|
483
|
+
TAG: "CustomerAddressLinked",
|
|
484
|
+
customerId: s.m(DcbTag$Reventless.string),
|
|
485
|
+
addressId: s.m(DcbTag$Reventless.string)
|
|
486
|
+
}));
|
|
487
|
+
|
|
476
488
|
let mixedStrategyEventSchema = Sury.$schema(s => ({
|
|
477
489
|
TAG: "BadEvent",
|
|
478
490
|
env: s.m(DcbTag$Reventless.compositePartitionMember(0, undefined)),
|
|
@@ -532,6 +544,8 @@ export {
|
|
|
532
544
|
compositeEventCustomSepSchema,
|
|
533
545
|
compositeMultiVariantSchema,
|
|
534
546
|
simplePartitionEventSchema,
|
|
547
|
+
addressLinkedEventSchema,
|
|
548
|
+
unannotatedLinkEventSchema,
|
|
535
549
|
mixedStrategyEventSchema,
|
|
536
550
|
singleCompositeEventSchema,
|
|
537
551
|
subscriptionEventSchema,
|
|
@@ -12,12 +12,14 @@ let slice = (
|
|
|
12
12
|
~consumed=[],
|
|
13
13
|
~produced=[],
|
|
14
14
|
~partitionHint=?,
|
|
15
|
+
~chapter=?,
|
|
15
16
|
): DSI.sliceShape => {
|
|
16
17
|
sliceName,
|
|
17
18
|
command,
|
|
18
19
|
consumed,
|
|
19
20
|
produced,
|
|
20
21
|
partitionHint,
|
|
22
|
+
?chapter,
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
// The online-shop-hybrid catalog boundary, transcribed structurally (NO tag
|
|
@@ -271,6 +273,238 @@ describe("DcbScopeInference:", () => {
|
|
|
271
273
|
)
|
|
272
274
|
})
|
|
273
275
|
|
|
276
|
+
describe("partition: identity given back only when subtraction leaves nothing", () => {
|
|
277
|
+
let partitionOf = (d: DSI.derived, name) => d.partitionBySlice->Dict.get(name)
|
|
278
|
+
|
|
279
|
+
testSync(
|
|
280
|
+
"AddProduct without a hint stays productId, though its category arms come from a categoryId slice",
|
|
281
|
+
() => {
|
|
282
|
+
let d = DSI.infer(catalog)
|
|
283
|
+
expect(addProduct.partitionHint)->toEqual(None)
|
|
284
|
+
expect(d->partitionOf("AddProduct"))->toEqual(Some("productId"))
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
testSync(
|
|
289
|
+
"a lifecycle arm naming the slice's own id resolves",
|
|
290
|
+
() => {
|
|
291
|
+
let changeName = slice(
|
|
292
|
+
"ChangeProductName",
|
|
293
|
+
~command=[scal("productId")],
|
|
294
|
+
~consumed=[ev("ProductAdded", [scal("productId")])],
|
|
295
|
+
~produced=[ev("ProductNameChanged", [scal("productId")])],
|
|
296
|
+
)
|
|
297
|
+
let d = DSI.infer(catalog->Array.concat([changeName]))
|
|
298
|
+
expect(d->partitionOf("ChangeProductName"))->toEqual(Some("productId"))
|
|
299
|
+
expect(d.ambiguities)->toEqual([])
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
// Every arm a slice reads from the other also names that slice's ids, so
|
|
304
|
+
// subtraction leaves both with nothing and each depends on the other.
|
|
305
|
+
let placeOrder = slice(
|
|
306
|
+
"PlaceOrder",
|
|
307
|
+
~partitionHint="orderId",
|
|
308
|
+
~command=[scal("orderId"), scal("customerId")],
|
|
309
|
+
~produced=[ev("OrderPlaced", [scal("orderId"), scal("customerId"), arr("productIds")])],
|
|
310
|
+
)
|
|
311
|
+
let shipOrder = slice(
|
|
312
|
+
"ShipOrder",
|
|
313
|
+
~command=[scal("orderId")],
|
|
314
|
+
~consumed=[
|
|
315
|
+
ev("OrderPlaced", [scal("orderId"), scal("customerId"), arr("productIds")]),
|
|
316
|
+
ev("OrderCancelled", [scal("orderId"), arr("productIds")]),
|
|
317
|
+
],
|
|
318
|
+
~produced=[ev("OrderShipped", [scal("orderId"), scal("customerId")])],
|
|
319
|
+
)
|
|
320
|
+
let cancelOrder = slice(
|
|
321
|
+
"CancelOrder",
|
|
322
|
+
~command=[scal("orderId")],
|
|
323
|
+
~consumed=[
|
|
324
|
+
ev("OrderPlaced", [scal("orderId"), scal("customerId"), arr("productIds")]),
|
|
325
|
+
ev("OrderShipped", [scal("orderId"), scal("customerId")]),
|
|
326
|
+
],
|
|
327
|
+
~produced=[ev("OrderCancelled", [scal("orderId"), arr("productIds")])],
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
testSync(
|
|
331
|
+
"the ShipOrder ⇄ CancelOrder cycle resolves both to orderId",
|
|
332
|
+
() => {
|
|
333
|
+
let d = DSI.infer([placeOrder, shipOrder, cancelOrder])
|
|
334
|
+
expect(d->partitionOf("ShipOrder"))->toEqual(Some("orderId"))
|
|
335
|
+
expect(d->partitionOf("CancelOrder"))->toEqual(Some("orderId"))
|
|
336
|
+
expect(d.ambiguities)->toEqual([])
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
testSync(
|
|
341
|
+
"seen alone, the same slice names the arms to change",
|
|
342
|
+
() =>
|
|
343
|
+
expect(DSI.partitionBlockers(cancelOrder))->toEqual([
|
|
344
|
+
("orderId", ["OrderPlaced", "OrderShipped"]),
|
|
345
|
+
("productId", ["OrderPlaced"]),
|
|
346
|
+
]),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
let recordDemand = slice(
|
|
350
|
+
"RecordProductDemand",
|
|
351
|
+
~command=[scal("productId"), scal("orderId")],
|
|
352
|
+
~consumed=[ev("ProductDemandRecorded", [scal("orderId")])],
|
|
353
|
+
~produced=[ev("ProductDemandRecorded", [scal("productId"), scal("orderId")])],
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
testSync(
|
|
357
|
+
"a join without a hint stays ambiguous",
|
|
358
|
+
() => {
|
|
359
|
+
let d = DSI.infer([recordDemand])
|
|
360
|
+
expect(d->partitionOf("RecordProductDemand"))->toEqual(None)
|
|
361
|
+
expect(d.ambiguities->Array.map(((n, _)) => n))->toEqual(["RecordProductDemand"])
|
|
362
|
+
},
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
testSync(
|
|
366
|
+
"a produced id another slice is partitioned by is not dropped",
|
|
367
|
+
() => {
|
|
368
|
+
// Were productId dropped for belonging to AddProduct, the join would
|
|
369
|
+
// silently resolve to orderId.
|
|
370
|
+
let d = DSI.infer(catalog->Array.concat([placeOrder, recordDemand]))
|
|
371
|
+
expect(d->partitionOf("RecordProductDemand"))->toEqual(None)
|
|
372
|
+
expect(
|
|
373
|
+
d.ambiguities->Array.some(
|
|
374
|
+
((n, reason)) =>
|
|
375
|
+
n == "RecordProductDemand" && reason->String.includes("orderId, productId"),
|
|
376
|
+
),
|
|
377
|
+
)->toBe(true)
|
|
378
|
+
},
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
testSync(
|
|
382
|
+
"a boundary that never settles is reported, not guessed",
|
|
383
|
+
() => {
|
|
384
|
+
// A resolves only while B is unresolved, and B only while A is: each
|
|
385
|
+
// pass flips both. Z* arms have no producer, so they always block.
|
|
386
|
+
let a = slice(
|
|
387
|
+
"A",
|
|
388
|
+
~consumed=[ev("BHappened", [scal("xId")]), ev("ZHappened", [scal("yId")])],
|
|
389
|
+
~produced=[ev("AHappened", [scal("xId"), scal("yId")])],
|
|
390
|
+
)
|
|
391
|
+
let b = slice(
|
|
392
|
+
"B",
|
|
393
|
+
~consumed=[ev("AHappened", [scal("yId")]), ev("Z2Happened", [scal("xId")])],
|
|
394
|
+
~produced=[ev("BHappened", [scal("xId"), scal("yId")])],
|
|
395
|
+
)
|
|
396
|
+
let d = DSI.infer([a, b])
|
|
397
|
+
expect(d.ambiguities->Array.map(((n, _)) => n))->toEqual(["A", "B"])
|
|
398
|
+
expect(
|
|
399
|
+
d.ambiguities->Array.every(((_, reason)) => reason->String.includes("did not settle")),
|
|
400
|
+
)->toBe(true)
|
|
401
|
+
},
|
|
402
|
+
)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
describe("partition: the chapter breaks a tie", () => {
|
|
406
|
+
let placeOrder = (~chapter=?) =>
|
|
407
|
+
slice(
|
|
408
|
+
"PlaceOrder",
|
|
409
|
+
~command=[scal("orderId"), scal("customerId")],
|
|
410
|
+
~consumed=[ev("OrderPlaced", [scal("orderId")])],
|
|
411
|
+
~produced=[ev("OrderPlaced", [scal("orderId"), scal("customerId")])],
|
|
412
|
+
~chapter?,
|
|
413
|
+
)
|
|
414
|
+
let cancelOrder = slice(
|
|
415
|
+
"CancelOrder",
|
|
416
|
+
~chapter="Order",
|
|
417
|
+
~consumed=[ev("OrderPlaced", [])],
|
|
418
|
+
~produced=[ev("OrderCancelled", [scal("orderId")])],
|
|
419
|
+
)
|
|
420
|
+
let partitionOf = (d: DSI.derived, name) => d.partitionBySlice->Dict.get(name)
|
|
421
|
+
|
|
422
|
+
testSync(
|
|
423
|
+
"an order and its customer resolve to the id every Order event carries",
|
|
424
|
+
() => {
|
|
425
|
+
let d = DSI.infer([placeOrder(~chapter="Order"), cancelOrder])
|
|
426
|
+
expect(d->partitionOf("PlaceOrder"))->toEqual(Some("orderId"))
|
|
427
|
+
expect(d.ambiguities)->toEqual([])
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
testSync(
|
|
432
|
+
"without a chapter the same slice stays ambiguous",
|
|
433
|
+
() => {
|
|
434
|
+
let d = DSI.infer([placeOrder(), cancelOrder])
|
|
435
|
+
expect(d->partitionOf("PlaceOrder"))->toEqual(None)
|
|
436
|
+
},
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
testSync(
|
|
440
|
+
"a join whose chapter carries both ids stays ambiguous, and the reason says so",
|
|
441
|
+
() => {
|
|
442
|
+
let demand = slice(
|
|
443
|
+
"RecordProductDemand",
|
|
444
|
+
~chapter="ProductDemand",
|
|
445
|
+
~produced=[ev("ProductDemandRecorded", [scal("productId"), scal("orderId")])],
|
|
446
|
+
)
|
|
447
|
+
let d = DSI.infer([demand])
|
|
448
|
+
expect(d->partitionOf("RecordProductDemand"))->toEqual(None)
|
|
449
|
+
expect(
|
|
450
|
+
d.ambiguities->Array.some(
|
|
451
|
+
((_, reason)) => reason->String.includes("The ProductDemand chapter does not decide"),
|
|
452
|
+
),
|
|
453
|
+
)->toBe(true)
|
|
454
|
+
},
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
testSync(
|
|
458
|
+
"it never overrides a key the subtraction decided",
|
|
459
|
+
() => {
|
|
460
|
+
// AddProduct filed under a chapter keyed by categoryId still resolves to
|
|
461
|
+
// productId: the tie-breaker only sees slices left with several keys.
|
|
462
|
+
let misfiled = {...addProduct, chapter: "Category"}
|
|
463
|
+
let d = DSI.infer([misfiled, {...addCategory, chapter: "Category"}])
|
|
464
|
+
expect(d->partitionOf("AddProduct"))->toEqual(Some("productId"))
|
|
465
|
+
},
|
|
466
|
+
)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
describe("validatePartitionHintsVsInference (@partitionTag ⇄ inference)", () => {
|
|
470
|
+
let hinted = (s: DSI.sliceShape, key) => {...s, partitionHint: Some(key)}
|
|
471
|
+
let check = shapes => Reventless.DcbValidation.validatePartitionHintsVsInference(~shapes)
|
|
472
|
+
let names = (issues: array<Reventless.DcbValidation.validationError>) =>
|
|
473
|
+
issues->Array.map(e => e.sliceName)
|
|
474
|
+
|
|
475
|
+
testSync(
|
|
476
|
+
"a hint naming a key the slice only references is a contradiction",
|
|
477
|
+
() => {
|
|
478
|
+
let issues = check([hinted(addProduct, "categoryId"), addCategory])
|
|
479
|
+
expect(issues.contradictions->names)->toEqual(["AddProduct"])
|
|
480
|
+
expect(issues.redundancies)->toEqual([])
|
|
481
|
+
},
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
testSync(
|
|
485
|
+
"a hint inference reaches unaided is redundant",
|
|
486
|
+
() => {
|
|
487
|
+
let issues = check([hinted(addProduct, "productId"), addCategory])
|
|
488
|
+
expect(issues.contradictions)->toEqual([])
|
|
489
|
+
expect(issues.redundancies->names)->toEqual(["AddProduct"])
|
|
490
|
+
},
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
testSync(
|
|
494
|
+
"a hint that decides a join is neither",
|
|
495
|
+
() => {
|
|
496
|
+
let demand = slice(
|
|
497
|
+
"RecordProductDemand",
|
|
498
|
+
~partitionHint="productId",
|
|
499
|
+
~produced=[ev("ProductDemandRecorded", [scal("productId"), scal("orderId")])],
|
|
500
|
+
)
|
|
501
|
+
let issues = check([demand])
|
|
502
|
+
expect(issues.contradictions)->toEqual([])
|
|
503
|
+
expect(issues.redundancies)->toEqual([])
|
|
504
|
+
},
|
|
505
|
+
)
|
|
506
|
+
})
|
|
507
|
+
|
|
274
508
|
describe("ambiguity surfacing", () => {
|
|
275
509
|
// A pure join: produces an event carrying two foreign keys, owns neither.
|
|
276
510
|
let aOwner = slice("A", ~produced=[ev("AHappened", [scal("aId")])])
|