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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +7 -7
  3. package/src/Message.res +1 -1
  4. package/src/adapter/Monitoring/Monitoring.res +1 -1
  5. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res +1 -1
  6. package/src/components/Aggregate/Aggregate_Callback.res +2 -2
  7. package/src/components/Api/ApiAllowedStatesHelpers.res +2 -3
  8. package/src/components/Api/ApiTargetStateHelpers.res +4 -3
  9. package/src/components/Dcb/Dcb_Builder.res +79 -16
  10. package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
  11. package/src/components/EventLog/EventLog.res +1 -1
  12. package/src/plugin/component/Plugin_Structure.res +269 -7
  13. package/src/plugin/component/Plugin_Structure.res.mjs +128 -1
  14. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
  15. package/tests/aggregate/AggregateCacheTest.res +1 -1
  16. package/tests/aggregate/AggregateSnapshotTest.res +1 -1
  17. package/tests/commandgenerator/OwnerStampingTest.res +57 -0
  18. package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
  19. package/tests/message/MessageTest.res +1 -1
  20. package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
  21. package/tests/plugin/PluginStructureTest.res +164 -3
  22. package/tests/plugin/PluginStructureTest.res.mjs +170 -1
  23. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
  24. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
  25. package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
  26. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
  27. package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
  28. package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
  29. package/tests/util/CsvStreamTest.res +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.238 (2026-08-16)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core:** stamp the owner from each DCB slice's own command schema ([6edbdf4](https://github.com/ReventlessDev/reventless-core/commit/6edbdf4680f2535597c3b560215aa514ceed27eb))
