@reventlessdev/reventless-core 3.0.0-alpha.236 → 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 (53) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +6 -6
  3. package/src/Message.res +1 -1
  4. package/src/adapter/Monitoring/Monitoring.res +1 -1
  5. package/src/admin/Platform_Admin_Structure.res +5 -1
  6. package/src/admin/Platform_Admin_Structure.res.mjs +4 -2
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +4 -2
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +11 -3
  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 +14 -1
  14. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +1 -1
  15. package/src/components/Api/QueryDbListQuery.res +25 -1
  16. package/src/components/Api/QueryDbListQuery.res.mjs +11 -4
  17. package/src/components/Api/SuryToJsonSchema.res +39 -0
  18. package/src/components/Api/SuryToJsonSchema.res.mjs +34 -3
  19. package/src/components/Dcb/Dcb_Builder.res +79 -16
  20. package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
  21. package/src/components/EventLog/EventLog.res +1 -1
  22. package/src/plugin/component/Plugin_Structure.res +377 -18
  23. package/src/plugin/component/Plugin_Structure.res.mjs +204 -9
  24. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
  25. package/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs +3 -2
  26. package/tests/admin/Platform_Admin_StructureTest.res +49 -0
  27. package/tests/admin/Platform_Admin_StructureTest.res.mjs +24 -0
  28. package/tests/admin/Platform_BakedManifestTest.res +3 -1
  29. package/tests/admin/Platform_BakedManifestTest.res.mjs +3 -1
  30. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +14 -8
  31. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +24 -12
  32. package/tests/admin/Platform_PluginStructuresApiTest.res +3 -1
  33. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +6 -2
  34. package/tests/aggregate/AggregateCacheTest.res +1 -1
  35. package/tests/aggregate/AggregateSnapshotTest.res +1 -1
  36. package/tests/api/Api_IdsTest.res.mjs +1 -1
  37. package/tests/api/SuryToJsonSchemaTest.res +141 -1
  38. package/tests/api/SuryToJsonSchemaTest.res.mjs +269 -54
  39. package/tests/commandgenerator/OwnerStampingTest.res +57 -0
  40. package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
  41. package/tests/message/MessageTest.res +1 -1
  42. package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
  43. package/tests/plugin/PluginStructureTest.res +332 -15
  44. package/tests/plugin/PluginStructureTest.res.mjs +316 -10
  45. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
  46. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
  47. package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
  48. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
  49. package/tests/plugin/StateViewSlice/PsAnnotatedView.res.mjs +3 -2
  50. package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
  51. package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
  52. package/tests/plugin/pluginDefinitionRequiredScalars.txt +2 -0
  53. 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
@@ -670,33 +831,189 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
670
831
  )
671
832
  })
672
833
 
