@reventlessdev/reventless-core 3.0.0-alpha.249 → 3.0.0-alpha.251
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 +32 -0
- package/package.json +9 -9
- package/src/admin/Platform_ComponentDefinitionsApi.res +31 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +29 -1
- package/src/admin/Platform_PluginStructuresApi.res +37 -1
- package/src/admin/Platform_PluginStructuresApi.res.mjs +29 -1
- package/src/components/Api/QueryDbListQuery.res +26 -6
- package/src/components/Api/QueryDbListQuery.res.mjs +12 -5
- package/src/plugin/component/Plugin_Structure.res +620 -370
- package/src/plugin/component/Plugin_Structure.res.mjs +341 -3
- package/src/plugin/connect/PluginConnectExtension_Mapping.res +3 -6
- package/src/plugin/connect/PluginConnectExtension_Mapping.res.mjs +11 -1
- package/src/plugin/connect/PluginExtensionPoint_Plugin.res +0 -1
- package/src/plugin/connect/PluginExtensionPoint_Plugin.res.mjs +60 -2
- package/src/plugin/connect/PluginExtensionPoint_UiFragment.res.mjs +19 -2
- package/tests/admin/AdminApiSchemaDriftTest.res +460 -0
- package/tests/admin/AdminApiSchemaDriftTest.res.mjs +599 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +6 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +9 -1
- package/tests/admin/Platform_PluginStructuresApiTest.res +12 -1
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +10 -2
- package/tests/plugin/PluginStructureTest.res +109 -0
- package/tests/plugin/PluginStructureTest.res.mjs +127 -0
- package/tests/plugin/pluginDefinitionRequiredScalars.txt +8 -0
|
@@ -1,32 +1,18 @@
|
|
|
1
|
-
// Pure metadata extraction from spec modules
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// unit-tested without spinning up a Platform.
|
|
1
|
+
// Pure metadata extraction from spec modules: the pluginStructure that Auto UI,
|
|
2
|
+
// the event graph and MCP tooling consume. Standalone, so it unit-tests without
|
|
3
|
+
// a Platform.
|
|
5
4
|
|
|
6
5
|
let log = Logger.fromEnv()
|
|
7
6
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// so a field that is a date, a reference or an enum is one for the label picker
|
|
12
|
-
// too.
|
|
13
|
-
|
|
14
|
-
// Whether a field can name a record. `Nullable` unwraps — an optional name is
|
|
15
|
-
// still the entity's name, absent on some rows, which is a rendering question
|
|
16
|
-
// rather than a declaration one. A `Semantic` wrapper is refused: it is the
|
|
17
|
-
// schema saying the string means something other than prose (a storage ref, a
|
|
18
|
-
// URL), and a bucket key is not a name. `DateTime`, `EntityId`, `Enum` and every
|
|
19
|
-
// composite shape fall out of the catch-all without being named — which is the
|
|
20
|
-
// point of reading an IR rather than restating it: the next shape it grows is
|
|
21
|
-
// excluded before it exists.
|
|
7
|
+
// Whether a field can name a record, stated over `SchemaType`'s IR so every
|
|
8
|
+
// shape it grows is excluded before it exists. `Nullable` unwraps; a `Semantic`
|
|
9
|
+
// wrapper is refused — a bucket key or URL is not prose.
|
|
22
10
|
let rec isLabelShape = (t: SchemaType.schemaType): bool =>
|
|
23
11
|
switch t {
|
|
24
12
|
| ScalarString => true
|
|
25
13
|
| Nullable(inner) => isLabelShape(inner)
|
|
26
|
-
// Written out rather than left to the catch-all:
|
|
27
|
-
// rendering depends on
|
|
28
|
-
// record. The label picker's fallback rung is a *guess*, and this is the shape
|
|
29
|
-
// it would guess most confidently and most wrongly.
|
|
14
|
+
// Written out rather than left to the catch-all: no single string names a row
|
|
15
|
+
// whose rendering depends on its arm.
|
|
30
16
|
| TaggedUnion(_, _) => false
|
|
31
17
|
| _ => false
|
|
32
18
|
}
|
|
@@ -53,19 +39,9 @@ let conventionalLabelNames = ["name", "title", "label", "displayname"]
|
|
|
53
39
|
let shapeOfField = (~entityName: string, ~name: string, schema: S.t<unknown>): SchemaType.schemaType =>
|
|
54
40
|
SchemaType.fromSury(~parentName=entityName, ~fieldName=name, schema)
|
|
55
41
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// 2. Field literally named `"lifecycle"` whose IR shape is an enum (convention;
|
|
60
|
-
// mirrors how labelField falls back to a conventionally-named field).
|
|
61
|
-
// 3. None — filter is inert for this read model.
|
|
62
|
-
//
|
|
63
|
-
// The convention rung is keyed on `lifecycle` rather than `status` deliberately:
|
|
64
|
-
// `status` is a promiscuous name — geocoding progress, todo-queue progress and
|
|
65
|
-
// translation audit outcome all wear it — so a convention keyed on it guesses,
|
|
66
|
-
// and guesses often. `lifecycle` is a word nobody types by accident, so matching
|
|
67
|
-
// it is closer to a declaration written in the field name. A record whose field
|
|
68
|
-
// is honestly called something else annotates instead.
|
|
42
|
+
// The field holding the entity's lifecycle, for filtering a per-row command menu:
|
|
43
|
+
// `@lifecycle`, else an enum field literally named `lifecycle`, else None. Not
|
|
44
|
+
// keyed on `status` — a promiscuous name that would guess, and guess often.
|
|
69
45
|
let lifecycleFieldFromStateSchema = (
|
|
70
46
|
~entityName: string,
|
|
71
47
|
stateSchema: S.t<unknown>,
|
|
@@ -91,12 +67,8 @@ let lifecycleFieldFromStateSchema = (
|
|
|
91
67
|
}
|
|
92
68
|
}
|
|
93
69
|
|
|
94
|
-
// The field whose truth withdraws a row from ordinary reads. Annotation-only
|
|
95
|
-
//
|
|
96
|
-
// may fall back to a field literally named "lifecycle" because guessing wrong makes
|
|
97
|
-
// a command menu filter oddly; guessing wrong here makes rows vanish for every
|
|
98
|
-
// caller who is not elevated, so a boolean named `archived` that nobody annotated
|
|
99
|
-
// stays exactly as visible as it was.
|
|
70
|
+
// The field whose truth withdraws a row from ordinary reads. Annotation-only —
|
|
71
|
+
// no convention rung, since guessing wrong here makes rows vanish.
|
|
100
72
|
let retiredFromStateSchema = (
|
|
101
73
|
stateSchema: S.t<unknown>,
|
|
102
74
|
): option<Reventless.StateAnnotations.retiredSpec> =>
|
|
@@ -108,62 +80,29 @@ let retiredFromStateSchema = (
|
|
|
108
80
|
let retiredFieldFromStateSchema = (stateSchema: S.t<unknown>): option<string> =>
|
|
109
81
|
retiredFromStateSchema(stateSchema)->Option.map(r => r.field)
|
|
110
82
|
|
|
111
|
-
// The states a row is retired *in*, for the enum form
|
|
112
|
-
// form
|
|
113
|
-
// can only hold one thing. A set, because a lifecycle may be withdrawn by more
|
|
114
|
-
// than one state, and a one-member set is the ordinary case.
|
|
115
|
-
//
|
|
116
|
-
// Published beside `retiredField` rather than left for a consumer to dig out of
|
|
117
|
-
// the schema: a client that has the def in hand has the whole predicate, and two
|
|
118
|
-
// places deriving one comparison is how they come to disagree about it.
|
|
83
|
+
// The states a row is retired *in*, for the enum form; `None` is the boolean
|
|
84
|
+
// form. Published beside `retiredField` so a client holds the whole predicate.
|
|
119
85
|
let retiredValuesFromStateSchema = (stateSchema: S.t<unknown>): option<array<string>> =>
|
|
120
86
|
retiredFromStateSchema(stateSchema)->Option.flatMap(r => r.values)
|
|
121
87
|
|
|
122
|
-
// Whether a reference to a retired row
|
|
123
|
-
//
|
|
124
|
-
// annotation, so a record cannot declare the reach of a retirement it does not
|
|
125
|
-
// have; the PPX refuses that pairing, and reading it here from the same place
|
|
126
|
-
// keeps the two halves agreeing by construction rather than by review.
|
|
88
|
+
// Whether a reference to a retired row still resolves its name. Read off the
|
|
89
|
+
// retirement itself, so a record cannot declare the reach of one it lacks.
|
|
127
90
|
let namedWhenRetiredFromStateSchema = (stateSchema: S.t<unknown>): bool =>
|
|
128
91
|
retiredFromStateSchema(stateSchema)->Option.mapOr(false, r => r.namedWhenRetired)
|
|
129
92
|
|
|
130
|
-
// What one record's `@retired`
|
|
131
|
-
//
|
|
132
|
-
// Three outcomes rather than a bool, because "nothing to check" and "could not
|
|
133
|
-
// check" are different facts and only the second is worth a plugin's attention.
|
|
93
|
+
// What one record's `@retired` could be told about its own field. Three outcomes,
|
|
94
|
+
// because "nothing to check" and "could not check" are different facts.
|
|
134
95
|
type retiredCheck =
|
|
135
96
|
| NotDeclared
|
|
136
|
-
// Why the names could not be compared
|
|
137
|
-
// does not run is the failure mode the transition check spends a counter to
|
|
138
|
-
// avoid, so this is reported rather than skipped in silence.
|
|
97
|
+
// Why the names could not be compared — reported, never skipped in silence.
|
|
139
98
|
| Unchecked(string)
|
|
140
99
|
| Checked(array<string>)
|
|
141
100
|
|
|
142
|
-
// The check the PPX cannot make
|
|
143
|
-
//
|
|
144
|
-
// is a
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
// **A name the field's enum does not declare is unambiguously wrong** — no domain
|
|
149
|
-
// means it — and the symptom is a data-exposure bug: the retirement predicate
|
|
150
|
-
// compares every row against a state no row is ever in, so every row stays
|
|
151
|
-
// visible to every caller while the annotation sits on the schema looking like
|
|
152
|
-
// enforcement. That is returned as a failure for the caller to raise on.
|
|
153
|
-
//
|
|
154
|
-
// It is the same fault the PPX already refuses to compile when the enum is
|
|
155
|
-
// declared in the same file, and the PPX says so in its own message. This is the
|
|
156
|
-
// residue that a per-file pass cannot reach: field form, enum imported from
|
|
157
|
-
// elsewhere. Two rungs of one ladder — until this was promoted, which rung you
|
|
158
|
-
// landed on decided whether a data-exposure bug stopped the build, and the
|
|
159
|
-
// arbiter was where the enum happened to be declared.
|
|
160
|
-
//
|
|
161
|
-
// **A `value` on a field that is not the record's lifecycle stays a warning.**
|
|
162
|
-
// It would keep the read narrowing while silently losing the command filtering
|
|
163
|
-
// that motivates it — `@transition` is written in terms of the lifecycle field,
|
|
164
|
-
// so a retirement state anywhere else is a state no command can name. That is a
|
|
165
|
-
// modelling judgement rather than a wrong name, and judgement calls are what the
|
|
166
|
-
// withdrawn dead-end rule taught us not to hard-fail on.
|
|
101
|
+
// The check the PPX cannot make (it sees the payload only as a name; the schema
|
|
102
|
+
// says whether that name is a case of the field's enum). A name the enum does not
|
|
103
|
+
// declare is a failure — the predicate then matches no row and every row stays
|
|
104
|
+
// visible. A `value` on a field that is not the lifecycle is only a warning: a
|
|
105
|
+
// modelling judgement rather than a wrong name.
|
|
167
106
|
let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): retiredCheck =>
|
|
168
107
|
switch retiredFromStateSchema(stateSchema) {
|
|
169
108
|
// The boolean form names no state, so there is nothing to compare. Not a skip.
|
|
@@ -216,14 +155,9 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): retire
|
|
|
216
155
|
}
|
|
217
156
|
}
|
|
218
157
|
|
|
219
|
-
// Raised together, after every view
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
// Retroactive in a way the transition check was not: `@transition` was new when
|
|
223
|
-
// its check landed, so nothing deployed could carry a stale name, while `@retired`
|
|
224
|
-
// has been shipping. A deployed plugin holding a misspelled retired value gets a
|
|
225
|
-
// red build on its next deploy — which is the point, and is why the examples were
|
|
226
|
-
// swept before this was promoted.
|
|
158
|
+
// Raised together, after every view is walked, so an author sees every bad name
|
|
159
|
+
// at once. Retroactive: a deployed plugin with a stale value goes red on its next
|
|
160
|
+
// build, which is the point.
|
|
227
161
|
let reportRetiredStates = (
|
|
228
162
|
~pluginName: string,
|
|
229
163
|
~failures: array<string>,
|
|
@@ -245,11 +179,8 @@ let reportRetiredStates = (
|
|
|
245
179
|
}
|
|
246
180
|
}
|
|
247
181
|
|
|
248
|
-
// The states a record's lifecycle field can hold
|
|
249
|
-
//
|
|
250
|
-
// retired one — which is the field a command's `@transition` is written in terms
|
|
251
|
-
// of. `None` means the record declares no lifecycle at all; `Some([])` means it
|
|
252
|
-
// declares one whose shape carries no cases to compare against.
|
|
182
|
+
// The states a record's lifecycle field can hold — the field `@transition` is
|
|
183
|
+
// written in terms of. `None`: no lifecycle; `Some([])`: one with no cases.
|
|
253
184
|
let lifecycleStatesFromStateSchema = (
|
|
254
185
|
~entityName: string,
|
|
255
186
|
stateSchema: S.t<unknown>,
|
|
@@ -270,34 +201,184 @@ let lifecycleStatesFromStateSchema = (
|
|
|
270
201
|
}
|
|
271
202
|
)
|
|
272
203
|
|
|
273
|
-
// The
|
|
274
|
-
// more than a rename.
|
|
275
|
-
//
|
|
276
|
-
// A command declares the states it may run from and the state it lands in, but
|
|
277
|
-
// those states belong to ANOTHER component's lifecycle enum. The PPX only ever
|
|
278
|
-
// sees them as names — it strips the attribute before the typechecker, and a
|
|
279
|
-
// synthetic reference to the constructor does not survive ReScript's pre-PPX
|
|
280
|
-
// dependency walk. So a misspelled state compiles clean, ships, and produces a
|
|
281
|
-
// command that is legal in a state no row is ever in: a menu entry that never
|
|
282
|
-
// appears, with nothing anywhere saying why.
|
|
204
|
+
// ── The port's translation table ─────────────────────────────────────────────
|
|
283
205
|
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
206
|
+
// The PPX reads the table off the mapping's own arms, so these checks are what
|
|
207
|
+
// stands behind a HAND-WRITTEN one — the escape hatch for a mapping whose arms
|
|
208
|
+
// the PPX refused to guess at. Names raise (both sides are `@schema` types, so a
|
|
209
|
+
// non-constructor is a mistake); the probe below is best-effort and reports what
|
|
210
|
+
// it could not follow.
|
|
211
|
+
|
|
212
|
+
let translationTableFailures = (
|
|
213
|
+
~label: string,
|
|
214
|
+
~declared: array<ReventlessInfra.ExtensionPointMapping.publishedEvent>,
|
|
215
|
+
~publishedNames: array<string>,
|
|
216
|
+
~sourceNames: array<string>,
|
|
217
|
+
): array<string> => {
|
|
218
|
+
let failures = []
|
|
219
|
+
let push = msg => failures->Array.push(msg)->ignore
|
|
220
|
+
declared->Array.forEach(({name, fromEventTypes}) => {
|
|
221
|
+
if !(publishedNames->Array.includes(name)) {
|
|
222
|
+
push(
|
|
223
|
+
`${label}: publishedEvents names "${name}", which the extension point does not ` ++
|
|
224
|
+
`publish — it declares ${publishedNames->Array.join(", ")}.`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
fromEventTypes->Array.forEach(src => {
|
|
228
|
+
if !(sourceNames->Array.includes(src)) {
|
|
229
|
+
push(
|
|
230
|
+
`${label}: publishedEvents says "${name}" comes from "${src}", which is not an ` ++
|
|
231
|
+
`event of ${sourceNames->Array.join(", ")}.`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
failures
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// What the probe saw against what was declared. Produced-but-undeclared raises
|
|
240
|
+
// (the probe watched it happen); declared-but-unproduced only warns (the arm may
|
|
241
|
+
// branch on a payload the probe cannot synthesise). Unfollowed sources are not
|
|
242
|
+
// judged at all — "did not look" must not read as "no edge".
|
|
243
|
+
let translationTableDrift = (
|
|
244
|
+
~label: string,
|
|
245
|
+
~declared: array<ReventlessInfra.ExtensionPointMapping.publishedEvent>,
|
|
246
|
+
~observed: array<(string, string)>,
|
|
247
|
+
~followed: array<string>,
|
|
248
|
+
): (array<string>, array<string>) => {
|
|
249
|
+
let declaredEdges = declared->Array.reduce([], (acc, {name, fromEventTypes}) =>
|
|
250
|
+
Array.concat(acc, fromEventTypes->Array.map(src => (src, name)))
|
|
251
|
+
)
|
|
252
|
+
let has = (edges, (src, pub)) => edges->Array.some(((s, p)) => s == src && p == pub)
|
|
253
|
+
|
|
254
|
+
let failures =
|
|
255
|
+
observed
|
|
256
|
+
->Array.filter(edge => !(declaredEdges->has(edge)))
|
|
257
|
+
->Array.map(((src, pub)) =>
|
|
258
|
+
`${label}: "${src}" publishes "${pub}", which publishedEvents does not declare.`
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
let warnings =
|
|
262
|
+
declaredEdges
|
|
263
|
+
->Array.filter(((src, pub)) => followed->Array.includes(src) && !(observed->has((src, pub))))
|
|
264
|
+
->Array.map(((src, pub)) =>
|
|
265
|
+
`${label}: publishedEvents declares "${src}" → "${pub}", which the mapping did not ` ++
|
|
266
|
+
`produce for a synthesised "${src}".`
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
(failures, warnings)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The subscriber's mirror, resolved against the union of both command sets:
|
|
273
|
+
// publishing back to the port is as real an edge as publishing inward.
|
|
274
|
+
let handledTableFailures = (
|
|
275
|
+
~label: string,
|
|
276
|
+
~declared: array<ReventlessInfra.ExtensionMapping.handledEvent>,
|
|
277
|
+
~eventNames: array<string>,
|
|
278
|
+
~commandNames: array<string>,
|
|
279
|
+
): array<string> => {
|
|
280
|
+
let failures = []
|
|
281
|
+
declared->Array.forEach(({name, toCommandTypes}) => {
|
|
282
|
+
if !(eventNames->Array.includes(name)) {
|
|
283
|
+
failures
|
|
284
|
+
->Array.push(
|
|
285
|
+
`${label}: handledEvents names "${name}", which the extension point does not ` ++
|
|
286
|
+
`publish — it declares ${eventNames->Array.join(", ")}.`,
|
|
287
|
+
)
|
|
288
|
+
->ignore
|
|
289
|
+
}
|
|
290
|
+
toCommandTypes->Array.forEach(cmd =>
|
|
291
|
+
if !(commandNames->Array.includes(cmd)) {
|
|
292
|
+
failures
|
|
293
|
+
->Array.push(
|
|
294
|
+
`${label}: handledEvents says "${name}" routes to "${cmd}", which is neither a ` ++
|
|
295
|
+
`delegate command nor an extension point command — ${commandNames->Array.join(", ")}.`,
|
|
296
|
+
)
|
|
297
|
+
->ignore
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
failures
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// The command direction's two tables. Both key on an EP command and value a list
|
|
305
|
+
// of delegate names, so one check serves them: `keyed` names the table, `keyKind`
|
|
306
|
+
// and `valueKind` name what each side must be.
|
|
307
|
+
let commandTableFailures = (
|
|
308
|
+
~label: string,
|
|
309
|
+
~keyed: string,
|
|
310
|
+
~valueKind: string,
|
|
311
|
+
~rows: array<(string, array<string>)>,
|
|
312
|
+
~keyNames: array<string>,
|
|
313
|
+
~valueNames: array<string>,
|
|
314
|
+
): array<string> => {
|
|
315
|
+
let failures = []
|
|
316
|
+
let push = msg => failures->Array.push(msg)->ignore
|
|
317
|
+
rows->Array.forEach(((name, values)) => {
|
|
318
|
+
if !(keyNames->Array.includes(name)) {
|
|
319
|
+
push(
|
|
320
|
+
`${label}: ${keyed} names "${name}", which is not a command of the extension ` ++
|
|
321
|
+
`point — it declares ${keyNames->Array.join(", ")}.`,
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
values->Array.forEach(v =>
|
|
325
|
+
if !(valueNames->Array.includes(v)) {
|
|
326
|
+
push(
|
|
327
|
+
`${label}: ${keyed} says "${name}" ${valueKind} "${v}", which the delegate does ` ++
|
|
328
|
+
`not declare — ${valueNames->Array.join(", ")}.`,
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
})
|
|
333
|
+
failures
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The probe's stand-ins. A mapping can only reach the query engine behind a
|
|
337
|
+
// promise, and such an arm is reported as unfollowed anyway.
|
|
338
|
+
let probeId = "probe"
|
|
339
|
+
let probeMeta: Reventless.Message.meta = {
|
|
340
|
+
service: "Plugin_Structure",
|
|
341
|
+
time: "",
|
|
342
|
+
msgId: "",
|
|
343
|
+
correlationId: "",
|
|
344
|
+
}
|
|
345
|
+
let probeQueryEngine: Reventless.QueryEngine.operations = {
|
|
346
|
+
scan: async (~readModelName as _, ~filterConfigs as _, ~limit as _) => [],
|
|
347
|
+
query: async (
|
|
348
|
+
~readModelName as _,
|
|
349
|
+
~key as _=?,
|
|
350
|
+
~id as _,
|
|
351
|
+
~subIdConfig as _=?,
|
|
352
|
+
~filterConfigs as _=?,
|
|
353
|
+
~ascending as _=?,
|
|
354
|
+
~limit as _=?,
|
|
355
|
+
) => [],
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// One place raises, at the end of the build: a stale table drifts in more than
|
|
359
|
+
// one spot, and stopping at the first would hide the rest.
|
|
360
|
+
let reportTranslationTables = (
|
|
361
|
+
~pluginName: string,
|
|
362
|
+
~failures: array<string>,
|
|
363
|
+
~warnings: array<string>,
|
|
364
|
+
): unit => {
|
|
365
|
+
if Array.length(warnings) > 0 {
|
|
366
|
+
log.warn(~comp="Plugin_Structure", `${pluginName}:\n` ++ warnings->Array.join("\n"))
|
|
367
|
+
}
|
|
368
|
+
if Array.length(failures) > 0 {
|
|
369
|
+
JsError.throwWithMessage(
|
|
370
|
+
`${pluginName}: the declared translation table does not match the mapping.\n` ++
|
|
371
|
+
failures->Array.join("\n"),
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// `@transition` names states belonging to another component's enum, which the PPX
|
|
377
|
+
// only ever sees as names — so a misspelling compiles clean and produces a command
|
|
378
|
+
// legal in a state no row is in. Both sides are in hand here. A state the linked
|
|
379
|
+
// views do not declare raises (this runs at assembly, never in a Lambda, so that is
|
|
380
|
+
// a failed deploy); no resolvable view only warns. Checked against the UNION of the
|
|
381
|
+
// linked views: a slice feeding two is not claiming which one.
|
|
301
382
|
let checkDeclaredTransitions = (
|
|
302
383
|
~pluginName: string,
|
|
303
384
|
~writables: array<Reventless.Plugin.writableDef>,
|
|
@@ -362,24 +443,10 @@ let checkDeclaredTransitions = (
|
|
|
362
443
|
}
|
|
363
444
|
}
|
|
364
445
|
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
// These ask whether the annotations AGREE with each other across an entity, and
|
|
370
|
-
// a graph can be built entirely out of valid names and still be wrong: a state
|
|
371
|
-
// nothing reaches, or one nothing can leave that was never marked as an ending.
|
|
372
|
-
//
|
|
373
|
-
// Reported as warnings rather than raised. The name check fails a build because
|
|
374
|
-
// a state that does not exist is unambiguously a mistake — there is no domain in
|
|
375
|
-
// which it is what the author meant. These are weaker signals: a state with no
|
|
376
|
-
// way out may be a genuine dead end nobody has marked yet, or a legitimate
|
|
377
|
-
// terminal the model reaches by a route this metadata cannot see (an automation,
|
|
378
|
-
// an external system). Failing a deploy on a modelling smell would be the wrong
|
|
379
|
-
// trade, and a smell that stops a deploy gets silenced rather than fixed.
|
|
380
|
-
// Pure, and returns its findings rather than logging them, so the rule can be
|
|
381
|
-
// tested without reading a log. `checkLifecycleTopology` below is the thin part
|
|
382
|
-
// that reports them.
|
|
446
|
+
// Whether the declared transitions AGREE across an entity — a graph of valid names
|
|
447
|
+
// can still leave a state nothing reaches. Warnings only: a smell is not a mistake,
|
|
448
|
+
// and one that stops a deploy gets silenced rather than fixed. Pure, so the rule
|
|
449
|
+
// tests without a log; `checkLifecycleTopology` reports.
|
|
383
450
|
let lifecycleTopologyFindings = (
|
|
384
451
|
~writables: array<Reventless.Plugin.writableDef>,
|
|
385
452
|
~lifecycleStatesByView: dict<array<string>>,
|
|
@@ -388,20 +455,15 @@ let lifecycleTopologyFindings = (
|
|
|
388
455
|
lifecycleStatesByView
|
|
389
456
|
->Dict.toArray
|
|
390
457
|
->Array.forEach(((view, states)) => {
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
// from-set — which is what lets a creating command (`@transition(() => X)`,
|
|
394
|
-
// a target and no from-set, because it runs from no row) count towards
|
|
395
|
-
// reachability the same way an edge out of another state does.
|
|
458
|
+
// Arrival only, so a creating command (a target and no from-set) counts
|
|
459
|
+
// towards reachability like any other edge.
|
|
396
460
|
let reachable = writables->Array.reduce([], (acc, w) =>
|
|
397
461
|
w.linkedViews->Array.includes(view)
|
|
398
462
|
? Array.concat(acc, w.commands->Array.filterMap(cmd => cmd.targetState))
|
|
399
463
|
: acc
|
|
400
464
|
)
|
|
401
465
|
if Array.length(reachable) > 0 {
|
|
402
|
-
// Rows start in the first declared state
|
|
403
|
-
// lifecycle diagram uses — so nothing pointing at it is expected rather
|
|
404
|
-
// than suspicious.
|
|
466
|
+
// Rows start in the first declared state, so nothing pointing at it is fine.
|
|
405
467
|
let initial = states->Array.get(0)
|
|
406
468
|
|
|
407
469
|
states->Array.forEach(state => {
|
|
@@ -414,23 +476,9 @@ let lifecycleTopologyFindings = (
|
|
|
414
476
|
))
|
|
415
477
|
->ignore
|
|
416
478
|
}
|
|
417
|
-
// NOT checked: a state with no way out.
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
// suspicious" — was written, run against the shipped examples, and
|
|
421
|
-
// removed, because it is wrong twice over.
|
|
422
|
-
//
|
|
423
|
-
// It fires on correct models: `Shipped` and `Refunded` are terminal in
|
|
424
|
-
// the aggregates shop, as terminal states are in most lifecycles, and
|
|
425
|
-
// there is nothing to fix about either.
|
|
426
|
-
//
|
|
427
|
-
// Worse, its suggested fix is harmful. `@retired` does not mean
|
|
428
|
-
// "terminal" — it means WITHDRAWN FROM ORDINARY READS. Marking a shipped
|
|
429
|
-
// order retired to silence a lint would hide every shipped order from
|
|
430
|
-
// every caller who cannot widen their read. An ending and a withdrawal
|
|
431
|
-
// are different facts, and nothing in the vocabulary currently
|
|
432
|
-
// distinguishes an intentional terminal from an accidental one, so the
|
|
433
|
-
// check cannot tell them apart and should not pretend to.
|
|
479
|
+
// NOT checked: a state with no way out. Terminal states are ordinary,
|
|
480
|
+
// and the only fix a lint could suggest — `@retired` — withdraws the
|
|
481
|
+
// rows from reads rather than marking an ending.
|
|
434
482
|
})
|
|
435
483
|
}
|
|
436
484
|
})
|
|
@@ -446,44 +494,12 @@ let checkLifecycleTopology = (
|
|
|
446
494
|
log.warn(~comp="Plugin_Structure", `${pluginName}/${view}: ${message}`)
|
|
447
495
|
)
|
|
448
496
|
|
|
449
|
-
// NOT here:
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
// no declaration check can see, because nothing declared is wrong.
|
|
456
|
-
//
|
|
457
|
-
// It is not computable from this metadata, and the reason is worth recording so
|
|
458
|
-
// the attempt is not repeated. Two narrowings were needed and only one was
|
|
459
|
-
// available:
|
|
460
|
-
//
|
|
461
|
-
// 1. The event must belong to the view's own entity, not merely be something a
|
|
462
|
-
// slice looked up. Available: require it to be PRODUCED by a writable
|
|
463
|
-
// linked to the same view. Without this the check fires on ordinary DCB —
|
|
464
|
-
// `AddProduct` folds `CategoryAdded` to check a category exists, and the
|
|
465
|
-
// `Products` view is right to ignore it.
|
|
466
|
-
//
|
|
467
|
-
// 2. The slice must be known to fold the event. NOT available:
|
|
468
|
-
// `consumedEventTypes` is built from `eventVariantNames`, which drops
|
|
469
|
-
// payload-less variants — and a lifecycle-moving event is usually
|
|
470
|
-
// payload-less in the slice that folds it. Measured on the shipped example:
|
|
471
|
-
// both order slices publish `consumedEventTypes: ["Ordering.OrderPlaced"]`
|
|
472
|
-
// and nothing else, though each folds three more.
|
|
473
|
-
//
|
|
474
|
-
// So the exact events the rule is about are the ones the metadata does not
|
|
475
|
-
// record. Making it work means publishing the payload-less consumed variants,
|
|
476
|
-
// which is a platform change with its own consequences, not a lint.
|
|
477
|
-
|
|
478
|
-
// Which rung of the ladder below produced the label. Published on `queryableDef`
|
|
479
|
-
// as `labelFieldSource`, because the four rungs are not equally believable and a
|
|
480
|
-
// consumer with a name rule of its own has to rank the declaration against it:
|
|
481
|
-
// rung 1 is the author saying which field names the record, rungs 2 and 3 are
|
|
482
|
-
// guesses this repo makes on their behalf, and rung 4 is the admission that
|
|
483
|
-
// there was nothing to guess from. Convention and position are both guesses and
|
|
484
|
-
// nothing here branches on the difference — but a conventional name is the one a
|
|
485
|
-
// client can independently arrive at, while a positional pick is a fact only
|
|
486
|
-
// this side knows.
|
|
497
|
+
// NOT here: an event-consumption completeness check. `consumedEventTypes` drops
|
|
498
|
+
// payload-less variants, which is exactly what a lifecycle-moving event usually
|
|
499
|
+
// is — so the events such a rule is about are the ones this metadata omits.
|
|
500
|
+
|
|
501
|
+
// Which rung produced the label, published as `labelFieldSource`: a consumer with
|
|
502
|
+
// a name rule of its own has to rank a declaration (rung 1) against a guess.
|
|
487
503
|
type labelFieldSource =
|
|
488
504
|
| Annotation
|
|
489
505
|
| Convention
|
|
@@ -504,22 +520,9 @@ type labelResolution = {
|
|
|
504
520
|
source: labelFieldSource,
|
|
505
521
|
}
|
|
506
522
|
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
// 2. A candidate field named `name`/`title`/`label`/`displayName`
|
|
511
|
-
// 3. The first candidate in declaration order
|
|
512
|
-
// 4. "id" fallback with a logged warning (no searchable fields)
|
|
513
|
-
//
|
|
514
|
-
// A *candidate* is a non-TAG field, not literally named `id`, whose IR shape can
|
|
515
|
-
// name a record (`isLabelShape`). Rungs 2 and 3 are both guesses, and the order
|
|
516
|
-
// between them matters: declaration order alone means a state gains a new name
|
|
517
|
-
// whenever a field is inserted above the old one — a `placedAt` added so date
|
|
518
|
-
// views have something to key off renames every order to a timestamp.
|
|
519
|
-
//
|
|
520
|
-
// `id` is excluded here rather than by the IR because `SchemaType` treats a name
|
|
521
|
-
// that short as an ordinary string; every other reference — `*Id`/`*Ids`, a DCB
|
|
522
|
-
// tag, a `Reference.to` — is already an `EntityId` there.
|
|
523
|
+
// The field a state is named by: `@displayName`, else a candidate named
|
|
524
|
+
// name/title/label/displayName, else the first candidate, else `id` with a
|
|
525
|
+
// warning. A candidate is a non-TAG field, not named `id`, passing `isLabelShape`.
|
|
523
526
|
let labelFieldsFromStateSchema = (
|
|
524
527
|
~entityName: string,
|
|
525
528
|
stateSchema: S.t<unknown>,
|
|
@@ -559,18 +562,13 @@ let labelFieldsFromStateSchema = (
|
|
|
559
562
|
}
|
|
560
563
|
}
|
|
561
564
|
|
|
562
|
-
// ── Per-variant event / error field extraction
|
|
563
|
-
// Mirrors
|
|
564
|
-
//
|
|
565
|
-
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
// A variant's declared cross-entity references. Shared by the command and event
|
|
570
|
-
// walks because the two ask the identical question of identical field dicts, and
|
|
571
|
-
// the question is `getFieldTarget` rather than `getTarget`: a reference declared
|
|
572
|
-
// on an `array<string>` field sits on the element schema. Collecting it in one
|
|
573
|
-
// place is what stops one walk from being taught that and the other not.
|
|
565
|
+
// ── Per-variant event / error field extraction ───────────────────────────────
|
|
566
|
+
// Mirrors the command walk for emitted events. Module-level so Platform_Admin,
|
|
567
|
+
// whose components never pass through `make`, derives its defs the same way.
|
|
568
|
+
|
|
569
|
+
// A variant's cross-entity references, shared by the command and event walks.
|
|
570
|
+
// `getFieldTarget`, not `getTarget`: an `array<string>` field declares it on the
|
|
571
|
+
// element schema.
|
|
574
572
|
let extractReferences = (properties: dict<S.t<unknown>>): array<
|
|
575
573
|
Reventless.Plugin.fieldReference,
|
|
576
574
|
> =>
|
|
@@ -607,10 +605,8 @@ let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
|
|
|
607
605
|
| _ => None
|
|
608
606
|
}
|
|
609
607
|
)
|
|
610
|
-
// Payload-less
|
|
611
|
-
//
|
|
612
|
-
// producedEventTypes/consumedEventTypes; but the full `events` list carries them
|
|
613
|
-
// so the event graph can still draw the emitted (orphan) event node.
|
|
608
|
+
// Payload-less variants compile to a bare string literal. Kept here (though not
|
|
609
|
+
// in produced/consumedEventTypes) so the graph can draw the orphan node.
|
|
614
610
|
| String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties=Dict.make()))
|
|
615
611
|
| _ => None
|
|
616
612
|
}
|
|
@@ -622,21 +618,16 @@ let extractEventDefs = (eventSchema: S.t<unknown>): array<Reventless.Plugin.even
|
|
|
622
618
|
| _ => toEventDef(eventSchema)->Option.mapOr([], def => [def])
|
|
623
619
|
}
|
|
624
620
|
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
// the bare-string branch above already handles. Reusing the walk rather than
|
|
628
|
-
// copying it keeps the two from drifting; only the def type differs, so that a
|
|
629
|
-
// consumer (and the SDL) can tell a refusal from a fact.
|
|
621
|
+
// Errors walk identically to events; only the def type differs, so a consumer
|
|
622
|
+
// can tell a refusal from a fact. Shared rather than copied, so they cannot drift.
|
|
630
623
|
let extractErrorDefs = (errorSchema: S.t<unknown>): array<Reventless.Plugin.errorDef> =>
|
|
631
624
|
extractEventDefs(errorSchema)->Array.map(({name, schema, references}) => (
|
|
632
625
|
{name, schema, references}: Reventless.Plugin.errorDef
|
|
633
626
|
))
|
|
634
627
|
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
// is what let its command metadata drift from the SDL generated off the same
|
|
639
|
-
// schema.
|
|
628
|
+
// Module-level like the event walk: the synthetic Platform_Admin structure has no
|
|
629
|
+
// `module(Aggregate.T)` to hand `make`, and a second copy of this walk is what let
|
|
630
|
+
// its metadata drift from the SDL.
|
|
640
631
|
|
|
641
632
|
// Aggregate commands that initialize a new aggregate instance are Collection-level
|
|
642
633
|
// (shown as table-top buttons); all others are Instance-level (shown per-row).
|
|
@@ -677,32 +668,18 @@ let commandLevelAndId = (~isAggregate, ~variantName, properties: dict<S.t<unknow
|
|
|
677
668
|
}
|
|
678
669
|
}
|
|
679
670
|
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
// `AllowAuthenticated` / `AllowAnonymous` ask for nothing a client can check —
|
|
686
|
-
// anyone holding a session already satisfies them — so they publish no keys
|
|
687
|
-
// rather than a key everyone holds.
|
|
688
|
-
//
|
|
689
|
-
// `DenyAll` also publishes none, deliberately. An unsatisfiable key would render
|
|
690
|
-
// as locked-with-upsell in a tiered shell: a surface advertised as purchasable
|
|
691
|
-
// that no purchase unlocks. A component nobody may call belongs in no menu, and
|
|
692
|
-
// that is an omission for the enumerating side to make, not a key to invent here.
|
|
671
|
+
// The server's rule as keys a client checks against `identity.groups ++
|
|
672
|
+
// config.accessTiers`; an any-of, since `isAllowed` is `some`. The rules asking
|
|
673
|
+
// for nothing checkable — and `DenyAll` — publish no keys rather than an
|
|
674
|
+
// unsatisfiable one.
|
|
693
675
|
let accessKeysFor = (rule: Reventless.Authorization.permission): option<array<string>> =>
|
|
694
676
|
switch rule {
|
|
695
677
|
| AllowGroups(groups) if groups->Array.length > 0 => Some(groups)
|
|
696
678
|
| AllowGroups(_) | AllowAuthenticated | AllowAnonymous | DenyAll => None
|
|
697
679
|
}
|
|
698
680
|
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
// variable the server actually expects instead of guessing `String!`.
|
|
702
|
-
//
|
|
703
|
-
// Mutates the freshly derived schema in place — `deriveObjectSchema` has just
|
|
704
|
-
// built it and nothing else holds it yet. A property with no matching
|
|
705
|
-
// argument is left alone rather than annotated with a guess.
|
|
681
|
+
// Each mutation argument's GraphQL type onto its property, so a consumer declares
|
|
682
|
+
// the variable the server expects. Mutates the freshly derived schema in place.
|
|
706
683
|
let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
|
|
707
684
|
schema
|
|
708
685
|
->JSON.Decode.object
|
|
@@ -738,10 +715,7 @@ let toCommandDef = (
|
|
|
738
715
|
let mkDef = (~variantName, ~properties) => {
|
|
739
716
|
let (level, aggregateIdField) = commandLevelAndId(~isAggregate, ~variantName, properties)
|
|
740
717
|
let references = extractReferences(properties)
|
|
741
|
-
// Per-variant
|
|
742
|
-
// (the PPX attaches a single dict<variantName, [|states|]> via
|
|
743
|
-
// markAllowedStates). Look it up by variant name; back-compat
|
|
744
|
-
// None when the variant lacks a @transition annotation.
|
|
718
|
+
// Per-variant, but attached by the PPX to the *parent* schema as one dict.
|
|
745
719
|
let allowedStates = ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName)
|
|
746
720
|
// The `@transition` target (the command's *to* status), read the same way
|
|
747
721
|
// as allowedStates. None ⇒ AutoUI's board resolver falls back to its
|
|
@@ -780,28 +754,13 @@ let toCommandDef = (
|
|
|
780
754
|
}
|
|
781
755
|
({
|
|
782
756
|
Reventless.Plugin.name: variantName,
|
|
783
|
-
// The derived schema, not sury's raw one
|
|
784
|
-
//
|
|
785
|
-
// fields, so a command's `@storageRef`/`@semantic`/`@ref` reached the
|
|
786
|
-
// wire on the read side and nowhere on the write side. A reader that
|
|
787
|
-
// matches a field against its setter — or picks the upload endpoint of
|
|
788
|
-
// the store a command argument declares — then has nothing to match on.
|
|
789
|
-
// `MCP_SchemaGenerator` already derives these same variant schemas.
|
|
790
|
-
//
|
|
791
|
-
// Carries `x-reventless-graphql-type` per property — see
|
|
792
|
-
// `annotateArgTypes`.
|
|
757
|
+
// The derived schema, not sury's raw one, which drops every `x-reventless-*`
|
|
758
|
+
// marker the PPX put on the fields. Also carries `x-reventless-graphql-type`.
|
|
793
759
|
schema: annotatedSchema->JSON.stringify,
|
|
794
760
|
level,
|
|
795
761
|
aggregateIdField,
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
// variant — including the `@noApi` one — to the slice's one mutation
|
|
799
|
-
// field, so emitting it here would hand the non-exposed variant a
|
|
800
|
-
// sibling's callable-looking field (e.g. `ReopenOrder` →
|
|
801
|
-
// `Ordering_CancelOrder`). Emit an empty sentinel instead; the variant
|
|
802
|
-
// stays listed with `apiExposed: false` for the event-graph badge, but
|
|
803
|
-
// no consumer can mistake it for a callable field. Exposed variants are
|
|
804
|
-
// byte-identical.
|
|
762
|
+
// Empty for a `@noApi` variant: `mutationFieldFor` would resolve it to a
|
|
763
|
+
// sibling's field, which reads as callable. Exposed variants are unchanged.
|
|
805
764
|
mutationField,
|
|
806
765
|
references,
|
|
807
766
|
allowedStates,
|
|
@@ -871,24 +830,10 @@ let visibilityTag = (v: Reventless.Visibility.t): option<string> =>
|
|
|
871
830
|
}
|
|
872
831
|
|
|
873
832
|
/**
|
|
874
|
-
A read model's
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
`make`
|
|
878
|
-
`module(ReadModel.T)` values, and the platform has no built module to hand
|
|
879
|
-
itself at structure-assembly time — the components it is describing are the ones
|
|
880
|
-
being assembled. That is a real constraint and this routes around it rather than
|
|
881
|
-
pretending it away, by taking the schemas the extractors actually need.
|
|
882
|
-
|
|
883
|
-
Everything here is the same helper `make` calls on the same schema, so the
|
|
884
|
-
platform's own view is described by the mechanism that describes everyone
|
|
885
|
-
else's. The alternative — a hand-written record — is what let the admin's
|
|
886
|
-
metadata drift from the SDL generated off the same spec, four times over.
|
|
887
|
-
|
|
888
|
-
`queryField` and `singleQueryField` come from `Api_Naming`, which is the only
|
|
889
|
-
place that decides how a name singularises. For the admin these are
|
|
890
|
-
byte-identical to the names it used to hand-write; `singularize` already handled
|
|
891
|
-
the plural-spec-name / singular-type shape that looked bespoke.
|
|
833
|
+
A read model's `queryableDef` assembled from its spec, for the platform's own
|
|
834
|
+
components: `make` takes `module(ReadModel.T)` values, and at structure-assembly
|
|
835
|
+
time the platform has no built module to hand itself. Every extractor here is the
|
|
836
|
+
one `make` calls, so the two cannot drift — a hand-written record did, four times.
|
|
892
837
|
*/
|
|
893
838
|
let queryableDefFromSpec = (
|
|
894
839
|
~plugin: string,
|
|
@@ -940,23 +885,18 @@ let make = (
|
|
|
940
885
|
~inboundTranslationSlices: array<module(ReventlessInfra.InboundTranslationSlice.T)>=[],
|
|
941
886
|
~extensions: array<module(ReventlessInfra.Extension.Blueprint)>=[],
|
|
942
887
|
~extensionPoints: array<module(ReventlessInfra.ExtensionPointMapping.Mapping)>=[],
|
|
943
|
-
// Component name → chapter
|
|
944
|
-
//
|
|
945
|
-
// spec name has no entry (or lives directly under a kind-folder) carries no chapter
|
|
946
|
-
// and renders flat. Keyed by `Spec.name`, which equals the source filename stem for
|
|
947
|
-
// every graph-node kind, so the generator can build this map from the discovered
|
|
948
|
-
// file paths. See `Codegen.chapterOf` and docs/plans/done/deployed-chapter-grouping.md.
|
|
888
|
+
// Component name → chapter, captured from each component's source folder by the
|
|
889
|
+
// plugin generator. Keyed by `Spec.name`; no entry renders flat.
|
|
949
890
|
~componentChapters: dict<string>=Dict.make(),
|
|
950
891
|
): Reventless.Plugin.pluginStructure => {
|
|
951
892
|
let chapterOf = (compName: string): option<string> => componentChapters->Dict.get(compName)
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
// claim cross-component edges that the runtime can't honour.
|
|
893
|
+
// Payload-less variants dropped: the graph must not claim an edge a DCB lookup
|
|
894
|
+
// cannot WHERE-clause on.
|
|
955
895
|
let eventVariantNames = schema => Reventless.DcbTag.extractVariantNames(schema)
|
|
956
|
-
//
|
|
957
|
-
// GraphQL mutation surface stays addressable.
|
|
896
|
+
// Every constructor, so the mutation surface stays addressable.
|
|
958
897
|
let commandVariantNames = schema => Reventless.DcbTag.extractAllVariantNames(schema)
|
|
959
898
|
let qualify = (~prefix, names) => names->Array.map(n => prefix ++ "." ++ n)
|
|
899
|
+
let dedupe = (xs: array<string>) => xs->Belt.Set.String.fromArray->Belt.Set.String.toArray
|
|
960
900
|
|
|
961
901
|
// ── Per-component event type extraction ────────────────────────────────────
|
|
962
902
|
|
|
@@ -1058,21 +998,10 @@ let make = (
|
|
|
1058
998
|
|
|
1059
999
|
// ── Declared object stores ─────────────────────────────────────────────────
|
|
1060
1000
|
//
|
|
1061
|
-
// A
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
//
|
|
1065
|
-
// would have to re-walk every component to find it.
|
|
1066
|
-
//
|
|
1067
|
-
// An unqualified store belongs to the declaring plugin, so every collected
|
|
1068
|
-
// entry is qualified to `{plugin}.{store}` — one shape, and the string is
|
|
1069
|
-
// directly the store's identity. Many fields legitimately name one store, so
|
|
1070
|
-
// the key set is deduplicated.
|
|
1071
|
-
//
|
|
1072
|
-
// Each collected entry keeps its declaration site `(component, field)` as
|
|
1073
|
-
// provenance, and `requiredStores` is derived from the collected entries —
|
|
1074
|
-
// one walk, so the capability manifest's provenance and the deploy's key set
|
|
1075
|
-
// cannot come from different readings of the schemas.
|
|
1001
|
+
// A storage-ref field states the deployment needs that store. Collected here so
|
|
1002
|
+
// the requirement travels with the structure, qualified to `{plugin}.{store}`
|
|
1003
|
+
// and deduplicated, each entry keeping its `(component, field)` site.
|
|
1004
|
+
// `requiredStores` is derived from the same walk, so the two cannot disagree.
|
|
1076
1005
|
|
|
1077
1006
|
let storesFromProperties = (~component, properties: dict<S.t<unknown>>): array<
|
|
1078
1007
|
Reventless.Plugin.requiredStoreDeclaration,
|
|
@@ -1088,11 +1017,8 @@ let make = (
|
|
|
1088
1017
|
Reventless.Plugin.store: target.plugin->Option.getOr(name) ++ "." ++ target.store,
|
|
1089
1018
|
component,
|
|
1090
1019
|
field,
|
|
1091
|
-
// The
|
|
1092
|
-
//
|
|
1093
|
-
// source text rather than a guess at it — and this is the only place
|
|
1094
|
-
// that distinction survives. Always `Some` here: the field is optional
|
|
1095
|
-
// on the type only so definitions stored before it existed still decode.
|
|
1020
|
+
// The source text, not a guess: `target.plugin` is `None` exactly when the
|
|
1021
|
+
// field left the store unqualified, and only here does that survive.
|
|
1096
1022
|
annotation: Some(
|
|
1097
1023
|
switch target.plugin {
|
|
1098
1024
|
| None => target.store
|
|
@@ -1135,10 +1061,8 @@ let make = (
|
|
|
1135
1061
|
}
|
|
1136
1062
|
}
|
|
1137
1063
|
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
// exactly once: either its declaration is collected, or its name is eligible
|
|
1141
|
-
// to warn — never a third reading that could disagree with both.
|
|
1064
|
+
// One list feeds both the store collection and the lint below, so a field is
|
|
1065
|
+
// read exactly once by either.
|
|
1142
1066
|
let storeDeclarationSites: array<(string, S.t<unknown>)> =
|
|
1143
1067
|
[
|
|
1144
1068
|
aggregates->Array.flatMap((module(A: ReventlessInfra.Aggregate.T with type api = api)) => [
|
|
@@ -1165,9 +1089,7 @@ let make = (
|
|
|
1165
1089
|
) => (ITS.Spec.name, ITS.Spec.commandSchema->S.castToUnknown)),
|
|
1166
1090
|
]->Array.flat
|
|
1167
1091
|
|
|
1168
|
-
// Sorted
|
|
1169
|
-
// (one store named by a component's command field and again by its event
|
|
1170
|
-
// field under the same field name is one declaration site).
|
|
1092
|
+
// Sorted for a deterministic manifest; identical triples collapse.
|
|
1171
1093
|
let requiredStoreDeclarations =
|
|
1172
1094
|
storeDeclarationSites
|
|
1173
1095
|
->Array.flatMap(((component, schema)) => storesFromSchema(~component, schema))
|
|
@@ -1185,9 +1107,8 @@ let make = (
|
|
|
1185
1107
|
->Belt.Set.String.fromArray
|
|
1186
1108
|
->Belt.Set.String.toArray
|
|
1187
1109
|
|
|
1188
|
-
// Two stores one edit apart
|
|
1189
|
-
//
|
|
1190
|
-
// mistake, and by deploy time both buckets exist.
|
|
1110
|
+
// Two stores one edit apart provision twice. Hard failure, unlike the lint
|
|
1111
|
+
// below: by deploy time both buckets exist.
|
|
1191
1112
|
switch Capability_Inference.collisions(requiredStoreDeclarations) {
|
|
1192
1113
|
| [] => ()
|
|
1193
1114
|
| found =>
|
|
@@ -1208,12 +1129,8 @@ let make = (
|
|
|
1208
1129
|
|
|
1209
1130
|
// ── Build queryable defs ───────────────────────────────────────────────────
|
|
1210
1131
|
//
|
|
1211
|
-
// Internal
|
|
1212
|
-
//
|
|
1213
|
-
// `Some("Internal")` = Internal). Developer tools — the `reventless-gwt` / VSCode domain
|
|
1214
|
-
// graph and dead-code analysis — read them so an Internal view still shows up there. The
|
|
1215
|
-
// deployed AutoUI's consumers (Platform_ComponentDefinitionsApi menu/pages) re-filter on
|
|
1216
|
-
// the tag so the live UI keeps hiding them — see Visibility.res, which documents this contract.
|
|
1132
|
+
// Internal views are carried here, tagged via `queryableDef.visibility`, so
|
|
1133
|
+
// developer tools see them; AutoUI's consumers re-filter on the tag.
|
|
1217
1134
|
// View name -> the states its lifecycle field can hold, collected as the view
|
|
1218
1135
|
// defs are built so the transition check below has both sides in one place.
|
|
1219
1136
|
let lifecycleStatesByView: dict<array<string>> = Dict.make()
|
|
@@ -1250,10 +1167,7 @@ let make = (
|
|
|
1250
1167
|
~entityName=R.Spec.name,
|
|
1251
1168
|
stateSchema,
|
|
1252
1169
|
)
|
|
1253
|
-
//
|
|
1254
|
-
// they match the producers' nodes in the graph. Was empty — which dropped projection
|
|
1255
|
-
// edges for any event reaching the read model via a DCB-log-sourced mapping (a classic
|
|
1256
|
-
// aggregate→view link is also drawn from the producer's linkedViews, deduped downstream).
|
|
1170
|
+
// Qualified to the plugin's event ids so they match the producers' nodes.
|
|
1257
1171
|
let consumed = qualify(~prefix=name, R.consumedEventNames)
|
|
1258
1172
|
recordRetired(~entityName=R.Spec.name, stateSchema)
|
|
1259
1173
|
recordLifecycle(~entityName=R.Spec.name, stateSchema)
|
|
@@ -1422,30 +1336,112 @@ let make = (
|
|
|
1422
1336
|
|
|
1423
1337
|
// ── Extensions ───────────────────────────────────────────────────────────
|
|
1424
1338
|
|
|
1339
|
+
let tableFailures = []
|
|
1340
|
+
let tableWarnings = []
|
|
1341
|
+
let pushAll = (into, xs) => xs->Array.forEach(x => into->Array.push(x)->ignore)
|
|
1342
|
+
|
|
1343
|
+
// Every constructor, payload-less included: a declaration is checked against
|
|
1344
|
+
// what the author can write, not the payload-filtered subset the edges use.
|
|
1345
|
+
let allVariantNames = schema => Reventless.DcbTag.extractAllVariantNames(schema)
|
|
1346
|
+
|
|
1425
1347
|
let extensionDefs =
|
|
1426
1348
|
extensions->Array.map((module(E: ReventlessInfra.Extension.Blueprint)) => {
|
|
1427
1349
|
let delegateNames = E.mappings->Array.map((module(M: E.Mapping)) => M.delegateName)
|
|
1350
|
+
let epEventNames = allVariantNames(E.Spec.eventSchema)
|
|
1351
|
+
let epCommandNames = allVariantNames(E.Spec.commandSchema)
|
|
1352
|
+
|
|
1353
|
+
// Mappings sharing one EP union their tables — the same event may route to
|
|
1354
|
+
// a different delegate's command in each.
|
|
1355
|
+
let commandsByEvent: Dict.t<array<string>> = Dict.make()
|
|
1356
|
+
let eventsByCommand: Dict.t<array<string>> = Dict.make()
|
|
1357
|
+
E.mappings->Array.forEach((module(M: E.Mapping)) => {
|
|
1358
|
+
let label = `${E.Spec.name} → ${M.delegateName}`
|
|
1359
|
+
pushAll(
|
|
1360
|
+
tableFailures,
|
|
1361
|
+
handledTableFailures(
|
|
1362
|
+
~label,
|
|
1363
|
+
~declared=M.handledEvents,
|
|
1364
|
+
~eventNames=epEventNames,
|
|
1365
|
+
~commandNames=Array.concat(M.delegateCommandNames, epCommandNames),
|
|
1366
|
+
),
|
|
1367
|
+
)
|
|
1368
|
+
pushAll(
|
|
1369
|
+
tableFailures,
|
|
1370
|
+
commandTableFailures(
|
|
1371
|
+
~label,
|
|
1372
|
+
~keyed="issuedCommands",
|
|
1373
|
+
~valueKind="comes from",
|
|
1374
|
+
~rows=M.issuedCommands->Array.map(({name, fromEventTypes}) => (name, fromEventTypes)),
|
|
1375
|
+
~keyNames=epCommandNames,
|
|
1376
|
+
~valueNames=M.delegateEventNames,
|
|
1377
|
+
),
|
|
1378
|
+
)
|
|
1379
|
+
M.issuedCommands->Array.forEach(({name: commandName, fromEventTypes}) => {
|
|
1380
|
+
let key = `${E.Spec.name}.${commandName}`
|
|
1381
|
+
eventsByCommand->Dict.set(
|
|
1382
|
+
key,
|
|
1383
|
+
Array.concat(
|
|
1384
|
+
eventsByCommand->Dict.get(key)->Option.getOr([]),
|
|
1385
|
+
qualify(~prefix=name, fromEventTypes),
|
|
1386
|
+
),
|
|
1387
|
+
)
|
|
1388
|
+
})
|
|
1389
|
+
M.handledEvents->Array.forEach(({name: eventName, toCommandTypes}) => {
|
|
1390
|
+
// The qualifier says which way the command goes: plugin-qualified
|
|
1391
|
+
// inward, EP-qualified back to the port.
|
|
1392
|
+
let qualified =
|
|
1393
|
+
toCommandTypes->Array.map(cmd =>
|
|
1394
|
+
M.delegateCommandNames->Array.includes(cmd)
|
|
1395
|
+
? `${name}.${cmd}`
|
|
1396
|
+
: `${E.Spec.name}.${cmd}`
|
|
1397
|
+
)
|
|
1398
|
+
let key = `${E.Spec.name}.${eventName}`
|
|
1399
|
+
commandsByEvent->Dict.set(
|
|
1400
|
+
key,
|
|
1401
|
+
Array.concat(commandsByEvent->Dict.get(key)->Option.getOr([]), qualified),
|
|
1402
|
+
)
|
|
1403
|
+
})
|
|
1404
|
+
})
|
|
1405
|
+
|
|
1428
1406
|
({
|
|
1429
1407
|
Reventless.Plugin.name: E.Spec.name,
|
|
1430
1408
|
delegateNames,
|
|
1431
1409
|
eventTypes: qualify(~prefix=E.Spec.name, eventVariantNames(E.Spec.eventSchema)),
|
|
1432
1410
|
commandTypes: qualify(~prefix=E.Spec.name, commandVariantNames(E.Spec.commandSchema)),
|
|
1411
|
+
handledEvents: Some(
|
|
1412
|
+
commandsByEvent
|
|
1413
|
+
->Dict.toArray
|
|
1414
|
+
->Array.map(((eventName, cmds)) => ({
|
|
1415
|
+
Reventless.Plugin.name: eventName,
|
|
1416
|
+
toCommandTypes: dedupe(cmds),
|
|
1417
|
+
}: Reventless.Plugin.handledEventDef)),
|
|
1418
|
+
),
|
|
1419
|
+
issuedCommands: Some(
|
|
1420
|
+
eventsByCommand
|
|
1421
|
+
->Dict.toArray
|
|
1422
|
+
->Array.map(((commandName, evs)) => ({
|
|
1423
|
+
Reventless.Plugin.name: commandName,
|
|
1424
|
+
fromEventTypes: dedupe(evs),
|
|
1425
|
+
}: Reventless.Plugin.issuedCommandDef)),
|
|
1426
|
+
),
|
|
1433
1427
|
}: Reventless.Plugin.extensionDef)
|
|
1434
1428
|
})
|
|
1435
1429
|
|
|
1436
1430
|
// ── Extension points (producer side) ──────────────────────────────────────
|
|
1437
1431
|
//
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
// each delegate's name + its source event types. Source events are qualified
|
|
1442
|
-
// with the plugin name to match `producedEventTypes` on the write-sides, so the
|
|
1443
|
-
// event graph can draw producing-write-side → event → extension-point.
|
|
1444
|
-
|
|
1445
|
-
let dedupe = (xs: array<string>) =>
|
|
1446
|
-
xs->Belt.Set.String.fromArray->Belt.Set.String.toArray
|
|
1432
|
+
// Several mappings can target one EP, so group by its dotted name and union each
|
|
1433
|
+
// delegate's name and source events. Source events are plugin-qualified to match
|
|
1434
|
+
// `producedEventTypes`, so the graph can draw write-side → event → EP.
|
|
1447
1435
|
|
|
1448
1436
|
let epByName: Dict.t<(array<string>, array<string>, array<string>)> = Dict.make()
|
|
1437
|
+
// Per EP: published event → the internal events producing it, unioned over
|
|
1438
|
+
// every mapping targeting it.
|
|
1439
|
+
let epPublished: Dict.t<Dict.t<array<string>>> = Dict.make()
|
|
1440
|
+
// The command direction's mirror: arriving command → the delegate commands it
|
|
1441
|
+
// routes to. Unioned the same way — one port's inbound protocol is split across
|
|
1442
|
+
// its mappings, so a command handled by a sibling is not dead surface.
|
|
1443
|
+
let epAccepted: Dict.t<Dict.t<array<string>>> = Dict.make()
|
|
1444
|
+
|
|
1449
1445
|
extensionPoints->Array.forEach((module(M: ReventlessInfra.ExtensionPointMapping.Mapping)) => {
|
|
1450
1446
|
let epName = M.ExtensionPoint.name
|
|
1451
1447
|
let sourceEvents = qualify(~prefix=name, eventVariantNames(M.Delegate.eventSchema->S.castToUnknown))
|
|
@@ -1457,7 +1453,217 @@ let make = (
|
|
|
1457
1453
|
epName,
|
|
1458
1454
|
(Array.concat(dels, [M.Delegate.name]), Array.concat(evs, sourceEvents), Array.concat(cmds, commands)),
|
|
1459
1455
|
)
|
|
1456
|
+
|
|
1457
|
+
let label = `${epName} ← ${M.Delegate.name}`
|
|
1458
|
+
let publishedNames = allVariantNames(M.ExtensionPoint.eventSchema)
|
|
1459
|
+
let sourceNames = allVariantNames(M.Delegate.eventSchema->S.castToUnknown)
|
|
1460
|
+
pushAll(
|
|
1461
|
+
tableFailures,
|
|
1462
|
+
translationTableFailures(~label, ~declared=M.publishedEvents, ~publishedNames, ~sourceNames),
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
// ── The command direction ────────────────────────────────────────────────
|
|
1466
|
+
let acceptedNames = allVariantNames(M.ExtensionPoint.commandSchema)
|
|
1467
|
+
let delegateCommandNames = allVariantNames(M.Delegate.commandSchema)
|
|
1468
|
+
pushAll(
|
|
1469
|
+
tableFailures,
|
|
1470
|
+
commandTableFailures(
|
|
1471
|
+
~label,
|
|
1472
|
+
~keyed="acceptedCommands",
|
|
1473
|
+
~valueKind="routes to",
|
|
1474
|
+
~rows=M.acceptedCommands->Array.map(({name, toCommandTypes}) => (name, toCommandTypes)),
|
|
1475
|
+
~keyNames=acceptedNames,
|
|
1476
|
+
~valueNames=delegateCommandNames,
|
|
1477
|
+
),
|
|
1478
|
+
)
|
|
1479
|
+
|
|
1480
|
+
// The mirror of the published-event probe: one synthesised EP command per
|
|
1481
|
+
// constructor, through the author's own mapIncomingCommand. Cheaper than the
|
|
1482
|
+
// event probe — the signature reaches no query engine.
|
|
1483
|
+
let acceptedObserved = []
|
|
1484
|
+
acceptedNames->Array.forEach(cmd => {
|
|
1485
|
+
let synthesised = Reventless.DcbTag.isVariantPayloadBearing(
|
|
1486
|
+
M.ExtensionPoint.commandSchema->S.castToUnknown,
|
|
1487
|
+
cmd,
|
|
1488
|
+
)
|
|
1489
|
+
? Dict.fromArray([("TAG", JSON.Encode.string(cmd))])->JSON.Encode.object
|
|
1490
|
+
: JSON.Encode.string(cmd)
|
|
1491
|
+
switch (
|
|
1492
|
+
try {
|
|
1493
|
+
let command =
|
|
1494
|
+
Reventless.Message.fillMissingDefaults(
|
|
1495
|
+
M.ExtensionPoint.commandSchema,
|
|
1496
|
+
synthesised,
|
|
1497
|
+
[],
|
|
1498
|
+
)->Reventless.Util_Sury.fromJson(M.ExtensionPoint.commandSchema)
|
|
1499
|
+
let decodedAs =
|
|
1500
|
+
command
|
|
1501
|
+
->Reventless.Message.encode(M.ExtensionPoint.commandSchema)
|
|
1502
|
+
->Reventless.Message.variantNameOfJson
|
|
1503
|
+
decodedAs != cmd
|
|
1504
|
+
? Error(`a synthesised "${cmd}" decoded as "${decodedAs}"`)
|
|
1505
|
+
: Ok(M.mapIncomingCommand(probeId, command, probeMeta))
|
|
1506
|
+
} catch {
|
|
1507
|
+
| _ => Error(`the mapping raised on a synthesised "${cmd}"`)
|
|
1508
|
+
}
|
|
1509
|
+
) {
|
|
1510
|
+
| Ok(actions) =>
|
|
1511
|
+
actions->Array.forEach(action =>
|
|
1512
|
+
switch action {
|
|
1513
|
+
| ReventlessInfra.ExtensionPointMapping.PublishCommand(_, routed) =>
|
|
1514
|
+
acceptedObserved
|
|
1515
|
+
->Array.push((
|
|
1516
|
+
cmd,
|
|
1517
|
+
routed
|
|
1518
|
+
->Reventless.Message.encode(M.Delegate.commandSchema)
|
|
1519
|
+
->Reventless.Message.variantNameOfJson,
|
|
1520
|
+
))
|
|
1521
|
+
->ignore
|
|
1522
|
+
| HandleDirective(_, _) => ()
|
|
1523
|
+
}
|
|
1524
|
+
)
|
|
1525
|
+
| Error(reason) =>
|
|
1526
|
+
tableWarnings->Array.push(`${label}: not checked against the arms — ${reason}.`)->ignore
|
|
1527
|
+
}
|
|
1528
|
+
})
|
|
1529
|
+
acceptedObserved->Array.forEach(((cmd, routed)) =>
|
|
1530
|
+
if (
|
|
1531
|
+
!(
|
|
1532
|
+
M.acceptedCommands->Array.some(({name, toCommandTypes}) =>
|
|
1533
|
+
name == cmd && toCommandTypes->Array.includes(routed)
|
|
1534
|
+
)
|
|
1535
|
+
)
|
|
1536
|
+
) {
|
|
1537
|
+
tableFailures
|
|
1538
|
+
->Array.push(
|
|
1539
|
+
`${label}: "${cmd}" routes to "${routed}", which acceptedCommands does not declare.`,
|
|
1540
|
+
)
|
|
1541
|
+
->ignore
|
|
1542
|
+
}
|
|
1543
|
+
)
|
|
1544
|
+
|
|
1545
|
+
let acceptedTable = epAccepted->Dict.get(epName)->Option.getOr(Dict.make())
|
|
1546
|
+
M.acceptedCommands->Array.forEach(({name: accepted, toCommandTypes}) => {
|
|
1547
|
+
let key = `${epName}.${accepted}`
|
|
1548
|
+
acceptedTable->Dict.set(
|
|
1549
|
+
key,
|
|
1550
|
+
Array.concat(
|
|
1551
|
+
acceptedTable->Dict.get(key)->Option.getOr([]),
|
|
1552
|
+
qualify(~prefix=name, toCommandTypes),
|
|
1553
|
+
),
|
|
1554
|
+
)
|
|
1555
|
+
})
|
|
1556
|
+
epAccepted->Dict.set(epName, acceptedTable)
|
|
1557
|
+
|
|
1558
|
+
switch M.mapOutgoingEvent {
|
|
1559
|
+
| None =>
|
|
1560
|
+
if Array.length(M.publishedEvents) > 0 {
|
|
1561
|
+
tableFailures
|
|
1562
|
+
->Array.push(
|
|
1563
|
+
`${label}: publishedEvents declares ${M.publishedEvents
|
|
1564
|
+
->Array.length
|
|
1565
|
+
->Int.toString} event(s), but the mapping has no mapOutgoingEvent and ` ++
|
|
1566
|
+
`publishes nothing.`,
|
|
1567
|
+
)
|
|
1568
|
+
->ignore
|
|
1569
|
+
}
|
|
1570
|
+
| Some(mapOutgoing) =>
|
|
1571
|
+
// One synthesised event per Delegate constructor, through the author's own
|
|
1572
|
+
// function — not the compiled one, which logs and re-encodes.
|
|
1573
|
+
let observed = []
|
|
1574
|
+
let followed = []
|
|
1575
|
+
sourceNames->Array.forEach(src => {
|
|
1576
|
+
let synthesised = Reventless.DcbTag.isVariantPayloadBearing(
|
|
1577
|
+
M.Delegate.eventSchema->S.castToUnknown,
|
|
1578
|
+
src,
|
|
1579
|
+
)
|
|
1580
|
+
? Dict.fromArray([("TAG", JSON.Encode.string(src))])->JSON.Encode.object
|
|
1581
|
+
: JSON.Encode.string(src)
|
|
1582
|
+
let outcome = try {
|
|
1583
|
+
// Filled directly rather than through `parseJsonTolerant`, which warns
|
|
1584
|
+
// about inventing values — here the invention is the point.
|
|
1585
|
+
let event =
|
|
1586
|
+
Reventless.Message.fillMissingDefaults(M.Delegate.eventSchema, synthesised, [])
|
|
1587
|
+
->Reventless.Util_Sury.fromJson(M.Delegate.eventSchema)
|
|
1588
|
+
// A fabricated payload can decode as a sibling constructor; only a
|
|
1589
|
+
// value that round-trips is judged.
|
|
1590
|
+
let decodedAs =
|
|
1591
|
+
event
|
|
1592
|
+
->Reventless.Message.encode(M.Delegate.eventSchema)
|
|
1593
|
+
->Reventless.Message.variantNameOfJson
|
|
1594
|
+
if decodedAs != src {
|
|
1595
|
+
Error(`a synthesised "${src}" decoded as "${decodedAs}"`)
|
|
1596
|
+
} else {
|
|
1597
|
+
let actions = mapOutgoing(probeId, event, probeMeta, probeQueryEngine)
|
|
1598
|
+
actions->Array.forEach(action =>
|
|
1599
|
+
switch action {
|
|
1600
|
+
| ReventlessInfra.ExtensionPointMapping.PublishEvent(_, published) =>
|
|
1601
|
+
observed
|
|
1602
|
+
->Array.push((
|
|
1603
|
+
src,
|
|
1604
|
+
published
|
|
1605
|
+
->Reventless.Message.encode(M.ExtensionPoint.eventSchema)
|
|
1606
|
+
->Reventless.Message.variantNameOfJson,
|
|
1607
|
+
))
|
|
1608
|
+
->ignore
|
|
1609
|
+
| PublishEventAsync(_) | HandleDirective(_, _) => ()
|
|
1610
|
+
}
|
|
1611
|
+
)
|
|
1612
|
+
// A promise hides what it will publish — leave the arm unjudged.
|
|
1613
|
+
actions->Array.some(action =>
|
|
1614
|
+
switch action {
|
|
1615
|
+
| PublishEventAsync(_) => true
|
|
1616
|
+
| PublishEvent(_, _) | HandleDirective(_, _) => false
|
|
1617
|
+
}
|
|
1618
|
+
)
|
|
1619
|
+
? Error(`"${src}" publishes behind a promise`)
|
|
1620
|
+
: Ok()
|
|
1621
|
+
}
|
|
1622
|
+
} catch {
|
|
1623
|
+
| _ => Error(`the mapping raised on a synthesised "${src}"`)
|
|
1624
|
+
}
|
|
1625
|
+
switch outcome {
|
|
1626
|
+
| Ok() => followed->Array.push(src)->ignore
|
|
1627
|
+
| Error(reason) =>
|
|
1628
|
+
tableWarnings->Array.push(`${label}: not checked against the arms — ${reason}.`)->ignore
|
|
1629
|
+
}
|
|
1630
|
+
})
|
|
1631
|
+
|
|
1632
|
+
let (failures, warnings) = translationTableDrift(
|
|
1633
|
+
~label,
|
|
1634
|
+
~declared=M.publishedEvents,
|
|
1635
|
+
~observed,
|
|
1636
|
+
~followed,
|
|
1637
|
+
)
|
|
1638
|
+
pushAll(tableFailures, failures)
|
|
1639
|
+
pushAll(tableWarnings, warnings)
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Dead protocol surface: an event of the published contract that no arm
|
|
1643
|
+
// produces, which a subscriber may already be routing.
|
|
1644
|
+
let declaredNames = M.publishedEvents->Array.map(p => p.name)
|
|
1645
|
+
publishedNames
|
|
1646
|
+
->Array.filter(p => !(declaredNames->Array.includes(p)))
|
|
1647
|
+
->Array.forEach(p =>
|
|
1648
|
+
tableWarnings
|
|
1649
|
+
->Array.push(`${label}: the extension point publishes "${p}", which no arm produces.`)
|
|
1650
|
+
->ignore
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
let table = epPublished->Dict.get(epName)->Option.getOr(Dict.make())
|
|
1654
|
+
M.publishedEvents->Array.forEach(({name: published, fromEventTypes}) => {
|
|
1655
|
+
let key = `${epName}.${published}`
|
|
1656
|
+
table->Dict.set(
|
|
1657
|
+
key,
|
|
1658
|
+
Array.concat(
|
|
1659
|
+
table->Dict.get(key)->Option.getOr([]),
|
|
1660
|
+
qualify(~prefix=name, fromEventTypes),
|
|
1661
|
+
),
|
|
1662
|
+
)
|
|
1663
|
+
})
|
|
1664
|
+
epPublished->Dict.set(epName, table)
|
|
1460
1665
|
})
|
|
1666
|
+
|
|
1461
1667
|
let extensionPointDefs =
|
|
1462
1668
|
epByName
|
|
1463
1669
|
->Dict.toArray
|
|
@@ -1466,8 +1672,52 @@ let make = (
|
|
|
1466
1672
|
delegateNames: dedupe(dels),
|
|
1467
1673
|
sourceEventTypes: dedupe(evs),
|
|
1468
1674
|
commandTypes: Some(dedupe(cmds)),
|
|
1675
|
+
publishedEvents: Some(
|
|
1676
|
+
epPublished
|
|
1677
|
+
->Dict.get(epName)
|
|
1678
|
+
->Option.getOr(Dict.make())
|
|
1679
|
+
->Dict.toArray
|
|
1680
|
+
->Array.map(((published, sources)) => ({
|
|
1681
|
+
Reventless.Plugin.name: published,
|
|
1682
|
+
fromEventTypes: dedupe(sources),
|
|
1683
|
+
}: Reventless.Plugin.publishedEventDef)),
|
|
1684
|
+
),
|
|
1685
|
+
acceptedCommands: Some(
|
|
1686
|
+
epAccepted
|
|
1687
|
+
->Dict.get(epName)
|
|
1688
|
+
->Option.getOr(Dict.make())
|
|
1689
|
+
->Dict.toArray
|
|
1690
|
+
->Array.map(((accepted, routed)) => ({
|
|
1691
|
+
Reventless.Plugin.name: accepted,
|
|
1692
|
+
toCommandTypes: dedupe(routed),
|
|
1693
|
+
}: Reventless.Plugin.acceptedCommandDef)),
|
|
1694
|
+
),
|
|
1469
1695
|
}: Reventless.Plugin.extensionPointDef))
|
|
1470
1696
|
|
|
1697
|
+
// Dead inbound surface, judged only after every mapping on an EP has been seen:
|
|
1698
|
+
// one port's inbound protocol is split across its mappings, so a command the
|
|
1699
|
+
// Plugin mapping ignores may be the UiFragment mapping's whole job.
|
|
1700
|
+
epByName
|
|
1701
|
+
->Dict.toArray
|
|
1702
|
+
->Array.forEach(((epName, (_, _, cmds))) => {
|
|
1703
|
+
let handled =
|
|
1704
|
+
epAccepted->Dict.get(epName)->Option.getOr(Dict.make())->Dict.keysToArray
|
|
1705
|
+
cmds
|
|
1706
|
+
->dedupe
|
|
1707
|
+
->Array.forEach(cmd =>
|
|
1708
|
+
if !(handled->Array.includes(cmd)) {
|
|
1709
|
+
tableWarnings
|
|
1710
|
+
->Array.push(
|
|
1711
|
+
`${epName}: the extension point accepts "${cmd}", which no arm handles — a ` ++
|
|
1712
|
+
`sender gets no error and nothing happens.`,
|
|
1713
|
+
)
|
|
1714
|
+
->ignore
|
|
1715
|
+
}
|
|
1716
|
+
)
|
|
1717
|
+
})
|
|
1718
|
+
|
|
1719
|
+
reportTranslationTables(~pluginName=name, ~failures=tableFailures, ~warnings=tableWarnings)
|
|
1720
|
+
|
|
1471
1721
|
// Second pass, on purpose: commands are built well before `linkedViews` is
|
|
1472
1722
|
// assembled, so the check cannot run inline where the defs are made.
|
|
1473
1723
|
checkDeclaredTransitions(
|