@reventlessdev/reventless-core 3.0.0-alpha.255 → 3.0.0-alpha.257

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 (79) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/package.json +9 -9
  3. package/src/adapter/Monitoring/Monitoring.res +79 -5
  4. package/src/adapter/Monitoring/Monitoring.res.mjs +42 -2
  5. package/src/admin/Platform_Admin_Structure.res.mjs +1 -1
  6. package/src/admin/Platform_ComponentDefinitionsApi.res +5 -1
  7. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  8. package/src/admin/Platform_UiSlots.res +21 -0
  9. package/src/admin/Platform_UiSlots.res.mjs +15 -0
  10. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs +1 -1
  11. package/src/components/Aggregate/Aggregate_Callback.res +13 -4
  12. package/src/components/Aggregate/Aggregate_Callback.res.mjs +6 -5
  13. package/src/components/Api/GraphQL_FragmentGenerator.res +2 -0
  14. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +4 -0
  15. package/src/components/Api/MCP_SchemaGenerator.res.mjs +2 -2
  16. package/src/components/Api/SchemaType.res +8 -0
  17. package/src/components/Api/SchemaType.res.mjs +15 -5
  18. package/src/components/Api/SuryToJsonSchema.res +70 -12
  19. package/src/components/Api/SuryToJsonSchema.res.mjs +43 -16
  20. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +8 -3
  21. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +1 -1
  22. package/src/plugin/component/Plugin.res +1 -0
  23. package/src/plugin/component/Plugin_Builder.res +2 -0
  24. package/src/plugin/component/Plugin_Builder.res.mjs +3 -2
  25. package/src/plugin/component/Plugin_Structure.res +105 -10
  26. package/src/plugin/component/Plugin_Structure.res.mjs +71 -27
  27. package/src/plugin/lifecycle/PluginsReadModelSpec.res +1 -1
  28. package/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs +1 -1
  29. package/src/util/Validation.res +1 -1
  30. package/src/util/Validation.res.mjs +2 -2
  31. package/tests/adapter/MonitoringTest.res +46 -7
  32. package/tests/adapter/MonitoringTest.res.mjs +47 -5
  33. package/tests/admin/AdminApiSchemaDriftTest.res +1 -0
  34. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +3 -0
  35. package/tests/admin/Platform_Admin_StructureTest.res.mjs +2 -14
  36. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +1 -0
  37. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +2 -0
  38. package/tests/admin/Platform_PluginStructuresApiTest.res +8 -14
  39. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +3 -3
  40. package/tests/aggregate/AggregateCacheTest.res.mjs +1 -1
  41. package/tests/aggregate/AggregateConflictTest.res.mjs +1 -1
  42. package/tests/aggregate/AggregateFixtures.res.mjs +1 -1
  43. package/tests/aggregate/AggregateSnapshotTest.res.mjs +1 -1
  44. package/tests/api/GraphQL_FragmentGeneratorTest.res +6 -4
  45. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +5 -5
  46. package/tests/api/SuryToJsonSchemaTest.res.mjs +57 -57
  47. package/tests/api/TaggedUnionSchemaTest.res.mjs +1 -1
  48. package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +1 -1
  49. package/tests/commandtopic/CommandTopicCallbackFixtures.res.mjs +1 -1
  50. package/tests/dcb/DcbFixtures.res.mjs +1 -1
  51. package/tests/eventmapper/EventMapperFixtures.res.mjs +1 -1
  52. package/tests/extensionpoint/ExtensionPointFixtures.res.mjs +1 -1
  53. package/tests/extensionpoint/ExtensionPointOperationsTest.res.mjs +1 -1
  54. package/tests/fixtures/plugin-lifecycle/2026-07-22-versionconnected-no-requiredstores.json +1 -1
  55. package/tests/fixtures/plugin-lifecycle/2026-07-22-versionsuperseded-nested-definitions.json +1 -1
  56. package/tests/fixtures/plugin-lifecycle/2026-07-28-versionconnected-requiredstores-only.json +1 -1
  57. package/tests/fixtures/plugin-lifecycle/2026-07-30-versionconnected-declarations-without-annotation.json +1 -1
  58. package/tests/fixtures/plugin-lifecycle/2026-07-30-versiondisconnected-declarations-without-annotation.json +1 -1
  59. package/tests/fixtures/plugin-lifecycle/README.md +12 -0
  60. package/tests/logger/LogFormatTest.res +1 -1
  61. package/tests/logger/LogFormatTest.res.mjs +1 -1
  62. package/tests/plugin/ManifestVisibilityTest.res.mjs +1 -1
  63. package/tests/plugin/PluginDefinitionRequiredScalarsTest.res +1 -1
  64. package/tests/plugin/PluginDefinitionScalars.res +13 -8
  65. package/tests/plugin/PluginDefinitionScalars.res.mjs +13 -5
  66. package/tests/plugin/PluginStructureAccessTest.res.mjs +1 -1
  67. package/tests/plugin/PluginStructureTest.res +184 -0
  68. package/tests/plugin/PluginStructureTest.res.mjs +142 -17
  69. package/tests/plugin/StateChangeSlice/PsAttachInvoice.res.mjs +1 -1
  70. package/tests/plugin/StateChangeSlice/PsChangePhoto.res.mjs +1 -1
  71. package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +1 -1
  72. package/tests/plugin/StateChangeSlice/PsPlaceOrder.res.mjs +1 -1
  73. package/tests/plugin/StateChangeSlice/PsReserveStock.res +14 -1
  74. package/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs +9 -2
  75. package/tests/plugin/StateChangeSlice/PsTypoStore.res.mjs +1 -1
  76. package/tests/plugin/StateChangeSlice/PsUploadAvatar.res.mjs +1 -1
  77. package/tests/plugin/StateChangeSlice/PsUploadImages.res.mjs +1 -1
  78. package/tests/plugin/pluginDefinitionRequiredScalars.txt +13 -14
  79. package/tests/util/CommandPublisherTest.res.mjs +1 -1
