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

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 +19 -0
  2. package/package.json +6 -6
  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 +131 -15
  16. package/src/plugin/component/Plugin_Structure.res.mjs +108 -24
  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 +99 -0
  22. package/tests/plugin/IdentityReferenceTest.res.mjs +169 -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,25 @@
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.267 (2026-09-21)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core:** an event mapping that commands its own source no longer hangs ([d30ce6b](https://github.com/ReventlessDev/reventless-core/commit/d30ce6b360707d933732fa2c90aba49c03dda006))
11
+ * feat(spec)!: typed projection keys ([fc6a018](https://github.com/ReventlessDev/reventless-core/commit/fc6a01811ba788f3289e84a435a09f77fa627e1a))
12
+ ### Features
13
+
14
+ * **core:** references derived from identities, and identity checks ([7cb1376](https://github.com/ReventlessDev/reventless-core/commit/7cb1376e56cdb4e4d5ab5954cd97fd37fd3ba258))
15
+
16
+ ### BREAKING CHANGES
17
+
18
+ * a projection that keys a row by a payload `string`, or uses
19
+ the envelope id as a `string`, converts explicitly with
20
+ `Target.Id.makeFromString` / `Source.Id.toString`. `StateChangeSlice.Spec` no
21
+ longer requires `module Id`.
22
+
23
+
24
+
6
25
  # 3.0.0-alpha.266 (2026-09-21)
7
26
 
8
27
  ### 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.267",
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-rc.2",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.33",
32
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
33
32
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.12",
34
- "@reventlessdev/rescript-node": "2.0.0-alpha.12",
33
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
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
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.168",
39
40
  "@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"
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,92 @@ 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
+ properties: dict<S.t<unknown>>,
652
+ ): array<Reventless.Plugin.fieldReference> =>
594
653
  properties
595
654
  ->Dict.toArray
596
- ->Array.flatMap(((fieldName, fieldSchema)) =>
655
+ ->Array.flatMap(((fieldName, fieldSchema)) => {
597
656
  // `collectFieldTargets`, not `getFieldTarget`: a reference declared on a field
598
657
  // of a record the field holds (an order line's `productId`) is one this
599
658
  // command declares, and it arrives named by its own path. Commands and events
600
659
  // both come through here, which is what stops one of them learning about
601
660
  // nesting and the other not.
602
- Reventless.Reference.collectFieldTargets(fieldName, fieldSchema)->Array.map(((
603
- path,
604
- target,
605
- )): Reventless.Plugin.fieldReference => {
661
+ let declared = Reventless.Reference.collectFieldTargets(fieldName, fieldSchema)
662
+ let targets = switch identityViews {
663
+ | Some(views) => identityReference(views, ~fieldName, fieldSchema, declared)
664
+ | None => declared
665
+ }
666
+ targets->Array.map(((path, target)): Reventless.Plugin.fieldReference => {
606
667
  Reventless.Plugin.fieldName: path,
607
668
  entity: target.entity,
608
669
  plugin: target.plugin,
609
670
  })
610
- )
671
+ })
611
672
 
612
- let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
673
+ let toEventDef = (~identityViews=?, v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
613
674
  let mkDef = (~variantName, ~properties) => {
614
- let references = extractReferences(properties)
675
+ let references = extractReferences(~identityViews?, properties)
615
676
 
616
677
  (
617
678
  {
@@ -640,10 +701,12 @@ let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
640
701
  }
641
702
  }
642
703
 
643
- let extractEventDefs = (eventSchema: S.t<unknown>): array<Reventless.Plugin.eventDef> =>
704
+ let extractEventDefs = (~identityViews=?, eventSchema: S.t<unknown>): array<
705
+ Reventless.Plugin.eventDef,
706
+ > =>
644
707
  switch eventSchema {
645
- | AnyOf({anyOf}) => anyOf->Array.filterMap(toEventDef)
646
- | _ => toEventDef(eventSchema)->Option.mapOr([], def => [def])
708
+ | AnyOf({anyOf}) => anyOf->Array.filterMap(v => toEventDef(~identityViews?, v))
709
+ | _ => toEventDef(~identityViews?, eventSchema)->Option.mapOr([], def => [def])
647
710
  }
648
711
 
649
712
  // Errors walk identically to events; only the def type differs, so a consumer
@@ -750,6 +813,7 @@ let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
750
813
  }
751
814
 
752
815
  let toCommandDef = (
816
+ ~identityViews: option<identityViews>=?,
753
817
  ~isAggregate,
754
818
  ~partitionKey: option<string>=?,
755
819
  ~mutationFieldFor: string => string,
@@ -785,7 +849,7 @@ let toCommandDef = (
785
849
  ~variantName,
786
850
  properties,
787
851
  )
788
- let references = extractReferences(properties)
852
+ let references = extractReferences(~identityViews?, properties)
789
853
  // Evaluated against a synthetic value per constructor, the same shape the
790
854
  // resolver builds at call time: a payload-bearing variant compiles to
791
855
  // `{TAG, ...}`, a payload-less one to a bare string.
@@ -923,6 +987,7 @@ let toCommandDef = (
923
987
  }
924
988
 
925
989
  let extractCommandDefs = (
990
+ ~identityViews: option<identityViews>=?,
926
991
  ~isAggregate,
927
992
  ~partitionKey: option<string>=?,
928
993
  ~mutationFieldFor: string => string,
@@ -935,6 +1000,7 @@ let extractCommandDefs = (
935
1000
  | AnyOf({anyOf}) =>
936
1001
  anyOf->Array.filterMap(v =>
937
1002
  toCommandDef(
1003
+ ~identityViews?,
938
1004
  ~isAggregate,
939
1005
  ~partitionKey?,
940
1006
  ~mutationFieldFor,
@@ -948,6 +1014,7 @@ let extractCommandDefs = (
948
1014
  | _ =>
949
1015
  // Single-variant command types compile to a bare Object schema, not a Union.
950
1016
  toCommandDef(
1017
+ ~identityViews?,
951
1018
  ~isAggregate,
952
1019
  ~partitionKey?,
953
1020
  ~mutationFieldFor,
@@ -1536,6 +1603,53 @@ let make = (
1536
1603
  }
1537
1604
  let partitionFailures = []
1538
1605
 
1606
+ // Each view's key as an identity: a read model's `Spec.Id` when it is one, else
1607
+ // the key field's type, else the key field's name, so a view keyed by an untyped
1608
+ // `productId` still lists the `productId` identity.
1609
+ let keyIdentityOf = (stateSchema: S.t<unknown>, idField: option<string>) =>
1610
+ idField->Option.map(field =>
1611
+ switch stateSchema {
1612
+ | Object({properties}) =>
1613
+ properties->Dict.get(field)->Option.flatMap(Reventless.Semantic.identityKey)
1614
+ | _ => None
1615
+ }->Option.getOr(field)
1616
+ )
1617
+ let viewKeys = Array.concat(
1618
+ readModels->Array.mapWithIndex((
1619
+ module(R: ReventlessInfra.ReadModel.T with type api = api and type role = role),
1620
+ i,
1621
+ ) => {
1622
+ let def = readModelDefs->Array.getUnsafe(i)
1623
+ let key = switch R.Spec.Id.schema->Reventless.Semantic.identityKey {
1624
+ | Some(_) as declared => declared
1625
+ | None => keyIdentityOf(R.Spec.stateSchema->S.castToUnknown, def.idField)
1626
+ }
1627
+ (R.Spec.name, key)
1628
+ }),
1629
+ stateViewSlices->Array.mapWithIndex((module(SVS: ReventlessInfra.StateViewSlice.T), i) => {
1630
+ let def = stateViewDefs->Array.getUnsafe(i)
1631
+ (SVS.Spec.name, keyIdentityOf(SVS.Spec.stateSchema->S.castToUnknown, def.idField))
1632
+ }),
1633
+ )
1634
+ let viewsByKey: dict<array<string>> = Dict.make()
1635
+ viewKeys->Array.forEach(((view, key)) =>
1636
+ key->Option.forEach(k =>
1637
+ viewsByKey->Dict.set(k, viewsByKey->Dict.get(k)->Option.getOr([])->Array.concat([view]))
1638
+ )
1639
+ )
1640
+ // Warned rather than refused, like the key-field gap: the field still decodes,
1641
+ // it just links nowhere. Once per message, since a field recurs across variants.
1642
+ let reported: Set.t<string> = Set.make()
1643
+ let identityViews = {
1644
+ viewsByKey,
1645
+ viewNames: viewKeys->Array.map(((view, _)) => view),
1646
+ report: message =>
1647
+ if !(reported->Set.has(message)) {
1648
+ reported->Set.add(message)
1649
+ log.warn(~comp="Plugin_Structure", `${name}: ${message}`)
1650
+ },
1651
+ }
1652
+
1539
1653
  let stateChangeDefs =
1540
1654
  stateChangeSlices->Array.mapWithIndex((module(SCS: ReventlessInfra.StateChangeSlice.T), i) => {
1541
1655
  let (_, produced) = scsProduced->Array.getUnsafe(i)
@@ -1545,6 +1659,7 @@ let make = (
1545
1659
  {
1546
1660
  Reventless.Plugin.name: SCS.Spec.name,
1547
1661
  commands: extractCommandDefs(
1662
+ ~identityViews,
1548
1663
  ~isAggregate=false,
1549
1664
  ~partitionKey=?partitionBySlice->Dict.get(SCS.Spec.name),
1550
1665
  ~mutationFieldFor=variantName =>
@@ -1563,7 +1678,7 @@ let make = (
1563
1678
  consumedEventTypes: consumed,
1564
1679
  linkedViews: linkedSvsFor(produced),
1565
1680
  consistencyRead: consistencyReadFor(consumed),
1566
- events: extractEventDefs(SCS.Spec.eventSchema->S.castToUnknown),
1681
+ events: extractEventDefs(~identityViews, SCS.Spec.eventSchema->S.castToUnknown),
1567
1682
  errors: extractErrorDefs(SCS.Spec.errorSchema->S.castToUnknown),
1568
1683
  chapter: chapterOf(SCS.Spec.name),
1569
1684
  }: Reventless.Plugin.writableDef
@@ -1580,6 +1695,7 @@ let make = (
1580
1695
  {
1581
1696
  Reventless.Plugin.name: A.Spec.name,
1582
1697
  commands: extractCommandDefs(
1698
+ ~identityViews,
1583
1699
  ~isAggregate=true,
1584
1700
  ~mutationFieldFor=variantName =>
1585
1701
  Api_Naming.aggregateMutationField(
@@ -1596,7 +1712,7 @@ let make = (
1596
1712
  consumedEventTypes: [],
1597
1713
  linkedViews: Array.concat(linkedSvsFor(produced), linkedReadModelsFor(A.Spec.name)),
1598
1714
  consistencyRead: None,
1599
- events: extractEventDefs(A.Spec.eventSchema->S.castToUnknown),
1715
+ events: extractEventDefs(~identityViews, A.Spec.eventSchema->S.castToUnknown),
1600
1716
  errors: extractErrorDefs(A.Spec.errorSchema->S.castToUnknown),
1601
1717
  chapter: chapterOf(A.Spec.name),
1602
1718
  }: Reventless.Plugin.writableDef