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