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

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 (49) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +8 -8
  3. package/src/Message.res +1 -1
  4. package/src/adapter/Monitoring/Monitoring.res +1 -1
  5. package/src/admin/Platform_Admin_Structure.res +1 -0
  6. package/src/admin/Platform_Admin_Structure.res.mjs +1 -0
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +2 -1
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  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 +122 -12
  14. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +41 -8
  15. package/src/components/Api/SuryToJsonSchema.res +8 -0
  16. package/src/components/Api/SuryToJsonSchema.res.mjs +8 -4
  17. package/src/components/Dcb/Dcb_Builder.res +79 -16
  18. package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
  19. package/src/components/EventLog/EventLog.res +1 -1
  20. package/src/plugin/component/Plugin_Builder.res +7 -0
  21. package/src/plugin/component/Plugin_Builder.res.mjs +3 -1
  22. package/src/plugin/component/Plugin_Structure.res +376 -27
  23. package/src/plugin/component/Plugin_Structure.res.mjs +175 -8
  24. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
  25. package/tests/admin/Platform_BakedManifestTest.res +1 -0
  26. package/tests/admin/Platform_BakedManifestTest.res.mjs +1 -0
  27. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +3 -0
  28. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +6 -0
  29. package/tests/admin/Platform_PluginStructuresApiTest.res +1 -0
  30. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +2 -0
  31. package/tests/aggregate/AggregateCacheTest.res +1 -1
  32. package/tests/aggregate/AggregateSnapshotTest.res +1 -1
  33. package/tests/api/GraphQL_FragmentGeneratorTest.res +171 -0
  34. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +108 -0
  35. package/tests/api/SuryToJsonSchemaTest.res +30 -3
  36. package/tests/api/SuryToJsonSchemaTest.res.mjs +49 -4
  37. package/tests/commandgenerator/OwnerStampingTest.res +57 -0
  38. package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
  39. package/tests/message/MessageTest.res +1 -1
  40. package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
  41. package/tests/plugin/PluginStructureTest.res +268 -4
  42. package/tests/plugin/PluginStructureTest.res.mjs +284 -3
  43. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
  44. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
  45. package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
  46. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
  47. package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
  48. package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
  49. package/tests/util/CsvStreamTest.res +1 -1
@@ -108,19 +108,57 @@ let retiredFieldFromStateSchema = (stateSchema: S.t<unknown>): option<string> =>
108
108
  let retiredValuesFromStateSchema = (stateSchema: S.t<unknown>): option<array<string>> =>
109
109
  retiredFromStateSchema(stateSchema)->Option.flatMap(r => r.values)
110
110
 
111
+ // Whether a reference to a retired row of this view still resolves its name —
112
+ // `@namedWhenRetired`. Read off the retirement rather than from a second
113
+ // annotation, so a record cannot declare the reach of a retirement it does not
114
+ // have; the PPX refuses that pairing, and reading it here from the same place
115
+ // keeps the two halves agreeing by construction rather than by review.
116
+ let namedWhenRetiredFromStateSchema = (stateSchema: S.t<unknown>): bool =>
117
+ retiredFromStateSchema(stateSchema)->Option.mapOr(false, r => r.namedWhenRetired)
118
+
119
+ // What one record's `@retired` declaration could be told about its own field.
120
+ //
121
+ // Three outcomes rather than a bool, because "nothing to check" and "could not
122
+ // check" are different facts and only the second is worth a plugin's attention.
123
+ type retiredCheck =
124
+ | NotDeclared
125
+ // Why the names could not be compared. A fatal rule that is invisible when it
126
+ // does not run is the failure mode the transition check spends a counter to
127
+ // avoid, so this is reported rather than skipped in silence.
128
+ | Unchecked(string)
129
+ | Checked(array<string>)
130
+
111
131
  // The check the PPX cannot make, in the one place that can: the payload is a
112
132
  // constructor reference the PPX only ever sees as a name, and whether that name
113
133
  // is a case of the field's enum needs the schema.
