@reventlessdev/reventless-spec 3.0.0-alpha.119 → 3.0.0-alpha.120

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,13 @@
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.120 (2026-08-20)
7
+
8
+ ### Features
9
+
10
+ * **spec:** type a geocoder's answer as one Geolocation value ([157be7a](https://github.com/ReventlessDev/reventless-core/commit/157be7aca4806ae19dba1f58979af13b88dc1821))
11
+
12
+
6
13
  # 3.0.0-alpha.119 (2026-08-20)
7
14
 
8
15
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.119",
3
+ "version": "3.0.0-alpha.120",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -1,17 +1,9 @@
1
1
  /**
2
2
  Turning an address into a point, and deciding whether to believe the answer.
3
3
 
4
- The *transport* is provider-specific — Amazon Location, a self-hosted Nominatim,
5
- a vendor's HTTP API — and lives with its provider. What lives here is everything
6
- that is the same regardless of who answers: the shape of an answer, the two ways
7
- a lookup can fail, and the rule for when a ranked list is confident enough to
8
- write a coordinate into an event log.
9
-
10
- That split matters because the confidence rule is the part that is easy to get
11
- wrong and expensive to get wrong twice. A caller that re-derives it — takes
12
- `results[0]` because the list was ranked — produces a plausible marker in the
13
- wrong region, drawn without an error anywhere. Deciding it once, here, is what
14
- stops each transport inventing its own answer.
4
+ The transport is provider-specific and lives with its provider. What is here is
5
+ provider-neutral: the shape of an answer, the two ways a lookup fails, and the
6
+ confidence rule — decided once, so no transport invents its own.
15
7
  */
16
8
 
17
9
  /** One candidate a geocoder returned. */
@@ -19,25 +11,13 @@ type candidate = {
19
11
  /** The provider's canonical rendering of the address it matched. */
20
12
  label: string,
21
13
  point: GeoPoint.t,
22
- /**
23
- How well this result matches the query, 0…1.
24
-
25
- `None` when the provider does not score its results. That is not the same as a
26
- low score and must not be read as a high one — `confidentMatch` declines it,
27
- because an unscored list cannot support an unattended decision.
28
- */
14
+ /** How well this matches, 0…1. `None` when the provider does not score, which
15
+ `confidentMatch` declines rather than reading as high. */
29
16
  relevance: option<float>,
30
17
  }
31
18
 
32
- /**
33
- Why a lookup produced no usable point.
34
-
35
- Two constructors rather than one message because the caller's retry decision
36
- turns on exactly this distinction, and a `switch` is the only form of it that
37
- cannot be got wrong. A translator that cannot tell "no such address" from "the
38
- service is down" turns one outage into a permanent verdict on every address in
39
- flight.
40
- */
19
+ /** Why a lookup produced no usable point. Two constructors because the retry
20
+ decision turns on the distinction: an outage must not become a verdict. */
41
21
  type failure =
42
22
  | /** The provider could not be reached, or refused the call. Retry. */
43
23
  Unavailable(string)
@@ -45,83 +25,82 @@ type failure =
45
25
  NoMatch
46
26
 
47
27
  /**
48
- The port a caller reaches a geocoder through.
49
-
50
- Everything above says what an answer looks like; this says how one is asked for.
51
- Written down as a type before anything is injected against it, so that swapping
52
- the implementation underneath a caller is a change of *supplier* rather than a
53
- change of call site: whatever eventually hands a translator its geocoder — an
54
- HTTP client reading an endpoint out of its environment, a provider SDK called
55
- directly — has to satisfy this, and the `await search(~text=…)` in the caller
56
- does not move.
28
+ The port a caller reaches a geocoder through, so swapping the implementation is a
29
+ change of supplier rather than of call site.
57
30
 
58
- `~text` is the address as a human typed it, unnormalised. Normalising it is the
59
- provider's job, and its canonical rendering comes back as `candidate.label`.
31
+ `~text` is unnormalised; the provider's canonical rendering comes back as
32
+ `candidate.label`.
60
33
  */
