@reventlessdev/reventless-spec 3.0.0-alpha.90 → 3.0.0-alpha.92
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 +26 -0
- package/package.json +1 -1
- package/src/components/CapabilityManifest.res +1 -1
- package/src/components/Plugin.res +40 -1
- package/src/components/Plugin.res.mjs +1 -1
- package/src/generator/PlatformCodegen.res +71 -7
- package/src/generator/PlatformCodegen.res.mjs +42 -3
- package/src/semantic/GeoPoint.res +226 -0
- package/src/semantic/GeoPoint.res.mjs +190 -0
- package/src/semantic/Semantic.res +7 -0
- package/src/semantic/Semantic.res.mjs +2 -1
- package/src/types/Message.res +63 -17
- package/src/types/Message.res.mjs +31 -10
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,32 @@
|
|
|
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.92 (2026-08-01)
|
|
7
|
+
|
|
8
|
+
* feat(aws,core,spec)!: qualify store prefixes by plugin and refuse name collisions ([da39405](https://github.com/ReventlessDev/reventless-core/commit/da394059d9f8f981bf7adc79e2c1ce2b429e0267))
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **spec:** a coordinate is one declared point, not two fields and a name guess ([bfe2f90](https://github.com/ReventlessDev/reventless-core/commit/bfe2f90241492422d6c242e3f50e31b81ed2a010))
|
|
12
|
+
|
|
13
|
+
### BREAKING CHANGES
|
|
14
|
+
|
|
15
|
+
* objects minted under the old bare prefix are orphaned and their
|
|
16
|
+
refs unresolvable. The migration is `seed:reset` for the owning plugin, then
|
|
17
|
+
re-seed. Legacy-prefix grandfathering was considered and deliberately dropped —
|
|
18
|
+
it would have added a permanent prefix SET across the deploy argument, both store
|
|
19
|
+
configs, the presign IAM fan-out, the release scope check and the stack output,
|
|
20
|
+
to spare a disposable stack one wipe.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# 3.0.0-alpha.91 (2026-08-01)
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
* **spec,core:** heal a missing scalar on read ([de9a98e](https://github.com/ReventlessDev/reventless-core/commit/de9a98ec5fe11ec19bef80626e99244d9c30a6b1))
|
|
29
|
+
* **spec,core:** make the storageRef annotation optional, as its readers already are ([c8477c5](https://github.com/ReventlessDev/reventless-core/commit/c8477c5c34384b864c06716dc5896310629dc349))
|
|
30
|
+
|
|
31
|
+
|
|
6
32
|
# 3.0.0-alpha.90 (2026-08-01)
|
|
7
33
|
|
|
8
34
|
### 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));
|
|
@@ -106,6 +106,62 @@ let renderEntry = (entry: unionEntry): result<array<string>, string> =>
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
Two deployables each claiming to own one plugin name.
|
|
111
|
+
|
|
112
|
+
Nothing else in the toolchain catches this. The deploy manifest's keys are
|
|
113
|
+
*deployable* names with no rule relating them to registered ones, and the Plugin
|
|
114
|
+
aggregate is keyed by the registered name — so a second plugin registering an
|
|
115
|
+
existing name is not rejected but read as a **new version of the first**, and
|
|
116
|
+
silently supersedes it in the registry.
|
|
117
|
+
|
|
118
|
+
Ownership is read from the annotation as authored, which is the one signal that
|
|
119
|
+
separates a duplicate from legitimate sharing: an **unqualified**
|
|
120
|
+
`@storageRef("productImages")` means "a store of my own plugin", so the
|
|
121
|
+
deployable that wrote it is the plugin the key names. A **qualified**
|
|
122
|
+
`@storageRef("Catalog.productImages")` points at someone else's store and says
|
|
123
|
+
nothing about who owns it — that is the sanctioned cross-plugin form and must not
|
|
124
|
+
trip this.
|
|
125
|
+
|
|
126
|
+
Partial by construction: it can only see plugins that declare a store, because
|
|
127
|
+
capability manifests are the only per-plugin input the generator reads. A
|
|
128
|
+
duplicate between two store-less plugins is invisible here and stays uncaught.
|
|
129
|
+
*/
|
|
130
|
+
let duplicatePluginOwners = (entries: array<unionEntry>): array<(string, array<string>)> => {
|
|
131
|
+
let owners: dict<array<string>> = Dict.make()
|
|
132
|
+
entries->Array.forEach(entry =>
|
|
133
|
+
switch splitKey(entry.key) {
|
|
134
|
+
| None => ()
|
|
135
|
+
| Some((plugin, _)) =>
|
|
136
|
+
entry.declaredBy->Array.forEach(site =>
|
|
137
|
+
switch site.annotation {
|
|
138
|
+
// A site with no recorded annotation predates the recording, so it
|
|
139
|
+
// cannot be read either way and is skipped rather than guessed at.
|
|
140
|
+
| Some(annotation) if !(annotation->String.includes(".")) =>
|
|
141
|
+
let claimants = owners->Dict.get(plugin)->Option.getOr([])
|
|
142
|
+
if !(claimants->Array.includes(site.pluginName)) {
|
|
143
|
+
owners->Dict.set(plugin, Array.concat(claimants, [site.pluginName]))
|
|
144
|
+
}
|
|
145
|
+
| _ => ()
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
owners
|
|
151
|
+
->Dict.toArray
|
|
152
|
+
->Array.filter(((_, claimants)) => claimants->Array.length > 1)
|
|
153
|
+
->Array.toSorted(((a, _), (b, _)) => String.compare(a, b))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let duplicatePluginMessage = ((plugin, claimants): (string, array<string>)): string =>
|
|
157
|
+
`Plugin name "${plugin}" is registered by more than one deployable: ${claimants->Array.join(
|
|
158
|
+
", ",
|
|
159
|
+
)}.\n` ++
|
|
160
|
+
` A platform keys its plugin registry by name, so the second registration is read as a new ` ++
|
|
161
|
+
`VERSION of the first and supersedes it.\n` ++
|
|
162
|
+
` Give each deployable's plugin a distinct name (plugin.json), or — if these were meant to be ` ++
|
|
163
|
+
`one plugin — deploy only one of them.`
|
|
164
|
+
|
|
109
165
|
let header = [
|
|
110
166
|
"// AUTO-GENERATED — do not edit. Run `pnpm run generate:platform` to update.",
|
|
111
167
|
"//",
|
|
@@ -122,14 +178,22 @@ let header = [
|
|
|
122
178
|
let render = (manifests: array<pluginManifest>): result<string, string> => {
|
|
123
179
|
let entries = union(manifests)
|
|
124
180
|
let rendered = entries->Array.map(renderEntry)
|
|
125
|
-
switch
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
181
|
+
switch (
|
|
182
|
+
duplicatePluginOwners(entries),
|
|
183
|
+
rendered->Array.findMap(r =>
|
|
184
|
+
switch r {
|
|
185
|
+
| Error(e) => Some(e)
|
|
186
|
+
| Ok(_) => None
|
|
187
|
+
}
|
|
188
|
+
),
|
|
130
189
|
) {
|
|
131
|
-
|
|
132
|
-
|
|
190
|
+
// Generation is the earliest point a deployment's plugins are seen together,
|
|
191
|
+
// so a name two of them both claim is refused here rather than left to
|
|
192
|
+
// supersede one of them at runtime.
|
|
193
|
+
| (duplicates, _) if duplicates->Array.length > 0 =>
|
|
194
|
+
Error(duplicates->Array.map(duplicatePluginMessage)->Array.join("\n\n"))
|
|
195
|
+
| (_, Some(e)) => Error(e)
|
|
196
|
+
| (_, None) => {
|
|
133
197
|
let body = if entries->Array.length == 0 {
|
|
134
198
|
["let capabilities: array<ReventlessInfra.Platform.capability> = []"]
|
|
135
199
|
} else {
|
|
@@ -71,6 +71,36 @@ function renderEntry(entry) {
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
function duplicatePluginOwners(entries) {
|
|
75
|
+
let owners = {};
|
|
76
|
+
entries.forEach(entry => {
|
|
77
|
+
let match = splitKey(entry.key);
|
|
78
|
+
if (match === undefined) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
let plugin = match[0];
|
|
82
|
+
entry.declaredBy.forEach(site => {
|
|
83
|
+
let annotation = site.annotation;
|
|
84
|
+
if (annotation === undefined) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (annotation.includes(".")) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
let claimants = Stdlib_Option.getOr(owners[plugin], []);
|
|
91
|
+
if (!claimants.includes(site.pluginName)) {
|
|
92
|
+
owners[plugin] = claimants.concat([site.pluginName]);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
return Object.entries(owners).filter(param => param[1].length > 1).toSorted((param, param$1) => Primitive_string.compare(param[0], param$1[0]));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function duplicatePluginMessage(param) {
|
|
101
|
+
return `Plugin name "` + param[0] + `" is registered by more than one deployable: ` + param[1].join(", ") + `.\n A platform keys its plugin registry by name, so the second registration is read as a new VERSION of the first and supersedes it.\n Give each deployable's plugin a distinct name (plugin.json), or — if these were meant to be one plugin — deploy only one of them.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
74
104
|
let header = [
|
|
75
105
|
"// AUTO-GENERATED — do not edit. Run `pnpm run generate:platform` to update.",
|
|
76
106
|
"//",
|
|
@@ -84,17 +114,24 @@ let header = [
|
|
|
84
114
|
function render(manifests) {
|
|
85
115
|
let entries = union(manifests);
|
|
86
116
|
let rendered = entries.map(renderEntry);
|
|
87
|
-
let
|
|
117
|
+
let match = duplicatePluginOwners(entries);
|
|
118
|
+
let match$1 = Stdlib_Array.findMap(rendered, r => {
|
|
88
119
|
if (r.TAG === "Ok") {
|
|
89
120
|
return;
|
|
90
121
|
} else {
|
|
91
122
|
return r._0;
|
|
92
123
|
}
|
|
93
124
|
});
|
|
94
|
-
if (
|
|
125
|
+
if (match.length !== 0) {
|
|
126
|
+
return {
|
|
127
|
+
TAG: "Error",
|
|
128
|
+
_0: match.map(duplicatePluginMessage).join("\n\n")
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (match$1 !== undefined) {
|
|
95
132
|
return {
|
|
96
133
|
TAG: "Error",
|
|
97
|
-
_0:
|
|
134
|
+
_0: match$1
|
|
98
135
|
};
|
|
99
136
|
}
|
|
100
137
|
let body = entries.length === 0 ? ["let capabilities: array<ReventlessInfra.Platform.capability> = []"] : ["let capabilities: array<ReventlessInfra.Platform.capability> = ["].concat(rendered.flatMap(r => Stdlib_Result.getOr(r, []))).concat(["]"]);
|
|
@@ -109,6 +146,8 @@ export {
|
|
|
109
146
|
quote,
|
|
110
147
|
splitKey,
|
|
111
148
|
renderEntry,
|
|
149
|
+
duplicatePluginOwners,
|
|
150
|
+
duplicatePluginMessage,
|
|
112
151
|
header,
|
|
113
152
|
render,
|
|
114
153
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
A location on the earth: a latitude and a longitude, as one value.
|
|
3
|
+
|
|
4
|
+
## Why the pair is the value, not two fields beside each other
|
|
5
|
+
|
|
6
|
+
Before this type a coordinate reached a map by three statements agreeing with
|
|
7
|
+
each other: a read model flattened the point into `lat` and `lng` scalar fields,
|
|
8
|
+
the UI guessed the pair back from those names, and a map was offered only when
|
|
9
|
+
both guesses hit. Three things go wrong and all three are silent. Two points in
|
|
10
|
+
one row mispair across each other — the two guesses are independent, so
|
|
11
|
+
`pickupLat`/`pickupLng` beside `dropoffLat`/`dropoffLng` can pin a marker at one
|
|
12
|
+
point's latitude and the other's longitude, a location in the sea drawn without
|
|
13
|
+
an error. A pair named anything else — `y`/`x`, `northing`/`easting` — is
|
|
14
|
+
invisible. And nothing checks the numbers: `lat: 181.0` is a `float` and passes
|
|
15
|
+
every boundary in the system.
|
|
16
|
+
|
|
17
|
+
Making the two numbers one value removes the pairing question, and gives the
|
|
18
|
+
checks somewhere to live.
|
|
19
|
+
|
|
20
|
+
## Latitude first here, longitude first only in GeoJSON
|
|
21
|
+
|
|
22
|
+
This is the ordering trap the whole geo domain is famous for. GeoJSON (RFC 7946)
|
|
23
|
+
encodes a point as `{"type":"Point","coordinates":[lng, lat]}` — **longitude
|
|
24
|
+
first** — while every UI API and every human writes latitude first. A swapped
|
|
25
|
+
pair is not an error anywhere: it is a plausible-looking marker in the wrong
|
|
26
|
+
hemisphere.
|
|
27
|
+
|
|
28
|
+
So the shape here keeps *names*, where an order cannot be got wrong, and the
|
|
29
|
+
positional order exists in exactly one place: `toGeoJson`/`fromGeoJson` below.
|
|
30
|
+
Any consumer that re-derives that conversion is re-deciding something already
|
|
31
|
+
decided, and will eventually disagree with it.
|
|
32
|
+
|
|
33
|
+
## The invariants are checked at decode
|
|
34
|
+
|
|
35
|
+
Latitude runs −90…90 and longitude −180…180. Each range is a property of *one*
|
|
36
|
+
field, so — unlike `DateRange`, whose ordering rule relates two fields and cannot
|
|
37
|
+
be refined while sury 11-alpha miscompiles a record-level refinement — these sit
|
|
38
|
+
on the field schemas and the boundary rejects a bad coordinate on the way in.
|
|
39
|
+
This type has the same decode-time guarantee `Money` has, and more than
|
|
40
|
+
`DateRange`; a reader arriving from `DateRange` would assume the weaker one.
|
|
41
|
+
|
|
42
|
+
The two ranges are deliberately not one "coordinate" check: latitude saturates
|
|
43
|
+
at the poles and longitude wraps at the antimeridian, and ±90 versus ±180 is the
|
|
44
|
+
whole of the difference between them.
|
|
45
|
+
|
|
46
|
+
## How a field declares it
|
|
47
|
+
|
|
48
|
+
The field's declared type *is* `GeoPoint.t`, and sury-ppx resolves it to this
|
|
49
|
+
module's `schema`:
|
|
50
|
+
|
|
51
|
+
```rescript
|
|
52
|
+
@schema type state = {
|
|
53
|
+
customerId: string,
|
|
54
|
+
location: option<Reventless.GeoPoint.t>,
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
which serializes as `{"lat": 48.2082, "lng": 16.3738}`.
|
|
59
|
+
|
|
60
|
+
**Replacing a hand-rolled `{lat: float, lng: float}` record with this type is
|
|
61
|
+
free** — the wire shape is identical, so every stored event decodes unchanged and
|
|
62
|
+
no upcaster is owed. That is the cheapest of the three adoption paths a semantic
|
|
63
|
+
type has, and it is the one most existing coordinate fields are on. It costs a
|
|
64
|
+
log something only if it *collapses* two flattened scalar fields back into one,
|
|
65
|
+
which rewrites that shape.
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
Validate a latitude, saying why when it is out of range.
|
|
70
|
+
|
|
71
|
+
The single statement of the rule; `latSchema` is derived from it, so there is
|
|
72
|
+
nowhere for a second grammar to drift.
|
|
73
|
+
*/
|
|
74
|
+
let validateLat = (raw: float): result<float, string> =>
|
|
75
|
+
if !Float.isFinite(raw) {
|
|
76
|
+
Error(`a latitude must be a finite number of degrees, got ${Float.toString(raw)}`)
|
|
77
|
+
} else if raw < -90.0 || raw > 90.0 {
|
|
78
|
+
Error(
|
|
79
|
+
`a latitude runs from -90 to 90 degrees, got ${Float.toString(raw)}. ` ++
|
|
80
|
+
`A value beyond ±90 is usually a longitude in the latitude's place.`,
|
|
81
|
+
)
|
|
82
|
+
} else {
|
|
83
|
+
Ok(raw)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Validate a longitude, saying why when it is out of range. The other half of
|
|
87
|
+
the pair, separate because ±180 is what makes it a longitude. */
|
|
88
|
+
let validateLng = (raw: float): result<float, string> =>
|
|
89
|
+
if !Float.isFinite(raw) {
|
|
90
|
+
Error(`a longitude must be a finite number of degrees, got ${Float.toString(raw)}`)
|
|
91
|
+
} else if raw < -180.0 || raw > 180.0 {
|
|
92
|
+
Error(`a longitude runs from -180 to 180 degrees, got ${Float.toString(raw)}`)
|
|
93
|
+
} else {
|
|
94
|
+
Ok(raw)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The latitude's own schema. The check sits on the field rather than on the
|
|
98
|
+
pair because the range is a property of the one number — and because sury
|
|
99
|
+
11-alpha miscompiles a refinement wrapping a *record* schema. Refining the
|
|
100
|
+
field is both the honest placement and the one that works. */
|
|
101
|
+
let latSchema: S.t<float> =
|
|
102
|
+
S.float->S.refine(s => raw =>
|
|
103
|
+
switch validateLat(raw) {
|
|
104
|
+
| Ok(_) => ()
|
|
105
|
+
| Error(why) => s.fail(why)
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
/** The longitude's own schema, for the same reason. */
|
|
110
|
+
let lngSchema: S.t<float> =
|
|
111
|
+
S.float->S.refine(s => raw =>
|
|
112
|
+
switch validateLng(raw) {
|
|
113
|
+
| Ok(_) => ()
|
|
114
|
+
| Error(why) => s.fail(why)
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@schema
|
|
119
|
+
type t = {
|
|
120
|
+
/** Degrees north of the equator, −90…90. Negative is south. */
|
|
121
|
+
lat: @s.matches(latSchema) float,
|
|
122
|
+
/** Degrees east of the prime meridian, −180…180. Negative is west. */
|
|
123
|
+
lng: @s.matches(lngSchema) float,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The sury schema for a geo-point field, carrying the `geoPoint` semantic.
|
|
127
|
+
|
|
128
|
+
Shadows the schema sury-ppx derived from the type above: the derived one is
|
|
129
|
+
the shape, and this adds the marker the shape cannot carry. */
|
|
130
|
+
let schema: S.t<t> = schema->Semantic.mark(~id=Semantic.Id.geoPoint)
|
|
131
|
+
|
|
132
|
+
/** Build a validated point. Both coordinates are checked, and the message names
|
|
133
|
+
which one is wrong — the common mistake is a swapped pair, where the
|
|
134
|
+
longitude lands in the latitude and is the value that fails. */
|
|
135
|
+
let make = (~lat: float, ~lng: float): result<t, string> =>
|
|
136
|
+
switch (validateLat(lat), validateLng(lng)) {
|
|
137
|
+
| (Error(why), _) | (Ok(_), Error(why)) => Error(why)
|
|
138
|
+
| (Ok(lat), Ok(lng)) => Ok({lat, lng})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
The point as text: latitude, then longitude — `"48.2082, 16.3738"`.
|
|
143
|
+
|
|
144
|
+
Latitude first, the order a human reads and the opposite of GeoJSON's. Locale
|
|
145
|
+
independent, matching the rest of the framework's formatters: the same value
|
|
146
|
+
reads the same in every log line and every test.
|
|
147
|
+
*/
|
|
148
|
+
let format = (p: t): string => `${Float.toString(p.lat)}, ${Float.toString(p.lng)}`
|
|
149
|
+
|
|
150
|
+
/** The mean radius of the earth in metres (IUGG R₁). One constant, so a distance
|
|
151
|
+
computed here and a distance computed elsewhere cannot disagree by their
|
|
152
|
+
choice of sphere. */
|
|
153
|
+
let earthRadiusMetres = 6371008.8
|
|
154
|
+
|
|
155
|
+
let toRadians = (degrees: float): float => degrees *. Math.Constants.pi /. 180.0
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
The great-circle distance between two points, in metres.
|
|
159
|
+
|
|
160
|
+
Haversine on a spherical earth, which is accurate to roughly 0.5% — right for
|
|
161
|
+
radii, catchments and sorting by nearness, and wrong for surveying. Anything
|
|
162
|
+
needing geodesic accuracy needs an ellipsoid and a library that models one.
|
|
163
|
+
|
|
164
|
+
It lives here rather than at each consumer because "how far" is the operation
|
|
165
|
+
every geo consumer eventually wants, and writing it per consumer is where the
|
|
166
|
+
degrees-versus-radians and earth-radius mistakes live — mistakes that produce a
|
|
167
|
+
plausible number rather than a failure.
|
|
168
|
+
*/
|
|
169
|
+
let distanceTo = (a: t, b: t): float => {
|
|
170
|
+
let lat1 = toRadians(a.lat)
|
|
171
|
+
let lat2 = toRadians(b.lat)
|
|
172
|
+
let dLat = toRadians(b.lat -. a.lat)
|
|
173
|
+
let dLng = toRadians(b.lng -. a.lng)
|
|
174
|
+
let h =
|
|
175
|
+
Math.sin(dLat /. 2.0) *. Math.sin(dLat /. 2.0) +.
|
|
176
|
+
Math.cos(lat1) *. Math.cos(lat2) *. Math.sin(dLng /. 2.0) *. Math.sin(dLng /. 2.0)
|
|
177
|
+
2.0 *. Math.asin(Math.sqrt(Math.min(1.0, h))) *. earthRadiusMetres
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
The point as a GeoJSON `Point` geometry — `{"type":"Point","coordinates":[lng, lat]}`.
|
|
182
|
+
|
|
183
|
+
**This is the one place the positional order is written.** Longitude first, per
|
|
184
|
+
RFC 7946. Every map library, spatial database and geocoding API on that side of
|
|
185
|
+
the boundary expects it; every human on this side does not, which is why the
|
|
186
|
+
stored shape keeps names and only this function turns them into an array.
|
|
187
|
+
*/
|
|
188
|
+
let toGeoJson = (p: t): JSON.t =>
|
|
189
|
+
JSON.Encode.object(
|
|
190
|
+
Dict.fromArray([
|
|
191
|
+
("type", JSON.Encode.string("Point")),
|
|
192
|
+
("coordinates", JSON.Encode.array([JSON.Encode.float(p.lng), JSON.Encode.float(p.lat)])),
|
|
193
|
+
]),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
Read a GeoJSON `Point` geometry back, validating both coordinates.
|
|
198
|
+
|
|
199
|
+
The inverse of `toGeoJson`, and the only other place the positional order is
|
|
200
|
+
read. Returns `Error` for a geometry that is not a point, for coordinates that
|
|
201
|
+
are not two numbers, and — via `make` — for numbers out of range, which is what
|
|
202
|
+
catches a `[lat, lng]` array produced by something that got the order wrong,
|
|
203
|
+
whenever the latitude exceeds ±90.
|
|
204
|
+
*/
|
|
205
|
+
let fromGeoJson = (json: JSON.t): result<t, string> =>
|
|
206
|
+
switch json->JSON.Decode.object {
|
|
207
|
+
| None => Error("a GeoJSON point is an object")
|
|
208
|
+
| Some(o) =>
|
|
209
|
+
switch o->Dict.get("type")->Option.flatMap(JSON.Decode.string) {
|
|
210
|
+
| Some("Point") =>
|
|
211
|
+
switch o->Dict.get("coordinates")->Option.flatMap(JSON.Decode.array) {
|
|
212
|
+
| Some(coords) =>
|
|
213
|
+
switch (
|
|
214
|
+
coords->Array.get(0)->Option.flatMap(JSON.Decode.float),
|
|
215
|
+
coords->Array.get(1)->Option.flatMap(JSON.Decode.float),
|
|
216
|
+
) {
|
|
217
|
+
// [lng, lat] — the RFC's order, not this module's.
|
|
218
|
+
| (Some(lng), Some(lat)) => make(~lat, ~lng)
|
|
219
|
+
| _ => Error("a GeoJSON point's coordinates are two numbers, [lng, lat]")
|
|
220
|
+
}
|
|
221
|
+
| None => Error("a GeoJSON point has a coordinates array")
|
|
222
|
+
}
|
|
223
|
+
| Some(other) => Error(`expected a GeoJSON Point, got ${other}`)
|
|
224
|
+
| None => Error("a GeoJSON geometry has a type")
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
7
|
+
|
|
8
|
+
function validateLat(raw) {
|
|
9
|
+
if (isFinite(raw)) {
|
|
10
|
+
if (raw < -90.0 || raw > 90.0) {
|
|
11
|
+
return {
|
|
12
|
+
TAG: "Error",
|
|
13
|
+
_0: `a latitude runs from -90 to 90 degrees, got ` + raw.toString() + `. A value beyond ±90 is usually a longitude in the latitude's place.`
|
|
14
|
+
};
|
|
15
|
+
} else {
|
|
16
|
+
return {
|
|
17
|
+
TAG: "Ok",
|
|
18
|
+
_0: raw
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
return {
|
|
23
|
+
TAG: "Error",
|
|
24
|
+
_0: `a latitude must be a finite number of degrees, got ` + raw.toString()
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateLng(raw) {
|
|
30
|
+
if (isFinite(raw)) {
|
|
31
|
+
if (raw < -180.0 || raw > 180.0) {
|
|
32
|
+
return {
|
|
33
|
+
TAG: "Error",
|
|
34
|
+
_0: `a longitude runs from -180 to 180 degrees, got ` + raw.toString()
|
|
35
|
+
};
|
|
36
|
+
} else {
|
|
37
|
+
return {
|
|
38
|
+
TAG: "Ok",
|
|
39
|
+
_0: raw
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
return {
|
|
44
|
+
TAG: "Error",
|
|
45
|
+
_0: `a longitude must be a finite number of degrees, got ` + raw.toString()
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let latSchema = S.refine(S.float, s => (raw => {
|
|
51
|
+
let why = validateLat(raw);
|
|
52
|
+
if (why.TAG === "Ok") {
|
|
53
|
+
return;
|
|
54
|
+
} else {
|
|
55
|
+
return s.fail(why._0, undefined);
|
|
56
|
+
}
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
let lngSchema = S.refine(S.float, s => (raw => {
|
|
60
|
+
let why = validateLng(raw);
|
|
61
|
+
if (why.TAG === "Ok") {
|
|
62
|
+
return;
|
|
63
|
+
} else {
|
|
64
|
+
return s.fail(why._0, undefined);
|
|
65
|
+
}
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
let schema = S.schema(s => ({
|
|
69
|
+
lat: s.m(latSchema),
|
|
70
|
+
lng: s.m(lngSchema)
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
let schema$1 = Semantic$Reventless.mark(schema, Semantic$Reventless.Id.geoPoint, undefined);
|
|
74
|
+
|
|
75
|
+
function make(lat, lng) {
|
|
76
|
+
let match = validateLat(lat);
|
|
77
|
+
let match$1 = validateLng(lng);
|
|
78
|
+
if (match.TAG === "Ok") {
|
|
79
|
+
if (match$1.TAG === "Ok") {
|
|
80
|
+
return {
|
|
81
|
+
TAG: "Ok",
|
|
82
|
+
_0: {
|
|
83
|
+
lat: match._0,
|
|
84
|
+
lng: match$1._0
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
} else {
|
|
88
|
+
return {
|
|
89
|
+
TAG: "Error",
|
|
90
|
+
_0: match$1._0
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
return {
|
|
95
|
+
TAG: "Error",
|
|
96
|
+
_0: match._0
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function format(p) {
|
|
102
|
+
return p.lat.toString() + `, ` + p.lng.toString();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toRadians(degrees) {
|
|
106
|
+
return degrees * Math.PI / 180.0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function distanceTo(a, b) {
|
|
110
|
+
let lat1 = toRadians(a.lat);
|
|
111
|
+
let lat2 = toRadians(b.lat);
|
|
112
|
+
let dLat = toRadians(b.lat - a.lat);
|
|
113
|
+
let dLng = toRadians(b.lng - a.lng);
|
|
114
|
+
let h = Math.sin(dLat / 2.0) * Math.sin(dLat / 2.0) + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2.0) * Math.sin(dLng / 2.0);
|
|
115
|
+
return 2.0 * Math.asin(Math.sqrt(Math.min(1.0, h))) * 6371008.8;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function toGeoJson(p) {
|
|
119
|
+
return Object.fromEntries([
|
|
120
|
+
[
|
|
121
|
+
"type",
|
|
122
|
+
"Point"
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
"coordinates",
|
|
126
|
+
[
|
|
127
|
+
p.lng,
|
|
128
|
+
p.lat
|
|
129
|
+
]
|
|
130
|
+
]
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function fromGeoJson(json) {
|
|
135
|
+
let o = Stdlib_JSON.Decode.object(json);
|
|
136
|
+
if (o === undefined) {
|
|
137
|
+
return {
|
|
138
|
+
TAG: "Error",
|
|
139
|
+
_0: "a GeoJSON point is an object"
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
let other = Stdlib_Option.flatMap(o["type"], Stdlib_JSON.Decode.string);
|
|
143
|
+
if (other === undefined) {
|
|
144
|
+
return {
|
|
145
|
+
TAG: "Error",
|
|
146
|
+
_0: "a GeoJSON geometry has a type"
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (other !== "Point") {
|
|
150
|
+
return {
|
|
151
|
+
TAG: "Error",
|
|
152
|
+
_0: `expected a GeoJSON Point, got ` + other
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
let coords = Stdlib_Option.flatMap(o["coordinates"], Stdlib_JSON.Decode.array);
|
|
156
|
+
if (coords === undefined) {
|
|
157
|
+
return {
|
|
158
|
+
TAG: "Error",
|
|
159
|
+
_0: "a GeoJSON point has a coordinates array"
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
let match = Stdlib_Option.flatMap(coords[0], Stdlib_JSON.Decode.float);
|
|
163
|
+
let match$1 = Stdlib_Option.flatMap(coords[1], Stdlib_JSON.Decode.float);
|
|
164
|
+
if (match !== undefined && match$1 !== undefined) {
|
|
165
|
+
return make(match$1, match);
|
|
166
|
+
} else {
|
|
167
|
+
return {
|
|
168
|
+
TAG: "Error",
|
|
169
|
+
_0: "a GeoJSON point's coordinates are two numbers, [lng, lat]"
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let earthRadiusMetres = 6371008.8;
|
|
175
|
+
|
|
176
|
+
export {
|
|
177
|
+
validateLat,
|
|
178
|
+
validateLng,
|
|
179
|
+
latSchema,
|
|
180
|
+
lngSchema,
|
|
181
|
+
schema$1 as schema,
|
|
182
|
+
make,
|
|
183
|
+
format,
|
|
184
|
+
earthRadiusMetres,
|
|
185
|
+
toRadians,
|
|
186
|
+
distanceTo,
|
|
187
|
+
toGeoJson,
|
|
188
|
+
fromGeoJson,
|
|
189
|
+
}
|
|
190
|
+
/* latSchema Not a pure module */
|
|
@@ -69,6 +69,13 @@ module Id = {
|
|
|
69
69
|
// is an object on the wire; unlike it, adopting it as a *new* optional field
|
|
70
70
|
// is additive — an absent optional decodes to `None`.
|
|
71
71
|
let dateRange = "dateRange"
|
|
72
|
+
|
|
73
|
+
// The third composite, and the cheapest to adopt. A latitude/longitude pair as
|
|
74
|
+
// one value, replacing a point the UI used to guess from a `lat`/`lng` name
|
|
75
|
+
// pair. Most coordinate fields already store `{lat, lng}` as a hand-rolled
|
|
76
|
+
// record, so retyping one is shape-preserving: the wire is unchanged and
|
|
77
|
+
// nothing stored needs upcasting.
|
|
78
|
+
let geoPoint = "geoPoint"
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless", ~name="semantic")
|
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
|
|