@reventlessdev/reventless-spec 3.0.0-alpha.100
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 +931 -0
- package/LICENSE +202 -0
- package/README.md +109 -0
- package/package.json +49 -0
- package/rescript.json +32 -0
- package/run-generator.mjs +2 -0
- package/run-platform-generator.mjs +2 -0
- package/scripts/generate-currency.mjs +215 -0
- package/scripts/iso-4217-list-one.xml +1956 -0
- package/src/AnsiStyle.res +40 -0
- package/src/AnsiStyle.res.mjs +54 -0
- package/src/LogPrefix.res +192 -0
- package/src/LogPrefix.res.mjs +159 -0
- package/src/PackageVersion.res +67 -0
- package/src/PackageVersion.res.mjs +81 -0
- package/src/components/Aggregate.res +64 -0
- package/src/components/Aggregate.res.mjs +2 -0
- package/src/components/AutomationSlice.res +279 -0
- package/src/components/AutomationSlice.res.mjs +30 -0
- package/src/components/CapabilityManifest.res +74 -0
- package/src/components/CapabilityManifest.res.mjs +61 -0
- package/src/components/ComponentKind.res +99 -0
- package/src/components/ComponentKind.res.mjs +125 -0
- package/src/components/Counter.res +24 -0
- package/src/components/Counter.res.mjs +2 -0
- package/src/components/DcbDecode.res +118 -0
- package/src/components/DcbDecode.res.mjs +100 -0
- package/src/components/DcbScopeInference.res +244 -0
- package/src/components/DcbScopeInference.res.mjs +177 -0
- package/src/components/DcbTag.res +1335 -0
- package/src/components/DcbTag.res.mjs +898 -0
- package/src/components/DcbValidation.res +427 -0
- package/src/components/DcbValidation.res.mjs +423 -0
- package/src/components/DisplayName.res +40 -0
- package/src/components/DisplayName.res.mjs +26 -0
- package/src/components/ExtensionPoint.res +27 -0
- package/src/components/ExtensionPoint.res.mjs +2 -0
- package/src/components/InboundTranslationSlice.res +85 -0
- package/src/components/InboundTranslationSlice.res.mjs +2 -0
- package/src/components/OutboundTranslationSlice.res +153 -0
- package/src/components/OutboundTranslationSlice.res.mjs +2 -0
- package/src/components/Plugin.res +538 -0
- package/src/components/Plugin.res.mjs +264 -0
- package/src/components/PluginName.res +39 -0
- package/src/components/PluginName.res.mjs +45 -0
- package/src/components/ReadModel.res +199 -0
- package/src/components/ReadModel.res.mjs +18 -0
- package/src/components/Reference.res +55 -0
- package/src/components/Reference.res.mjs +50 -0
- package/src/components/Snapshot.res +26 -0
- package/src/components/Snapshot.res.mjs +2 -0
- package/src/components/StateAnnotations.res +97 -0
- package/src/components/StateAnnotations.res.mjs +15 -0
- package/src/components/StateChangeSlice.res +131 -0
- package/src/components/StateChangeSlice.res.mjs +2 -0
- package/src/components/StateViewSlice.res +123 -0
- package/src/components/StateViewSlice.res.mjs +2 -0
- package/src/components/Task.res +62 -0
- package/src/components/Task.res.mjs +2 -0
- package/src/generator/Codegen.res +842 -0
- package/src/generator/Codegen.res.mjs +565 -0
- package/src/generator/Config.res +106 -0
- package/src/generator/Config.res.mjs +69 -0
- package/src/generator/Discovery.res +230 -0
- package/src/generator/Discovery.res.mjs +198 -0
- package/src/generator/Generator_Node.res +14 -0
- package/src/generator/Generator_Node.res.mjs +18 -0
- package/src/generator/Pairing.res +460 -0
- package/src/generator/Pairing.res.mjs +415 -0
- package/src/generator/PlatformCodegen.res +207 -0
- package/src/generator/PlatformCodegen.res.mjs +154 -0
- package/src/generator/PlatformGenerator.res +126 -0
- package/src/generator/PlatformGenerator.res.mjs +114 -0
- package/src/generator/PlatformManifests.res +203 -0
- package/src/generator/PlatformManifests.res.mjs +212 -0
- package/src/generator/PluginGenerator.res +57 -0
- package/src/generator/PluginGenerator.res.mjs +73 -0
- package/src/semantic/Bytes.res +54 -0
- package/src/semantic/Bytes.res.mjs +38 -0
- package/src/semantic/Capabilities.res +43 -0
- package/src/semantic/Capabilities.res.mjs +17 -0
- package/src/semantic/Color.res +51 -0
- package/src/semantic/Color.res.mjs +29 -0
- package/src/semantic/Currency.res +598 -0
- package/src/semantic/Currency.res.mjs +743 -0
- package/src/semantic/DateRange.res +148 -0
- package/src/semantic/DateRange.res.mjs +74 -0
- package/src/semantic/Duration.res +53 -0
- package/src/semantic/Duration.res.mjs +26 -0
- package/src/semantic/Email.res +51 -0
- package/src/semantic/Email.res.mjs +31 -0
- package/src/semantic/GeoPoint.res +226 -0
- package/src/semantic/GeoPoint.res.mjs +190 -0
- package/src/semantic/Geocoding.res +127 -0
- package/src/semantic/Geocoding.res.mjs +36 -0
- package/src/semantic/Money.res +196 -0
- package/src/semantic/Money.res.mjs +138 -0
- package/src/semantic/Offload.res +294 -0
- package/src/semantic/Offload.res.mjs +191 -0
- package/src/semantic/Percent.res +53 -0
- package/src/semantic/Percent.res.mjs +33 -0
- package/src/semantic/Phone.res +55 -0
- package/src/semantic/Phone.res.mjs +29 -0
- package/src/semantic/Semantic.res +162 -0
- package/src/semantic/Semantic.res.mjs +95 -0
- package/src/semantic/StorageRef.res +164 -0
- package/src/semantic/StorageRef.res.mjs +111 -0
- package/src/semantic/Url.res +66 -0
- package/src/semantic/Url.res.mjs +48 -0
- package/src/types/Authorization.res +23 -0
- package/src/types/Authorization.res.mjs +33 -0
- package/src/types/Behavior.res +86 -0
- package/src/types/Behavior.res.mjs +2 -0
- package/src/types/DateTime.res +29 -0
- package/src/types/DateTime.res.mjs +16 -0
- package/src/types/EventMapping.res +100 -0
- package/src/types/EventMapping.res.mjs +15 -0
- package/src/types/Handler.res +30 -0
- package/src/types/Handler.res.mjs +2 -0
- package/src/types/Id.res +75 -0
- package/src/types/Id.res.mjs +37 -0
- package/src/types/Identity.res +46 -0
- package/src/types/Identity.res.mjs +51 -0
- package/src/types/Message.res +326 -0
- package/src/types/Message.res.mjs +186 -0
- package/src/types/Projection.res +220 -0
- package/src/types/Projection.res.mjs +44 -0
- package/src/types/QueryEngine.res +123 -0
- package/src/types/QueryEngine.res.mjs +12 -0
- package/src/types/ReadConsistency.res +38 -0
- package/src/types/ReadConsistency.res.mjs +29 -0
- package/src/types/Schedule.res +65 -0
- package/src/types/Schedule.res.mjs +68 -0
- package/src/types/SideEffect.res +45 -0
- package/src/types/SideEffect.res.mjs +2 -0
- package/src/types/StoredEvent.res +46 -0
- package/src/types/StoredEvent.res.mjs +32 -0
- package/src/types/Visibility.res +24 -0
- package/src/types/Visibility.res.mjs +25 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
A span of time: two ISO-8601 instants, a start and an end, as one value.
|
|
3
|
+
|
|
4
|
+
## Why the pair is the value, not two fields beside each other
|
|
5
|
+
|
|
6
|
+
Before this type a span was a *guess*. A view that wanted a calendar, a timeline
|
|
7
|
+
or a gantt bar asked which fields were date-like and took the first named
|
|
8
|
+
`start…` and the first named `end…`, independently. Three things go wrong and
|
|
9
|
+
all three are silent: two intervals in one row mispair across each other (a bar
|
|
10
|
+
drawn from one interval's start to the other's end); an interval named anything
|
|
11
|
+
else — `checkIn`/`checkOut`, `from`/`to` — is invisible; and a lone `started…`
|
|
12
|
+
point pairs with whatever `end…` is nearby. Making the two instants one value
|
|
13
|
+
removes the pairing question: a consumer either holds the span or it does not.
|
|
14
|
+
|
|
15
|
+
The parts keep their own `dateTime` marker, so a walker that only understands
|
|
16
|
+
date-times still sees them, and the whole carries `dateRange` besides. That is
|
|
17
|
+
the same layering `Money` uses — the amount keeps being a number, the composite
|
|
18
|
+
adds the meaning the number cannot carry.
|
|
19
|
+
|
|
20
|
+
## `[start, end)` — end exclusive
|
|
21
|
+
|
|
22
|
+
A range runs from `start` up to but not including `end`. `09:00–11:00` and
|
|
23
|
+
`11:00–13:00` are adjacent, not overlapping, and an all-day grid needs no
|
|
24
|
+
off-by-one-millisecond convention invented per consumer. This is a decision, not
|
|
25
|
+
a default: `overlaps` and `contains` are the only places it is written, and no
|
|
26
|
+
layout may re-decide it.
|
|
27
|
+
|
|
28
|
+
## Why the ordering rule is not enforced at decode
|
|
29
|
+
|
|
30
|
+
`start <= end` relates two fields, so — unlike `Money`'s wholeness, which is a
|
|
31
|
+
property of one field and rides on that field's schema — it is a *record-level*
|
|
32
|
+
invariant. sury 11.0.0-alpha.4 miscompiles a refinement wrapping a record schema
|
|
33
|
+
(it hoists the result object above the field reads, so parse and serialize throw
|
|
34
|
+
`Cannot access 'v0' before initialization`), and the pin has not moved. So the
|
|
35
|
+
rule lives in `validate`/`make` and **the schema does not enforce it at decode**.
|
|
36
|
+
|
|
37
|
+
This is the first semantic type in this library whose invariant the boundary does
|
|
38
|
+
not check: `Money` rejects a fractional minor unit on the way in; `DateRange`
|
|
39
|
+
will accept a range that ends before it starts if one is ever written. A reader
|
|
40
|
+
who assumes parity with `Money` assumes wrong. When sury fixes the record
|
|
41
|
+
refinement the rule moves into the schema and `validate` stays as its single
|
|
42
|
+
definition — the relationship `Money.validateAmount` has with `amountSchema`.
|
|
43
|
+
|
|
44
|
+
Parsing is `Date.fromString` on each instant. A range whose strings do not parse
|
|
45
|
+
is a decode-time problem the `DateTime` marker does not currently catch either,
|
|
46
|
+
so there is no second validation layer here — a reversed *parseable* range is
|
|
47
|
+
what `validate` catches, and an unparseable one is out of both their scope.
|
|
48
|
+
|
|
49
|
+
## How a field declares it
|
|
50
|
+
|
|
51
|
+
The field's declared type *is* `DateRange.t`, and sury-ppx resolves it to this
|
|
52
|
+
module's `schema`:
|
|
53
|
+
|
|
54
|
+
```rescript
|
|
55
|
+
@schema type state = {
|
|
56
|
+
orderId: string,
|
|
57
|
+
deliveryWindow: option<Reventless.DateRange.t>,
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
which serializes as `{"start": "2026-03-02T09:00:00Z", "end": "2026-03-02T11:00:00Z"}`.
|
|
62
|
+
|
|
63
|
+
**Introduced as a new optional field it is additive** — an absent optional
|
|
64
|
+
decodes to `None` for events written before it existed, so no upcaster and no
|
|
65
|
+
projection rebuild. It costs a log something only if it *collapses* an existing
|
|
66
|
+
`start*`/`end*` pair, which rewrites the wire shape the way `Money` rewrote
|
|
67
|
+
`price: float`. That collapse belongs to whoever builds the upcaster.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
@schema
|
|
71
|
+
type t = {
|
|
72
|
+
/** The instant the range opens, inclusive. */
|
|
73
|
+
start: @s.matches(DateTime.string) string,
|
|
74
|
+
/** The instant the range closes, **exclusive** — the range does not contain
|
|
75
|
+
it. `@as("end")` puts `end` on the wire (where the UI's own `GanttChart`
|
|
76
|
+
already spells it that way); `end_` is the source spelling because `end`
|
|
77
|
+
is awkward as a bare ReScript field. */
|
|
78
|
+
@as("end") end_: @s.matches(DateTime.string) string,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The sury schema for a date-range field, carrying the `dateRange` semantic.
|
|
82
|
+
|
|
83
|
+
Shadows the schema sury-ppx derived from the type above: the derived one is
|
|
84
|
+
the shape, and this adds the marker the shape cannot carry. The ordering rule
|
|
85
|
+
is deliberately *not* refined in here — see the module doc. */
|
|
86
|
+
let schema: S.t<t> = schema->Semantic.mark(~id=Semantic.Id.dateRange)
|
|
87
|
+
|
|
88
|
+
/** An instant as milliseconds since the epoch — `NaN` if it does not parse. The
|
|
89
|
+
one place a range's strings become numbers, so end-exclusivity and the
|
|
90
|
+
ordering rule are all expressed against a single parse. */
|
|
91
|
+
let millis = (instant: string): float => instant->Date.fromString->Date.getTime
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
Validate a range's ordering, saying why when it is reversed.
|
|
95
|
+
|
|
96
|
+
The single statement of the `start <= end` rule; `make` is derived from it, and
|
|
97
|
+
`schema` will be once sury's record refinement is fixed. An unparseable instant
|
|
98
|
+
is not caught here (see the module doc) — a reversed range means two instants
|
|
99
|
+
that both parse, the earlier one second.
|
|
100
|
+
*/
|
|
101
|
+
let validate = (range: t): result<t, string> =>
|
|
102
|
+
millis(range.start) > millis(range.end_)
|
|
103
|
+
? Error(
|
|
104
|
+
`a range ends before it starts: ${range.start} is after ${range.end_}. ` ++
|
|
105
|
+
`A range is [start, end) — the start is the earlier instant.`,
|
|
106
|
+
)
|
|
107
|
+
: Ok(range)
|
|
108
|
+
|
|
109
|
+
/** Build a validated range from its two instants. `end` is exclusive. */
|
|
110
|
+
let make = (~start: string, ~end_: string): result<t, string> => validate({start, end_})
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
The range's length as a `Duration`, in whole seconds — the composite composing
|
|
114
|
+
with one of the branded scalars.
|
|
115
|
+
|
|
116
|
+
Total: a valid range has a non-negative length, and a zero-length range is
|
|
117
|
+
zero seconds. Truncated to whole seconds because that is what `Duration` is.
|
|
118
|
+
*/
|
|
119
|
+
let duration = (range: t): Duration.t =>
|
|
120
|
+
Duration.unsafe(Math.trunc((millis(range.end_) -. millis(range.start)) /. 1000.0)->Float.toInt)
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
Whether an instant falls within the range — at or after `start`, strictly before
|
|
124
|
+
`end`. End-exclusive, so the instant that opens the next adjacent range is *not*
|
|
125
|
+
contained by this one. One of the two places `[start, end)` is decided.
|
|
126
|
+
*/
|
|
127
|
+
let contains = (range: t, instant: string): bool => {
|
|
128
|
+
let t = millis(instant)
|
|
129
|
+
t >= millis(range.start) && t < millis(range.end_)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
Whether two ranges share any instant. End-exclusive: `09:00–11:00` and
|
|
134
|
+
`11:00–13:00` are adjacent and do *not* overlap. The other place `[start, end)`
|
|
135
|
+
is decided — a layout that re-decides it will disagree with this.
|
|
136
|
+
*/
|
|
137
|
+
let overlaps = (a: t, b: t): bool =>
|
|
138
|
+
millis(a.start) < millis(b.end_) && millis(b.start) < millis(a.end_)
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
The range as text: the two instants with an en dash between them —
|
|
142
|
+
`"2026-03-02T09:00:00Z – 2026-03-02T11:00:00Z"`.
|
|
143
|
+
|
|
144
|
+
Locale-independent, matching the rest of the framework's formatters: the same
|
|
145
|
+
value reads the same in every log line and every test. A calendar-style
|
|
146
|
+
rendering is the presentation layer's job.
|
|
147
|
+
*/
|
|
148
|
+
let format = (range: t): string => `${range.start} – ${range.end_}`
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as DateTime$Reventless from "../types/DateTime.res.mjs";
|
|
5
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
6
|
+
|
|
7
|
+
let schema = S.schema(s => ({
|
|
8
|
+
start: s.m(DateTime$Reventless.string),
|
|
9
|
+
end: s.m(DateTime$Reventless.string)
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
let schema$1 = Semantic$Reventless.mark(schema, Semantic$Reventless.Id.dateRange, undefined);
|
|
13
|
+
|
|
14
|
+
function millis(instant) {
|
|
15
|
+
return new Date(instant).getTime();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validate(range) {
|
|
19
|
+
if (new Date(range.start).getTime() > new Date(range.end).getTime()) {
|
|
20
|
+
return {
|
|
21
|
+
TAG: "Error",
|
|
22
|
+
_0: `a range ends before it starts: ` + range.start + ` is after ` + range.end + `. A range is [start, end) — the start is the earlier instant.`
|
|
23
|
+
};
|
|
24
|
+
} else {
|
|
25
|
+
return {
|
|
26
|
+
TAG: "Ok",
|
|
27
|
+
_0: range
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function make(start, end_) {
|
|
33
|
+
return validate({
|
|
34
|
+
start: start,
|
|
35
|
+
end: end_
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function duration(range) {
|
|
40
|
+
return Math.trunc((new Date(range.end).getTime() - new Date(range.start).getTime()) / 1000.0) | 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function contains(range, instant) {
|
|
44
|
+
let t = new Date(instant).getTime();
|
|
45
|
+
if (t >= new Date(range.start).getTime()) {
|
|
46
|
+
return t < new Date(range.end).getTime();
|
|
47
|
+
} else {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function overlaps(a, b) {
|
|
53
|
+
if (new Date(a.start).getTime() < new Date(b.end).getTime()) {
|
|
54
|
+
return new Date(b.start).getTime() < new Date(a.end).getTime();
|
|
55
|
+
} else {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function format(range) {
|
|
61
|
+
return range.start + ` – ` + range.end;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
schema$1 as schema,
|
|
66
|
+
millis,
|
|
67
|
+
validate,
|
|
68
|
+
make,
|
|
69
|
+
duration,
|
|
70
|
+
contains,
|
|
71
|
+
overlaps,
|
|
72
|
+
format,
|
|
73
|
+
}
|
|
74
|
+
/* schema Not a pure module */
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Marks an `int` field as a length of time, in **seconds**.
|
|
3
|
+
|
|
4
|
+
## Why a scalar, and why seconds are part of the type
|
|
5
|
+
|
|
6
|
+
The obvious richer design is `{value, unit}`. It is not available here, and the
|
|
7
|
+
reason is the reason this whole set of types is safe to add: a record is an
|
|
8
|
+
*object* on the wire, so adopting it would turn every field that used it into a
|
|
9
|
+
decode failure against events already written. These types are additive
|
|
10
|
+
precisely because they stay scalars. A duration that carries its unit is a
|
|
11
|
+
different type for a different plan, not a later version of this one — widening
|
|
12
|
+
this one would retroactively hand an upcaster obligation to every field that had
|
|
13
|
+
already adopted it.
|
|
14
|
+
|
|
15
|
+
Seconds, because that is what the renderer reads: it formats `3660` as
|
|
16
|
+
`"1h 1m"`. Milliseconds would be off by a factor of a thousand and would
|
|
17
|
+
render as weeks, which is the same class of silent wrongness `Percent` avoids by
|
|
18
|
+
matching its gauge.
|
|
19
|
+
|
|
20
|
+
`int` is right here where it was wrong for `Bytes`: int32 seconds is 68 years,
|
|
21
|
+
which no duration field needs to exceed.
|
|
22
|
+
|
|
23
|
+
## The grammar
|
|
24
|
+
|
|
25
|
+
A whole number of seconds, zero or greater. Zero is a real duration — an instant
|
|
26
|
+
timeout, a zero-length window.
|
|
27
|
+
|
|
28
|
+
@example
|
|
29
|
+
```rescript
|
|
30
|
+
@schema type state = {
|
|
31
|
+
jobId: string,
|
|
32
|
+
runtimeSeconds: @s.matches(Reventless.Duration.schema) int,
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The duration's representation, in seconds. Transparent `int`. */
|
|
38
|
+
type t = int
|
|
39
|
+
|
|
40
|
+
external unsafe: int => t = "%identity"
|
|
41
|
+
external toInt: t => int = "%identity"
|
|
42
|
+
|
|
43
|
+
/** Validate a number of seconds as a duration, saying why when it is not one. */
|
|
44
|
+
let fromInt = (raw: int): result<t, string> =>
|
|
45
|
+
if raw < 0 {
|
|
46
|
+
Error(`a duration cannot be negative, got ${Int.toString(raw)} seconds`)
|
|
47
|
+
} else {
|
|
48
|
+
Ok(raw)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The sury schema for a duration field, in seconds.
|
|
52
|
+
Use with `@s.matches(Reventless.Duration.schema)`. */
|
|
53
|
+
let schema: S.t<t> = S.int->Semantic.refined(~id=Semantic.Id.duration, ~check=fromInt)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
5
|
+
|
|
6
|
+
function fromInt(raw) {
|
|
7
|
+
if (raw < 0) {
|
|
8
|
+
return {
|
|
9
|
+
TAG: "Error",
|
|
10
|
+
_0: `a duration cannot be negative, got ` + raw.toString() + ` seconds`
|
|
11
|
+
};
|
|
12
|
+
} else {
|
|
13
|
+
return {
|
|
14
|
+
TAG: "Ok",
|
|
15
|
+
_0: raw
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let schema = Semantic$Reventless.refined(S.int, Semantic$Reventless.Id.duration, fromInt);
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
fromInt,
|
|
24
|
+
schema,
|
|
25
|
+
}
|
|
26
|
+
/* schema Not a pure module */
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Marks a `string` field as an email address.
|
|
3
|
+
|
|
4
|
+
## Why a type rather than a name
|
|
5
|
+
|
|
6
|
+
An `email` field already renders as a mailto link today, because the UI guesses
|
|
7
|
+
from the field's *name*. The guess is the whole problem: `contact`, `owner` and
|
|
8
|
+
`notifyTo` hold addresses and are not guessed, `emailTemplate` is guessed and is
|
|
9
|
+
not an address, and nothing anywhere checks that what was written is one. A type
|
|
10
|
+
says it, and the check comes with it.
|
|
11
|
+
|
|
12
|
+
## The grammar
|
|
13
|
+
|
|
14
|
+
Sury's `S.email`, and nothing added. This is a case where the framework has no
|
|
15
|
+
opinion worth having: address syntax is somebody else's specification, and a
|
|
16
|
+
second regex here would only be a way to disagree with it.
|
|
17
|
+
|
|
18
|
+
Note that a syntactically valid address is not a deliverable one — nothing here
|
|
19
|
+
sends a probe. This rejects what is not an address, which is the part a boundary
|
|
20
|
+
check can honestly do.
|
|
21
|
+
|
|
22
|
+
@example
|
|
23
|
+
```rescript
|
|
24
|
+
@schema type command =
|
|
25
|
+
| InviteMember({
|
|
26
|
+
teamId: @s.matches(DcbTag.string) string,
|
|
27
|
+
email: @s.matches(Reventless.Email.schema) string,
|
|
28
|
+
})
|
|
29
|
+
```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The address's representation. Transparent `string`: the marker refines an
|
|
33
|
+
existing field rather than replacing it, so nothing stored changes. */
|
|
34
|
+
type t = string
|
|
35
|
+
|
|
36
|
+
external unsafe: string => t = "%identity"
|
|
37
|
+
external toString: t => string = "%identity"
|
|
38
|
+
|
|
39
|
+
// Sury's check, held once. `fromString` runs it rather than restating it, and
|
|
40
|
+
// `schema` is built from `fromString`, so there is exactly one grammar here.
|
|
41
|
+
let grammar: S.t<string> = S.string->S.email
|
|
42
|
+
|
|
43
|
+
/** Validate a raw string as an email address, saying why when it is not one. */
|
|
44
|
+
let fromString = (raw: string): result<t, string> =>
|
|
45
|
+
switch raw->S.parseOrThrow(grammar) {
|
|
46
|
+
| value => Ok(value)
|
|
47
|
+
| exception _ => Error(`expected an email address, got ${Semantic.showString(raw)}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The sury schema for an email field. Use with `@s.matches(Reventless.Email.schema)`. */
|
|
51
|
+
let schema: S.t<t> = S.string->Semantic.refined(~id=Semantic.Id.email, ~check=fromString)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
5
|
+
|
|
6
|
+
let grammar = S.email(S.string, undefined);
|
|
7
|
+
|
|
8
|
+
function fromString(raw) {
|
|
9
|
+
let value;
|
|
10
|
+
try {
|
|
11
|
+
value = S.parseOrThrow(raw, grammar);
|
|
12
|
+
} catch (exn) {
|
|
13
|
+
return {
|
|
14
|
+
TAG: "Error",
|
|
15
|
+
_0: `expected an email address, got ` + Semantic$Reventless.showString(raw)
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
TAG: "Ok",
|
|
20
|
+
_0: value
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let schema = Semantic$Reventless.refined(S.string, Semantic$Reventless.Id.email, fromString);
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
grammar,
|
|
28
|
+
fromString,
|
|
29
|
+
schema,
|
|
30
|
+
}
|
|
31
|
+
/* grammar Not a pure module */
|
|
@@ -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
|
+
}
|