673
- describe("statusField — the shape rule", () => {
674
- let statusOf = schema =>
675
- Plugin_Structure.statusFieldFromStateSchema(~entityName="Test", schema->S.castToUnknown)
834
+ describe("lifecycleField — the name rule and the shape rule", () => {
835
+ let lifecycleOf = schema =>
836
+ Plugin_Structure.lifecycleFieldFromStateSchema(~entityName="Test", schema->S.castToUnknown)
676
837
 
677
- testSync("a `status` field holding a closed set of values is the lifecycle field", () => {
838
+ let withLifecycle = (schema, ~field) =>
839
+ schema->S.castToUnknown->S.Metadata.set(
840
+ ~id=Reventless.StateAnnotations.stateAnnotationsId,
841
+ {
842
+ ids: [],
843
+ compositeIds: [],
844
+ subIds: [],
845
+ compositeSubIds: [],
846
+ indexes: [],
847
+ hidden: [],
848
+ summary: [],
849
+ drillTargets: [],
850
+ drillTargetKeys: [],
851
+ collapsed: [],
852
+ scan: [],
853
+ scanSort: [],
854
+ semantic: [],
855
+ metric: [],
856
+ lifecycle: Some(field),
857
+ groupBy: None,
858
+ visibility: None,
859
+ live: None,
860
+ retired: None,
861
+ },
862
+ )
863
+
864
+ testSync("a `lifecycle` field holding a closed set of values is the lifecycle field", () => {
678
865
  let schema = S.schema(s =>
679
866
  {
680
- "status": s.matches(S.union([S.literal("Placed"), S.literal("Shipped")])),
867
+ "lifecycle": s.matches(S.union([S.literal("Placed"), S.literal("Shipped")])),
681
868
  }
682
869
  )
683
- expect(statusOf(schema))->toEqual(Some("status"))
870
+ expect(lifecycleOf(schema))->toEqual(Some("lifecycle"))
684
871
  })
685
872
 
686
873
  testSync("an optional one counts too", () => {
687
874
  let schema = S.schema(s =>
688
875
  {
689
- "status": s.matches(S.option(S.union([S.literal("Placed"), S.literal("Shipped")]))),
876
+ "lifecycle": s.matches(S.option(S.union([S.literal("Placed"), S.literal("Shipped")]))),
690
877
  }
691
878
  )
692
- expect(statusOf(schema))->toEqual(Some("status"))
879
+ expect(lifecycleOf(schema))->toEqual(Some("lifecycle"))
693
880
  })
694
881
 
695
- testSync("free text named `status` is not a lifecycle", () => {
882
+ testSync("free text named `lifecycle` is not a lifecycle", () => {
696
883
  // `allowedStates` filtering needs states to compare against; a string
697
- // field named `status` gives a command menu nothing to match.
698
- let schema = S.schema(s => {"status": s.matches(S.string)})
699
- expect(statusOf(schema))->toEqual(None)
884
+ // field named `lifecycle` gives a command menu nothing to match.
885
+ let schema = S.schema(s => {"lifecycle": s.matches(S.string)})
886
+ expect(lifecycleOf(schema))->toEqual(None)
887
+ })
888
+
889
+ // The one behaviour the rename deliberately changes, so it is asserted rather
890
+ // than assumed. `status` is a promiscuous name — geocoding progress, todo-queue
891
+ // progress, translation audit outcome — and a convention keyed on it guessed
892
+ // often. A record whose lifecycle really does live in a field called `status`
893
+ // says so with `@lifecycle`.
894
+ testSync("an unannotated field named `status` resolves to None", () => {
895
+ let schema = S.schema(s =>
896
+ {
897
+ "status": s.matches(S.union([S.literal("Placed"), S.literal("Shipped")])),
898
+ }
899
+ )
900
+ expect(lifecycleOf(schema))->toEqual(None)
901
+ })
902
+
903
+ testSync("the annotation names a field the convention would never reach", () => {
904
+ let schema = S.schema(s =>
905
+ {
906
+ "locationStatus": s.matches(S.union([S.literal("Pending"), S.literal("Located")])),
907
+ }
908
+ )
909
+ expect(lifecycleOf(schema->withLifecycle(~field="locationStatus")))->toEqual(
910
+ Some("locationStatus"),
911
+ )
912
+ })
913
+ })
914
+
915
+ describe("retiredField — annotation and nothing else", () => {
916
+ let retiredOf = schema =>
917
+ Plugin_Structure.retiredFieldFromStateSchema(schema->S.castToUnknown)
918
+
919
+ let withRetired = (schema, ~field) =>
920
+ schema->S.castToUnknown->S.Metadata.set(
921
+ ~id=Reventless.StateAnnotations.stateAnnotationsId,
922
+ {
923
+ ids: [],
924
+ compositeIds: [],
925
+ subIds: [],
926
+ compositeSubIds: [],
927
+ indexes: [],
928
+ hidden: [],
929
+ summary: [],
930
+ drillTargets: [],
931
+ drillTargetKeys: [],
932
+ collapsed: [],
933
+ scan: [],
934
+ scanSort: [],
935
+ semantic: [],
936
+ metric: [],
937
+ lifecycle: None,
938
+ groupBy: None,
939
+ visibility: None,
940
+ live: None,
941
+ retired: Some({field, label: "", showWhenFalse: false, values: None}),
942
+ },
943
+ )
944
+
945
+ // The state form. The value is published beside the field so a client holding
946
+ // the def holds the whole predicate — two places deriving one comparison is
947
+ // how they come to disagree about which rows a caller may see.
948
+ testSync("publishes the retirement state beside the field", () => {
949
+ let withState = schema =>
950
+ schema->S.castToUnknown->S.Metadata.set(
951
+ ~id=Reventless.StateAnnotations.stateAnnotationsId,
952
+ {
953
+ ids: [],
954
+ compositeIds: [],
955
+ subIds: [],
956
+ compositeSubIds: [],
957
+ indexes: [],
958
+ hidden: [],
959
+ summary: [],
960
+ drillTargets: [],
961
+ drillTargetKeys: [],
962
+ collapsed: [],
963
+ scan: [],
964
+ scanSort: [],
965
+ semantic: [],
966
+ metric: [],
967
+ lifecycle: Some("accountStatus"),
968
+ groupBy: None,
969
+ visibility: None,
970
+ live: None,
971
+ retired: Some({
972
+ field: "accountStatus",
973
+ label: "",
974
+ showWhenFalse: false,
975
+ values: Some(["Deactivated", "Closed"]),
976
+ }),
977
+ },
978
+ )
979
+ let schema = S.schema(s => {"accountStatus": s.matches(S.string)})->withState
980
+ expect((
981
+ Plugin_Structure.retiredFieldFromStateSchema(schema),
982
+ Plugin_Structure.retiredValuesFromStateSchema(schema),
983
+ ))->toEqual((Some("accountStatus"), Some(["Deactivated", "Closed"])))
984
+ })
985
+
986
+ // Absent is what says "boolean form", so it has to stay tellable from a state
987
+ // form — including one naming nothing, which is `Some([])`.
988
+ testSync("publishes no states on the boolean form", () => {
989
+ let schema = S.schema(s => {"archived": s.matches(S.bool)})
990
+ expect(
991
+ Plugin_Structure.retiredValuesFromStateSchema(schema->withRetired(~field="archived")),
992
+ )->toEqual(None)
993
+ })
994
+
995
+ testSync("names the annotated field", () => {
996
+ let schema = S.schema(s => {"archived": s.matches(S.bool)})
997
+ expect(retiredOf(schema->withRetired(~field="archived")))->toEqual(Some("archived"))
998
+ })
999
+
1000
+ // The whole reason this has no convention rung. `lifecycleField` may still fall
1001
+ // back to a field literally named `lifecycle` because guessing wrong there makes
1002
+ // a command menu filter oddly; guessing wrong here makes rows disappear for every caller who is not
1003
+ // elevated, so an unannotated boolean stays as visible as it was.
1004
+ testSync("declines a conventionally-named boolean nobody annotated", () => {
1005
+ let schema = S.schema(s =>
1006
+ {
1007
+ "archived": s.matches(S.bool),
1008
+ "deactivated": s.matches(S.bool),
1009
+ }
1010
+ )
1011
+ expect(retiredOf(schema))->toEqual(None)
1012
+ })
1013
+
1014
+ testSync("declines a schema carrying no annotation spec at all", () => {
1015
+ let schema = S.schema(s => {"id": s.matches(S.string)})
1016
+ expect(retiredOf(schema))->toEqual(None)
700
1017
  })
701
1018
  })
702
1019
 
@@ -806,7 +1123,7 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
806
1123
  // The generator captures each component's chapter (source-folder grouping band)
807
1124
  // and passes it as ~componentChapters; Plugin_Structure looks it up by Spec.name
808
1125
  // and threads it onto every def, so a deployed-graph consumer can render chapter
809
- // bands without workspace access. See docs/plans/deployed-chapter-grouping.md.
1126
+ // bands without workspace access. See docs/plans/done/deployed-chapter-grouping.md.
810
1127
  describe("componentChapters threading", () => {
811
1128
  let chaptered = Plugin_Structure.make(
812
1129
  ~name="TestPlugin",