@reventlessdev/reventless-core 3.0.0-alpha.237 → 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 (29) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +7 -7
  3. package/src/Message.res +1 -1
  4. package/src/adapter/Monitoring/Monitoring.res +1 -1
  5. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res +1 -1
  6. package/src/components/Aggregate/Aggregate_Callback.res +2 -2
  7. package/src/components/Api/ApiAllowedStatesHelpers.res +2 -3
  8. package/src/components/Api/ApiTargetStateHelpers.res +4 -3
  9. package/src/components/Dcb/Dcb_Builder.res +79 -16
  10. package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
  11. package/src/components/EventLog/EventLog.res +1 -1
  12. package/src/plugin/component/Plugin_Structure.res +269 -7
  13. package/src/plugin/component/Plugin_Structure.res.mjs +128 -1
  14. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
  15. package/tests/aggregate/AggregateCacheTest.res +1 -1
  16. package/tests/aggregate/AggregateSnapshotTest.res +1 -1
  17. package/tests/commandgenerator/OwnerStampingTest.res +57 -0
  18. package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
  19. package/tests/message/MessageTest.res +1 -1
  20. package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
  21. package/tests/plugin/PluginStructureTest.res +164 -3
  22. package/tests/plugin/PluginStructureTest.res.mjs +170 -1
  23. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
  24. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
  25. package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
  26. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
  27. package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
  28. package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
  29. package/tests/util/CsvStreamTest.res +1 -1
@@ -114,13 +114,13 @@ let retiredValuesFromStateSchema = (stateSchema: S.t<unknown>): option<array<str
114
114
  //
115
115
  // Two rules, and the second is the one the form exists for. A `value` on a field
116
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
117
+ // losing the command filtering that motivates it — `@transition` is written in
118
118
  // terms of the lifecycle field, so a retirement state anywhere else is a state no
119
119
  // command can name.
120
120
  let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =>
121
121
  switch retiredFromStateSchema(stateSchema) {
122
122
  | Some({field, values: Some(values)}) =>
123
- let named = values->Array.joinWith(", ")
123
+ let named = values->Array.join(", ")
124
124
  let lifecycle = lifecycleFieldFromStateSchema(~entityName, stateSchema)
125
125
  if lifecycle != Some(field) {
126
126
  log.warn(
@@ -129,7 +129,7 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =
129
129
  ->Option.map(f => ` (that is "${f}")`)
130
130
  ->Option.getOr(
131
131
  " (it declares none)",
132
- )}. A retirement state no command's @allowedStates can name loses the command filtering the state form exists for.`,
132
+ )}. A retirement state no command's @transition can name loses the command filtering the state form exists for.`,
133
133
  )
134
134
  }