114
134
  //
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 =>
135
+ // Two rules, and they are held to different standards on purpose.
136
+ //
137
+ // **A name the field's enum does not declare is unambiguously wrong** — no domain
138
+ // means it — and the symptom is a data-exposure bug: the retirement predicate
139
+ // compares every row against a state no row is ever in, so every row stays
140
+ // visible to every caller while the annotation sits on the schema looking like
141
+ // enforcement. That is returned as a failure for the caller to raise on.
142
+ //
143
+ // It is the same fault the PPX already refuses to compile when the enum is
144
+ // declared in the same file, and the PPX says so in its own message. This is the
145
+ // residue that a per-file pass cannot reach: field form, enum imported from
146
+ // elsewhere. Two rungs of one ladder — until this was promoted, which rung you
147
+ // landed on decided whether a data-exposure bug stopped the build, and the
148
+ // arbiter was where the enum happened to be declared.
149
+ //
150
+ // **A `value` on a field that is not the record's lifecycle stays a warning.**
151
+ // It would keep the read narrowing while silently losing the command filtering
152
+ // that motivates it — `@transition` is written in terms of the lifecycle field,
153
+ // so a retirement state anywhere else is a state no command can name. That is a
154
+ // modelling judgement rather than a wrong name, and judgement calls are what the
155
+ // withdrawn dead-end rule taught us not to hard-fail on.
156
+ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): retiredCheck =>
121
157
  switch retiredFromStateSchema(stateSchema) {
158
+ // The boolean form names no state, so there is nothing to compare. Not a skip.
159
+ | None | Some({values: None}) => NotDeclared
122
160
  | Some({field, values: Some(values)}) =>
123
- let named = values->Array.joinWith(", ")
161
+ let named = values->Array.join(", ")
124
162
  let lifecycle = lifecycleFieldFromStateSchema(~entityName, stateSchema)
125
163
  if lifecycle != Some(field) {
126
164
  log.warn(
@@ -129,7 +167,7 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =
129
167
  ->Option.map(f => ` (that is "${f}")`)
130
168
  ->Option.getOr(
131
169
  " (it declares none)",
132
- )}. A retirement state no command's @allowedStates can name loses the command filtering the state form exists for.`,
170
+ )}. A retirement state no command's @transition can name loses the command filtering the state form exists for.`,
133
171
  )
134
172
  }
135
173
  let declared = switch stateSchema {
@@ -146,24 +184,292 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =
146
184
  ->Option.getOr([])
147
185
  | _ => []
148
186
  }
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
- )
187
+ if Array.length(declared) == 0 {
188
+ Unchecked(
189
+ `${entityName}: @retired(${named}) is on "${field}", whose shape carries no cases to check the names against.`,
190
+ )
191
+ } else {
192
+ // Reported per state rather than as a set: one wrong entry among three still
193
+ // narrows something, so the symptom is a subset of rows leaking rather than
194
+ // all of them — which is harder to spot than the single-value case was.
195
+ Checked(
196
+ values
197
+ ->Array.filter(v => !(declared->Array.includes(v)))
198
+ ->Array.map(
199
+ v =>
200
+ `${entityName}: @retired(${v}) names a state "${field}" does not declare — known values: ${declared->Array.join(
201
+ ", ",
202
+ )}.`,
203
+ ),
162
204
  )
163
205
  }
164
- | _ => ()
165
206
  }
166
207
 
