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

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 (49) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +8 -8
  3. package/src/Message.res +1 -1
  4. package/src/adapter/Monitoring/Monitoring.res +1 -1
  5. package/src/admin/Platform_Admin_Structure.res +1 -0
  6. package/src/admin/Platform_Admin_Structure.res.mjs +1 -0
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +2 -1
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  9. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res +1 -1
  10. package/src/components/Aggregate/Aggregate_Callback.res +2 -2
  11. package/src/components/Api/ApiAllowedStatesHelpers.res +2 -3
  12. package/src/components/Api/ApiTargetStateHelpers.res +4 -3
  13. package/src/components/Api/GraphQL_FragmentGenerator.res +122 -12
  14. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +41 -8
  15. package/src/components/Api/SuryToJsonSchema.res +8 -0
  16. package/src/components/Api/SuryToJsonSchema.res.mjs +8 -4
  17. package/src/components/Dcb/Dcb_Builder.res +79 -16
  18. package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
  19. package/src/components/EventLog/EventLog.res +1 -1
  20. package/src/plugin/component/Plugin_Builder.res +7 -0
  21. package/src/plugin/component/Plugin_Builder.res.mjs +3 -1
  22. package/src/plugin/component/Plugin_Structure.res +376 -27
  23. package/src/plugin/component/Plugin_Structure.res.mjs +175 -8
  24. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
  25. package/tests/admin/Platform_BakedManifestTest.res +1 -0
  26. package/tests/admin/Platform_BakedManifestTest.res.mjs +1 -0
  27. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +3 -0
  28. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +6 -0
  29. package/tests/admin/Platform_PluginStructuresApiTest.res +1 -0
  30. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +2 -0
  31. package/tests/aggregate/AggregateCacheTest.res +1 -1
  32. package/tests/aggregate/AggregateSnapshotTest.res +1 -1
  33. package/tests/api/GraphQL_FragmentGeneratorTest.res +171 -0
  34. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +108 -0
  35. package/tests/api/SuryToJsonSchemaTest.res +30 -3
  36. package/tests/api/SuryToJsonSchemaTest.res.mjs +49 -4
  37. package/tests/commandgenerator/OwnerStampingTest.res +57 -0
  38. package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
  39. package/tests/message/MessageTest.res +1 -1
  40. package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
  41. package/tests/plugin/PluginStructureTest.res +268 -4
  42. package/tests/plugin/PluginStructureTest.res.mjs +284 -3
  43. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
  44. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
  45. package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
  46. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
  47. package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
  48. package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
  49. 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
@@ -777,7 +938,7 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
777
938
  groupBy: None,
778
939
  visibility: None,
779
940
  live: None,
780
- retired: Some({field, label: "", showWhenFalse: false, values: None}),
941
+ retired: Some({field, label: "", showWhenFalse: false, values: None, namedWhenRetired: false}),
781
942
  },
782
943
  )
783
944
 
@@ -812,6 +973,7 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
812
973
  label: "",
813
974
  showWhenFalse: false,
814
975
  values: Some(["Deactivated", "Closed"]),
976
+ namedWhenRetired: false,
815
977
  }),
816
978
  },
817
979
  )
@@ -856,6 +1018,108 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
856
1018
  })
857
1019
  })
858
1020
 