61
34
  type search = (~text: string) => promise<result<array<candidate>, failure>>
62
35
 
63
36
  /**
64
- The default confidence floor, calibrated against Amazon Location's Esri index.
65
-
66
- Measured, not guessed — and the measurement moved it a long way. Esri does not
67
- spread its scores over 0…1: everything it is willing to return at all lands in
68
- roughly 0.9…1.0, so a floor of `0.8` admitted almost every answer and left the
69
- ambiguity margin doing all the work. The separation is up at the top of the
70
- range instead — in a 24-address corpus the correct pinpoint matches scored
71
- ≥ 0.988 while the wrong ones (a misspelling resolved to the wrong state, a
72
- street name matched to a different street in the right city) scored 0.913…0.958.
37
+ The default confidence floor, measured against Amazon Location's Esri index:
38
+ correct matches scored ≥ 0.988, wrong ones 0.913…0.958, so `0.97` sits in the gap.
73
39
 
74
- `0.97` sits in that gap. The wider consequence is that these numbers are
75
- *provider-calibrated* even though everything else in this module is
76
- provider-neutral, which is exactly why both are labelled arguments: a geocoder
77
- that scores on a different curve needs its own pair, and the defaults are the
78
- Esri answer rather than a universal one.
40
+ Provider-calibrated, which is why it is a labelled argument — a geocoder scoring
41
+ on a different curve needs its own pair.
79
42
  */
80
43
  let defaultMinRelevance = 0.97
81
44
 
82
- /**
83
- How close the runner-up may come before the answer is called ambiguous.
84
-
85
- Deliberately small, for the same reason the floor is large. Genuine ambiguity in
86
- Esri shows up as a *tie* — "Springfield" returns five states at exactly 1.0,
87
- "221B Baker" five towns at exactly 0.8222 — not as a near miss. A wide margin
88
- does not catch more of those; it only starts rejecting clear winners, because a
89
- correct match at 0.991 routinely has a plausible runner-up at 0.962.
90
- */
45
+ /** How close the runner-up may come before the answer is ambiguous. Small, because
46
+ real ambiguity shows up as a tie; a wide margin only rejects clear winners. */
91
47
  let defaultAmbiguityMargin = 0.01
92
48
 
