@reventlessdev/reventless-spec 3.0.0-alpha.84 → 3.0.0-alpha.86

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,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)
@@ -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 e164 = /^\+[1-9]\d{0,14}$/;
7
+
8
+ function fromString(raw) {
9
+ if (e164.test(raw)) {
10
+ return {
11
+ TAG: "Ok",
12
+ _0: raw
13
+ };
14
+ } else {
15
+ return {
16
+ TAG: "Error",
17
+ _0: `expected a phone number in E.164 form — "+" then up to 15 digits, as in "+4930123456" — got ` + Semantic$Reventless.showString(raw) + `. Spaces, dashes and brackets are not part of the stored form.`
18
+ };
19
+ }
20
+ }
21
+
22
+ let schema = Semantic$Reventless.refined(S.string, Semantic$Reventless.Id.phone, fromString);
23
+
24
+ export {
25
+ e164,
26
+ fromString,
27
+ schema,
28
+ }
29
+ /* schema Not a pure module */
@@ -48,6 +48,16 @@ module Id = {
48
48
  let dateTime = "dateTime"
49
49
  let reference = "reference"
50
50
  let storageRef = "storageRef"
51
+
52
+ // The branded scalars. Each refines a `string` or a number without changing
53
+ // its shape, so a field gains one of these without anything stored changing.
54
+ let email = "email"
55
+ let phone = "phone"
56
+ let url = "url"
57
+ let percent = "percent"
58
+ let bytes = "bytes"
59
+ let duration = "duration"
60
+ let color = "color"
51
61
  }
52
62
 
53
63
  let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless", ~name="semantic")
