@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 +18 -0
- package/package.json +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/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
|
@@ -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")
|