@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 +7 -0
- package/package.json +1 -1
- package/src/types/Message.res +87 -4
- package/src/types/Message.res.mjs +59 -2
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
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,
|