208
+ // Raised together, after every view has been walked, so an author sees every bad
209
+ // name at once rather than the first one and then a rebuild.
210
+ //
211
+ // Retroactive in a way the transition check was not: `@transition` was new when
212
+ // its check landed, so nothing deployed could carry a stale name, while `@retired`
213
+ // has been shipping. A deployed plugin holding a misspelled retired value gets a
214
+ // red build on its next deploy — which is the point, and is why the examples were
215
+ // swept before this was promoted.
216
+ let reportRetiredStates = (
217
+ ~pluginName: string,
218
+ ~failures: array<string>,
219
+ ~unchecked: array<string>,
220
+ ): unit => {
221
+ if Array.length(unchecked) > 0 {
222
+ log.warn(
223
+ ~comp="Plugin_Structure",
224
+ `${pluginName}: ${unchecked
225
+ ->Array.length
226
+ ->Int.toString} @retired declaration(s) could not be checked.\n` ++
227
+ unchecked->Array.join("\n"),
228
+ )
229
+ }
230
+ if Array.length(failures) > 0 {
231
+ JsError.throwWithMessage(
232
+ `${pluginName}: @retired names states that do not exist.\n` ++ failures->Array.join("\n"),
233
+ )
234
+ }
235
+ }
236
+
237
+ // The states a record's lifecycle field can hold. The same extraction
238
+ // `checkRetiredValue` does, keyed on the declared lifecycle field rather than the
239
+ // retired one — which is the field a command's `@transition` is written in terms
240
+ // of. `None` means the record declares no lifecycle at all; `Some([])` means it
241
+ // declares one whose shape carries no cases to compare against.
242
+ let lifecycleStatesFromStateSchema = (
243
+ ~entityName: string,
244
+ stateSchema: S.t<unknown>,
245
+ ): option<array<string>> =>
246
+ lifecycleFieldFromStateSchema(~entityName, stateSchema)->Option.flatMap(field =>
247
+ switch stateSchema {
248
+ | Object({items}) =>
249
+ items
250
+ ->Array.find(item => item.location == field)
251
+ ->Option.map(item =>
252
+ switch shapeOfItem(~entityName, item) {
253
+ | Enum(_, values) => values
254
+ | Nullable(Enum(_, values)) => values
255
+ | _ => []
256
+ }
257
+ )
258
+ | _ => None
259
+ }
260
+ )
261
+
262
+ // The other check the PPX cannot make, and the reason `@transition` is worth
263
+ // more than a rename.
264
+ //
265
+ // A command declares the states it may run from and the state it lands in, but
266
+ // those states belong to ANOTHER component's lifecycle enum. The PPX only ever
267
+ // sees them as names — it strips the attribute before the typechecker, and a
268
+ // synthetic reference to the constructor does not survive ReScript's pre-PPX
269
+ // dependency walk. So a misspelled state compiles clean, ships, and produces a
270
+ // command that is legal in a state no row is ever in: a menu entry that never
271
+ // appears, with nothing anywhere saying why.
272
+ //
273
+ // Here both sides are in hand. This runs at plugin-structure assembly, which
274
+ // happens in the deploy program and at local-platform start — never inside a
275
+ // deployed Lambda, which reads a persisted structure rather than building one.
276
+ // So raising is a failed deploy, not a dead function.
277
+ //
278
+ // Two severities, and the split is deliberate:
279
+ // - a state the linked views do not declare → RAISE. The author named
280
+ // something that does not exist.
281
+ // - no resolvable linked view, or none declaring a lifecycle → warn. The
282
+ // metadata gap is real but hard-failing on it would turn "this plugin's
283
+ // views could not be resolved" into a deploy outage, and that population is
284
+ // broad.
285
+ //
286
+ // Checked against the UNION of the linked views' lifecycles rather than a single
287
+ // view: a command whose slice feeds two views is not claiming which one, and
288
+ // failing on an ambiguity the author never expressed would be a false positive
289
+ // on correct code.
290
+ let checkDeclaredTransitions = (
291
+ ~pluginName: string,
292
+ ~writables: array<Reventless.Plugin.writableDef>,
293
+ ~lifecycleStatesByView: dict<array<string>>,
294
+ ): unit => {
295
+ let unvalidated = ref(0)
296
+ let failures = []
297
+
298
+ writables->Array.forEach(w =>
299
+ w.commands->Array.forEach(cmd => {
300
+ let declared = Array.concat(
301
+ cmd.allowedStates->Option.getOr([]),
302
+ switch cmd.targetState {
303
+ | Some(t) => [t]
304
+ | None => []
305
+ },
306
+ )
307
+ if Array.length(declared) > 0 {
308
+ let known =
309
+ w.linkedViews->Array.reduce([], (acc, view) =>
310
+ switch lifecycleStatesByView->Dict.get(view) {
311
+ | Some(states) => Array.concat(acc, states)
312
+ | None => acc
313
+ }
314
+ )
315
+ if Array.length(known) == 0 {
316
+ unvalidated := unvalidated.contents + 1
317
+ } else {
318
+ declared
319
+ ->Array.filter(state => !(known->Array.includes(state)))
320
+ ->Array.forEach(state =>
321
+ failures
322
+ ->Array.push(
323
+ `${w.name}.${cmd.name}: @transition names "${state}", which none of its ` ++
324
+ `linked views declare — ${w.linkedViews->Array.join(
325
+ ", ",
326
+ )} know ${known->Array.join(", ")}.`,
327
+ )
328
+ ->ignore
329
+ )
330
+ }
331
+ }
332
+ })
333
+ )
334
+
335
+ // Reported rather than silent: a plugin nothing could be checked against looks
336
+ // exactly like a plugin that passed, and that population is the one most
337
+ // likely to be carrying a stale name.
338
+ if unvalidated.contents > 0 {
339
+ log.warn(
340
+ ~comp="Plugin_Structure",
341
+ `${pluginName}: ${unvalidated.contents->Int.toString} command(s) declare a @transition ` ++
342
+ `but no linked view declares a lifecycle to check it against.`,
343
+ )
344
+ }
345
+
346
+ if Array.length(failures) > 0 {
347
+ JsError.throwWithMessage(
348
+ `${pluginName}: @transition names states that do not exist.\n` ++
349
+ failures->Array.join("\n"),
350
+ )
351
+ }
352
+ }
353
+
354
+ // Beyond "does this state exist" — does the declared graph make sense?
355
+ //
356
+ // A separate pass from the name check on purpose. That one asks whether a name
357
+ // is a case of an enum, which is a question about one annotation in isolation.
358
+ // These ask whether the annotations AGREE with each other across an entity, and
359
+ // a graph can be built entirely out of valid names and still be wrong: a state
360
+ // nothing reaches, or one nothing can leave that was never marked as an ending.
361
+ //
362
+ // Reported as warnings rather than raised. The name check fails a build because
363
+ // a state that does not exist is unambiguously a mistake — there is no domain in
364
+ // which it is what the author meant. These are weaker signals: a state with no
365
+ // way out may be a genuine dead end nobody has marked yet, or a legitimate
366
+ // terminal the model reaches by a route this metadata cannot see (an automation,
367
+ // an external system). Failing a deploy on a modelling smell would be the wrong
368
+ // trade, and a smell that stops a deploy gets silenced rather than fixed.
369
+ // Pure, and returns its findings rather than logging them, so the rule can be
370
+ // tested without reading a log. `checkLifecycleTopology` below is the thin part
371
+ // that reports them.
372
+ let lifecycleTopologyFindings = (
373
+ ~writables: array<Reventless.Plugin.writableDef>,
374
+ ~lifecycleStatesByView: dict<array<string>>,
375
+ ): array<(string, string)> => {
376
+ let findings = []
377
+ lifecycleStatesByView
378
+ ->Dict.toArray
379
+ ->Array.forEach(((view, states)) => {
380
+ // Every edge any command declares into or out of this view's lifecycle.
381
+ let edges = writables->Array.reduce([], (acc, w) =>
382
+ w.linkedViews->Array.includes(view)
383
+ ? Array.concat(
384
+ acc,
385
+ w.commands->Array.reduce([], (inner, cmd) =>
386
+ switch (cmd.allowedStates, cmd.targetState) {
387
+ | (Some(froms), Some(to)) =>
388
+ Array.concat(inner, froms->Array.map(from => (from, to)))
389
+ | _ => inner
390
+ }
391
+ ),
392
+ )
393
+ : acc
394
+ )
395
+ if Array.length(edges) > 0 {
396
+ // Rows start in the first declared state — the same convention the
397
+ // lifecycle diagram uses — so nothing pointing at it is expected rather
398
+ // than suspicious.
399
+ let initial = states->Array.get(0)
400
+ let reachable = edges->Array.map(((_, to)) => to)
401
+
402
+ states->Array.forEach(state => {
403
+ if !(reachable->Array.includes(state)) && Some(state) != initial {
404
+ findings
405
+ ->Array.push((
406
+ view,
407
+ `no command declares a transition INTO "${state}" — it is unreachable ` ++
408
+ `unless something outside this plugin's declarations puts a row there.`,
409
+ ))
410
+ ->ignore
411
+ }
412
+ // NOT checked: a state with no way out.
413
+ //
414
+ // The obvious second rule — "a dead end that is not `@retired` is
415
+ // suspicious" — was written, run against the shipped examples, and
416
+ // removed, because it is wrong twice over.
417
+ //
418
+ // It fires on correct models: `Shipped` and `Refunded` are terminal in
419
+ // the aggregates shop, as terminal states are in most lifecycles, and
420
+ // there is nothing to fix about either.
421
+ //
422
+ // Worse, its suggested fix is harmful. `@retired` does not mean
423
+ // "terminal" — it means WITHDRAWN FROM ORDINARY READS. Marking a shipped
424
+ // order retired to silence a lint would hide every shipped order from
425
+ // every caller who cannot widen their read. An ending and a withdrawal
426
+ // are different facts, and nothing in the vocabulary currently
427
+ // distinguishes an intentional terminal from an accidental one, so the
428
+ // check cannot tell them apart and should not pretend to.
429
+ })
430
+ }
431
+ })
432
+ findings
433
+ }
434
+
435
+ let checkLifecycleTopology = (
436
+ ~pluginName: string,
437
+ ~writables: array<Reventless.Plugin.writableDef>,
438
+ ~lifecycleStatesByView: dict<array<string>>,
439
+ ): unit =>
440
+ lifecycleTopologyFindings(~writables, ~lifecycleStatesByView)->Array.forEach(((view, message)) =>
441
+ log.warn(~comp="Plugin_Structure", `${pluginName}/${view}: ${message}`)
442
+ )
443
+
444
+ // NOT here: the event-consumption completeness check.
445
+ //
446
+ // The failure it would catch is real and has already happened: a command emitted
447
+ // an event, the slice that owned the opposite command folded it, and the view
448
+ // that renders the entity did not — so the row kept rendering the state it was
449
+ // in before, with every annotation in the plugin correct. That is a class of bug
450
+ // no declaration check can see, because nothing declared is wrong.
451
+ //
452
+ // It is not computable from this metadata, and the reason is worth recording so
453
+ // the attempt is not repeated. Two narrowings were needed and only one was
454
+ // available:
455
+ //
456
+ // 1. The event must belong to the view's own entity, not merely be something a
457
+ // slice looked up. Available: require it to be PRODUCED by a writable
458
+ // linked to the same view. Without this the check fires on ordinary DCB —
459
+ // `AddProduct` folds `CategoryAdded` to check a category exists, and the
460
+ // `Products` view is right to ignore it.
461
+ //
462
+ // 2. The slice must be known to fold the event. NOT available:
463
+ // `consumedEventTypes` is built from `eventVariantNames`, which drops
464
+ // payload-less variants — and a lifecycle-moving event is usually
465
+ // payload-less in the slice that folds it. Measured on the shipped example:
466
+ // both order slices publish `consumedEventTypes: ["Ordering.OrderPlaced"]`
467
+ // and nothing else, though each folds three more.
468
+ //
469
+ // So the exact events the rule is about are the ones the metadata does not
470
+ // record. Making it work means publishing the payload-less consumed variants,
471
+ // which is a platform change with its own consequences, not a lint.
472
+
167
473
  // Which rung of the ladder below produced the label. Published on `queryableDef`
