@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.
- package/CHANGELOG.md +12 -0
- package/package.json +7 -7
- package/src/Message.res +1 -1
- package/src/adapter/Monitoring/Monitoring.res +1 -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/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_Structure.res +269 -7
- package/src/plugin/component/Plugin_Structure.res.mjs +128 -1
- package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
- package/tests/aggregate/AggregateCacheTest.res +1 -1
- package/tests/aggregate/AggregateSnapshotTest.res +1 -1
- 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 +164 -3
- package/tests/plugin/PluginStructureTest.res.mjs +170 -1
- 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
|
@@ -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 — `@
|
|
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.
|
|
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 @
|
|
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.
|
|
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
|
|
693
|
+
// None when the variant lacks a @transition annotation.
|
|
458
694
|
let allowedStates = ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName)
|
|
459
|
-
//
|
|
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 @
|
|
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);
|