@reventlessdev/reventless-core 3.0.0-alpha.250 → 3.0.0-alpha.252
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 +40 -0
- package/package.json +7 -7
- package/src/admin/Platform_Admin_Structure.res +4 -1
- package/src/admin/Platform_Admin_Structure.res.mjs +4 -2
- package/src/admin/Platform_BakedManifest.res.mjs +3 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res +31 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +29 -1
- package/src/admin/Platform_PluginStructuresApi.res +80 -8
- package/src/admin/Platform_PluginStructuresApi.res.mjs +76 -2
- package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs +8 -0
- package/src/components/Api/ApiNoApiHelpers.res +37 -3
- package/src/components/Api/ApiNoApiHelpers.res.mjs +29 -2
- package/src/components/AutomationSlice/AutomationSlice_Callback.res +4 -4
- package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +3 -3
- package/src/plugin/api/PluginBaseFragment.res +3 -3
- package/src/plugin/component/Plugin_Structure.res +787 -384
- package/src/plugin/component/Plugin_Structure.res.mjs +430 -20
- package/src/plugin/connect/PluginConnectExtension_Mapping.res +3 -6
- package/src/plugin/connect/PluginConnectExtension_Mapping.res.mjs +14 -2
- package/src/plugin/connect/PluginExtensionPoint_Plugin.res +0 -1
- package/src/plugin/connect/PluginExtensionPoint_Plugin.res.mjs +63 -3
- package/src/plugin/connect/PluginExtensionPoint_UiFragment.res.mjs +22 -3
- package/src/plugin/lifecycle/PluginBehavior.res +11 -11
- package/src/plugin/lifecycle/PluginBehavior.res.mjs +11 -11
- package/src/plugin/lifecycle/PluginSpec.res +11 -3
- package/src/plugin/lifecycle/PluginSpec.res.mjs +11 -3
- package/tests/admin/AdminApiSchemaDriftTest.res +486 -0
- package/tests/admin/AdminApiSchemaDriftTest.res.mjs +619 -0
- package/tests/admin/Platform_Admin_StructureTest.res +8 -3
- package/tests/admin/Platform_Admin_StructureTest.res.mjs +8 -2
- package/tests/admin/Platform_BakedManifestTest.res +2 -0
- package/tests/admin/Platform_BakedManifestTest.res.mjs +3 -1
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +10 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +30 -8
- package/tests/admin/Platform_PluginStructuresApiTest.res +28 -1
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +27 -5
- package/tests/aggregate/AggregateCacheTest.res.mjs +15 -3
- package/tests/aggregate/AggregateConflictTest.res.mjs +15 -3
- package/tests/aggregate/AggregateFixtures.res.mjs +15 -3
- package/tests/aggregate/AggregateSnapshotTest.res.mjs +21 -5
- package/tests/api/ApiNoApiTest.res +46 -0
- package/tests/api/ApiNoApiTest.res.mjs +49 -1
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +27 -3
- package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +12 -2
- package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +2 -0
- package/tests/commandtopic/CommandTopicCallbackFixtures.res.mjs +12 -2
- package/tests/dcb/DcbFixtures.res.mjs +8 -0
- package/tests/dcb/DcbStateChangeSliceTest.res.mjs +18 -0
- package/tests/eventmapper/EventMapperFixtures.res.mjs +15 -3
- package/tests/extensionpoint/ExtensionPointFixtures.res.mjs +9 -1
- package/tests/extensionpoint/ExtensionPointOperationsTest.res.mjs +9 -1
- package/tests/message/MessageTest.res +2 -0
- package/tests/message/MessageTest.res.mjs +3 -1
- package/tests/plugin/PluginStructureAccessTest.res.mjs +2 -0
- package/tests/plugin/PluginStructureTest.res +294 -1
- package/tests/plugin/PluginStructureTest.res.mjs +373 -3
- package/tests/plugin/StateChangeSlice/PsAttachInvoice.res.mjs +8 -0
- package/tests/plugin/StateChangeSlice/PsChangePhoto.res.mjs +8 -0
- package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +8 -0
- package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +8 -0
- package/tests/plugin/StateChangeSlice/PsPlaceOrder.res.mjs +8 -0
- package/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs +8 -0
- package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +8 -0
- package/tests/plugin/StateChangeSlice/PsTransitionSwitch.res +59 -0
- package/tests/plugin/StateChangeSlice/PsTransitionSwitch.res.mjs +115 -0
- package/tests/plugin/StateChangeSlice/PsTypoStore.res.mjs +8 -0
- package/tests/plugin/StateChangeSlice/PsUploadAvatar.res.mjs +8 -0
- package/tests/plugin/StateChangeSlice/PsUploadImages.res.mjs +8 -0
- package/tests/plugin/pluginDefinitionRequiredScalars.txt +26 -0
- package/tests/util/CommandPublisherTest.res.mjs +9 -1
|
@@ -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,190 @@ 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
|
-
|
|
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
|
+
// A command's declared edge names states belonging to another component's enum.
|
|
377
|
+
// From `@transition` the PPX only ever sees names, so a misspelling compiles clean
|
|
378
|
+
// and produces a command legal in a state no row is in. Both sides are in hand
|
|
379
|
+
// here. A state the linked views do not declare raises (this runs at assembly,
|
|
380
|
+
// never in a Lambda, so that is a failed deploy); no resolvable view only warns.
|
|
381
|
+
// Checked against the UNION of the linked views: a slice feeding two is not
|
|
382
|
+
// claiming which one.
|
|
296
383
|
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
384
|
+
// A `commandTransition` switch comes through the same `commandDef` fields and is
|
|
385
|
+
// checked the same way. The compiler has already resolved its constructors, so
|
|
386
|
+
// what survives to here is naming the wrong enum rather than misspelling one —
|
|
387
|
+
// which this catches for the same reason and with the same message.
|
|
301
388
|
let checkDeclaredTransitions = (
|
|
302
389
|
~pluginName: string,
|
|
303
390
|
~writables: array<Reventless.Plugin.writableDef>,
|
|
@@ -331,7 +418,7 @@ let checkDeclaredTransitions = (
|
|
|
331
418
|
->Array.forEach(state =>
|
|
332
419
|
failures
|
|
333
420
|
->Array.push(
|
|
334
|
-
`${w.name}.${cmd.name}
|
|
421
|
+
`${w.name}.${cmd.name} declares state "${state}", which none of its ` ++
|
|
335
422
|
`linked views declare — ${w.linkedViews->Array.join(
|
|
336
423
|
", ",
|
|
337
424
|
)} know ${known->Array.join(", ")}.`,
|
|
@@ -349,37 +436,23 @@ let checkDeclaredTransitions = (
|
|
|
349
436
|
if unvalidated.contents > 0 {
|
|
350
437
|
log.warn(
|
|
351
438
|
~comp="Plugin_Structure",
|
|
352
|
-
`${pluginName}: ${unvalidated.contents->Int.toString} command(s) declare a
|
|
439
|
+
`${pluginName}: ${unvalidated.contents->Int.toString} command(s) declare a transition ` ++
|
|
353
440
|
`but no linked view declares a lifecycle to check it against.`,
|
|
354
441
|
)
|
|
355
442
|
}
|
|
356
443
|
|
|
357
444
|
if Array.length(failures) > 0 {
|
|
358
445
|
JsError.throwWithMessage(
|
|
359
|
-
`${pluginName}:
|
|
446
|
+
`${pluginName}: a declared transition names states that do not exist.\n` ++
|
|
360
447
|
failures->Array.join("\n"),
|
|
361
448
|
)
|
|
362
449
|
}
|
|
363
450
|
}
|
|
364
451
|
|
|
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.
|
|
452
|
+
// Whether the declared transitions AGREE across an entity — a graph of valid names
|
|
453
|
+
// can still leave a state nothing reaches. Warnings only: a smell is not a mistake,
|
|
454
|
+
// and one that stops a deploy gets silenced rather than fixed. Pure, so the rule
|
|
455
|
+
// tests without a log; `checkLifecycleTopology` reports.
|
|
383
456
|
let lifecycleTopologyFindings = (
|
|
384
457
|
~writables: array<Reventless.Plugin.writableDef>,
|
|
385
458
|
~lifecycleStatesByView: dict<array<string>>,
|
|
@@ -388,20 +461,15 @@ let lifecycleTopologyFindings = (
|
|
|
388
461
|
lifecycleStatesByView
|
|
389
462
|
->Dict.toArray
|
|
390
463
|
->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.
|
|
464
|
+
// Arrival only, so a creating command (a target and no from-set) counts
|
|
465
|
+
// towards reachability like any other edge.
|
|
396
466
|
let reachable = writables->Array.reduce([], (acc, w) =>
|
|
397
467
|
w.linkedViews->Array.includes(view)
|
|
398
468
|
? Array.concat(acc, w.commands->Array.filterMap(cmd => cmd.targetState))
|
|
399
469
|
: acc
|
|
400
470
|
)
|
|
401
471
|
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.
|
|
472
|
+
// Rows start in the first declared state, so nothing pointing at it is fine.
|
|
405
473
|
let initial = states->Array.get(0)
|
|
406
474
|
|
|
407
475
|
states->Array.forEach(state => {
|
|
@@ -414,23 +482,9 @@ let lifecycleTopologyFindings = (
|
|
|
414
482
|
))
|
|
415
483
|
->ignore
|
|
416
484
|
}
|
|
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.
|
|
485
|
+
// NOT checked: a state with no way out. Terminal states are ordinary,
|
|
486
|
+
// and the only fix a lint could suggest — `@retired` — withdraws the
|
|
487
|
+
// rows from reads rather than marking an ending.
|
|
434
488
|
})
|
|
435
489
|
}
|
|
436
490
|
})
|
|
@@ -446,44 +500,12 @@ let checkLifecycleTopology = (
|
|
|
446
500
|
log.warn(~comp="Plugin_Structure", `${pluginName}/${view}: ${message}`)
|
|
447
501
|
)
|
|
448
502
|
|
|
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.
|
|
503
|
+
// NOT here: an event-consumption completeness check. `consumedEventTypes` drops
|
|
504
|
+
// payload-less variants, which is exactly what a lifecycle-moving event usually
|
|
505
|
+
// is — so the events such a rule is about are the ones this metadata omits.
|
|
506
|
+
|
|
507
|
+
// Which rung produced the label, published as `labelFieldSource`: a consumer with
|
|
508
|
+
// a name rule of its own has to rank a declaration (rung 1) against a guess.
|
|
487
509
|
type labelFieldSource =
|
|
488
510
|
| Annotation
|
|
489
511
|
| Convention
|
|
@@ -504,22 +526,9 @@ type labelResolution = {
|
|
|
504
526
|
source: labelFieldSource,
|
|
505
527
|
}
|
|
506
528
|
|
|
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.
|
|
529
|
+
// The field a state is named by: `@displayName`, else a candidate named
|
|
530
|
+
// name/title/label/displayName, else the first candidate, else `id` with a
|
|
531
|
+
// warning. A candidate is a non-TAG field, not named `id`, passing `isLabelShape`.
|
|
523
532
|
let labelFieldsFromStateSchema = (
|
|
524
533
|
~entityName: string,
|
|
525
534
|
stateSchema: S.t<unknown>,
|
|
@@ -559,18 +568,13 @@ let labelFieldsFromStateSchema = (
|
|
|
559
568
|
}
|
|
560
569
|
}
|
|
561
570
|
|
|
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.
|
|
571
|
+
// ── Per-variant event / error field extraction ───────────────────────────────
|
|
572
|
+
// Mirrors the command walk for emitted events. Module-level so Platform_Admin,
|
|
573
|
+
// whose components never pass through `make`, derives its defs the same way.
|
|
574
|
+
|
|
575
|
+
// A variant's cross-entity references, shared by the command and event walks.
|
|
576
|
+
// `getFieldTarget`, not `getTarget`: an `array<string>` field declares it on the
|
|
577
|
+
// element schema.
|
|
574
578
|
let extractReferences = (properties: dict<S.t<unknown>>): array<
|
|
575
579
|
Reventless.Plugin.fieldReference,
|
|
576
580
|
> =>
|
|
@@ -607,10 +611,8 @@ let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
|
|
|
607
611
|
| _ => None
|
|
608
612
|
}
|
|
609
613
|
)
|
|
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.
|
|
614
|
+
// Payload-less variants compile to a bare string literal. Kept here (though not
|
|
615
|
+
// in produced/consumedEventTypes) so the graph can draw the orphan node.
|
|
614
616
|
| String({const: ?Some(variantName)}) => Some(mkDef(~variantName, ~properties=Dict.make()))
|
|
615
617
|
| _ => None
|
|
616
618
|
}
|
|
@@ -622,21 +624,16 @@ let extractEventDefs = (eventSchema: S.t<unknown>): array<Reventless.Plugin.even
|
|
|
622
624
|
| _ => toEventDef(eventSchema)->Option.mapOr([], def => [def])
|
|
623
625
|
}
|
|
624
626
|
|
|
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.
|
|
627
|
+
// Errors walk identically to events; only the def type differs, so a consumer
|
|
628
|
+
// can tell a refusal from a fact. Shared rather than copied, so they cannot drift.
|
|
630
629
|
let extractErrorDefs = (errorSchema: S.t<unknown>): array<Reventless.Plugin.errorDef> =>
|
|
631
630
|
extractEventDefs(errorSchema)->Array.map(({name, schema, references}) => (
|
|
632
631
|
{name, schema, references}: Reventless.Plugin.errorDef
|
|
633
632
|
))
|
|
634
633
|
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
// is what let its command metadata drift from the SDL generated off the same
|
|
639
|
-
// schema.
|
|
634
|
+
// Module-level like the event walk: the synthetic Platform_Admin structure has no
|
|
635
|
+
// `module(Aggregate.T)` to hand `make`, and a second copy of this walk is what let
|
|
636
|
+
// its metadata drift from the SDL.
|
|
640
637
|
|
|
641
638
|
// Aggregate commands that initialize a new aggregate instance are Collection-level
|
|
642
639
|
// (shown as table-top buttons); all others are Instance-level (shown per-row).
|
|
@@ -677,32 +674,18 @@ let commandLevelAndId = (~isAggregate, ~variantName, properties: dict<S.t<unknow
|
|
|
677
674
|
}
|
|
678
675
|
}
|
|
679
676
|
|
|
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.
|
|
677
|
+
// The server's rule as keys a client checks against `identity.groups ++
|
|
678
|
+
// config.accessTiers`; an any-of, since `isAllowed` is `some`. The rules asking
|
|
679
|
+
// for nothing checkable — and `DenyAll` — publish no keys rather than an
|
|
680
|
+
// unsatisfiable one.
|
|
693
681
|
let accessKeysFor = (rule: Reventless.Authorization.permission): option<array<string>> =>
|
|
694
682
|
switch rule {
|
|
695
683
|
| AllowGroups(groups) if groups->Array.length > 0 => Some(groups)
|
|
696
684
|
| AllowGroups(_) | AllowAuthenticated | AllowAnonymous | DenyAll => None
|
|
697
685
|
}
|
|
698
686
|
|
|
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.
|
|
687
|
+
// Each mutation argument's GraphQL type onto its property, so a consumer declares
|
|
688
|
+
// the variable the server expects. Mutates the freshly derived schema in place.
|
|
706
689
|
let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
|
|
707
690
|
schema
|
|
708
691
|
->JSON.Decode.object
|
|
@@ -730,6 +713,18 @@ let toCommandDef = (
|
|
|
730
713
|
// one aggregate carries commands with very different audiences, and a
|
|
731
714
|
// component-level shortcut would gate `AddProduct` and `PlaceOrder` alike.
|
|
732
715
|
~commandAuthorization: unknown => Reventless.Authorization.permission,
|
|
716
|
+
// The spec's `command => Transition.t<_>`, evaluated per variant the same way
|
|
717
|
+
// `commandAuthorization` is. It wins over the `@transition` metadata where it
|
|
718
|
+
// declares an edge, and stands aside where it says `Unrestricted` — which is
|
|
719
|
+
// what the PPX injects for a spec that never wrote the switch.
|
|
720
|
+
//
|
|
721
|
+
// Read at `t<string>` because this is the erasure boundary: the spec declares
|
|
722
|
+
// its edges over the linked view's own lifecycle enum, whose arms are
|
|
723
|
+
// payload-less and therefore ARE their own names at run time. The one cast
|
|
724
|
+
// that says so is the same `->Obj.magic` every spec member arrives through
|
|
725
|
+
// (see the call sites below), so `Transition` itself asserts nothing about
|
|
726
|
+
// representation and stays parameterised all the way down.
|
|
727
|
+
~commandTransition: unknown => Reventless.Transition.t<string>,
|
|
733
728
|
v: S.t<unknown>,
|
|
734
729
|
): option<Reventless.Plugin.commandDef> => {
|
|
735
730
|
// Build a commandDef for one variant. `properties` is the variant's field dict —
|
|
@@ -738,15 +733,41 @@ let toCommandDef = (
|
|
|
738
733
|
let mkDef = (~variantName, ~properties) => {
|
|
739
734
|
let (level, aggregateIdField) = commandLevelAndId(~isAggregate, ~variantName, properties)
|
|
740
735
|
let references = extractReferences(properties)
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
//
|
|
749
|
-
|
|
736
|
+
// Evaluated against a synthetic value per constructor, the same shape the
|
|
737
|
+
// resolver builds at call time: a payload-bearing variant compiles to
|
|
738
|
+
// `{TAG, ...}`, a payload-less one to a bare string.
|
|
739
|
+
let syntheticCommand: unknown =
|
|
740
|
+
Reventless.DcbTag.isVariantPayloadBearing(parentSchema, variantName)
|
|
741
|
+
? {"TAG": variantName}->Obj.magic
|
|
742
|
+
: variantName->Obj.magic
|
|
743
|
+
// The spec's own switch, which is the only form that can speak for a
|
|
744
|
+
// constructor the host did not declare: `@transition` lowers to a dict on
|
|
745
|
+
// the parent union, and a variant spread splices members, so a spliced
|
|
746
|
+
// command reaches the metadata below carrying nothing.
|
|
747
|
+
let declared = commandTransition(syntheticCommand)
|
|
748
|
+
// An edge is ONE declaration, so the two fields are chosen together. Taking
|
|
749
|
+
// them separately would let `Guards(…)` — which positively claims the
|
|
750
|
+
// command moves the row nowhere — inherit a target from an annotation it
|
|
751
|
+
// was written to replace.
|
|
752
|
+
//
|
|
753
|
+
// The `@transition` metadata is per-variant but attached by the PPX to the
|
|
754
|
+
// *parent* schema as one dict. It is consulted only where the switch
|
|
755
|
+
// declares no edge at all: a spec that never wrote one answers
|
|
756
|
+
// `Unrestricted` for every command, which is how the annotation stays in
|
|
757
|
+
// charge for the hosts that still use it.
|
|
758
|
+
//
|
|
759
|
+
// `targetState: None` ⇒ AutoUI's board resolver falls back to its name-stem
|
|
760
|
+
// heuristic.
|
|
761
|
+
let (allowedStates, targetState) = switch declared {
|
|
762
|
+
| Unrestricted => (
|
|
763
|
+
ApiAllowedStatesHelpers.getAllowedStates(parentSchema, ~variantName),
|
|
764
|
+
ApiTargetStateHelpers.getTargetState(parentSchema, ~variantName),
|
|
765
|
+
)
|
|
766
|
+
| _ => (
|
|
767
|
+
Reventless.Transition.allowedStates(declared),
|
|
768
|
+
Reventless.Transition.targetState(declared),
|
|
769
|
+
)
|
|
770
|
+
}
|
|
750
771
|
// API-exposed iff the whole command isn't @noApi and this variant
|
|
751
772
|
// isn't in its @noApi-variants set — mirrors the API-generation filter
|
|
752
773
|
// (Plugin_Helpers / PluginBaseFragment). Drives the event-graph API badge.
|
|
@@ -756,13 +777,6 @@ let toCommandDef = (
|
|
|
756
777
|
| Some(excluded) => !(excluded->Set.has(variantName))
|
|
757
778
|
| None => true
|
|
758
779
|
}
|
|
759
|
-
// Evaluated against a synthetic value per constructor, the same shape the
|
|
760
|
-
// resolver builds at call time: a payload-bearing variant compiles to
|
|
761
|
-
// `{TAG, ...}`, a payload-less one to a bare string.
|
|
762
|
-
let syntheticCommand: unknown =
|
|
763
|
-
Reventless.DcbTag.isVariantPayloadBearing(parentSchema, variantName)
|
|
764
|
-
? {"TAG": variantName}->Obj.magic
|
|
765
|
-
: variantName->Obj.magic
|
|
766
780
|
let requiredAccess = accessKeysFor(commandAuthorization(syntheticCommand))
|
|
767
781
|
// See the note on the record's `mutationField` for why a non-exposed
|
|
768
782
|
// variant gets the empty sentinel. It has no callable field, and the
|
|
@@ -780,28 +794,13 @@ let toCommandDef = (
|
|
|
780
794
|
}
|
|
781
795
|
({
|
|
782
796
|
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`.
|
|
797
|
+
// The derived schema, not sury's raw one, which drops every `x-reventless-*`
|
|
798
|
+
// marker the PPX put on the fields. Also carries `x-reventless-graphql-type`.
|
|
793
799
|
schema: annotatedSchema->JSON.stringify,
|
|
794
800
|
level,
|
|
795
801
|
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.
|
|
802
|
+
// Empty for a `@noApi` variant: `mutationFieldFor` would resolve it to a
|
|
803
|
+
// sibling's field, which reads as callable. Exposed variants are unchanged.
|
|
805
804
|
mutationField,
|
|
806
805
|
references,
|
|
807
806
|
allowedStates,
|
|
@@ -837,6 +836,7 @@ let extractCommandDefs = (
|
|
|
837
836
|
~isAggregate,
|
|
838
837
|
~mutationFieldFor: string => string,
|
|
839
838
|
~commandAuthorization: unknown => Reventless.Authorization.permission,
|
|
839
|
+
~commandTransition: unknown => Reventless.Transition.t<string>,
|
|
840
840
|
commandSchema: S.t<unknown>,
|
|
841
841
|
): array<Reventless.Plugin.commandDef> =>
|
|
842
842
|
switch commandSchema {
|
|
@@ -847,6 +847,7 @@ let extractCommandDefs = (
|
|
|
847
847
|
~mutationFieldFor,
|
|
848
848
|
~parentSchema=commandSchema,
|
|
849
849
|
~commandAuthorization,
|
|
850
|
+
~commandTransition,
|
|
850
851
|
v,
|
|
851
852
|
)
|
|
852
853
|
)
|
|
@@ -857,6 +858,7 @@ let extractCommandDefs = (
|
|
|
857
858
|
~mutationFieldFor,
|
|
858
859
|
~parentSchema=commandSchema,
|
|
859
860
|
~commandAuthorization,
|
|
861
|
+
~commandTransition,
|
|
860
862
|
commandSchema,
|
|
861
863
|
)->Option.mapOr([], def => [def])
|
|
862
864
|
}
|
|
@@ -871,24 +873,10 @@ let visibilityTag = (v: Reventless.Visibility.t): option<string> =>
|
|
|
871
873
|
}
|
|
872
874
|
|
|
873
875
|
/**
|
|
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.
|
|
876
|
+
A read model's `queryableDef` assembled from its spec, for the platform's own
|
|
877
|
+
components: `make` takes `module(ReadModel.T)` values, and at structure-assembly
|
|
878
|
+
time the platform has no built module to hand itself. Every extractor here is the
|
|
879
|
+
one `make` calls, so the two cannot drift — a hand-written record did, four times.
|
|
892
880
|
*/
|
|
893
881
|
let queryableDefFromSpec = (
|
|
894
882
|
~plugin: string,
|
|
@@ -940,23 +928,18 @@ let make = (
|
|
|
940
928
|
~inboundTranslationSlices: array<module(ReventlessInfra.InboundTranslationSlice.T)>=[],
|
|
941
929
|
~extensions: array<module(ReventlessInfra.Extension.Blueprint)>=[],
|
|
942
930
|
~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.
|
|
931
|
+
// Component name → chapter, captured from each component's source folder by the
|
|
932
|
+
// plugin generator. Keyed by `Spec.name`; no entry renders flat.
|
|
949
933
|
~componentChapters: dict<string>=Dict.make(),
|
|
950
934
|
): Reventless.Plugin.pluginStructure => {
|
|
951
935
|
let chapterOf = (compName: string): option<string> => componentChapters->Dict.get(compName)
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
// claim cross-component edges that the runtime can't honour.
|
|
936
|
+
// Payload-less variants dropped: the graph must not claim an edge a DCB lookup
|
|
937
|
+
// cannot WHERE-clause on.
|
|
955
938
|
let eventVariantNames = schema => Reventless.DcbTag.extractVariantNames(schema)
|
|
956
|
-
//
|
|
957
|
-
// GraphQL mutation surface stays addressable.
|
|
939
|
+
// Every constructor, so the mutation surface stays addressable.
|
|
958
940
|
let commandVariantNames = schema => Reventless.DcbTag.extractAllVariantNames(schema)
|
|
959
941
|
let qualify = (~prefix, names) => names->Array.map(n => prefix ++ "." ++ n)
|
|
942
|
+
let dedupe = (xs: array<string>) => xs->Belt.Set.String.fromArray->Belt.Set.String.toArray
|
|
960
943
|
|
|
961
944
|
// ── Per-component event type extraction ────────────────────────────────────
|
|
962
945
|
|
|
@@ -1058,21 +1041,10 @@ let make = (
|
|
|
1058
1041
|
|
|
1059
1042
|
// ── Declared object stores ─────────────────────────────────────────────────
|
|
1060
1043
|
//
|
|
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.
|
|
1044
|
+
// A storage-ref field states the deployment needs that store. Collected here so
|
|
1045
|
+
// the requirement travels with the structure, qualified to `{plugin}.{store}`
|
|
1046
|
+
// and deduplicated, each entry keeping its `(component, field)` site.
|
|
1047
|
+
// `requiredStores` is derived from the same walk, so the two cannot disagree.
|
|
1076
1048
|
|
|
1077
1049
|
let storesFromProperties = (~component, properties: dict<S.t<unknown>>): array<
|
|
1078
1050
|
Reventless.Plugin.requiredStoreDeclaration,
|
|
@@ -1088,11 +1060,8 @@ let make = (
|
|
|
1088
1060
|
Reventless.Plugin.store: target.plugin->Option.getOr(name) ++ "." ++ target.store,
|
|
1089
1061
|
component,
|
|
1090
1062
|
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.
|
|
1063
|
+
// The source text, not a guess: `target.plugin` is `None` exactly when the
|
|
1064
|
+
// field left the store unqualified, and only here does that survive.
|
|
1096
1065
|
annotation: Some(
|
|
1097
1066
|
switch target.plugin {
|
|
1098
1067
|
| None => target.store
|
|
@@ -1135,10 +1104,51 @@ let make = (
|
|
|
1135
1104
|
}
|
|
1136
1105
|
}
|
|
1137
1106
|
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
1107
|
+
// ── One command name, one handler ──────────────────────────────────────────
|
|
1108
|
+
//
|
|
1109
|
+
// A DCB plugin routes a command by its bare type name: the runtime builds
|
|
1110
|
+
// `handlersByType[typeName]` across every StateChangeSlice in the plugin and
|
|
1111
|
+
// dispatches on the incoming command's TAG alone. Two slices declaring one name
|
|
1112
|
+
// therefore have one handler between them — the second registration overwrites
|
|
1113
|
+
// the first, and the plugin deploys.
|
|
1114
|
+
//
|
|
1115
|
+
// Refused here rather than logged there. The runtime notices only when it goes
|
|
1116
|
+
// to stamp owner fields, by which point the deploy has succeeded and the losing
|
|
1117
|
+
// slice is silently unreachable; a name is a compile-time fact and this is the
|
|
1118
|
+
// last place that can see all of them at once.
|
|
1119
|
+
//
|
|
1120
|
+
// Aggregates are exempt and are not checked: their commands are addressed per
|
|
1121
|
+
// component channel, so two aggregates sharing a command name is ordinary.
|
|
1122
|
+
let dcbCommandOwners: dict<array<string>> = Dict.make()
|
|
1123
|
+
stateChangeSlices->Array.forEach((module(SCS: ReventlessInfra.StateChangeSlice.T)) =>
|
|
1124
|
+
Reventless.DcbTag.extractAllVariantNames(
|
|
1125
|
+
SCS.Spec.commandSchema->S.castToUnknown,
|
|
1126
|
+
)->Array.forEach(cmd => {
|
|
1127
|
+
let owners = dcbCommandOwners->Dict.get(cmd)->Option.getOr([])
|
|
1128
|
+
dcbCommandOwners->Dict.set(cmd, Array.concat(owners, [SCS.Spec.name]))
|
|
1129
|
+
})
|
|
1130
|
+
)
|
|
1131
|
+
let clashes =
|
|
1132
|
+
dcbCommandOwners
|
|
1133
|
+
->Dict.toArray
|
|
1134
|
+
->Array.filter(((_, owners)) => owners->Array.length > 1)
|
|
1135
|
+
->Array.toSorted((((a, _)), ((b, _))) => String.compare(a, b))
|
|
1136
|
+
if clashes->Array.length > 0 {
|
|
1137
|
+
JsError.throwWithMessage(
|
|
1138
|
+
`${name}: ` ++
|
|
1139
|
+
clashes
|
|
1140
|
+
->Array.map((((cmd, owners))) =>
|
|
1141
|
+
`${owners->Array.join(" and ")} both declare the command "${cmd}"`
|
|
1142
|
+
)
|
|
1143
|
+
->Array.join("; ") ++
|
|
1144
|
+
`.\n A DCB plugin routes a command by its bare name, so one of these would never run. ` ++
|
|
1145
|
+
`Qualify them — the shipped traits' emitters prefix every name with the host they are ` ++
|
|
1146
|
+
`grafted onto, for exactly this reason.`,
|
|
1147
|
+
)
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// One list feeds both the store collection and the lint below, so a field is
|
|
1151
|
+
// read exactly once by either.
|
|
1142
1152
|
let storeDeclarationSites: array<(string, S.t<unknown>)> =
|
|
1143
1153
|
[
|
|
1144
1154
|
aggregates->Array.flatMap((module(A: ReventlessInfra.Aggregate.T with type api = api)) => [
|
|
@@ -1165,9 +1175,7 @@ let make = (
|
|
|
1165
1175
|
) => (ITS.Spec.name, ITS.Spec.commandSchema->S.castToUnknown)),
|
|
1166
1176
|
]->Array.flat
|
|
1167
1177
|
|
|
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).
|
|
1178
|
+
// Sorted for a deterministic manifest; identical triples collapse.
|
|
1171
1179
|
let requiredStoreDeclarations =
|
|
1172
1180
|
storeDeclarationSites
|
|
1173
1181
|
->Array.flatMap(((component, schema)) => storesFromSchema(~component, schema))
|
|
@@ -1185,9 +1193,8 @@ let make = (
|
|
|
1185
1193
|
->Belt.Set.String.fromArray
|
|
1186
1194
|
->Belt.Set.String.toArray
|
|
1187
1195
|
|
|
1188
|
-
// Two stores one edit apart
|
|
1189
|
-
//
|
|
1190
|
-
// mistake, and by deploy time both buckets exist.
|
|
1196
|
+
// Two stores one edit apart provision twice. Hard failure, unlike the lint
|
|
1197
|
+
// below: by deploy time both buckets exist.
|
|
1191
1198
|
switch Capability_Inference.collisions(requiredStoreDeclarations) {
|
|
1192
1199
|
| [] => ()
|
|
1193
1200
|
| found =>
|
|
@@ -1197,6 +1204,69 @@ let make = (
|
|
|
1197
1204
|
)
|
|
1198
1205
|
}
|
|
1199
1206
|
|
|
1207
|
+
// Capabilities a slice declares its `translate` reaches for. Sorted and
|
|
1208
|
+
// deduplicated for the same reason the store declarations are: the manifest is
|
|
1209
|
+
// committed, so it must not churn on a re-build. Only outbound translation
|
|
1210
|
+
// slices are handed `Capabilities.t`, so only they can declare.
|
|
1211
|
+
let requiredCapabilities =
|
|
1212
|
+
outboundTranslationSlices
|
|
1213
|
+
->Array.flatMap((module(OTS: ReventlessInfra.OutboundTranslationSlice.T)) =>
|
|
1214
|
+
OTS.Spec.capabilityNeeds->Array.map(need => (
|
|
1215
|
+
{
|
|
1216
|
+
Reventless.Plugin.capability: Reventless.CapabilityNeed.toString(need),
|
|
1217
|
+
component: OTS.Spec.name,
|
|
1218
|
+
}: Reventless.Plugin.requiredCapabilityDeclaration
|
|
1219
|
+
))
|
|
1220
|
+
)
|
|
1221
|
+
->Array.toSorted((a, b) =>
|
|
1222
|
+
a.capability == b.capability
|
|
1223
|
+
? String.compare(a.component, b.component)
|
|
1224
|
+
: String.compare(a.capability, b.capability)
|
|
1225
|
+
)
|
|
1226
|
+
->Array.reduce([], (acc, d) =>
|
|
1227
|
+
switch acc->Array.last {
|
|
1228
|
+
| Some(prev) if prev == d => acc
|
|
1229
|
+
| _ => acc->Array.concat([d])
|
|
1230
|
+
}
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
// The traits grafted into this plugin, one entry per declaring component. The
|
|
1234
|
+
// component name is added here rather than declared: a trait cannot know which
|
|
1235
|
+
// component a host grafted it onto, and a host writing it down would be the one
|
|
1236
|
+
// hand-typed string this whole mechanism exists to avoid. Sorted and
|
|
1237
|
+
// deduplicated for the same reason the two declarations above are.
|
|
1238
|
+
let traitDeclarations = {
|
|
1239
|
+
let entry = (~component, decl: Reventless.Trait.t) => (
|
|
1240
|
+
{
|
|
1241
|
+
Reventless.Plugin.trait: decl.trait,
|
|
1242
|
+
version: decl.version,
|
|
1243
|
+
posture: Reventless.Trait.postureToString(decl.posture),
|
|
1244
|
+
component,
|
|
1245
|
+
}: Reventless.Plugin.traitDeclaration
|
|
1246
|
+
)
|
|
1247
|
+
[
|
|
1248
|
+
aggregates->Array.flatMap((module(A: ReventlessInfra.Aggregate.T with type api = api)) =>
|
|
1249
|
+
A.Spec.traits->Array.map(entry(~component=A.Spec.name, ...))
|
|
1250
|
+
),
|
|
1251
|
+
stateChangeSlices->Array.flatMap((module(SCS: ReventlessInfra.StateChangeSlice.T)) =>
|
|
1252
|
+
SCS.Spec.traits->Array.map(entry(~component=SCS.Spec.name, ...))
|
|
1253
|
+
),
|
|
1254
|
+
outboundTranslationSlices->Array.flatMap((
|
|
1255
|
+
module(OTS: ReventlessInfra.OutboundTranslationSlice.T),
|
|
1256
|
+
) => OTS.Spec.traits->Array.map(entry(~component=OTS.Spec.name, ...))),
|
|
1257
|
+
]
|
|
1258
|
+
->Array.flat
|
|
1259
|
+
->Array.toSorted((a, b) =>
|
|
1260
|
+
a.trait == b.trait ? String.compare(a.component, b.component) : String.compare(a.trait, b.trait)
|
|
1261
|
+
)
|
|
1262
|
+
->Array.reduce([], (acc, d) =>
|
|
1263
|
+
switch acc->Array.last {
|
|
1264
|
+
| Some(prev) if prev == d => acc
|
|
1265
|
+
| _ => acc->Array.concat([d])
|
|
1266
|
+
}
|
|
1267
|
+
)
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1200
1270
|
// Heuristic-only matches — a field *named* like a stored-object ref with no
|
|
1201
1271
|
// `@storageRef` — warn and provision nothing. Declaration outranks inference;
|
|
1202
1272
|
// the warning names the annotation that would settle it.
|
|
@@ -1208,12 +1278,8 @@ let make = (
|
|
|
1208
1278
|
|
|
1209
1279
|
// ── Build queryable defs ───────────────────────────────────────────────────
|
|
1210
1280
|
//
|
|
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.
|
|
1281
|
+
// Internal views are carried here, tagged via `queryableDef.visibility`, so
|
|
1282
|
+
// developer tools see them; AutoUI's consumers re-filter on the tag.
|
|
1217
1283
|
// View name -> the states its lifecycle field can hold, collected as the view
|
|
1218
1284
|
// defs are built so the transition check below has both sides in one place.
|
|
1219
1285
|
let lifecycleStatesByView: dict<array<string>> = Dict.make()
|
|
@@ -1250,10 +1316,7 @@ let make = (
|
|
|
1250
1316
|
~entityName=R.Spec.name,
|
|
1251
1317
|
stateSchema,
|
|
1252
1318
|
)
|
|
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).
|
|
1319
|
+
// Qualified to the plugin's event ids so they match the producers' nodes.
|
|
1257
1320
|
let consumed = qualify(~prefix=name, R.consumedEventNames)
|
|
1258
1321
|
recordRetired(~entityName=R.Spec.name, stateSchema)
|
|
1259
1322
|
recordLifecycle(~entityName=R.Spec.name, stateSchema)
|
|
@@ -1338,6 +1401,7 @@ let make = (
|
|
|
1338
1401
|
~variant=variantName,
|
|
1339
1402
|
),
|
|
1340
1403
|
~commandAuthorization=SCS.Spec.commandAuthorization->Obj.magic,
|
|
1404
|
+
~commandTransition=SCS.Spec.commandTransition->Obj.magic,
|
|
1341
1405
|
SCS.Spec.commandSchema->S.castToUnknown,
|
|
1342
1406
|
),
|
|
1343
1407
|
producedEventTypes: produced,
|
|
@@ -1362,6 +1426,7 @@ let make = (
|
|
|
1362
1426
|
~isAggregate=true,
|
|
1363
1427
|
~mutationFieldFor=variantName => Api_Naming.aggregateMutationField(~plugin=name, ~aggregate=A.Spec.name, ~command=variantName),
|
|
1364
1428
|
~commandAuthorization=A.Spec.commandAuthorization->Obj.magic,
|
|
1429
|
+
~commandTransition=A.Spec.commandTransition->Obj.magic,
|
|
1365
1430
|
A.Spec.commandSchema->S.castToUnknown,
|
|
1366
1431
|
),
|
|
1367
1432
|
producedEventTypes: produced,
|
|
@@ -1422,30 +1487,112 @@ let make = (
|
|
|
1422
1487
|
|
|
1423
1488
|
// ── Extensions ───────────────────────────────────────────────────────────
|
|
1424
1489
|
|
|
1490
|
+
let tableFailures = []
|
|
1491
|
+
let tableWarnings = []
|
|
1492
|
+
let pushAll = (into, xs) => xs->Array.forEach(x => into->Array.push(x)->ignore)
|
|
1493
|
+
|
|
1494
|
+
// Every constructor, payload-less included: a declaration is checked against
|
|
1495
|
+
// what the author can write, not the payload-filtered subset the edges use.
|
|
1496
|
+
let allVariantNames = schema => Reventless.DcbTag.extractAllVariantNames(schema)
|
|
1497
|
+
|
|
1425
1498
|
let extensionDefs =
|
|
1426
1499
|
extensions->Array.map((module(E: ReventlessInfra.Extension.Blueprint)) => {
|
|
1427
1500
|
let delegateNames = E.mappings->Array.map((module(M: E.Mapping)) => M.delegateName)
|
|
1501
|
+
let epEventNames = allVariantNames(E.Spec.eventSchema)
|
|
1502
|
+
let epCommandNames = allVariantNames(E.Spec.commandSchema)
|
|
1503
|
+
|
|
1504
|
+
// Mappings sharing one EP union their tables — the same event may route to
|
|
1505
|
+
// a different delegate's command in each.
|
|
1506
|
+
let commandsByEvent: Dict.t<array<string>> = Dict.make()
|
|
1507
|
+
let eventsByCommand: Dict.t<array<string>> = Dict.make()
|
|
1508
|
+
E.mappings->Array.forEach((module(M: E.Mapping)) => {
|
|
1509
|
+
let label = `${E.Spec.name} → ${M.delegateName}`
|
|
1510
|
+
pushAll(
|
|
1511
|
+
tableFailures,
|
|
1512
|
+
handledTableFailures(
|
|
1513
|
+
~label,
|
|
1514
|
+
~declared=M.handledEvents,
|
|
1515
|
+
~eventNames=epEventNames,
|
|
1516
|
+
~commandNames=Array.concat(M.delegateCommandNames, epCommandNames),
|
|
1517
|
+
),
|
|
1518
|
+
)
|
|
1519
|
+
pushAll(
|
|
1520
|
+
tableFailures,
|
|
1521
|
+
commandTableFailures(
|
|
1522
|
+
~label,
|
|
1523
|
+
~keyed="issuedCommands",
|
|
1524
|
+
~valueKind="comes from",
|
|
1525
|
+
~rows=M.issuedCommands->Array.map(({name, fromEventTypes}) => (name, fromEventTypes)),
|
|
1526
|
+
~keyNames=epCommandNames,
|
|
1527
|
+
~valueNames=M.delegateEventNames,
|
|
1528
|
+
),
|
|
1529
|
+
)
|
|
1530
|
+
M.issuedCommands->Array.forEach(({name: commandName, fromEventTypes}) => {
|
|
1531
|
+
let key = `${E.Spec.name}.${commandName}`
|
|
1532
|
+
eventsByCommand->Dict.set(
|
|
1533
|
+
key,
|
|
1534
|
+
Array.concat(
|
|
1535
|
+
eventsByCommand->Dict.get(key)->Option.getOr([]),
|
|
1536
|
+
qualify(~prefix=name, fromEventTypes),
|
|
1537
|
+
),
|
|
1538
|
+
)
|
|
1539
|
+
})
|
|
1540
|
+
M.handledEvents->Array.forEach(({name: eventName, toCommandTypes}) => {
|
|
1541
|
+
// The qualifier says which way the command goes: plugin-qualified
|
|
1542
|
+
// inward, EP-qualified back to the port.
|
|
1543
|
+
let qualified =
|
|
1544
|
+
toCommandTypes->Array.map(cmd =>
|
|
1545
|
+
M.delegateCommandNames->Array.includes(cmd)
|
|
1546
|
+
? `${name}.${cmd}`
|
|
1547
|
+
: `${E.Spec.name}.${cmd}`
|
|
1548
|
+
)
|
|
1549
|
+
let key = `${E.Spec.name}.${eventName}`
|
|
1550
|
+
commandsByEvent->Dict.set(
|
|
1551
|
+
key,
|
|
1552
|
+
Array.concat(commandsByEvent->Dict.get(key)->Option.getOr([]), qualified),
|
|
1553
|
+
)
|
|
1554
|
+
})
|
|
1555
|
+
})
|
|
1556
|
+
|
|
1428
1557
|
({
|
|
1429
1558
|
Reventless.Plugin.name: E.Spec.name,
|
|
1430
1559
|
delegateNames,
|
|
1431
1560
|
eventTypes: qualify(~prefix=E.Spec.name, eventVariantNames(E.Spec.eventSchema)),
|
|
1432
1561
|
commandTypes: qualify(~prefix=E.Spec.name, commandVariantNames(E.Spec.commandSchema)),
|
|
1562
|
+
handledEvents: Some(
|
|
1563
|
+
commandsByEvent
|
|
1564
|
+
->Dict.toArray
|
|
1565
|
+
->Array.map(((eventName, cmds)) => ({
|
|
1566
|
+
Reventless.Plugin.name: eventName,
|
|
1567
|
+
toCommandTypes: dedupe(cmds),
|
|
1568
|
+
}: Reventless.Plugin.handledEventDef)),
|
|
1569
|
+
),
|
|
1570
|
+
issuedCommands: Some(
|
|
1571
|
+
eventsByCommand
|
|
1572
|
+
->Dict.toArray
|
|
1573
|
+
->Array.map(((commandName, evs)) => ({
|
|
1574
|
+
Reventless.Plugin.name: commandName,
|
|
1575
|
+
fromEventTypes: dedupe(evs),
|
|
1576
|
+
}: Reventless.Plugin.issuedCommandDef)),
|
|
1577
|
+
),
|
|
1433
1578
|
}: Reventless.Plugin.extensionDef)
|
|
1434
1579
|
})
|
|
1435
1580
|
|
|
1436
1581
|
// ── Extension points (producer side) ──────────────────────────────────────
|
|
1437
1582
|
//
|
|
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
|
|
1583
|
+
// Several mappings can target one EP, so group by its dotted name and union each
|
|
1584
|
+
// delegate's name and source events. Source events are plugin-qualified to match
|
|
1585
|
+
// `producedEventTypes`, so the graph can draw write-side → event → EP.
|
|
1447
1586
|
|
|
1448
1587
|
let epByName: Dict.t<(array<string>, array<string>, array<string>)> = Dict.make()
|
|
1588
|
+
// Per EP: published event → the internal events producing it, unioned over
|
|
1589
|
+
// every mapping targeting it.
|
|
1590
|
+
let epPublished: Dict.t<Dict.t<array<string>>> = Dict.make()
|
|
1591
|
+
// The command direction's mirror: arriving command → the delegate commands it
|
|
1592
|
+
// routes to. Unioned the same way — one port's inbound protocol is split across
|
|
1593
|
+
// its mappings, so a command handled by a sibling is not dead surface.
|
|
1594
|
+
let epAccepted: Dict.t<Dict.t<array<string>>> = Dict.make()
|
|
1595
|
+
|
|
1449
1596
|
extensionPoints->Array.forEach((module(M: ReventlessInfra.ExtensionPointMapping.Mapping)) => {
|
|
1450
1597
|
let epName = M.ExtensionPoint.name
|
|
1451
1598
|
let sourceEvents = qualify(~prefix=name, eventVariantNames(M.Delegate.eventSchema->S.castToUnknown))
|
|
@@ -1457,7 +1604,217 @@ let make = (
|
|
|
1457
1604
|
epName,
|
|
1458
1605
|
(Array.concat(dels, [M.Delegate.name]), Array.concat(evs, sourceEvents), Array.concat(cmds, commands)),
|
|
1459
1606
|
)
|
|
1607
|
+
|
|
1608
|
+
let label = `${epName} ← ${M.Delegate.name}`
|
|
1609
|
+
let publishedNames = allVariantNames(M.ExtensionPoint.eventSchema)
|
|
1610
|
+
let sourceNames = allVariantNames(M.Delegate.eventSchema->S.castToUnknown)
|
|
1611
|
+
pushAll(
|
|
1612
|
+
tableFailures,
|
|
1613
|
+
translationTableFailures(~label, ~declared=M.publishedEvents, ~publishedNames, ~sourceNames),
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1616
|
+
// ── The command direction ────────────────────────────────────────────────
|
|
1617
|
+
let acceptedNames = allVariantNames(M.ExtensionPoint.commandSchema)
|
|
1618
|
+
let delegateCommandNames = allVariantNames(M.Delegate.commandSchema)
|
|
1619
|
+
pushAll(
|
|
1620
|
+
tableFailures,
|
|
1621
|
+
commandTableFailures(
|
|
1622
|
+
~label,
|
|
1623
|
+
~keyed="acceptedCommands",
|
|
1624
|
+
~valueKind="routes to",
|
|
1625
|
+
~rows=M.acceptedCommands->Array.map(({name, toCommandTypes}) => (name, toCommandTypes)),
|
|
1626
|
+
~keyNames=acceptedNames,
|
|
1627
|
+
~valueNames=delegateCommandNames,
|
|
1628
|
+
),
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
// The mirror of the published-event probe: one synthesised EP command per
|
|
1632
|
+
// constructor, through the author's own mapIncomingCommand. Cheaper than the
|
|
1633
|
+
// event probe — the signature reaches no query engine.
|
|
1634
|
+
let acceptedObserved = []
|
|
1635
|
+
acceptedNames->Array.forEach(cmd => {
|
|
1636
|
+
let synthesised = Reventless.DcbTag.isVariantPayloadBearing(
|
|
1637
|
+
M.ExtensionPoint.commandSchema->S.castToUnknown,
|
|
1638
|
+
cmd,
|
|
1639
|
+
)
|
|
1640
|
+
? Dict.fromArray([("TAG", JSON.Encode.string(cmd))])->JSON.Encode.object
|
|
1641
|
+
: JSON.Encode.string(cmd)
|
|
1642
|
+
switch (
|
|
1643
|
+
try {
|
|
1644
|
+
let command =
|
|
1645
|
+
Reventless.Message.fillMissingDefaults(
|
|
1646
|
+
M.ExtensionPoint.commandSchema,
|
|
1647
|
+
synthesised,
|
|
1648
|
+
[],
|
|
1649
|
+
)->Reventless.Util_Sury.fromJson(M.ExtensionPoint.commandSchema)
|
|
1650
|
+
let decodedAs =
|
|
1651
|
+
command
|
|
1652
|
+
->Reventless.Message.encode(M.ExtensionPoint.commandSchema)
|
|
1653
|
+
->Reventless.Message.variantNameOfJson
|
|
1654
|
+
decodedAs != cmd
|
|
1655
|
+
? Error(`a synthesised "${cmd}" decoded as "${decodedAs}"`)
|
|
1656
|
+
: Ok(M.mapIncomingCommand(probeId, command, probeMeta))
|
|
1657
|
+
} catch {
|
|
1658
|
+
| _ => Error(`the mapping raised on a synthesised "${cmd}"`)
|
|
1659
|
+
}
|
|
1660
|
+
) {
|
|
1661
|
+
| Ok(actions) =>
|
|
1662
|
+
actions->Array.forEach(action =>
|
|
1663
|
+
switch action {
|
|
1664
|
+
| ReventlessInfra.ExtensionPointMapping.PublishCommand(_, routed) =>
|
|
1665
|
+
acceptedObserved
|
|
1666
|
+
->Array.push((
|
|
1667
|
+
cmd,
|
|
1668
|
+
routed
|
|
1669
|
+
->Reventless.Message.encode(M.Delegate.commandSchema)
|
|
1670
|
+
->Reventless.Message.variantNameOfJson,
|
|
1671
|
+
))
|
|
1672
|
+
->ignore
|
|
1673
|
+
| HandleDirective(_, _) => ()
|
|
1674
|
+
}
|
|
1675
|
+
)
|
|
1676
|
+
| Error(reason) =>
|
|
1677
|
+
tableWarnings->Array.push(`${label}: not checked against the arms — ${reason}.`)->ignore
|
|
1678
|
+
}
|
|
1679
|
+
})
|
|
1680
|
+
acceptedObserved->Array.forEach(((cmd, routed)) =>
|
|
1681
|
+
if (
|
|
1682
|
+
!(
|
|
1683
|
+
M.acceptedCommands->Array.some(({name, toCommandTypes}) =>
|
|
1684
|
+
name == cmd && toCommandTypes->Array.includes(routed)
|
|
1685
|
+
)
|
|
1686
|
+
)
|
|
1687
|
+
) {
|
|
1688
|
+
tableFailures
|
|
1689
|
+
->Array.push(
|
|
1690
|
+
`${label}: "${cmd}" routes to "${routed}", which acceptedCommands does not declare.`,
|
|
1691
|
+
)
|
|
1692
|
+
->ignore
|
|
1693
|
+
}
|
|
1694
|
+
)
|
|
1695
|
+
|
|
1696
|
+
let acceptedTable = epAccepted->Dict.get(epName)->Option.getOr(Dict.make())
|
|
1697
|
+
M.acceptedCommands->Array.forEach(({name: accepted, toCommandTypes}) => {
|
|
1698
|
+
let key = `${epName}.${accepted}`
|
|
1699
|
+
acceptedTable->Dict.set(
|
|
1700
|
+
key,
|
|
1701
|
+
Array.concat(
|
|
1702
|
+
acceptedTable->Dict.get(key)->Option.getOr([]),
|
|
1703
|
+
qualify(~prefix=name, toCommandTypes),
|
|
1704
|
+
),
|
|
1705
|
+
)
|
|
1706
|
+
})
|
|
1707
|
+
epAccepted->Dict.set(epName, acceptedTable)
|
|
1708
|
+
|
|
1709
|
+
switch M.mapOutgoingEvent {
|
|
1710
|
+
| None =>
|
|
1711
|
+
if Array.length(M.publishedEvents) > 0 {
|
|
1712
|
+
tableFailures
|
|
1713
|
+
->Array.push(
|
|
1714
|
+
`${label}: publishedEvents declares ${M.publishedEvents
|
|
1715
|
+
->Array.length
|
|
1716
|
+
->Int.toString} event(s), but the mapping has no mapOutgoingEvent and ` ++
|
|
1717
|
+
`publishes nothing.`,
|
|
1718
|
+
)
|
|
1719
|
+
->ignore
|
|
1720
|
+
}
|
|
1721
|
+
| Some(mapOutgoing) =>
|
|
1722
|
+
// One synthesised event per Delegate constructor, through the author's own
|
|
1723
|
+
// function — not the compiled one, which logs and re-encodes.
|
|
1724
|
+
let observed = []
|
|
1725
|
+
let followed = []
|
|
1726
|
+
sourceNames->Array.forEach(src => {
|
|
1727
|
+
let synthesised = Reventless.DcbTag.isVariantPayloadBearing(
|
|
1728
|
+
M.Delegate.eventSchema->S.castToUnknown,
|
|
1729
|
+
src,
|
|
1730
|
+
)
|
|
1731
|
+
? Dict.fromArray([("TAG", JSON.Encode.string(src))])->JSON.Encode.object
|
|
1732
|
+
: JSON.Encode.string(src)
|
|
1733
|
+
let outcome = try {
|
|
1734
|
+
// Filled directly rather than through `parseJsonTolerant`, which warns
|
|
1735
|
+
// about inventing values — here the invention is the point.
|
|
1736
|
+
let event =
|
|
1737
|
+
Reventless.Message.fillMissingDefaults(M.Delegate.eventSchema, synthesised, [])
|
|
1738
|
+
->Reventless.Util_Sury.fromJson(M.Delegate.eventSchema)
|
|
1739
|
+
// A fabricated payload can decode as a sibling constructor; only a
|
|
1740
|
+
// value that round-trips is judged.
|
|
1741
|
+
let decodedAs =
|
|
1742
|
+
event
|
|
1743
|
+
->Reventless.Message.encode(M.Delegate.eventSchema)
|
|
1744
|
+
->Reventless.Message.variantNameOfJson
|
|
1745
|
+
if decodedAs != src {
|
|
1746
|
+
Error(`a synthesised "${src}" decoded as "${decodedAs}"`)
|
|
1747
|
+
} else {
|
|
1748
|
+
let actions = mapOutgoing(probeId, event, probeMeta, probeQueryEngine)
|
|
1749
|
+
actions->Array.forEach(action =>
|
|
1750
|
+
switch action {
|
|
1751
|
+
| ReventlessInfra.ExtensionPointMapping.PublishEvent(_, published) =>
|
|
1752
|
+
observed
|
|
1753
|
+
->Array.push((
|
|
1754
|
+
src,
|
|
1755
|
+
published
|
|
1756
|
+
->Reventless.Message.encode(M.ExtensionPoint.eventSchema)
|
|
1757
|
+
->Reventless.Message.variantNameOfJson,
|
|
1758
|
+
))
|
|
1759
|
+
->ignore
|
|
1760
|
+
| PublishEventAsync(_) | HandleDirective(_, _) => ()
|
|
1761
|
+
}
|
|
1762
|
+
)
|
|
1763
|
+
// A promise hides what it will publish — leave the arm unjudged.
|
|
1764
|
+
actions->Array.some(action =>
|
|
1765
|
+
switch action {
|
|
1766
|
+
| PublishEventAsync(_) => true
|
|
1767
|
+
| PublishEvent(_, _) | HandleDirective(_, _) => false
|
|
1768
|
+
}
|
|
1769
|
+
)
|
|
1770
|
+
? Error(`"${src}" publishes behind a promise`)
|
|
1771
|
+
: Ok()
|
|
1772
|
+
}
|
|
1773
|
+
} catch {
|
|
1774
|
+
| _ => Error(`the mapping raised on a synthesised "${src}"`)
|
|
1775
|
+
}
|
|
1776
|
+
switch outcome {
|
|
1777
|
+
| Ok() => followed->Array.push(src)->ignore
|
|
1778
|
+
| Error(reason) =>
|
|
1779
|
+
tableWarnings->Array.push(`${label}: not checked against the arms — ${reason}.`)->ignore
|
|
1780
|
+
}
|
|
1781
|
+
})
|
|
1782
|
+
|
|
1783
|
+
let (failures, warnings) = translationTableDrift(
|
|
1784
|
+
~label,
|
|
1785
|
+
~declared=M.publishedEvents,
|
|
1786
|
+
~observed,
|
|
1787
|
+
~followed,
|
|
1788
|
+
)
|
|
1789
|
+
pushAll(tableFailures, failures)
|
|
1790
|
+
pushAll(tableWarnings, warnings)
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Dead protocol surface: an event of the published contract that no arm
|
|
1794
|
+
// produces, which a subscriber may already be routing.
|
|
1795
|
+
let declaredNames = M.publishedEvents->Array.map(p => p.name)
|
|
1796
|
+
publishedNames
|
|
1797
|
+
->Array.filter(p => !(declaredNames->Array.includes(p)))
|
|
1798
|
+
->Array.forEach(p =>
|
|
1799
|
+
tableWarnings
|
|
1800
|
+
->Array.push(`${label}: the extension point publishes "${p}", which no arm produces.`)
|
|
1801
|
+
->ignore
|
|
1802
|
+
)
|
|
1803
|
+
|
|
1804
|
+
let table = epPublished->Dict.get(epName)->Option.getOr(Dict.make())
|
|
1805
|
+
M.publishedEvents->Array.forEach(({name: published, fromEventTypes}) => {
|
|
1806
|
+
let key = `${epName}.${published}`
|
|
1807
|
+
table->Dict.set(
|
|
1808
|
+
key,
|
|
1809
|
+
Array.concat(
|
|
1810
|
+
table->Dict.get(key)->Option.getOr([]),
|
|
1811
|
+
qualify(~prefix=name, fromEventTypes),
|
|
1812
|
+
),
|
|
1813
|
+
)
|
|
1814
|
+
})
|
|
1815
|
+
epPublished->Dict.set(epName, table)
|
|
1460
1816
|
})
|
|
1817
|
+
|
|
1461
1818
|
let extensionPointDefs =
|
|
1462
1819
|
epByName
|
|
1463
1820
|
->Dict.toArray
|
|
@@ -1466,8 +1823,52 @@ let make = (
|
|
|
1466
1823
|
delegateNames: dedupe(dels),
|
|
1467
1824
|
sourceEventTypes: dedupe(evs),
|
|
1468
1825
|
commandTypes: Some(dedupe(cmds)),
|
|
1826
|
+
publishedEvents: Some(
|
|
1827
|
+
epPublished
|
|
1828
|
+
->Dict.get(epName)
|
|
1829
|
+
->Option.getOr(Dict.make())
|
|
1830
|
+
->Dict.toArray
|
|
1831
|
+
->Array.map(((published, sources)) => ({
|
|
1832
|
+
Reventless.Plugin.name: published,
|
|
1833
|
+
fromEventTypes: dedupe(sources),
|
|
1834
|
+
}: Reventless.Plugin.publishedEventDef)),
|
|
1835
|
+
),
|
|
1836
|
+
acceptedCommands: Some(
|
|
1837
|
+
epAccepted
|
|
1838
|
+
->Dict.get(epName)
|
|
1839
|
+
->Option.getOr(Dict.make())
|
|
1840
|
+
->Dict.toArray
|
|
1841
|
+
->Array.map(((accepted, routed)) => ({
|
|
1842
|
+
Reventless.Plugin.name: accepted,
|
|
1843
|
+
toCommandTypes: dedupe(routed),
|
|
1844
|
+
}: Reventless.Plugin.acceptedCommandDef)),
|
|
1845
|
+
),
|
|
1469
1846
|
}: Reventless.Plugin.extensionPointDef))
|
|
1470
1847
|
|
|
1848
|
+
// Dead inbound surface, judged only after every mapping on an EP has been seen:
|
|
1849
|
+
// one port's inbound protocol is split across its mappings, so a command the
|
|
1850
|
+
// Plugin mapping ignores may be the UiFragment mapping's whole job.
|
|
1851
|
+
epByName
|
|
1852
|
+
->Dict.toArray
|
|
1853
|
+
->Array.forEach(((epName, (_, _, cmds))) => {
|
|
1854
|
+
let handled =
|
|
1855
|
+
epAccepted->Dict.get(epName)->Option.getOr(Dict.make())->Dict.keysToArray
|
|
1856
|
+
cmds
|
|
1857
|
+
->dedupe
|
|
1858
|
+
->Array.forEach(cmd =>
|
|
1859
|
+
if !(handled->Array.includes(cmd)) {
|
|
1860
|
+
tableWarnings
|
|
1861
|
+
->Array.push(
|
|
1862
|
+
`${epName}: the extension point accepts "${cmd}", which no arm handles — a ` ++
|
|
1863
|
+
`sender gets no error and nothing happens.`,
|
|
1864
|
+
)
|
|
1865
|
+
->ignore
|
|
1866
|
+
}
|
|
1867
|
+
)
|
|
1868
|
+
})
|
|
1869
|
+
|
|
1870
|
+
reportTranslationTables(~pluginName=name, ~failures=tableFailures, ~warnings=tableWarnings)
|
|
1871
|
+
|
|
1471
1872
|
// Second pass, on purpose: commands are built well before `linkedViews` is
|
|
1472
1873
|
// assembled, so the check cannot run inline where the defs are made.
|
|
1473
1874
|
checkDeclaredTransitions(
|
|
@@ -1497,5 +1898,7 @@ let make = (
|
|
|
1497
1898
|
extensionPoints: Some(extensionPointDefs),
|
|
1498
1899
|
requiredStores: Some(requiredStores),
|
|
1499
1900
|
requiredStoreDeclarations: Some(requiredStoreDeclarations),
|
|
1901
|
+
traitDeclarations: Some(traitDeclarations),
|
|
1902
|
+
requiredCapabilities: Some(requiredCapabilities),
|
|
1500
1903
|
}
|
|
1501
1904
|
}
|