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

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 (53) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +6 -6
  3. package/src/Message.res +1 -1
  4. package/src/adapter/Monitoring/Monitoring.res +1 -1
  5. package/src/admin/Platform_Admin_Structure.res +5 -1
  6. package/src/admin/Platform_Admin_Structure.res.mjs +4 -2
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +4 -2
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +11 -3
  9. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res +1 -1
  10. package/src/components/Aggregate/Aggregate_Callback.res +2 -2
  11. package/src/components/Api/ApiAllowedStatesHelpers.res +2 -3
  12. package/src/components/Api/ApiTargetStateHelpers.res +4 -3
  13. package/src/components/Api/GraphQL_FragmentGenerator.res +14 -1
  14. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +1 -1
  15. package/src/components/Api/QueryDbListQuery.res +25 -1
  16. package/src/components/Api/QueryDbListQuery.res.mjs +11 -4
  17. package/src/components/Api/SuryToJsonSchema.res +39 -0
  18. package/src/components/Api/SuryToJsonSchema.res.mjs +34 -3
  19. package/src/components/Dcb/Dcb_Builder.res +79 -16
  20. package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
  21. package/src/components/EventLog/EventLog.res +1 -1
  22. package/src/plugin/component/Plugin_Structure.res +377 -18
  23. package/src/plugin/component/Plugin_Structure.res.mjs +204 -9
  24. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
  25. package/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs +3 -2
  26. package/tests/admin/Platform_Admin_StructureTest.res +49 -0
  27. package/tests/admin/Platform_Admin_StructureTest.res.mjs +24 -0
  28. package/tests/admin/Platform_BakedManifestTest.res +3 -1
  29. package/tests/admin/Platform_BakedManifestTest.res.mjs +3 -1
  30. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +14 -8
  31. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +24 -12
  32. package/tests/admin/Platform_PluginStructuresApiTest.res +3 -1
  33. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +6 -2
  34. package/tests/aggregate/AggregateCacheTest.res +1 -1
  35. package/tests/aggregate/AggregateSnapshotTest.res +1 -1
  36. package/tests/api/Api_IdsTest.res.mjs +1 -1
  37. package/tests/api/SuryToJsonSchemaTest.res +141 -1
  38. package/tests/api/SuryToJsonSchemaTest.res.mjs +269 -54
  39. package/tests/commandgenerator/OwnerStampingTest.res +57 -0
  40. package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
  41. package/tests/message/MessageTest.res +1 -1
  42. package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
  43. package/tests/plugin/PluginStructureTest.res +332 -15
  44. package/tests/plugin/PluginStructureTest.res.mjs +316 -10
  45. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
  46. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
  47. package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
  48. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
  49. package/tests/plugin/StateViewSlice/PsAnnotatedView.res.mjs +3 -2
  50. package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
  51. package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
  52. package/tests/plugin/pluginDefinitionRequiredScalars.txt +2 -0
  53. package/tests/util/CsvStreamTest.res +1 -1
@@ -422,13 +422,42 @@ function Make(DcbEventLogStorage) {
422
422
  ]);
423
423
  })).apply(pairs => Object.fromEntries(pairs));
424
424
  let dcbHandlerBase = DcbCommandTopic.makeFilteringHandler(dcbCommandTopic);