11
+ ### Features
12
+
13
+ * **core:** check a declared lifecycle edge against the states that exist ([d427d92](https://github.com/ReventlessDev/reventless-core/commit/d427d92f8912b4dc9a9407c3c0cec3200d41a5f1))
14
+ * **core:** report a lifecycle state no command can reach ([eb24dbd](https://github.com/ReventlessDev/reventless-core/commit/eb24dbd90df07185f0f4785c4bb726f5b617e7b2))
15
+ * **ppx:** declare a command's lifecycle edge once, as [@transition](https://github.com/transition) ([dd35130](https://github.com/ReventlessDev/reventless-core/commit/dd3513014f14b71879ee21263c6755d3c3d95096))
16
+
17
+
6
18
  # 3.0.0-alpha.237 (2026-08-16)
7
19
 
8
20
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.237",
3
+ "version": "3.0.0-alpha.238",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,21 +29,21 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
33
- "@reventlessdev/rescript-node": "2.0.0-alpha.7",
34
32
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.7",
33
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
35
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
36
- "@reventlessdev/rescript-ssh2": "2.0.0-alpha.7",
35
+ "@reventlessdev/rescript-node": "2.0.0-alpha.7",
37
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
37
+ "@reventlessdev/rescript-ssh2": "2.0.0-alpha.7",
38
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.143",
40
39
  "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.115"
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.116",
41
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.144"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-alpha.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.67"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.68"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "12.3.0"
package/src/Message.res CHANGED
@@ -69,7 +69,7 @@ let toCommandSchema' = (idSchema, commandSchema) =>
69
69
  // before a nested `@schema` field existed still decodes instead of bricking the
70
70
  // aggregate's log replay. These two entry points thread the memoized envelope schema;
71
71
  // `Reventless.Message.decode` (the other central decoder) is tolerant at the source.
72
- // See docs/plans/platform-infrastructure-in-plugin-list.md (durable fix option 2).
72
+ // See docs/plans/done/platform-infrastructure-in-plugin-list.md (durable fix option 2).
73
73
  let decodeEvent' = (json, idSchema, eventSchema) =>
74
74
  json->parseJsonTolerant(toEventSchema'(idSchema, eventSchema))
75
75
  let decodeCommand' = (json, idSchema, commandSchema) =>
@@ -10,7 +10,7 @@ concern; this only exposes the choke point through which the framework says
10
10
  "I just provisioned an execution unit" and leaves what to do about it to the
11
11
  listener.
12
12
 
13
- See `docs/plans/monitoring-hook-seam.md`.
13
+ See `docs/plans/done/monitoring-hook-seam.md`.
14
14
  */
15
15
 
16
16
  /**
@@ -1,6 +1,6 @@
1
1
  // UiFragmentRegistry StateChangeSlice — the write side of the platform UI-fragment
2
2
  // registry, extracted off the Plugin aggregate (see
3
- // docs/plans/event-sourced-fragment-registries.md).
3
+ // docs/plans/done/event-sourced-fragment-registries.md).
4
4
  //
5
5
  // Commands are driven by the plugin runtime's connect handshake and routed here by the
6
6
  // admin PluginExtensionPoint's UI-fragment mapping (RegisterUiFragment on ConnectPlugin,
@@ -114,7 +114,7 @@ module Make = (
114
114
  // at append time → the conflict branch invalidates the entry and the retry
115
115
  // replays cold. Mirrors the StateChangeSlice decision-model cache, including
116
116
  // the fixed capacity (a per-aggregate knob is a future refinement — see
117
- // docs/plans/aggregate-snapshotting.md).
117
+ // docs/plans/done/aggregate-snapshotting.md).
118
118
  let replayCacheCapacity = 100
119
119
  let replayCache: Lru.t<string, (Behavior.state, int)> = Lru.make(
120
120
  ~capacity=replayCacheCapacity,
@@ -122,7 +122,7 @@ module Make = (
122
122
 
123
123
  let resetCache = () => replayCache->Lru.clear
124
124
 
125
- // Persisted-snapshot configuration (docs/plans/aggregate-snapshotting.md).
125
+ // Persisted-snapshot configuration (docs/plans/done/aggregate-snapshotting.md).
126
126
  // `None` (the default) keeps full replay; `Some({interval, stateSchema})`
127
127
  // seeds cold replays from the latest persisted snapshot and writes a fresh
128
128
  // one every `interval` events. Snapshots are a read optimization only — the
@@ -1,5 +1,5 @@
1
1
  // Read per-variant `allowedStates` metadata that the reventless-ppx
2
- // `@allowedStates([…])` attribute attaches to a command schema. Consumed by
2
+ // an `@transition` from-set attaches to a command schema. Consumed by
3
3
  // `Plugin_Structure.toCommandDef` when building the `commandDef` records that
4
4
  // land in `Platform_ComponentDefinitions` so AutoUI can filter the per-row command
5
5
  // menu by the row's status.
@@ -7,8 +7,7 @@
7
7
  open ReventlessInfra.Api
8
8
 
9
9
  /** Returns the allowed-state list for a given command-variant name, or None
10
- when the variant has no `@allowedStates` annotation (back-compat default
11
- — the command shows on every row). */
10
+ when the variant declares no `@transition` (the command shows on every row). */
12
11
  let getAllowedStates = (
13
12
  commandSchema: S.t<unknown>,
14
13
  ~variantName: string,
@@ -1,5 +1,5 @@
1
1
  // Read per-variant `targetState` metadata that the reventless-ppx
2
- // `@targetState("…")` attribute attaches to a command schema. Consumed by
2
+ // an `@transition` target attaches to a command schema. Consumed by
3
3
  // `Plugin_Structure.toCommandDef` when building the `commandDef` records that
4
4
  // land in `Platform_ComponentDefinitions`, so AutoUI's board drag resolver can
5
5
  // move a row by a *declared* transition instead of a name-stem guess. Mirrors
@@ -8,8 +8,9 @@
8
8
  open ReventlessInfra.Api
9
9
 
10
10
  /** Returns the declared target state for a given command-variant name, or None
11
- when the variant has no `@targetState` annotation (back-compat default —
12
- the resolver falls back to its name-stem heuristic). */
11
+ when the variant declares no target — either because it carries no
12
+ `@transition` at all, or because it declares a from-set only, which is a
13
+ command saying it does not move the row. */
13
14
  let getTargetState = (
14
15
  commandSchema: S.t<unknown>,
15
16
  ~variantName: string,
@@ -312,7 +312,7 @@ module Make = (
312
312
  // sweeps up sibling products. Cross-partition fan-out and capacity reads are
313
313
  // preserved (generalised rule 3). Safety guard: if any slice's partition is
314
314
  // ambiguous we keep the annotated values for the whole boundary rather than
315
- // thread a partial inference. See docs/plans/dcb-tag-scope-inference.md.
315
+ // thread a partial inference. See docs/plans/done/dcb-tag-scope-inference.md.
316
316
  let inferenceShapes =
317
317
  stateChangeSlices->Array.map((module(Sc: StateChangeSlice.T)) =>
318
318
  Reventless.DcbTag.sliceShapeFromSchemas(
@@ -761,28 +761,80 @@ module Make = (
761
761
  // + AppSync direct invocations (CommandGenerator.payload format)
762
762
  let dcbHandlerBase = DcbCommandTopic.makeFilteringHandler(dcbCommandTopic)
763
763
 
764
- // Shared generateCommand for AppSync direct invocations — all sync StateChangeSlices
765
- // share the same DCB CommandTopic's publishJsons, so a single function suffices.
766
- // commandSchema validation is skipped (permissive JSON.t schema) because AppSync
767
- // already validates input against the SDL.
764
+ // generateCommand for AppSync direct invocations, one per command constructor.
765
+ //
766
+ // Every one of them shares this CommandTopic's publishJsons — the reason a
767
+ // single function used to suffice — and they differ in exactly one argument:
768
+ // the schema. That argument is not only a validator. `makeGenerateCommand`
769
+ // also reads it to find the fields a command marks as its owner, and stamps
770
+ // them with the caller. Handing it the permissive `S.json` (as this did, on
771
+ // the still-true grounds that AppSync has already validated against the SDL)
772
+ // makes that lookup answer "no owner fields" for every command there is — so
773
+ // the write publishes whatever owner the CLIENT sent, silently, and the row
774
+ // is wrong from then on.
775
+ //
776
+ // Per-slice decoding still happens inside `buildSliceHandler`; nothing here
777
+ // re-validates. The schema travels for what it says about ownership.
778
+ let dcbCommandSchemasByTag = {
779
+ let byTag = Dict.make()
780
+ stateChangeSlices->Array.forEach((module(S: StateChangeSlice.T)) => {
781
+ let commandSchema = S.Spec.commandSchema->Reventless.DcbTag.toUnknownSchema
782
+ if !(ApiNoApiHelpers.isNoApi(commandSchema)) {
783
+ Api_Naming.sliceMutationFields(
784
+ ~plugin=apiNamePrefix,
785
+ ~slice=S.Spec.name,
786
+ ~commandSchema,
787
+ )->Array.forEach(((_, tag)) => {
788
+ // The resolver injects the TAG alone, so two slices claiming one
789
+ // constructor name would make the stamp ambiguous — and the wrong
790
+ // arm of that ambiguity writes a row owned by nobody's decision.
791
+ // Multi-command slices cannot reach here (their mutation fields
792
+ // collide first), which leaves single-command slices, where the
793
+ // field names differ and nothing else would notice.
794
+ switch byTag->Dict.get(tag) {
795
+ | Some(_) =>
796
+ log.error(
797
+ ~comp="Dcb_Builder",
798
+ `Two DCB slices in "${name}" both declare the command "${tag}". ` ++
799
+ "Owner stamping resolves a command by that name alone, so it cannot " ++
800
+ "tell them apart. Rename one of the constructors.",
801
+ )
802
+ | None => byTag->Dict.set(tag, commandSchema)
803
+ }
804
+ })
805
+ }
806
+ })
807
+ byTag
808
+ }
809
+
768
810
  let dcbGenerateCommandOutput =
769
811
  dcbCommandTopic
770
812
  ->Component.operations
771
- ->Pulumi.Output.apply(ops =>
772
- CommandGenerator_Callback.makeGenerateCommand(
773
- ~publishJsons=ops.publishJsons,
774
- ~publishJsonsAndWait=?ops.publishJsonsAndWait,
775
- ~serviceName=name,
776
- ~commandSchema=S.json->S.castToUnknown,
777
- ~componentKind=CommandGenerator_Callback.StateChangeSlice,
778
- ~stripIdFromParams=false,
779
- )
780
- )
813
+ ->Pulumi.Output.apply(ops => {
814
+ let make = commandSchema =>
815
+ CommandGenerator_Callback.makeGenerateCommand(
816
+ ~publishJsons=ops.publishJsons,
817
+ ~publishJsonsAndWait=?ops.publishJsonsAndWait,
818
+ ~serviceName=name,
819
+ ~commandSchema,
820
+ ~componentKind=CommandGenerator_Callback.StateChangeSlice,
821
+ ~stripIdFromParams=false,
822
+ )
823
+ let byTag = Dict.make()
824
+ dcbCommandSchemasByTag
825
+ ->Dict.toArray
826
+ ->Array.forEach(((tag, schema)) => byTag->Dict.set(tag, make(schema)))
827
+ // A command no slice claims keeps the permissive generator — what every
828
+ // command got before this change — so nothing that reaches this handler
829
+ // by another route starts failing. It also cannot stamp, which is why
830
+ // the unknown case is logged rather than left to look like a success.
831
+ (byTag, make(S.json->S.castToUnknown))
832
+ })
781
833
 
782
834
  let dcbHandler =
783
835
  (dcbHandlerBase, inboundReceiversOutput, dcbGenerateCommandOutput)
784
836
  ->Pulumi.Output.all3
785
- ->Pulumi.Output.apply(((baseHandler, receivers, generateCommand)) => {
837
+ ->Pulumi.Output.apply(((baseHandler, receivers, (generatorsByTag, generateFallback))) => {
786
838
  let composite = (event, ctx) => {
787
839
  let raw: dict<JSON.t> = event->Obj.magic
788
840
  switch raw->Dict.get("__inboundTranslation") {
@@ -810,6 +862,17 @@ module Make = (
810
862
  switch (raw->Dict.get("command"), raw->Dict.get("arguments")) {
811
863
  | (Some(JSON.String(_)), Some(_)) =>
812
864
  let payload: CommandGenerator.payload = event->Obj.magic
865
+ let generateCommand = switch generatorsByTag->Dict.get(payload.command) {
866
+ | Some(g) => g
867
+ | None =>
868
+ log.warn(
869
+ ~comp="Dcb_Builder",
870
+ `No DCB slice in "${name}" declares the command "${payload.command}", ` ++
871
+ "so it is dispatched without an owner stamp. Any field the command " ++
872
+ "marks as its owner keeps the value the caller sent.",
873
+ )
874
+ generateFallback
875
+ }
813
876
  (generateCommand(payload)->Effect.map(msgId => msgId->Obj.magic))->Obj.magic
814
877
  | _ => baseHandler(event, ctx)
815
878
  }
@@ -422,13 +422,42 @@ function Make(DcbEventLogStorage) {
422
422
  ]);
423
423
  })).apply(pairs => Object.fromEntries(pairs));
424
424
  let dcbHandlerBase = DcbCommandTopic.makeFilteringHandler(dcbCommandTopic);
425
- let dcbGenerateCommandOutput = Component$ReventlessCore.operations(dcbCommandTopic).apply(ops => CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(ops.publishJsons, ops.publishJsonsAndWait, name, S.json, "StateChangeSlice", false));
425
+ let byTag = {};
426
+ stateChangeSlices.forEach(S => {
427
+ let commandSchema = S.Spec.commandSchema;
428
+ if (!ApiNoApiHelpers$ReventlessCore.isNoApi(commandSchema)) {
429
+ Api_Naming$ReventlessCore.sliceMutationFields(apiNamePrefix, S.Spec.name, commandSchema).forEach(param => {
430
+ let tag = param[1];
431
+ let match = byTag[tag];
432
+ if (match !== undefined) {
433
+ return log.error("Dcb_Builder", undefined, `Two DCB slices in "` + name + `" both declare the command "` + tag + `". ` + "Owner stamping resolves a command by that name alone, so it cannot tell them apart. Rename one of the constructors.");
434
+ } else {
435
+ byTag[tag] = commandSchema;
436
+ return;
437
+ }
438
+ });
439
+ return;
440
+ }
441
+ });
442
+ let dcbGenerateCommandOutput = Component$ReventlessCore.operations(dcbCommandTopic).apply(ops => {
443
+ let make = commandSchema => CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(ops.publishJsons, ops.publishJsonsAndWait, name, commandSchema, "StateChangeSlice", false);
444
+ let byTag$1 = {};
445
+ Object.entries(byTag).forEach(param => {
446
+ byTag$1[param[0]] = make(param[1]);
447
+ });
448
+ return [
449
+ byTag$1,
450
+ make(S.json)
451
+ ];
452
+ });
426
453
  let dcbHandler = Pulumi.all([
427
454
  dcbHandlerBase,
428
455
  inboundReceiversOutput,
429
456
  dcbGenerateCommandOutput
430
457
  ]).apply(param => {
431
- let generateCommand = param[2];
458
+ let match = param[2];
459
+ let generateFallback = match[1];
460
+ let generatorsByTag = match[0];
432
461
  let receivers = param[1];
433
462
  let baseHandler = param[0];
434
463
  return (event, ctx) => {
@@ -448,11 +477,18 @@ function Make(DcbEventLogStorage) {
448
477
  }
449
478
  let match$1 = event["command"];
450
479
  let match$2 = event["arguments"];
451
- if (typeof match$1 === "string" && match$2 !== undefined) {
452
- return Effect.map(generateCommand(event), msgId => msgId);
453
- } else {
480
+ if (match$1 === undefined) {
481
+ return baseHandler(event, ctx);
482
+ }
483
+ if (typeof match$1 !== "string") {
484
+ return baseHandler(event, ctx);
485
+ }
486
+ if (match$2 === undefined) {
454
487
  return baseHandler(event, ctx);
455
488
  }
489
+ let g = generatorsByTag[event.command];
490
+ let generateCommand = g !== undefined ? g : (log.warn("Dcb_Builder", undefined, `No DCB slice in "` + name + `" declares the command "` + event.command + `", ` + "so it is dispatched without an owner stamp. Any field the command marks as its owner keeps the value the caller sent."), generateFallback);
491
+ return Effect.map(generateCommand(event), msgId => msgId);
456
492
  };
457
493
  });
458
494
  let dcbResources = Object.values(stateChangeSlicesOutputs).flatMap(outputs => outputs.resources).concat(Object.values(inboundTranslationSlicesOutputs).flatMap(outputs => outputs.resources.concat(outputs.queryDb.resources)));
@@ -25,7 +25,7 @@ type appendError =
25
25
  // staleness — consumers ignore a snapshot whose hash differs from their current
26
26
  // state schema and fall back to full replay. Snapshots are a read optimization
27
27
  // only; the OCC append remains the sole consistency primitive
28
- // (docs/plans/aggregate-snapshotting.md).
28
+ // (docs/plans/done/aggregate-snapshotting.md).
29
29
  type snapshot = {seqNr: int, state: JSON.t, schemaHash: string}
30
30
 
31
31
  type append<'id, 'event> = (int, 'id, array<'event>) => promise<result<unit, appendError>>