@reventlessdev/reventless-spec 3.0.0-alpha.127 → 3.0.0-alpha.128

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,18 @@
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.128 (2026-09-04)
7
+
8
+ ### Features
9
+
10
+ * **notifications:** the wording is a table of values, not a switch ([bfcc939](https://github.com/ReventlessDev/reventless-core/commit/bfcc93940b81060d201fd231447fb14dcf48d80e))
11
+ * **plugin:** a slice publishes which topics it subscribes to ([c689695](https://github.com/ReventlessDev/reventless-core/commit/c6896957ecb636204678222ac5a26b30870439cb))
12
+ * **spec,traits:** an image carries the text that goes with it, and a set's first member is its primary ([e4e5845](https://github.com/ReventlessDev/reventless-core/commit/e4e58458aee7b3db5564727d358a3a9767362ca4))
13
+ * **spec:** a field can say it selects one of the values its row already holds ([2ae50c3](https://github.com/ReventlessDev/reventless-core/commit/2ae50c34deee48508a9f1f39e3eef6a5d2f5df00))
14
+ * **spec:** a field can say its value must not be rendered into a message ([3183f53](https://github.com/ReventlessDev/reventless-core/commit/3183f53a0ae667f794bbd1a3d77acf362dfa8e57))
15
+ * **spec:** a message template renders a payload through its own schema ([39e3f61](https://github.com/ReventlessDev/reventless-core/commit/39e3f6126bd831a316323b4663bc37c50cdfc704))
16
+
17
+
6
18
  # 3.0.0-alpha.127 (2026-09-02)
7
19
 
8
20
  * feat(aws)!: the messaging sender is configuration, and a stack can choose to only log ([23b8b4b](https://github.com/ReventlessDev/reventless-core/commit/23b8b4bfe9c70555de4d74266ca686cb427485ca))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.127",
3
+ "version": "3.0.0-alpha.128",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -134,6 +134,7 @@ type Platform_IssuedCommandDef {
134
134
  type Platform_OutboundTranslationSliceDef {
135
135
  chapter: String
136
136
  consumedEventTypes: [String!]!
137
+ consumedSources: [String!]
137
138
  externalSystem: String
138
139
  inboundCommandTypes: [String!]!
139
140
  name: String!
@@ -261,6 +261,14 @@ type outboundTranslationSliceDef = {
261
261
  externalSystem: @s.matches(stringOptionSchema) option<string>,
262
262
  /** Chapter grouping band — see `queryableDef.chapter`. */
263
263
  chapter: @s.matches(stringOptionSchema) option<string>,
264
+ /** The topics this slice subscribes to, as `Spec.sourceNames` declares them —
265
+ an Aggregate's `Spec.name` or a DCB source name. `Some([])` is the declared
266
+ default and means this plugin's own DCB log; `None` is an older structure
267
+ that did not publish the field. A different fact from `consumedEventTypes`,
268
+ which names event types and not where they came from — two topics carrying
269
+ an event of the same name are indistinguishable there. Optional so an older
270
+ reader ignores it. */
271
+ consumedSources: @s.matches(stringArrayOptionSchema) option<array<string>>,
264
272
  }
265
273
 
266
274
  @schema
@@ -172,7 +172,8 @@ let outboundTranslationSliceDefSchema = Sury.$schema(s => ({
172
172
  inboundCommandTypes: s.m(Sury.array(Sury.string)),
173
173
  targetName: s.m(stringOptionSchema),
174
174
  externalSystem: s.m(stringOptionSchema),
175
- chapter: s.m(stringOptionSchema)
175
+ chapter: s.m(stringOptionSchema),
176
+ consumedSources: s.m(stringArrayOptionSchema)
176
177
  }));
177
178
 
178
179
  let inboundTranslationSliceDefSchema = Sury.$schema(s => ({
@@ -0,0 +1,137 @@
1
+ /**
2
+ Marks a field whose value must not be rendered into content a person receives.
3
+
4
+ `@sensitive` is a *position*, not a type: it says "whatever this field holds, do
5
+ not put it in an outbound message". What the field **is** — an email, a token, a
6
+ reference — is declared separately, exactly as owning and tagging are separate
7
+ facts about one field.
8
+
9
+ It is declared where the field is declared, and read by anything that composes
10
+ text somebody will read. That is the whole reason it lives here rather than
11
+ beside whichever consumer happens to need it first: a marking that exists in one
12
+ consumer is a marking the rest of the system cannot honour, and the domain model
13
+ is the only place that knows which values are sensitive.
14
+
15
+ ## Absent means "not stated", not "safe"
16
+
17
+ A reader that misses the marker leaks the value, so the failure is *open* — the
18
+ same shape as `Owner`, and the reason `isFieldSensitive` follows the wrappers
19
+ rather than leaving each consumer to unwrap for itself.
20
+
21
+ ## Some fields need no annotation
22
+
23
+ A field whose semantic already says it is a contact detail is sensitive without
24
+ anybody writing it down — see `impliedBySemantic`. The annotation is for the
25
+ values a type cannot betray: a token, a note, a reference somebody chose.
26
+
27
+ `@sensitive` is the authoring form and is sugar over the constructors below, the
28
+ way `@owner` is sugar over `Owner`'s. Write `@s.matches(Sensitive.mark(…))` by
29
+ hand where the ppx shorthand cannot reach — a file with no `@@reventless.spec`
30
+ annotation, or a field whose type is neither `string` nor `option<string>`.
31
+
32
+ @example
33
+ ```rescript
34
+ @schema type event =
35
+ PasswordReset({
36
+ @owner customerId: string,
37
+ @sensitive resetToken: string,
38
+ })
39
+ ```
40
+ */
41
+ let sensitiveId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(
42
+ ~namespace="reventless",
43
+ ~name="sensitive",
44
+ )
45
+
46
+ /**
47
+ Layers the marker onto a schema that already says something else.
48
+
49
+ Sensitivity is independent of everything else a field declares — it may equally
50
+ be a DCB tag, an owner, or a reference — but a field carries at most one
51
+ `@s.matches`, so this composes by *wrapping* what the field already resolved to
52
+ rather than replacing it. Replacing would silently drop the field's tag, and a
53
+ dropped tag is a decision read that quietly misses events.
54
+ */
55
+ let mark = (schema: S.t<'a>): S.t<'a> => schema->S.Metadata.set(~id=sensitiveId, true)
56
+
57
+ /** A string field that must not be rendered outbound. */
58
+ let string: S.t<string> = S.string->mark
59
+
60
+ /**
61
+ An `option<string>` field that must not be rendered outbound.
62
+
63
+ Needed because `@s.matches` on an explicitly-`option`-typed field must supply the
64
+ whole field schema, wrapper included. The `f?: string` form needs nothing extra:
65
+ sury wraps the annotated inner schema itself, and `isFieldSensitive` looks
66
+ through that wrapper either way.
67
+ */
68
+ let optionString: S.t<option<string>> = S.option(string)
69
+
70
+ /** Whether this exact schema carries the marker. Does not look through wrappers. */
71
+ let isSensitive = (schema: S.t<unknown>): bool =>
72
+ S.Metadata.get(schema, ~id=sensitiveId)->Option.getOr(false)
73
+
74
+ /**
75
+ Whether a *field* is sensitive, wherever inside the field's type the marker sits.
76
+
77
+ An optional field keeps its marker inside the union wrapper, so a walk reading
78
+ only the outer schema answers `false` for `token?: string` — and here that means
79
+ the value is rendered into a message rather than withheld. Object properties are
80
+ deliberately not followed, for the same reason `Owner` does not follow them: a
81
+ marker on a nested record's field belongs to that field, and attributing it to
82
+ the enclosing one would withhold a whole record because one leaf is private.
83
+ */
84
+ let isFieldSensitive = (schema: S.t<unknown>): bool =>
85
+ isSensitive(schema) ||
86
+ switch schema->Semantic.unwrapOptional {
87
+ | Some(inner) => isSensitive(inner)
88
+ | None => false
89
+ }
90
+
91
+ /**
92
+ The sensitive fields declared on an object schema, in declaration order.
93
+
94
+ Threaded to the JSON-Schema walk the way `Owner.fieldNames` is, and for the same
95
+ reason: the IR is shape-driven and sensitivity is not a shape — the field stays a
96
+ plain string either way. Reading it off the sury schema here also keeps it
97
+ available on command and event variants, which carry no annotation spec.
98
+ */
99
+ let fieldNamesOfProperties = (properties: dict<S.t<unknown>>): array<string> =>
100
+ properties
101
+ ->Dict.toArray
102
+ ->Array.filterMap(((propName, propSchema)) =>
103
+ isFieldSensitive(propSchema) ? Some(propName) : None
104
+ )
105
+
106
+ let fieldNames = (schema: S.t<unknown>): array<string> =>
107
+ switch schema {
108
+ | Object({properties}) => fieldNamesOfProperties(properties)
109
+ | _ => []
110
+ }
111
+
112
+ /**
113
+ The sensitive fields of one constructor of a command or event union.
114
+
115
+ The form a renderer actually needs: it composes text about **one** occurrence, so
116
+ only that variant's fields have anything to say. `OrderPlaced` and
117
+ `PasswordReset` live in the same union and know nothing about each other's
118
+ fields — resolving by TAG here rather than at the call site is what stops a
119
+ template from consulting the wrong arm.
120
+
121
+ Answers `[]` for an unknown tag and for a payload-less variant, both of which
122
+ mean the same thing to a caller: nothing on this event is withheld.
123
+ */
124
+ let variantFieldNames = (schema: S.t<unknown>, ~variant: string): array<string> =>
125
+ schema->Semantic.unionVariant(~variant)->Option.mapOr([], fieldNames)
126
+
127
+ /**
128
+ The semantics that are sensitive without an annotation.
129
+
130
+ A contact detail is one whether or not anybody remembered to mark it, so the
131
+ rule is stated once here rather than left to each consumer to remember. Kept
132
+ deliberately short: it holds only the semantics whose *whole meaning* is "how to
133
+ reach a particular person". A postal address or a display name can be sensitive
134
+ in context, and context is exactly what an annotation is for.
135
+ */
136
+ let impliedBySemantic = (semanticId: string): bool =>
137
+ semanticId === Semantic.Id.email || semanticId === Semantic.Id.phone
@@ -0,0 +1,74 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
7
+
8
+ let sensitiveId = Sury.$Metadata_Id_make("reventless", "sensitive");
9
+
10
+ function mark(schema) {
11
+ return Sury.$Metadata_set(schema, sensitiveId, true);
12
+ }
13
+
14
+ let string = Sury.$Metadata_set(Sury.string, sensitiveId, true);
15
+
16
+ let optionString = Sury.$option(string);
17
+
18
+ function isSensitive(schema) {
19
+ return Stdlib_Option.getOr(Sury.$Metadata_get(schema, sensitiveId), false);
20
+ }
21
+
22
+ function isFieldSensitive(schema) {
23
+ if (isSensitive(schema)) {
24
+ return true;
25
+ }
26
+ let inner = Semantic$Reventless.unwrapOptional(schema);
27
+ if (inner !== undefined) {
28
+ return isSensitive(inner);
29
+ } else {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function fieldNamesOfProperties(properties) {
35
+ return Stdlib_Array.filterMap(Object.entries(properties), param => {
36
+ if (isFieldSensitive(param[1])) {
37
+ return param[0];
38
+ }
39
+ });
40
+ }
41
+
42
+ function fieldNames(schema) {
43
+ if (schema.type === "object") {
44
+ return fieldNamesOfProperties(schema.properties);
45
+ } else {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ function variantFieldNames(schema, variant) {
51
+ return Stdlib_Option.mapOr(Semantic$Reventless.unionVariant(schema, variant), [], fieldNames);
52
+ }
53
+
54
+ function impliedBySemantic(semanticId) {
55
+ if (semanticId === Semantic$Reventless.Id.email) {
56
+ return true;
57
+ } else {
58
+ return semanticId === Semantic$Reventless.Id.phone;
59
+ }
60
+ }
61
+
62
+ export {
63
+ sensitiveId,
64
+ mark,
65
+ string,
66
+ optionString,
67
+ isSensitive,
68
+ isFieldSensitive,
69
+ fieldNamesOfProperties,
70
+ fieldNames,
71
+ variantFieldNames,
72
+ impliedBySemantic,
73
+ }
74
+ /* sensitiveId Not a pure module */
@@ -1,26 +1,9 @@
1
1
  /**
2
- Marks a numeric field as a count of bytes.
2
+ Marks a numeric field as a count of bytes: finite, whole, zero or greater.
3
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.
4
+ `float` rather than `int` because ReScript's `int` is int32, which caps a byte
5
+ count just under 2 GiB; wholeness is recovered by the grammar below. The wire is
6
+ a JSON number either way.
24
7
 
25
8
  @example
26
9
  ```rescript
@@ -52,3 +35,20 @@ let fromFloat = (raw: float): result<t, string> =>
52
35
 
53
36
  /** The sury schema for a byte-count field. Use with `@s.matches(Reventless.Bytes.schema)`. */
54
37
  let schema: S.t<t> = S.float->Semantic.refined(~id=Semantic.Id.bytes, ~check=fromFloat)
38
+
39
+ /** Binary, because a byte count is what a filesystem reports. */
40
+ let step = 1024.0
41
+ let units = ["B", "KB", "MB", "GB", "TB", "PB"]
42
+
43
+ /** The count as text — `"512 B"`, `"1.5 KB"`, `"2 MB"`. Locale-independent, the
44
+ way `Money.format` is, so one value reads the same everywhere. */
45
+ let format = (b: t): string => {
46
+ let last = Array.length(units) - 1
47
+ let rec reduce = (value: float, index: int): (float, string) =>
48
+ value < step || index >= last
49
+ ? (value, units->Array.getUnsafe(index))
50
+ : reduce(value /. step, index + 1)
51
+ let (value, unit) = reduce(b, 0)
52
+ // One decimal, and only when it says something: 2097152 is "2 MB", not "2.0 MB".
53
+ Float.toString(Math.round(value *. 10.0) /. 10.0) ++ " " ++ unit
54
+ }
@@ -31,8 +31,43 @@ function fromFloat(raw) {
31
31
 
32
32
  let schema = Semantic$Reventless.refined(Sury.float, Semantic$Reventless.Id.bytes, fromFloat);
33
33
 
34
+ let units = [
35
+ "B",
36
+ "KB",
37
+ "MB",
38
+ "GB",
39
+ "TB",
40
+ "PB"
41
+ ];
42
+
43
+ function format(b) {
44
+ let last = units.length - 1 | 0;
45
+ let reduce = (_value, _index) => {
46
+ while (true) {
47
+ let index = _index;
48
+ let value = _value;
49
+ if (value < 1024.0 || index >= last) {
50
+ return [
51
+ value,
52
+ units[index]
53
+ ];
54
+ }
55
+ _index = index + 1 | 0;
56
+ _value = value / 1024.0;
57
+ continue;
58
+ };
59
+ };
60
+ let match = reduce(b, 0);
61
+ return (Math.round(match[0] * 10.0) / 10.0).toString() + " " + match[1];
62
+ }
63
+
64
+ let step = 1024.0;
65
+
34
66
  export {
35
67
  fromFloat,
36
68
  schema,
69
+ step,
70
+ units,
71
+ format,
37
72
  }
38
73
  /* schema Not a pure module */
@@ -0,0 +1,100 @@
1
+ /**
2
+ An image the platform stores, together with the text that goes with it.
3
+
4
+ ## Why the text travels with the reference
5
+
6
+ A picture and the words that describe it are one thing, and holding them apart is
7
+ what let them drift. A view that carried an image field and a caption field
8
+ beside it had to keep the pair in step on every arm of its projection, and the
9
+ one arm that forgot produced a hero image with no alternative text — an
10
+ accessibility hole opened by a projection, not by a missing command.
11
+
12
+ Inside one value there is no pair to keep in step. It also puts the text where a
13
+ renderer can reach it: a cell renderer is handed a field's key, its schema and
14
+ its value, and never the row — so a caption in a *sibling field* is a caption no
15
+ cell can draw.
16
+
17
+ ## Two texts, not one
18
+
19
+ They are different things and one does not substitute for the other.
20
+ **`altText` replaces** the image: what a screen reader announces instead of it,
21
+ what shows when it fails to load. **`caption` sits beside** it, visible to
22
+ everyone. `altText: "Blue running shoe, side profile"` and
23
+ `caption: "Front view"` are both correct and neither does the other's job.
24
+
25
+ A consumer resolves them like this, and the rules are written down because a
26
+ wrong one is silent:
27
+
28
+ - **the visible caption** is `caption` only. Absent means no visible text.
29
+ - **`alt`** is `altText`, else `caption`, else `""`.
30
+
31
+ The fallback through `caption` is deliberate and slightly impure. The pure rule —
32
+ `altText` else `""` — makes a host that filled only `caption` emit `alt=""` on
33
+ every photo, which declares them decorative. An imperfect accessible name is
34
+ better than a wrong one.
35
+
36
+ ## No store argument on the field
37
+
38
+ The store is the host field's name, pluralised, derived by the ppx exactly as it
39
+ is for {!UploadableImage} — and the rule is idempotent, so `productImages:
40
+ array<CaptionedImage.t>` and `categoryImage?: CaptionedImage.t` both derive the
41
+ store their host means. The marker lands on this record rather than on the `ref`
42
+ inside it, which is what lets `StorageRef.getFieldStore` find the store through
43
+ an array wrapper without looking a level deeper.
44
+
45
+ ## A pair, not a general type
46
+
47
+ Monomorphic on purpose: the ppx matches the *spelling* of a field's type under an
48
+ empty type-argument list, so an `Attachment.t<'ref>` would not match and no store
49
+ would be derived — silently. A document counterpart is a second type for the
50
+ reason {!UploadableImage} and {!UploadableFile} are two: they differ in what the
51
+ value depicts, not in how a reference to it is written. It would carry no
52
+ `altText`, because a document has no visual to replace.
53
+
54
+ @example
55
+ ```rescript
56
+ @schema type state = {
57
+ productId: string,
58
+ productImages: array<Reventless.CaptionedImage.t>,
59
+ }
60
+ ```
61
+ */
62
+
63
+ /** The record itself. No `@schema`: the derived schema would have to name a
64
+ store, and the store is the host field's — so `forField` is the only way to
65
+ a schema, and a field the ppx did not reach fails to compile rather than
66
+ provisioning a store nobody meant. */
67
+ type t = {
68
+ ref: UploadableImage.t,
69
+ altText?: string,
70
+ caption?: string,
71
+ }
72
+
73
+ /**
74
+ The sury schema for a field holding captioned images out of a named store.
75
+
76
+ Prefer typing the field `CaptionedImage.t` (or `array<CaptionedImage.t>`) and
77
+ letting the ppx call this. Written by hand it needs the derived store name, which
78
+ defeats the point of the type.
79
+ */
80
+ let forField = (~plugin: option<string>=?, ~store: string): S.t<t> =>
81
+ S.schema(s => {
82
+ ref: s.matches(UploadableImage.forField(~plugin?, ~store)),
83
+ altText: ?s.matches(S.option(S.string)),
84
+ caption: ?s.matches(S.option(S.string)),
85
+ })->Semantic.mark(
86
+ ~id=Semantic.Id.captionedImage,
87
+ // The store rides on the record, not on `ref`: a field reader looks through
88
+ // an optional wrapper and an array element and no further, so a marker one
89
+ // record deeper would be a store declaration nothing provisions.
90
+ ~payload=StoredIn({plugin, store, threshold: None}),
91
+ )
92
+
93
+ /** The text that replaces the image, by the rule above — `altText`, else the
94
+ caption, else nothing. One definition, because a second one would be the
95
+ drift this type exists to remove. */
96
+ let altTextOf = (c: t): option<string> =>
97
+ switch c.altText {
98
+ | Some(_) as text => text
99
+ | None => c.caption
100
+ }
@@ -0,0 +1,35 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Semantic$Reventless from "./Semantic.res.mjs";
5
+ import * as UploadableImage$Reventless from "./UploadableImage.res.mjs";
6
+
7
+ function forField(plugin, store) {
8
+ return Semantic$Reventless.mark(Sury.$schema(s => ({
9
+ ref: s.m(UploadableImage$Reventless.forField(plugin, store)),
10
+ altText: s.m(Sury.$option(Sury.string)),
11
+ caption: s.m(Sury.$option(Sury.string))
12
+ })), Semantic$Reventless.Id.captionedImage, {
13
+ TAG: "StoredIn",
14
+ _0: {
15
+ plugin: plugin,
16
+ store: store,
17
+ threshold: undefined
18
+ }
19
+ });
20
+ }
21
+
22
+ function altTextOf(c) {
23
+ let text = c.altText;
24
+ if (text !== undefined) {
25
+ return text;
26
+ } else {
27
+ return c.caption;
28
+ }
29
+ }
30
+
31
+ export {
32
+ forField,
33
+ altTextOf,
34
+ }
35
+ /* sury Not a pure module */
@@ -1,29 +1,10 @@
1
1
  /**
2
- Marks an `int` field as a length of time, in **seconds**.
2
+ Marks an `int` field as a length of time, in **seconds** — whole, zero or
3
+ greater. Zero is a real duration.
3
4
 
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.
5
+ Seconds because that is the unit `format` reads; milliseconds would render as
6
+ weeks. A scalar rather than `{value, unit}` so the type stays additive on the
7
+ wire. `int` is safe here — int32 seconds is 68 years.
27
8
 
28
9
  @example
29
10
  ```rescript
@@ -51,3 +32,20 @@ let fromInt = (raw: int): result<t, string> =>
51
32
  /** The sury schema for a duration field, in seconds.
52
33
  Use with `@s.matches(Reventless.Duration.schema)`. */
53
34
  let schema: S.t<t> = S.int->Semantic.refined(~id=Semantic.Id.duration, ~check=fromInt)
35
+
36
+ let scales = [(86400, "d"), (3600, "h"), (60, "m"), (1, "s")]
37
+
38
+ /** The duration as text, largest unit first and zero units dropped — `3660` is
39
+ `"1h 1m"`, `0` is `"0s"`. Locale-independent, the way `Money.format` is. */
40
+ let format = (d: t): string => {
41
+ let remaining = ref(d < 0 ? 0 : d)
42
+ let parts = []
43
+ scales->Array.forEach(((size, suffix)) => {
44
+ let count = remaining.contents / size
45
+ if count > 0 {
46
+ parts->Array.push(Int.toString(count) ++ suffix)
47
+ remaining := remaining.contents - count * size
48
+ }
49
+ })
50
+ Array.length(parts) == 0 ? "0s" : parts->Array.join(" ")
51
+ }
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Sury from "sury";
4
+ import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
4
5
  import * as Semantic$Reventless from "./Semantic.res.mjs";
5
6
 
6
7
  function fromInt(raw) {
@@ -19,8 +20,50 @@ function fromInt(raw) {
19
20
 
20
21
  let schema = Semantic$Reventless.refined(Sury.int, Semantic$Reventless.Id.duration, fromInt);
21
22
 
23
+ let scales = [
24
+ [
25
+ 86400,
26
+ "d"
27
+ ],
28
+ [
29
+ 3600,
30
+ "h"
31
+ ],
32
+ [
33
+ 60,
34
+ "m"
35
+ ],
36
+ [
37
+ 1,
38
+ "s"
39
+ ]
40
+ ];
41
+
42
+ function format(d) {
43
+ let remaining = {
44
+ contents: d < 0 ? 0 : d
45
+ };
46
+ let parts = [];
47
+ scales.forEach(param => {
48
+ let size = param[0];
49
+ let count = Primitive_int.div(remaining.contents, size);
50
+ if (count > 0) {
51
+ parts.push(count.toString() + param[1]);
52
+ remaining.contents = remaining.contents - (count * size | 0) | 0;
53
+ return;
54
+ }
55
+ });
56
+ if (parts.length === 0) {
57
+ return "0s";
58
+ } else {
59
+ return parts.join(" ");
60
+ }
61
+ }
62
+
22
63
  export {
23
64
  fromInt,
24
65
  schema,
66
+ scales,
67
+ format,
25
68
  }
26
69
  /* schema Not a pure module */