@@ -241,6 +241,7 @@ describe("lifecycle topology", () => {
241
241
  references: [],
242
242
  allowedStates,
243
243
  targetState,
244
+ allowedStatesSource: ?allowedStates->Option.map(_ => "declared"),
244
245
  apiExposed: None,
245
246
  requiredAccess: None,
246
247
  ownerField: None,
@@ -344,6 +345,141 @@ describe("commandTransition — the edge as a switch", () => {
344
345
  })
345
346
  })
346
347
 
348
+ describe("the harvested model against the switch", () => {
349
+ // The switch is a claim; the component's own scenarios are evidence. Where the
350
+ // evidence says something it wins and the structure records that it did, so a
351
+ // consumer can rank an observed edge against an authored one. Where it says
352
+ // nothing the claim stands — an empty derivation published as `Some([])` would
353
+ // match no row's lifecycle and offer the command nowhere at all, while the
354
+ // annotation that would have been right sat unread beside it.
355
+ let edge = (
356
+ ~command,
357
+ ~level: option<Reventless.Plugin.commandLevel>=?,
358
+ ~allowedStates=[],
359
+ ~targets=[],
360
+ ): Reventless.Plugin.derivedEdge => {
361
+ component: "TransitionSwitch",
362
+ command,
363
+ level: ?level,
364
+ allowedStates,
365
+ targets,
366
+ }
367
+ let build = lifecycleModel =>
368
+ Plugin_Structure.make(
369
+ ~name="HarvestPlugin",
370
+ ~stateChangeSlices=[module(PsTransitionSwitchSlice)],
371
+ ~stateViewSlices=[],
372
+ ~lifecycleModel,
373
+ )
374
+ let edgeOf = (model, name) => {
375
+ let slice = build(model).stateChangeSlices->Array.getUnsafe(0)
376
+ slice.commands
377
+ ->Array.find(c => c.name == name)
378
+ ->Option.map(c => (c.allowedStates, c.targetState, c.allowedStatesSource))
379
+ }
380
+
381
+ testSync("an observed from-set replaces the declared one, and says where it came from", () => {
382
+ expect(
383
+ edgeOf([edge(~command="Book", ~allowedStates=["Booked"], ~targets=["Abandoned"])], "Book"),
384
+ )->toEqual(Some((Some(["Booked"]), Some("Abandoned"), Some("derived"))))
385
+ })
386
+
387
+ testSync("a corpus that observed nothing leaves the switch standing", () => {
388
+ // Present in the model and empty is the same answer as absent from it: the
389
+ // scenarios did not reach this command.
390
+ expect(edgeOf([edge(~command="Book")], "Book"))->toEqual(
391
+ Some((Some(["Draft"]), Some("Booked"), Some("declared"))),
392
+ )
393
+ })
394
+
395
+ testSync("the two halves resolve separately", () => {
396
+ // A corpus routinely shows a command taking effect without ever showing
397
+ // where it lands, and erasing the declared target for that would cost a
398
+ // consumer the edge it had.
399
+ expect(edgeOf([edge(~command="Book", ~allowedStates=["Booked"])], "Book"))->toEqual(
400
+ Some((Some(["Booked"]), Some("Booked"), Some("derived"))),
401
+ )
402
+ })
403
+
404
+ testSync("two observed targets leave the declaration to speak", () => {
405
+ // `targetState` carries one state, so a branching command is an edge it
406
+ // cannot express. The harvest reports that as a contradiction; here the
407
+ // choice is between the declaration and picking one of the two arbitrarily.
408
+ expect(
409
+ edgeOf([edge(~command="Book", ~allowedStates=["Booked"], ~targets=["Booked", "Abandoned"])], "Book"),
410
+ )->toEqual(Some((Some(["Booked"]), Some("Booked"), Some("derived"))))
411
+ })
412
+
413
+ testSync("a command the model does not mention keeps its declaration", () => {
414
+ expect(edgeOf([edge(~command="Book", ~allowedStates=["Booked"])], "Rebook"))->toEqual(
415
+ Some((Some(["Booked"]), None, Some("declared"))),
416
+ )
417
+ })
418
+
419
+ testSync("an authored Unrestricted publishes no from-set, and says it meant to", () => {
420
+ // Distinct from the field being absent, which is "nobody said". A consumer
421
+ // drawing a state machine can tell an edge left unconstrained on purpose
422
+ // from one nothing is known about.
423
+ expect(edgeOf([], "Abandon"))->toEqual(Some((None, None, Some("unrestricted"))))
424
+ })
425
+
426
+ testSync("and the scenarios may not narrow it", () => {
427
+ // "Legal in every state" is a claim; a corpus only covers the states
428
+ // somebody wrote a scenario for. Deriving a from-set here would shrink the
429
+ // claim to an accident of coverage and withdraw the command from every row
430
+ // nobody tested — which for `Customer.SetLocation`'s trait reports is the
431
+ // difference between a late geocode landing and a row parked in Failed.
432
+ expect(edgeOf([edge(~command="Abandon", ~allowedStates=["Booked"])], "Abandon"))->toEqual(
433
+ Some((None, None, Some("unrestricted"))),
434
+ )
435
+ })
436
+
437
+ testSync("a spec that wrote no switch is silence, which they may answer", () => {
438
+ // `PsPlaceOrder` declares no `commandTransition`, so the ppx injects
439
+ // `Undeclared`. It erases to the same pair of `None`s as `Unrestricted` and
440
+ // is the reason the two are separate constructors at all.
441
+ let structure = Plugin_Structure.make(
442
+ ~name="SilentPlugin",
443
+ ~stateChangeSlices=[module(PsPlaceOrderSlice)],
444
+ ~stateViewSlices=[],
445
+ ~lifecycleModel=[
446
+ {
447
+ component: "PlaceOrder",
448
+ command: "PlaceOrder",
449
+ allowedStates: ["Draft"],
450
+ targets: ["Placed"],
451
+ },
452
+ ],
453
+ )
454
+ let slice = structure.stateChangeSlices->Array.getUnsafe(0)
455
+ expect(
456
+ slice.commands
457
+ ->Array.find(c => c.name == "PlaceOrder")
458
+ ->Option.map(c => (c.allowedStates, c.targetState, c.allowedStatesSource)),
459
+ )->toEqual(Some((Some(["Draft"]), Some("Placed"), Some("derived"))))
460
+ })
461
+
462
+ let levelOf = (model, name) => {
463
+ let slice = build(model).stateChangeSlices->Array.getUnsafe(0)
464
+ slice.commands->Array.find(c => c.name == name)->Option.map(c => c.level)
465
+ }
466
+
467
+ testSync("the level comes from the corpus where it has one", () => {
468
+ // `Abandon` starts with none of the create-command stems, so the name-stem
469
+ // guess makes it Instance-level. The scenarios say otherwise, and they have
470
+ // seen the histories.
471
+ expect(levelOf([edge(~command="Abandon", ~level=Collection)], "Abandon"))->toEqual(
472
+ Some(Reventless.Plugin.Collection),
473
+ )
474
+ })
475
+
476
+ testSync("and from the name stem where it does not", () => {
477
+ expect(levelOf([edge(~command="Abandon")], "Abandon"))->toEqual(
478
+ Some(Reventless.Plugin.Instance),
479
+ )
480
+ })
481
+ })
482
+
347
483
  describe("traitDeclarations — a graft leaves a trace", () => {
348
484
  // The one signal a graft leaves that survives into a deployed plugin. Every
349
485
  // other one — the package dependency, the variant spread, the rules alias, the
@@ -1859,15 +1995,43 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1859
1995
 
1860
1996
  // Scalar and plural in one command: the plural forms are the regression, the
1861
1997
  // scalar one is here so a fix that reaches them by breaking it cannot pass.
1998
+ // The nested one rides alongside, named by its path rather than by the field
1999
+ // that holds it.
1862
2000
  testSync("a command collects its array refs alongside its scalar one", () => {
1863
2001
  let cmd = slice.commands->Array.getUnsafe(0)
1864
2002
  expect(cmd.references->referencesOf)->toEqual([
1865
2003
  ("customerId", "Customers"),
1866
2004
  ("productIds", "AvailableProducts"),
1867
2005
  ("warehouseIds", "Warehouses"),
2006
+ ("lineItems[].productId", "AvailableProducts"),
1868
2007
  ])
1869
2008
  })
1870
2009
 
2010
+ // A reference declared on a field of a record the command holds belongs to
2011
+ // *that* field, so it is named by its path: `[]` for "each element of", `.`
2012
+ // for "property of". Attributing it to `lineItems` would name a field that
2013
+ // declares nothing, and dropping it would leave the consuming side free to
2014
+ // guess an entity from the name.
2015
+ testSync("a reference on a nested record's field is named by its path", () => {
2016
+ let cmd = slice.commands->Array.getUnsafe(0)
2017
+ expect(
2018
+ cmd.references->Array.find(({fieldName}) => fieldName == "lineItems[].productId"),
2019
+ )->toEqual(
2020
+ Some({
2021
+ Reventless.Plugin.fieldName: "lineItems[].productId",
2022
+ entity: "AvailableProducts",
2023
+ plugin: None,
2024
+ }),
2025
+ )
2026
+ })
2027
+
2028
+ // The other half of the path contract: a flat field keeps its bare name, so
2029
+ // nothing reading today's paths has to learn the new form to go on working.
2030
+ testSync("a flat reference in the same variant keeps its bare name", () => {
2031
+ let cmd = slice.commands->Array.getUnsafe(0)
2032
+ expect(cmd.references->Array.some(({fieldName}) => fieldName == "customerId"))->toBe(true)
2033
+ })
2034
+
1871
2035
  // Optionality is a statement about presence. An optional array puts the
1872
2036
  // element two wrappers down, which is the shape most likely to be missed by
1873
2037
  // an unwrap that only handles one.
@@ -1885,6 +2049,26 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1885
2049
  expect(evt.references->referencesOf)->toEqual([("productIds", "AvailableProducts")])
1886
2050
  })
