@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.71",
3
+ "version": "3.0.0-alpha.73",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -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 = (~config: Config.config, ~resolved: Pairing.resolved): string => {
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,
@@ -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. Throws on parse failure.
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->S.parseJsonOrThrow(schema)
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->S.parseJsonOrThrow(toEventSchema'(idSchema, eventSchema))
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 decode = S.parseJsonOrThrow;
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 S.parseJsonOrThrow(json, toEventSchema$p(idSchema, eventSchema));
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,