@reventlessdev/reventless-core 3.0.0-alpha.237 → 3.0.0-alpha.238

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 (29) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +7 -7
  3. package/src/Message.res +1 -1
  4. package/src/adapter/Monitoring/Monitoring.res +1 -1
  5. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res +1 -1
  6. package/src/components/Aggregate/Aggregate_Callback.res +2 -2
  7. package/src/components/Api/ApiAllowedStatesHelpers.res +2 -3
  8. package/src/components/Api/ApiTargetStateHelpers.res +4 -3
  9. package/src/components/Dcb/Dcb_Builder.res +79 -16
  10. package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
  11. package/src/components/EventLog/EventLog.res +1 -1
  12. package/src/plugin/component/Plugin_Structure.res +269 -7
  13. package/src/plugin/component/Plugin_Structure.res.mjs +128 -1
  14. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
  15. package/tests/aggregate/AggregateCacheTest.res +1 -1
  16. package/tests/aggregate/AggregateSnapshotTest.res +1 -1
  17. package/tests/commandgenerator/OwnerStampingTest.res +57 -0
  18. package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
  19. package/tests/message/MessageTest.res +1 -1
  20. package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
  21. package/tests/plugin/PluginStructureTest.res +164 -3
  22. package/tests/plugin/PluginStructureTest.res.mjs +170 -1
  23. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
  24. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
  25. package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
  26. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
  27. package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
  28. package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
  29. package/tests/util/CsvStreamTest.res +1 -1
@@ -144,6 +144,29 @@ module PsReserveStockSlice: ReventlessInfra.StateChangeSlice.T = {
144
144
  let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
145
145
  }
146
146
 