1021
+ // A misspelled retirement state used to be fatal or merely logged depending on
1022
+ // where the enum happened to be declared — the PPX refuses it when the enum is
1023
+ // in the same file, and this pass, which sees the resolved schema, only warned.
1024
+ // File layout is not a defensible arbiter of whether a data-exposure bug stops
1025
+ // a build, so this rung was raised to match the other.
1026
+ describe("@retired state names are checked against the field's own enum", () => {
1027
+ let withRetiredStates = (schema, ~field, ~lifecycle, ~values) =>
1028
+ schema->S.castToUnknown->S.Metadata.set(
1029
+ ~id=Reventless.StateAnnotations.stateAnnotationsId,
1030
+ {
1031
+ ids: [],
1032
+ compositeIds: [],
1033
+ subIds: [],
1034
+ compositeSubIds: [],
1035
+ indexes: [],
1036
+ hidden: [],
1037
+ summary: [],
1038
+ drillTargets: [],
1039
+ drillTargetKeys: [],
1040
+ collapsed: [],
1041
+ scan: [],
1042
+ scanSort: [],
1043
+ semantic: [],
1044
+ metric: [],
1045
+ lifecycle,
1046
+ groupBy: None,
1047
+ visibility: None,
1048
+ live: None,
1049
+ retired: Some({field, label: "", showWhenFalse: false, values, namedWhenRetired: false}),
1050
+ },
1051
+ )
1052
+
1053
+ let shelf = values =>
1054
+ S.schema(s =>
1055
+ {
1056
+ "shelf": s.matches(S.union([S.literal("Listed"), S.literal("Archived")])),
1057
+ }
1058
+ )->withRetiredStates(~field="shelf", ~lifecycle=Some("shelf"), ~values=Some(values))
1059
+
1060
+ let check = schema => Plugin_Structure.checkRetiredValue(~entityName="Products", schema)
1061
+
1062
+ testSync("a declared state passes", () => {
1063
+ expect(check(shelf(["Archived"])))->toEqual(Plugin_Structure.Checked([]))
1064
+ })
1065
+
1066
+ // Reported per state rather than as a set: one wrong entry among several
1067
+ // still narrows something, so the symptom is a subset of rows leaking.
1068
+ testSync("each undeclared state is reported on its own", () => {
1069
+ switch check(shelf(["Archived", "Discontinud", "Withdrawn"])) {
1070
+ | Checked(failures) =>
1071
+ expect(failures->Array.length)->toBe(2)
1072
+ expect(failures->Array.some(f => f->String.includes("Discontinud")))->toBe(true)
1073
+ expect(failures->Array.some(f => f->String.includes("Withdrawn")))->toBe(true)
1074
+ // Naming what the field does declare is what turns the failure into a fix.
1075
+ expect(failures->Array.every(f => f->String.includes("Listed, Archived")))->toBe(true)
1076
+ | other => expect(other)->toEqual(Plugin_Structure.Checked([]))
1077
+ }
1078
+ })
1079
+
1080
+ // The condition that came with promoting this: a fatal rule that is silent
1081
+ // when it cannot run looks exactly like one that passed.
1082
+ testSync("a field with no cases to compare against says so instead of passing", () => {
1083
+ let schema =
1084
+ S.schema(s => {"shelf": s.matches(S.string)})->withRetiredStates(
1085
+ ~field="shelf",
1086
+ ~lifecycle=Some("shelf"),
1087
+ ~values=Some(["Archived"]),
1088
+ )
1089
+ switch check(schema) {
1090
+ | Unchecked(why) => expect(why->String.includes("no cases"))->toBe(true)
1091
+ | other => expect(other)->toEqual(Plugin_Structure.Unchecked(""))
1092
+ }
1093
+ })
1094
+
1095
+ // The boolean form names no state, so there is nothing to compare — which is
1096
+ // not the same as having failed to compare it.
1097
+ testSync("the boolean form is not a skip", () => {
1098
+ let schema =
1099
+ S.schema(s => {"archived": s.matches(S.bool)})->withRetiredStates(
1100
+ ~field="archived",
1101
+ ~lifecycle=None,
1102
+ ~values=None,
1103
+ )
1104
+ expect(Plugin_Structure.checkRetiredValue(~entityName="Products", schema))->toEqual(
1105
+ Plugin_Structure.NotDeclared,
1106
+ )
1107
+ })
1108
+
1109
+ // The second rule stays a warning: retirement still works, and which field
1110
+ // carries it is a modelling judgement rather than a name that is wrong.
1111
+ testSync("a state on a non-lifecycle field is not a failure", () => {
1112
+ let schema =
1113
+ S.schema(s =>
1114
+ {
1115
+ "shelf": s.matches(S.union([S.literal("Listed"), S.literal("Archived")])),
1116
+ "lifecycle": s.matches(S.union([S.literal("Draft"), S.literal("Live")])),
1117
+ }
1118
+ )->withRetiredStates(~field="shelf", ~lifecycle=Some("lifecycle"), ~values=Some(["Archived"]))
1119
+ expect(check(schema))->toEqual(Plugin_Structure.Checked([]))
1120
+ })
1121
+ })
1122
+
859
1123
  // Shared by the read-side and write-side schema cases below, which ask the