1887
2051
 
2052
+ // A client assembling this mutation declares a variable per argument, and
2053
+ // for `lineItems` it needs the *element* input type by name. The rendered
2054
+ // reference `x-reventless-graphql-type` publishes is keyed by argument, so it
2055
+ // stops at the top level; this one travels with the object itself, which is
2056
+ // what a reader that has descended into the list has in hand.
2057
+ //
2058
+ // The name is composed from the mutation field by a server-side convention,
2059
+ // so nothing downstream of the JSON Schema could reconstruct it.
2060
+ testSync("a nested input object publishes the type name the SDL declares", () => {
2061
+ let cmd = slice.commands->Array.getUnsafe(0)
2062
+ let itemName =
2063
+ cmd.schema
2064
+ ->JSON.parseOrThrow
2065
+ ->getPropertyOf("lineItems")
2066
+ ->Option.flatMap(s => getProperty(s, "items"))
2067
+ ->Option.flatMap(s => getProperty(s, "x-reventless-graphql-input"))
2068
+ ->Option.flatMap(JSON.Decode.string)
2069
+ expect(itemName)->toEqual(Some(cmd.mutationField ++ "LineItems"))
2070
+ })
2071
+
1888
2072
  // The declared entity, not the one `productIds` reads like. Before the
1889
2073
  // element unwrap this field resolved by heuristic to `Products`.
