@reventlessdev/reventless-spec 3.0.0-alpha.89 → 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,21 @@
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
+
14
+ # 3.0.0-alpha.90 (2026-08-01)
15
+
16
+ ### Features
17
+
18
+ * **spec:** add DateRange semantic type ([d85b6cc](https://github.com/ReventlessDev/reventless-core/commit/d85b6cc18241644905241df2abd99949dd758059))
19
+
20
+
6
21
  # 3.0.0-alpha.89 (2026-07-31)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.89",
3
+ "version": "3.0.0-alpha.91",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -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(S.string)
185
+ annotation: s.m(stringOptionSchema)
186
186
  }));
187
187
 
188
188
  let requiredStoreDeclarationArrayOptionSchema = SuryResMjs.js_nullable(S.array(requiredStoreDeclarationSchema));
@@ -0,0 +1,148 @@
1
+ /**
2
+ A span of time: two ISO-8601 instants, a start and an end, as one value.
3
+
4
+ ## Why the pair is the value, not two fields beside each other
5
+
6
+ Before this type a span was a *guess*. A view that wanted a calendar, a timeline
7
+ or a gantt bar asked which fields were date-like and took the first named
8
+ `start…` and the first named `end…`, independently. Three things go wrong and
9
+ all three are silent: two intervals in one row mispair across each other (a bar
10
+ drawn from one interval's start to the other's end); an interval named anything
11
+ else — `checkIn`/`checkOut`, `from`/`to` — is invisible; and a lone `started…`
12
+ point pairs with whatever `end…` is nearby. Making the two instants one value
13
+ removes the pairing question: a consumer either holds the span or it does not.
14
+
15
+ The parts keep their own `dateTime` marker, so a walker that only understands
16
+ date-times still sees them, and the whole carries `dateRange` besides. That is
17
+ the same layering `Money` uses — the amount keeps being a number, the composite
18
+ adds the meaning the number cannot carry.
19
+
20
+ ## `[start, end)` — end exclusive
21
+
22
+ A range runs from `start` up to but not including `end`. `09:00–11:00` and
23
+ `11:00–13:00` are adjacent, not overlapping, and an all-day grid needs no
24
+ off-by-one-millisecond convention invented per consumer. This is a decision, not
25
+ a default: `overlaps` and `contains` are the only places it is written, and no
26
+ layout may re-decide it.
27
+
28
+ ## Why the ordering rule is not enforced at decode
29
+
30
+ `start <= end` relates two fields, so — unlike `Money`'s wholeness, which is a
31
+ property of one field and rides on that field's schema — it is a *record-level*
32
+ invariant. sury 11.0.0-alpha.4 miscompiles a refinement wrapping a record schema
33
+ (it hoists the result object above the field reads, so parse and serialize throw
34
+ `Cannot access 'v0' before initialization`), and the pin has not moved. So the
35
+ rule lives in `validate`/`make` and **the schema does not enforce it at decode**.
36
+
37
+ This is the first semantic type in this library whose invariant the boundary does
38
+ not check: `Money` rejects a fractional minor unit on the way in; `DateRange`
39
+ will accept a range that ends before it starts if one is ever written. A reader
40
+ who assumes parity with `Money` assumes wrong. When sury fixes the record
41
+ refinement the rule moves into the schema and `validate` stays as its single
42
+ definition — the relationship `Money.validateAmount` has with `amountSchema`.
43
+
44
+ Parsing is `Date.fromString` on each instant. A range whose strings do not parse
45
+ is a decode-time problem the `DateTime` marker does not currently catch either,
46
+ so there is no second validation layer here — a reversed *parseable* range is
47
+ what `validate` catches, and an unparseable one is out of both their scope.
48
+
49
+ ## How a field declares it
50
+
51
+ The field's declared type *is* `DateRange.t`, and sury-ppx resolves it to this
52
+ module's `schema`:
53
+
54
+ ```rescript
55
+ @schema type state = {
56
+ orderId: string,
57
+ deliveryWindow: option<Reventless.DateRange.t>,
58
+ }
59
+ ```
60
+
61
+ which serializes as `{"start": "2026-03-02T09:00:00Z", "end": "2026-03-02T11:00:00Z"}`.
62
+
63
+ **Introduced as a new optional field it is additive** — an absent optional
64
+ decodes to `None` for events written before it existed, so no upcaster and no
65
+ projection rebuild. It costs a log something only if it *collapses* an existing
66
+ `start*`/`end*` pair, which rewrites the wire shape the way `Money` rewrote
67
+ `price: float`. That collapse belongs to whoever builds the upcaster.
68
+ */
69
+
70
+ @schema
71
+ type t = {
72
+ /** The instant the range opens, inclusive. */
73
+ start: @s.matches(DateTime.string) string,
74
+ /** The instant the range closes, **exclusive** — the range does not contain
75
+ it. `@as("end")` puts `end` on the wire (where the UI's own `GanttChart`
76
+ already spells it that way); `end_` is the source spelling because `end`
77
+ is awkward as a bare ReScript field. */
78
+ @as("end") end_: @s.matches(DateTime.string) string,
79
+ }
80
+
81
+ /** The sury schema for a date-range field, carrying the `dateRange` semantic.
82
+
83
+ Shadows the schema sury-ppx derived from the type above: the derived one is
84
+ the shape, and this adds the marker the shape cannot carry. The ordering rule
85
+ is deliberately *not* refined in here — see the module doc. */
86
+ let schema: S.t<t> = schema->Semantic.mark(~id=Semantic.Id.dateRange)
87
+
88
+ /** An instant as milliseconds since the epoch — `NaN` if it does not parse. The
89
+ one place a range's strings become numbers, so end-exclusivity and the
90
+ ordering rule are all expressed against a single parse. */
91
+ let millis = (instant: string): float => instant->Date.fromString->Date.getTime
92
+
93
+ /**
94
+ Validate a range's ordering, saying why when it is reversed.
95
+
96
+ The single statement of the `start <= end` rule; `make` is derived from it, and
97
+ `schema` will be once sury's record refinement is fixed. An unparseable instant
98
+ is not caught here (see the module doc) — a reversed range means two instants
99
+ that both parse, the earlier one second.
100
+ */
101
+ let validate = (range: t): result<t, string> =>
102
+ millis(range.start) > millis(range.end_)
103
+ ? Error(
104
+ `a range ends before it starts: ${range.start} is after ${range.end_}. ` ++
105
+ `A range is [start, end) — the start is the earlier instant.`,
106
+ )
107
+ : Ok(range)
108
+
109
+ /** Build a validated range from its two instants. `end` is exclusive. */
110
+ let make = (~start: string, ~end_: string): result<t, string> => validate({start, end_})
111
+
112
+ /**
113
+ The range's length as a `Duration`, in whole seconds — the composite composing
114
+ with one of the branded scalars.
115
+
116
+ Total: a valid range has a non-negative length, and a zero-length range is
117
+ zero seconds. Truncated to whole seconds because that is what `Duration` is.
118
+ */
119
+ let duration = (range: t): Duration.t =>
120
+ Duration.unsafe(Math.trunc((millis(range.end_) -. millis(range.start)) /. 1000.0)->Float.toInt)
121
+
122
+ /**
123
+ Whether an instant falls within the range — at or after `start`, strictly before
124
+ `end`. End-exclusive, so the instant that opens the next adjacent range is *not*
125
+ contained by this one. One of the two places `[start, end)` is decided.
126
+ */
127
+ let contains = (range: t, instant: string): bool => {
128
+ let t = millis(instant)
129
+ t >= millis(range.start) && t < millis(range.end_)
130
+ }
131
+
132
+ /**
133
+ Whether two ranges share any instant. End-exclusive: `09:00–11:00` and
134
+ `11:00–13:00` are adjacent and do *not* overlap. The other place `[start, end)`
135
+ is decided — a layout that re-decides it will disagree with this.
136
+ */
137
+ let overlaps = (a: t, b: t): bool =>
138
+ millis(a.start) < millis(b.end_) && millis(b.start) < millis(a.end_)
139
+
140
+ /**
141
+ The range as text: the two instants with an en dash between them —
142
+ `"2026-03-02T09:00:00Z – 2026-03-02T11:00:00Z"`.
143
+
144
+ Locale-independent, matching the rest of the framework's formatters: the same
145
+ value reads the same in every log line and every test. A calendar-style
146
+ rendering is the presentation layer's job.
147
+ */
148
+ let format = (range: t): string => `${range.start} – ${range.end_}`
@@ -0,0 +1,74 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as DateTime$Reventless from "../types/DateTime.res.mjs";
5
+ import * as Semantic$Reventless from "./Semantic.res.mjs";
6
+
7
+ let schema = S.schema(s => ({
8
+ start: s.m(DateTime$Reventless.string),
9
+ end: s.m(DateTime$Reventless.string)
10
+ }));
11
+
12
+ let schema$1 = Semantic$Reventless.mark(schema, Semantic$Reventless.Id.dateRange, undefined);
13
+
14
+ function millis(instant) {
15
+ return new Date(instant).getTime();
16
+ }
17
+
18
+ function validate(range) {
19
+ if (new Date(range.start).getTime() > new Date(range.end).getTime()) {
20
+ return {
21
+ TAG: "Error",
22
+ _0: `a range ends before it starts: ` + range.start + ` is after ` + range.end + `. A range is [start, end) — the start is the earlier instant.`
23
+ };
24
+ } else {
25
+ return {
26
+ TAG: "Ok",
27
+ _0: range
28
+ };
29
+ }
30
+ }
31
+
32
+ function make(start, end_) {
33
+ return validate({
34
+ start: start,
35
+ end: end_
36
+ });
37
+ }
38
+
39
+ function duration(range) {
40
+ return Math.trunc((new Date(range.end).getTime() - new Date(range.start).getTime()) / 1000.0) | 0;
41
+ }
42
+
43
+ function contains(range, instant) {
44
+ let t = new Date(instant).getTime();
45
+ if (t >= new Date(range.start).getTime()) {
46
+ return t < new Date(range.end).getTime();
47
+ } else {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ function overlaps(a, b) {
53
+ if (new Date(a.start).getTime() < new Date(b.end).getTime()) {
54
+ return new Date(b.start).getTime() < new Date(a.end).getTime();
55
+ } else {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function format(range) {
61
+ return range.start + ` – ` + range.end;
62
+ }
63
+
64
+ export {
65
+ schema$1 as schema,
66
+ millis,
67
+ validate,
68
+ make,
69
+ duration,
70
+ contains,
71
+ overlaps,
72
+ format,
73
+ }
74
+ /* schema Not a pure module */
@@ -63,6 +63,12 @@ module Id = {
63
63
  // this one changes a field's *shape* — a number becomes an object — so it is
64
64
  // a wire-breaking declaration rather than a refinement of one.
65
65
  let money = "money"
66
+
67
+ // The second composite. A pair of ISO-8601 instants as one value, replacing a
68
+ // span the UI used to guess from a `start*`/`end*` name pair. Like `money` it
69
+ // is an object on the wire; unlike it, adopting it as a *new* optional field
70
+ // is additive — an absent optional decodes to `None`.
71
+ let dateRange = "dateRange"
66
72
  }
67
73
 
68
74
  let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless", ~name="semantic")
@@ -13,7 +13,8 @@ let Id = {
13
13
  bytes: "bytes",
14
14
  duration: "duration",
15
15
  color: "color",
16
- money: "money"
16
+ money: "money",
17
+ dateRange: "dateRange"
17
18
  };
18
19
 
19
20
  let semanticId = S.Metadata.Id.make("reventless", "semantic");
@@ -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`), 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){
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 fill(schema, value){
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: return value;
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
- switch fillMissingDefaults(schema, json)->S.parseJsonOrThrow(schema) {
214
- | value => value
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 fill(schema, value){
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: return value;
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
- return S.parseJsonOrThrow(fillMissingDefaults(schema, json), schema);
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