135
135
  let declared = switch stateSchema {
@@ -155,7 +155,7 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =
155
155
  ->Array.forEach(v =>
156
156
  log.warn(
157
157
  ~comp="Plugin_Structure",
158
- `${entityName}: @retired(${v}) names a state "${field}" does not declare — known values: ${declared->Array.joinWith(
158
+ `${entityName}: @retired(${v}) names a state "${field}" does not declare — known values: ${declared->Array.join(
159
159
  ", ",
160
160
  )}.`,
161
161
  )
@@ -164,6 +164,242 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): unit =
164
164
  | _ => ()
165
165
  }
166
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
+
167
403
  // Which rung of the ladder below produced the label. Published on `queryableDef`
168
404
  // as `labelFieldSource`, because the four rungs are not equally believable and a
169
405
  // consumer with a name rule of its own has to rank the declaration against it:
@@ -338,7 +574,7 @@ let make = (
338
574
  // spec name has no entry (or lives directly under a kind-folder) carries no chapter
339
575
  // and renders flat. Keyed by `Spec.name`, which equals the source filename stem for
340
576
  // 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.
577
+ // file paths. See `Codegen.chapterOf` and docs/plans/done/deployed-chapter-grouping.md.
342
578
  ~componentChapters: dict<string>=Dict.make(),
343
579
  ): Reventless.Plugin.pluginStructure => {
344
580
  let chapterOf = (compName: string): option<string> => componentChapters->Dict.get(compName)
@@ -454,9 +690,9 @@ let make = (
454
690
  // Per-variant `allowedStates` lives on the *parent* command schema
455
691
  // (the PPX attaches a single dict<variantName, [|states|]> via
456
692
  // markAllowedStates). Look it up by variant name; back-compat
457
- // None when the variant lacks an @allowedStates annotation.
693
+ // None when the variant lacks a @transition annotation.
458
694
  let allowedStates = ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName)
459
- // Declared `@targetState` (the command's *to* status), read the same way
695
+ // The `@transition` target (the command's *to* status), read the same way
460
696
  // as allowedStates. None ⇒ AutoUI's board resolver falls back to its
461
697
  // name-stem heuristic.
462
698
  let targetState = ApiTargetStateHelpers.getTargetState(parentSchema, ~variantName)
@@ -824,6 +1060,17 @@ let make = (
824
1060
  | Internal => Some("Internal")
825
1061
  }
826
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
+
827
1074
  let readModelDefs =
828
1075
  readModels
829
1076
  ->Array.map((
@@ -844,6 +1091,7 @@ let make = (
844
1091
  // aggregate→view link is also drawn from the producer's linkedViews, deduped downstream).
845
1092
  let consumed = qualify(~prefix=name, R.consumedEventNames)
846
1093
  checkRetiredValue(~entityName=R.Spec.name, stateSchema)
1094
+ recordLifecycle(~entityName=R.Spec.name, stateSchema)
847
1095
  ({
848
1096
  Reventless.Plugin.name: R.Spec.name,
849
1097
  queryField: qf.listFieldName,
@@ -882,6 +1130,7 @@ let make = (
882
1130
  stateSchema,
883
1131
  )
884
1132
  checkRetiredValue(~entityName=SVS.Spec.name, stateSchema)
1133
+ recordLifecycle(~entityName=SVS.Spec.name, stateSchema)
885
1134
  ({
886
1135
  Reventless.Plugin.name: SVS.Spec.name,
887
1136
  queryField: qf.listFieldName,
@@ -1052,6 +1301,19 @@ let make = (
1052
1301
  commandTypes: Some(dedupe(cmds)),
1053
1302
  }: Reventless.Plugin.extensionPointDef))
1054
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
+
1055
1317
  {
1056
1318
  readModels: readModelDefs,
1057
1319
  stateViewSlices: stateViewDefs,
@@ -5,6 +5,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
8
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
9
  import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
9
10
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
10
11
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
@@ -114,7 +115,7 @@ function checkRetiredValue(entityName, stateSchema) {
114
115
  let named = values.join(", ");
115
116
  let lifecycle = lifecycleFieldFromStateSchema(entityName, stateSchema);
116
117
  if (Primitive_object.notequal(lifecycle, field)) {
117
- log.warn("Plugin_Structure", undefined, entityName + `: @retired(` + named + `) is on "` + field + `", which is not this record's lifecycle field` + Stdlib_Option.getOr(Stdlib_Option.map(lifecycle, f => ` (that is "` + f + `")`), " (it declares none)") + `. A retirement state no command's @allowedStates can name loses the command filtering the state form exists for.`);
118
+ log.warn("Plugin_Structure", undefined, entityName + `: @retired(` + named + `) is on "` + field + `", which is not this record's lifecycle field` + Stdlib_Option.getOr(Stdlib_Option.map(lifecycle, f => ` (that is "` + f + `")`), " (it declares none)") + `. A retirement state no command's @transition can name loses the command filtering the state form exists for.`);
118
119
  }
119
120
  let declared;
120
121
  declared = stateSchema.type === "object" ? Stdlib_Option.getOr(Stdlib_Option.map(stateSchema.items.find(item => item.location === field), item => {
@@ -144,6 +145,116 @@ function checkRetiredValue(entityName, stateSchema) {
144
145
  }
145
146
  }
146
147
 
148
+ function lifecycleStatesFromStateSchema(entityName, stateSchema) {
149
+ return Stdlib_Option.flatMap(lifecycleFieldFromStateSchema(entityName, stateSchema), field => {
150
+ if (stateSchema.type === "object") {
151
+ return Stdlib_Option.map(stateSchema.items.find(item => item.location === field), item => {
152
+ let match = shapeOfItem(entityName, item);
153
+ if (typeof match !== "object") {
154
+ return [];
155
+ }
156
+ switch (match.TAG) {
157
+ case "Nullable" :
158
+ let match$1 = match._0;
159
+ if (typeof match$1 !== "object") {
160
+ return [];
161
+ } else if (match$1.TAG === "Enum") {
162
+ return match$1._1;
163
+ } else {
164
+ return [];
165
+ }
166
+ case "Enum" :
167
+ return match._1;
168
+ default:
169
+ return [];
170
+ }
171
+ });
172
+ }
173
+ });
174
+ }
175
+
176
+ function checkDeclaredTransitions(pluginName, writables, lifecycleStatesByView) {
177
+ let unvalidated = {
178
+ contents: 0
179
+ };
180
+ let failures = [];
181
+ writables.forEach(w => {
182
+ w.commands.forEach(cmd => {
183
+ let t = cmd.targetState;
184
+ let declared = Stdlib_Option.getOr(cmd.allowedStates, []).concat(t !== undefined ? [t] : []);
185
+ if (declared.length === 0) {
186
+ return;
187
+ }
188
+ let known = Stdlib_Array.reduce(w.linkedViews, [], (acc, view) => {
189
+ let states = lifecycleStatesByView[view];
190
+ if (states !== undefined) {
191
+ return acc.concat(states);
192
+ } else {
193
+ return acc;
194
+ }
195
+ });
196
+ if (known.length === 0) {
197
+ unvalidated.contents = unvalidated.contents + 1 | 0;
198
+ } else {
199
+ declared.filter(state => !known.includes(state)).forEach(state => {
200
+ failures.push(w.name + `.` + cmd.name + `: @transition names "` + state + `", which none of its ` + (`linked views declare — ` + w.linkedViews.join(", ") + ` know ` + known.join(", ") + `.`));
201
+ });
202
+ }
203
+ });
204
+ });
205
+ if (unvalidated.contents > 0) {
206
+ log.warn("Plugin_Structure", undefined, pluginName + `: ` + unvalidated.contents.toString() + ` command(s) declare a @transition but no linked view declares a lifecycle to check it against.`);
207
+ }
208
+ if (failures.length !== 0) {
209
+ return Stdlib_JsError.throwWithMessage(pluginName + `: @transition names states that do not exist.\n` + failures.join("\n"));
210
+ }
211
+ }
212
+
213
+ function lifecycleTopologyFindings(writables, lifecycleStatesByView) {
214
+ let findings = [];
215
+ Object.entries(lifecycleStatesByView).forEach(param => {
216
+ let states = param[1];
217
+ let view = param[0];
218
+ let edges = Stdlib_Array.reduce(writables, [], (acc, w) => {
219
+ if (w.linkedViews.includes(view)) {
220
+ return acc.concat(Stdlib_Array.reduce(w.commands, [], (inner, cmd) => {
221
+ let match = cmd.allowedStates;
222
+ let match$1 = cmd.targetState;
223
+ if (match !== undefined && match$1 !== undefined) {
224
+ return inner.concat(match.map(from => [
225
+ from,
226
+ match$1
227
+ ]));
228
+ } else {
229
+ return inner;
230
+ }
231
+ }));
232
+ } else {
233
+ return acc;
234
+ }
235
+ });
236
+ if (edges.length === 0) {
237
+ return;
238
+ }
239
+ let initial = states[0];
240
+ let reachable = edges.map(param => param[1]);
241
+ states.forEach(state => {
242
+ if (!reachable.includes(state) && Primitive_object.notequal(state, initial)) {
243
+ findings.push([
244
+ view,
245
+ `no command declares a transition INTO "` + state + `" — it is unreachable unless something outside this plugin's declarations puts a row there.`
246
+ ]);
247
+ return;
248
+ }
249
+ });
250
+ });
251
+ return findings;
252
+ }
253
+
254
+ function checkLifecycleTopology(pluginName, writables, lifecycleStatesByView) {
255
+ lifecycleTopologyFindings(writables, lifecycleStatesByView).forEach(param => log.warn("Plugin_Structure", undefined, pluginName + `/` + param[0] + `: ` + param[1]));
256
+ }
257
+
147
258
  function labelFieldSourceToString(s) {
148
259
  switch (s) {
149
260
  case "Annotation" :
@@ -587,6 +698,14 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
587
698
  return "Internal";
588
699
  }
589
700
  };