1890
2074
  testSync("the declared entity wins over the one the field name suggests", () => {
@@ -519,7 +519,7 @@ let structure = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, un
519
519
  ], [
520
520
  PsPlaceOrderSlice,
521
521
  PsShipOrderSlice
522
- ], undefined, undefined, undefined, undefined, undefined, undefined);
522
+ ], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
523
523
 
524
524
  globalThis.describe("lifecycle topology", () => {
525
525
  let ordersStates = Object.fromEntries([[
@@ -550,6 +550,7 @@ globalThis.describe("lifecycle topology", () => {
550
550
  references: [],
551
551
  allowedStates: allowedStates,
552
552
  targetState: targetState,
553
+ allowedStatesSource: Stdlib_Option.map(allowedStates, param => "declared"),
553
554
  apiExposed: undefined,
554
555
  requiredAccess: undefined,
555
556
  ownerField: undefined
@@ -582,7 +583,7 @@ globalThis.describe("lifecycle topology", () => {
582
583
  });
583
584
 
584
585
  globalThis.describe("commandTransition — the edge as a switch", () => {
585
- let structure = Plugin_Structure$ReventlessCore.make("TransitionSwitchPlugin", undefined, undefined, [], [PsTransitionSwitchSlice], undefined, undefined, undefined, undefined, undefined, undefined);
586
+ let structure = Plugin_Structure$ReventlessCore.make("TransitionSwitchPlugin", undefined, undefined, [], [PsTransitionSwitchSlice], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
586
587
  let slice = structure.stateChangeSlices[0];
587
588
  let byName = name => slice.commands.find(c => c.name === name);
588
589
  globalThis.test("the switch fills allowedStates and targetState", () => {
@@ -614,11 +615,114 @@ globalThis.describe("commandTransition — the edge as a switch", () => {
614
615
  });
615
616
  });
616
617
 
618
+ globalThis.describe("the harvested model against the switch", () => {
619
+ let edge = (command, level, allowedStatesOpt, targetsOpt) => {
620
+ let allowedStates = allowedStatesOpt !== undefined ? allowedStatesOpt : [];
621
+ let targets = targetsOpt !== undefined ? targetsOpt : [];
622
+ return {
623
+ component: "TransitionSwitch",
624
+ command: command,
625
+ level: level,
626
+ allowedStates: allowedStates,
627
+ targets: targets
628
+ };
629
+ };
630
+ let build = lifecycleModel => Plugin_Structure$ReventlessCore.make("HarvestPlugin", undefined, undefined, [], [PsTransitionSwitchSlice], undefined, undefined, undefined, undefined, undefined, undefined, lifecycleModel);
631
+ let edgeOf = (model, name) => {
632
+ let slice = build(model).stateChangeSlices[0];
633
+ return Stdlib_Option.map(slice.commands.find(c => c.name === name), c => [
634
+ c.allowedStates,
635
+ c.targetState,
636
+ c.allowedStatesSource
637
+ ]);
638
+ };
639
+ globalThis.test("an observed from-set replaces the declared one, and says where it came from", () => {
640
+ globalThis.expect(edgeOf([edge("Book", undefined, ["Booked"], ["Abandoned"])], "Book")).toEqual([
641
+ ["Booked"],
642
+ "Abandoned",
643
+ "derived"
644
+ ]);
645
+ });
646
+ globalThis.test("a corpus that observed nothing leaves the switch standing", () => {
647
+ globalThis.expect(edgeOf([edge("Book", undefined, undefined, undefined)], "Book")).toEqual([
648
+ ["Draft"],
649
+ "Booked",
650
+ "declared"
651
+ ]);
652
+ });
653
+ globalThis.test("the two halves resolve separately", () => {
654
+ globalThis.expect(edgeOf([edge("Book", undefined, ["Booked"], undefined)], "Book")).toEqual([
655
+ ["Booked"],
656
+ "Booked",
657
+ "derived"
658
+ ]);
659
+ });
660
+ globalThis.test("two observed targets leave the declaration to speak", () => {
661
+ globalThis.expect(edgeOf([edge("Book", undefined, ["Booked"], [
662
+ "Booked",
663
+ "Abandoned"
664
+ ])], "Book")).toEqual([
665
+ ["Booked"],
666
+ "Booked",
667
+ "derived"
668
+ ]);
669
+ });
670
+ globalThis.test("a command the model does not mention keeps its declaration", () => {
671
+ globalThis.expect(edgeOf([edge("Book", undefined, ["Booked"], undefined)], "Rebook")).toEqual([
672
+ ["Booked"],
673
+ undefined,
674
+ "declared"
675
+ ]);
676
+ });
677
+ globalThis.test("an authored Unrestricted publishes no from-set, and says it meant to", () => {
678
+ globalThis.expect(edgeOf([], "Abandon")).toEqual([
679
+ undefined,
680
+ undefined,
681
+ "unrestricted"
682
+ ]);
683
+ });
684
+ globalThis.test("and the scenarios may not narrow it", () => {
685
+ globalThis.expect(edgeOf([edge("Abandon", undefined, ["Booked"], undefined)], "Abandon")).toEqual([
686
+ undefined,
687
+ undefined,
688
+ "unrestricted"
689
+ ]);
690
+ });
691
+ globalThis.test("a spec that wrote no switch is silence, which they may answer", () => {
692
+ let structure = Plugin_Structure$ReventlessCore.make("SilentPlugin", undefined, undefined, [], [PsPlaceOrderSlice], undefined, undefined, undefined, undefined, undefined, undefined, [{
693
+ component: "PlaceOrder",
694
+ command: "PlaceOrder",
695
+ allowedStates: ["Draft"],
696
+ targets: ["Placed"]
697
+ }]);
698
+ let slice = structure.stateChangeSlices[0];
699
+ globalThis.expect(Stdlib_Option.map(slice.commands.find(c => c.name === "PlaceOrder"), c => [
700
+ c.allowedStates,
701
+ c.targetState,
702
+ c.allowedStatesSource
703
+ ])).toEqual([
704
+ ["Draft"],
705
+ "Placed",
706
+ "derived"
707
+ ]);
708
+ });
709
+ let levelOf = (model, name) => {
710
+ let slice = build(model).stateChangeSlices[0];
711
+ return Stdlib_Option.map(slice.commands.find(c => c.name === name), c => c.level);
712
+ };
713
+ globalThis.test("the level comes from the corpus where it has one", () => {
714
+ globalThis.expect(levelOf([edge("Abandon", "Collection", undefined, undefined)], "Abandon")).toEqual("Collection");
715
+ });
716
+ globalThis.test("and from the name stem where it does not", () => {
717
+ globalThis.expect(levelOf([edge("Abandon", undefined, undefined, undefined)], "Abandon")).toEqual("Instance");
718
+ });
719
+ });
720
+
617
721
  globalThis.describe("traitDeclarations — a graft leaves a trace", () => {
618
722
  let structure = Plugin_Structure$ReventlessCore.make("TraitPlugin", undefined, undefined, [], [
619
723
  PsTransitionSwitchSlice,
620
724
  PsShipOrderSlice
621
- ], undefined, undefined, undefined, undefined, undefined, undefined);
725
+ ], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
622
726
  globalThis.test("a declaring component's trait reaches the structure", () => {
623
727
  globalThis.expect(Stdlib_Option.getOr(structure.traitDeclarations, []).map(d => [
624
728
  d.trait,
@@ -647,7 +751,7 @@ globalThis.describe("traitDeclarations — a graft leaves a trace", () => {
647
751
  globalThis.describe("declared-edge cross-check", () => {
648
752
  let buildBad = () => {
649
753
  try {
650
- Plugin_Structure$ReventlessCore.make("TransitionPlugin", undefined, undefined, [PsShipmentsViewSlice], [PsDispatchShipmentSlice], undefined, undefined, undefined, undefined, undefined, undefined);
754
+ Plugin_Structure$ReventlessCore.make("TransitionPlugin", undefined, undefined, [PsShipmentsViewSlice], [PsDispatchShipmentSlice], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
651
755
  return;
652
756
  } catch (raw_e) {
653
757
  let e = Primitive_exceptions.internalToException(raw_e);
@@ -1094,7 +1198,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1094
1198
  });
1095
1199
  });
1096
1200
  globalThis.describe("singleQueryField — the singular the schema actually serves", () => {
1097
- let withIrregularPlural = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, [PsCategoriesViewSlice], [], undefined, undefined, undefined, undefined, undefined, undefined);
1201
+ let withIrregularPlural = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, [PsCategoriesViewSlice], [], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
1098
1202
  let categories = withIrregularPlural.stateViewSlices[0];
1099
1203
  globalThis.test("an `-ies` plural publishes the `y` singular, not a truncation", () => {
1100
1204
  globalThis.expect([
@@ -1115,7 +1219,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1115
1219
  q.idField,
1116
1220
  q.idFieldSource
1117
1221
  ];
1118
- let withCategories = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, [PsCategoriesViewSlice], [], undefined, undefined, undefined, undefined, undefined, undefined);
1222
+ let withCategories = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, [PsCategoriesViewSlice], [], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
1119
1223
  globalThis.test("a name-matching `*Id` field is a guess the consumer can also make", () => {
1120
1224
  globalThis.expect(keyOf(structure.stateViewSlices[0])).toEqual([
1121
1225
  "orderId",
@@ -1499,7 +1603,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1499
1603
  "Orders",
1500
1604
  "Order"
1501
1605
  ]
1502
- ]));
1606
+ ]), undefined);
1503
1607
  globalThis.test("a chaptered state-change slice carries Some(chapter)", () => {
1504
1608
  let placeOrder = chaptered.stateChangeSlices[0];
1505
1609
  globalThis.expect(placeOrder.chapter).toEqual("Order");
@@ -1518,7 +1622,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1518
1622
  });
1519
1623
  });
1520
1624
  globalThis.describe("requiredStores", () => {
1521
- let withStores = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsAttachInvoiceSlice], undefined, undefined, undefined, undefined, undefined, undefined);
1625
+ let withStores = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsAttachInvoiceSlice], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
1522
1626
  let stores = Stdlib_Option.getOr(withStores.requiredStores, []);
1523
1627
  globalThis.test("an unqualified store resolves against the declaring plugin", () => {
1524
1628
  globalThis.expect(stores.includes("TestPlugin.documents")).toBe(true);
@@ -1674,7 +1778,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1674
1778
  });
1675
1779
  });
1676
1780
  globalThis.describe("uploadable types derive their store from the field name", () => {
1677
- let withUploads = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsUploadImagesSlice], undefined, undefined, undefined, undefined, undefined, undefined);
1781
+ let withUploads = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsUploadImagesSlice], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
1678
1782
  let stores = Stdlib_Option.getOr(withUploads.requiredStores, []);
1679
1783
  globalThis.test("the derived store is the field name, pluralised", () => {
1680
1784
  globalThis.expect(stores.includes("TestPlugin.productImages")).toBe(true);
@@ -1735,7 +1839,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1735
1839
  globalThis.describe("near-duplicate stores are refused", () => {
1736
1840
  let failureText = () => {
1737
1841
  try {
1738
- Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsTypoStoreSlice], undefined, undefined, undefined, undefined, undefined, undefined);
1842
+ Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsTypoStoreSlice], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
1739
1843
  return "";
1740
1844
  } catch (raw_e) {
1741
1845
  let e = Primitive_exceptions.internalToException(raw_e);
@@ -1764,7 +1868,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1764
1868
  ]);
1765
1869
  });
1766
1870
  globalThis.test("the four stores of the uploadable fixture do not collide", () => {
1767
- globalThis.expect(Capability_Inference$ReventlessCore.collisions(Stdlib_Option.getOr(Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsUploadImagesSlice], undefined, undefined, undefined, undefined, undefined, undefined).requiredStoreDeclarations, []))).toEqual([]);
1871
+ globalThis.expect(Capability_Inference$ReventlessCore.collisions(Stdlib_Option.getOr(Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsUploadImagesSlice], undefined, undefined, undefined, undefined, undefined, undefined, undefined).requiredStoreDeclarations, []))).toEqual([]);
1768
1872
  });
1769
1873
  globalThis.test("one edit, transpositions included; two edits not", () => {
1770
1874
  globalThis.expect([
@@ -1801,7 +1905,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1801
1905
  });
1802
1906
  });
1803
1907
  globalThis.describe("optional carriers keep their declarations", () => {
1804
- let withOptional = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsUploadAvatarSlice], undefined, undefined, undefined, undefined, undefined, undefined);
1908
+ let withOptional = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsUploadAvatarSlice], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
1805
1909
  globalThis.test("an optional storage ref still requires its store", () => {
1806
1910
  globalThis.expect(withOptional.requiredStores).toEqual(["TestPlugin.avatars"]);
1807
1911
  });
@@ -1852,7 +1956,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1852
1956
  globalThis.expect(eventWarnings).toEqual(expected);
1853
1957
  });
1854
1958
  globalThis.test("a heuristic-only match reaches no manifest entry", () => {
1855
- let withHeuristicField = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsChangePhotoSlice], undefined, undefined, undefined, undefined, undefined, undefined);
1959
+ let withHeuristicField = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsChangePhotoSlice], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
1856
1960
  globalThis.expect(withHeuristicField.requiredStores).toEqual(["TestPlugin.productPhotos"]);
1857
1961
  let manifest = CapabilityManifest$Reventless.fromStructure(withHeuristicField);
1858
1962
  globalThis.expect(manifest.capabilities.every(entry => entry.declaredBy.every(site => Primitive_object.notequal(site.field, "photoUrl")))).toBe(true);
@@ -1879,7 +1983,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1879
1983
  });
1880
1984
  });