425
- let dcbGenerateCommandOutput = Component$ReventlessCore.operations(dcbCommandTopic).apply(ops => CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(ops.publishJsons, ops.publishJsonsAndWait, name, S.json, "StateChangeSlice", false));
425
+ let byTag = {};
426
+ stateChangeSlices.forEach(S => {
427
+ let commandSchema = S.Spec.commandSchema;
428
+ if (!ApiNoApiHelpers$ReventlessCore.isNoApi(commandSchema)) {
429
+ Api_Naming$ReventlessCore.sliceMutationFields(apiNamePrefix, S.Spec.name, commandSchema).forEach(param => {
430
+ let tag = param[1];
431
+ let match = byTag[tag];
432
+ if (match !== undefined) {
433
+ return log.error("Dcb_Builder", undefined, `Two DCB slices in "` + name + `" both declare the command "` + tag + `". ` + "Owner stamping resolves a command by that name alone, so it cannot tell them apart. Rename one of the constructors.");
434
+ } else {
435
+ byTag[tag] = commandSchema;
436
+ return;
437
+ }
438
+ });
439
+ return;
440
+ }
441
+ });
442
+ let dcbGenerateCommandOutput = Component$ReventlessCore.operations(dcbCommandTopic).apply(ops => {
443
+ let make = commandSchema => CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(ops.publishJsons, ops.publishJsonsAndWait, name, commandSchema, "StateChangeSlice", false);
444
+ let byTag$1 = {};
445
+ Object.entries(byTag).forEach(param => {
446
+ byTag$1[param[0]] = make(param[1]);
447
+ });
448
+ return [
449
+ byTag$1,
450
+ make(S.json)
451
+ ];
452
+ });
426
453
  let dcbHandler = Pulumi.all([
427
454
  dcbHandlerBase,
428
455
  inboundReceiversOutput,
429
456
  dcbGenerateCommandOutput
430
457
  ]).apply(param => {
431
- let generateCommand = param[2];
458
+ let match = param[2];
459
+ let generateFallback = match[1];
460
+ let generatorsByTag = match[0];
432
461
  let receivers = param[1];
433
462
  let baseHandler = param[0];
434
463
  return (event, ctx) => {
@@ -448,11 +477,18 @@ function Make(DcbEventLogStorage) {
448
477
  }
449
478
  let match$1 = event["command"];
450
479
  let match$2 = event["arguments"];
451
- if (typeof match$1 === "string" && match$2 !== undefined) {
452
- return Effect.map(generateCommand(event), msgId => msgId);
453
- } else {
480
+ if (match$1 === undefined) {
481
+ return baseHandler(event, ctx);
482
+ }
483
+ if (typeof match$1 !== "string") {
484
+ return baseHandler(event, ctx);
485
+ }
486
+ if (match$2 === undefined) {
454
487
  return baseHandler(event, ctx);
455
488
  }
489
+ let g = generatorsByTag[event.command];
490
+ let generateCommand = g !== undefined ? g : (log.warn("Dcb_Builder", undefined, `No DCB slice in "` + name + `" declares the command "` + event.command + `", ` + "so it is dispatched without an owner stamp. Any field the command marks as its owner keeps the value the caller sent."), generateFallback);
491
+ return Effect.map(generateCommand(event), msgId => msgId);
456
492
  };
457
493
  });
458
494
  let dcbResources = Object.values(stateChangeSlicesOutputs).flatMap(outputs => outputs.resources).concat(Object.values(inboundTranslationSlicesOutputs).flatMap(outputs => outputs.resources.concat(outputs.queryDb.resources)));
@@ -25,7 +25,7 @@ type appendError =
25
25
  // staleness — consumers ignore a snapshot whose hash differs from their current
26
26
  // state schema and fall back to full replay. Snapshots are a read optimization
27
27
  // only; the OCC append remains the sole consistency primitive
28
- // (docs/plans/aggregate-snapshotting.md).
28
+ // (docs/plans/done/aggregate-snapshotting.md).
29
29
  type snapshot = {seqNr: int, state: JSON.t, schemaHash: string}
30
30
 
31
31
  type append<'id, 'event> = (int, 'id, array<'event>) => promise<result<unit, appendError>>
@@ -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,326 @@ 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 — `@transition` 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.join(", ")
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 @transition 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.join(
159
+ ", ",
160
+ )}.`,
161
+ )
162
+ )
163
+ }
164
+ | _ => ()
165
+ }
166
+
167
+ // The states a record's lifecycle field can hold. The same extraction
168
+ // `checkRetiredValue` does, keyed on the declared lifecycle field rather than the
169
+ // retired one — which is the field a command's `@transition` is written in terms
170
+ // of. `None` means the record declares no lifecycle at all; `Some([])` means it
171
+ // declares one whose shape carries no cases to compare against.
172
+ let lifecycleStatesFromStateSchema = (
173
+ ~entityName: string,
174
+ stateSchema: S.t<unknown>,
175
+ ): option<array<string>> =>
176
+ lifecycleFieldFromStateSchema(~entityName, stateSchema)->Option.flatMap(field =>
177
+ switch stateSchema {
178
+ | Object({items}) =>
179
+ items
180
+ ->Array.find(item => item.location == field)
181
+ ->Option.map(item =>
182
+ switch shapeOfItem(~entityName, item) {
183
+ | Enum(_, values) => values
184
+ | Nullable(Enum(_, values)) => values
185
+ | _ => []
186
+ }
187
+ )
188
+ | _ => None
189
+ }
190
+ )
191
+
192
+ // The other check the PPX cannot make, and the reason `@transition` is worth
193
+ // more than a rename.
194
+ //
195
+ // A command declares the states it may run from and the state it lands in, but
196
+ // those states belong to ANOTHER component's lifecycle enum. The PPX only ever
197
+ // sees them as names — it strips the attribute before the typechecker, and a
198
+ // synthetic reference to the constructor does not survive ReScript's pre-PPX
199
+ // dependency walk. So a misspelled state compiles clean, ships, and produces a
200
+ // command that is legal in a state no row is ever in: a menu entry that never
201
+ // appears, with nothing anywhere saying why.
202
+ //
203
+ // Here both sides are in hand. This runs at plugin-structure assembly, which
204
+ // happens in the deploy program and at local-platform start — never inside a
205
+ // deployed Lambda, which reads a persisted structure rather than building one.
206
+ // So raising is a failed deploy, not a dead function.
207
+ //
208
+ // Two severities, and the split is deliberate:
209
+ // - a state the linked views do not declare → RAISE. The author named
210
+ // something that does not exist.
211
+ // - no resolvable linked view, or none declaring a lifecycle → warn. The
212
+ // metadata gap is real but hard-failing on it would turn "this plugin's
213
+ // views could not be resolved" into a deploy outage, and that population is
214
+ // broad.
215
+ //
216
+ // Checked against the UNION of the linked views' lifecycles rather than a single
217
+ // view: a command whose slice feeds two views is not claiming which one, and
218
+ // failing on an ambiguity the author never expressed would be a false positive
219
+ // on correct code.
220
+ let checkDeclaredTransitions = (
221
+ ~pluginName: string,
222
+ ~writables: array<Reventless.Plugin.writableDef>,
223
+ ~lifecycleStatesByView: dict<array<string>>,
224
+ ): unit => {
225
+ let unvalidated = ref(0)
226
+ let failures = []
227
+
228
+ writables->Array.forEach(w =>
229
+ w.commands->Array.forEach(cmd => {
230
+ let declared = Array.concat(
231
+ cmd.allowedStates->Option.getOr([]),
232
+ switch cmd.targetState {
233
+ | Some(t) => [t]
234
+ | None => []
235
+ },
236
+ )
237
+ if Array.length(declared) > 0 {
238
+ let known =
239
+ w.linkedViews->Array.reduce([], (acc, view) =>
240
+ switch lifecycleStatesByView->Dict.get(view) {
241
+ | Some(states) => Array.concat(acc, states)
242
+ | None => acc
243
+ }
244
+ )
245
+ if Array.length(known) == 0 {
246
+ unvalidated := unvalidated.contents + 1
247
+ } else {
248
+ declared
249
+ ->Array.filter(state => !(known->Array.includes(state)))
250
+ ->Array.forEach(state =>
251
+ failures
252
+ ->Array.push(
253
+ `${w.name}.${cmd.name}: @transition names "${state}", which none of its ` ++
254
+ `linked views declare — ${w.linkedViews->Array.join(
255
+ ", ",
256
+ )} know ${known->Array.join(", ")}.`,
257
+ )
258
+ ->ignore
259
+ )
260
+ }
261
+ }
262
+ })
263
+ )
264
+
265
+ // Reported rather than silent: a plugin nothing could be checked against looks
266
+ // exactly like a plugin that passed, and that population is the one most
267
+ // likely to be carrying a stale name.
268
+ if unvalidated.contents > 0 {
269
+ log.warn(
270
+ ~comp="Plugin_Structure",
271
+ `${pluginName}: ${unvalidated.contents->Int.toString} command(s) declare a @transition ` ++
272
+ `but no linked view declares a lifecycle to check it against.`,
273
+ )
274
+ }
275
+
276
+ if Array.length(failures) > 0 {
277
+ JsError.throwWithMessage(
278
+ `${pluginName}: @transition names states that do not exist.\n` ++
279
+ failures->Array.join("\n"),
280
+ )
281
+ }
282
+ }
283
+
284
+ // Beyond "does this state exist" — does the declared graph make sense?
285
+ //
286
+ // A separate pass from the name check on purpose. That one asks whether a name
287
+ // is a case of an enum, which is a question about one annotation in isolation.
288
+ // These ask whether the annotations AGREE with each other across an entity, and
289
+ // a graph can be built entirely out of valid names and still be wrong: a state
290
+ // nothing reaches, or one nothing can leave that was never marked as an ending.
291
+ //
292
+ // Reported as warnings rather than raised. The name check fails a build because
293
+ // a state that does not exist is unambiguously a mistake — there is no domain in
294
+ // which it is what the author meant. These are weaker signals: a state with no
295
+ // way out may be a genuine dead end nobody has marked yet, or a legitimate
296
+ // terminal the model reaches by a route this metadata cannot see (an automation,
297
+ // an external system). Failing a deploy on a modelling smell would be the wrong
298
+ // trade, and a smell that stops a deploy gets silenced rather than fixed.
299
+ // Pure, and returns its findings rather than logging them, so the rule can be
300
+ // tested without reading a log. `checkLifecycleTopology` below is the thin part
301
+ // that reports them.
302
+ let lifecycleTopologyFindings = (
303
+ ~writables: array<Reventless.Plugin.writableDef>,
304
+ ~lifecycleStatesByView: dict<array<string>>,
305
+ ): array<(string, string)> => {
306
+ let findings = []
307
+ lifecycleStatesByView
308
+ ->Dict.toArray
309
+ ->Array.forEach(((view, states)) => {
310
+ // Every edge any command declares into or out of this view's lifecycle.
311
+ let edges = writables->Array.reduce([], (acc, w) =>
312
+ w.linkedViews->Array.includes(view)
313
+ ? Array.concat(
314
+ acc,
315
+ w.commands->Array.reduce([], (inner, cmd) =>
316
+ switch (cmd.allowedStates, cmd.targetState) {
317
+ | (Some(froms), Some(to)) =>
318
+ Array.concat(inner, froms->Array.map(from => (from, to)))
319
+ | _ => inner
320
+ }
321
+ ),
322
+ )
323
+ : acc
324
+ )
325
+ if Array.length(edges) > 0 {
326
+ // Rows start in the first declared state — the same convention the
327
+ // lifecycle diagram uses — so nothing pointing at it is expected rather
328
+ // than suspicious.
329
+ let initial = states->Array.get(0)
330
+ let reachable = edges->Array.map(((_, to)) => to)
331
+
332
+ states->Array.forEach(state => {
333
+ if !(reachable->Array.includes(state)) && Some(state) != initial {
334
+ findings
335
+ ->Array.push((
336
+ view,
337
+ `no command declares a transition INTO "${state}" — it is unreachable ` ++
338
+ `unless something outside this plugin's declarations puts a row there.`,
339
+ ))
340
+ ->ignore
341
+ }
342
+ // NOT checked: a state with no way out.
343
+ //
344
+ // The obvious second rule — "a dead end that is not `@retired` is
345
+ // suspicious" — was written, run against the shipped examples, and
346
+ // removed, because it is wrong twice over.
347
+ //
348
+ // It fires on correct models: `Shipped` and `Refunded` are terminal in
349
+ // the aggregates shop, as terminal states are in most lifecycles, and
350
+ // there is nothing to fix about either.
351
+ //
352
+ // Worse, its suggested fix is harmful. `@retired` does not mean
353
+ // "terminal" — it means WITHDRAWN FROM ORDINARY READS. Marking a shipped
354
+ // order retired to silence a lint would hide every shipped order from
355
+ // every caller who cannot widen their read. An ending and a withdrawal
356
+ // are different facts, and nothing in the vocabulary currently
357
+ // distinguishes an intentional terminal from an accidental one, so the
358
+ // check cannot tell them apart and should not pretend to.
359
+ })
360
+ }
361
+ })
362
+ findings
363
+ }
364
+
365
+ let checkLifecycleTopology = (
366
+ ~pluginName: string,
367
+ ~writables: array<Reventless.Plugin.writableDef>,
368
+ ~lifecycleStatesByView: dict<array<string>>,
369
+ ): unit =>
370
+ lifecycleTopologyFindings(~writables, ~lifecycleStatesByView)->Array.forEach(((view, message)) =>
371
+ log.warn(~comp="Plugin_Structure", `${pluginName}/${view}: ${message}`)
372
+ )
373
+
374
+ // NOT here: the event-consumption completeness check.
375
+ //
376
+ // The failure it would catch is real and has already happened: a command emitted
377
+ // an event, the slice that owned the opposite command folded it, and the view
378
+ // that renders the entity did not — so the row kept rendering the state it was
379
+ // in before, with every annotation in the plugin correct. That is a class of bug
380
+ // no declaration check can see, because nothing declared is wrong.
381
+ //
382
+ // It is not computable from this metadata, and the reason is worth recording so
383
+ // the attempt is not repeated. Two narrowings were needed and only one was
384
+ // available:
385
+ //
386
+ // 1. The event must belong to the view's own entity, not merely be something a
387
+ // slice looked up. Available: require it to be PRODUCED by a writable
388
+ // linked to the same view. Without this the check fires on ordinary DCB —
389
+ // `AddProduct` folds `CategoryAdded` to check a category exists, and the
390
+ // `Products` view is right to ignore it.
391
+ //
392
+ // 2. The slice must be known to fold the event. NOT available:
393
+ // `consumedEventTypes` is built from `eventVariantNames`, which drops
394
+ // payload-less variants — and a lifecycle-moving event is usually
395
+ // payload-less in the slice that folds it. Measured on the shipped example:
396
+ // both order slices publish `consumedEventTypes: ["Ordering.OrderPlaced"]`
397
+ // and nothing else, though each folds three more.
398
+ //
399
+ // So the exact events the rule is about are the ones the metadata does not
400
+ // record. Making it work means publishing the payload-less consumed variants,
401
+ // which is a platform change with its own consequences, not a lint.
402
+
76
403
  // Which rung of the ladder below produced the label. Published on `queryableDef`