701
+ let lifecycleStatesByView = {};
702
+ let recordLifecycle = (entityName, stateSchema) => {
703
+ let states = lifecycleStatesFromStateSchema(entityName, stateSchema);
704
+ if (states !== undefined && states.length !== 0) {
705
+ lifecycleStatesByView[entityName] = states;
706
+ return;
707
+ }
708
+ };
590
709
  let readModelDefs = readModels.map(R => {
591
710
  let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(name, R.Spec.name, undefined);
592
711
  let stateSchema = R.Spec.stateSchema;
@@ -594,6 +713,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
594
713
  let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(R.Spec.name, stateSchema);
595
714
  let consumed = qualify(name, R.consumedEventNames);
596
715
  checkRetiredValue(R.Spec.name, stateSchema);
716
+ recordLifecycle(R.Spec.name, stateSchema);
597
717
  return {
598
718
  name: R.Spec.name,
599
719
  queryField: qf.listFieldName,
@@ -623,6 +743,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
623
743
  let label = labelFieldsFromStateSchema(SVS.Spec.name, stateSchema);
624
744
  let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(SVS.Spec.name, stateSchema);
625
745
  checkRetiredValue(SVS.Spec.name, stateSchema);
746
+ recordLifecycle(SVS.Spec.name, stateSchema);
626
747
  return {
627
748
  name: SVS.Spec.name,
628
749
  queryField: qf.listFieldName,
@@ -735,6 +856,8 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
735
856
  commandTypes: Belt_SetString.toArray(Belt_SetString.fromArray(match[2]))
736
857
  };
737
858
  });
859
+ checkDeclaredTransitions(name, stateChangeDefs.concat(aggregateDefs), lifecycleStatesByView);
860
+ checkLifecycleTopology(name, stateChangeDefs.concat(aggregateDefs), lifecycleStatesByView);
738
861
  return {
739
862
  readModels: readModelDefs,
740
863
  stateViewSlices: stateViewDefs,
@@ -761,6 +884,10 @@ export {
761
884
  retiredFieldFromStateSchema,
762
885
  retiredValuesFromStateSchema,
763
886
  checkRetiredValue,
887
+ lifecycleStatesFromStateSchema,
888
+ checkDeclaredTransitions,
889
+ lifecycleTopologyFindings,
890
+ checkLifecycleTopology,
764
891
  labelFieldSourceToString,
765
892
  labelFieldsFromStateSchema,
766
893
  extractReferences,
@@ -1,5 +1,5 @@
1
1
  // Second mapping on the admin PluginExtensionPoint, routing UI-fragment lifecycle to the
2
- // admin UiFragmentRegistry StateChangeSlice (docs/plans/event-sourced-fragment-registries.md).
2
+ // admin UiFragmentRegistry StateChangeSlice (docs/plans/done/event-sourced-fragment-registries.md).
3
3
  //
4
4
  // The EP runtime fans every incoming command through ALL mappings and flattens the results
5
5
  // (ExtensionPoint_Callback.mapIncomingCommands). This mapping's Delegate is the slice, so its
@@ -1,5 +1,5 @@
1
1
  // Tests for the Aggregate_Callback in-process replay cache (Phase 1 of
2
- // docs/plans/aggregate-snapshotting.md): a warm same-id command skips the
2
+ // docs/plans/done/aggregate-snapshotting.md): a warm same-id command skips the
3
3
  // event-log replay and decides on the cached (state, sequenceNr); the OCC
4
4
  // append fences staleness — a stale cache conflicts, invalidates, and the
5
5
  // retry replays cold.
@@ -1,4 +1,4 @@
1
- // Step 5 of docs/plans/aggregate-snapshotting.md — the Aggregate_Callback
1
+ // Step 5 of docs/plans/done/aggregate-snapshotting.md — the Aggregate_Callback
2
2
  // snapshot wiring: a snapshot-enabled behavior seeds cold replays from the
3
3
  // latest persisted snapshot (hash-gated) and writes a fresh one every
4
4
  // `interval` events, fire-and-forget. Snapshots never affect correctness — the
@@ -185,3 +185,60 @@ describe("@owner stamping on the command path:", () => {
185
185
  })
186
186
  })
187
187
  })