168
474
  // as `labelFieldSource`, because the four rungs are not equally believable and a
169
475
  // consumer with a name rule of its own has to rank the declaration against it:
@@ -338,7 +644,7 @@ let make = (
338
644
  // spec name has no entry (or lives directly under a kind-folder) carries no chapter
339
645
  // and renders flat. Keyed by `Spec.name`, which equals the source filename stem for
340
646
  // every graph-node kind, so the generator can build this map from the discovered
341
- // file paths. See `Codegen.chapterOf` and docs/plans/deployed-chapter-grouping.md.
647
+ // file paths. See `Codegen.chapterOf` and docs/plans/done/deployed-chapter-grouping.md.
342
648
  ~componentChapters: dict<string>=Dict.make(),
343
649
  ): Reventless.Plugin.pluginStructure => {
344
650
  let chapterOf = (compName: string): option<string> => componentChapters->Dict.get(compName)
@@ -454,9 +760,9 @@ let make = (
454
760
  // Per-variant `allowedStates` lives on the *parent* command schema
455
761
  // (the PPX attaches a single dict<variantName, [|states|]> via
456
762
  // markAllowedStates). Look it up by variant name; back-compat
457
- // None when the variant lacks an @allowedStates annotation.
763
+ // None when the variant lacks a @transition annotation.
458
764
  let allowedStates = ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName)
459
- // Declared `@targetState` (the command's *to* status), read the same way
765
+ // The `@transition` target (the command's *to* status), read the same way
460
766
  // as allowedStates. None ⇒ AutoUI's board resolver falls back to its
461
767
  // name-stem heuristic.
462
768
  let targetState = ApiTargetStateHelpers.getTargetState(parentSchema, ~variantName)
@@ -824,6 +1130,28 @@ let make = (
824
1130
  | Internal => Some("Internal")
825
1131
  }
826
1132
 
1133
+ // View name -> the states its lifecycle field can hold, collected as the view
1134
+ // defs are built so the transition check below has both sides in one place.
1135
+ let lifecycleStatesByView: dict<array<string>> = Dict.make()
1136
+ let recordLifecycle = (~entityName, stateSchema) => {
1137
+ switch lifecycleStatesFromStateSchema(~entityName, stateSchema) {
1138
+ | Some(states) if Array.length(states) > 0 =>
1139
+ lifecycleStatesByView->Dict.set(entityName, states)
1140
+ | _ => ()
1141
+ }
1142
+ }
1143
+
1144
+ // Collected as the view defs are built and reported once, so a plugin with
1145
+ // three bad names fails naming three rather than one at a time.
1146
+ let retiredFailures = []
1147
+ let retiredUnchecked = []
1148
+ let recordRetired = (~entityName, stateSchema) =>
1149
+ switch checkRetiredValue(~entityName, stateSchema) {
1150
+ | NotDeclared => ()
1151
+ | Unchecked(why) => retiredUnchecked->Array.push(why)->ignore
1152
+ | Checked(failures) => failures->Array.forEach(f => retiredFailures->Array.push(f)->ignore)
1153
+ }
1154
+
827
1155
  let readModelDefs =
828
1156
  readModels
829
1157
  ->Array.map((
@@ -843,7 +1171,8 @@ let make = (
843
1171
  // edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
844
1172
  // aggregate→view link is also drawn from the producer's linkedViews, deduped downstream).
845
1173
  let consumed = qualify(~prefix=name, R.consumedEventNames)
846
- checkRetiredValue(~entityName=R.Spec.name, stateSchema)
1174
+ recordRetired(~entityName=R.Spec.name, stateSchema)
1175
+ recordLifecycle(~entityName=R.Spec.name, stateSchema)
847
1176
  ({
848
1177
  Reventless.Plugin.name: R.Spec.name,
849
1178
  queryField: qf.listFieldName,
@@ -859,6 +1188,7 @@ let make = (
859
1188
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
860
1189
  retiredField: retiredFieldFromStateSchema(stateSchema),
861
1190
  retiredValues: retiredValuesFromStateSchema(stateSchema),
1191
+ namedWhenRetired: Some(namedWhenRetiredFromStateSchema(stateSchema)),
862
1192
  visibility: visibilityTag(R.Spec.visibility),
863
1193
  chapter: chapterOf(R.Spec.name),
864
1194
  // Taken from the `qf` record, never re-derived: `Api_Naming` is the only
@@ -881,7 +1211,8 @@ let make = (
881
1211
  ~entityName=SVS.Spec.name,
882
1212
  stateSchema,
883
1213
  )
884
- checkRetiredValue(~entityName=SVS.Spec.name, stateSchema)
1214
+ recordRetired(~entityName=SVS.Spec.name, stateSchema)
1215
+ recordLifecycle(~entityName=SVS.Spec.name, stateSchema)
885
1216
  ({
886
1217
  Reventless.Plugin.name: SVS.Spec.name,
887
1218
  queryField: qf.listFieldName,
@@ -895,6 +1226,7 @@ let make = (
895
1226
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
896
1227
  retiredField: retiredFieldFromStateSchema(stateSchema),
897
1228
  retiredValues: retiredValuesFromStateSchema(stateSchema),
1229
+ namedWhenRetired: Some(namedWhenRetiredFromStateSchema(stateSchema)),
898
1230
  visibility: visibilityTag(SVS.Spec.visibility),
899
1231
  chapter: chapterOf(SVS.Spec.name),
900
1232
  singleQueryField: Some(qf.singleFieldName),
@@ -1052,6 +1384,23 @@ let make = (
1052
1384
  commandTypes: Some(dedupe(cmds)),
1053
1385
  }: Reventless.Plugin.extensionPointDef))
1054
1386
 
1387
+ // Second pass, on purpose: commands are built well before `linkedViews` is
1388
+ // assembled, so the check cannot run inline where the defs are made.
1389
+ checkDeclaredTransitions(
1390
+ ~pluginName=name,
1391
+ ~writables=Array.concat(stateChangeDefs, aggregateDefs),
1392
+ ~lifecycleStatesByView,
1393
+ )
1394
+ checkLifecycleTopology(
1395
+ ~pluginName=name,
1396
+ ~writables=Array.concat(stateChangeDefs, aggregateDefs),
1397
+ ~lifecycleStatesByView,
1398
+ )
1399
+ // Also a second pass, for a different reason: the failures are gathered per
1400
+ // view as those defs are built, and raising inline would report the first bad
1401
+ // name and hide the rest.
1402
+ reportRetiredStates(~pluginName=name, ~failures=retiredFailures, ~unchecked=retiredUnchecked)
1403
+
1055
1404
  {
1056
1405
  readModels: readModelDefs,
1057
1406
  stateViewSlices: stateViewDefs,