@reventlessdev/reventless-spec 3.0.0-alpha.91 → 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,24 @@
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
+
6
24
  # 3.0.0-alpha.91 (2026-08-01)
7
25
 
8
26
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.91",
3
+ "version": "3.0.0-alpha.92",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -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");