188
+
189
+ // The schema is not only a validator, and the case below is the one that proves
190
+ // it. Two AppSync-side call sites passed `S.json` in place of the command schema
191
+ // — a defensible substitution while its only job was validation, since AppSync
192
+ // has already checked the input against the SDL, and per-slice decoding happens
193
+ // downstream anyway. Owner stamping then gave the same argument a second job:
194
+ // it is where the `@owner` fields are read from. Handed a permissive schema the
195
+ // lookup answers "no owner fields" for every command there is, and the write
196
+ // keeps whatever owner the client sent — no error, no warning, a wrong row.
197
+ //
198
+ // Nothing about a single call site was wrong, which is why per-path tests could
199
+ // not catch it. What was missing is a check on the RELATIONSHIP: a generator
200
+ // built for a command whose spec marks an owner must be able to find it.
201
+ describe("the schema a generator is built with must answer for its commands:", () => {
202
+ let permissive = CommandGenerator_Callback.makeGenerateCommand(
203
+ ~publishJsons=async cmds => published := published.contents->Array.concat(cmds),
204
+ ~serviceName="Ordering",
205
+ ~commandSchema=S.json->S.castToUnknown,
206
+ ~componentKind=CommandGenerator_Callback.StateChangeSlice,
207
+ ~stripIdFromParams=false,
208
+ )
209
+
210
+ testPromise("a permissive schema silently stamps nothing — the defect, pinned", async () => {
211
+ let _ =
212
+ await permissive(
213
+ placeOrder(~customerId="cust-B", ~identity=cognito(~userId="cust-A", ~groups=["User"])),
214
+ )->Effect.runPromise
215
+ // Not the behaviour we want anywhere — recorded so that a change making the
216
+ // permissive path stamp correctly is a deliberate one, and so the contrast
217
+ // with the case below cannot be read as incidental.
218
+ expect(lastPublished()->fieldOf("customerId"))->toEqual(Some(str("cust-B")))
219
+ })
220
+
221
+ testPromise("the real schema stamps, from the same payload and caller", async () => {
222
+ let _ =
223
+ await generate(
224
+ placeOrder(~customerId="cust-B", ~identity=cognito(~userId="cust-A", ~groups=["User"])),
225
+ )->Effect.runPromise
226
+ expect(lastPublished()->fieldOf("customerId"))->toEqual(Some(str("cust-A")))
227
+ })
228
+
229
+ // The check a call site can run against itself, and the one the DCB routing
230
+ // maps now satisfy by construction: every command a spec exposes resolves to
231
+ // its own owner fields through the schema that command will be dispatched
232
+ // with. Reading `[]` here is exactly the state that stamps nothing.
233
+ testPromise("every command of a spec resolves its owner fields through that spec's schema", async () => {
234
+ let schema = commandSchema->S.castToUnknown
235
+ expect(Reventless.Owner.variantFieldNames(schema, ~variant="PlaceOrder"))->toEqual([
236
+ "customerId",
237
+ ])
238
+ expect(Reventless.Owner.variantFieldNames(schema, ~variant="ImportProducts"))->toEqual([])
239
+ // The substitution, asked the same question.
240
+ expect(
241
+ Reventless.Owner.variantFieldNames(S.json->S.castToUnknown, ~variant="PlaceOrder"),
242
+ )->toEqual([])
243
+ })
244
+ })
@@ -192,6 +192,25 @@ globalThis.describe("@owner stamping on the command path:", () => {
192
192
  });
