@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.
- package/CHANGELOG.md +24 -0
- package/package.json +8 -8
- package/src/Message.res +1 -1
- package/src/adapter/Monitoring/Monitoring.res +1 -1
- package/src/admin/Platform_Admin_Structure.res +1 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +1 -0
- package/src/admin/Platform_ComponentDefinitionsApi.res +2 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
- package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res +1 -1
- package/src/components/Aggregate/Aggregate_Callback.res +2 -2
- package/src/components/Api/ApiAllowedStatesHelpers.res +2 -3
- package/src/components/Api/ApiTargetStateHelpers.res +4 -3
- package/src/components/Api/GraphQL_FragmentGenerator.res +122 -12
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +41 -8
- package/src/components/Api/SuryToJsonSchema.res +8 -0
- package/src/components/Api/SuryToJsonSchema.res.mjs +8 -4
- package/src/components/Dcb/Dcb_Builder.res +79 -16
- package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
- package/src/components/EventLog/EventLog.res +1 -1
- package/src/plugin/component/Plugin_Builder.res +7 -0
- package/src/plugin/component/Plugin_Builder.res.mjs +3 -1
- package/src/plugin/component/Plugin_Structure.res +376 -27
- package/src/plugin/component/Plugin_Structure.res.mjs +175 -8
- package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
- package/tests/admin/Platform_BakedManifestTest.res +1 -0
- package/tests/admin/Platform_BakedManifestTest.res.mjs +1 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +3 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +6 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res +1 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +2 -0
- package/tests/aggregate/AggregateCacheTest.res +1 -1
- package/tests/aggregate/AggregateSnapshotTest.res +1 -1
- package/tests/api/GraphQL_FragmentGeneratorTest.res +171 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +108 -0
- package/tests/api/SuryToJsonSchemaTest.res +30 -3
- package/tests/api/SuryToJsonSchemaTest.res.mjs +49 -4
- package/tests/commandgenerator/OwnerStampingTest.res +57 -0
- package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
- package/tests/message/MessageTest.res +1 -1
- package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
- package/tests/plugin/PluginStructureTest.res +268 -4
- package/tests/plugin/PluginStructureTest.res.mjs +284 -3
- package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
- package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
- package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
- package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
- package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
- package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
- 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
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
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.
|
|
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 @
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
763
|
+
// None when the variant lacks a @transition annotation.
|
|
458
764
|
let allowedStates = ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName)
|
|
459
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|