1881
1985
  globalThis.describe("plural references reach the manifest", () => {
1882
- let withPluralRefs = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsReserveStockSlice], undefined, undefined, undefined, undefined, undefined, undefined);
1986
+ let withPluralRefs = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [PsReserveStockSlice], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
1883
1987
  let slice = withPluralRefs.stateChangeSlices[0];
1884
1988
  let referencesOf = refs => refs.map(param => [
1885
1989
  param.fieldName,
@@ -1899,9 +2003,25 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1899
2003
  [
1900
2004
  "warehouseIds",
1901
2005
  "Warehouses"
2006
+ ],
2007
+ [
2008
+ "lineItems[].productId",
2009
+ "AvailableProducts"
1902
2010
  ]
1903
2011
  ]);
1904
2012
  });
2013
+ globalThis.test("a reference on a nested record's field is named by its path", () => {
2014
+ let cmd = slice.commands[0];
2015
+ globalThis.expect(cmd.references.find(param => param.fieldName === "lineItems[].productId")).toEqual({
2016
+ fieldName: "lineItems[].productId",
2017
+ entity: "AvailableProducts",
2018
+ plugin: undefined
2019
+ });
2020
+ });
2021
+ globalThis.test("a flat reference in the same variant keeps its bare name", () => {
2022
+ let cmd = slice.commands[0];
2023
+ globalThis.expect(cmd.references.some(param => param.fieldName === "customerId")).toBe(true);
2024
+ });
1905
2025
  globalThis.test("an optional array ref is collected under the field's own name", () => {
1906
2026
  let cmd = slice.commands[0];
1907
2027
  globalThis.expect(cmd.references.find(param => param.fieldName === "warehouseIds")).toEqual({
@@ -1917,6 +2037,11 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1917
2037
  "AvailableProducts"
1918
2038
  ]]);
1919
2039
  });
