@reventlessdev/reventless-spec 3.0.0-alpha.72 → 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,13 @@
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
+
6
13
  # 3.0.0-alpha.72 (2026-07-10)
7
14
 
8
15
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.72",
3
+ "version": "3.0.0-alpha.73",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -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,