77
404
  // as `labelFieldSource`, because the four rungs are not equally believable and a
78
405
  // consumer with a name rule of its own has to rank the declaration against it:
@@ -247,7 +574,7 @@ let make = (
247
574
  // spec name has no entry (or lives directly under a kind-folder) carries no chapter
248
575
  // and renders flat. Keyed by `Spec.name`, which equals the source filename stem for
249
576
  // every graph-node kind, so the generator can build this map from the discovered
250
- // file paths. See `Codegen.chapterOf` and docs/plans/deployed-chapter-grouping.md.
577
+ // file paths. See `Codegen.chapterOf` and docs/plans/done/deployed-chapter-grouping.md.
251
578
  ~componentChapters: dict<string>=Dict.make(),
252
579
  ): Reventless.Plugin.pluginStructure => {
253
580
  let chapterOf = (compName: string): option<string> => componentChapters->Dict.get(compName)
@@ -363,9 +690,9 @@ let make = (
363
690
  // Per-variant `allowedStates` lives on the *parent* command schema
364
691
  // (the PPX attaches a single dict<variantName, [|states|]> via
365
692
  // markAllowedStates). Look it up by variant name; back-compat
366
- // None when the variant lacks an @allowedStates annotation.
693
+ // None when the variant lacks a @transition annotation.
367
694
  let allowedStates = ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName)
368
- // Declared `@targetState` (the command's *to* status), read the same way
695
+ // The `@transition` target (the command's *to* status), read the same way
369
696
  // as allowedStates. None ⇒ AutoUI's board resolver falls back to its
370
697
  // name-stem heuristic.
371
698
  let targetState = ApiTargetStateHelpers.getTargetState(parentSchema, ~variantName)
@@ -733,6 +1060,17 @@ let make = (
733
1060
  | Internal => Some("Internal")
734
1061
  }
735
1062
 
1063
+ // View name -> the states its lifecycle field can hold, collected as the view
1064
+ // defs are built so the transition check below has both sides in one place.
1065
+ let lifecycleStatesByView: dict<array<string>> = Dict.make()
1066
+ let recordLifecycle = (~entityName, stateSchema) => {
1067
+ switch lifecycleStatesFromStateSchema(~entityName, stateSchema) {
1068
+ | Some(states) if Array.length(states) > 0 =>
1069
+ lifecycleStatesByView->Dict.set(entityName, states)
1070
+ | _ => ()
1071
+ }
1072
+ }
1073
+
736
1074
  let readModelDefs =
737
1075
  readModels
738
1076
  ->Array.map((
@@ -752,6 +1090,8 @@ let make = (
752
1090
  // edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
753
1091
  // aggregate→view link is also drawn from the producer's linkedViews, deduped downstream).
754
1092
  let consumed = qualify(~prefix=name, R.consumedEventNames)
1093
+ checkRetiredValue(~entityName=R.Spec.name, stateSchema)
1094
+ recordLifecycle(~entityName=R.Spec.name, stateSchema)
755
1095
  ({
756
1096
  Reventless.Plugin.name: R.Spec.name,
757
1097
  queryField: qf.listFieldName,
@@ -761,10 +1101,12 @@ let make = (
761
1101
  labelField: label.field,
762
1102
  searchableFields: label.searchableFields,
763
1103
  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
1104
+ lifecycleField: lifecycleFieldFromStateSchema(~entityName=R.Spec.name, stateSchema),
1105
+ // Same schema `lifecycleField` reads, so the two cannot disagree about which
766
1106
  // fields this view has.
767
1107
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
1108
+ retiredField: retiredFieldFromStateSchema(stateSchema),
1109
+ retiredValues: retiredValuesFromStateSchema(stateSchema),
768
1110
  visibility: visibilityTag(R.Spec.visibility),
769
1111
  chapter: chapterOf(R.Spec.name),
770
1112
  // Taken from the `qf` record, never re-derived: `Api_Naming` is the only
@@ -787,6 +1129,8 @@ let make = (
787
1129
  ~entityName=SVS.Spec.name,
788
1130
  stateSchema,
789
1131
  )
1132
+ checkRetiredValue(~entityName=SVS.Spec.name, stateSchema)
1133
+ recordLifecycle(~entityName=SVS.Spec.name, stateSchema)
790
1134
  ({
791
1135
  Reventless.Plugin.name: SVS.Spec.name,
792
1136
  queryField: qf.listFieldName,
@@ -796,8 +1140,10 @@ let make = (
796
1140
  labelField: label.field,
797
1141
  searchableFields: label.searchableFields,
798
1142
  labelFieldSource: Some(labelFieldSourceToString(label.source)),
799
- statusField: statusFieldFromStateSchema(~entityName=SVS.Spec.name, stateSchema),
1143
+ lifecycleField: lifecycleFieldFromStateSchema(~entityName=SVS.Spec.name, stateSchema),
800
1144
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
1145
+ retiredField: retiredFieldFromStateSchema(stateSchema),
1146
+ retiredValues: retiredValuesFromStateSchema(stateSchema),
801
1147
  visibility: visibilityTag(SVS.Spec.visibility),
802
1148
  chapter: chapterOf(SVS.Spec.name),
803
1149
  singleQueryField: Some(qf.singleFieldName),
@@ -955,6 +1301,19 @@ let make = (
955
1301
  commandTypes: Some(dedupe(cmds)),
956
1302
  }: Reventless.Plugin.extensionPointDef))
957
1303
 
1304
+ // Second pass, on purpose: commands are built well before `linkedViews` is
1305
+ // assembled, so the check cannot run inline where the defs are made.
1306
+ checkDeclaredTransitions(
1307
+ ~pluginName=name,
1308
+ ~writables=Array.concat(stateChangeDefs, aggregateDefs),
1309
+ ~lifecycleStatesByView,
1310
+ )
1311
+ checkLifecycleTopology(
1312
+ ~pluginName=name,
1313
+ ~writables=Array.concat(stateChangeDefs, aggregateDefs),
1314
+ ~lifecycleStatesByView,
1315
+ )
1316
+
958
1317
  {
959
1318
  readModels: readModelDefs,
960
1319
  stateViewSlices: stateViewDefs,