2040
+ globalThis.test("a nested input object publishes the type name the SDL declares", () => {
2041
+ let cmd = slice.commands[0];
2042
+ let itemName = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(JSON.parse(cmd.schema), "lineItems"), s => getProperty(s, "items")), s => getProperty(s, "x-reventless-graphql-input")), Stdlib_JSON.Decode.string);
2043
+ globalThis.expect(itemName).toEqual(cmd.mutationField + "LineItems");
2044
+ });
1920
2045
  globalThis.test("the declared entity wins over the one the field name suggests", () => {
1921
2046
  let cmd = slice.commands[0];
1922
2047
  globalThis.expect(Stdlib_Option.map(cmd.references.find(param => param.fieldName === "productIds"), param => param.entity)).toEqual("AvailableProducts");
@@ -2058,7 +2183,7 @@ globalThis.describe("duplicate DCB command names", () => {
2058
2183
  Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [
2059
2184
  PsShipOrderSlice,
2060
2185
  PsShipOrderSlice
2061
- ], undefined, undefined, undefined, undefined, undefined, undefined);
2186
+ ], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
2062
2187
  tmp = false;
2063
2188
  } catch (exn) {
2064
2189
  tmp = true;
@@ -2071,7 +2196,7 @@ globalThis.describe("duplicate DCB command names", () => {
2071
2196
  val = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [
2072
2197
  PsShipOrderSlice,
2073
2198
  PsShipOrderSlice
2074
- ], undefined, undefined, undefined, undefined, undefined, undefined);
2199
+ ], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
2075
2200
  } catch (raw_e) {
2076
2201
  let e = Primitive_exceptions.internalToException(raw_e);
2077
2202
  if (e.RE_EXN_ID === "JsExn") {
@@ -2095,7 +2220,7 @@ globalThis.describe("duplicate DCB command names", () => {
2095
2220
  Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, undefined, [
2096
2221
  PsPlaceOrderSlice,
2097
2222
  PsShipOrderSlice
2098
- ], undefined, undefined, undefined, undefined, undefined, undefined);
2223
+ ], undefined, undefined, undefined, undefined, undefined, undefined, undefined);
2099
2224
  tmp = true;
2100
2225
  } catch (exn) {
2101
2226
  tmp = false;
@@ -37,7 +37,7 @@ function commandAuthorization(param) {
37
37
  }
38
38
 
39
39
  function commandTransition(param) {
40
- return "Unrestricted";
40
+ return "Undeclared";
41
41
  }
42
42
 
43
43
  let traits = [];
@@ -38,7 +38,7 @@ function commandAuthorization(param) {
38
38
  }
39
39
 
40
40
  function commandTransition(param) {
41
- return "Unrestricted";
41
+ return "Undeclared";
42
42
  }
43
43
 
44
44
  let traits = [];
@@ -58,7 +58,7 @@ function commandAuthorization(command) {
58
58
  }
59
59
 
60
60
  function commandTransition(param) {
61
- return "Unrestricted";
61
+ return "Undeclared";
62
62
  }
63
63
 
64
64
  let traits = [];
@@ -36,7 +36,7 @@ function commandAuthorization(param) {
36
36
  }
37
37
 
38
38
  function commandTransition(param) {
39
- return "Unrestricted";
39
+ return "Undeclared";
40
40
  }
41
41
 
42
42
  let traits = [];
@@ -1,4 +1,5 @@
1
- // Test fixture spec where every reference carrier is PLURAL.
1
+ // Test fixture spec where every reference carrier is one the naive walk misses:
2
+ // PLURAL, or declared on a field of a record the command holds.
2
3
  //
3
4
  // `Reference.to_` returns an *element* schema, so the ppx annotates the `string`
4
5
  // inside `array<string>` and the field's own schema carries no marker. A walk
@@ -11,6 +12,11 @@
11
12
  // shows up as one of the two disappearing rather than as an empty list, and
12
13
  // `warehouseIds?` covers the plural-inside-optional shape, where the array sits
13
14
  // one wrapper further down still.
15
+ //
16
+ // `lineItems` covers the other shape the walk has to descend into: a reference
17
+ // declared on a field of a record the command holds, which belongs to that field
18
+ // and is named by its path. A named record type, not an inline one — the ppx
19
+ // walks a type declaration, which is what puts the marker on `productId` at all.
14
20
 
15
21
  @@reventless.spec("ReserveStock")
16
22
 
@@ -22,6 +28,12 @@ type consumedEvent = StockReserved({reservationId: string})
22
28
 
23
29
  let evolve = (_state, _event) => true
24
30
 
31
+ @schema
32
+ type lineItem = {
33
+ @ref("AvailableProducts") productId: string,
34
+ quantity: int,
35
+ }
36
+
25
37
  @schema
26
38
  type command =
27
39
  | ReserveStock({
@@ -29,6 +41,7 @@ type command =
29
41
  @ref("Customers") customerId: string,
30
42
  @ref("AvailableProducts") productIds: array<string>,
31
43
  @ref("Warehouses") warehouseIds?: array<string>,
44
+ lineItems: array<lineItem>,
32
45
  })
33
46
 
34
47
  @schema
@@ -13,12 +13,18 @@ function evolve(_state, _event) {
13
13
  return true;
14
14
  }
15
15
 
16
+ let lineItemSchema = Sury.$schema(s => ({
17
+ productId: s.m(Reference$Reventless.to_(undefined, undefined, "AvailableProducts")),
18
+ quantity: s.m(Sury.int)
19
+ }));
20
+
16
21
  let commandSchema = Sury.$schema(s => ({
17
22
  TAG: "ReserveStock",
18
23
  reservationId: s.m(DcbTag$Reventless.partition),
19
24
  customerId: s.m(Reference$Reventless.to_(undefined, undefined, "Customers")),
20
25
  productIds: s.m(Sury.array(Reference$Reventless.to_(undefined, "productId", "AvailableProducts"))),
21
- warehouseIds: s.m(Sury.$option(Sury.array(Reference$Reventless.to_(undefined, "warehouseId", "Warehouses"))))
26
+ warehouseIds: s.m(Sury.$option(Sury.array(Reference$Reventless.to_(undefined, "warehouseId", "Warehouses")))),
27
+ lineItems: s.m(Sury.array(lineItemSchema))
22
28
  }));
23
29
 
24
30
  let errorSchema = Sury.literal("OutOfStock");
@@ -41,7 +47,7 @@ function commandAuthorization(param) {
41
47
  }
42
48
 
43
49
  function commandTransition(param) {
44
- return "Unrestricted";
50
+ return "Undeclared";
45
51
  }
46
52
 
47
53
  let traits = [];
@@ -62,6 +68,7 @@ export {
62
68
  initialState,
63
69
  consumedEventSchema,
64
70
  evolve,
71
+ lineItemSchema,
65
72
  commandSchema,
66
73
  errorSchema,
67
74
  eventSchema,
@@ -36,7 +36,7 @@ function commandAuthorization(param) {
36
36
  }
37
37
 
38
38
  function commandTransition(param) {
39
- return "Unrestricted";
39
+ return "Undeclared";
40
40
  }
41
41
 
42
42
  let traits = [];
@@ -38,7 +38,7 @@ function commandAuthorization(param) {
38
38
  }
39
39
 
40
40
  function commandTransition(param) {
41
- return "Unrestricted";
41
+ return "Undeclared";
42
42
  }
43
43
 
44
44
  let traits = [];