@reventlessdev/reventless-spec 3.0.0-alpha.90 → 3.0.0-alpha.91
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,14 @@
|
|
|
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.91 (2026-08-01)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **spec,core:** heal a missing scalar on read ([de9a98e](https://github.com/ReventlessDev/reventless-core/commit/de9a98ec5fe11ec19bef80626e99244d9c30a6b1))
|
|
11
|
+
* **spec,core:** make the storageRef annotation optional, as its readers already are ([c8477c5](https://github.com/ReventlessDev/reventless-core/commit/c8477c5c34384b864c06716dc5896310629dc349))
|
|
12
|
+
|
|
13
|
+
|
|
6
14
|
# 3.0.0-alpha.90 (2026-08-01)
|
|
7
15
|
|
|
8
16
|
### Features
|
package/package.json
CHANGED
|
@@ -56,7 +56,7 @@ let fromStructure = (structure: Plugin.pluginStructure): t => {
|
|
|
56
56
|
key,
|
|
57
57
|
declaredBy: declarations->Array.filterMap(d =>
|
|
58
58
|
d.store == key
|
|
59
|
-
? Some({component: d.component, field: d.field, annotation: d.annotation})
|
|
59
|
+
? Some({component: d.component, field: d.field, annotation: ?d.annotation})
|
|
60
60
|
: None
|
|
61
61
|
),
|
|
62
62
|
}),
|
|
@@ -395,13 +395,26 @@ than reconstructed: only here is the owning plugin unambiguous, so anything
|
|
|
395
395
|
downstream would have to infer it by comparing a registered plugin name with
|
|
396
396
|
whatever name a deploy manifest happened to use, and those were never required
|
|
397
397
|
to match.
|
|
398
|
+
|
|
399
|
+
Optional for the same reason `CapabilityManifest.provenance` and
|
|
400
|
+
`PlatformCodegen.provenance` — the two places this value travels onward to —
|
|
401
|
+
already declare it optional: an event stored before the field existed cannot
|
|
402
|
+
say what the source said, and a reader that cannot say omits the claim rather
|
|
403
|
+
than inventing one. Every definition emitted now carries it.
|
|
404
|
+
|
|
405
|
+
That is not a stylistic preference. It was first added here as a required
|
|
406
|
+
`string` while events written without it were already stored, and since the
|
|
407
|
+
lifecycle aggregate replays its own log before every decision, those events
|
|
408
|
+
stopped decoding and the plugin's registration froze for two days. `None` is
|
|
409
|
+
also the honest value: `""` would assert the author wrote an empty annotation.
|
|
410
|
+
See the schema-evolution note on `pluginStructure` below.
|
|
398
411
|
*/
|
|
399
412
|
@schema
|
|
400
413
|
type requiredStoreDeclaration = {
|
|
401
414
|
store: string,
|
|
402
415
|
component: string,
|
|
403
416
|
field: string,
|
|
404
|
-
annotation: string
|
|
417
|
+
annotation: @s.matches(stringOptionSchema) option<string>,
|
|
405
418
|
}
|
|
406
419
|
|
|
407
420
|
let requiredStoreDeclarationArrayOptionSchema = _jsNullable(
|
|
@@ -409,6 +422,32 @@ let requiredStoreDeclarationArrayOptionSchema = _jsNullable(
|
|
|
409
422
|
(),
|
|
410
423
|
)
|
|
411
424
|
|
|
425
|
+
/**
|
|
426
|
+
Adding a field here? It has to be a shape a stale event can be healed into.
|
|
427
|
+
|
|
428
|
+
Everything reachable from `pluginDefinition` is persisted in the Plugin lifecycle
|
|
429
|
+
aggregate's event log, and that aggregate replays its own log before every
|
|
430
|
+
decision. Events already written do not have your new field, so if decoding one
|
|
431
|
+
of them throws, the aggregate cannot process ANY command for that plugin — it
|
|
432
|
+
stops answering the deploy handshake and its registration silently freezes at
|
|
433
|
+
whatever version connected last.
|
|
434
|
+
|
|
435
|
+
`Message.parseJsonTolerant` heals a stale event on read, but only for shapes it
|
|
436
|
+
can supply a value for: a `T | null` union (→ `None`), an array (→ `[]`), a
|
|
437
|
+
mandatory enum (→ first variant), a nested object (→ recursively filled), and a
|
|
438
|
+
scalar (→ `""` / `0` / `false`, logged as a warning because it is a fabricated
|
|
439
|
+
value, not a derived one).
|
|
440
|
+
|
|
441
|
+
So: **prefer `js_nullable` for anything genuinely optional**, and expect a scalar
|
|
442
|
+
addition to show up as a warning in the logs of every deployment that still holds
|
|
443
|
+
older events. A field that can be absent should say so in its type rather than
|
|
444
|
+
lean on the healer.
|
|
445
|
+
|
|
446
|
+
The regression suite for this is `PluginLifecycleCorpusTest` in reventless-core,
|
|
447
|
+
which decodes frozen payloads captured off a deployed log. If it goes red naming
|
|
448
|
+
your field, re-shape the field — do not re-cut the fixtures. Background:
|
|
449
|
+
`docs/analysis/plugin-definition-schema-evolution-wedge.md`.
|
|
450
|
+
*/
|
|
412
451
|
@schema
|
|
413
452
|
type pluginStructure = {
|
|
414
453
|
readModels: array<queryableDef>,
|
|
@@ -182,7 +182,7 @@ let requiredStoreDeclarationSchema = S.schema(s => ({
|
|
|
182
182
|
store: s.m(S.string),
|
|
183
183
|
component: s.m(S.string),
|
|
184
184
|
field: s.m(S.string),
|
|
185
|
-
annotation: s.m(
|
|
185
|
+
annotation: s.m(stringOptionSchema)
|
|
186
186
|
}));
|
|
187
187
|
|
|
188
188
|
let requiredStoreDeclarationArrayOptionSchema = SuryResMjs.js_nullable(S.array(requiredStoreDeclarationSchema));
|
package/src/types/Message.res
CHANGED
|
@@ -156,27 +156,53 @@ type commandJson = {
|
|
|
156
156
|
// message); only when it throws do we schema-guide the raw JSON and retry once. The fill
|
|
157
157
|
// walks the target sury schema and inserts, for any absent field, the value that field's
|
|
158
158
|
// schema expects: `null` for a `T | null` union (→ `None`), `[]` for a missing array, the
|
|
159
|
-
// first variant of a mandatory enum (`kind` → `Domain`),
|
|
160
|
-
// nested object. It descends only into values
|
|
161
|
-
// members by their `TAG` const, is purely additive
|
|
162
|
-
// re-encodes through the schema), is idempotent on
|
|
163
|
-
// ORIGINAL error when the fill doesn't resolve the
|
|
164
|
-
//
|
|
165
|
-
|
|
159
|
+
// first variant of a mandatory enum (`kind` → `Domain`), a filled `{}` for a missing
|
|
160
|
+
// nested object, and a zero value for a missing scalar. It descends only into values
|
|
161
|
+
// actually present, matches tagged-union members by their `TAG` const, is purely additive
|
|
162
|
+
// (clones via a JSON round-trip; never re-encodes through the schema), is idempotent on
|
|
163
|
+
// valid data, and falls back to the ORIGINAL error when the fill doesn't resolve the
|
|
164
|
+
// failure — so genuine corruption still surfaces.
|
|
165
|
+
// See docs/plans/platform-infrastructure-in-plugin-list.md (durable fix option 2).
|
|
166
|
+
//
|
|
167
|
+
// The scalar arm is the odd one out and is deliberately noisy. Every other fill is
|
|
168
|
+
// *derived* — the schema states what an absent value means, and the fill supplies exactly
|
|
169
|
+
// that. A scalar has no such statement, so `""` / `0` / `false` is a value this code
|
|
170
|
+
// invented: right for a descriptive field added later, wrong for a field that carries
|
|
171
|
+
// meaning. It also widens what can be masked, since a genuinely truncated payload now
|
|
172
|
+
// decodes where it used to throw. Every scalar fill is therefore reported to the caller
|
|
173
|
+
// and logged, so healing stays findable instead of becoming the silent default. Without
|
|
174
|
+
// it, a required-scalar addition freezes an aggregate outright — see
|
|
175
|
+
// docs/analysis/plugin-definition-schema-evolution-wedge.md.
|
|
176
|
+
//
|
|
177
|
+
// `bigint` is excluded on purpose: it has no JSON representation, so any value invented
|
|
178
|
+
// here would fail the retry anyway and mask the real error path.
|
|
179
|
+
//
|
|
180
|
+
// `scalarFills` is an out-parameter: the walker pushes `path := value` for each scalar it
|
|
181
|
+
// invented.
|
|
182
|
+
let fillMissingDefaults: (S.t<'a>, JSON.t, array<string>) => JSON.t = %raw(`function(schema, json, scalarFills){
|
|
166
183
|
function isSchema(x){ return x && typeof x === "object" && typeof x.type === "string"; }
|
|
167
184
|
function firstConst(anyOf){ var m=(anyOf||[]).find(function(s){return s.const!==undefined;}); return m ? m.const : undefined; }
|
|
168
|
-
function
|
|
185
|
+
function scalarDefault(schema){
|
|
186
|
+
if(schema.const!==undefined) return schema.const;
|
|
187
|
+
switch(schema.type){
|
|
188
|
+
case "string": return "";
|
|
189
|
+
case "number": return 0;
|
|
190
|
+
case "boolean": return false;
|
|
191
|
+
default: return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function fill(schema, value, path){
|
|
169
195
|
if(!isSchema(schema)) return value;
|
|
170
196
|
switch(schema.type){
|
|
171
197
|
case "object": {
|
|
172
198
|
if(value===undefined){ value={}; }
|
|
173
199
|
else if(value===null || typeof value!=="object" || Array.isArray(value)) return value;
|
|
174
200
|
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]); }
|
|
201
|
+
for(var i=0;i<items.length;i++){ var it=items[i]; value[it.location]=fill(it.schema, value[it.location], path+"."+it.location); }
|
|
176
202
|
return value;
|
|
177
203
|
}
|
|
178
204
|
case "array": {
|
|
179
|
-
if(Array.isArray(value)){ var el=schema.additionalItems; return isSchema(el) ? value.map(function(v){return fill(el,v);}) : value; }
|
|
205
|
+
if(Array.isArray(value)){ var el=schema.additionalItems; return isSchema(el) ? value.map(function(v,ix){return fill(el,v,path+"["+ix+"]");}) : value; }
|
|
180
206
|
if(value===undefined) return [];
|
|
181
207
|
return value;
|
|
182
208
|
}
|
|
@@ -185,24 +211,30 @@ let fillMissingDefaults: (S.t<'a>, JSON.t) => JSON.t = %raw(`function(schema, js
|
|
|
185
211
|
if(value===undefined){
|
|
186
212
|
if(has.null) return null;
|
|
187
213
|
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,{});
|
|
214
|
+
var obj=(schema.anyOf||[]).find(function(s){return s.type==="object";}); if(obj) return fill(obj,{},path);
|
|
189
215
|
return undefined;
|
|
190
216
|
}
|
|
191
217
|
if(value===null) return null;
|
|
192
218
|
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; }
|
|
219
|
+
if(Array.isArray(value)){ var a=members.find(function(s){return s.type==="array";}); return a ? fill(a,value,path) : value; }
|
|
194
220
|
if(typeof value==="object"){
|
|
195
221
|
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
222
|
if(!m) m=members.find(function(s){return s.type==="object";});
|
|
197
|
-
return m ? fill(m,value) : value;
|
|
223
|
+
return m ? fill(m,value,path) : value;
|
|
198
224
|
}
|
|
199
225
|
return value;
|
|
200
226
|
}
|
|
201
|
-
default:
|
|
227
|
+
default: {
|
|
228
|
+
if(value!==undefined) return value;
|
|
229
|
+
var d=scalarDefault(schema);
|
|
230
|
+
if(d===undefined) return value;
|
|
231
|
+
scalarFills.push(path + " := " + JSON.stringify(d));
|
|
232
|
+
return d;
|
|
233
|
+
}
|
|
202
234
|
}
|
|
203
235
|
}
|
|
204
236
|
// 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)));
|
|
237
|
+
return fill(schema, JSON.parse(JSON.stringify(json)), "");
|
|
206
238
|
}`)
|
|
207
239
|
|
|
208
240
|
// Strict parse with a single schema-migration-on-read retry (see fillMissingDefaults).
|
|
@@ -210,8 +242,22 @@ let parseJsonTolerant = (json, schema) =>
|
|
|
210
242
|
switch json->S.parseJsonOrThrow(schema) {
|
|
211
243
|
| value => value
|
|
212
244
|
| exception firstErr =>
|
|
213
|
-
|
|
214
|
-
|
|
245
|
+
let scalarFills = []
|
|
246
|
+
switch fillMissingDefaults(schema, json, scalarFills)->S.parseJsonOrThrow(schema) {
|
|
247
|
+
| value =>
|
|
248
|
+
// Only the invented values are worth a line. A heal that used nothing but
|
|
249
|
+
// schema-derived defaults is the mechanism working as designed.
|
|
250
|
+
if scalarFills->Array.length > 0 {
|
|
251
|
+
Console.warn(
|
|
252
|
+
`[reventless] decoded a stored message by inventing ${scalarFills
|
|
253
|
+
->Array.length
|
|
254
|
+
->Int.toString} missing scalar field(s): ${scalarFills->Array.join(
|
|
255
|
+
", ",
|
|
256
|
+
)}. A required scalar was added to a persisted type after this message was ` ++
|
|
257
|
+
`written; the value above is fabricated, not recovered. Prefer a js_nullable (T | null) field.`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
value
|
|
215
261
|
| exception _ => throw(firstErr)
|
|
216
262
|
}
|
|
217
263
|
}
|
|
@@ -35,21 +35,30 @@ let commandJsonSchema = S.schema(s => ({
|
|
|
35
35
|
delay: s.m(S.option(S.int))
|
|
36
36
|
}));
|
|
37
37
|
|
|
38
|
-
let fillMissingDefaults = (function(schema, json){
|
|
38
|
+
let fillMissingDefaults = (function(schema, json, scalarFills){
|
|
39
39
|
function isSchema(x){ return x && typeof x === "object" && typeof x.type === "string"; }
|
|
40
40
|
function firstConst(anyOf){ var m=(anyOf||[]).find(function(s){return s.const!==undefined;}); return m ? m.const : undefined; }
|
|
41
|
-
function
|
|
41
|
+
function scalarDefault(schema){
|
|
42
|
+
if(schema.const!==undefined) return schema.const;
|
|
43
|
+
switch(schema.type){
|
|
44
|
+
case "string": return "";
|
|
45
|
+
case "number": return 0;
|
|
46
|
+
case "boolean": return false;
|
|
47
|
+
default: return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function fill(schema, value, path){
|
|
42
51
|
if(!isSchema(schema)) return value;
|
|
43
52
|
switch(schema.type){
|
|
44
53
|
case "object": {
|
|
45
54
|
if(value===undefined){ value={}; }
|
|
46
55
|
else if(value===null || typeof value!=="object" || Array.isArray(value)) return value;
|
|
47
56
|
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]); }
|
|
57
|
+
for(var i=0;i<items.length;i++){ var it=items[i]; value[it.location]=fill(it.schema, value[it.location], path+"."+it.location); }
|
|
49
58
|
return value;
|
|
50
59
|
}
|
|
51
60
|
case "array": {
|
|
52
|
-
if(Array.isArray(value)){ var el=schema.additionalItems; return isSchema(el) ? value.map(function(v){return fill(el,v);}) : value; }
|
|
61
|
+
if(Array.isArray(value)){ var el=schema.additionalItems; return isSchema(el) ? value.map(function(v,ix){return fill(el,v,path+"["+ix+"]");}) : value; }
|
|
53
62
|
if(value===undefined) return [];
|
|
54
63
|
return value;
|
|
55
64
|
}
|
|
@@ -58,35 +67,47 @@ let fillMissingDefaults = (function(schema, json){
|
|
|
58
67
|
if(value===undefined){
|
|
59
68
|
if(has.null) return null;
|
|
60
69
|
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,{});
|
|
70
|
+
var obj=(schema.anyOf||[]).find(function(s){return s.type==="object";}); if(obj) return fill(obj,{},path);
|
|
62
71
|
return undefined;
|
|
63
72
|
}
|
|
64
73
|
if(value===null) return null;
|
|
65
74
|
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; }
|
|
75
|
+
if(Array.isArray(value)){ var a=members.find(function(s){return s.type==="array";}); return a ? fill(a,value,path) : value; }
|
|
67
76
|
if(typeof value==="object"){
|
|
68
77
|
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
78
|
if(!m) m=members.find(function(s){return s.type==="object";});
|
|
70
|
-
return m ? fill(m,value) : value;
|
|
79
|
+
return m ? fill(m,value,path) : value;
|
|
71
80
|
}
|
|
72
81
|
return value;
|
|
73
82
|
}
|
|
74
|
-
default:
|
|
83
|
+
default: {
|
|
84
|
+
if(value!==undefined) return value;
|
|
85
|
+
var d=scalarDefault(schema);
|
|
86
|
+
if(d===undefined) return value;
|
|
87
|
+
scalarFills.push(path + " := " + JSON.stringify(d));
|
|
88
|
+
return d;
|
|
89
|
+
}
|
|
75
90
|
}
|
|
76
91
|
}
|
|
77
92
|
// 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)));
|
|
93
|
+
return fill(schema, JSON.parse(JSON.stringify(json)), "");
|
|
79
94
|
});
|
|
80
95
|
|
|
81
96
|
function parseJsonTolerant(json, schema) {
|
|
82
97
|
try {
|
|
83
98
|
return S.parseJsonOrThrow(json, schema);
|
|
84
99
|
} catch (firstErr) {
|
|
100
|
+
let scalarFills = [];
|
|
101
|
+
let value;
|
|
85
102
|
try {
|
|
86
|
-
|
|
103
|
+
value = S.parseJsonOrThrow(fillMissingDefaults(schema, json, scalarFills), schema);
|
|
87
104
|
} catch (exn) {
|
|
88
105
|
throw firstErr;
|
|
89
106
|
}
|
|
107
|
+
if (scalarFills.length !== 0) {
|
|
108
|
+
console.warn(`[reventless] decoded a stored message by inventing ` + scalarFills.length.toString() + ` missing scalar field(s): ` + scalarFills.join(", ") + `. A required scalar was added to a persisted type after this message was written; the value above is fabricated, not recovered. Prefer a js_nullable (T | null) field.`);
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
90
111
|
}
|
|
91
112
|
}
|
|
92
113
|
|