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

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 (30) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +7 -7
  3. package/src/admin/Platform_Admin_Structure.res +5 -1
  4. package/src/admin/Platform_Admin_Structure.res.mjs +4 -2
  5. package/src/admin/Platform_ComponentDefinitionsApi.res +4 -2
  6. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +11 -3
  7. package/src/components/Api/GraphQL_FragmentGenerator.res +14 -1
  8. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +1 -1
  9. package/src/components/Api/QueryDbListQuery.res +25 -1
  10. package/src/components/Api/QueryDbListQuery.res.mjs +11 -4
  11. package/src/components/Api/SuryToJsonSchema.res +39 -0
  12. package/src/components/Api/SuryToJsonSchema.res.mjs +34 -3
  13. package/src/plugin/component/Plugin_Structure.res +112 -15
  14. package/src/plugin/component/Plugin_Structure.res.mjs +77 -9
  15. package/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs +3 -2
  16. package/tests/admin/Platform_Admin_StructureTest.res +49 -0
  17. package/tests/admin/Platform_Admin_StructureTest.res.mjs +24 -0
  18. package/tests/admin/Platform_BakedManifestTest.res +3 -1
  19. package/tests/admin/Platform_BakedManifestTest.res.mjs +3 -1
  20. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +14 -8
  21. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +24 -12
  22. package/tests/admin/Platform_PluginStructuresApiTest.res +3 -1
  23. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +6 -2
  24. package/tests/api/Api_IdsTest.res.mjs +1 -1
  25. package/tests/api/SuryToJsonSchemaTest.res +141 -1
  26. package/tests/api/SuryToJsonSchemaTest.res.mjs +269 -54
  27. package/tests/plugin/PluginStructureTest.res +168 -12
  28. package/tests/plugin/PluginStructureTest.res.mjs +146 -9
  29. package/tests/plugin/StateViewSlice/PsAnnotatedView.res.mjs +3 -2
  30. package/tests/plugin/pluginDefinitionRequiredScalars.txt +2 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
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.237 (2026-08-16)
7
+
8
+ ### Features
9
+
10
+ * **core:** exclude retired rows from reads a caller may not widen ([662f31a](https://github.com/ReventlessDev/reventless-core/commit/662f31abb717bda5154199d349da6dcf8e2d3e78))
11
+ * **core:** let [@retired](https://github.com/retired) name a lifecycle state, not only a boolean ([6bb346b](https://github.com/ReventlessDev/reventless-core/commit/6bb346b4f6a5f33826fc24537953482a76067177))
12
+ * **core:** mark the state that retires a row, and allow more than one ([cb1461f](https://github.com/ReventlessDev/reventless-core/commit/cb1461f024d3ca3b53fd9c8b010a054e3fcc4555))
13
+ * **core:** publish queryableDef.retiredField from the [@retired](https://github.com/retired) annotation ([b44436a](https://github.com/ReventlessDev/reventless-core/commit/b44436a997b6c4ff0531f0b07d793cc858eef94a))
14
+ * **spec:** [@retired](https://github.com/retired) state-field annotation and its schema emission ([2d8234b](https://github.com/ReventlessDev/reventless-core/commit/2d8234b6b3dd8f479031a67eb5b4b47b5c0c2ff9))
15
+
16
+
6
17
  # 3.0.0-alpha.236 (2026-08-15)
7
18
 
8
19
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.236",
3
+ "version": "3.0.0-alpha.237",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,21 +29,21 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.7",
33
- "@reventlessdev/rescript-node": "2.0.0-alpha.7",
34
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
35
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
33
+ "@reventlessdev/rescript-node": "2.0.0-alpha.7",
34
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.7",
36
35
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
37
36
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.7",
37
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
38
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.142",
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.143",
40
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.114"
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.115"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-alpha.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.66"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.67"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "12.3.0"
@@ -122,7 +122,9 @@ let pluginReadModel: queryableDef = {
122
122
  // Hand-rolled rather than resolved, but the rung is the same one
123
123
  // `labelFieldsFromStateSchema` would report for a field literally named `name`.
124
124
  labelFieldSource: Some("convention"),
125
- statusField: Some("status"),
125
+ // The published wire name stays `status` — it is what the SDL declares and what
126
+ // stored rows carry. Only the key naming it moved.
127
+ lifecycleField: Some("status"),
126
128
  visibility: None,
127
129
  chapter: None,
128
130
  // The admin fragment hand-declares its query names rather than deriving them from
@@ -137,6 +139,8 @@ let pluginReadModel: queryableDef = {
137
139
  idFieldSource: None,
138
140
  requiredAccess: None,
139
141
  ownerField: None,
142
+ retiredField: None,
143
+ retiredValues: None,
140
144
  }
141
145
 
142
146
  let structure: pluginStructure = {
@@ -137,7 +137,7 @@ let pluginReadModel_searchableFields = ["name"];
137
137
 
138
138
  let pluginReadModel_labelFieldSource = "convention";
139
139
 
140
- let pluginReadModel_statusField = "status";
140
+ let pluginReadModel_lifecycleField = "status";
141
141
 
142
142
  let pluginReadModel_singleQueryField = Api_Naming$ReventlessCore.adminField("Plugin");
143
143
 
@@ -150,8 +150,10 @@ let pluginReadModel = {
150
150
  labelField: "name",
151
151
  searchableFields: pluginReadModel_searchableFields,
152
152
  labelFieldSource: pluginReadModel_labelFieldSource,
153
- statusField: pluginReadModel_statusField,
153
+ lifecycleField: pluginReadModel_lifecycleField,
154
154
  ownerField: undefined,
155
+ retiredField: undefined,
156
+ retiredValues: undefined,
155
157
  visibility: undefined,
156
158
  chapter: undefined,
157
159
  singleQueryField: pluginReadModel_singleQueryField,
@@ -29,7 +29,7 @@ let sdlTypes: array<string> = [
29
29
  // structures persisted before the fields existed decode as `None`, a hand-rolled
30
30
  // def may decline to state them, and a state whose key cannot be resolved has no
31
31
  // `idField` to report.
32
- `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 visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n}`,
32
+ `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 lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n}`,
33
33
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
34
34
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
35
35
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
@@ -113,7 +113,7 @@ let encodeQueryableDef = (r: queryableDef): JSON.t =>
113
113
  "labelFieldSource",
114
114
  r.labelFieldSource->Option.mapOr(JSON.Encode.null, JSON.Encode.string),
115
115
  ),
116
- ("statusField", r.statusField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
116
+ ("lifecycleField", r.lifecycleField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
117
117
  // Carried rather than only filtered on: a tooling consumer reading the
118
118
  // unfiltered structure needs to know WHICH components are Internal, and a
119
119
  // consumer of the filtered query reads null here because nothing Internal
@@ -128,6 +128,8 @@ let encodeQueryableDef = (r: queryableDef): JSON.t =>
128
128
  ("idFieldSource", r.idFieldSource->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
129
129
  ("requiredAccess", r.requiredAccess->Option.mapOr(JSON.Encode.null, encodeStrings)),
130
130
  ("ownerField", r.ownerField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
131
+ ("retiredField", r.retiredField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
132
+ ("retiredValues", r.retiredValues->Option.mapOr(JSON.Encode.null, encodeStrings)),
131
133
  ])->JSON.Encode.object
132
134
 
133
135
  let encodeEventDef = (e: eventDef): JSON.t =>
@@ -10,7 +10,7 @@ let sdlTypes = [
10
10
  `type Platform_EventDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
11
11
  `type Platform_ErrorDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
12
12
  `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 errors: [Platform_ErrorDef!]!\n chapter: String\n}`,
13
- `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 visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n}`,
13
+ `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 lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n}`,
14
14
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
15
15
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
16
16
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
@@ -130,8 +130,8 @@ function encodeQueryableDef(r) {
130
130
  Stdlib_Option.mapOr(r.labelFieldSource, null, prim => prim)
131
131
  ],
132
132
  [
133
- "statusField",
134
- Stdlib_Option.mapOr(r.statusField, null, prim => prim)
133
+ "lifecycleField",
134
+ Stdlib_Option.mapOr(r.lifecycleField, null, prim => prim)
135
135
  ],
136
136
  [
137
137
  "visibility",
@@ -160,6 +160,14 @@ function encodeQueryableDef(r) {
160
160
  [
161
161
  "ownerField",
162
162
  Stdlib_Option.mapOr(r.ownerField, null, prim => prim)
163
+ ],
164
+ [
165
+ "retiredField",
166
+ Stdlib_Option.mapOr(r.retiredField, null, prim => prim)
167
+ ],
168
+ [
169
+ "retiredValues",
170
+ Stdlib_Option.mapOr(r.retiredValues, null, encodeStrings)
163
171
  ]
164
172
  ]);
165
173
  }
@@ -436,6 +436,19 @@ let deriveByIdsQueryField = (
436
436
  ): string =>
437
437
  ` ${listFieldName}ByIds(ids: [String!]!): [${returnTypeName}!]!`
438
438
 
439
+ // `includeRetired` sits beside the paging arguments rather than inside `filter`,
440
+ // and that placement is the point. `filter` is the caller's description of the
441
+ // rows they want; this is a request to lift a restriction the server placed on
442
+ // them, which the server grants or ignores on its own terms. Putting it in the
443
+ // filter input would also require the field to be a declared filter field, which
444
+ // would publish `<field>Eq` to callers who can only ever get an empty page from
445
+ // it.
446
+ //
447
+ // Emitted unconditionally, on every connection field, whether or not the view
448
+ // declares a retirement flag. A view that declares none ignores it, and the
449
+ // alternative — an argument that appears and disappears with the annotation —
450
+ // makes adding `@retired` a breaking schema change for every client that had
451
+ // already learned the field's shape.
439
452
  let deriveConnectionQueryField = (
440
453
  ~listFieldName: string,
441
454
  ~singularTypeName: string,
@@ -443,7 +456,7 @@ let deriveConnectionQueryField = (
443
456
  ~hasOrderBy: bool=false,
444
457
  ): string => {
445
458
  let orderByArg = hasOrderBy ? `, orderBy: ${singularTypeName}OrderBy` : ""
446
- ` ${listFieldName}(filter: ${filterTypeName}${orderByArg}, first: Int, after: String, last: Int, before: String): ${singularTypeName}Connection!`
459
+ ` ${listFieldName}(filter: ${filterTypeName}${orderByArg}, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): ${singularTypeName}Connection!`
447
460
  }
448
461
 
449
462
  // ── Mutation field derivation ──────────────────────────────────────────────
@@ -374,7 +374,7 @@ function deriveByIdsQueryField(listFieldName, returnTypeName) {
374
374
  function deriveConnectionQueryField(listFieldName, singularTypeName, filterTypeName, hasOrderByOpt) {
375
375
  let hasOrderBy = hasOrderByOpt !== undefined ? hasOrderByOpt : false;
376
376
  let orderByArg = hasOrderBy ? `, orderBy: ` + singularTypeName + `OrderBy` : "";
377
- return ` ` + listFieldName + `(filter: ` + filterTypeName + orderByArg + `, first: Int, after: String, last: Int, before: String): ` + singularTypeName + `Connection!`;
377
+ return ` ` + listFieldName + `(filter: ` + filterTypeName + orderByArg + `, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): ` + singularTypeName + `Connection!`;
378
378
  }
379
379
 
380
380
  function deriveMutationFieldFromObject(fieldName, collectedTypes, seenTypes, variantSchema) {
@@ -35,6 +35,17 @@ let getId = (item: JSON.t): string =>
35
35
  // String form of a field for comparison / cursor purposes: the string itself,
36
36
  // or a number rendered via Float.toString, else None. The push-down must render
37
37
  // the same string in SQL (CAST … AS TEXT) for cursor/order parity.
38
+ // A boolean field's value, or None when the row does not carry one. Separate
39
+ // from `getFieldString` rather than reusing it: JSON `true` stringifies to
40
+ // nothing useful, and a retirement predicate read off a coerced string would
41
+ // treat `"false"` — a legitimate value for a field someone stored as text — as
42
+ // truthy and hide the row.
43
+ let getFieldBool = (item: JSON.t, field: string): option<bool> =>
44
+ item
45
+ ->JSON.Decode.object
46
+ ->Option.flatMap(d => d->Dict.get(field))
47
+ ->Option.flatMap(JSON.Decode.bool)
48
+
38
49
  let getFieldString = (item: JSON.t, field: string): option<string> =>
39
50
  item
40
51
  ->JSON.Decode.object
@@ -97,6 +108,7 @@ let run = (
97
108
  ~labelField: string,
98
109
  ~decodeLocalId: string => option<string>=Api_Ids.toLocalId,
99
110
  ~ownerScope: option<(string, string)>=?,
111
+ ~retiredScope: option<Reventless.OwnerScope.retiredScope>=?,
100
112
  ): JSON.t => {
101
113
  let filterDict =
102
114
  argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
@@ -185,7 +197,19 @@ let run = (
185
197
  getFieldString(item, field)->Option.mapOr(false, v => v == required)
186
198
  | None => true
187
199
  }
188
- passSearch && passPrefix && passIds && passPerField && passOwner
200
+ // The missing-field rule lives in `OwnerScope.isRetiredValue`, beside the two
201
+ // forms it has to answer for; the note on why it lands the opposite way from
202
+ // `passOwner` is there.
203
+ let passRetired = switch retiredScope {
204
+ | Some(scope) =>
205
+ !(
206
+ scope->Reventless.OwnerScope.isRetiredValue(
207
+ item->JSON.Decode.object->Option.flatMap(d => d->Dict.get(scope.field)),
208
+ )
209
+ )
210
+ | None => true
211
+ }
212
+ passSearch && passPrefix && passIds && passPerField && passOwner && passRetired
189
213
  })
190
214
 
191
215
  let orderByDict = argsDict->Dict.get("orderBy")->Option.flatMap(JSON.Decode.object)
@@ -5,6 +5,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js";
7
7
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
8
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
8
9
  import * as Api_Ids$ReventlessCore from "./Api_Ids.res.mjs";
9
10
 
10
11
  function encodeCursor(value) {
@@ -19,6 +20,10 @@ function getId(item) {
19
20
  return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d["id"]), Stdlib_JSON.Decode.string), "");
20
21
  }
21
22
 
23
+ function getFieldBool(item, field) {
24
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[field]), Stdlib_JSON.Decode.bool);
25
+ }
26
+
22
27
  function getFieldString(item, field) {
23
28
  return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[field]), v => {
24
29
  let s = Stdlib_JSON.Decode.string(v);
@@ -48,7 +53,7 @@ function buildConnection(pageItems, hasNextPage, hasPreviousPage, cursorValueOf)
48
53
  };
49
54
  }
50
55
 
51
- function run(items, argsDict, capability, labelField, decodeLocalIdOpt, ownerScope) {
56
+ function run(items, argsDict, capability, labelField, decodeLocalIdOpt, ownerScope, retiredScope) {
52
57
  let decodeLocalId = decodeLocalIdOpt !== undefined ? decodeLocalIdOpt : Api_Ids$ReventlessCore.toLocalId;
53
58
  let filterDict = Stdlib_Option.getOr(Stdlib_Option.flatMap(argsDict["filter"], Stdlib_JSON.Decode.object), {});
54
59
  let search = Stdlib_Option.flatMap(filterDict["search"], Stdlib_JSON.Decode.string);
@@ -102,8 +107,9 @@ function run(items, argsDict, capability, labelField, decodeLocalIdOpt, ownerSco
102
107
  } else {
103
108
  passOwner = true;
104
109
  }
105
- if (passSearch && passPrefix && passIds && passPerField) {
106
- return passOwner;
110
+ let passRetired = retiredScope !== undefined ? !OwnerScope$Reventless.isRetiredValue(retiredScope, Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[retiredScope.field])) : true;
111
+ if (passSearch && passPrefix && passIds && passPerField && passOwner) {
112
+ return passRetired;
107
113
  } else {
108
114
  return false;
109
115
  }
@@ -214,8 +220,9 @@ export {
214
220
  decodeCursor,
215
221
  defaultListPageSize,
216
222
  getId,
223
+ getFieldBool,
217
224
  getFieldString,
218
225
  buildConnection,
219
226
  run,
220
227
  }
221
- /* No side effect */
228
+ /* OwnerScope-Reventless Not a pure module */
@@ -101,6 +101,45 @@ let mergeAnnotations = (
101
101
  }
102
102
  | None => ()
103
103
  }
104
+ switch spec.retired {
105
+ | Some(r) if r.field === fieldName =>
106
+ // `x-reventless-retired: {label?, showWhenFalse, values?, value?}`. The
107
+ // label is omitted when empty, the way `x-reventless-metric` omits its own,
108
+ // so a consumer deriving one from the field name can tell "not stated" from
109
+ // "stated as empty". `showWhenFalse` always travels: it is a decision the
110
+ // annotation author made either way, and its default is not the consumer's
111
+ // to pick.
112
+ //
113
+ // `values` follows the same omit-rather-than-write-empty rule, and its
114
+ // absence is what says which form this is: absent ⇒ the row is retired when
115
+ // the field is `true`; present ⇒ retired when the field's value is in the
116
+ // set, and the field is the record's lifecycle.
117
+ //
118
+ // The singular `value` rides alongside for one release, and only where the
119
+ // set has exactly one member — there is no singular reading of two. It
120
+ // exists to close a window, not to support two shapes: a consumer pinned
121
+ // before the set degrades to nothing at all rather than to a missing badge.
122
+ // Enforcement never depended on it — the resolvers exclude retired rows
123
+ // whatever the consumer understands — so the whole of the lag is cosmetic.
124
+ // Drop it once no consumer predating the set can be reached.
125
+ let entries = [("showWhenFalse", JSON.Encode.bool(r.showWhenFalse))]
126
+ let entries = switch r.values {
127
+ | Some(vs) =>
128
+ let entries = Array.concat(
129
+ entries,
130
+ [("values", JSON.Encode.array(vs->Array.map(JSON.Encode.string)))],
131
+ )
132
+ switch vs {
133
+ | [only] => Array.concat(entries, [("value", JSON.Encode.string(only))])
134
+ | _ => entries
135
+ }
136
+ | None => entries
137
+ }
138
+ let entries =
139
+ r.label === "" ? entries : Array.concat([("label", JSON.Encode.string(r.label))], entries)
140
+ obj->Dict.set("x-reventless-retired", JSON.Encode.object(Dict.fromArray(entries)))
141
+ | _ => ()
142
+ }
104
143
  switch spec.metric->Array.find(((field, _)) => field === fieldName) {
105
144
  | Some((_, m)) =>
106
145
  // `x-reventless-metric: {aggregate, label}`. Omit an empty label so the
@@ -90,18 +90,49 @@ function mergeAnnotations(fieldSchema, fieldName, spec) {
90
90
  obj["x-reventless-semantic"] = semanticId;
91
91
  }
92
92
  }
93
+ let r = spec.retired;
94
+ if (r !== undefined && r.field === fieldName) {
95
+ let entries = [[
96
+ "showWhenFalse",
97
+ r.showWhenFalse
98
+ ]];
99
+ let vs = r.values;
100
+ let entries$1;
101
+ if (vs !== undefined) {
102
+ let entries$2 = entries.concat([[
103
+ "values",
104
+ vs.map(prim => prim)
105
+ ]]);
106
+ if (vs.length !== 1) {
107
+ entries$1 = entries$2;
108
+ } else {
109
+ let only = vs[0];
110
+ entries$1 = entries$2.concat([[
111
+ "value",
112
+ only
113
+ ]]);
114
+ }
115
+ } else {
116
+ entries$1 = entries;
117
+ }
118
+ let entries$3 = r.label === "" ? entries$1 : [[
119
+ "label",
120
+ r.label
121
+ ]].concat(entries$1);
122
+ obj["x-reventless-retired"] = Object.fromEntries(entries$3);
123
+ }
93
124
  let match$4 = spec.metric.find(param => param[0] === fieldName);
94
125
  if (match$4 !== undefined) {
95
126
  let m = match$4[1];
96
- let entries = [[
127
+ let entries$4 = [[
97
128
  "aggregate",
98
129
  m.aggregate
99
130
  ]];
100
- let entries$1 = m.label === "" ? entries : entries.concat([[
131
+ let entries$5 = m.label === "" ? entries$4 : entries$4.concat([[
101
132
  "label",
102
133
  m.label
103
134
  ]]);
104
- obj["x-reventless-metric"] = Object.fromEntries(entries$1);
135
+ obj["x-reventless-metric"] = Object.fromEntries(entries$5);
105
136
  }
106
137
  return obj;
107
138
  }
@@ -26,13 +26,13 @@ let rec isLabelShape = (t: SchemaType.schemaType): bool =>
26
26
  | _ => false
27
27
  }
28
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 =>
29
+ // Whether a field can hold a lifecycle: a closed set of values, or an optional
30
+ // one. A free-text `lifecycle: string` is not a lifecycle — filtering a command
31
+ // menu against `allowedStates` needs states to compare with.
32
+ let rec isLifecycleShape = (t: SchemaType.schemaType): bool =>
33
33
  switch t {
34
34
  | Enum(_, _) => true
35
- | Nullable(inner) => isStatusShape(inner)
35
+ | Nullable(inner) => isLifecycleShape(inner)
36
36
  | _ => false
37
37
  }
38
38
 
@@ -44,18 +44,25 @@ let conventionalLabelNames = ["name", "title", "label", "displayname"]
44
44
  let shapeOfItem = (~entityName: string, item: S.item): SchemaType.schemaType =>
45
45
  SchemaType.fromSury(~parentName=entityName, ~fieldName=item.location, item.schema)
46
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:
49
- // 1. Field annotated `@status` (PPX-emitted; see StateAnnotations).
50
- // 2. Field literally named `"status"` whose IR shape is an enum (convention;
47
+ // Resolve the field that holds the entity's lifecycle, used to filter a per-row
48
+ // command menu against each command's `allowedStates`. Resolution order:
49
+ // 1. Field annotated `@lifecycle` (PPX-emitted; see StateAnnotations).
50
+ // 2. Field literally named `"lifecycle"` whose IR shape is an enum (convention;
51
51
  // mirrors how labelField falls back to a conventionally-named field).
52
52
  // 3. None — filter is inert for this read model.
53
- let statusFieldFromStateSchema = (
53
+ //
54
+ // The convention rung is keyed on `lifecycle` rather than `status` deliberately:
55
+ // `status` is a promiscuous name — geocoding progress, todo-queue progress and
56
+ // translation audit outcome all wear it — so a convention keyed on it guesses,
57
+ // and guesses often. `lifecycle` is a word nobody types by accident, so matching
58
+ // it is closer to a declaration written in the field name. A record whose field
59
+ // is honestly called something else annotates instead.
60
+ let lifecycleFieldFromStateSchema = (
54
61
  ~entityName: string,
55
62
  stateSchema: S.t<unknown>,
56
63
  ): option<string> => {
57
64
  let annotated = switch Reventless.StateAnnotations.getSpec(stateSchema) {
58
- | Some(spec) => spec.status
65
+ | Some(spec) => spec.lifecycle
59
66
  | None => None
60
67
  }
61
68
  switch annotated {
@@ -65,7 +72,7 @@ let statusFieldFromStateSchema = (
65
72
  | Object({items}) =>
66
73
  items
67
74
  ->Array.find(item =>
68
- item.location == "status" && isStatusShape(shapeOfItem(~entityName, item))
75
+ item.location == "lifecycle" && isLifecycleShape(shapeOfItem(~entityName, item))
69
76
  )
70
77
  ->Option.map(item => item.location)
71
78
  | _ => None
@@ -73,6 +80,90 @@ let statusFieldFromStateSchema = (
73
80
  }
74
81
  }
75
82
 
83
+ // The field whose truth withdraws a row from ordinary reads. Annotation-only:
84
+ // there is no convention rung here, and its absence is the point. `lifecycleField`
85
+ // may fall back to a field literally named "lifecycle" because guessing wrong makes
86
+ // a command menu filter oddly; guessing wrong here makes rows vanish for every
87
+ // caller who is not elevated, so a boolean named `archived` that nobody annotated
88
+ // stays exactly as visible as it was.
89
+ let retiredFromStateSchema = (
90
+ stateSchema: S.t<unknown>,
91
+ ): option<Reventless.StateAnnotations.retiredSpec> =>
92
+ switch Reventless.StateAnnotations.getSpec(stateSchema) {
93
+ | Some(spec) => spec.retired
94
+ | None => None
95
+ }
96
+
97
+ let retiredFieldFromStateSchema = (stateSchema: S.t<unknown>): option<string> =>
98
+ retiredFromStateSchema(stateSchema)->Option.map(r => r.field)
99
+
100
+ // The states a row is retired *in*, for the enum form. `None` is the boolean
101
+ // form, where the value is always `true` and naming it would be a parameter that
102
+ // can only hold one thing. A set, because a lifecycle may be withdrawn by more
103
+ // than one state, and a one-member set is the ordinary case.
104
+ //
105
+ // Published beside `retiredField` rather than left for a consumer to dig out of
106
+ // the schema: a client that has the def in hand has the whole predicate, and two
107
+ // places deriving one comparison is how they come to disagree about it.
108
+ let retiredValuesFromStateSchema = (stateSchema: S.t<unknown>): option<array<string>> =>
109
+ retiredFromStateSchema(stateSchema)->Option.flatMap(r => r.values)
110
+
111
+ // The check the PPX cannot make, in the one place that can: the payload is a
112
+ // constructor reference the PPX only ever sees as a name, and whether that name
113
+ // is a case of the field's enum needs the schema.
114
+ //
115
+ // Two rules, and the second is the one the form exists for. A `value` on a field
116
+ // that is not the record's lifecycle would keep the read narrowing while silently
117
+ // losing the command filtering that motivates it — `@allowedStates` is written in
118
+ // terms of the lifecycle field, so a retirement state anywhere else is a state no
119
+ // command can name.
120
+ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =>
121
+ switch retiredFromStateSchema(stateSchema) {
122
+ | Some({field, values: Some(values)}) =>
123
+ let named = values->Array.joinWith(", ")
124
+ let lifecycle = lifecycleFieldFromStateSchema(~entityName, stateSchema)
125
+ if lifecycle != Some(field) {
126
+ log.warn(
127
+ ~comp="Plugin_Structure",
128
+ `${entityName}: @retired(${named}) is on "${field}", which is not this record's lifecycle field${lifecycle
129
+ ->Option.map(f => ` (that is "${f}")`)
130
+ ->Option.getOr(
131
+ " (it declares none)",
132
+ )}. A retirement state no command's @allowedStates can name loses the command filtering the state form exists for.`,
133
+ )
134
+ }
135
+ let declared = switch stateSchema {
136
+ | Object({items}) =>
137
+ items
138
+ ->Array.find(item => item.location == field)
139
+ ->Option.map(item =>
140
+ switch shapeOfItem(~entityName, item) {
141
+ | Enum(_, values) => values
142
+ | Nullable(Enum(_, values)) => values
143
+ | _ => []
144
+ }
145
+ )
146
+ ->Option.getOr([])
147
+ | _ => []
148
+ }
149
+ // Reported per state rather than as a set: one wrong entry among three still
150
+ // narrows something, so the symptom is a subset of rows leaking rather than
151
+ // all of them — which is harder to spot than the single-value case was.
152
+ if Array.length(declared) > 0 {
153
+ values
154
+ ->Array.filter(v => !(declared->Array.includes(v)))
155
+ ->Array.forEach(v =>
156
+ log.warn(
157
+ ~comp="Plugin_Structure",
158
+ `${entityName}: @retired(${v}) names a state "${field}" does not declare — known values: ${declared->Array.joinWith(
159
+ ", ",
160
+ )}.`,
161
+ )
162
+ )
163
+ }
164
+ | _ => ()
165
+ }
166
+
76
167
  // Which rung of the ladder below produced the label. Published on `queryableDef`
77
168
  // as `labelFieldSource`, because the four rungs are not equally believable and a
78
169
  // consumer with a name rule of its own has to rank the declaration against it:
@@ -752,6 +843,7 @@ let make = (
752
843
  // edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
753
844
  // aggregate→view link is also drawn from the producer's linkedViews, deduped downstream).
754
845
  let consumed = qualify(~prefix=name, R.consumedEventNames)
846
+ checkRetiredValue(~entityName=R.Spec.name, stateSchema)
755
847
  ({
756
848
  Reventless.Plugin.name: R.Spec.name,
757
849
  queryField: qf.listFieldName,
@@ -761,10 +853,12 @@ let make = (
761
853
  labelField: label.field,
762
854
  searchableFields: label.searchableFields,
763
855
  labelFieldSource: Some(labelFieldSourceToString(label.source)),
764
- statusField: statusFieldFromStateSchema(~entityName=R.Spec.name, stateSchema),
765
- // Same schema `statusField` reads, so the two cannot disagree about which
856
+ lifecycleField: lifecycleFieldFromStateSchema(~entityName=R.Spec.name, stateSchema),
857
+ // Same schema `lifecycleField` reads, so the two cannot disagree about which
766
858
  // fields this view has.
767
859
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
860
+ retiredField: retiredFieldFromStateSchema(stateSchema),
861
+ retiredValues: retiredValuesFromStateSchema(stateSchema),
768
862
  visibility: visibilityTag(R.Spec.visibility),
769
863
  chapter: chapterOf(R.Spec.name),
770
864
  // Taken from the `qf` record, never re-derived: `Api_Naming` is the only
@@ -787,6 +881,7 @@ let make = (
787
881
  ~entityName=SVS.Spec.name,
788
882
  stateSchema,
789
883
  )
884
+ checkRetiredValue(~entityName=SVS.Spec.name, stateSchema)
790
885
  ({
791
886
  Reventless.Plugin.name: SVS.Spec.name,
792
887
  queryField: qf.listFieldName,
@@ -796,8 +891,10 @@ let make = (
796
891
  labelField: label.field,
797
892
  searchableFields: label.searchableFields,
798
893
  labelFieldSource: Some(labelFieldSourceToString(label.source)),
799
- statusField: statusFieldFromStateSchema(~entityName=SVS.Spec.name, stateSchema),
894
+ lifecycleField: lifecycleFieldFromStateSchema(~entityName=SVS.Spec.name, stateSchema),
800
895
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
896
+ retiredField: retiredFieldFromStateSchema(stateSchema),
897
+ retiredValues: retiredValuesFromStateSchema(stateSchema),
801
898
  visibility: visibilityTag(SVS.Spec.visibility),
802
899
  chapter: chapterOf(SVS.Spec.name),
803
900
  singleQueryField: Some(qf.singleFieldName),