193
193
  });
194
194
 
195
+ globalThis.describe("the schema a generator is built with must answer for its commands:", () => {
196
+ let permissive = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(async cmds => {
197
+ published.contents = published.contents.concat(cmds);
198
+ }, undefined, "Ordering", S.json, "StateChangeSlice", false);
199
+ globalThis.test("a permissive schema silently stamps nothing — the defect, pinned", async () => {
200
+ await Effect.runPromise(permissive(placeOrder("cust-B", cognito("cust-A", ["User"]))));
201
+ globalThis.expect(fieldOf(published.contents[published.contents.length - 1 | 0], "customerId")).toEqual("cust-B");
202
+ });
203
+ globalThis.test("the real schema stamps, from the same payload and caller", async () => {
204
+ await Effect.runPromise(generate(placeOrder("cust-B", cognito("cust-A", ["User"]))));
205
+ globalThis.expect(fieldOf(published.contents[published.contents.length - 1 | 0], "customerId")).toEqual("cust-A");
206
+ });
207
+ globalThis.test("every command of a spec resolves its owner fields through that spec's schema", async () => {
208
+ globalThis.expect(Owner$Reventless.variantFieldNames(commandSchema, "PlaceOrder")).toEqual(["customerId"]);
209
+ globalThis.expect(Owner$Reventless.variantFieldNames(commandSchema, "ImportProducts")).toEqual([]);
210
+ globalThis.expect(Owner$Reventless.variantFieldNames(S.json, "PlaceOrder")).toEqual([]);
211
+ });
212
+ });
213
+
195
214
  export {
196
215
  commandSchema,
197
216
  published,
@@ -96,7 +96,7 @@ describe("Message should", () => {
96
96
  // Schema-migration-on-read: a VersionConnected event persisted before `kind` (and
97
97
  // before later pluginStructure fields like `events`/`chapter`) existed must still
98
98
  // decode — otherwise the one stale event bricks the whole lifecycle aggregate. See
99
- // docs/plans/platform-infrastructure-in-plugin-list.md (durable fix option 2).
99
+ // docs/plans/done/platform-infrastructure-in-plugin-list.md (durable fix option 2).
100
100
  testSync("tolerantly decode a VersionConnected persisted before kind/structure fields existed", () => {
101
101
  open PluginSpec
102
102
  let def: Reventless.Plugin.pluginDefinition = {
@@ -7,7 +7,7 @@
7
7
  // (which drives CreateDisconnectSchedule). A deployed plugin once ran with schedule
8
8
  // rate(60min) but a Heartbeat(10) command (grace ~12min), so it reconnected for
9
9
  // ~12min after each hourly beat then sat Disconnected for the rest of the hour —
10
- // it flapped. See docs/plans/plugin-heartbeat-disconnect-margin-hardening.md.
10
+ // it flapped. See docs/plans/done/plugin-heartbeat-disconnect-margin-hardening.md.
11
11
  //
12
12
  // These tests pin the two guarantees that the fix provides at this layer:
13
13
  // 1. the disconnect grace scales with the interval (large cadences keep headroom);