@reventlessdev/reventless-core 3.0.0-alpha.190 → 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,16 @@
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
+
6
16
  # 3.0.0-alpha.190 (2026-07-28)
7
17
 
8
18
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.190",
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",
33
+ "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
34
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
35
35
  "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
36
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
37
36
  "@reventlessdev/rescript-ssh2": "1.1.0-alpha.14",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.108",
37
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
39
38
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
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.83"
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,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
@@ -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
 
@@ -475,10 +558,7 @@ let make = (
475
558
  ) => {
476
559
  let qf = Api_Naming.queryFieldNamesForReadModel(~plugin=name, ~name=R.Spec.name)
477
560
  let stateSchema = R.Spec.stateSchema->S.castToUnknown
478
- let (labelField, searchableFields) = labelFieldsFromStateSchema(
479
- ~entityName=R.Spec.name,
480
- stateSchema,
481
- )
561
+ let label = labelFieldsFromStateSchema(~entityName=R.Spec.name, stateSchema)
482
562
  // The events this read model projects from, qualified to the plugin's event ids so
483
563
  // they match the producers' nodes in the graph. Was empty — which dropped projection
484
564
  // edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
@@ -490,9 +570,10 @@ let make = (
490
570
  schema: stateSchema->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
491
571
  consumedEventTypes: consumed,
492
572
  linkedWriteSide: linkedWriteSideFor(consumed),
493
- labelField,
494
- searchableFields,
495
- 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),
496
577
  visibility: visibilityTag(R.Spec.visibility),
497
578
  chapter: chapterOf(R.Spec.name),
498
579
  }: Reventless.Plugin.queryableDef)
@@ -503,19 +584,17 @@ let make = (
503
584
  let qf = Api_Naming.queryFieldNamesForStateView(~plugin=name, ~viewName=SVS.Spec.name)
504
585
  let (_, consumed) = svsConsumed->Array.getUnsafe(i)
505
586
  let stateSchema = SVS.Spec.stateSchema->S.castToUnknown
506
- let (labelField, searchableFields) = labelFieldsFromStateSchema(
507
- ~entityName=SVS.Spec.name,
508
- stateSchema,
509
- )
587
+ let label = labelFieldsFromStateSchema(~entityName=SVS.Spec.name, stateSchema)
510
588
  ({
511
589
  Reventless.Plugin.name: SVS.Spec.name,
512
590
  queryField: qf.listFieldName,
513
591
  schema: stateSchema->SuryToJsonSchema.deriveObjectSchema->JSON.stringify,
514
592
  consumedEventTypes: consumed,
515
593
  linkedWriteSide: linkedWriteSideFor(consumed),
516
- labelField,
517
- searchableFields,
518
- 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),
519
598
  visibility: visibilityTag(SVS.Spec.visibility),
520
599
  chapter: chapterOf(SVS.Spec.name),
521
600
  }: Reventless.Plugin.queryableDef)
@@ -11,6 +11,7 @@ import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
11
11
  import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
12
12
  import * as DisplayName$Reventless from "@reventlessdev/reventless-spec/src/components/DisplayName.res.mjs";
13
13
  import * as Api_Naming$ReventlessCore from "../../components/Api/Api_Naming.res.mjs";
14
+ import * as SchemaType$ReventlessCore from "../../components/Api/SchemaType.res.mjs";
14
15
  import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
15
16
  import * as ApiNoApiHelpers$ReventlessCore from "../../components/Api/ApiNoApiHelpers.res.mjs";
16
17
  import * as SuryToJsonSchema$ReventlessCore from "../../components/Api/SuryToJsonSchema.res.mjs";
@@ -19,54 +20,122 @@ import * as ApiAllowedStatesHelpers$ReventlessCore from "../../components/Api/Ap
19
20
 
20
21
  let log = Logger$ReventlessCore.fromEnv();
21
22
 
