@reventlessdev/reventless-spec 3.0.0-alpha.118 → 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,20 @@
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
+
13
+ # 3.0.0-alpha.119 (2026-08-20)
14
+
15
+ ### Features
16
+
17
+ * **api:** emit a tagged-union state field as a GraphQL union ([3a380c0](https://github.com/ReventlessDev/reventless-core/commit/3a380c0ab055b87048d90a852ec1664f6aab6b00))
18
+
19
+
6
20
  # 3.0.0-alpha.118 (2026-08-18)
7
21
 
8
22
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.118",
3
+ "version": "3.0.0-alpha.120",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -0,0 +1,238 @@
1
+ /**
2
+ A variant used as a **field** of a queryable's state — one fact with several
3
+ shapes, rather than several fields that have to be kept in step by hand.
4
+
5
+ ```rescript
6
+ @schema
7
+ type geolocation =
8
+ | Pending({requestedFor: string})
9
+ | Located({point: Reventless.GeoPoint.t})
10
+ | Unresolvable({reason: string})
11
+ ```
12
+
13
+ The value is stored the way sury encodes it — `{"TAG":"Located","point":{…}}` —
14
+ and reaches GraphQL as a union of one object type per arm. Both halves need the
15
+ *same* name for the union, and neither can derive it from the other: the SDL
16
+ emitter walks a schema it reaches through a field path, and the write path has
17
+ only the schema in hand. So the name is carried **on the schema**, set once at
18
+ the declaration by `named` — the ppx does it for a variant a state record uses,
19
+ and a framework type carrying a union does it beside its `Semantic.mark`.
20
+
21
+ A union with no name is not emitted as one. It falls through to the IR's
22
+ `Unknown`, which is a `String` in the SDL — the behaviour that predates this
23
+ module — except that it is now reported at deploy time instead of silently.
24
+ */
25
+ let unionNameId: S.Metadata.Id.t<string> = S.Metadata.Id.make(
26
+ ~namespace="reventless",
27
+ ~name="taggedUnionName",
28
+ )
29
+
30
+ /**
31
+ Names a union, so that the type emitted for it and the `__typename` stamped into
32
+ every stored value of it agree.
33
+
34
+ Written at the declaration, never at the field: two fields holding the same union
35
+ hold the same type, and naming it per field is what produces one GraphQL type per
36
+ field path — the mistake `semanticCompositeNames` exists to undo for `Money`.
37
+ */
38
+ let named = (~name: string, schema: S.t<'a>): S.t<'a> =>
39
+ schema->S.Metadata.set(~id=unionNameId, name)
40
+
41
+ /**
42
+ The name a union field's schema carries.
43
+
44
+ Read straight off the field's schema, wrapper included: sury's `option` keeps the
45
+ metadata of what it wraps, which matters because it does *not* keep the union as
46
+ a nested schema — `option<t>` flattens the arms and the `undefined` into one
47
+ `anyOf`, leaving nothing inside to consult. `TaggedUnionTest` pins that, since a
48
+ sury release that stopped preserving it would turn every optional union field
49
+ into a `String` with no compile error anywhere.
50
+ */
51
+ let getName = (schema: S.t<unknown>): option<string> => S.Metadata.get(schema, ~id=unionNameId)
52
+
53
+ /**
54
+ The GraphQL type emitted for one arm: the union's name with the arm's own
55
+ appended (`Geolocation` + `Located` = `GeolocationLocated`).
56
+
57
+ One derivation, used by the SDL emitter and by the write-time stamp. A second
58
+ spelling of this rule anywhere is a `__typename` that resolves to no member,
59
+ which GraphQL reports as a null — and a null in a non-nullable field takes its
60
+ parent with it.
61
+ */
62
+ let memberTypeName = (~union: string, ~arm: string): string => union ++ arm
63
+
64
+ /** The key a stored union value carries its member type under. */
65
+ let typenameKey = "__typename"
66
+
67
+ /** One arm: the constructor name sury writes into `TAG`, and the arm's schema. */
68
+ type arm = {tag: string, schema: S.t<unknown>}
69
+
70
+ // A payload sury named rather than the author: `| Located(GeoPoint.t)` encodes to
71
+ // `{"TAG":"Located","_0":{…}}`, and `_0` would be published as an SDL field name
72
+ // and as a stored key. The ppx refuses the shape at its declaration; this refuses
73
+ // it again for a union declared where the ppx cannot see it, by declining to
74
+ // classify the union at all.
75
+ let isPositionalName = (name: string): bool =>
76
+ name->String.startsWith("_") &&
77
+ name->String.length > 1 &&
78
+ name
79
+ ->String.slice(~start=1, ~end=name->String.length)
80
+ ->String.split("")
81
+ ->Array.every(c => c >= "0" && c <= "9")
82
+
83
+ /**
84
+ The arms of a schema that is a union of tagged objects, or `None`.
85
+
86
+ Refuses three shapes, all for reasons that are GraphQL's rather than sury's, and
87
+ all of which encode and decode perfectly well:
88
+
89
+ - a payload-less arm (`| Pending`), which sury writes as the bare string
90
+ `"Pending"` — a union member must be an object type;
91
+ - an arm with no field of its own (`| Pending({})`), which would imply a member
92
+ type with zero fields;
93
+ - a positional payload, whose field name is the compiler's `_0`.
94
+
95
+ Declining leaves the field an `Unknown`, which is reported where it is emitted.
96
+ */
97
+ let armsOf = (schema: S.t<unknown>): option<array<arm>> =>
98
+ switch schema {
99
+ | AnyOf({anyOf}) =>
100
+ let members = anyOf->Array.filter(v =>
101
+ switch v {
102
+ | Null(_) | Undefined(_) => false
103
+ | _ => true
104
+ }
105
+ )
106
+ if members->Array.length < 2 {
107
+ None
108
+ } else {
109
+ let arms = members->Array.filterMap(member =>
110
+ switch member {
111
+ | Object({properties}) =>
112
+ switch properties->Dict.get("TAG") {
113
+ | Some(String({const: ?Some(tag)})) =>
114
+ let fields = properties->Dict.toArray->Array.filter(((name, _)) => name !== "TAG")
115
+ if (
116
+ fields->Array.length == 0 ||
117
+ fields->Array.some(((name, _)) => isPositionalName(name))
118
+ ) {
119
+ None
120
+ } else {
121
+ Some({tag, schema: member})
122
+ }
123
+ | _ => None
124
+ }
125
+ | _ => None
126
+ }
127
+ )
128
+ arms->Array.length == members->Array.length ? Some(arms) : None
129
+ }
130
+ | _ => None
131
+ }
132
+
133
+ /** A named union of tagged objects: its name and its arms, or `None`. */
134
+ let classify = (schema: S.t<unknown>): option<(string, array<arm>)> =>
135
+ switch (getName(schema), armsOf(schema)) {
136
+ | (Some(name), Some(arms)) => Some((name, arms))
137
+ | _ => None
138
+ }
139
+
140
+ /**
141
+ Whether a schema is a union of tagged objects that carries no name — the one case
142
+ worth telling a deploy about, since it is a union the author meant and the SDL
143
+ cannot emit.
144
+ */
145
+ let isUnnamedUnion = (schema: S.t<unknown>): bool =>
146
+ getName(schema)->Option.isNone && armsOf(schema)->Option.isSome
147
+
148
+ /**
149
+ Stamps `__typename` into every union value inside an encoded row, in place.
150
+
151
+ Written once at save rather than by each read door: both AppSync and graphql-js
152
+ resolve a union member from `__typename` on the value they are handed, and the
153
+ AppSync resolvers hand back the stored item unchanged. The doors that would each
154
+ have to stamp number fourteen across three backends, and the live change channel
155
+ — which carries the row as raw JSON, past the typed field entirely — is reachable
156
+ from none of them. Stamping here is one place, and it is the only one both
157
+ channels share.
158
+
159
+ Driven by the schema, so a row of a view with no union field is walked and left
160
+ byte-identical.
161
+ */
162
+ let rec stampInto = (~schema: S.t<unknown>, json: JSON.t): unit =>
163
+ switch classify(schema) {
164
+ | Some((name, arms)) =>
165
+ switch json->JSON.Decode.object {
166
+ | Some(obj) =>
167
+ switch obj->Dict.get("TAG")->Option.flatMap(JSON.Decode.string) {
168
+ | Some(tag) =>
169
+ obj->Dict.set(typenameKey, JSON.Encode.string(memberTypeName(~union=name, ~arm=tag)))
170
+ // An arm's own fields may hold unions too, so the arm is walked as the
171
+ // record it is — the union case above cannot recurse into it, since a
172
+ // union's schema says nothing about which arm this value took.
173
+ switch arms->Array.find(a => a.tag === tag) {
174
+ | Some({schema: armSchema}) => stampMembers(~schema=armSchema, json)
175
+ | None => ()
176
+ }
177
+ | None => ()
178
+ }
179
+ | None => ()
180
+ }
181
+ | None => stampMembers(~schema, json)
182
+ }
183
+
184
+ and stampMembers = (~schema: S.t<unknown>, json: JSON.t): unit =>
185
+ switch schema {
186
+ | Object({properties}) =>
187
+ switch json->JSON.Decode.object {
188
+ | Some(obj) =>
189
+ properties
190
+ ->Dict.toArray
191
+ ->Array.forEach(((name, propSchema)) =>
192
+ switch obj->Dict.get(name) {
193
+ | Some(value) => stampInto(~schema=propSchema, value)
194
+ | None => ()
195
+ }
196
+ )
197
+ | None => ()
198
+ }
199
+ | Array({items, additionalItems}) =>
200
+ let itemSchema = switch items->Array.get(0) {
201
+ | Some(itemSchema) => Some(itemSchema)
202
+ | None =>
203
+ switch additionalItems {
204
+ | Schema(s) => Some(s)
205
+ | _ => None
206
+ }
207
+ }
208
+ switch (itemSchema, json->JSON.Decode.array) {
209
+ | (Some(itemSchema), Some(values)) =>
210
+ values->Array.forEach(value => stampInto(~schema=itemSchema, value))
211
+ | _ => ()
212
+ }
213
+ | _ =>
214
+ // An optional field wraps its schema in a union with `undefined`; the value
215
+ // that reached us is the inner one either way.
216
+ switch Semantic.unwrapOptional(schema) {
217
+ | Some(inner) => stampInto(~schema=inner, json)
218
+ | None => ()
219
+ }
220
+ }
221
+
222
+ /**
223
+ The union fields an object schema declares, named and unnamed alike, with the
224
+ name where there is one.
225
+
226
+ The SDL emitter and the deploy-time report both need to say *which field* — a
227
+ report that names only the view leaves the author grepping.
228
+ */
229
+ let fieldsOf = (schema: S.t<unknown>): array<(string, option<string>)> =>
230
+ switch schema {
231
+ | Object({properties}) =>
232
+ properties
233
+ ->Dict.toArray
234
+ ->Array.filterMap(((name, propSchema)) =>
235
+ armsOf(propSchema)->Option.isSome ? Some((name, getName(propSchema))) : None
236
+ )
237
+ | _ => []
238
+ }
@@ -0,0 +1,193 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
8
+
9
+ let unionNameId = Sury.$Metadata_Id_make("reventless", "taggedUnionName");
10
+
11
+ function named(name, schema) {
12
+ return Sury.$Metadata_set(schema, unionNameId, name);
13
+ }
14
+
15
+ function getName(schema) {
16
+ return Sury.$Metadata_get(schema, unionNameId);
17
+ }
18
+
19
+ function memberTypeName(union, arm) {
20
+ return union + arm;
21
+ }
22
+
23
+ let typenameKey = "__typename";
24
+
25
+ function isPositionalName(name) {
26
+ if (name.startsWith("_") && name.length > 1) {
27
+ return name.slice(1, name.length).split("").every(c => {
28
+ if (c >= "0") {
29
+ return c <= "9";
30
+ } else {
31
+ return false;
32
+ }
33
+ });
34
+ } else {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function armsOf(schema) {
40
+ if (schema.type !== "anyOf") {
41
+ return;
42
+ }
43
+ let members = schema.anyOf.filter(v => {
44
+ switch (v.type) {
45
+ case "null" :
46
+ case "undefined" :
47
+ return false;
48
+ default:
49
+ return true;
50
+ }
51
+ });
52
+ if (members.length < 2) {
53
+ return;
54
+ }
55
+ let arms = Stdlib_Array.filterMap(members, member => {
56
+ if (member.type !== "object") {
57
+ return;
58
+ }
59
+ let properties = member.properties;
60
+ let match = properties["TAG"];
61
+ if (match === undefined) {
62
+ return;
63
+ }
64
+ if (match.type !== "string") {
65
+ return;
66
+ }
67
+ let tag = match.const;
68
+ if (tag === undefined) {
69
+ return;
70
+ }
71
+ let fields = Object.entries(properties).filter(param => param[0] !== "TAG");
72
+ if (fields.length === 0 || fields.some(param => isPositionalName(param[0]))) {
73
+ return;
74
+ } else {
75
+ return {
76
+ tag: tag,
77
+ schema: member
78
+ };
79
+ }
80
+ });
81
+ if (arms.length === members.length) {
82
+ return arms;
83
+ }
84
+ }
85
+
86
+ function classify(schema) {
87
+ let match = Sury.$Metadata_get(schema, unionNameId);
88
+ let match$1 = armsOf(schema);
89
+ if (match !== undefined && match$1 !== undefined) {
90
+ return [
91
+ match,
92
+ match$1
93
+ ];
94
+ }
95
+ }
96
+
97
+ function isUnnamedUnion(schema) {
98
+ if (Stdlib_Option.isNone(Sury.$Metadata_get(schema, unionNameId))) {
99
+ return Stdlib_Option.isSome(armsOf(schema));
100
+ } else {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function stampInto(schema, json) {
106
+ let match = classify(schema);
107
+ if (match === undefined) {
108
+ return stampMembers(schema, json);
109
+ }
110
+ let obj = Stdlib_JSON.Decode.object(json);
111
+ if (obj === undefined) {
112
+ return;
113
+ }
114
+ let tag = Stdlib_Option.flatMap(obj["TAG"], Stdlib_JSON.Decode.string);
115
+ if (tag === undefined) {
116
+ return;
117
+ }
118
+ obj[typenameKey] = match[0] + tag;
119
+ let match$1 = match[1].find(a => a.tag === tag);
120
+ if (match$1 !== undefined) {
121
+ return stampMembers(match$1.schema, json);
122
+ }
123
+ }
124
+
125
+ function stampMembers(schema, json) {
126
+ switch (schema.type) {
127
+ case "array" :
128
+ let additionalItems = schema.additionalItems;
129
+ let itemSchema = schema.items[0];
130
+ let itemSchema$1 = itemSchema !== undefined ? itemSchema : (
131
+ additionalItems === "strip" || additionalItems === "strict" ? undefined : additionalItems
132
+ );
133
+ let match = Stdlib_JSON.Decode.array(json);
134
+ if (itemSchema$1 !== undefined && match !== undefined) {
135
+ match.forEach(value => stampInto(itemSchema$1, value));
136
+ return;
137
+ } else {
138
+ return;
139
+ }
140
+ case "object" :
141
+ let obj = Stdlib_JSON.Decode.object(json);
142
+ if (obj !== undefined) {
143
+ Object.entries(schema.properties).forEach(param => {
144
+ let value = obj[param[0]];
145
+ if (value !== undefined) {
146
+ return stampInto(param[1], value);
147
+ }
148
+ });
149
+ return;
150
+ } else {
151
+ return;
152
+ }
153
+ default:
154
+ let inner = Semantic$Reventless.unwrapOptional(schema);
155
+ if (inner !== undefined) {
156
+ return stampInto(inner, json);
157
+ } else {
158
+ return;
159
+ }
160
+ }
161
+ }
162
+
163
+ function fieldsOf(schema) {
164
+ if (schema.type === "object") {
165
+ return Stdlib_Array.filterMap(Object.entries(schema.properties), param => {
166
+ let propSchema = param[1];
167
+ if (Stdlib_Option.isSome(armsOf(propSchema))) {
168
+ return [
169
+ param[0],
170
+ Sury.$Metadata_get(propSchema, unionNameId)
171
+ ];
172
+ }
173
+ });
174
+ } else {
175
+ return [];
176
+ }
177
+ }
178
+
179
+ export {
180
+ unionNameId,
181
+ named,
182
+ getName,
183
+ memberTypeName,
184
+ typenameKey,
185
+ isPositionalName,
186
+ armsOf,
187
+ classify,
188
+ isUnnamedUnion,
189
+ stampInto,
190
+ stampMembers,
191
+ fieldsOf,
192
+ }
193
+ /* unionNameId Not a pure module */
@@ -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");