@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.90",
3
+ "version": "3.0.0-alpha.92",
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));
@@ -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 rendered->Array.findMap(r =>
126
- switch r {
127
- | Error(e) => Some(e)
128
- | Ok(_) => None
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
- | Some(e) => Error(e)
132
- | None => {
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 e = Stdlib_Array.findMap(rendered, r => {
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 (e !== undefined) {
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: e
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")
@@ -14,7 +14,8 @@ let Id = {
14
14
  duration: "duration",
15
15
  color: "color",
16
16
  money: "money",
17
- dateRange: "dateRange"
17
+ dateRange: "dateRange",
18
+ geoPoint: "geoPoint"
18
19
  };
19
20
 
20
21
  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