147
+ module PsDispatchShipmentSlice: ReventlessInfra.StateChangeSlice.T = {
148
+ module Spec = PsDispatchShipment
149
+ module Behavior = {
150
+ type state = PsDispatchShipment.state
151
+ let initialState = PsDispatchShipment.initialState
152
+ let evolve = PsDispatchShipment.evolve
153
+ let decide = PsDispatchShipment.decide
154
+ let moduleUrl = PsDispatchShipment.moduleUrl
155
+ }
156
+ let isAsync = false
157
+ type component = scsComponent
158
+ let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
159
+ }
160
+ module PsShipmentsViewSlice: ReventlessInfra.StateViewSlice.T = {
161
+ module Spec = PsShipmentsView
162
+ module Projection = {
163
+ let project = PsShipmentsView.project
164
+ let moduleUrl = PsShipmentsView.moduleUrl
165
+ }
166
+ type component = svsComponent
167
+ let make = (~dcbEventLog as _, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
168
+ }
169
+
147
170
  let structure = Plugin_Structure.make(
148
171
  ~name="TestPlugin",
149
172
  ~stateChangeSlices=[module(PsPlaceOrderSlice), module(PsShipOrderSlice)],
@@ -155,6 +178,132 @@ let structure = Plugin_Structure.make(
155
178
  ],
156
179
  )
157
180
 
181
+ describe("lifecycle topology", () => {
182
+ let ordersStates = Dict.fromArray([("Orders", ["Placed", "Shipped", "Cancelled"])])
183
+ let writableWith = (commands): Reventless.Plugin.writableDef => {
184
+ name: "Ordering",
185
+ commands,
186
+ producedEventTypes: [],
187
+ consumedEventTypes: [],
188
+ linkedViews: ["Orders"],
189
+ consistencyRead: None,
190
+ events: [],
191
+ errors: [],
192
+ chapter: None,
193
+ }
194
+ let command = (~name, ~allowedStates=?, ~targetState=?, ()): Reventless.Plugin.commandDef => {
195
+ name,
196
+ schema: "",
197
+ level: Instance,
198
+ aggregateIdField: None,
199
+ mutationField: "",
200
+ references: [],
201
+ allowedStates,
202
+ targetState,
203
+ apiExposed: None,
204
+ requiredAccess: None,
205
+ ownerField: None,
206
+ }
207
+
208
+ testSync("names a state no command can reach", () => {
209
+ // `Cancelled` is in the enum and nothing declares an edge into it.
210
+ let findings = Plugin_Structure.lifecycleTopologyFindings(
211
+ ~writables=[
212
+ writableWith([command(~name="Ship", ~allowedStates=["Placed"], ~targetState="Shipped", ())]),
213
+ ],
214
+ ~lifecycleStatesByView=ordersStates,
215
+ )
216
+ expect(findings->Array.map(((_, m)) => m->String.includes("Cancelled")))->toEqual([true])
217
+ })
218
+
219
+ testSync("says nothing when every state is reachable", () => {
220
+ let findings = Plugin_Structure.lifecycleTopologyFindings(
221
+ ~writables=[
222
+ writableWith([
223
+ command(~name="Ship", ~allowedStates=["Placed"], ~targetState="Shipped", ()),
224
+ command(~name="Cancel", ~allowedStates=["Placed"], ~targetState="Cancelled", ()),
225
+ ]),
226
+ ],
227
+ ~lifecycleStatesByView=ordersStates,
228
+ )
229
+ expect(findings->Array.length)->toBe(0)
230
+ })
231
+
232
+ // The first declared state is where rows begin, so nothing pointing at it is
233
+ // expected rather than suspicious.
234
+ testSync("does not call the initial state unreachable", () => {
235
+ let findings = Plugin_Structure.lifecycleTopologyFindings(
236
+ ~writables=[
237
+ writableWith([
238
+ command(~name="Ship", ~allowedStates=["Placed"], ~targetState="Shipped", ()),
239
+ command(~name="Cancel", ~allowedStates=["Placed"], ~targetState="Cancelled", ()),
240
+ ]),
241
+ ],
242
+ ~lifecycleStatesByView=ordersStates,
243
+ )
244
+ expect(findings->Array.map(((_, m)) => m->String.includes("Placed")))->toEqual([])
245
+ })
246
+
247
+ // A terminal state is not a finding. `Shipped` and `Refunded` have no way out
248
+ // in the shipped aggregates example, and both are correct — which is why the
249
+ // "dead end" rule this pass was going to carry was dropped rather than
250
+ // silenced. See the comment in Plugin_Structure.
251
+ testSync("does not report a state with no way out", () => {
252
+ let findings = Plugin_Structure.lifecycleTopologyFindings(
253
+ ~writables=[
254
+ writableWith([
255
+ command(~name="Ship", ~allowedStates=["Placed"], ~targetState="Shipped", ()),
256
+ command(~name="Cancel", ~allowedStates=["Placed"], ~targetState="Cancelled", ()),
257
+ ]),
258
+ ],
259
+ ~lifecycleStatesByView=ordersStates,
260
+ )
261
+ expect(findings->Array.length)->toBe(0)
262
+ })
263
+ })
264
+
265
+ describe("@transition cross-check", () => {
266
+ // The exit criterion: a state no linked view declares stops the build, and the
267
+ // message says which command, which state, and what it was checked against.
268
+ // `DispatchShipment` produces `ShipmentDispatched`, which the `Shipments` view
269
+ // consumes — so the two are linked, the view's lifecycle is in hand, and
270
+ // "Dispatchd" has somewhere to be wrong.
271
+ let buildBad = () =>
272
+ try {
273
+ Plugin_Structure.make(
274
+ ~name="TransitionPlugin",
275
+ ~stateChangeSlices=[module(PsDispatchShipmentSlice)],
276
+ ~stateViewSlices=[module(PsShipmentsViewSlice)],
277
+ )->ignore
278
+ None
279
+ } catch {
280
+ | Exn.Error(e) => Some(Exn.message(e)->Option.getOr(""))
281
+ | _ => Some("")
282
+ }
283
+
284
+ testSync("a state no linked view declares fails the build", () => {
285
+ expect(buildBad()->Option.isSome)->toBe(true)
286
+ })
287
+
288
+ testSync("the failure names the command, the state and the view", () => {
289
+ let message = buildBad()->Option.getOr("")
290
+ expect((
291
+ message->String.includes("DispatchShipment"),
292
+ message->String.includes("Dispatchd"),
293
+ message->String.includes("Shipments"),
294
+ message->String.includes("Booked"),
295
+ ))->toEqual((true, true, true, true))
296
+ })
297
+
298
+ // The other half of the rule: a correctly-spelled edge passes, and a command
299
+ // whose linked views declare no lifecycle is warned about rather than failed —
300
+ // `PsShipOrder` is exactly that shape, and the suite's main `structure` above
301
+ // builds without throwing, which is that path already exercised.
302
+ testSync("a plugin whose views declare no lifecycle still builds", () => {
303
+ expect(structure.stateChangeSlices->Array.length)->toBe(2)
304
+ })
305
+ })
306
+
158
307
  describe("Plugin_Structure.make — Phase 2 graph fields", () => {
159
308
  describe("stateChangeSlices", () => {
160
309
  testSync("produces two SCS entries in declaration order", () => {
@@ -233,8 +382,8 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
233
382
  ))->toEqual((Some(true), Some(false)))
234
383
  })
235
384
 
236
- testSync("ShipOrder: @targetState(\"Shipped\") flows through the PPX to commandDef.targetState", () => {
237
- // End-to-end: the reventless-ppx @targetState annotation → markTargetState
385
+ testSync("ShipOrder: @transition's target flows through the PPX to commandDef.targetState", () => {
386
+ // End-to-end: the reventless-ppx @transition annotation → markTargetState
238
387
  // metadata → ApiTargetStateHelpers.getTargetState → commandDef. The
239
388
  // un-annotated CancelShipment carries None (resolver falls back to
240
389
  // name-stem).
@@ -246,6 +395,18 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
246
395
  ))->toEqual((Some("Shipped"), None))
247
396
  })
248
397
 
398
+ testSync("ShipOrder: @transition's from-set flows through to commandDef.allowedStates", () => {
399
+ // The other half of the same annotation, which the removed pair spelled
400
+ // separately: one attribute now fills both fields, so a command that
401
+ // declares a target cannot end up without the states it may run from.
402
+ let shipOrder = structure.stateChangeSlices->Array.getUnsafe(1)
403
+ let byName = name => shipOrder.commands->Array.find(c => c.name == name)
404
+ expect((
405
+ byName("ShipOrder")->Option.flatMap(c => c.allowedStates),
406
+ byName("CancelShipment")->Option.flatMap(c => c.allowedStates),
407
+ ))->toEqual((Some(["Placed"]), None))
408
+ })
409
+
249
410
  testSync("ShipOrder: the @noApi variant carries no callable mutation field (no sibling leak)", () => {
250
411
  // Regression: for a single-exposed-command slice, `mutationFieldFor`
251
412
  // resolves every variant — including the @noApi one — to the slice's one
@@ -962,7 +1123,7 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
962
1123
  // The generator captures each component's chapter (source-folder grouping band)
963
1124
  // and passes it as ~componentChapters; Plugin_Structure looks it up by Spec.name
964
1125
  // and threads it onto every def, so a deployed-graph consumer can render chapter
965
- // bands without workspace access. See docs/plans/deployed-chapter-grouping.md.
1126
+ // bands without workspace access. See docs/plans/done/deployed-chapter-grouping.md.
966
1127
  describe("componentChapters threading", () => {
967
1128
  let chaptered = Plugin_Structure.make(
968
1129
  ~name="TestPlugin",
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
+ import * as Stdlib_Exn from "@rescript/runtime/lib/es6/Stdlib_Exn.js";
4
5
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
6
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
7
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
@@ -8,6 +9,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
9
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
9
10
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
10
11
  import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
12
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
11
13
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
12
14
  import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
13
15
  import * as Api_Naming$ReventlessCore from "../../src/components/Api/Api_Naming.res.mjs";
@@ -22,8 +24,10 @@ import * as PsUploadAvatar$ReventlessCore from "./StateChangeSlice/PsUploadAvata
22
24
  import * as PsAnnotatedView$ReventlessCore from "./StateViewSlice/PsAnnotatedView.res.mjs";
23
25
  import * as PsAttachInvoice$ReventlessCore from "./StateChangeSlice/PsAttachInvoice.res.mjs";
24
26
  import * as PsCustomersView$ReventlessCore from "./StateViewSlice/PsCustomersView.res.mjs";
27
+ import * as PsShipmentsView$ReventlessCore from "./StateViewSlice/PsShipmentsView.res.mjs";
25
28
  import * as Plugin_Structure$ReventlessCore from "../../src/plugin/component/Plugin_Structure.res.mjs";
26
29
  import * as PsCategoriesView$ReventlessCore from "./StateViewSlice/PsCategoriesView.res.mjs";
30
+ import * as PsDispatchShipment$ReventlessCore from "./StateChangeSlice/PsDispatchShipment.res.mjs";
27
31
  import * as Capability_Inference$ReventlessCore from "../../src/plugin/component/Capability_Inference.res.mjs";
28
32
  import * as PsAvailableProductsView$ReventlessCore from "./StateViewSlice/PsAvailableProductsView.res.mjs";
29
33
 
@@ -337,6 +341,62 @@ let PsReserveStockSlice = {
337
341
  make: make$10
338
342
  };
339
343
 
344
+ let Behavior$6 = {
345
+ initialState: false,
346
+ evolve: PsDispatchShipment$ReventlessCore.evolve,
347
+ decide: PsDispatchShipment$ReventlessCore.decide,
348
+ moduleUrl: PsDispatchShipment$ReventlessCore.moduleUrl
349
+ };
350
+
351
+ function make$11(param, param$1, param$2, param$3, param$4, param$5) {
352
+ return 0;
353
+ }
354
+
355
+ let PsDispatchShipmentSlice_Spec = {
356
+ name: PsDispatchShipment$ReventlessCore.name,
357
+ moduleUrl: PsDispatchShipment$ReventlessCore.moduleUrl,
358
+ Id: Id$Reventless.$$String,
359
+ consumedEventSchema: PsDispatchShipment$ReventlessCore.consumedEventSchema,
360
+ errorSchema: PsDispatchShipment$ReventlessCore.errorSchema,
361
+ eventSchema: PsDispatchShipment$ReventlessCore.eventSchema,
362
+ commandSchema: PsDispatchShipment$ReventlessCore.commandSchema,
363
+ commandAuthorization: PsDispatchShipment$ReventlessCore.commandAuthorization,
364
+ readConsistency: PsDispatchShipment$ReventlessCore.readConsistency
365
+ };
366
+
367
+ let PsDispatchShipmentSlice = {
368
+ Spec: PsDispatchShipmentSlice_Spec,
369
+ Behavior: Behavior$6,
370
+ isAsync: false,
371
+ make: make$11
372
+ };
373
+
374
+ let Projection$5 = {
375
+ project: PsShipmentsView$ReventlessCore.project,
376
+ moduleUrl: PsShipmentsView$ReventlessCore.moduleUrl
377
+ };
378
+
379
+ function make$12(param, param$1, param$2) {
380
+ return 0;
381
+ }
382
+
383
+ let PsShipmentsViewSlice_Spec = {
384
+ name: PsShipmentsView$ReventlessCore.name,
385
+ moduleUrl: PsShipmentsView$ReventlessCore.moduleUrl,
386
+ stateSchema: PsShipmentsView$ReventlessCore.stateSchema,
387
+ consumedEventSchema: PsShipmentsView$ReventlessCore.consumedEventSchema,
388
+ config: PsShipmentsView$ReventlessCore.config,
389
+ subIdConfig: undefined,
390
+ authorization: PsShipmentsView$ReventlessCore.authorization,
391
+ visibility: PsShipmentsView$ReventlessCore.visibility
392
+ };
393
+
394
+ let PsShipmentsViewSlice = {
395
+ Spec: PsShipmentsViewSlice_Spec,
396
+ Projection: Projection$5,
397
+ make: make$12
398
+ };
399
+
340
400
  let structure = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, [
341
401
  PsOrdersViewSlice,
342
402
  PsAvailableProductsViewSlice,
@@ -347,6 +407,102 @@ let structure = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, un
347
407
  PsShipOrderSlice
348
408
  ], undefined, undefined, undefined, undefined, undefined, undefined);
349
409
 
410
+ globalThis.describe("lifecycle topology", () => {
411
+ let ordersStates = Object.fromEntries([[
412
+ "Orders",
413
+ [
414
+ "Placed",
415
+ "Shipped",
416
+ "Cancelled"
417
+ ]
418
+ ]]);
419
+ let writableWith = commands => ({
420
+ name: "Ordering",
421
+ commands: commands,
422
+ producedEventTypes: [],
423
+ consumedEventTypes: [],
424
+ linkedViews: ["Orders"],
425
+ consistencyRead: undefined,
426
+ events: [],
427
+ errors: [],
428
+ chapter: undefined
429
+ });
430
+ let command = (name, allowedStates, targetState, param) => ({
431
+ name: name,
432
+ schema: "",
433
+ level: "Instance",
434
+ aggregateIdField: undefined,
435
+ mutationField: "",
436
+ references: [],
437
+ allowedStates: allowedStates,
438
+ targetState: targetState,
439
+ apiExposed: undefined,
440
+ requiredAccess: undefined,
441
+ ownerField: undefined
442
+ });
443
+ globalThis.test("names a state no command can reach", () => {
444
+ let findings = Plugin_Structure$ReventlessCore.lifecycleTopologyFindings([writableWith([command("Ship", ["Placed"], "Shipped", undefined)])], ordersStates);
445
+ globalThis.expect(findings.map(param => param[1].includes("Cancelled"))).toEqual([true]);
446
+ });
447
+ globalThis.test("says nothing when every state is reachable", () => {
448
+ let findings = Plugin_Structure$ReventlessCore.lifecycleTopologyFindings([writableWith([
449
+ command("Ship", ["Placed"], "Shipped", undefined),
450
+ command("Cancel", ["Placed"], "Cancelled", undefined)
451
+ ])], ordersStates);
452
+ globalThis.expect(findings.length).toBe(0);
453
+ });
454
+ globalThis.test("does not call the initial state unreachable", () => {
455
+ let findings = Plugin_Structure$ReventlessCore.lifecycleTopologyFindings([writableWith([
456
+ command("Ship", ["Placed"], "Shipped", undefined),
457
+ command("Cancel", ["Placed"], "Cancelled", undefined)
458
+ ])], ordersStates);
459
+ globalThis.expect(findings.map(param => param[1].includes("Placed"))).toEqual([]);
460
+ });
461
+ globalThis.test("does not report a state with no way out", () => {
462
+ let findings = Plugin_Structure$ReventlessCore.lifecycleTopologyFindings([writableWith([
463
+ command("Ship", ["Placed"], "Shipped", undefined),
464
+ command("Cancel", ["Placed"], "Cancelled", undefined)
465
+ ])], ordersStates);
466
+ globalThis.expect(findings.length).toBe(0);
467
+ });
468
+ });
469
+
470
+ globalThis.describe("@transition cross-check", () => {
471
+ let buildBad = () => {
472
+ try {
473
+ Plugin_Structure$ReventlessCore.make("TransitionPlugin", undefined, undefined, [PsShipmentsViewSlice], [PsDispatchShipmentSlice], undefined, undefined, undefined, undefined, undefined, undefined);
474
+ return;
475
+ } catch (raw_e) {
476
+ let e = Primitive_exceptions.internalToException(raw_e);
477
+ if (e.RE_EXN_ID === Stdlib_Exn.$$Error) {
478
+ return Stdlib_Option.getOr(e._1.message, "");
479
+ } else {
480
+ return "";
481
+ }
482
+ }
483
+ };
484
+ globalThis.test("a state no linked view declares fails the build", () => {
485
+ globalThis.expect(Stdlib_Option.isSome(buildBad())).toBe(true);
486
+ });
487
+ globalThis.test("the failure names the command, the state and the view", () => {
488
+ let message = Stdlib_Option.getOr(buildBad(), "");
489
+ globalThis.expect([
490
+ message.includes("DispatchShipment"),
491
+ message.includes("Dispatchd"),
492
+ message.includes("Shipments"),
493
+ message.includes("Booked")
494
+ ]).toEqual([
495
+ true,
496
+ true,
497
+ true,
498
+ true
499
+ ]);
500
+ });
501
+ globalThis.test("a plugin whose views declare no lifecycle still builds", () => {
502
+ globalThis.expect(structure.stateChangeSlices.length).toBe(2);
503
+ });
504
+ });
505
+
350
506
  globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
351
507
  globalThis.describe("stateChangeSlices", () => {
352
508
  globalThis.test("produces two SCS entries in declaration order", () => {
@@ -424,7 +580,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
424
580
  false
425
581
  ]);
426
582
  });
427
- globalThis.test("ShipOrder: @targetState(\"Shipped\") flows through the PPX to commandDef.targetState", () => {
583
+ globalThis.test("ShipOrder: @transition's target flows through the PPX to commandDef.targetState", () => {
428
584
  let shipOrder = structure.stateChangeSlices[1];
429
585
  let byName = name => shipOrder.commands.find(c => c.name === name);
430
586
  globalThis.expect([
@@ -435,6 +591,17 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
435
591
  undefined
436
592
  ]);
437
593
  });
594
+ globalThis.test("ShipOrder: @transition's from-set flows through to commandDef.allowedStates", () => {
595
+ let shipOrder = structure.stateChangeSlices[1];
596
+ let byName = name => shipOrder.commands.find(c => c.name === name);
597
+ globalThis.expect([
598
+ Stdlib_Option.flatMap(byName("ShipOrder"), c => c.allowedStates),
599
+ Stdlib_Option.flatMap(byName("CancelShipment"), c => c.allowedStates)
600
+ ]).toEqual([
601
+ ["Placed"],
602
+ undefined
603
+ ]);
604
+ });
438
605
  globalThis.test("ShipOrder: the @noApi variant carries no callable mutation field (no sibling leak)", () => {
439
606
  let shipOrder = structure.stateChangeSlices[1];
440
607
  let byName = name => shipOrder.commands.find(c => c.name === name);
@@ -1275,6 +1442,8 @@ export {
1275
1442
  PsUploadAvatarSlice,
1276
1443
  PsChangePhotoSlice,
1277
1444
  PsReserveStockSlice,
1445
+ PsDispatchShipmentSlice,
1446
+ PsShipmentsViewSlice,
1278
1447
  structure,
1279
1448
  }
1280
1449
  /* structure Not a pure module */
@@ -0,0 +1,31 @@
1
+ // Test fixture spec for the @transition cross-check — the failing case.
2
+ //
3
+ // `Dispatchd` is a deliberate typo. It compiles: the PPX strips the attribute
4
+ // before the typechecker sees it, so a misspelled state is a well-formed string
5
+ // all the way down. That is exactly the hole the structure-side check closes,
6
+ // and a fixture that could not compile would not demonstrate it.
7
+
8
+ @@reventless.spec("DispatchShipment")
9
+
10
+ type shipmentStatus =
11
+ | Booked
12
+ | Dispatched
13
+
14
+ @schema
15
+ type consumedEvent = ShipmentBooked({shipmentId: string})
16
+
17
+ let evolve = (_state, _event) => true
18
+
19
+ type state = bool
20
+ let initialState = false
21
+
22
+ @schema
23
+ type command = @transition(([Booked]) => Dispatchd) DispatchShipment({shipmentId: string})
24
+
25
+ @schema
26
+ type error = ShipmentNotFound
27
+
28
+ @schema
29
+ type event = ShipmentDispatched({shipmentId: string})
30
+
31
+ let decide = (_state, _command): result<array<event>, error> => Ok([])
@@ -0,0 +1,73 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
5
+ import * as Api$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/Api.res.mjs";
6
+
7
+ let consumedEventSchema = S.schema(s => ({
8
+ TAG: "ShipmentBooked",
9
+ shipmentId: s.m(DcbTag$Reventless.string)
10
+ }));
11
+
12
+ function evolve(_state, _event) {
13
+ return true;
14
+ }
15
+
16
+ let commandSchema = S.schema(s => ({
17
+ TAG: "DispatchShipment",
18
+ shipmentId: s.m(DcbTag$Reventless.string)
19
+ }));
20
+
21
+ let errorSchema = S.literal("ShipmentNotFound");
22
+
23
+ let eventSchema = S.schema(s => ({
24
+ TAG: "ShipmentDispatched",
25
+ shipmentId: s.m(DcbTag$Reventless.string)
26
+ }));
27
+
28
+ function decide(_state, _command) {
29
+ return {
30
+ TAG: "Ok",
31
+ _0: []
32
+ };
33
+ }
34
+
35
+ let commandSchema$1 = Api$ReventlessInfra.markAllowedStates(commandSchema, [[
36
+ "DispatchShipment",
37
+ ["Booked"]
38
+ ]]);
39
+
40
+ let commandSchema$2 = Api$ReventlessInfra.markTargetState(commandSchema$1, [[
41
+ "DispatchShipment",
42
+ "Dispatchd"
43
+ ]]);
44
+
45
+ function commandAuthorization(param) {
46
+ return "AllowAuthenticated";
47
+ }
48
+
49
+ let name = "DispatchShipment";
50
+
51
+ let Id;
52
+
53
+ let initialState = false;
54
+
55
+ let moduleUrl = "@reventlessdev/reventless-core/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs";
56
+
57
+ let readConsistency = "EscalateOnRetry";
58
+
59
+ export {
60
+ name,
61
+ Id,
62
+ consumedEventSchema,
63
+ evolve,
64
+ initialState,
65
+ errorSchema,
66
+ eventSchema,
67
+ decide,
68
+ commandSchema$2 as commandSchema,
69
+ moduleUrl,
70
+ commandAuthorization,
71
+ readConsistency,
72
+ }
73
+ /* consumedEventSchema Not a pure module */
@@ -18,8 +18,23 @@ let evolve = (_state, _event) => true
18
18
  // It is also `@noApi` (internal/admin only): a two-variant slice with exactly
19
19
  // one API-exposed command, which is the case that used to leak the exposed
20
20
  // command's slice mutation field onto the non-exposed variant.
21
+ // The lifecycle this slice's commands move a shipment through. The PPX strips
22
+ // @transition before the typechecker sees it, so this enum is not what makes the
23
+ // reference below valid — the structure-side check against the linked view is.
24
+ // It is here because a state is a constructor, and naming one in a string is a
25
+ // reference nothing can check at any layer.
26
+ type shipmentStatus = Placed | Shipped | Cancelled
27
+
28
+ // CancelShipment is payload-less — it compiles to a bare string literal, so it
29
+ // exercises toCommandDef's payload-less branch (still surfaced in `commands`).
30
+ // It is also `@noApi` (internal/admin only): a two-variant slice with exactly
31
+ // one API-exposed command, which is the case that used to leak the exposed
32
+ // command's slice mutation field onto the non-exposed variant. It carries no
33
+ // @transition, which is the un-annotated case the resolver still guesses for.
21
34
  @schema
22
- type command = @targetState("Shipped") ShipOrder({orderId: string}) | @noApi CancelShipment
35
+ type command =
36
+ | @transition(([Placed]) => Shipped) ShipOrder({orderId: string})
37
+ | @noApi CancelShipment
23
38
 
24
39
  // Two variants, one payload-less and one carrying a field — the same two branches
25
40
  // `events` exercises, so the structure's `errors` list is pinned on both.
@@ -47,7 +47,12 @@ function decide(_state, _command) {
47
47
 
48
48
  let commandSchema$1 = Api$ReventlessInfra.markNoApiVariants(commandSchema, ["CancelShipment"]);
49
49
 
50
- let commandSchema$2 = Api$ReventlessInfra.markTargetState(commandSchema$1, [[
50
+ let commandSchema$2 = Api$ReventlessInfra.markAllowedStates(commandSchema$1, [[
51
+ "ShipOrder",
52
+ ["Placed"]
53
+ ]]);
54
+
55
+ let commandSchema$3 = Api$ReventlessInfra.markTargetState(commandSchema$2, [[
51
56
  "ShipOrder",
52
57
  "Shipped"
53
58
  ]]);
@@ -75,7 +80,7 @@ export {
75
80
  errorSchema,
76
81
  eventSchema,
77
82
  decide,
78
- commandSchema$2 as commandSchema,
83
+ commandSchema$3 as commandSchema,
79
84
  moduleUrl,
80
85
  commandAuthorization,
81
86
  readConsistency,
@@ -0,0 +1,26 @@
1
+ // Test fixture spec for the @transition cross-check.
2
+ // A view that declares a lifecycle, so a command's declared states have
3
+ // something to be checked against. `PsOrdersView` deliberately declares none —
4
+ // the two together cover both the checked and the unvalidated path.
5
+
6
+ @@reventless.spec("Shipments")
7
+
8
+ @schema
9
+ type consumedEvent =
10
+ | ShipmentBooked({shipmentId: string})
11
+ | ShipmentDispatched({shipmentId: string})
12
+
13
+ @schema
14
+ type shipmentStatus =
15
+ | Booked
16
+ | Dispatched
17
+
18
+ @schema
19
+ type state = {shipmentId: string, @lifecycle shipmentStatus: shipmentStatus}
20
+
21
+ let project = ({event}: Reventless.StateViewSlice.consumed<consumedEvent>) =>
22
+ switch event {
23
+ | ShipmentBooked({shipmentId}) => [Set(shipmentId, {shipmentId, shipmentStatus: Booked})]
24
+ | ShipmentDispatched({shipmentId}) =>
25
+ [Update(shipmentId, s => {...s, shipmentStatus: Dispatched})]
26
+ }