@@ -56,6 +66,30 @@ let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless",
56
66
  let mark = (schema: S.t<'a>, ~id: string, ~payload: payload=Plain): S.t<'a> =>
57
67
  schema->S.Metadata.set(~id=semanticId, {id, payload})
58
68
 
69
+ /**
70
+ A schema that validates with `check` and carries the semantic `id`.
71
+
72
+ The branded scalars all have the same shape — one constructor function that
73
+ defines the grammar, and a schema that must agree with it — and `StorageRef`
74
+ established that the schema is *derived* from the constructor rather than
75
+ hand-rolling a second check beside it. Deriving it here makes that structural:
76
+ there is one place a grammar can be written, so there is nowhere for a second
77
+ one to drift.
78
+ */
79
+ let refined = (base: S.t<'a>, ~id: string, ~check: 'a => result<'a, string>): S.t<'a> =>
80
+ base
81
+ ->S.refine(s => value =>
82
+ switch check(value) {
83
+ | Ok(_) => ()
84
+ | Error(why) => s.fail(why)
85
+ }
86
+ )
87
+ ->mark(~id)
88
+
89
+ /** A value as it should read back to the person who typed it. Rejection messages
90
+ reach forms through `validateInput`, so they quote the offending value. */
91
+ let showString = (raw: string): string => raw->JSON.Encode.string->JSON.stringify
92
+
59
93
  /** The semantic a field's schema carries, if any. */
60
94
  let get = (fieldSchema: S.t<'a>): option<t> => S.Metadata.get(fieldSchema, ~id=semanticId)
61
95
 
@@ -5,7 +5,14 @@ import * as S from "sury/src/S.res.mjs";
5
5
  let Id = {
6
6
  dateTime: "dateTime",
7
7
  reference: "reference",
8
- storageRef: "storageRef"
8
+ storageRef: "storageRef",
9
+ email: "email",
10
+ phone: "phone",
11
+ url: "url",
12
+ percent: "percent",
13
+ bytes: "bytes",
14
+ duration: "duration",
15
+ color: "color"
9
16
  };
10
17
 
11
18
  let semanticId = S.Metadata.Id.make("reventless", "semantic");
@@ -18,6 +25,21 @@ function mark(schema, id, payloadOpt) {
18
25
  });
19
26
  }
20
27
 
28
+ function refined(base, id, check) {
29
+ return mark(S.refine(base, s => (value => {
30
+ let why = check(value);
31
+ if (why.TAG === "Ok") {
32
+ return;
33
+ } else {
34
+ return s.fail(why._0, undefined);
35
+ }
36
+ })), id, undefined);
37
+ }
38
+
39
+ function showString(raw) {
40
+ return JSON.stringify(raw);
41
+ }
42
+
21
43
  function get(fieldSchema) {
22
44
  return S.Metadata.get(fieldSchema, semanticId);
23
45
  }
@@ -35,6 +57,8 @@ export {
35
57
  Id,
36
58
  semanticId,
37
59
  mark,
60
+ refined,
61
+ showString,
38
62
  get,
39
63
  has,
40
64
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ Marks a `string` field as a web address.
3
+
4
+ ## The grammar: parseable, **and** `http`/`https`
5
+
6
+ Sury's `S.url` is the parse — it is `new URL()`, so it settles host, port and
7
+ escaping without this module having an opinion. But `new URL()` accepts every
8
+ scheme, `javascript:alert(1)` included, and that is not an academic gap here: a
9
+ field carrying this semantic renders as an anchor whose `href` is the stored
10
+ value. An append-only log plus a scheme nobody checked is a stored XSS that
11
+ cannot be deleted afterwards — the same shape of hole `StorageRef` exists to
12
+ close, arriving through a different field.
13
+
14
+ So the grammar is sury's parse plus a two-scheme allowlist. `mailto:` and `tel:`
15
+ are deliberately outside it: those are `Email` and `Phone`, which validate what
16
+ they actually hold and render correctly on their own.
17
+
18
+ @example
19
+ ```rescript
20
+ @schema type command =
21
+ | SetSupplierSite({
22
+ supplierId: @s.matches(DcbTag.string) string,
23
+ website: @s.matches(Reventless.Url.schema) string,
24
+ })
25
+ ```
26
+ */
27
+
28
+ /** The address's representation. Transparent `string`; see `Email.t`. */
29
+ type t = string
30
+
31
+ external unsafe: string => t = "%identity"
32
+ external toString: t => string = "%identity"
33
+
34
+ // Sury's parse, held once — `fromString` runs it and adds the scheme check, and
35
+ // `schema` derives from `fromString`. One grammar, in one place.
36
+ let grammar: S.t<string> = S.string->S.url
37
+
38
+ // Schemes are case-insensitive per RFC 3986, and `HTTPS://x` parses fine, so the
39
+ // allowlist has to fold case or it rejects a valid address on a technicality.
40
+ let hasWebScheme = (raw: string): bool => {
41
+ let lower = String.toLowerCase(raw)
42
+ String.startsWith(lower, "http://") || String.startsWith(lower, "https://")
43
+ }
44
+
45
+ /** Validate a raw string as an `http`/`https` URL, saying why when it is not one. */
46
+ let fromString = (raw: string): result<t, string> =>
47
+ switch raw->S.parseOrThrow(grammar) {
48
+ | value =>
49
+ if hasWebScheme(value) {
50
+ Ok(value)
51
+ } else {
52
+ Error(
53
+ `a URL field takes an http:// or https:// address, got ${Semantic.showString(raw)}. ` ++
54
+ `Use Email or Phone for mailto:/tel:, and a storage ref for an uploaded object.`,
55
+ )
56
+ }
57
+ | exception _ =>
58
+ Error(
59
+ `expected an absolute URL, got ${Semantic.showString(
60
+ raw,
61
+ )}. A relative path is not a URL — include the scheme and host.`,
62
+ )
63
+ }
64
+
65
+ /** The sury schema for a URL field. Use with `@s.matches(Reventless.Url.schema)`. */
66
+ let schema: S.t<t> = S.string->Semantic.refined(~id=Semantic.Id.url, ~check=fromString)
@@ -0,0 +1,48 @@
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.url(S.string, undefined);
7
+
8
+ function hasWebScheme(raw) {
9
+ let lower = raw.toLowerCase();
10
+ if (lower.startsWith("http://")) {
11
+ return true;
12
+ } else {
13
+ return lower.startsWith("https://");
14
+ }
15
+ }
16
+
17
+ function fromString(raw) {
18
+ let value;
19
+ try {
20
+ value = S.parseOrThrow(raw, grammar);
21
+ } catch (exn) {
22
+ return {
23
+ TAG: "Error",
24
+ _0: `expected an absolute URL, got ` + Semantic$Reventless.showString(raw) + `. A relative path is not a URL — include the scheme and host.`
25
+ };
26
+ }
27
+ if (hasWebScheme(value)) {
28
+ return {
29
+ TAG: "Ok",
30
+ _0: value
31
+ };
32
+ } else {
33
+ return {
34
+ TAG: "Error",
35
+ _0: `a URL field takes an http:// or https:// address, got ` + Semantic$Reventless.showString(raw) + `. Use Email or Phone for mailto:/tel:, and a storage ref for an uploaded object.`
36
+ };
37
+ }
38
+ }
39
+
40
+ let schema = Semantic$Reventless.refined(S.string, Semantic$Reventless.Id.url, fromString);
41
+
42
+ export {
43
+ grammar,
44
+ hasWebScheme,
45
+ fromString,
46
+ schema,
47
+ }
48
+ /* grammar Not a pure module */