@reventlessdev/reventless-spec 3.0.0-alpha.85 → 3.0.0-alpha.87

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.
@@ -0,0 +1,212 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodepath from "node:path";
5
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
6
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
7
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
+
9
+ function nodeFileSystem_exists(prim) {
10
+ return Nodefs.existsSync(prim);
11
+ }
12
+
13
+ function nodeFileSystem_readJson(path) {
14
+ try {
15
+ return JSON.parse(Nodefs.readFileSync(path, "utf8"));
16
+ } catch (exn) {
17
+ return;
18
+ }
19
+ }
20
+
21
+ let nodeFileSystem = {
22
+ exists: nodeFileSystem_exists,
23
+ readJson: nodeFileSystem_readJson
24
+ };
25
+
26
+ function describeVia(via) {
27
+ if (typeof via !== "object") {
28
+ return "its own src/";
29
+ } else if (via.TAG === "Generated") {
30
+ return `its \`generate\` script's ` + via._0;
31
+ } else {
32
+ return `dependency ` + via._0;
33
+ }
34
+ }
35
+
36
+ function asObject(json) {
37
+ return Stdlib_JSON.Decode.object(json);
38
+ }
39
+
40
+ function packageJsonAt(fs, dir) {
41
+ return Stdlib_Option.flatMap(fs.readJson(Nodepath.join(dir, "package.json")), asObject);
42
+ }
43
+
44
+ function scriptsOf(pkg) {
45
+ return Stdlib_Option.mapOr(Stdlib_Option.flatMap(pkg["scripts"], asObject), [], scripts => Stdlib_Array.filterMap(Object.values(scripts), Stdlib_JSON.Decode.string));
46
+ }
47
+
48
+ function generatedSrcDir(pkg) {
49
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(pkg["scripts"], asObject), scripts => scripts["generate"]), Stdlib_JSON.Decode.string), script => {
50
+ let tokens = script.split(" ").filter(t => t !== "");
51
+ if (tokens.length !== 4) {
52
+ return;
53
+ }
54
+ let match = tokens[0];
55
+ if (match !== "generate-plugin") {
56
+ return;
57
+ }
58
+ let match$1 = tokens[1];
59
+ if (match$1 === "--aws") {
60
+ return tokens[3];
61
+ }
62
+ });
63
+ }
64
+
65
+ function emitsCapabilities(pkg) {
66
+ return scriptsOf(pkg).some(script => script.includes("emit-capabilities"));
67
+ }
68
+
69
+ function dependencyNames(pkg) {
70
+ let seen = {};
71
+ return [
72
+ "dependencies",
73
+ "devDependencies"
74
+ ].flatMap(field => Stdlib_Option.mapOr(Stdlib_Option.flatMap(pkg[field], asObject), [], prim => Object.keys(prim))).filter(name => {
75
+ let match = seen[name];
76
+ if (match !== undefined) {
77
+ return false;
78
+ } else {
79
+ seen[name] = true;
80
+ return true;
81
+ }
82
+ });
83
+ }
84
+
85
+ function resolvePackageDir(fs, fromDir, name) {
86
+ let _dir = fromDir;
87
+ while (true) {
88
+ let dir = _dir;
89
+ let candidate = Nodepath.join(dir, "node_modules", name);
90
+ if (fs.exists(Nodepath.join(candidate, "package.json"))) {
91
+ return candidate;
92
+ }
93
+ let parent = Nodepath.dirname(dir);
94
+ if (parent === dir) {
95
+ return;
96
+ }
97
+ _dir = parent;
98
+ continue;
99
+ };
100
+ }
101
+
102
+ function fromDependencies(fs, pluginDir, pkg) {
103
+ let found = [];
104
+ let unbuilt = {
105
+ contents: undefined
106
+ };
107
+ dependencyNames(pkg).forEach(name => {
108
+ let packageDir = resolvePackageDir(fs, pluginDir, name);
109
+ if (packageDir === undefined) {
110
+ return;
111
+ }
112
+ let candidate = Nodepath.join(packageDir, "src", "capabilities.json");
113
+ if (fs.exists(candidate)) {
114
+ found.push({
115
+ path: candidate,
116
+ via: {
117
+ TAG: "Dependency",
118
+ _0: name
119
+ }
120
+ });
121
+ return;
122
+ }
123
+ if (!Stdlib_Option.mapOr(packageJsonAt(fs, packageDir), false, emitsCapabilities)) {
124
+ return;
125
+ }
126
+ let match = unbuilt.contents;
127
+ if (match !== undefined) {
128
+ return;
129
+ } else {
130
+ unbuilt.contents = {
131
+ evidence: `dependency ` + name + ` emits a capability manifest`,
132
+ expected: candidate
133
+ };
134
+ return;
135
+ }
136
+ });
137
+ let match = unbuilt.contents;
138
+ if (match !== undefined) {
139
+ return {
140
+ TAG: "Unbuilt",
141
+ _0: {
142
+ evidence: match.evidence,
143
+ expected: match.expected
144
+ }
145
+ };
146
+ } else if (found.length !== 0) {
147
+ return {
148
+ TAG: "Manifests",
149
+ _0: found
150
+ };
151
+ } else {
152
+ return "NotAPlugin";
153
+ }
154
+ }
155
+
156
+ function resolve(fsOpt, pluginDir) {
157
+ let fs = fsOpt !== undefined ? fsOpt : nodeFileSystem;
158
+ let direct = Nodepath.join(pluginDir, "src", "capabilities.json");
159
+ if (fs.exists(direct)) {
160
+ return {
161
+ TAG: "Manifests",
162
+ _0: [{
163
+ path: direct,
164
+ via: "Composition"
165
+ }]
166
+ };
167
+ }
168
+ let pkg = packageJsonAt(fs, pluginDir);
169
+ if (pkg === undefined) {
170
+ return "NotAPlugin";
171
+ }
172
+ let srcDir = generatedSrcDir(pkg);
173
+ if (srcDir === undefined) {
174
+ return fromDependencies(fs, pluginDir, pkg);
175
+ }
176
+ let expected = Nodepath.resolve(pluginDir, srcDir, "capabilities.json");
177
+ if (fs.exists(expected)) {
178
+ return {
179
+ TAG: "Manifests",
180
+ _0: [{
181
+ path: expected,
182
+ via: {
183
+ TAG: "Generated",
184
+ _0: srcDir
185
+ }
186
+ }]
187
+ };
188
+ } else {
189
+ return {
190
+ TAG: "Unbuilt",
191
+ _0: {
192
+ evidence: `its \`generate\` script composes ` + srcDir,
193
+ expected: expected
194
+ }
195
+ };
196
+ }
197
+ }
198
+
199
+ export {
200
+ nodeFileSystem,
201
+ describeVia,
202
+ asObject,
203
+ packageJsonAt,
204
+ scriptsOf,
205
+ generatedSrcDir,
206
+ emitsCapabilities,
207
+ dependencyNames,
208
+ resolvePackageDir,
209
+ fromDependencies,
210
+ resolve,
211
+ }
212
+ /* node:fs Not a pure module */
@@ -0,0 +1,54 @@
1
+ /**
2
+ Marks a numeric field as a count of bytes.
3
+
4
+ ## Why `float` and not `int`
5
+
6
+ A byte count is a whole number and cannot be negative, so `int` is the type it
7
+ wants to be. It is the wrong one here anyway: ReScript's `int` is int32, and
8
+ sury enforces that, so an `int` byte count silently caps at 2,147,483,647 — just
9
+ under 2 GiB. A type whose stated job is file sizes cannot stop at 2 GB.
10
+
11
+ `float` is a JS number, exact for every integer up to 2^53 — nine petabytes,
12
+ which is enough. The discreteness `int` would have given for free is recovered
13
+ by checking it: the grammar below rejects a fractional byte count, so the type
14
+ still means what `int` meant, minus the ceiling.
15
+
16
+ The wire is unaffected either way — both are JSON numbers — so this is a source
17
+ choice, not a format one. A field genuinely bounded below 2 GiB can still be
18
+ declared `int` and left unmarked; this type is for the ones that are not.
19
+
20
+ ## The grammar
21
+
22
+ A finite, whole number, zero or greater. Zero is a real byte count — an empty
23
+ object — so it is accepted.
24
+
25
+ @example
26
+ ```rescript
27
+ @schema type state = {
28
+ documentId: string,
29
+ size: @s.matches(Reventless.Bytes.schema) float,
30
+ }
31
+ ```
32
+ */
33
+
34
+ /** The count's representation. Transparent `float`; see the note above on why
35
+ it is not `int`. */
36
+ type t = float
37
+
38
+ external unsafe: float => t = "%identity"
39
+ external toFloat: t => float = "%identity"
40
+
41
+ /** Validate a number as a byte count, saying why when it is not one. */
42
+ let fromFloat = (raw: float): result<t, string> =>
43
+ if !Float.isFinite(raw) {
44
+ Error(`a byte count must be a finite number, got ${Float.toString(raw)}`)
45
+ } else if raw < 0.0 {
46
+ Error(`a byte count cannot be negative, got ${Float.toString(raw)}`)
47
+ } else if Math.floor(raw) !== raw {
48
+ Error(`a byte count is a whole number of bytes, got ${Float.toString(raw)}`)
49
+ } else {
50
+ Ok(raw)
51
+ }
52
+
53
+ /** The sury schema for a byte-count field. Use with `@s.matches(Reventless.Bytes.schema)`. */
54
+ let schema: S.t<t> = S.float->Semantic.refined(~id=Semantic.Id.bytes, ~check=fromFloat)
@@ -0,0 +1,38 @@
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 fromFloat(raw) {
7
+ if (isFinite(raw)) {
8
+ if (raw < 0.0) {
9
+ return {
10
+ TAG: "Error",
11
+ _0: `a byte count cannot be negative, got ` + raw.toString()
12
+ };
13
+ } else if (Math.floor(raw) !== raw) {
14
+ return {
15
+ TAG: "Error",
16
+ _0: `a byte count is a whole number of bytes, got ` + raw.toString()
17
+ };
18
+ } else {
19
+ return {
20
+ TAG: "Ok",
21
+ _0: raw
22
+ };
23
+ }
24
+ } else {
25
+ return {
26
+ TAG: "Error",
27
+ _0: `a byte count must be a finite number, got ` + raw.toString()
28
+ };
29
+ }
30
+ }
31
+
32
+ let schema = Semantic$Reventless.refined(S.float, Semantic$Reventless.Id.bytes, fromFloat);
33
+
34
+ export {
35
+ fromFloat,
36
+ schema,
37
+ }
38
+ /* schema Not a pure module */
@@ -0,0 +1,51 @@
1
+ /**
2
+ Marks a `string` field as a colour, written as a hex triplet.
3
+
4
+ ## Why hex only
5
+
6
+ A field carrying this semantic is rendered as a swatch by setting the value as a
7
+ CSS `background`. CSS accepts far more than a colour there — `url(…)`, gradients,
8
+ `var(…)` — so "any CSS colour" would make a colour field a way to write arbitrary
9
+ CSS into a log that cannot be edited afterwards. Hex is the form that is a
10
+ colour and nothing else, and it is what a colour picker emits anyway.
11
+
12
+ Named colours (`rebeccapurple`) and functional forms (`rgb(…)`, `oklch(…)`) are
13
+ rejected for the same reason, not because they are worse notation.
14
+
15
+ ## The grammar
16
+
17
+ `#` then 3, 4, 6 or 8 hex digits — the shorthand, the shorthand with alpha, the
18
+ full triplet, and the triplet with alpha. Case is not significant.
19
+
20
+ @example
21
+ ```rescript
22
+ @schema type command =
23
+ | SetLabelColour({
24
+ labelId: @s.matches(DcbTag.string) string,
25
+ colour: @s.matches(Reventless.Color.schema) string,
26
+ })
27
+ ```
28
+ */
29
+
30
+ /** The colour's representation. Transparent `string`; see `Email.t`. */
31
+ type t = string
32
+
33
+ external unsafe: string => t = "%identity"
34
+ external toString: t => string = "%identity"
35
+
36
+ let hex = /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
37
+
38
+ /** Validate a raw string as a hex colour, saying why when it is not one. */
39
+ let fromString = (raw: string): result<t, string> =>
40
+ if hex->RegExp.test(raw) {
41
+ Ok(raw)
42
+ } else {
43
+ Error(
44
+ `expected a hex colour such as "#1e90ff" or "#1e90ffcc", got ${Semantic.showString(
45
+ raw,
46
+ )}. Named colours and rgb()/oklch() forms are not accepted.`,
47
+ )
48
+ }
49
+
50
+ /** The sury schema for a colour field. Use with `@s.matches(Reventless.Color.schema)`. */
51
+ let schema: S.t<t> = S.string->Semantic.refined(~id=Semantic.Id.color, ~check=fromString)
@@ -0,0 +1,29 @@
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 hex = /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
7
+
8
+ function fromString(raw) {
9
+ if (hex.test(raw)) {
10
+ return {
11
+ TAG: "Ok",
12
+ _0: raw
13
+ };
14
+ } else {
15
+ return {
16
+ TAG: "Error",
17
+ _0: `expected a hex colour such as "#1e90ff" or "#1e90ffcc", got ` + Semantic$Reventless.showString(raw) + `. Named colours and rgb()/oklch() forms are not accepted.`
18
+ };
19
+ }
20
+ }
21
+
22
+ let schema = Semantic$Reventless.refined(S.string, Semantic$Reventless.Id.color, fromString);
23
+
24
+ export {
25
+ hex,
26
+ fromString,
27
+ schema,
28
+ }
29
+ /* 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,53 @@
1
+ /**
2
+ Marks a `float` field as a percentage, expressed **0–100**.
3
+
4
+ ## Why 0–100 and not 0–1
5
+
6
+ Both conventions are defensible in the abstract, so the tie is broken by the
7
+ consumer that already exists: the dashboard gauges a field with this semantic
8
+ against fixed bounds of 0 and 100, and formats `42.0` as `"42%"`. Under a 0–1
9
+ convention every value would render as a rounding error near zero — a gauge
10
+ pinned at empty and a label reading `"0.42%"`.
11
+
12
+ That failure is quiet, and it is quiet in the worst way: the numbers are
13
+ *present* and *wrong*, and the layer at fault is not the one showing the symptom.
14
+ Agreeing with the renderer costs nothing; disagreeing costs an afternoon.
15
+
16
+ A fraction is still perfectly good arithmetic — it just multiplies by 100 before
17
+ it becomes this type.
18
+
19
+ ## The grammar
20
+
21
+ A finite number in `[0, 100]`. Fractions are allowed: `99.95` is a percentage.
22
+
23
+ @example
24
+ ```rescript
25
+ @schema type state = {
26
+ productId: string,
27
+ taxRate: @s.matches(Reventless.Percent.schema) float,
28
+ }
29
+ ```
30
+ */
31
+
32
+ /** The percentage's representation. Transparent `float`: the marker refines an
33
+ existing numeric field rather than replacing it, so nothing stored changes. */
34
+ type t = float
35
+
36
+ external unsafe: float => t = "%identity"
37
+ external toFloat: t => float = "%identity"
38
+
39
+ /** Validate a number as a percentage, saying why when it is not one. */
40
+ let fromFloat = (raw: float): result<t, string> =>
41
+ if !Float.isFinite(raw) {
42
+ Error(`a percentage must be a finite number, got ${Float.toString(raw)}`)
43
+ } else if raw < 0.0 || raw > 100.0 {
44
+ Error(
45
+ `a percentage runs from 0 to 100, got ${Float.toString(raw)}. ` ++
46
+ `This scale is 0–100, not 0–1 — a fraction multiplies by 100 first.`,
47
+ )
48
+ } else {
49
+ Ok(raw)
50
+ }
51
+
52
+ /** The sury schema for a percentage field. Use with `@s.matches(Reventless.Percent.schema)`. */
53
+ let schema: S.t<t> = S.float->Semantic.refined(~id=Semantic.Id.percent, ~check=fromFloat)
@@ -0,0 +1,33 @@
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 fromFloat(raw) {
7
+ if (isFinite(raw)) {
8
+ if (raw < 0.0 || raw > 100.0) {
9
+ return {
10
+ TAG: "Error",
11
+ _0: `a percentage runs from 0 to 100, got ` + raw.toString() + `. This scale is 0–100, not 0–1 — a fraction multiplies by 100 first.`
12
+ };
13
+ } else {
14
+ return {
15
+ TAG: "Ok",
16
+ _0: raw
17
+ };
18
+ }
19
+ } else {
20
+ return {
21
+ TAG: "Error",
22
+ _0: `a percentage must be a finite number, got ` + raw.toString()
23
+ };
24
+ }
25
+ }
26
+
27
+ let schema = Semantic$Reventless.refined(S.float, Semantic$Reventless.Id.percent, fromFloat);
28
+
29
+ export {
30
+ fromFloat,
31
+ schema,
32
+ }
33
+ /* schema Not a pure module */
@@ -0,0 +1,55 @@
1
+ /**
2
+ Marks a `string` field as a phone number in E.164 form.
3
+
4
+ ## Why E.164, and only E.164
5
+
6
+ A phone number written the way a person says it out loud — `(030) 12 34 56`,
7
+ `+49 30 123456`, `0049-30-123456` — is three different strings for one number,
8
+ so a log full of them cannot be searched, deduplicated or dialled reliably. E.164
9
+ is the one form that is unambiguous internationally, and it is the form the
10
+ `tel:` link this field renders as actually wants.
11
+
12
+ That makes this the one branded scalar that is likely to *reject* input a form
13
+ would otherwise have accepted, and it should: the alternative is storing an
14
+ un-dialable string permanently. Normalising a local number into E.164 needs a
15
+ default country the framework does not know, so that belongs to the caller,
16
+ before the command.
17
+
18
+ ## The grammar
19
+
20
+ `+`, then a country digit 1–9, then up to 14 more digits — at most 15 in total,
21
+ which is the E.164 limit. No spaces, no punctuation, no leading zero after the
22
+ `+`.
23
+
24
+ @example
25
+ ```rescript
26
+ @schema type command =
27
+ | SetContactPhone({
28
+ customerId: @s.matches(DcbTag.string) string,
29
+ phone: @s.matches(Reventless.Phone.schema) string,
30
+ })
31
+ ```
32
+ */
33
+
34
+ /** The number's representation. Transparent `string`; see `Email.t`. */
35
+ type t = string
36
+
37
+ external unsafe: string => t = "%identity"
38
+ external toString: t => string = "%identity"
39
+
40
+ let e164 = /^\+[1-9]\d{0,14}$/
41
+
42
+ /** Validate a raw string as an E.164 number, saying why when it is not one. */
43
+ let fromString = (raw: string): result<t, string> =>
44
+ if e164->RegExp.test(raw) {
45
+ Ok(raw)
46
+ } else {
47
+ Error(
48
+ `expected a phone number in E.164 form — "+" then up to 15 digits, as in "+4930123456" — got ${Semantic.showString(
49
+ raw,
50
+ )}. Spaces, dashes and brackets are not part of the stored form.`,
51
+ )
52
+ }
53
+
54
+ /** The sury schema for a phone field. Use with `@s.matches(Reventless.Phone.schema)`. */
55
+ let schema: S.t<t> = S.string->Semantic.refined(~id=Semantic.Id.phone, ~check=fromString)