@reventlessdev/reventless-spec 3.0.0-alpha.71 → 3.0.0-alpha.73
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 +14 -0
- package/package.json +1 -1
- package/src/components/Plugin.res +22 -0
- package/src/components/Plugin.res.mjs +10 -5
- package/src/generator/Codegen.res +38 -2
- package/src/generator/Codegen.res.mjs +20 -4
- package/src/generator/Discovery.res +47 -0
- package/src/generator/Discovery.res.mjs +35 -0
- package/src/types/Message.res +87 -4
- package/src/types/Message.res.mjs +59 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.73 (2026-07-11)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **plugin-lifecycle:** heal message decode of definitions persisted before a schema field existed ([6bb3e72](https://github.com/ReventlessDev/reventless-core/commit/6bb3e7259ad606a0f77fb670bcfc680256592003))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.72 (2026-07-10)
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **plugin-structure:** capture per-component chapter grouping for the deployed graph ([f9c88a9](https://github.com/ReventlessDev/reventless-core/commit/f9c88a9a48d8c032ffe23f9e5277caf12c29e85c))
|
|
18
|
+
|
|
19
|
+
|
|
6
20
|
# 3.0.0-alpha.71 (2026-07-10)
|
|
7
21
|
|
|
8
22
|
**Note:** Version bump only for package @reventlessdev/reventless-spec
|
package/package.json
CHANGED
|
@@ -215,6 +215,20 @@ type queryableDef = {
|
|
|
215
215
|
existed decode as `None` (Public).
|
|
216
216
|
*/
|
|
217
217
|
visibility: @s.matches(stringOptionSchema) option<string>,
|
|
218
|
+
/**
|
|
219
|
+
Intra-plugin grouping band (the "chapter") this component belongs to, captured at
|
|
220
|
+
build time from its source folder by the plugin generator: the first path segment
|
|
221
|
+
under the plugin's `src/` that is not a recognised kind-folder
|
|
222
|
+
(`src/<Chapter>/…/<Component>.res` → `Some("<Chapter>")`; a component directly under a
|
|
223
|
+
kind-folder → `None`). Lets a consumer that renders the event graph from the
|
|
224
|
+
*deployed* plugin structure group components into chapter sub-containers identically
|
|
225
|
+
to the authoring tooling, with no workspace/disk access — the renderer already
|
|
226
|
+
supports the bands (`DomainGraphD2 ~chapters`); only this datum was missing on the
|
|
227
|
+
deployed side. `None` (absent) renders flat. js_nullable (T | null) keeps it JSON-safe
|
|
228
|
+
inside the lifecycle Message union; always written (None → null), so defs persisted
|
|
229
|
+
before this field existed must be reset/re-emitted. See [[deployed-chapter-grouping]].
|
|
230
|
+
*/
|
|
231
|
+
chapter: @s.matches(stringOptionSchema) option<string>,
|
|
218
232
|
}
|
|
219
233
|
|
|
220
234
|
/**
|
|
@@ -244,6 +258,8 @@ type writableDef = {
|
|
|
244
258
|
arrays; `[]` when there are none. The structure is re-derived on every build/
|
|
245
259
|
deploy, so no persisted-data back-compat shim is needed. */
|
|
246
260
|
events: array<eventDef>,
|
|
261
|
+
/** Chapter grouping band — see `queryableDef.chapter`. */
|
|
262
|
+
chapter: @s.matches(stringOptionSchema) option<string>,
|
|
247
263
|
}
|
|
248
264
|
|
|
249
265
|
@schema
|
|
@@ -252,6 +268,8 @@ type automationSliceDef = {
|
|
|
252
268
|
consumedEventTypes: array<string>,
|
|
253
269
|
producedCommandTypes: array<string>,
|
|
254
270
|
targetName: string,
|
|
271
|
+
/** Chapter grouping band — see `queryableDef.chapter`. */
|
|
272
|
+
chapter: @s.matches(stringOptionSchema) option<string>,
|
|
255
273
|
}
|
|
256
274
|
|
|
257
275
|
@schema
|
|
@@ -262,6 +280,8 @@ type outboundTranslationSliceDef = {
|
|
|
262
280
|
targetName: @s.matches(stringOptionSchema) option<string>,
|
|
263
281
|
// Foreign system this slice publishes to — drives the external box (Event Graph).
|
|
264
282
|
externalSystem: @s.matches(stringOptionSchema) option<string>,
|
|
283
|
+
/** Chapter grouping band — see `queryableDef.chapter`. */
|
|
284
|
+
chapter: @s.matches(stringOptionSchema) option<string>,
|
|
265
285
|
}
|
|
266
286
|
|
|
267
287
|
@schema
|
|
@@ -271,6 +291,8 @@ type inboundTranslationSliceDef = {
|
|
|
271
291
|
targetName: string,
|
|
272
292
|
// Foreign system this slice receives from — drives the external box (Event Graph).
|
|
273
293
|
externalSystem: @s.matches(stringOptionSchema) option<string>,
|
|
294
|
+
/** Chapter grouping band — see `queryableDef.chapter`. */
|
|
295
|
+
chapter: @s.matches(stringOptionSchema) option<string>,
|
|
274
296
|
}
|
|
275
297
|
|
|
276
298
|
@schema
|
|
@@ -109,7 +109,8 @@ let queryableDefSchema = S.schema(s => ({
|
|
|
109
109
|
labelField: s.m(S.string),
|
|
110
110
|
searchableFields: s.m(S.array(S.string)),
|
|
111
111
|
statusField: s.m(stringOptionSchema),
|
|
112
|
-
visibility: s.m(stringOptionSchema)
|
|
112
|
+
visibility: s.m(stringOptionSchema),
|
|
113
|
+
chapter: s.m(stringOptionSchema)
|
|
113
114
|
}));
|
|
114
115
|
|
|
115
116
|
let eventDefSchema = S.schema(s => ({
|
|
@@ -125,14 +126,16 @@ let writableDefSchema = S.schema(s => ({
|
|
|
125
126
|
consumedEventTypes: s.m(S.array(S.string)),
|
|
126
127
|
linkedViews: s.m(S.array(S.string)),
|
|
127
128
|
consistencyRead: s.m(stringOptionSchema),
|
|
128
|
-
events: s.m(S.array(eventDefSchema))
|
|
129
|
+
events: s.m(S.array(eventDefSchema)),
|
|
130
|
+
chapter: s.m(stringOptionSchema)
|
|
129
131
|
}));
|
|
130
132
|
|
|
131
133
|
let automationSliceDefSchema = S.schema(s => ({
|
|
132
134
|
name: s.m(S.string),
|
|
133
135
|
consumedEventTypes: s.m(S.array(S.string)),
|
|
134
136
|
producedCommandTypes: s.m(S.array(S.string)),
|
|
135
|
-
targetName: s.m(S.string)
|
|
137
|
+
targetName: s.m(S.string),
|
|
138
|
+
chapter: s.m(stringOptionSchema)
|
|
136
139
|
}));
|
|
137
140
|
|
|
138
141
|
let outboundTranslationSliceDefSchema = S.schema(s => ({
|
|
@@ -140,14 +143,16 @@ let outboundTranslationSliceDefSchema = S.schema(s => ({
|
|
|
140
143
|
consumedEventTypes: s.m(S.array(S.string)),
|
|
141
144
|
inboundCommandTypes: s.m(S.array(S.string)),
|
|
142
145
|
targetName: s.m(stringOptionSchema),
|
|
143
|
-
externalSystem: s.m(stringOptionSchema)
|
|
146
|
+
externalSystem: s.m(stringOptionSchema),
|
|
147
|
+
chapter: s.m(stringOptionSchema)
|
|
144
148
|
}));
|
|
145
149
|
|
|
146
150
|
let inboundTranslationSliceDefSchema = S.schema(s => ({
|
|
147
151
|
name: s.m(S.string),
|
|
148
152
|
commandTypes: s.m(S.array(S.string)),
|
|
149
153
|
targetName: s.m(S.string),
|
|
150
|
-
externalSystem: s.m(stringOptionSchema)
|
|
154
|
+
externalSystem: s.m(stringOptionSchema),
|
|
155
|
+
chapter: s.m(stringOptionSchema)
|
|
151
156
|
}));
|
|
152
157
|
|
|
153
158
|
let extensionDefSchema = S.schema(s => ({
|
|
@@ -269,6 +269,7 @@ let renderPluginStructureCall = (
|
|
|
269
269
|
~inboundTranslationSlices: array<string>,
|
|
270
270
|
~extensions: array<string>,
|
|
271
271
|
~extensionPoints: array<Pairing.extensionPointDef>,
|
|
272
|
+
~componentChapters: array<(string, string)>,
|
|
272
273
|
): option<array<string>> => {
|
|
273
274
|
// The structure call carries the EP *mapping* files (one per Delegate
|
|
274
275
|
// connection), not the wrapped ExtensionPoint module — Plugin_Structure reads
|
|
@@ -337,6 +338,16 @@ let renderPluginStructureCall = (
|
|
|
337
338
|
let entries = epMappingStems->Array.map(s => "module(" ++ s ++ ")")
|
|
338
339
|
ls->Array.push(" ~extensionPoints=[" ++ entries->Array.join(", ") ++ "],")
|
|
339
340
|
}
|
|
341
|
+
// Chapter grouping bands per component, captured from the source folder layout.
|
|
342
|
+
// Only emitted when at least one component lives under a chapter folder, so
|
|
343
|
+
// plugins with a flat `src/` keep a byte-identical generated Plugin.res.
|
|
344
|
+
if componentChapters->Array.length > 0 {
|
|
345
|
+
let entries =
|
|
346
|
+
componentChapters->Array.map(((stem, chapter)) =>
|
|
347
|
+
"(\"" ++ stem ++ "\", \"" ++ chapter ++ "\")"
|
|
348
|
+
)
|
|
349
|
+
ls->Array.push(" ~componentChapters=Dict.fromArray([" ++ entries->Array.join(", ") ++ "]),")
|
|
350
|
+
}
|
|
340
351
|
ls->Array.push(" )")
|
|
341
352
|
Some(ls)
|
|
342
353
|
}
|
|
@@ -493,7 +504,11 @@ let renderAwsWrapper = (~compositionNamespace: string): string => {
|
|
|
493
504
|
|
|
494
505
|
// ── Composition-variant render ───────────────────────────────────────────────
|
|
495
506
|
|
|
496
|
-
let renderComposition = (
|
|
507
|
+
let renderComposition = (
|
|
508
|
+
~config: Config.config,
|
|
509
|
+
~resolved: Pairing.resolved,
|
|
510
|
+
~componentChapters: array<(string, string)>,
|
|
511
|
+
): string => {
|
|
497
512
|
let lines: array<string> = []
|
|
498
513
|
|
|
499
514
|
// Header
|
|
@@ -616,6 +631,7 @@ let renderComposition = (~config: Config.config, ~resolved: Pairing.resolved): s
|
|
|
616
631
|
~inboundTranslationSlices=resolved.inboundTranslationSlices,
|
|
617
632
|
~extensions=resolved.extensions,
|
|
618
633
|
~extensionPoints=resolved.extensionPoints,
|
|
634
|
+
~componentChapters,
|
|
619
635
|
)
|
|
620
636
|
let hasPluginStructure = pluginStructureLines->Option.isSome
|
|
621
637
|
switch pluginStructureLines {
|
|
@@ -706,8 +722,28 @@ let render = (
|
|
|
706
722
|
): string => {
|
|
707
723
|
validateUniqueSpecStems(~discovered)
|
|
708
724
|
validateSliceTargets(~resolved)
|
|
725
|
+
// Chapter map, keyed by each component's spec stem (= its `Spec.name` for every
|
|
726
|
+
// graph-node kind, which is how `Plugin_Structure` looks the chapter up). Filtered
|
|
727
|
+
// to the actual component stems so body files (`_Behavior`, `_Projections`, …) —
|
|
728
|
+
// which `Discovery` also surfaces and which share their component's chapter — don't
|
|
729
|
+
// leak noise entries into the generated call. Tasks / extensions / extension points
|
|
730
|
+
// carry no chapter field, so they are excluded too.
|
|
731
|
+
let componentStems = Array.flat([
|
|
732
|
+
resolved.aggregates->Array.map(({spec}) => spec),
|
|
733
|
+
resolved.readModels->Array.map(({readModel}) => readModel),
|
|
734
|
+
resolved.stateChangeSlices,
|
|
735
|
+
resolved.stateViewSlices,
|
|
736
|
+
resolved.stateViewSlicesStream,
|
|
737
|
+
resolved.automationSlices,
|
|
738
|
+
resolved.outboundTranslationSlices,
|
|
739
|
+
resolved.inboundTranslationSlices,
|
|
740
|
+
])
|
|
741
|
+
let componentChapters =
|
|
742
|
+
Discovery.chaptersByStem(discovered)->Array.filter(((stem, _)) =>
|
|
743
|
+
componentStems->Array.includes(stem)
|
|
744
|
+
)
|
|
709
745
|
switch config.variant {
|
|
710
746
|
| Aws({compositionNamespace}) => renderAwsWrapper(~compositionNamespace)
|
|
711
|
-
| Composition => renderComposition(~config, ~resolved)
|
|
747
|
+
| Composition => renderComposition(~config, ~resolved, ~componentChapters)
|
|
712
748
|
}
|
|
713
749
|
}
|
|
@@ -4,6 +4,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
6
6
|
import * as Pairing$Reventless from "./Pairing.res.mjs";
|
|
7
|
+
import * as Discovery$Reventless from "./Discovery.res.mjs";
|
|
7
8
|
|
|
8
9
|
function stripSuffix(s, suffix) {
|
|
9
10
|
if (s.endsWith(suffix)) {
|
|
@@ -185,7 +186,7 @@ function renderTaskMakeParam(tasks) {
|
|
|
185
186
|
return " ~tasks=[" + entries.join(", ") + "],";
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
function renderPluginStructureCall(name, aggregates, readModels, stateViewSlices, stateViewSlicesStream, stateChangeSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, extensions, extensionPoints) {
|
|
189
|
+
function renderPluginStructureCall(name, aggregates, readModels, stateViewSlices, stateViewSlicesStream, stateChangeSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, extensions, extensionPoints, componentChapters) {
|
|
189
190
|
let epMappingStems = extensionPoints.flatMap(param => param.mappings);
|
|
190
191
|
let hasComponents = aggregates.length !== 0 || readModels.length !== 0 || stateViewSlices.length !== 0 || stateViewSlicesStream.length !== 0 || stateChangeSlices.length !== 0 || automationSlices.length !== 0 || outboundTranslationSlices.length !== 0 || inboundTranslationSlices.length !== 0 || extensions.length !== 0 || epMappingStems.length !== 0;
|
|
191
192
|
if (!hasComponents) {
|
|
@@ -237,6 +238,10 @@ function renderPluginStructureCall(name, aggregates, readModels, stateViewSlices
|
|
|
237
238
|
let entries$7 = epMappingStems.map(s => "module(" + s + ")");
|
|
238
239
|
ls.push(" ~extensionPoints=[" + entries$7.join(", ") + "],");
|
|
239
240
|
}
|
|
241
|
+
if (componentChapters.length !== 0) {
|
|
242
|
+
let entries$8 = componentChapters.map(param => "(\"" + param[0] + "\", \"" + param[1] + "\")");
|
|
243
|
+
ls.push(" ~componentChapters=Dict.fromArray([" + entries$8.join(", ") + "]),");
|
|
244
|
+
}
|
|
240
245
|
ls.push(" )");
|
|
241
246
|
return ls;
|
|
242
247
|
}
|
|
@@ -332,7 +337,7 @@ function renderAwsWrapper(compositionNamespace) {
|
|
|
332
337
|
].join("\n");
|
|
333
338
|
}
|
|
334
339
|
|
|
335
|
-
function renderComposition(config, resolved) {
|
|
340
|
+
function renderComposition(config, resolved, componentChapters) {
|
|
336
341
|
let lines = [];
|
|
337
342
|
lines.push("// AUTO-GENERATED — do not edit. Run `npm run generate` to update.");
|
|
338
343
|
lines.push("");
|
|
@@ -389,7 +394,7 @@ function renderComposition(config, resolved) {
|
|
|
389
394
|
lines.push(" // Extensions");
|
|
390
395
|
push(renderExtensions(resolved.extensions));
|
|
391
396
|
}
|
|
392
|
-
let pluginStructureLines = renderPluginStructureCall(config.name, resolved.aggregates, resolved.readModels, resolved.stateViewSlices, resolved.stateViewSlicesStream, resolved.stateChangeSlices, resolved.automationSlices, resolved.outboundTranslationSlices, resolved.inboundTranslationSlices, resolved.extensions, resolved.extensionPoints);
|
|
397
|
+
let pluginStructureLines = renderPluginStructureCall(config.name, resolved.aggregates, resolved.readModels, resolved.stateViewSlices, resolved.stateViewSlicesStream, resolved.stateChangeSlices, resolved.automationSlices, resolved.outboundTranslationSlices, resolved.inboundTranslationSlices, resolved.extensions, resolved.extensionPoints, componentChapters);
|
|
393
398
|
let hasPluginStructure = Stdlib_Option.isSome(pluginStructureLines);
|
|
394
399
|
if (pluginStructureLines !== undefined) {
|
|
395
400
|
pluginStructureLines.forEach(l => {
|
|
@@ -448,9 +453,20 @@ function renderComposition(config, resolved) {
|
|
|
448
453
|
function render(config, resolved, discovered) {
|
|
449
454
|
validateUniqueSpecStems(discovered);
|
|
450
455
|
validateSliceTargets(resolved);
|
|
456
|
+
let componentStems = [
|
|
457
|
+
resolved.aggregates.map(param => param.spec),
|
|
458
|
+
resolved.readModels.map(param => param.readModel),
|
|
459
|
+
resolved.stateChangeSlices,
|
|
460
|
+
resolved.stateViewSlices,
|
|
461
|
+
resolved.stateViewSlicesStream,
|
|
462
|
+
resolved.automationSlices,
|
|
463
|
+
resolved.outboundTranslationSlices,
|
|
464
|
+
resolved.inboundTranslationSlices
|
|
465
|
+
].flat();
|
|
466
|
+
let componentChapters = Discovery$Reventless.chaptersByStem(discovered).filter(param => componentStems.includes(param[0]));
|
|
451
467
|
let match = config.variant;
|
|
452
468
|
if (typeof match !== "object") {
|
|
453
|
-
return renderComposition(config, resolved);
|
|
469
|
+
return renderComposition(config, resolved, componentChapters);
|
|
454
470
|
} else {
|
|
455
471
|
return renderAwsWrapper(match.compositionNamespace);
|
|
456
472
|
}
|
|
@@ -22,6 +22,53 @@ type discoveredFile = {stem: string, componentType: componentType, epGroup: opti
|
|
|
22
22
|
|
|
23
23
|
let folderToComponentType = ComponentKind.folderToKind
|
|
24
24
|
|
|
25
|
+
// The intra-plugin grouping band ("chapter") a file belongs to, derived from its
|
|
26
|
+
// path relative to `src/`: the first directory segment that is not a recognised
|
|
27
|
+
// kind-folder. `src/<Chapter>/<Kind>/<Component>.res` → `Some("<Chapter>")`; a file
|
|
28
|
+
// directly under a kind-folder (`src/<Kind>/<Component>.res`) or at the src root →
|
|
29
|
+
// `None`. Uses the single-source `ComponentKind.isKindFolder`, so a chapter read
|
|
30
|
+
// here (build time, disk) agrees with the authoring tool's identical heuristic and
|
|
31
|
+
// with a chapter reflected off the deployed plugin structure. See
|
|
32
|
+
// docs/plans/deployed-chapter-grouping.md.
|
|
33
|
+
let chapterOf = (relPath: string): option<string> => {
|
|
34
|
+
let segments = relPath->String.split("/")
|
|
35
|
+
// Need at least one directory segment before the filename.
|
|
36
|
+
if segments->Array.length < 2 {
|
|
37
|
+
None
|
|
38
|
+
} else {
|
|
39
|
+
switch segments->Array.get(0) {
|
|
40
|
+
| Some(first) if !ComponentKind.isKindFolder(first) => Some(first)
|
|
41
|
+
| _ => None
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Deduplicated (stem → chapter) pairs across the discovered files, sorted by stem
|
|
47
|
+
// for deterministic codegen. Only files that carry a chapter are included; stems are
|
|
48
|
+
// unique within a plugin (validated), so keying by stem cannot collide. Body files
|
|
49
|
+
// (`_Behavior`, `_Projections`, …) share their component's chapter but are never
|
|
50
|
+
// looked up by `Spec.name`, so their presence is harmless.
|
|
51
|
+
let chaptersByStem = (discovered: array<discoveredFile>): array<(string, string)> => {
|
|
52
|
+
let byStem: Dict.t<string> = Dict.make()
|
|
53
|
+
discovered->Array.forEach(d =>
|
|
54
|
+
switch chapterOf(d.relPath) {
|
|
55
|
+
| Some(ch) => byStem->Dict.set(d.stem, ch)
|
|
56
|
+
| None => ()
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
byStem
|
|
60
|
+
->Dict.toArray
|
|
61
|
+
->Array.toSorted(((a, _), (b, _)) =>
|
|
62
|
+
if a < b {
|
|
63
|
+
-1.0
|
|
64
|
+
} else if a > b {
|
|
65
|
+
1.0
|
|
66
|
+
} else {
|
|
67
|
+
0.0
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
25
72
|
let isAlwaysExcludedDir = (name: string): bool =>
|
|
26
73
|
name === "Plugin" || name === "tests" || name === "lib"
|
|
27
74
|
|
|
@@ -4,6 +4,39 @@ import * as Nodepath from "node:path";
|
|
|
4
4
|
import * as ComponentKind$Reventless from "../components/ComponentKind.res.mjs";
|
|
5
5
|
import * as Generator_Node$Reventless from "./Generator_Node.res.mjs";
|
|
6
6
|
|
|
7
|
+
function chapterOf(relPath) {
|
|
8
|
+
let segments = relPath.split("/");
|
|
9
|
+
if (segments.length < 2) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
let first = segments[0];
|
|
13
|
+
if (first !== undefined && !ComponentKind$Reventless.isKindFolder(first)) {
|
|
14
|
+
return first;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function chaptersByStem(discovered) {
|
|
19
|
+
let byStem = {};
|
|
20
|
+
discovered.forEach(d => {
|
|
21
|
+
let ch = chapterOf(d.relPath);
|
|
22
|
+
if (ch !== undefined) {
|
|
23
|
+
byStem[d.stem] = ch;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return Object.entries(byStem).toSorted((param, param$1) => {
|
|
28
|
+
let b = param$1[0];
|
|
29
|
+
let a = param[0];
|
|
30
|
+
if (a < b) {
|
|
31
|
+
return -1.0;
|
|
32
|
+
} else if (a > b) {
|
|
33
|
+
return 1.0;
|
|
34
|
+
} else {
|
|
35
|
+
return 0.0;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
7
40
|
function isAlwaysExcludedDir(name) {
|
|
8
41
|
if (name === "Plugin" || name === "tests") {
|
|
9
42
|
return true;
|
|
@@ -151,6 +184,8 @@ let folderToComponentType = ComponentKind$Reventless.folderToKind;
|
|
|
151
184
|
|
|
152
185
|
export {
|
|
153
186
|
folderToComponentType,
|
|
187
|
+
chapterOf,
|
|
188
|
+
chaptersByStem,
|
|
154
189
|
isAlwaysExcludedDir,
|
|
155
190
|
matchesGlob,
|
|
156
191
|
isExcluded,
|
package/src/types/Message.res
CHANGED
|
@@ -140,15 +140,96 @@ type commandJson = {
|
|
|
140
140
|
delay?: int,
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// ── Schema-migration-on-read ──────────────────────────────────────────────────
|
|
144
|
+
// Nested `@schema` types (notably `pluginDefinition`/`pluginStructure`) gain fields
|
|
145
|
+
// over time — `kind`, `chapter`, `events`, `extensionPoints`, `apiExposed`, … Because
|
|
146
|
+
// those types are JSON-encoded inside union-variant payloads, every optional field must
|
|
147
|
+
// use the `js_nullable` (`T | null`) encoding: it is the only JSON-safe optional form,
|
|
148
|
+
// since `S.option`/`nullableAsOption` carry `undefined`, which fails sury's
|
|
149
|
+
// `jsonableValidation` inside a union variant. That encoding is *present-required on
|
|
150
|
+
// decode*, so ONE message persisted before a field was added SuryError-bricks decode. For
|
|
151
|
+
// an aggregate that rehydrates from its own event log (the Plugin lifecycle aggregate),
|
|
152
|
+
// that single event then freezes EVERY later heartbeat/redetect/connect on that instance
|
|
153
|
+
// — a silent lifecycle freeze with no error surfaced near the operator.
|
|
154
|
+
//
|
|
155
|
+
// We heal on read. Strict decode stays the fast path (unchanged for every current
|
|
156
|
+
// message); only when it throws do we schema-guide the raw JSON and retry once. The fill
|
|
157
|
+
// walks the target sury schema and inserts, for any absent field, the value that field's
|
|
158
|
+
// schema expects: `null` for a `T | null` union (→ `None`), `[]` for a missing array, the
|
|
159
|
+
// first variant of a mandatory enum (`kind` → `Domain`), and a filled `{}` for a missing
|
|
160
|
+
// nested object. It descends only into values actually present, matches tagged-union
|
|
161
|
+
// members by their `TAG` const, is purely additive (clones via a JSON round-trip; never
|
|
162
|
+
// re-encodes through the schema), is idempotent on valid data, and falls back to the
|
|
163
|
+
// ORIGINAL error when the fill doesn't resolve the failure — so genuine corruption still
|
|
164
|
+
// surfaces. See docs/plans/platform-infrastructure-in-plugin-list.md (durable fix option 2).
|
|
165
|
+
let fillMissingDefaults: (S.t<'a>, JSON.t) => JSON.t = %raw(`function(schema, json){
|
|
166
|
+
function isSchema(x){ return x && typeof x === "object" && typeof x.type === "string"; }
|
|
167
|
+
function firstConst(anyOf){ var m=(anyOf||[]).find(function(s){return s.const!==undefined;}); return m ? m.const : undefined; }
|
|
168
|
+
function fill(schema, value){
|
|
169
|
+
if(!isSchema(schema)) return value;
|
|
170
|
+
switch(schema.type){
|
|
171
|
+
case "object": {
|
|
172
|
+
if(value===undefined){ value={}; }
|
|
173
|
+
else if(value===null || typeof value!=="object" || Array.isArray(value)) return value;
|
|
174
|
+
var items=schema.items||[];
|
|
175
|
+
for(var i=0;i<items.length;i++){ var it=items[i]; value[it.location]=fill(it.schema, value[it.location]); }
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
case "array": {
|
|
179
|
+
if(Array.isArray(value)){ var el=schema.additionalItems; return isSchema(el) ? value.map(function(v){return fill(el,v);}) : value; }
|
|
180
|
+
if(value===undefined) return [];
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
case "union": {
|
|
184
|
+
var has=schema.has||{};
|
|
185
|
+
if(value===undefined){
|
|
186
|
+
if(has.null) return null;
|
|
187
|
+
var c=firstConst(schema.anyOf); if(c!==undefined) return c;
|
|
188
|
+
var obj=(schema.anyOf||[]).find(function(s){return s.type==="object";}); if(obj) return fill(obj,{});
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
if(value===null) return null;
|
|
192
|
+
var members=schema.anyOf||[];
|
|
193
|
+
if(Array.isArray(value)){ var a=members.find(function(s){return s.type==="array";}); return a ? fill(a,value) : value; }
|
|
194
|
+
if(typeof value==="object"){
|
|
195
|
+
var m=members.find(function(s){return s.type==="object" && (s.items||[]).some(function(it){return it.location==="TAG" && it.schema.const===value.TAG;});});
|
|
196
|
+
if(!m) m=members.find(function(s){return s.type==="object";});
|
|
197
|
+
return m ? fill(m,value) : value;
|
|
198
|
+
}
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
default: return value;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Clone via JSON round-trip (json is already pure JSON) so the caller's value is never mutated.
|
|
205
|
+
return fill(schema, JSON.parse(JSON.stringify(json)));
|
|
206
|
+
}`)
|
|
207
|
+
|
|
208
|
+
// Strict parse with a single schema-migration-on-read retry (see fillMissingDefaults).
|
|
209
|
+
let parseJsonTolerant = (json, schema) =>
|
|
210
|
+
switch json->S.parseJsonOrThrow(schema) {
|
|
211
|
+
| value => value
|
|
212
|
+
| exception firstErr =>
|
|
213
|
+
switch fillMissingDefaults(schema, json)->S.parseJsonOrThrow(schema) {
|
|
214
|
+
| value => value
|
|
215
|
+
| exception _ => throw(firstErr)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
143
219
|
/**
|
|
144
|
-
Decode a JSON value into `'a` using a sury schema.
|
|
220
|
+
Decode a JSON value into `'a` using a sury schema.
|
|
221
|
+
|
|
222
|
+
Strict decode is the fast path; on failure it applies a single schema-migration-on-read
|
|
223
|
+
retry (see `fillMissingDefaults`) so a message persisted before a nested `@schema` field
|
|
224
|
+
existed still decodes instead of bricking. Re-throws the original error if the fill does
|
|
225
|
+
not resolve the failure.
|
|
145
226
|
|
|
146
227
|
@example
|
|
147
228
|
```rescript
|
|
148
229
|
let event = json->Message.decode(Category.eventSchema)
|
|
149
230
|
```
|
|
150
231
|
*/
|
|
151
|
-
let decode = (json, schema: S.t<'a>) => json->
|
|
232
|
+
let decode = (json, schema: S.t<'a>) => json->parseJsonTolerant(schema)
|
|
152
233
|
|
|
153
234
|
/**
|
|
154
235
|
Encode a value to JSON using a sury schema.
|
|
@@ -170,9 +251,11 @@ let toEventSchema' = (idSchema, eventSchema) =>
|
|
|
170
251
|
event: s.field("event", eventSchema),
|
|
171
252
|
})
|
|
172
253
|
|
|
173
|
-
/** Decode a raw event JSON envelope into a typed `event'<'id, 'event>`.
|
|
254
|
+
/** Decode a raw event JSON envelope into a typed `event'<'id, 'event>`. Tolerant on
|
|
255
|
+
read (see `fillMissingDefaults`) so envelopes persisted before a nested field existed
|
|
256
|
+
still decode. */
|
|
174
257
|
let decodeEvent' = (json, idSchema, eventSchema) =>
|
|
175
|
-
json->
|
|
258
|
+
json->parseJsonTolerant(toEventSchema'(idSchema, eventSchema))
|
|
176
259
|
|
|
177
260
|
/** Extract the variant constructor name from a sury-encoded variant JSON. */
|
|
178
261
|
let variantNameOfJson = json =>
|
|
@@ -35,7 +35,62 @@ let commandJsonSchema = S.schema(s => ({
|
|
|
35
35
|
delay: s.m(S.option(S.int))
|
|
36
36
|
}));
|
|
37
37
|
|
|
38
|
-
let
|
|
38
|
+
let fillMissingDefaults = (function(schema, json){
|
|
39
|
+
function isSchema(x){ return x && typeof x === "object" && typeof x.type === "string"; }
|
|
40
|
+
function firstConst(anyOf){ var m=(anyOf||[]).find(function(s){return s.const!==undefined;}); return m ? m.const : undefined; }
|
|
41
|
+
function fill(schema, value){
|
|
42
|
+
if(!isSchema(schema)) return value;
|
|
43
|
+
switch(schema.type){
|
|
44
|
+
case "object": {
|
|
45
|
+
if(value===undefined){ value={}; }
|
|
46
|
+
else if(value===null || typeof value!=="object" || Array.isArray(value)) return value;
|
|
47
|
+
var items=schema.items||[];
|
|
48
|
+
for(var i=0;i<items.length;i++){ var it=items[i]; value[it.location]=fill(it.schema, value[it.location]); }
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
case "array": {
|
|
52
|
+
if(Array.isArray(value)){ var el=schema.additionalItems; return isSchema(el) ? value.map(function(v){return fill(el,v);}) : value; }
|
|
53
|
+
if(value===undefined) return [];
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
case "union": {
|
|
57
|
+
var has=schema.has||{};
|
|
58
|
+
if(value===undefined){
|
|
59
|
+
if(has.null) return null;
|
|
60
|
+
var c=firstConst(schema.anyOf); if(c!==undefined) return c;
|
|
61
|
+
var obj=(schema.anyOf||[]).find(function(s){return s.type==="object";}); if(obj) return fill(obj,{});
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if(value===null) return null;
|
|
65
|
+
var members=schema.anyOf||[];
|
|
66
|
+
if(Array.isArray(value)){ var a=members.find(function(s){return s.type==="array";}); return a ? fill(a,value) : value; }
|
|
67
|
+
if(typeof value==="object"){
|
|
68
|
+
var m=members.find(function(s){return s.type==="object" && (s.items||[]).some(function(it){return it.location==="TAG" && it.schema.const===value.TAG;});});
|
|
69
|
+
if(!m) m=members.find(function(s){return s.type==="object";});
|
|
70
|
+
return m ? fill(m,value) : value;
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
default: return value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Clone via JSON round-trip (json is already pure JSON) so the caller's value is never mutated.
|
|
78
|
+
return fill(schema, JSON.parse(JSON.stringify(json)));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function parseJsonTolerant(json, schema) {
|
|
82
|
+
try {
|
|
83
|
+
return S.parseJsonOrThrow(json, schema);
|
|
84
|
+
} catch (firstErr) {
|
|
85
|
+
try {
|
|
86
|
+
return S.parseJsonOrThrow(fillMissingDefaults(schema, json), schema);
|
|
87
|
+
} catch (exn) {
|
|
88
|
+
throw firstErr;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let decode = parseJsonTolerant;
|
|
39
94
|
|
|
40
95
|
let encode = S.reverseConvertToJsonOrThrow;
|
|
41
96
|
|
|
@@ -50,7 +105,7 @@ function toEventSchema$p(idSchema, eventSchema) {
|
|
|
50
105
|
}
|
|
51
106
|
|
|
52
107
|
function decodeEvent$p(json, idSchema, eventSchema) {
|
|
53
|
-
return
|
|
108
|
+
return parseJsonTolerant(json, toEventSchema$p(idSchema, eventSchema));
|
|
54
109
|
}
|
|
55
110
|
|
|
56
111
|
function variantNameOfJson(json) {
|
|
@@ -97,6 +152,8 @@ export {
|
|
|
97
152
|
contextSchema,
|
|
98
153
|
statusChangeSchema,
|
|
99
154
|
commandJsonSchema,
|
|
155
|
+
fillMissingDefaults,
|
|
156
|
+
parseJsonTolerant,
|
|
100
157
|
decode,
|
|
101
158
|
encode,
|
|
102
159
|
InvalidEvent,
|