860
1124
  // same question of the same emitter.
861
1125
  let getProperty = (json: JSON.t, key: string): option<JSON.t> =>
@@ -962,7 +1226,7 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
962
1226
  // The generator captures each component's chapter (source-folder grouping band)
963
1227
  // and passes it as ~componentChapters; Plugin_Structure looks it up by Spec.name
964
1228
  // 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.
1229
+ // bands without workspace access. See docs/plans/done/deployed-chapter-grouping.md.
966
1230
  describe("componentChapters threading", () => {
967
1231
  let chaptered = Plugin_Structure.make(
968
1232
  ~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);
@@ -886,7 +1053,8 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
886
1053
  field: field,
887
1054
  label: "",
888
1055
  showWhenFalse: false,
889
- values: undefined
1056
+ values: undefined,
1057
+ namedWhenRetired: false
890
1058
  }
891
1059
  });
892
1060
  globalThis.test("publishes the retirement state beside the field", () => {
@@ -916,7 +1084,8 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
916
1084
  values: [
917
1085
  "Deactivated",
918
1086
  "Closed"
919
- ]
1087
+ ],
1088
+ namedWhenRetired: false
920
1089
  }
921
1090
  });
922
1091
  let schema = withState(S.schema(s => ({
@@ -959,6 +1128,116 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
959
1128
  globalThis.expect(Plugin_Structure$ReventlessCore.retiredFieldFromStateSchema(schema)).toEqual(undefined);
960
1129
  });
961
1130
  });