93
- /**
94
- The one candidate confident enough to store, or `None`.
95
-
96
- Two ways to be unsure, and both decline:
97
-
98
- - the top candidate scores below `minRelevance` — the provider matched
99
- something, loosely;
100
- - the runner-up scores within `ambiguityMargin` of the top — the provider
101
- matched several things about equally well, which is what a bare town name
102
- does.
103
-
104
- `None` means "ask a human", not "no result". The candidates are still there for
105
- a caller that wants to show them.
106
- */
107
- let confidentMatch = (
49
+ /** The confidence decision with the reason attached, for a caller that reports
50
+ the outcome rather than acting on it. */
51
+ type assessment =
52
+ | Confident(candidate)
53
+ | NoCandidates
54
+ | /** Unscored is not a low score, and must not read as a high one. */
55
+ Unscored(candidate)
56
+ | LowRelevance({top: candidate, score: float, floor: float})
57
+ | /** Several matches about equally well. */
58
+ Ambiguous({top: candidate, runnerUp: candidate, margin: float})
59
+
60
+ /** The confidence rule, stated once. Everything else here derives from it. */
61
+ let assess = (
108
62
  candidates: array<candidate>,
109
63
  ~minRelevance: float=defaultMinRelevance,
110
64
  ~ambiguityMargin: float=defaultAmbiguityMargin,
111
- ): option<candidate> =>
65
+ ): assessment =>
112
66
  switch candidates->Array.get(0) {
113
- | None => None
67
+ | None => NoCandidates
114
68
  | Some(top) =>
115
69
  switch top.relevance {
116
- | None => None
70
+ | None => Unscored(top)
117
71
  | Some(topScore) =>
118
72
  if topScore < minRelevance {
119
- None
73
+ LowRelevance({top, score: topScore, floor: minRelevance})
120
74
  } else {
121
- switch candidates->Array.get(1)->Option.flatMap(c => c.relevance) {
122
- | Some(runnerUp) if topScore -. runnerUp < ambiguityMargin => None
123
- | _ => Some(top)
75
+ switch candidates->Array.get(1) {
76
+ | Some(runnerUp) =>
77
+ switch runnerUp.relevance {
78
+ | Some(runnerUpScore) if topScore -. runnerUpScore < ambiguityMargin =>
79
+ Ambiguous({top, runnerUp, margin: ambiguityMargin})
80
+ | _ => Confident(top)
81
+ }
82
+ | None => Confident(top)
124
83
  }
125
84
  }
126
85
  }
127
86
  }
87
+
88
+ /**
89
+ The one candidate confident enough to store, or `None` — which means "ask a
90
+ human", not "no result". Declines a top scoring below `minRelevance` and a
91
+ runner-up within `ambiguityMargin`. `assess` says which rule declined.
92
+ */
93
+ let confidentMatch = (
94
+ candidates: array<candidate>,
95
+ ~minRelevance: float=defaultMinRelevance,
96
+ ~ambiguityMargin: float=defaultAmbiguityMargin,
97
+ ): option<candidate> =>
98
+ // Listed rather than `_`, so a new case must be decided here.
99
+ switch candidates->assess(~minRelevance, ~ambiguityMargin) {
100
+ | Confident(top) => Some(top)
101
+ | NoCandidates
102
+ | Unscored(_)
103
+ | LowRelevance(_)
104
+ | Ambiguous(_) =>
105
+ None
106
+ }
@@ -1,26 +1,59 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
- import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
3
 
5
- function confidentMatch(candidates, minRelevanceOpt, ambiguityMarginOpt) {
4
+ function assess(candidates, minRelevanceOpt, ambiguityMarginOpt) {
6
5
  let minRelevance = minRelevanceOpt !== undefined ? minRelevanceOpt : 0.97;
7
6
  let ambiguityMargin = ambiguityMarginOpt !== undefined ? ambiguityMarginOpt : 0.01;
8
7
  let top = candidates[0];
9
8
  if (top === undefined) {
10
- return;
9
+ return "NoCandidates";
11
10
  }
12
11
  let topScore = top.relevance;
13
12
  if (topScore === undefined) {
14
- return;
13
+ return {
14
+ TAG: "Unscored",
15
+ _0: top
16
+ };
15
17
  }
16
18
  if (topScore < minRelevance) {
17
- return;
19
+ return {
20
+ TAG: "LowRelevance",
21
+ top: top,
22
+ score: topScore,
23
+ floor: minRelevance
24
+ };
25
+ }
26
+ let runnerUp = candidates[1];
27
+ if (runnerUp === undefined) {
28
+ return {
29
+ TAG: "Confident",
30
+ _0: top
31
+ };
32
+ }
33
+ let runnerUpScore = runnerUp.relevance;
34
+ if (runnerUpScore !== undefined && topScore - runnerUpScore < ambiguityMargin) {
35
+ return {
36
+ TAG: "Ambiguous",
37
+ top: top,
38
+ runnerUp: runnerUp,
39
+ margin: ambiguityMargin
40
+ };
41
+ } else {
42
+ return {
43
+ TAG: "Confident",
44
+ _0: top
45
+ };
18
46
  }
19
- let runnerUp = Stdlib_Option.flatMap(candidates[1], c => c.relevance);
20
- if (runnerUp !== undefined && topScore - runnerUp < ambiguityMargin) {
47
+ }
48
+
49
+ function confidentMatch(candidates, minRelevanceOpt, ambiguityMarginOpt) {
50
+ let minRelevance = minRelevanceOpt !== undefined ? minRelevanceOpt : 0.97;
51
+ let ambiguityMargin = ambiguityMarginOpt !== undefined ? ambiguityMarginOpt : 0.01;
52
+ let top = assess(candidates, minRelevance, ambiguityMargin);
53
+ if (typeof top !== "object" || top.TAG !== "Confident") {
21
54
  return;
22
55
  } else {
23
- return top;
56
+ return top._0;
24
57
  }
25
58
  }
26
59
 
@@ -31,6 +64,7 @@ let defaultAmbiguityMargin = 0.01;
31
64
  export {
32
65
  defaultMinRelevance,
33
66
  defaultAmbiguityMargin,
67
+ assess,
34
68
  confidentMatch,
35
69
  }
36
70
  /* No side effect */
@@ -0,0 +1,81 @@
1
+ /**
2
+ A geocoder's answer as one value; `Geocoding`'s return type.
3
+
4
+ Three arms rather than `option<GeoPoint.t>`, whose `None` means both "has not run"
5
+ and "ran and failed". Emitted as a GraphQL union (see `Reventless.TaggedUnion`).
6
+ Replacing a point/status/note trio with it is wire-breaking.
7
+ */
8
+
9
+ @schema
10
+ type t =
11
+ | /** `requestedFor` is the address asked about, so a stale answer is detectable. */
12
+ Pending({requestedFor: string})
13
+ | Located({point: GeoPoint.t})
14
+ | /** Answered, with nothing storable unattended. A verdict for a human. */
15
+ Unresolvable({reason: string})
16
+
17
+ /** Adds the two markers the shape cannot carry: the semantic, and the union name
18
+ the SDL and the `__typename` stamp share. */
19
+ let schema: S.t<t> =
20
+ schema->Semantic.mark(~id=Semantic.Id.geolocation)->TaggedUnion.named(~name="Geolocation")
21
+
22
+ /** The point, when there is one. */
23
+ let point = (geolocation: t): option<GeoPoint.t> =>
24
+ switch geolocation {
25
+ | Located({point}) => Some(point)
26
+ | Pending(_) | Unresolvable(_) => None
27
+ }
28
+
29
+ let isLocated = (geolocation: t): bool => geolocation->point->Option.isSome
30
+
31
+ /** Why it could not be resolved. `None` for `Pending`, which is still waiting. */
32
+ let reason = (geolocation: t): option<string> =>
33
+ switch geolocation {
34
+ | Unresolvable({reason}) => Some(reason)
35
+ | Pending(_) | Located(_) => None
36
+ }
37
+
38
+ /**
39
+ The geolocation a geocoder's answer implies, via `Geocoding.assess`.
40
+
41
+ `Error(Unavailable(_))` returns `None` — leave the row alone, since an outage
42
+ written as a verdict is permanent. Never returns `Pending`. Thresholds are
43
+ threaded because they are provider-calibrated.
44
+ */
45
+ let ofSearch = (
46
+ ~requestedFor: string,
47
+ ~minRelevance: float=Geocoding.defaultMinRelevance,
48
+ ~ambiguityMargin: float=Geocoding.defaultAmbiguityMargin,
49
+ answer: result<array<Geocoding.candidate>, Geocoding.failure>,
50
+ ): option<t> =>
51
+ switch answer {
52
+ | Error(Unavailable(_)) => None
53
+ | Error(NoMatch) => Some(Unresolvable({reason: `no match for "${requestedFor}"`}))
54
+ | Ok(candidates) =>
55
+ // Reasons report what came back; the rule that rejected it stays in `assess`.
56
+ switch candidates->Geocoding.assess(~minRelevance, ~ambiguityMargin) {
57
+ | Confident(top) => Some(Located({point: top.point}))
58
+ | NoCandidates => Some(Unresolvable({reason: `no candidates for "${requestedFor}"`}))
59
+ | Unscored(top) =>
60
+ Some(
61
+ Unresolvable({
62
+ reason: `the geocoder returned "${top.label}" for "${requestedFor}" without scoring it, ` ++
63
+ `and an unscored answer cannot be accepted unattended`,
64
+ }),
65
+ )
66
+ | LowRelevance({top, score, floor}) =>
67
+ Some(
68
+ Unresolvable({
69
+ reason: `the best match for "${requestedFor}" was "${top.label}" at relevance ` ++
70
+ `${Float.toString(score)}, below the ${Float.toString(floor)} needed to store one`,
71
+ }),
72
+ )
73
+ | Ambiguous({top, runnerUp}) =>
74
+ Some(
75
+ Unresolvable({
76
+ reason: `"${requestedFor}" matched "${top.label}" and "${runnerUp.label}" ` ++
77
+ `about equally well`,
78
+ }),
79
+ )
80
+ }
81
+ }
@@ -0,0 +1,104 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as GeoPoint$Reventless from "./GeoPoint.res.mjs";
6
+ import * as Semantic$Reventless from "./Semantic.res.mjs";
7
+ import * as Geocoding$Reventless from "./Geocoding.res.mjs";
8
+ import * as TaggedUnion$Reventless from "../components/TaggedUnion.res.mjs";
9
+
10
+ let schema = Sury.union([
11
+ Sury.$schema(s => ({
12
+ TAG: "Pending",
13
+ requestedFor: s.m(Sury.string)
14
+ })),
15
+ Sury.$schema(s => ({
16
+ TAG: "Located",
17
+ point: s.m(GeoPoint$Reventless.schema)
18
+ })),
19
+ Sury.$schema(s => ({
20
+ TAG: "Unresolvable",
21
+ reason: s.m(Sury.string)
22
+ }))
23
+ ]);
24
+
25
+ let schema$1 = TaggedUnion$Reventless.named("Geolocation", Semantic$Reventless.mark(schema, Semantic$Reventless.Id.geolocation, undefined));
26
+
27
+ function point(geolocation) {
28
+ switch (geolocation.TAG) {
29
+ case "Located" :
30
+ return geolocation.point;
31
+ case "Pending" :
32
+ case "Unresolvable" :
33
+ return;
34
+ }
35
+ }
36
+
37
+ function isLocated(geolocation) {
38
+ return Stdlib_Option.isSome(point(geolocation));
39
+ }
40
+
41
+ function reason(geolocation) {
42
+ switch (geolocation.TAG) {
43
+ case "Pending" :
44
+ case "Located" :
45
+ return;
46
+ case "Unresolvable" :
47
+ return geolocation.reason;
48
+ }
49
+ }
50
+
51
+ function ofSearch(requestedFor, minRelevanceOpt, ambiguityMarginOpt, answer) {
52
+ let minRelevance = minRelevanceOpt !== undefined ? minRelevanceOpt : Geocoding$Reventless.defaultMinRelevance;
53
+ let ambiguityMargin = ambiguityMarginOpt !== undefined ? ambiguityMarginOpt : Geocoding$Reventless.defaultAmbiguityMargin;
54
+ if (answer.TAG === "Ok") {
55
+ let top = Geocoding$Reventless.assess(answer._0, minRelevance, ambiguityMargin);
56
+ if (typeof top !== "object") {
57
+ return {
58
+ TAG: "Unresolvable",
59
+ reason: `no candidates for "` + requestedFor + `"`
60
+ };
61
+ }
62
+ switch (top.TAG) {
63
+ case "Confident" :
64
+ return {
65
+ TAG: "Located",
66
+ point: top._0.point
67
+ };
68
+ case "Unscored" :
69
+ return {
70
+ TAG: "Unresolvable",
71
+ reason: `the geocoder returned "` + top._0.label + `" for "` + requestedFor + `" without scoring it, and an unscored answer cannot be accepted unattended`
72
+ };
73
+ case "LowRelevance" :
74
+ return {
75
+ TAG: "Unresolvable",
76
+ reason: `the best match for "` + requestedFor + `" was "` + top.top.label + `" at relevance ` + (top.score.toString() + `, below the ` + top.floor.toString() + ` needed to store one`)
77
+ };
78
+ case "Ambiguous" :
79
+ return {
80
+ TAG: "Unresolvable",
81
+ reason: `"` + requestedFor + `" matched "` + top.top.label + `" and "` + top.runnerUp.label + `" about equally well`
82
+ };
83
+ }
84
+ } else {
85
+ let tmp = answer._0;
86
+ if (typeof tmp !== "object") {
87
+ return {
88
+ TAG: "Unresolvable",
89
+ reason: `no match for "` + requestedFor + `"`
90
+ };
91
+ } else {
92
+ return;
93
+ }
94
+ }
95
+ }
96
+
97
+ export {
98
+ schema$1 as schema,
99
+ point,
100
+ isLocated,
101
+ reason,
102
+ ofSearch,
103
+ }
104
+ /* schema Not a pure module */
@@ -1,34 +1,21 @@
1
1
  /**
2
2
  The one marker every typed semantic marks itself with.
3
3
 
4
- A semantic type says what a field's value *is* — a date-time, a reference to
5
- another entity, a ref into an object store — as a property of the field's
6
- **type**, not as a string stapled beside it. Every layer downstream then derives
7
- from that single declaration: validation, the wire contract, the UI widget it
8
- gets rendered with, and eventually the infrastructure provisioned for it.
9
-
10
- Typed markers predate this module, and each was bespoke: `DateTime` carried its
11
- own metadata id, `Reference` carried another, and the schema walk detected both
12
- by hardcoded special case. That made every new typed marker new detection code.
13
- One shared marker means the walk reads a semantic generically and a new semantic
14
- type is a new *value*, not a new branch.
15
-
16
- The payload is a real variant rather than free-form JSON. The semantic
17
- vocabulary is framework-owned — an application declares a field *is* a storage
18
- ref, it does not invent what a storage ref means — so the set is closed, and a
19
- closed set typed here is one the compiler checks at every producer and consumer.
20
- It also keeps `Reference.getTarget` a total typed function instead of a decode
21
- that can fail at runtime.
4
+ A semantic says what a field's value *is*, as a property of its type; validation,
5
+ the wire contract and the UI widget all derive from that one declaration. One
6
+ shared marker means the schema walk reads semantics generically, so a new
7
+ semantic is a new value rather than a new branch.
8
+
9
+ The payload is a typed variant because the vocabulary is framework-owned and
10
+ closed — which also keeps `Reference.getTarget` total.
22
11
  */
23
12
 
24
13
  /** Which entity a reference field points to. */
25
14
  type referenceTarget = {entity: string, plugin: option<string>}
26
15
 
27
- /** Which object store a storage-ref / offload field's value lives in. `plugin` is
28
- absent when the store belongs to the declaring plugin, which is the common
29
- case. `threshold` is the per-field inline-vs-offloaded byte cut an `@offload`
30
- field may declare (`None` for `@storageRef`, which is always a ref, and for
31
- `@offload` fields that leave it to the platform default). */
16
+ /** Which object store the value lives in. `plugin` is absent for the declaring
17
+ plugin's own store; `threshold` is `@offload`'s per-field byte cut, `None`
18
+ when it defers to the platform default. */
32
19
  type storeTarget = {plugin: option<string>, store: string, threshold: option<int>}
33
20
 
34
21
  /** Per-semantic detail, for the semantics that carry any. */
@@ -40,37 +27,25 @@ type payload =
40
27
  /** A field's semantic: the vocabulary id, plus its detail. */
41
28
  type t = {id: string, payload: payload}
42
29
 
43
- /**
44
- The semantic ids the framework itself defines.
45
-
46
- These strings are the wire vocabulary — they are what `x-reventless-semantic`
47
- carries, and the same vocabulary the string annotation path already uses, so the
48
- type path and the annotation path converge on one wire format rather than two.
49
- */
30
+ /** The semantic ids the framework defines. These strings are the wire vocabulary
31
+ `x-reventless-semantic` carries, shared with the annotation path. */
50
32
  module Id = {
51
33
  let dateTime = "dateTime"
52
34
  let reference = "reference"
53
35
  let storageRef = "storageRef"
54
- // A field whose large value the client stored in a content-addressed object
55
- // store and carries by reference (inline below a size threshold). Sibling of
56
- // `storageRef`: same `StoredIn` store declaration, but an inline-or-reference
57
- // value rather than an always-a-ref path string.
36
+ // Like `storageRef`, but inline-or-reference rather than always a ref path.
58
37
  let offload = "offload"
59
38
 
60
- // The uploadable family: the same `StoredIn` declaration as `storageRef`,
61
- // plus a statement about what the value *is*, so one declaration picks both a
62
- // renderer and an upload endpoint. The store is derived from the field name by
63
- // the ppx rather than written on the type.
39
+ // `storageRef` plus what the value is, so one declaration picks a renderer and
40
+ // an upload endpoint. The ppx derives the store from the field name.
64
41
  let uploadableImage = "uploadableImage"
65
42
  let uploadableFile = "uploadableFile"
66
43
 
67
- // The same content facts with no store attached, for values the platform
68
- // reads but does not own. Nothing is provisioned for these.
44
+ // The same content facts with no store — nothing is provisioned.
69
45
  let imageRef = "imageRef"
70
46
  let fileRef = "fileRef"
71
47
 
72
- // The branded scalars. Each refines a `string` or a number without changing
73
- // its shape, so a field gains one of these without anything stored changing.
48
+ // Branded scalars: a refinement, so adopting one changes nothing stored.
74
49
  let email = "email"
75
50
  let phone = "phone"
76
51
  let url = "url"
@@ -79,23 +54,18 @@ module Id = {
79
54
  let duration = "duration"
80
55
  let color = "color"
81
56
 
82
- // The first composite that is not infrastructure. Unlike the seven above it
83
- // this one changes a field's *shape* — a number becomes an object — so it is
84
- // a wire-breaking declaration rather than a refinement of one.
57
+ // The first composite: changes a field's shape, so it is wire-breaking.
85
58
  let money = "money"
86
59
 
87
- // The second composite. A pair of ISO-8601 instants as one value, replacing a
88
- // span the UI used to guess from a `start*`/`end*` name pair. Like `money` it
89
- // is an object on the wire; unlike it, adopting it as a *new* optional field
90
- // is additive — an absent optional decodes to `None`.
60
+ // A pair of ISO-8601 instants. Adopting it as a new optional field is additive.
91
61
  let dateRange = "dateRange"
92
62
 
93
- // The third composite, and the cheapest to adopt. A latitude/longitude pair as
94
- // one value, replacing a point the UI used to guess from a `lat`/`lng` name
95
- // pair. Most coordinate fields already store `{lat, lng}` as a hand-rolled
96
- // record, so retyping one is shape-preserving: the wire is unchanged and
97
- // nothing stored needs upcasting.
63
+ // A lat/lng pair. Cheapest to adopt: `{lat, lng}` is already the stored shape.
98
64
  let geoPoint = "geoPoint"
65
+
66
+ // The first composite that is a union rather than an object. Collapses fields,
67
+ // so adopting it changes the wire and rebuilds a derived view.
68
+ let geolocation = "geolocation"
99
69
  }
100
70
 
101
71
  let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless", ~name="semantic")
@@ -104,20 +74,10 @@ let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless",
104
74
  let mark = (schema: S.t<'a>, ~id: string, ~payload: payload=Plain): S.t<'a> =>
105
75
  schema->S.Metadata.set(~id=semanticId, {id, payload})
106
76
 
107
- /**
108
- A schema that validates with `check` and carries the semantic `id`.
109
-
110
- The branded scalars all have the same shape — one constructor function that
111
- defines the grammar, and a schema that must agree with it — and `StorageRef`
112
- established that the schema is *derived* from the constructor rather than
113
- hand-rolling a second check beside it. Deriving it here makes that structural:
114
- there is one place a grammar can be written, so there is nowhere for a second
115
- one to drift.
116
- */
117
- // sury's refiner is a predicate with a fixed message, so the per-value reason
118
- // `check` returns is not threaded into the schema error; call the scalar's own
119
- // `fromString`/`fromFloat` directly when the caller needs to report which rule
120
- // the value broke.
77
+ /** A schema that validates with `check` and carries the semantic `id`, derived
78
+ from the constructor so no second grammar can drift from it. */
79
+ // sury's refiner takes a fixed message, so `check`'s per-value reason is lost
80
+ // here; call the scalar's own `fromString`/`fromFloat` to report which rule broke.
121
81
  let refined = (base: S.t<'a>, ~id: string, ~check: 'a => result<'a, string>): S.t<'a> =>
122
82
  base
123
83
  ->S.refine(
@@ -130,22 +90,16 @@ let refined = (base: S.t<'a>, ~id: string, ~check: 'a => result<'a, string>): S.
130
90
  )
131
91
  ->mark(~id)
132
92
 
133
- /** A value as it should read back to the person who typed it. Rejection messages
134
- reach forms through `validateInput`, so they quote the offending value. */
93
+ /** A value as it should read back to whoever typed it — rejection messages quote
94
+ the offending value. */
135
95
  let showString = (raw: string): string => raw->JSON.Encode.string->JSON.stringify
136
96
 
137
97
  /**
138
98
  The schema an optional field's wrapper stands for, if it is one.
139
99
 
140
- sury-ppx compiles `f?: X` to a union of `X`'s schema with `Undefined`/`Null`, and
141
- that wrapper is a new schema carrying no metadata of its own. Every reader that
142
- looks *through* a field — for its semantic, or for the element type inside it —
143
- needs the same one-level unwrap, so it lives here once rather than once per
144
- reader.
145
-
146
- Only a union with exactly one non-null variant is followed: that is the shape an
147
- optional field has, and a genuine multi-variant union has no single inner schema
148
- that could stand for the whole.
100
+ sury-ppx compiles `f?: X` to a union with `Undefined`/`Null`, and that wrapper
101
+ carries no metadata of its own. Only a union with exactly one non-null variant is
102
+ followed — a real multi-variant union has no single inner schema.
149
103
  */
150
104
  let unwrapOptional = (schema: S.t<unknown>): option<S.t<unknown>> =>
151
105
  switch schema {
@@ -165,14 +119,8 @@ let unwrapOptional = (schema: S.t<unknown>): option<S.t<unknown>> =>
165
119
  /**
166
120
  The semantic a field's schema carries, if any.
167
121
 
168
- An **optional** field keeps its marker one level down, inside the wrapper
169
- `unwrapOptional` describes. So a walk that reads only the outer schema sees
170
- `imageUrl?: string` as carrying no semantic at all — the store goes undeclared,
171
- the reference goes uncollected, the branded scalar loses its brand. Every reader
172
- converges here, so following the wrapper once here is what keeps "optional" a
173
- statement about presence rather than a way to lose the field's type.
174
-
175
- The outer schema is read first, so a marker set on the wrapper itself still wins.
122
+ An optional field keeps its marker inside the wrapper, so reading only the outer
123
+ schema loses it. The outer schema is read first, so a marker on the wrapper wins.
176
124
  */
177
125
  let rec getFrom = (schema: S.t<unknown>): option<t> =>
178
126
  switch S.Metadata.get(schema, ~id=semanticId) {
@@ -22,7 +22,8 @@ let Id = {
22
22
  color: "color",
23
23
  money: "money",
24
24
  dateRange: "dateRange",
25
- geoPoint: "geoPoint"
25
+ geoPoint: "geoPoint",
26
+ geolocation: "geolocation"
26
27
  };
27
28
 
28
29
  let semanticId = Sury.$Metadata_Id_make("reventless", "semantic");