@reventlessdev/reventless-core 3.0.0-alpha.189 → 3.0.0-alpha.191

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
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.191 (2026-07-29)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **plugin:** the field a read model is named by ([c679bb9](https://github.com/ReventlessDev/reventless-core/commit/c679bb9f610be9947417174bc3dfceea38207ba0))
11
+ ### Features
12
+
13
+ * **plugin:** why a read model is named by that field ([699ef8e](https://github.com/ReventlessDev/reventless-core/commit/699ef8e72f1b1d82fb04672739e5e4a8db61ae9a))
14
+
15
+
16
+ # 3.0.0-alpha.190 (2026-07-28)
17
+
18
+ ### Features
19
+
20
+ * **platform:** provision object stores from the fields that declare them ([b5e2a1e](https://github.com/ReventlessDev/reventless-core/commit/b5e2a1ec88099941c113e4963f9c4b346b96b0d6))
21
+
22
+
6
23
  # 3.0.0-alpha.189 (2026-07-28)
7
24
 
8
25
  **Note:** Version bump only for package @reventlessdev/reventless-core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.189",
3
+ "version": "3.0.0-alpha.191",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,16 +29,16 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
32
- "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
33
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.13",
34
- "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
33
+ "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
35
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
36
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
35
+ "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
37
36
  "@reventlessdev/rescript-ssh2": "1.1.0-alpha.14",
37
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
38
38
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.107",
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.109",
40
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.82"
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.84"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -252,7 +252,7 @@ module Make = (
252
252
  // pushed SDL — CreateResolver then loops on
253
253
  // `NotFoundException: No field named ...`.
254
254
  let registryKey = entry.specName->Option.getOr(entityName)
255
- let (labelField, _searchableFields) = Plugin_Structure.labelFieldsFromStateSchema(
255
+ let label: Plugin_Structure.labelResolution = Plugin_Structure.labelFieldsFromStateSchema(
256
256
  ~entityName,
257
257
  entry.stateSchema,
258
258
  )
@@ -263,7 +263,7 @@ module Make = (
263
263
  pluralTypeName: entry.listFieldName,
264
264
  includeIdParam: entry.includeIdParam->Option.getOr(true),
265
265
  connectionSpec: entry.connectionSpec->Option.getOr(true),
266
- labelField,
266
+ labelField: label.field,
267
267
  connectionFilterTypeName: entry.returnTypeName ++ "Filter",
268
268
  }
269
269
  Plugin_Helpers.queryFieldNamesRegistry->Dict.set(registryKey, qn)
@@ -110,13 +110,13 @@ function Make(RuntimeEnvironment) {
110
110
  let prefix = "Platform_";
111
111
  let entityName = entry.returnTypeName.startsWith(prefix) ? entry.returnTypeName.slice(prefix.length, entry.returnTypeName.length) : entry.returnTypeName;
112
112
  let registryKey = Stdlib_Option.getOr(entry.specName, entityName);
113
- let match = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(entityName, entry.stateSchema);
113
+ let label = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(entityName, entry.stateSchema);
114
114
  let qn_singleFieldName = entry.singleFieldName;
115
115
  let qn_listFieldName = entry.listFieldName;
116
116
  let qn_returnTypeName = entry.returnTypeName;
117
117
  let qn_pluralTypeName = entry.listFieldName;
118
118
  let qn_connectionFilterTypeName = entry.returnTypeName + "Filter";
119
- let qn_labelField = match[0];
119
+ let qn_labelField = label.field;
120
120
  let qn_includeIdParam = Stdlib_Option.getOr(entry.includeIdParam, true);
121
121
  let qn_connectionSpec = Stdlib_Option.getOr(entry.connectionSpec, true);
122
122
  let qn = {
@@ -111,6 +111,9 @@ let pluginReadModel: queryableDef = {
111
111
  linkedWriteSide: ["Plugin"],
112
112
  labelField: "name",
113
113
  searchableFields: ["name"],
114
+ // Hand-rolled rather than resolved, but the rung is the same one
115
+ // `labelFieldsFromStateSchema` would report for a field literally named `name`.
116
+ labelFieldSource: Some("convention"),
114
117
  statusField: Some("status"),
115
118
  visibility: None,
116
119
  chapter: None,
@@ -126,4 +129,5 @@ let structure: pluginStructure = {
126
129
  inboundTranslationSlices: [],
127
130
  extensions: [],
128
131
  extensionPoints: None,
132
+ requiredStores: None,
129
133
  }
@@ -126,6 +126,8 @@ let pluginReadModel_linkedWriteSide = ["Plugin"];
126
126
 
127
127
  let pluginReadModel_searchableFields = ["name"];
128
128
 
129
+ let pluginReadModel_labelFieldSource = "convention";
130
+
129
131
  let pluginReadModel_statusField = "status";
130
132
 
131
133
  let pluginReadModel = {
@@ -136,6 +138,7 @@ let pluginReadModel = {
136
138
  linkedWriteSide: pluginReadModel_linkedWriteSide,
137
139
  labelField: "name",
138
140
  searchableFields: pluginReadModel_searchableFields,
141
+ labelFieldSource: pluginReadModel_labelFieldSource,
139
142
  statusField: pluginReadModel_statusField,
140
143
  visibility: undefined,
141
144
  chapter: undefined
@@ -166,7 +169,8 @@ let structure = {
166
169
  outboundTranslationSlices: structure_outboundTranslationSlices,
167
170
  inboundTranslationSlices: structure_inboundTranslationSlices,
168
171
  extensions: structure_extensions,
169
- extensionPoints: undefined
172
+ extensionPoints: undefined,
173
+ requiredStores: undefined
170
174
  };
171
175
 
172
176
  let pluginName = pluginId;
@@ -11,7 +11,7 @@ let sdlTypes: array<string> = [
11
11
  `type Platform_CommandDef {\n name: String!\n schema: String!\n level: String!\n aggregateIdField: String\n mutationField: String!\n references: [Platform_FieldReference!]!\n allowedStates: [String!]\n targetState: String\n apiExposed: Boolean\n}`,
12
12
  `type Platform_EventDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
13
13
  `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n}`,
14
- `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n statusField: String\n}`,
14
+ `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n}`,
15
15
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n}`,
16
16
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n}`,
17
17
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n}`,
@@ -72,6 +72,10 @@ let encodeQueryableDef = (r: queryableDef): JSON.t =>
72
72
  ("linkedWriteSide", encodeStrings(r.linkedWriteSide)),
73
73
  ("labelField", JSON.Encode.string(r.labelField)),
74
74
  ("searchableFields", encodeStrings(r.searchableFields)),
75
+ (
76
+ "labelFieldSource",
77
+ r.labelFieldSource->Option.mapOr(JSON.Encode.null, JSON.Encode.string),
78
+ ),
75
79
  ("statusField", r.statusField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
76
80
  ("chapter", r.chapter->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
77
81
  ])->JSON.Encode.object
@@ -9,7 +9,7 @@ let sdlTypes = [
9
9
  `type Platform_CommandDef {\n name: String!\n schema: String!\n level: String!\n aggregateIdField: String\n mutationField: String!\n references: [Platform_FieldReference!]!\n allowedStates: [String!]\n targetState: String\n apiExposed: Boolean\n}`,
10
10
  `type Platform_EventDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
11
11
  `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n}`,
12
- `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n statusField: String\n}`,
12
+ `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n}`,
13
13
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n}`,
14
14
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n}`,
15
15
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n}`,
@@ -116,6 +116,10 @@ function encodeQueryableDef(r) {
116
116
  "searchableFields",
117
117
  r.searchableFields.map(prim => prim)
118
118
  ],
119
+ [
120
+ "labelFieldSource",
121
+ Stdlib_Option.mapOr(r.labelFieldSource, null, prim => prim)
122
+ ],
119
123
  [
120
124
  "statusField",
121
125
  Stdlib_Option.mapOr(r.statusField, null, prim => prim)
@@ -526,13 +526,13 @@ module Make = (
526
526
  module(V: StateViewSlice.T),
527
527
  ) => {
528
528
  let qn = Api_Naming.queryFieldNamesForStateView(~plugin=name, ~viewName=V.Spec.name)
529
- let (labelField, _searchableFields) = Plugin_Structure.labelFieldsFromStateSchema(
529
+ let label = Plugin_Structure.labelFieldsFromStateSchema(
530
530
  ~entityName=V.Spec.name,
531
531
  V.Spec.stateSchema->S.castToUnknown,
532
532
  )
533
533
  let qn = {
534
534
  ...qn,
535
- labelField,
535
+ labelField: label.field,
536
536
  connectionFilterTypeName: qn.returnTypeName ++ "Filter",
537
537
  }
538
538
  let qn = switch V.Spec.subIdConfig {
@@ -276,13 +276,13 @@ function Make(DcbEventLogStorage) {
276
276
  }
277
277
  stateViewSlices.forEach(V => {
278
278
  let qn = Api_Naming$ReventlessCore.queryFieldNamesForStateView(name, V.Spec.name, undefined);
279
- let match = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(V.Spec.name, V.Spec.stateSchema);
279
+ let label = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(V.Spec.name, V.Spec.stateSchema);
280
280
  let newrecord = {...qn};
281
- newrecord.labelField = match[0];
281
+ newrecord.labelField = label.field;
282
282
  newrecord.connectionFilterTypeName = qn.returnTypeName + "Filter";
283
- let match$1 = V.Spec.subIdConfig;
283
+ let match = V.Spec.subIdConfig;
284
284
  let qn$1;
285
- if (match$1 !== undefined) {
285
+ if (match !== undefined) {
286
286
  let newrecord$1 = {...newrecord};
287
287
  newrecord$1.itemsFilterTypeName = newrecord.returnTypeName + "ItemsFilter";
288
288
  newrecord$1.itemsFieldName = newrecord.singleFieldName + "Items";
@@ -267,13 +267,13 @@ module Make = (
267
267
  module(R: ReventlessInfra.ReadModel.T with type api = api and type role = role),
268
268
  ) => {
269
269
  let qn = Api_Naming.queryFieldNamesForReadModel(~plugin=name, ~name=R.Spec.name)
270
- let (labelField, _searchableFields) = Plugin_Structure.labelFieldsFromStateSchema(
270
+ let label = Plugin_Structure.labelFieldsFromStateSchema(
271
271
  ~entityName=R.Spec.name,
272
272
  R.Spec.stateSchema->S.castToUnknown,
273
273
  )
274
274
  let qn = {
275
275
  ...qn,
276
- labelField,
276
+ labelField: label.field,
277
277
  connectionFilterTypeName: qn.returnTypeName ++ "Filter",
278
278
  }
279
279
  let qn = switch R.Spec.subIdConfig {
@@ -208,13 +208,13 @@ function Make(Spec) {
208
208
  let eventLogEntries = eventLogEntriesFromAggregates.concat(dcbResult.eventLogEntries);
209
209
  readModels.forEach(R => {
210
210
  let qn = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(extra$1, R.Spec.name, undefined);
211
- let match = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(R.Spec.name, R.Spec.stateSchema);
211
+ let label = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(R.Spec.name, R.Spec.stateSchema);
212
212
  let newrecord = {...qn};
213
- newrecord.labelField = match[0];
213
+ newrecord.labelField = label.field;
214
214
  newrecord.connectionFilterTypeName = qn.returnTypeName + "Filter";
215
- let match$1 = R.Spec.subIdConfig;
215
+ let match = R.Spec.subIdConfig;
216
216
  let qn$1;
217
- if (match$1 !== undefined) {
217
+ if (match !== undefined) {
218
218
  let newrecord$1 = {...newrecord};
219
219
  newrecord$1.itemsFilterTypeName = newrecord.returnTypeName + "ItemsFilter";
220
220
  newrecord$1.itemsFieldName = newrecord.singleFieldName + "Items";
@@ -5,26 +5,53 @@
5
5
 
6
6
  let log = Logger.fromEnv()
7
7
 
8
- // Returns (labelField, searchableFields) from a state schema.
9
- // Source ladder:
10
- // 1. @displayName spec present → "displayName" + spec.fields (raw underlying fields)
11
- // 2. First non-id, non-`*Id`/`*Ids`, non-TAG, string-typed field in declaration order
12
- // 3. "id" fallback with a logged warning (no searchable fields)
13
- //
14
- // `*Id` / `*Ids` fields (e.g. `productId`, `customerId`, `orderIds`) are entity
15
- // references, not human-readable labels — skipping them lets a sibling like
16
- // `name` win even when it appears later in declaration order.
17
- let isIdLikeFieldName = (name: string): bool =>
18
- name == "id" || name->String.endsWith("Id") || name->String.endsWith("Ids")
19
-
20
- // Resolve the field that holds the entity's lifecycle status, used by
21
- // AutoUI to filter the per-row command menu against each command's
22
- // `allowedStates`. Resolution order:
8
+ // What a field *is* is `SchemaType`'s question, and it is the IR every other
9
+ // schema consumer here reads. The two predicates below are the only two answers
10
+ // this module needs; both are stated over the IR rather than over sury shapes,
11
+ // so a field that is a date, a reference or an enum is one for the label picker
12
+ // too.
13
+
14
+ // Whether a field can name a record. `Nullable` unwraps — an optional name is
15
+ // still the entity's name, absent on some rows, which is a rendering question
16
+ // rather than a declaration one. A `Semantic` wrapper is refused: it is the
17
+ // schema saying the string means something other than prose (a storage ref, a
18
+ // URL), and a bucket key is not a name. `DateTime`, `EntityId`, `Enum` and every
19
+ // composite shape fall out of the catch-all without being named — which is the
20
+ // point of reading an IR rather than restating it: the next shape it grows is
21
+ // excluded before it exists.
22
+ let rec isLabelShape = (t: SchemaType.schemaType): bool =>
23
+ switch t {
24
+ | ScalarString => true
25
+ | Nullable(inner) => isLabelShape(inner)
26
+ | _ => false
27
+ }
28
+
29
+ // Whether a field can hold a lifecycle status: a closed set of values, or an
30
+ // optional one. A free-text `status: string` is not a lifecycle — filtering a
31
+ // command menu against `allowedStates` needs states to compare with.
32
+ let rec isStatusShape = (t: SchemaType.schemaType): bool =>
33
+ switch t {
34
+ | Enum(_, _) => true
35
+ | Nullable(inner) => isStatusShape(inner)
36
+ | _ => false
37
+ }
38
+
39
+ // Field names that say "this one is the record's name" in the only way
40
+ // available short of the `@displayName` annotation. Matched case-insensitively
41
+ // and *exactly*: `customerName` holds a customer's name, not this record's.
42
+ let conventionalLabelNames = ["name", "title", "label", "displayname"]
43
+
44
+ let shapeOfItem = (~entityName: string, item: S.item): SchemaType.schemaType =>
45
+ SchemaType.fromSury(~parentName=entityName, ~fieldName=item.location, item.schema)
46
+
47
+ // Resolve the field that holds the entity's lifecycle status, used to filter a
48
+ // per-row command menu against each command's `allowedStates`. Resolution order:
23
49
  // 1. Field annotated `@status` (PPX-emitted; see StateAnnotations).
24
- // 2. Field literally named `"status"` (convention; mirrors how
25
- // labelField falls back to a conventionally-named string field).
50
+ // 2. Field literally named `"status"` whose IR shape is an enum (convention;
51
+ // mirrors how labelField falls back to a conventionally-named field).
26
52
  // 3. None — filter is inert for this read model.
27
53
  let statusFieldFromStateSchema = (
54
+ ~entityName: string,
28
55
  stateSchema: S.t<unknown>,
29
56
  ): option<string> => {
30
57
  let annotated = switch Reventless.StateAnnotations.getSpec(stateSchema) {
@@ -37,40 +64,96 @@ let statusFieldFromStateSchema = (
37
64
  switch stateSchema {
38
65
  | Object({items}) =>
39
66
  items
40
- ->Array.find(item => item.location == "status")
67
+ ->Array.find(item =>
68
+ item.location == "status" && isStatusShape(shapeOfItem(~entityName, item))
69
+ )
41
70
  ->Option.map(item => item.location)
42
71
  | _ => None
43
72
  }
44
73
  }
45
74
  }
46
75
 
76
+ // Which rung of the ladder below produced the label. Published on `queryableDef`
77
+ // as `labelFieldSource`, because the four rungs are not equally believable and a
78
+ // consumer with a name rule of its own has to rank the declaration against it:
79
+ // rung 1 is the author saying which field names the record, rungs 2 and 3 are
80
+ // guesses this repo makes on their behalf, and rung 4 is the admission that
81
+ // there was nothing to guess from. Convention and position are both guesses and
82
+ // nothing here branches on the difference — but a conventional name is the one a
83
+ // client can independently arrive at, while a positional pick is a fact only
84
+ // this side knows.
85
+ type labelFieldSource =
86
+ | Annotation
87
+ | Convention
88
+ | Position
89
+ | Fallback
90
+
91
+ let labelFieldSourceToString = (s: labelFieldSource): string =>
92
+ switch s {
93
+ | Annotation => "annotation"
94
+ | Convention => "convention"
95
+ | Position => "position"
96
+ | Fallback => "fallback"
97
+ }
98
+
99
+ type labelResolution = {
100
+ field: string,
101
+ searchableFields: array<string>,
102
+ source: labelFieldSource,
103
+ }
104
+
105
+ // Resolves the field a state is named by, from a state schema.
106
+ // Source ladder:
107
+ // 1. @displayName spec present → "displayName" + spec.fields (raw underlying fields)
108
+ // 2. A candidate field named `name`/`title`/`label`/`displayName`
109
+ // 3. The first candidate in declaration order
110
+ // 4. "id" fallback with a logged warning (no searchable fields)
111
+ //
112
+ // A *candidate* is a non-TAG field, not literally named `id`, whose IR shape can
113
+ // name a record (`isLabelShape`). Rungs 2 and 3 are both guesses, and the order
114
+ // between them matters: declaration order alone means a state gains a new name
115
+ // whenever a field is inserted above the old one — a `placedAt` added so date
116
+ // views have something to key off renames every order to a timestamp.
117
+ //
118
+ // `id` is excluded here rather than by the IR because `SchemaType` treats a name
119
+ // that short as an ordinary string; every other reference — `*Id`/`*Ids`, a DCB
120
+ // tag, a `Reference.to` — is already an `EntityId` there.
47
121
  let labelFieldsFromStateSchema = (
48
122
  ~entityName: string,
49
123
  stateSchema: S.t<unknown>,
50
- ): (string, array<string>) =>
124
+ ): labelResolution =>
51
125
  switch Reventless.DisplayName.getSpec(stateSchema) {
52
- | Some(spec) => ("displayName", spec.fields)
126
+ | Some(spec) => {field: "displayName", searchableFields: spec.fields, source: Annotation}
53
127
  | None =>
54
- let firstStringItem = switch stateSchema {
128
+ let candidates = switch stateSchema {
55
129
  | Object({items}) =>
56
- items->Array.find(item =>
130
+ items->Array.filter(item =>
57
131
  item.location != "TAG" &&
58
- !isIdLikeFieldName(item.location) &&
59
- switch item.schema {
60
- | String(_) => true
61
- | _ => false
62
- }
132
+ item.location != "id" &&
133
+ isLabelShape(shapeOfItem(~entityName, item))
63
134
  )
64
- | _ => None
135
+ | _ => []
136
+ }
137
+ let conventional = candidates->Array.find(item => {
138
+ let lower = item.location->String.toLowerCase
139
+ conventionalLabelNames->Array.some(n => n == lower)
140
+ })
141
+ let picked = switch conventional {
142
+ | Some(item) => Some((item, Convention))
143
+ | None => candidates->Array.get(0)->Option.map(item => (item, Position))
65
144
  }
66
- switch firstStringItem {
67
- | Some(item) => (item.location, [item.location])
145
+ switch picked {
146
+ | Some((item, source)) => {
147
+ field: item.location,
148
+ searchableFields: [item.location],
149
+ source,
150
+ }
68
151
  | None =>
69
152
  log.warn(
70
153
  ~comp="Plugin_Structure",
71
154
  `${entityName}: no @displayName annotation and no suitable string field — labelField falls back to "id"`,
72
155
  )
73
- ("id", [])
156
+ {field: "id", searchableFields: [], source: Fallback}
74
157
  }
75
158
  }
76
159
 
@@ -387,6 +470,73 @@ let make = (
387
470
  }
388
471
  }
389
472
 
473
+ // ── Declared object stores ─────────────────────────────────────────────────
474
+ //
475
+ // A field typed as a storage ref states that the deployment needs the named
476
+ // store to exist. Read that declaration the same way `Reference.getTarget` is
477
+ // read for entity references, so the requirement travels with the plugin's
478
+ // structure instead of living only inside a field's schema, where the deploy
479
+ // would have to re-walk every component to find it.
480
+ //
481
+ // An unqualified store belongs to the declaring plugin, so every collected
482
+ // entry is qualified to `{plugin}.{store}` — one shape, and the string is
483
+ // directly the store's identity. Many fields legitimately name one store, so
484
+ // the result is deduplicated.
485
+
486
+ let storesFromProperties = (properties: dict<S.t<unknown>>): array<string> =>
487
+ properties
488
+ ->Dict.toArray
489
+ ->Array.filterMap(((_, fieldSchema)) =>
490
+ Reventless.StorageRef.getStore(fieldSchema)->Option.map(target =>
491
+ target.plugin->Option.getOr(name) ++ "." ++ target.store
492
+ )
493
+ )
494
+
495
+ // Commands and events are unions of per-variant objects; state is a single
496
+ // object. Both reduce to "the properties of every object in this schema".
497
+ let storesFromSchema = (schema: S.t<unknown>): array<string> => {
498
+ let fromVariant = v =>
499
+ switch v {
500
+ | S.Object({properties}) => storesFromProperties(properties)
501
+ | _ => []
502
+ }
503
+ switch schema {
504
+ | Union({anyOf}) => anyOf->Array.flatMap(fromVariant)
505
+ | other => fromVariant(other)
506
+ }
507
+ }
508
+
509
+ let requiredStores =
510
+ [
511
+ aggregates->Array.flatMap((module(A: ReventlessInfra.Aggregate.T with type api = api)) =>
512
+ Array.concat(
513
+ storesFromSchema(A.Spec.commandSchema->S.castToUnknown),
514
+ storesFromSchema(A.Spec.eventSchema->S.castToUnknown),
515
+ )
516
+ ),
517
+ stateChangeSlices->Array.flatMap((module(SCS: ReventlessInfra.StateChangeSlice.T)) =>
518
+ Array.concat(
519
+ storesFromSchema(SCS.Spec.commandSchema->S.castToUnknown),
520
+ storesFromSchema(SCS.Spec.eventSchema->S.castToUnknown),
521
+ )
522
+ ),
523
+ stateViewSlices->Array.flatMap((module(SVS: ReventlessInfra.StateViewSlice.T)) =>
524
+ storesFromSchema(SVS.Spec.stateSchema->S.castToUnknown)
525
+ ),
526
+ readModels->Array.flatMap((
527
+ module(R: ReventlessInfra.ReadModel.T with type api = api and type role = role),
528
+ ) => storesFromSchema(R.Spec.stateSchema->S.castToUnknown)),
529
+ automationSlices->Array.flatMap((module(AS: ReventlessInfra.AutomationSlice.T)) =>
530
+ storesFromSchema(AS.Spec.commandSchema->S.castToUnknown)
531
+ ),
532
+ inboundTranslationSlices->Array.flatMap((
533
+ module(ITS: ReventlessInfra.InboundTranslationSlice.T),
534
+ ) => storesFromSchema(ITS.Spec.commandSchema->S.castToUnknown)),
535
+ ]
536
+ ->Array.flat
537
+ ->Belt.Set.String.fromArray
538
+ ->Belt.Set.String.toArray
539
+
390
540
  // ── Build queryable defs ───────────────────────────────────────────────────
391
541
  //
392
542
  // Internal ReadModels and StateViewSlices (marked `@@reventless.visibility(Internal)`)
@@ -408,10 +558,7 @@ let make = (
408
558
  ) => {
409
559
  let qf = Api_Naming.queryFieldNamesForReadModel(~plugin=name, ~name=R.Spec.name)
410
560
  let stateSchema = R.Spec.stateSchema->S.castToUnknown
411
- let (labelField, searchableFields) = labelFieldsFromStateSchema(
412
- ~entityName=R.Spec.name,
413
- stateSchema,
414
- )
561
+ let label = labelFieldsFromStateSchema(~entityName=R.Spec.name, stateSchema)
415
562
  // The events this read model projects from, qualified to the plugin's event ids so
416
563
  // they match the producers' nodes in the graph. Was empty — which dropped projection
417
564
  // edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
@@ -423,9 +570,10 @@ let make = (
423
570
  schema: stateSchema->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
424
571
  consumedEventTypes: consumed,
425
572
  linkedWriteSide: linkedWriteSideFor(consumed),
426
- labelField,
427
- searchableFields,
428
- statusField: statusFieldFromStateSchema(stateSchema),
573
+ labelField: label.field,
574
+ searchableFields: label.searchableFields,
575
+ labelFieldSource: Some(labelFieldSourceToString(label.source)),
576
+ statusField: statusFieldFromStateSchema(~entityName=R.Spec.name, stateSchema),
429
577
  visibility: visibilityTag(R.Spec.visibility),
430
578
  chapter: chapterOf(R.Spec.name),
431
579
  }: Reventless.Plugin.queryableDef)
@@ -436,19 +584,17 @@ let make = (
436
584
  let qf = Api_Naming.queryFieldNamesForStateView(~plugin=name, ~viewName=SVS.Spec.name)
437
585
  let (_, consumed) = svsConsumed->Array.getUnsafe(i)
438
586
  let stateSchema = SVS.Spec.stateSchema->S.castToUnknown
439
- let (labelField, searchableFields) = labelFieldsFromStateSchema(
440
- ~entityName=SVS.Spec.name,
441
- stateSchema,
442
- )
587
+ let label = labelFieldsFromStateSchema(~entityName=SVS.Spec.name, stateSchema)
443
588
  ({
444
589
  Reventless.Plugin.name: SVS.Spec.name,
445
590
  queryField: qf.listFieldName,
446
591
  schema: stateSchema->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
447
592
  consumedEventTypes: consumed,
448
593
  linkedWriteSide: linkedWriteSideFor(consumed),
449
- labelField,
450
- searchableFields,
451
- statusField: statusFieldFromStateSchema(stateSchema),
594
+ labelField: label.field,
595
+ searchableFields: label.searchableFields,
596
+ labelFieldSource: Some(labelFieldSourceToString(label.source)),
597
+ statusField: statusFieldFromStateSchema(~entityName=SVS.Spec.name, stateSchema),
452
598
  visibility: visibilityTag(SVS.Spec.visibility),
453
599
  chapter: chapterOf(SVS.Spec.name),
454
600
  }: Reventless.Plugin.queryableDef)
@@ -608,5 +754,6 @@ let make = (
608
754
  inboundTranslationSlices: inboundTranslationSliceDefs,
609
755
  extensions: extensionDefs,
610
756
  extensionPoints: Some(extensionPointDefs),
757
+ requiredStores: Some(requiredStores),
611
758
  }
612
759
  }