@reventlessdev/reventless-core 3.0.0-alpha.266 → 3.0.0-alpha.268

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 (31) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +5 -5
  3. package/src/Projection.res +0 -10
  4. package/src/Projection.res.mjs +0 -14
  5. package/src/ProjectionMapper.res +8 -2
  6. package/src/ProjectionMapper.res.mjs +3 -3
  7. package/src/admin/Platform_Admin_Structure.res.mjs +1 -1
  8. package/src/admin/UiFragmentRegistry/StateView/UiFragments.res.mjs +3 -0
  9. package/src/components/EventMapper/EventMapper_Builder.res +19 -1
  10. package/src/components/EventMapper/EventMapper_Builder.res.mjs +10 -1
  11. package/src/components/StateViewSlice/StateViewSlice_Builder.res +11 -1
  12. package/src/components/StateViewSlice/StateViewSlice_Builder.res.mjs +2 -1
  13. package/src/components/StateViewSlice/StateViewSlice_Callback.res +8 -1
  14. package/src/components/StateViewSlice/StateViewSlice_Callback.res.mjs +2 -1
  15. package/src/plugin/component/Plugin_Structure.res +139 -15
  16. package/src/plugin/component/Plugin_Structure.res.mjs +110 -25
  17. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +0 -1
  18. package/tests/dcb/DcbStateChangeSliceTest.res.mjs +0 -3
  19. package/tests/eventmapper/EventMapperFixtures.res +2 -2
  20. package/tests/eventmapper/EventMapperFixtures.res.mjs +14 -2
  21. package/tests/plugin/IdentityReferenceTest.res +118 -0
  22. package/tests/plugin/IdentityReferenceTest.res.mjs +204 -0
  23. package/tests/plugin/PluginStructureAccessTest.res.mjs +14 -1
  24. package/tests/plugin/PluginStructureTest.res.mjs +42 -10
  25. package/tests/plugin/StateView/PsAnnotatedView.res.mjs +3 -0
  26. package/tests/plugin/StateView/PsAvailableProductsView.res.mjs +3 -0
  27. package/tests/plugin/StateView/PsCategoriesView.res.mjs +3 -0
  28. package/tests/plugin/StateView/PsCustomersView.res.mjs +3 -0
  29. package/tests/plugin/StateView/PsGatedView.res.mjs +3 -0
  30. package/tests/plugin/StateView/PsOrdersView.res.mjs +3 -0
  31. package/tests/plugin/StateView/PsShipmentsView.res.mjs +3 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,32 @@
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.268 (2026-09-21)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core:** a creating command's own id derives no reference ([58a921c](https://github.com/ReventlessDev/reventless-core/commit/58a921c84352f6522e7c175387927e32365aa94a))
11
+
12
+
13
+ # 3.0.0-alpha.267 (2026-09-21)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **core:** an event mapping that commands its own source no longer hangs ([d30ce6b](https://github.com/ReventlessDev/reventless-core/commit/d30ce6b360707d933732fa2c90aba49c03dda006))
18
+ * feat(spec)!: typed projection keys ([fc6a018](https://github.com/ReventlessDev/reventless-core/commit/fc6a01811ba788f3289e84a435a09f77fa627e1a))
19
+ ### Features
20
+
21
+ * **core:** references derived from identities, and identity checks ([7cb1376](https://github.com/ReventlessDev/reventless-core/commit/7cb1376e56cdb4e4d5ab5954cd97fd37fd3ba258))
22
+
23
+ ### BREAKING CHANGES
24
+
25
+ * a projection that keys a row by a payload `string`, or uses
26
+ the envelope id as a `string`, converts explicitly with
27
+ `Target.Id.makeFromString` / `Source.Id.toString`. `StateChangeSlice.Spec` no
28
+ longer requires `module Id`.
29
+
30
+
31
+
6
32
  # 3.0.0-alpha.266 (2026-09-21)
7
33
 
8
34
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.266",
3
+ "version": "3.0.0-alpha.268",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -31,19 +31,19 @@
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.33",
32
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
33
33
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.12",
34
- "@reventlessdev/rescript-node": "2.0.0-alpha.12",
35
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
35
+ "@reventlessdev/rescript-node": "2.0.0-alpha.12",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.21",
37
37
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.12",
38
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
39
39
  "@reventlessdev/reventless-interop": "3.0.0-alpha.37",
40
- "@reventlessdev/reventless-infra": "3.0.0-alpha.167",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.139"
40
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.168",
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.140"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-rc.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.80"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.81"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "12.3.0"
@@ -6,16 +6,6 @@ module Set = Belt.Set.String
6
6
 
7
7
  let log = Logger.fromEnv()
8
8
 
9
- module Mapping = {
10
- module MakeGenericSource = (Mapping: Reventless.Projection.Mapping): (
11
- Mapper.GenericSource with type t = Mapping.sourceEvent
12
- ) => {
13
- let name = Mapping.sourceName
14
- type t = Mapping.sourceEvent
15
- let decode' = json => json->Message.decodeEvent'(S.string, Mapping.sourceEventSchema)
16
- }
17
- }
18
-
19
9
  // Lazy: the message thunk (and any `stateToString` it triggers) runs only when
20
10
  // debug logging is enabled, so projection actions don't serialize state on the
21
11
  // hot path at the default Info level.
@@ -1,6 +1,5 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
- import * as Sury from "sury";
4
3
  import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
5
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
6
5
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
@@ -22,18 +21,6 @@ import * as LogFormat$ReventlessCore from "./util/LogFormat.res.mjs";
22
21
 
23
22
  let log = Logger$ReventlessCore.fromEnv();
24
23
 
25
- function MakeGenericSource(Mapping) {
26
- let decode$p = json => Message$ReventlessCore.decodeEvent$p(json, Sury.string, Mapping.sourceEventSchema);
27
- return {
28
- name: Mapping.sourceName,
29
- decode$p: decode$p
30
- };
31
- }
32
-
33
- let Mapping = {
34
- MakeGenericSource: MakeGenericSource
35
- };
36
-
37
24
  function logAction(makeStr) {
38
25
  log.debugLazy("Projection", makeStr);
39
26
  }
@@ -786,7 +773,6 @@ let $$Set;
786
773
  export {
787
774
  $$Set,
788
775
  log,
789
- Mapping,
790
776
  logAction,
791
777
  overlaysFor,
792
778
  applyOverlays,
@@ -34,8 +34,14 @@ module Make = (
34
34
  module GenericMapping = {
35
35
  let sourceName = M.sourceName
36
36
  let acceptedTags = Reventless.DcbTag.extractAllVariantNames(M.sourceEventSchema)
37
- module Source = Projection.Mapping.MakeGenericSource(M)
38
- let project = MapperNto1.makeGenericMap(Source.decode', M.project)
37
+ // The storage edge: the envelope and the table carry strings, the
38
+ // projection typed ids — the same strings, decoded and encoded here.
39
+ let decode = json => json->Message.decodeEvent'(M.SourceId.schema, M.sourceEventSchema)
40
+ let project = MapperNto1.makeGenericMap(decode, msg =>
41
+ msg
42
+ ->M.project
43
+ ->Reventless.Projection.mapActionId(~to_=M.targetIdToString, ~from=M.targetIdFromString)
44
+ )
39
45
  }
40
46
  module(GenericMapping: Mapping)
41
47
  })
@@ -1,9 +1,9 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
4
+ import * as Projection$Reventless from "@reventlessdev/reventless-spec/src/types/Projection.res.mjs";
4
5
  import * as Message$ReventlessCore from "./Message.res.mjs";
5
6
  import * as MapperNto1$ReventlessCore from "./MapperNto1.res.mjs";
6
- import * as Projection$ReventlessCore from "./Projection.res.mjs";
7
7
 
8
8
  function MakeGenericTargetFromStateTarget(StateTarget) {
9
9
  let decode = json => Message$ReventlessCore.decode(json, StateTarget.stateSchema);
@@ -27,8 +27,8 @@ function Make(Target) {
27
27
  };
28
28
  let mappings = Mappings.mappings.map(M => {
29
29
  let acceptedTags = DcbTag$Reventless.extractAllVariantNames(M.sourceEventSchema);
30
- let Source = Projection$ReventlessCore.Mapping.MakeGenericSource(M);
31
- let project = MapperNto1$ReventlessCore.makeGenericMap(Source.decode$p, M.project);
30
+ let decode = json => Message$ReventlessCore.decodeEvent$p(json, M.SourceId.schema, M.sourceEventSchema);
31
+ let project = MapperNto1$ReventlessCore.makeGenericMap(decode, msg => Projection$Reventless.mapActionId(M.project(msg), M.targetIdToString, M.targetIdFromString));
32
32
  return {
33
33
  sourceName: M.sourceName,
34
34
  acceptedTags: acceptedTags,
@@ -7,7 +7,7 @@ import * as PluginsReadModelSpec$ReventlessCore from "../plugin/lifecycle/Plugin
7
7
 
8
8
  let pluginId = "Platform";
9
9
 
10
- let pluginCommands = Plugin_Structure$ReventlessCore.extractCommandDefs(true, undefined, variantName => Api_Naming$ReventlessCore.adminField(PluginSpec$ReventlessCore.name + "_" + variantName), PluginSpec$ReventlessCore.commandAuthorization, PluginSpec$ReventlessCore.commandTransition, undefined, PluginSpec$ReventlessCore.commandSchema);
10
+ let pluginCommands = Plugin_Structure$ReventlessCore.extractCommandDefs(undefined, true, undefined, variantName => Api_Naming$ReventlessCore.adminField(PluginSpec$ReventlessCore.name + "_" + variantName), PluginSpec$ReventlessCore.commandAuthorization, PluginSpec$ReventlessCore.commandTransition, undefined, PluginSpec$ReventlessCore.commandSchema);
11
11
 
12
12
  let pluginAggregate_producedEventTypes = [];
13
13
 
@@ -40,6 +40,8 @@ let name = "UiFragments";
40
40
 
41
41
  let Id;
42
42
 
43
+ let Key;
44
+
43
45
  let subIdConfig;
44
46
 
45
47
  let moduleUrl = "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateView/UiFragments.res.mjs";
@@ -54,6 +56,7 @@ export {
54
56
  stateSchema,
55
57
  consumedEventSchema,
56
58
  config,
59
+ Key,
57
60
  subIdConfig,
58
61
  moduleUrl,
59
62
  authorization,
@@ -79,8 +79,26 @@ module Make = (
79
79
  ~opts,
80
80
  )
81
81
 
82
+ // Not awaited, for the reason AutomationSlice_Builder gives: a mapping that
83
+ // commands its own source (Placed → Ship) appends an event this handler's
84
+ // fiber must dequeue, while the fiber is still waiting on the command. The
85
+ // bus then waits on itself, and so does the request that started it. Only
86
+ // the local runtime runs this handler; a deployed one uses its entry point.
87
+ let publishJsonsDetached = jsons => {
88
+ let _ = publishJsons(jsons)->Promise.catch(exn => {
89
+ log.error(
90
+ ~comp="EventMapper",
91
+ `detached publish error: ${exn
92
+ ->JsExn.fromException
93
+ ->Option.flatMap(JsExn.message)
94
+ ->Option.getOr("unknown")}`,
95
+ )
96
+ Promise.resolve()
97
+ })
98
+ Promise.resolve()
99
+ }
82
100
  module EventCollectorHandler = EventMapper_Callback.MakeEventCollectorHandler({
83
- let publishJsons = publishJsons
101
+ let publishJsons = publishJsonsDetached
84
102
  let count = count
85
103
  let addToCounterTarget = addToCounterTarget
86
104
  let commonEventsHandler = CounterHandler.commonEventsHandler
@@ -1,9 +1,11 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
6
  import * as Pulumi from "@pulumi/pulumi";
6
7
  import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
8
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
7
9
  import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
8
10
  import * as Counter$ReventlessCore from "../Counter/Counter.res.mjs";
9
11
  import * as Component$ReventlessCore from "../Component.res.mjs";
@@ -53,8 +55,15 @@ function Make(Target) {
53
55
  kind: "EventMapper",
54
56
  name: name
55
57
  }, opts);
58
+ let publishJsonsDetached = jsons => {
59
+ Stdlib_Promise.$$catch(publishJsons(jsons), exn => {
60
+ log.error("EventMapper", undefined, `detached publish error: ` + Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown"));
61
+ return Promise.resolve();
62
+ });
63
+ return Promise.resolve();
64
+ };
56
65
  let EventCollectorHandler = EventMapper_Callback$ReventlessCore.MakeEventCollectorHandler({
57
- publishJsons: publishJsons,
66
+ publishJsons: publishJsonsDetached,
58
67
  count: param.count,
59
68
  addToCounterTarget: param.addToCounterTarget,
60
69
  commonEventsHandler: CounterHandler.commonEventsHandler
@@ -124,7 +124,17 @@ module Make = (
124
124
  let (eventType, dataDict) = rawEvent->Message.splitMessage
125
125
  switch decoder.decode(~eventType, ~data=dataDict) {
126
126
  | Some(event) =>
127
- let actions = try Projection.project({event, meta, recordedAt}) catch {
127
+ let actions = try Projection.project({
128
+ event,
129
+ meta,
130
+ recordedAt,
131
+ })->Array.map(
132
+ Reventless.Projection.mapActionId(
133
+ _,
134
+ ~to_=Spec.Key.toString,
135
+ ~from=Spec.Key.makeFromString,
136
+ ),
137
+ ) catch {
128
138
  | exn =>
129
139
  let errMsg =
130
140
  exn
@@ -12,6 +12,7 @@ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js
12
12
  import * as DcbDecode$Reventless from "@reventlessdev/reventless-spec/src/components/DcbDecode.res.mjs";
13
13
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
14
14
  import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
15
+ import * as Projection$Reventless from "@reventlessdev/reventless-spec/src/types/Projection.res.mjs";
15
16
  import * as Message$ReventlessCore from "../../Message.res.mjs";
16
17
  import * as Component$ReventlessCore from "../Component.res.mjs";
17
18
  import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
@@ -108,7 +109,7 @@ function Make(RuntimeEnvironment) {
108
109
  event: Primitive_option.valFromOption(event),
109
110
  meta: meta,
110
111
  recordedAt: recordedAt
111
- });
112
+ }).map(__x => Projection$Reventless.mapActionId(__x, Spec.Key.toString, Spec.Key.makeFromString));
112
113
  } catch (raw_exn) {
113
114
  let exn$1 = Primitive_exceptions.internalToException(raw_exn);
114
115
  let errMsg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn$1), Stdlib_JsExn.message), "unknown");
@@ -40,7 +40,14 @@ module Make = (
40
40
  | Some(event) =>
41
41
  idx := idx.contents + 1
42
42
  let id = raw.tags->Array.get(0)->Option.map(tag => tag.value)->Option.getOr("?")
43
- let actions = Projection.project({event, meta: raw.meta, recordedAt: raw.recordedAt})
43
+ let actions =
44
+ Projection.project({event, meta: raw.meta, recordedAt: raw.recordedAt})->Array.map(
45
+ Reventless.Projection.mapActionId(
46
+ _,
47
+ ~to_=Spec.Key.toString,
48
+ ~from=Spec.Key.makeFromString,
49
+ ),
50
+ )
44
51
  let actionsStr = LogFormat.actionNames(actions)
45
52
  EffectLogger.logInfo(
46
53
  ~comp,
@@ -6,6 +6,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Effect from "effect/Effect";
7
7
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
8
8
  import * as DcbDecode$Reventless from "@reventlessdev/reventless-spec/src/components/DcbDecode.res.mjs";
9
+ import * as Projection$Reventless from "@reventlessdev/reventless-spec/src/types/Projection.res.mjs";
9
10
  import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
10
11
  import * as Projection$ReventlessCore from "../../Projection.res.mjs";
11
12
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
@@ -32,7 +33,7 @@ function Make(Spec) {
32
33
  event: event,
33
34
  meta: raw.meta,
34
35
  recordedAt: raw.recordedAt
35
- });
36
+ }).map(__x => Projection$Reventless.mapActionId(__x, Spec.Key.toString, Spec.Key.makeFromString));
36
37
  let actionsStr = LogFormat$ReventlessCore.actionNames(actions);
37
38
  Effect.runSync(EffectLogger$ReventlessCore.logInfo(comp, raw.data, `handling event ` + idx.contents.toString() + `/` + count + `: ` + LogFormat$ReventlessCore.bold(raw.eventType) + `(` + id + `) ` + actionsStr));
38
39
  allActions.push(...actions.map(__x => Projection$ReventlessCore.rewriteAction(__x, raw.meta.time, Spec.stateSchema)));
@@ -587,31 +587,96 @@ let labelFieldsFromStateSchema = (
587
587
  // Mirrors the command walk for emitted events. Module-level so Platform_Admin,
588
588
  // whose components never pass through `make`, derives its defs the same way.
589
589
 
590
+ /**
591
+ The plugin's views by the identity each is keyed by, which is what lets a typed
592
+ id find the view that lists it without an `@ref`. `report` says where the type
593
+ cannot decide: several views keyed by the identity, none, or an `@ref` naming a
594
+ view keyed by another.
595
+ */
596
+ type identityViews = {
597
+ viewsByKey: dict<array<string>>,
598
+ viewNames: array<string>,
599
+ report: string => unit,
600
+ }
601
+
602
+ // The reference a typed id implies, and the check on one it declares.
603
+ let identityReference = (
604
+ identityViews: identityViews,
605
+ ~fieldName,
606
+ fieldSchema: S.t<unknown>,
607
+ declared: array<(string, Reventless.Reference.target)>,
608
+ ): array<(string, Reventless.Reference.target)> =>
609
+ switch Reventless.Semantic.fieldIdentityKey(fieldSchema) {
610
+ | None => declared
611
+ | Some(key) =>
612
+ let keyed = identityViews.viewsByKey->Dict.get(key)->Option.getOr([])
613
+ switch declared {
614
+ | [] =>
615
+ switch keyed {
616
+ | [view] => [(fieldName, {Reventless.Semantic.entity: view, plugin: None, identity: key})]
617
+ | [] =>
618
+ identityViews.report(
619
+ `${fieldName} is a ${key}, and no view in this plugin is keyed by it, so it references nothing. List the identity in a view, or reference another plugin's with @ref("Plugin.View").`,
620
+ )
621
+ []
622
+ | several =>
623
+ identityViews.report(
624
+ `${fieldName} is a ${key}, and ${several->Array.join(
625
+ " and ",
626
+ )} are all keyed by it. Say which it references with @ref.`,
627
+ )
628
+ []
629
+ }
630
+ | _ =>
631
+ declared->Array.forEach(((_, target)) =>
632
+ if (
633
+ target.plugin->Option.isNone &&
634
+ identityViews.viewNames->Array.includes(target.entity) &&
635
+ !(keyed->Array.includes(target.entity))
636
+ ) {
637
+ identityViews.report(
638
+ `${fieldName} is a ${key}, and its @ref names ${target.entity}, which is not keyed by it.`,
639
+ )
640
+ }
641
+ )
642
+ declared
643
+ }
644
+ }
645
+
590
646
  // A variant's cross-entity references, shared by the command and event walks.
591
647
  // `getFieldTarget`, not `getTarget`: an `array<string>` field declares it on the
592
648
  // element schema.
593
- let extractReferences = (properties: dict<S.t<unknown>>): array<Reventless.Plugin.fieldReference> =>
649
+ let extractReferences = (
650
+ ~identityViews: option<identityViews>=?,
651
+ // The id a creating command mints. Its identity names the row the command
652
+ // brings into existence, not one it refers to, so nothing is derived for it.
653
+ ~minted: option<string>=?,
654
+ properties: dict<S.t<unknown>>,
655
+ ): array<Reventless.Plugin.fieldReference> =>
594
656
  properties
595
657
  ->Dict.toArray
596
- ->Array.flatMap(((fieldName, fieldSchema)) =>
658
+ ->Array.flatMap(((fieldName, fieldSchema)) => {
597
659
  // `collectFieldTargets`, not `getFieldTarget`: a reference declared on a field
598
660
  // of a record the field holds (an order line's `productId`) is one this
599
661
  // command declares, and it arrives named by its own path. Commands and events
600
662
  // both come through here, which is what stops one of them learning about
601
663
  // nesting and the other not.
602
- Reventless.Reference.collectFieldTargets(fieldName, fieldSchema)->Array.map(((
603
- path,
604
- target,
605
- )): Reventless.Plugin.fieldReference => {
664
+ let declared = Reventless.Reference.collectFieldTargets(fieldName, fieldSchema)
665
+ let targets = switch identityViews {
666
+ | Some(views) if minted != Some(fieldName) =>
667
+ identityReference(views, ~fieldName, fieldSchema, declared)
668
+ | _ => declared
669
+ }
670
+ targets->Array.map(((path, target)): Reventless.Plugin.fieldReference => {
606
671
  Reventless.Plugin.fieldName: path,
607
672
  entity: target.entity,
608
673
  plugin: target.plugin,
609
674
  })
610
- )
675
+ })
611
676
 
612
- let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
677
+ let toEventDef = (~identityViews=?, v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
613
678
  let mkDef = (~variantName, ~properties) => {
614
- let references = extractReferences(properties)
679
+ let references = extractReferences(~identityViews?, properties)
615
680
 
616
681
  (
617
682
  {
@@ -640,10 +705,12 @@ let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
640
705
  }
641
706
  }
642
707
 
643
- let extractEventDefs = (eventSchema: S.t<unknown>): array<Reventless.Plugin.eventDef> =>
708
+ let extractEventDefs = (~identityViews=?, eventSchema: S.t<unknown>): array<
709
+ Reventless.Plugin.eventDef,
710
+ > =>
644
711
  switch eventSchema {
645
- | AnyOf({anyOf}) => anyOf->Array.filterMap(toEventDef)
646
- | _ => toEventDef(eventSchema)->Option.mapOr([], def => [def])
712
+ | AnyOf({anyOf}) => anyOf->Array.filterMap(v => toEventDef(~identityViews?, v))
713
+ | _ => toEventDef(~identityViews?, eventSchema)->Option.mapOr([], def => [def])
647
714
  }
648
715
 
649
716
  // Errors walk identically to events; only the def type differs, so a consumer
@@ -750,6 +817,7 @@ let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
750
817
  }
751
818
 
752
819
  let toCommandDef = (
820
+ ~identityViews: option<identityViews>=?,
753
821
  ~isAggregate,
754
822
  ~partitionKey: option<string>=?,
755
823
  ~mutationFieldFor: string => string,
@@ -785,7 +853,6 @@ let toCommandDef = (
785
853
  ~variantName,
786
854
  properties,
787
855
  )
788
- let references = extractReferences(properties)
789
856
  // Evaluated against a synthetic value per constructor, the same shape the
790
857
  // resolver builds at call time: a payload-bearing variant compiles to
791
858
  // `{TAG, ...}`, a payload-less one to a bare string.
@@ -849,6 +916,11 @@ let toCommandDef = (
849
916
  | None => Reventless.Transition.targetState(declared)
850
917
  }
851
918
  let level = derived->Option.flatMap(d => d.level)->Option.getOr(guessedLevel)
919
+ let references = extractReferences(
920
+ ~identityViews?,
921
+ ~minted=?level == Reventless.Plugin.Collection ? aggregateIdField : None,
922
+ properties,
923
+ )
852
924
  // API-exposed iff the whole command isn't @noApi and this variant
853
925
  // isn't in its @noApi-variants set — mirrors the API-generation filter
854
926
  // (Plugin_Helpers / PluginBaseFragment). Drives the event-graph API badge.
@@ -923,6 +995,7 @@ let toCommandDef = (
923
995
  }
924
996
 
925
997
  let extractCommandDefs = (
998
+ ~identityViews: option<identityViews>=?,
926
999
  ~isAggregate,
927
1000
  ~partitionKey: option<string>=?,
928
1001
  ~mutationFieldFor: string => string,
@@ -935,6 +1008,7 @@ let extractCommandDefs = (
935
1008
  | AnyOf({anyOf}) =>
936
1009
  anyOf->Array.filterMap(v =>
937
1010
  toCommandDef(
1011
+ ~identityViews?,
938
1012
  ~isAggregate,
939
1013
  ~partitionKey?,
940
1014
  ~mutationFieldFor,
@@ -948,6 +1022,7 @@ let extractCommandDefs = (
948
1022
  | _ =>
949
1023
  // Single-variant command types compile to a bare Object schema, not a Union.
950
1024
  toCommandDef(
1025
+ ~identityViews?,
951
1026
  ~isAggregate,
952
1027
  ~partitionKey?,
953
1028
  ~mutationFieldFor,
@@ -1536,6 +1611,53 @@ let make = (
1536
1611
  }
1537
1612
  let partitionFailures = []
1538
1613
 
1614
+ // Each view's key as an identity: a read model's `Spec.Id` when it is one, else
1615
+ // the key field's type, else the key field's name, so a view keyed by an untyped
1616
+ // `productId` still lists the `productId` identity.
1617
+ let keyIdentityOf = (stateSchema: S.t<unknown>, idField: option<string>) =>
1618
+ idField->Option.map(field =>
1619
+ switch stateSchema {
1620
+ | Object({properties}) =>
1621
+ properties->Dict.get(field)->Option.flatMap(Reventless.Semantic.identityKey)
1622
+ | _ => None
1623
+ }->Option.getOr(field)
1624
+ )
1625
+ let viewKeys = Array.concat(
1626
+ readModels->Array.mapWithIndex((
1627
+ module(R: ReventlessInfra.ReadModel.T with type api = api and type role = role),
1628
+ i,
1629
+ ) => {
1630
+ let def = readModelDefs->Array.getUnsafe(i)
1631
+ let key = switch R.Spec.Id.schema->Reventless.Semantic.identityKey {
1632
+ | Some(_) as declared => declared
1633
+ | None => keyIdentityOf(R.Spec.stateSchema->S.castToUnknown, def.idField)
1634
+ }
1635
+ (R.Spec.name, key)
1636
+ }),
1637
+ stateViewSlices->Array.mapWithIndex((module(SVS: ReventlessInfra.StateViewSlice.T), i) => {
1638
+ let def = stateViewDefs->Array.getUnsafe(i)
1639
+ (SVS.Spec.name, keyIdentityOf(SVS.Spec.stateSchema->S.castToUnknown, def.idField))
1640
+ }),
1641
+ )
1642
+ let viewsByKey: dict<array<string>> = Dict.make()
1643
+ viewKeys->Array.forEach(((view, key)) =>
1644
+ key->Option.forEach(k =>
1645
+ viewsByKey->Dict.set(k, viewsByKey->Dict.get(k)->Option.getOr([])->Array.concat([view]))
1646
+ )
1647
+ )
1648
+ // Warned rather than refused, like the key-field gap: the field still decodes,
1649
+ // it just links nowhere. Once per message, since a field recurs across variants.
1650
+ let reported: Set.t<string> = Set.make()
1651
+ let identityViews = {
1652
+ viewsByKey,
1653
+ viewNames: viewKeys->Array.map(((view, _)) => view),
1654
+ report: message =>
1655
+ if !(reported->Set.has(message)) {
1656
+ reported->Set.add(message)
1657
+ log.warn(~comp="Plugin_Structure", `${name}: ${message}`)
1658
+ },
1659
+ }
1660
+
1539
1661
  let stateChangeDefs =
1540
1662
  stateChangeSlices->Array.mapWithIndex((module(SCS: ReventlessInfra.StateChangeSlice.T), i) => {
1541
1663
  let (_, produced) = scsProduced->Array.getUnsafe(i)
@@ -1545,6 +1667,7 @@ let make = (
1545
1667
  {
1546
1668
  Reventless.Plugin.name: SCS.Spec.name,
1547
1669
  commands: extractCommandDefs(
1670
+ ~identityViews,
1548
1671
  ~isAggregate=false,
1549
1672
  ~partitionKey=?partitionBySlice->Dict.get(SCS.Spec.name),
1550
1673
  ~mutationFieldFor=variantName =>
@@ -1563,7 +1686,7 @@ let make = (
1563
1686
  consumedEventTypes: consumed,
1564
1687
  linkedViews: linkedSvsFor(produced),
1565
1688
  consistencyRead: consistencyReadFor(consumed),
1566
- events: extractEventDefs(SCS.Spec.eventSchema->S.castToUnknown),
1689
+ events: extractEventDefs(~identityViews, SCS.Spec.eventSchema->S.castToUnknown),
1567
1690
  errors: extractErrorDefs(SCS.Spec.errorSchema->S.castToUnknown),
1568
1691
  chapter: chapterOf(SCS.Spec.name),
1569
1692
  }: Reventless.Plugin.writableDef
@@ -1580,6 +1703,7 @@ let make = (
1580
1703
  {
1581
1704
  Reventless.Plugin.name: A.Spec.name,
1582
1705
  commands: extractCommandDefs(
1706
+ ~identityViews,
1583
1707
  ~isAggregate=true,
1584
1708
  ~mutationFieldFor=variantName =>
1585
1709
  Api_Naming.aggregateMutationField(
@@ -1596,7 +1720,7 @@ let make = (
1596
1720
  consumedEventTypes: [],
1597
1721
  linkedViews: Array.concat(linkedSvsFor(produced), linkedReadModelsFor(A.Spec.name)),
1598
1722
  consistencyRead: None,
1599
- events: extractEventDefs(A.Spec.eventSchema->S.castToUnknown),
1723
+ events: extractEventDefs(~identityViews, A.Spec.eventSchema->S.castToUnknown),
1600
1724
  errors: extractErrorDefs(A.Spec.errorSchema->S.castToUnknown),
1601
1725
  chapter: chapterOf(A.Spec.name),
1602
1726
  }: Reventless.Plugin.writableDef