1131
+ globalThis.describe("@retired state names are checked against the field's own enum", () => {
1132
+ let withRetiredStates = (schema, field, lifecycle, values) => S.Metadata.set(schema, StateAnnotations$Reventless.stateAnnotationsId, {
1133
+ ids: [],
1134
+ compositeIds: [],
1135
+ subIds: [],
1136
+ compositeSubIds: [],
1137
+ indexes: [],
1138
+ hidden: [],
1139
+ summary: [],
1140
+ drillTargets: [],
1141
+ drillTargetKeys: [],
1142
+ collapsed: [],
1143
+ scan: [],
1144
+ scanSort: [],
1145
+ semantic: [],
1146
+ metric: [],
1147
+ lifecycle: lifecycle,
1148
+ groupBy: undefined,
1149
+ visibility: undefined,
1150
+ live: undefined,
1151
+ retired: {
1152
+ field: field,
1153
+ label: "",
1154
+ showWhenFalse: false,
1155
+ values: values,
1156
+ namedWhenRetired: false
1157
+ }
1158
+ });
1159
+ let shelf = values => withRetiredStates(S.schema(s => ({
1160
+ shelf: s.m(S.union([
1161
+ S.literal("Listed"),
1162
+ S.literal("Archived")
1163
+ ]))
1164
+ })), "shelf", "shelf", values);
1165
+ globalThis.test("a declared state passes", () => {
1166
+ globalThis.expect(Plugin_Structure$ReventlessCore.checkRetiredValue("Products", shelf(["Archived"]))).toEqual({
1167
+ TAG: "Checked",
1168
+ _0: []
1169
+ });
1170
+ });
1171
+ globalThis.test("each undeclared state is reported on its own", () => {
1172
+ let failures = Plugin_Structure$ReventlessCore.checkRetiredValue("Products", shelf([
1173
+ "Archived",
1174
+ "Discontinud",
1175
+ "Withdrawn"
1176
+ ]));
1177
+ if (typeof failures !== "object") {
1178
+ globalThis.expect(failures).toEqual({
1179
+ TAG: "Checked",
1180
+ _0: []
1181
+ });
1182
+ return;
1183
+ }
1184
+ if (failures.TAG === "Unchecked") {
1185
+ globalThis.expect(failures).toEqual({
1186
+ TAG: "Checked",
1187
+ _0: []
1188
+ });
1189
+ return;
1190
+ }
1191
+ let failures$1 = failures._0;
1192
+ globalThis.expect(failures$1.length).toBe(2);
1193
+ globalThis.expect(failures$1.some(f => f.includes("Discontinud"))).toBe(true);
1194
+ globalThis.expect(failures$1.some(f => f.includes("Withdrawn"))).toBe(true);
1195
+ globalThis.expect(failures$1.every(f => f.includes("Listed, Archived"))).toBe(true);
1196
+ });
1197
+ globalThis.test("a field with no cases to compare against says so instead of passing", () => {
1198
+ let schema = withRetiredStates(S.schema(s => ({
1199
+ shelf: s.m(S.string)
1200
+ })), "shelf", "shelf", ["Archived"]);
1201
+ let why = Plugin_Structure$ReventlessCore.checkRetiredValue("Products", schema);
1202
+ if (typeof why !== "object") {
1203
+ globalThis.expect(why).toEqual({
1204
+ TAG: "Unchecked",
1205
+ _0: ""
1206
+ });
1207
+ return;
1208
+ }
1209
+ if (why.TAG === "Unchecked") {
1210
+ globalThis.expect(why._0.includes("no cases")).toBe(true);
1211
+ return;
1212
+ }
1213
+ globalThis.expect(why).toEqual({
1214
+ TAG: "Unchecked",
1215
+ _0: ""
1216
+ });
1217
+ });
1218
+ globalThis.test("the boolean form is not a skip", () => {
1219
+ let schema = withRetiredStates(S.schema(s => ({
1220
+ archived: s.m(S.bool)
1221
+ })), "archived", undefined, undefined);
1222
+ globalThis.expect(Plugin_Structure$ReventlessCore.checkRetiredValue("Products", schema)).toEqual("NotDeclared");
1223
+ });
1224
+ globalThis.test("a state on a non-lifecycle field is not a failure", () => {
1225
+ let schema = withRetiredStates(S.schema(s => ({
1226
+ shelf: s.m(S.union([
1227
+ S.literal("Listed"),
1228
+ S.literal("Archived")
1229
+ ])),
1230
+ lifecycle: s.m(S.union([
1231
+ S.literal("Draft"),
1232
+ S.literal("Live")
1233
+ ]))
1234
+ })), "shelf", "lifecycle", ["Archived"]);
1235
+ globalThis.expect(Plugin_Structure$ReventlessCore.checkRetiredValue("Products", schema)).toEqual({
1236
+ TAG: "Checked",
1237
+ _0: []
1238
+ });
1239
+ });
1240
+ });
962
1241
  let getProperty = (json, key) => {
963
1242
  let obj = Stdlib_JSON.Decode.object(json);
964
1243
  if (obj !== undefined) {
@@ -1275,6 +1554,8 @@ export {
1275
1554
  PsUploadAvatarSlice,
1276
1555
  PsChangePhotoSlice,
1277
1556
  PsReserveStockSlice,
1557
+ PsDispatchShipmentSlice,
1558
+ PsShipmentsViewSlice,
1278
1559
  structure,
1279
1560
  }
1280
1561
  /* structure Not a pure module */