@reventlessdev/reventless-core 3.0.0-alpha.262 → 3.0.0-alpha.264

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 (34) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +9 -9
  3. package/src/admin/Platform_Admin_Structure.res.mjs +1 -1
  4. package/src/components/Aggregate/Aggregate_Builder.res.mjs +1 -1
  5. package/src/components/CommandGenerator/CommandGenerator.res +4 -4
  6. package/src/components/CommandGenerator/CommandGenerator_Callback.res +13 -20
  7. package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +3 -24
  8. package/src/components/Dcb/Dcb_Builder.res +45 -49
  9. package/src/components/Dcb/Dcb_Builder.res.mjs +31 -40
  10. package/src/components/StateChangeSlice/StateChangeSlice.res +2 -0
  11. package/src/components/StateChangeSlice/StateChangeSlice_Builder.res +6 -0
  12. package/src/components/StateChangeSlice/StateChangeSlice_Builder.res.mjs +4 -4
  13. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +51 -22
  14. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +47 -25
  15. package/src/plugin/component/Plugin_Structure.res +101 -4
  16. package/src/plugin/component/Plugin_Structure.res.mjs +82 -9
  17. package/tests/adapter/RuntimeExtensionHookReachTest.res.mjs +1 -1
  18. package/tests/commandgenerator/CommandGeneratorCallbackTest.res +1 -1
  19. package/tests/commandgenerator/CommandGeneratorCallbackTest.res.mjs +1 -1
  20. package/tests/commandgenerator/CommandGeneratorFixtures.res +2 -0
  21. package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +17 -3
  22. package/tests/commandgenerator/OwnerStampingTest.res.mjs +2 -2
  23. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +1 -1
  24. package/tests/dcb/DcbFixtures.res +18 -1
  25. package/tests/dcb/DcbFixtures.res.mjs +14 -0
  26. package/tests/dcb/DcbScopeInferenceTest.res +234 -0
  27. package/tests/dcb/DcbScopeInferenceTest.res.mjs +425 -13
  28. package/tests/dcb/DcbStateChangeSliceTest.res.mjs +22 -22
  29. package/tests/dcb/DcbTagTest.res +81 -68
  30. package/tests/dcb/DcbTagTest.res.mjs +88 -89
  31. package/tests/plugin/PluginStructureAccessTest.res +1 -0
  32. package/tests/plugin/PluginStructureAccessTest.res.mjs +1 -1
  33. package/tests/plugin/PluginStructureTest.res +47 -0
  34. package/tests/plugin/PluginStructureTest.res.mjs +50 -10
@@ -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) for derivePartitionTagV2 fallback test
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")])])