22
- function isIdLikeFieldName(name) {
23
- if (name === "id" || name.endsWith("Id")) {
24
- return true;
25
- } else {
26
- return name.endsWith("Ids");
27
- }
23
+ function isLabelShape(_t) {
24
+ while (true) {
25
+ let t = _t;
26
+ if (typeof t !== "object") {
27
+ return t === "ScalarString";
28
+ }
29
+ if (t.TAG !== "Nullable") {
30
+ return false;
31
+ }
32
+ _t = t._0;
33
+ continue;
34
+ };
35
+ }
36
+
37
+ function isStatusShape(_t) {
38
+ while (true) {
39
+ let t = _t;
40
+ if (typeof t !== "object") {
41
+ return false;
42
+ }
43
+ switch (t.TAG) {
44
+ case "Nullable" :
45
+ _t = t._0;
46
+ continue;
47
+ case "Enum" :
48
+ return true;
49
+ default:
50
+ return false;
51
+ }
52
+ };
28
53
  }
29
54
 
30
- function statusFieldFromStateSchema(stateSchema) {
55
+ let conventionalLabelNames = [
56
+ "name",
57
+ "title",
58
+ "label",
59
+ "displayname"
60
+ ];
61
+
62
+ function shapeOfItem(entityName, item) {
63
+ return SchemaType$ReventlessCore.fromSury(entityName, item.location, item.schema);
64
+ }
65
+
66
+ function statusFieldFromStateSchema(entityName, stateSchema) {
31
67
  let spec = StateAnnotations$Reventless.getSpec(stateSchema);
32
68
  let annotated = spec !== undefined ? spec.status : undefined;
33
69
  if (annotated !== undefined) {
34
70
  return annotated;
35
71
  } else if (stateSchema.type === "object") {
36
- return Stdlib_Option.map(stateSchema.items.find(item => item.location === "status"), item => item.location);
72
+ return Stdlib_Option.map(stateSchema.items.find(item => {
73
+ if (item.location === "status") {
74
+ return isStatusShape(shapeOfItem(entityName, item));
75
+ } else {
76
+ return false;
77
+ }
78
+ }), item => item.location);
37
79
  } else {
38
80
  return;
39
81
  }
40
82
  }
41
83
 
84
+ function labelFieldSourceToString(s) {
85
+ switch (s) {
86
+ case "Annotation" :
87
+ return "annotation";
88
+ case "Convention" :
89
+ return "convention";
90
+ case "Position" :
91
+ return "position";
92
+ case "Fallback" :
93
+ return "fallback";
94
+ }
95
+ }
96
+
42
97
  function labelFieldsFromStateSchema(entityName, stateSchema) {
43
98
  let spec = DisplayName$Reventless.getSpec(stateSchema);
44
99
  if (spec !== undefined) {
45
- return [
46
- "displayName",
47
- spec.fields
48
- ];
100
+ return {
101
+ field: "displayName",
102
+ searchableFields: spec.fields,
103
+ source: "Annotation"
104
+ };
49
105
  }
50
- let firstStringItem;
51
- firstStringItem = stateSchema.type === "object" ? stateSchema.items.find(item => {
52
- if (item.location === "TAG" || isIdLikeFieldName(item.location)) {
106
+ let candidates;
107
+ candidates = stateSchema.type === "object" ? stateSchema.items.filter(item => {
108
+ if (item.location !== "TAG" && item.location !== "id") {
109
+ return isLabelShape(shapeOfItem(entityName, item));
110
+ } else {
53
111
  return false;
54
112
  }
55
- let match = item.schema;
56
- return match.type === "string";
57
- }) : undefined;
58
- if (firstStringItem !== undefined) {
59
- return [
60
- firstStringItem.location,
61
- [firstStringItem.location]
62
- ];
63
- } else {
64
- log.warn("Plugin_Structure", undefined, entityName + `: no @displayName annotation and no suitable string field — labelField falls back to "id"`);
65
- return [
66
- "id",
67
- []
68
- ];
113
+ }) : [];
114
+ let conventional = candidates.find(item => {
115
+ let lower = item.location.toLowerCase();
116
+ return conventionalLabelNames.some(n => n === lower);
117
+ });
118
+ let picked = conventional !== undefined ? [
119
+ conventional,
120
+ "Convention"
121
+ ] : Stdlib_Option.map(candidates[0], item => [
122
+ item,
123
+ "Position"
124
+ ]);
125
+ if (picked !== undefined) {
126
+ let item = picked[0];
127
+ return {
128
+ field: item.location,
129
+ searchableFields: [item.location],
130
+ source: picked[1]
131
+ };
69
132
  }
133
+ log.warn("Plugin_Structure", undefined, entityName + `: no @displayName annotation and no suitable string field — labelField falls back to "id"`);
134
+ return {
135
+ field: "id",
136
+ searchableFields: [],
137
+ source: "Fallback"
138
+ };
70
139
  }
71
140
 
72
141
  function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChangeSlicesOpt, automationSlicesOpt, outboundTranslationSlicesOpt, inboundTranslationSlicesOpt, extensionsOpt, extensionPointsOpt, componentChaptersOpt) {
@@ -350,7 +419,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
350
419
  let readModelDefs = readModels.map(R => {
351
420
  let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(name, R.Spec.name, undefined);
352
421
  let stateSchema = R.Spec.stateSchema;
353
- let match = labelFieldsFromStateSchema(R.Spec.name, stateSchema);
422
+ let label = labelFieldsFromStateSchema(R.Spec.name, stateSchema);
354
423
  let consumed = qualify(name, R.consumedEventNames);
355
424
  return {
356
425
  name: R.Spec.name,
@@ -358,9 +427,10 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
358
427
  schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(stateSchema)),
359
428
  consumedEventTypes: consumed,
360
429
  linkedWriteSide: linkedWriteSideFor(consumed),
361
- labelField: match[0],
362
- searchableFields: match[1],
363
- statusField: statusFieldFromStateSchema(stateSchema),
430
+ labelField: label.field,
431
+ searchableFields: label.searchableFields,
432
+ labelFieldSource: labelFieldSourceToString(label.source),
433
+ statusField: statusFieldFromStateSchema(R.Spec.name, stateSchema),
364
434
  visibility: visibilityTag(R.Spec.visibility),
365
435
  chapter: componentChapters[R.Spec.name]
366
436
  };
@@ -370,16 +440,17 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
370
440
  let match = svsConsumed[i];
371
441
  let consumed = match[1];
372
442
  let stateSchema = SVS.Spec.stateSchema;
373
- let match$1 = labelFieldsFromStateSchema(SVS.Spec.name, stateSchema);
443
+ let label = labelFieldsFromStateSchema(SVS.Spec.name, stateSchema);
374
444
  return {
375
445
  name: SVS.Spec.name,
376
446
  queryField: qf.listFieldName,
377
447
  schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(stateSchema)),
378
448
  consumedEventTypes: consumed,
379
449
  linkedWriteSide: linkedWriteSideFor(consumed),
380
- labelField: match$1[0],
381
- searchableFields: match$1[1],
382
- statusField: statusFieldFromStateSchema(stateSchema),
450
+ labelField: label.field,
451
+ searchableFields: label.searchableFields,
452
+ labelFieldSource: labelFieldSourceToString(label.source),
453
+ statusField: statusFieldFromStateSchema(SVS.Spec.name, stateSchema),
383
454
  visibility: visibilityTag(SVS.Spec.visibility),
384
455
  chapter: componentChapters[SVS.Spec.name]
385
456
  };
@@ -489,8 +560,12 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
489
560
 
490
561
  export {
491
562
  log,
492
- isIdLikeFieldName,
563
+ isLabelShape,
564
+ isStatusShape,
565
+ conventionalLabelNames,
566
+ shapeOfItem,
493
567
  statusFieldFromStateSchema,
568
+ labelFieldSourceToString,
494
569
  labelFieldsFromStateSchema,
495
570
  make,
496
571
  }
@@ -27,6 +27,7 @@ let qbl: queryableDef = {
27
27
  linkedWriteSide: ["Product"],
28
28
  labelField: "displayName",
29
29
  searchableFields: ["displayName"],
30
+ labelFieldSource: Some("annotation"),
30
31
  statusField: None,
31
32
  visibility: None,
32
33
  chapter: None,
@@ -228,6 +229,7 @@ describe("visibility filtering (deployed AutoUI hides Internal)", () => {
228
229
  linkedWriteSide: [],
229
230
  labelField: "name",
230
231
  searchableFields: ["name"],
232
+ labelFieldSource: Some("convention"),
231
233
  statusField: None,
232
234
  visibility: Some("Internal"),
233
235
  chapter: None,
@@ -270,6 +272,7 @@ describe("allowedStates + statusField populated", () => {
270
272
  linkedWriteSide: ["Plugin"],
271
273
  labelField: "name",
272
274
  searchableFields: ["name"],
275
+ labelFieldSource: Some("convention"),
273
276
  statusField: Some("status"),
274
277
  visibility: None,
275
278
  chapter: None,
@@ -31,6 +31,8 @@ let qbl_linkedWriteSide = ["Product"];
31
31
 
32
32
  let qbl_searchableFields = ["displayName"];
33
33
 
34
+ let qbl_labelFieldSource = "annotation";
35
+
34
36
  let qbl = {
35
37
  name: "Products",
36
38
  queryField: "Catalog_Products",
@@ -39,6 +41,7 @@ let qbl = {
39
41
  linkedWriteSide: qbl_linkedWriteSide,
40
42
  labelField: "displayName",
41
43
  searchableFields: qbl_searchableFields,
44
+ labelFieldSource: qbl_labelFieldSource,
42
45
  statusField: undefined,
43
46
  visibility: undefined,
44
47
  chapter: undefined
@@ -233,6 +236,7 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
233
236
  let internalQbl_consumedEventTypes = ["CatalogProductSynced"];
234
237
  let internalQbl_linkedWriteSide = [];
235
238
  let internalQbl_searchableFields = ["name"];
239
+ let internalQbl_labelFieldSource = "convention";
236
240
  let internalQbl_visibility = "Internal";
237
241
  let internalQbl = {
238
242
  name: "AvailableProducts",
@@ -242,6 +246,7 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
242
246
  linkedWriteSide: internalQbl_linkedWriteSide,
243
247
  labelField: "name",
244
248
  searchableFields: internalQbl_searchableFields,
249
+ labelFieldSource: internalQbl_labelFieldSource,
245
250
  statusField: undefined,
246
251
  visibility: internalQbl_visibility,
247
252
  chapter: undefined
@@ -298,6 +303,7 @@ globalThis.describe("allowedStates + statusField populated", () => {
298
303
  let qblWithStatus_consumedEventTypes = [];
299
304
  let qblWithStatus_linkedWriteSide = ["Plugin"];
300
305
  let qblWithStatus_searchableFields = ["name"];
306
+ let qblWithStatus_labelFieldSource = "convention";
301
307
  let qblWithStatus_statusField = "status";
302
308
  let qblWithStatus = {
303
309
  name: "Plugin",
@@ -307,6 +313,7 @@ globalThis.describe("allowedStates + statusField populated", () => {
307
313
  linkedWriteSide: qblWithStatus_linkedWriteSide,
308
314
  labelField: "name",
309
315
  searchableFields: qblWithStatus_searchableFields,
316
+ labelFieldSource: qblWithStatus_labelFieldSource,
310
317
  statusField: qblWithStatus_statusField,
311
318
  visibility: undefined,
312
319
  chapter: undefined
@@ -275,6 +275,252 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
275
275
  })
276
276
  })
277
277
 
278
+ // What a field *is* comes from `SchemaType`, the IR every other schema
279
+ // consumer reads, rather than from a `String(_)` match here. These pin the
280
+ // shapes that separates the two: a date, a reference, a semantic-carrying
281
+ // string and an optional one all match `String(_)` loosely or not at all, and
282
+ // each was answered wrong before.
283
+ describe("labelField — the shape rule", () => {
284
+ let labelOf = (~entityName="Test", schema) => {
285
+ let r = Plugin_Structure.labelFieldsFromStateSchema(~entityName, schema->S.castToUnknown)
286
+ (r.field, r.searchableFields)
287
+ }
288
+
289
+ testSync("a DateTime is not a name — a state whose only string is one falls to id", () => {
290
+ // The shipped `Orders` case: `placedAt` was added so date views have a
291
+ // field to key off, and a positional rule let it rename the entity.
292
+ let schema = S.schema(s =>
293
+ {
294
+ "orderId": s.matches(Reventless.Reference.to_("Order")),
295
+ "placedAt": s.matches(Reventless.DateTime.string),
296
+ "shippedAt": s.matches(Reventless.DateTime.string),
297
+ }
298
+ )
299
+ expect(labelOf(schema))->toEqual(("id", []))
300
+ })
301
+
302
+ testSync("a DateTime is skipped, not fatal — a later plain string still wins", () => {
303
+ let schema = S.schema(s =>
304
+ {
305
+ "placedAt": s.matches(Reventless.DateTime.string),
306
+ "reference": s.matches(S.string),
307
+ }
308
+ )
309
+ expect(labelOf(schema))->toEqual(("reference", ["reference"]))
310
+ })
311
+
312
+ testSync("an optional string is still the entity's name", () => {
313
+ let schema = S.schema(s =>
314
+ {
315
+ "nickname": s.matches(S.option(S.string)),
316
+ }
317
+ )
318
+ expect(labelOf(schema))->toEqual(("nickname", ["nickname"]))
319
+ })
320
+
321
+ testSync("a reference is not a name even when its field is not called `*Id`", () => {
322
+ let schema = S.schema(s =>
323
+ {
324
+ "customer": s.matches(Reventless.Reference.to_("Customer")),
325
+ "note": s.matches(S.string),
326
+ }
327
+ )
328
+ expect(labelOf(schema))->toEqual(("note", ["note"]))
329
+ })
330
+
331
+ testSync("a DCB-tagged string is not a name", () => {
332
+ let schema = S.schema(s =>
333
+ {
334
+ "partition": s.matches(Reventless.DcbTag.string),
335
+ "note": s.matches(S.string),
336
+ }
337
+ )
338
+ expect(labelOf(schema))->toEqual(("note", ["note"]))
339
+ })
340
+
341
+ testSync("the `*Id` test is case-insensitive — `productID` is a reference", () => {
342
+ let schema = S.schema(s =>
343
+ {
344
+ "productID": s.matches(S.string),
345
+ "note": s.matches(S.string),
346
+ }
347
+ )
348
+ expect(labelOf(schema))->toEqual(("note", ["note"]))
349
+ })
350
+
351
+ testSync("a semantic-carrying string is a value, not a name", () => {
352
+ // `imageUrl` names a stored object. A bucket key read as a product's name
353
+ // is what the previous rule did whenever no plain string preceded it.
354
+ let schema = S.schema(s =>
355
+ {
356
+ "imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages")),
357
+ "note": s.matches(S.string),
358
+ }
359
+ )
360
+ expect(labelOf(schema))->toEqual(("note", ["note"]))
361
+ })
362
+
363
+ testSync("a state whose only string is a semantic falls to id", () => {
364
+ let schema = S.schema(s =>
365
+ {
366
+ "imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages")),
367
+ }
368
+ )
369
+ expect(labelOf(schema))->toEqual(("id", []))
370
+ })
371
+
372
+ testSync("`id` itself is still excluded by name", () => {
373
+ // `SchemaType` reads a name that short as an ordinary string, so the
374
+ // exclusion cannot come from the IR.
375
+ let schema = S.schema(s =>
376
+ {
377
+ "id": s.matches(S.string),
378
+ "note": s.matches(S.string),
379
+ }
380
+ )
381
+ expect(labelOf(schema))->toEqual(("note", ["note"]))
382
+ })
383
+ })
384
+
385
+ describe("labelField — the conventional-name rung", () => {
386
+ let labelOf = schema => {
387
+ let r = Plugin_Structure.labelFieldsFromStateSchema(~entityName="Test", schema->S.castToUnknown)
388
+ (r.field, r.searchableFields)
389
+ }
390
+
391
+ testSync("a field named `name` beats an earlier string", () => {
392
+ let schema = S.schema(s =>
393
+ {
394
+ "sku": s.matches(S.string),
395
+ "name": s.matches(S.string),
396
+ }
397
+ )
398
+ expect(labelOf(schema))->toEqual(("name", ["name"]))
399
+ })
400
+
401
+ testSync("`title` counts too, case-insensitively", () => {
402
+ let schema = S.schema(s =>
403
+ {
404
+ "body": s.matches(S.string),
405
+ "Title": s.matches(S.string),
406
+ }
407
+ )
408
+ expect(labelOf(schema))->toEqual(("Title", ["Title"]))
409
+ })
410
+
411
+ testSync("the match is exact — `customerName` is a customer's name, not this record's", () => {
412
+ // The shipped `OrdersView` fixture depends on this: it has exactly one
413
+ // eligible field and must be picked by position, not by convention.
414
+ let schema = S.schema(s =>
415
+ {
416
+ "customerName": s.matches(S.string),
417
+ "note": s.matches(S.string),
418
+ }
419
+ )
420
+ expect(labelOf(schema))->toEqual(("customerName", ["customerName"]))
421
+ })
422
+
423
+ testSync("an optional `name` still outranks an earlier string", () => {
424
+ let schema = S.schema(s =>
425
+ {
426
+ "sku": s.matches(S.string),
427
+ "name": s.matches(S.option(S.string)),
428
+ }
429
+ )
430
+ expect(labelOf(schema))->toEqual(("name", ["name"]))
431
+ })
432
+ })
433
+
434
+ // The rung is published because the four are not equally believable: a
435
+ // consumer with a name rule of its own has to know whether it is ranking
436
+ // against a declaration or against a guess.
437
+ describe("labelFieldSource — which rung answered", () => {
438
+ let sourceOf = schema =>
439
+ Plugin_Structure.labelFieldsFromStateSchema(
440
+ ~entityName="Test",
441
+ schema->S.castToUnknown,
442
+ ).source->Plugin_Structure.labelFieldSourceToString
443
+
444
+ testSync("a @displayName spec is a declaration", () => {
445
+ let customers = structure.stateViewSlices->Array.getUnsafe(2)
446
+ expect((customers.labelField, customers.labelFieldSource))->toEqual((
447
+ "displayName",
448
+ Some("annotation"),
449
+ ))
450
+ })
451
+
452
+ testSync("a field named `name` is a guess the consumer can also make", () => {
453
+ let schema = S.schema(s =>
454
+ {
455
+ "sku": s.matches(S.string),
456
+ "name": s.matches(S.string),
457
+ }
458
+ )
459
+ expect(sourceOf(schema))->toEqual("convention")
460
+ })
461
+
462
+ testSync("declaration order is a guess only this side can make", () => {
463
+ // `customerName` is eligible and unconventional, so it wins by position —
464
+ // and a consumer told only "customerName" could not tell that from a
465
+ // declaration.
466
+ let schema = S.schema(s =>
467
+ {
468
+ "customerName": s.matches(S.string),
469
+ "note": s.matches(S.string),
470
+ }
471
+ )
472
+ expect(sourceOf(schema))->toEqual("position")
473
+ })
474
+
475
+ testSync("no candidate at all is the state saying it has no human field", () => {
476
+ let schema = S.schema(s =>
477
+ {
478
+ "orderId": s.matches(Reventless.Reference.to_("Order")),
479
+ "placedAt": s.matches(Reventless.DateTime.string),
480
+ }
481
+ )
482
+ expect(sourceOf(schema))->toEqual("fallback")
483
+ })
484
+
485
+ testSync("every built def states one", () => {
486
+ let stated =
487
+ Array.concat(structure.readModels, structure.stateViewSlices)->Array.every(q =>
488
+ q.labelFieldSource->Option.isSome
489
+ )
490
+ expect(stated)->toEqual(true)
491
+ })
492
+ })
493
+
494
+ describe("statusField — the shape rule", () => {
495
+ let statusOf = schema =>
496
+ Plugin_Structure.statusFieldFromStateSchema(~entityName="Test", schema->S.castToUnknown)
497
+
498
+ testSync("a `status` field holding a closed set of values is the lifecycle field", () => {
499
+ let schema = S.schema(s =>
500
+ {
501
+ "status": s.matches(S.union([S.literal("Placed"), S.literal("Shipped")])),
502
+ }
503
+ )
504
+ expect(statusOf(schema))->toEqual(Some("status"))
505
+ })
506
+
507
+ testSync("an optional one counts too", () => {
508
+ let schema = S.schema(s =>
509
+ {
510
+ "status": s.matches(S.option(S.union([S.literal("Placed"), S.literal("Shipped")]))),
511
+ }
512
+ )
513
+ expect(statusOf(schema))->toEqual(Some("status"))
514
+ })
515
+
516
+ testSync("free text named `status` is not a lifecycle", () => {
517
+ // `allowedStates` filtering needs states to compare against; a string
518
+ // field named `status` gives a command menu nothing to match.
519
+ let schema = S.schema(s => {"status": s.matches(S.string)})
520
+ expect(statusOf(schema))->toEqual(None)
521
+ })
522
+ })
523
+
278
524
  // Phase 4: queryableDef.schema must carry x-reventless-* extension keys for
279
525
  // annotated state types. Plugin_Structure now uses SuryToJsonSchema.deriveObjectSchema
280
526
  // (annotation-aware) instead of S.toJSONSchema (metadata-blind).
@@ -1,8 +1,13 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as S from "sury/src/S.res.mjs";
3
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
5
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
5
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
8
+ import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
9
+ import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
10
+ import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
6
11
  import * as PsShipOrder$ReventlessCore from "./StateChangeSlice/PsShipOrder.res.mjs";
7
12
  import * as PsOrdersView$ReventlessCore from "./StateViewSlice/PsOrdersView.res.mjs";
8
13
  import * as PsPlaceOrder$ReventlessCore from "./StateChangeSlice/PsPlaceOrder.res.mjs";
@@ -395,6 +400,218 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
395
400
  ]);
396
401
  });
397
402
  });
403
+ globalThis.describe("labelField — the shape rule", () => {
404
+ let labelOf = (entityNameOpt, schema) => {
405
+ let entityName = entityNameOpt !== undefined ? entityNameOpt : "Test";
406
+ let r = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema(entityName, schema);
407
+ return [
408
+ r.field,
409
+ r.searchableFields
410
+ ];
411
+ };
412
+ globalThis.test("a DateTime is not a name — a state whose only string is one falls to id", () => {
413
+ let schema = S.schema(s => ({
414
+ orderId: s.m(Reference$Reventless.to_(undefined, undefined, "Order")),
415
+ placedAt: s.m(DateTime$Reventless.string),
416
+ shippedAt: s.m(DateTime$Reventless.string)
417
+ }));
418
+ globalThis.expect(labelOf(undefined, schema)).toEqual([
419
+ "id",
420
+ []
421
+ ]);
422
+ });
423
+ globalThis.test("a DateTime is skipped, not fatal — a later plain string still wins", () => {
424
+ let schema = S.schema(s => ({
425
+ placedAt: s.m(DateTime$Reventless.string),
426
+ reference: s.m(S.string)
427
+ }));
428
+ globalThis.expect(labelOf(undefined, schema)).toEqual([
429
+ "reference",
430
+ ["reference"]
431
+ ]);
432
+ });
433
+ globalThis.test("an optional string is still the entity's name", () => {
434
+ let schema = S.schema(s => ({
435
+ nickname: s.m(S.option(S.string))
436
+ }));
437
+ globalThis.expect(labelOf(undefined, schema)).toEqual([
438
+ "nickname",
439
+ ["nickname"]
440
+ ]);
441
+ });
442
+ globalThis.test("a reference is not a name even when its field is not called `*Id`", () => {
443
+ let schema = S.schema(s => ({
444
+ customer: s.m(Reference$Reventless.to_(undefined, undefined, "Customer")),
445
+ note: s.m(S.string)
446
+ }));
447
+ globalThis.expect(labelOf(undefined, schema)).toEqual([
448
+ "note",
449
+ ["note"]
450
+ ]);
451
+ });
452
+ globalThis.test("a DCB-tagged string is not a name", () => {
453
+ let schema = S.schema(s => ({
454
+ partition: s.m(DcbTag$Reventless.string),
455
+ note: s.m(S.string)
456
+ }));
457
+ globalThis.expect(labelOf(undefined, schema)).toEqual([
458
+ "note",
459
+ ["note"]
460
+ ]);
461
+ });
462
+ globalThis.test("the `*Id` test is case-insensitive — `productID` is a reference", () => {
463
+ let schema = S.schema(s => ({
464
+ productID: s.m(S.string),
465
+ note: s.m(S.string)
466
+ }));
467
+ globalThis.expect(labelOf(undefined, schema)).toEqual([
468
+ "note",
469
+ ["note"]
470
+ ]);
471
+ });
472
+ globalThis.test("a semantic-carrying string is a value, not a name", () => {
473
+ let schema = S.schema(s => ({
474
+ imageUrl: s.m(StorageRef$Reventless.forStore(undefined, "productImages")),
475
+ note: s.m(S.string)
476
+ }));
477
+ globalThis.expect(labelOf(undefined, schema)).toEqual([
478
+ "note",
479
+ ["note"]
480
+ ]);
481
+ });
482
+ globalThis.test("a state whose only string is a semantic falls to id", () => {
483
+ let schema = S.schema(s => ({
484
+ imageUrl: s.m(StorageRef$Reventless.forStore(undefined, "productImages"))
485
+ }));
486
+ globalThis.expect(labelOf(undefined, schema)).toEqual([
487
+ "id",
488
+ []
489
+ ]);
490
+ });
491
+ globalThis.test("`id` itself is still excluded by name", () => {
492
+ let schema = S.schema(s => ({
493
+ id: s.m(S.string),
494
+ note: s.m(S.string)
495
+ }));
496
+ globalThis.expect(labelOf(undefined, schema)).toEqual([
497
+ "note",
498
+ ["note"]
499
+ ]);
500
+ });
501
+ });
502
+ globalThis.describe("labelField — the conventional-name rung", () => {
503
+ let labelOf = schema => {
504
+ let r = Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema("Test", schema);
505
+ return [
506
+ r.field,
507
+ r.searchableFields
508
+ ];
509
+ };
510
+ globalThis.test("a field named `name` beats an earlier string", () => {
511
+ let schema = S.schema(s => ({
512
+ sku: s.m(S.string),
513
+ name: s.m(S.string)
514
+ }));
515
+ globalThis.expect(labelOf(schema)).toEqual([
516
+ "name",
517
+ ["name"]
518
+ ]);
519
+ });
520
+ globalThis.test("`title` counts too, case-insensitively", () => {
521
+ let schema = S.schema(s => ({
522
+ body: s.m(S.string),
523
+ Title: s.m(S.string)
524
+ }));
525
+ globalThis.expect(labelOf(schema)).toEqual([
526
+ "Title",
527
+ ["Title"]
528
+ ]);
529
+ });
530
+ globalThis.test("the match is exact — `customerName` is a customer's name, not this record's", () => {
531
+ let schema = S.schema(s => ({
532
+ customerName: s.m(S.string),
533
+ note: s.m(S.string)
534
+ }));
535
+ globalThis.expect(labelOf(schema)).toEqual([
536
+ "customerName",
537
+ ["customerName"]
538
+ ]);
539
+ });
540
+ globalThis.test("an optional `name` still outranks an earlier string", () => {
541
+ let schema = S.schema(s => ({
542
+ sku: s.m(S.string),
543
+ name: s.m(S.option(S.string))
544
+ }));
545
+ globalThis.expect(labelOf(schema)).toEqual([
546
+ "name",
547
+ ["name"]
548
+ ]);
549
+ });
550
+ });
551
+ globalThis.describe("labelFieldSource — which rung answered", () => {
552
+ let sourceOf = schema => Plugin_Structure$ReventlessCore.labelFieldSourceToString(Plugin_Structure$ReventlessCore.labelFieldsFromStateSchema("Test", schema).source);
553
+ globalThis.test("a @displayName spec is a declaration", () => {
554
+ let customers = structure.stateViewSlices[2];
555
+ globalThis.expect([
556
+ customers.labelField,
557
+ customers.labelFieldSource
558
+ ]).toEqual([
559
+ "displayName",
560
+ "annotation"
561
+ ]);
562
+ });
563
+ globalThis.test("a field named `name` is a guess the consumer can also make", () => {
564
+ let schema = S.schema(s => ({
565
+ sku: s.m(S.string),
566
+ name: s.m(S.string)
567
+ }));
568
+ globalThis.expect(sourceOf(schema)).toEqual("convention");
569
+ });
570
+ globalThis.test("declaration order is a guess only this side can make", () => {
571
+ let schema = S.schema(s => ({
572
+ customerName: s.m(S.string),
573
+ note: s.m(S.string)
574
+ }));
575
+ globalThis.expect(sourceOf(schema)).toEqual("position");
576
+ });
577
+ globalThis.test("no candidate at all is the state saying it has no human field", () => {
578
+ let schema = S.schema(s => ({
579
+ orderId: s.m(Reference$Reventless.to_(undefined, undefined, "Order")),
580
+ placedAt: s.m(DateTime$Reventless.string)
581
+ }));
582
+ globalThis.expect(sourceOf(schema)).toEqual("fallback");
583
+ });
584
+ globalThis.test("every built def states one", () => {
585
+ let stated = structure.readModels.concat(structure.stateViewSlices).every(q => Stdlib_Option.isSome(q.labelFieldSource));
586
+ globalThis.expect(stated).toEqual(true);
587
+ });
588
+ });
589
+ globalThis.describe("statusField — the shape rule", () => {
590
+ globalThis.test("a `status` field holding a closed set of values is the lifecycle field", () => {
591
+ let schema = S.schema(s => ({
592
+ status: s.m(S.union([
593
+ S.literal("Placed"),
594
+ S.literal("Shipped")
595
+ ]))
596
+ }));
597
+ globalThis.expect(Plugin_Structure$ReventlessCore.statusFieldFromStateSchema("Test", schema)).toEqual("status");
598
+ });
599
+ globalThis.test("an optional one counts too", () => {
600
+ let schema = S.schema(s => ({
601
+ status: s.m(S.option(S.union([
602
+ S.literal("Placed"),
603
+ S.literal("Shipped")
604
+ ])))
605
+ }));
606
+ globalThis.expect(Plugin_Structure$ReventlessCore.statusFieldFromStateSchema("Test", schema)).toEqual("status");
607
+ });
608
+ globalThis.test("free text named `status` is not a lifecycle", () => {
609
+ let schema = S.schema(s => ({
610
+ status: s.m(S.string)
611
+ }));
612
+ globalThis.expect(Plugin_Structure$ReventlessCore.statusFieldFromStateSchema("Test", schema)).toEqual(undefined);
613
+ });
614
+ });
398
615
  globalThis.describe("queryableDef.schema propagates x-reventless-* annotations", () => {
399
616
  let getProperty = (json, key) => {
400
617
  let obj = Stdlib_JSON.Decode.object(json);