@reventlessdev/reventless-spec 3.0.0-alpha.131 → 3.0.0-alpha.132
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 +10 -0
- package/package.json +1 -1
- package/schema/platform-api.graphql +1 -0
- package/src/components/ComponentKind.res +14 -8
- package/src/components/ComponentKind.res.mjs +8 -6
- package/src/lifecycle/CheckLifecycleModel.res +23 -7
- package/src/lifecycle/CheckLifecycleModel.res.mjs +37 -10
- package/src/semantic/CalendarDate.res +58 -0
- package/src/semantic/CalendarDate.res.mjs +37 -0
- package/src/semantic/DateRange.res +10 -10
- package/src/semantic/DateRange.res.mjs +12 -14
- package/src/semantic/DateTime.res +92 -0
- package/src/semantic/DateTime.res.mjs +65 -0
- package/src/semantic/RowImage.res +122 -0
- package/src/semantic/RowImage.res.mjs +101 -0
- package/src/semantic/Semantic.res +8 -0
- package/src/semantic/Semantic.res.mjs +2 -0
- package/src/types/Lifecycle.res +106 -0
- package/src/types/Lifecycle.res.mjs +115 -0
- package/src/types/DateTime.res +0 -29
- package/src/types/DateTime.res.mjs +0 -16
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
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.132 (2026-09-08)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* a slice folder is named for its kind, without the Slice ([a400555](https://github.com/ReventlessDev/reventless-core/commit/a400555121b023823983ebe846e99cda27063c1f))
|
|
11
|
+
* **core:** a reference names a row and shows its picture ([2b40d04](https://github.com/ReventlessDev/reventless-core/commit/2b40d0418bba2bf83463f446e94e372ea0c9898c))
|
|
12
|
+
* **core:** a state view records when it reached each state ([5216f71](https://github.com/ReventlessDev/reventless-core/commit/5216f71b8dcafcb2f0c991923ebcf2438b27e347))
|
|
13
|
+
* **spec:** an instant is a type, and a calendar day is a different one ([a60bebc](https://github.com/ReventlessDev/reventless-core/commit/a60bebc20fe5f3413f9252be36f94ba2a9fb620d))
|
|
14
|
+
|
|
15
|
+
|
|
6
16
|
# 3.0.0-alpha.131 (2026-09-07)
|
|
7
17
|
|
|
8
18
|
### Features
|
package/package.json
CHANGED
|
@@ -36,15 +36,17 @@ let all = [
|
|
|
36
36
|
]
|
|
37
37
|
|
|
38
38
|
// The canonical (singular) folder name for a kind — the spelling the naming
|
|
39
|
-
// conventions and the graph `kind` field use.
|
|
39
|
+
// conventions and the graph `kind` field use. A slice folder drops the `Slice`
|
|
40
|
+
// the kind carries: the folder already sits inside a plugin's `src/`, so the
|
|
41
|
+
// suffix only lengthened every path. The suffixed spellings stay accepted below.
|
|
40
42
|
let folderName = (t: t): string =>
|
|
41
43
|
switch t {
|
|
42
|
-
| StateChangeSlice => "
|
|
43
|
-
| StateViewSlice => "
|
|
44
|
-
| StateViewSliceStream => "
|
|
45
|
-
| AutomationSlice => "
|
|
46
|
-
| InboundTranslationSlice => "
|
|
47
|
-
| OutboundTranslationSlice => "
|
|
44
|
+
| StateChangeSlice => "StateChange"
|
|
45
|
+
| StateViewSlice => "StateView"
|
|
46
|
+
| StateViewSliceStream => "StateViewStream"
|
|
47
|
+
| AutomationSlice => "Automation"
|
|
48
|
+
| InboundTranslationSlice => "InboundTranslation"
|
|
49
|
+
| OutboundTranslationSlice => "OutboundTranslation"
|
|
48
50
|
| Aggregate => "Aggregate"
|
|
49
51
|
| ReadModel => "ReadModel"
|
|
50
52
|
| ReadModelStream => "ReadModelStream"
|
|
@@ -60,7 +62,11 @@ let folderToKind = (folder: string): option<t> =>
|
|
|
60
62
|
| "StateChange" | "StateChanges" | "StateChangeSlice" | "StateChangeSlices" =>
|
|
61
63
|
Some(StateChangeSlice)
|
|
62
64
|
| "StateView" | "StateViews" | "StateViewSlice" | "StateViewSlices" => Some(StateViewSlice)
|
|
63
|
-
| "
|
|
65
|
+
| "StateViewStream"
|
|
66
|
+
| "StateViewStreams"
|
|
67
|
+
| "StateViewSliceStream"
|
|
68
|
+
| "StateViewSliceStreams" =>
|
|
69
|
+
Some(StateViewSliceStream)
|
|
64
70
|
| "Automation" | "Automations" | "AutomationSlice" | "AutomationSlices" => Some(AutomationSlice)
|
|
65
71
|
| "InboundTranslation"
|
|
66
72
|
| "InboundTranslations"
|
|
@@ -20,17 +20,17 @@ let all = [
|
|
|
20
20
|
function folderName(t) {
|
|
21
21
|
switch (t) {
|
|
22
22
|
case "StateChangeSlice" :
|
|
23
|
-
return "
|
|
23
|
+
return "StateChange";
|
|
24
24
|
case "StateViewSlice" :
|
|
25
|
-
return "
|
|
25
|
+
return "StateView";
|
|
26
26
|
case "StateViewSliceStream" :
|
|
27
|
-
return "
|
|
27
|
+
return "StateViewStream";
|
|
28
28
|
case "AutomationSlice" :
|
|
29
|
-
return "
|
|
29
|
+
return "Automation";
|
|
30
30
|
case "InboundTranslationSlice" :
|
|
31
|
-
return "
|
|
31
|
+
return "InboundTranslation";
|
|
32
32
|
case "OutboundTranslationSlice" :
|
|
33
|
-
return "
|
|
33
|
+
return "OutboundTranslation";
|
|
34
34
|
case "Aggregate" :
|
|
35
35
|
return "Aggregate";
|
|
36
36
|
case "ReadModel" :
|
|
@@ -85,6 +85,8 @@ function folderToKind(folder) {
|
|
|
85
85
|
return "StateChangeSlice";
|
|
86
86
|
case "StateViewSliceStream" :
|
|
87
87
|
case "StateViewSliceStreams" :
|
|
88
|
+
case "StateViewStream" :
|
|
89
|
+
case "StateViewStreams" :
|
|
88
90
|
return "StateViewSliceStream";
|
|
89
91
|
case "StateView" :
|
|
90
92
|
case "StateViewSlice" :
|
|
@@ -897,16 +897,32 @@ let roots: array<appRoot> = switch flagValues("--root") {
|
|
|
897
897
|
})
|
|
898
898
|
}
|
|
899
899
|
|
|
900
|
-
/**
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
900
|
+
/** The kind a sidecar's source folder names, read through `ComponentKind` — the
|
|
901
|
+
one vocabulary the plugin generator and the PPX already classify a folder by,
|
|
902
|
+
so every accepted spelling (short, plural, `Slice`-suffixed) lands here too.
|
|
903
|
+
The innermost matching segment wins, as it does everywhere else. */
|
|
904
|
+
let kindOfPath = (path: string): option<ComponentKind.t> =>
|
|
905
|
+
path
|
|
906
|
+
->String.split("/")
|
|
907
|
+
->Array.reduce(None, (found, segment) =>
|
|
908
|
+
switch ComponentKind.folderToKind(segment) {
|
|
909
|
+
| Some(_) as kind => kind
|
|
910
|
+
| None => found
|
|
911
|
+
}
|
|
906
912
|
)
|
|
907
913
|
|
|
914
|
+
/** Sidecar paths that describe a queryable, and those that describe a writable. */
|
|
915
|
+
let isViewPath = (path: string) =>
|
|
916
|
+
switch kindOfPath(path) {
|
|
917
|
+
| Some(ReadModel | ReadModelStream | StateViewSlice | StateViewSliceStream) => true
|
|
918
|
+
| _ => false
|
|
919
|
+
}
|
|
920
|
+
|
|
908
921
|
let isWritablePath = (path: string) =>
|
|
909
|
-
|
|
922
|
+
switch kindOfPath(path) {
|
|
923
|
+
| Some(Aggregate | StateChangeSlice) => true
|
|
924
|
+
| _ => false
|
|
925
|
+
}
|
|
910
926
|
|
|
911
927
|
let runPlugin = async (
|
|
912
928
|
~plugin: string,
|
|
@@ -11,6 +11,7 @@ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js
|
|
|
11
11
|
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
12
12
|
import * as Nodechild_process from "node:child_process";
|
|
13
13
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
14
|
+
import * as ComponentKind$Reventless from "../components/ComponentKind.res.mjs";
|
|
14
15
|
import * as LoadPluginStructureMjs from "./loadPluginStructure.mjs";
|
|
15
16
|
|
|
16
17
|
process.env["REVENTLESS_DECLARED_TRANSITIONS_ONLY"] = "1";
|
|
@@ -612,20 +613,45 @@ if (given.length !== 0) {
|
|
|
612
613
|
}
|
|
613
614
|
}
|
|
614
615
|
|
|
616
|
+
function kindOfPath(path) {
|
|
617
|
+
return Stdlib_Array.reduce(path.split("/"), undefined, (found, segment) => {
|
|
618
|
+
let kind = ComponentKind$Reventless.folderToKind(segment);
|
|
619
|
+
if (kind !== undefined) {
|
|
620
|
+
return kind;
|
|
621
|
+
} else {
|
|
622
|
+
return found;
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
615
627
|
function isViewPath(path) {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
628
|
+
let match = kindOfPath(path);
|
|
629
|
+
if (match === undefined) {
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
switch (match) {
|
|
633
|
+
case "StateViewSlice" :
|
|
634
|
+
case "StateViewSliceStream" :
|
|
635
|
+
case "ReadModel" :
|
|
636
|
+
case "ReadModelStream" :
|
|
637
|
+
return true;
|
|
638
|
+
default:
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
622
641
|
}
|
|
623
642
|
|
|
624
643
|
function isWritablePath(path) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
644
|
+
let match = kindOfPath(path);
|
|
645
|
+
if (match === undefined) {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
switch (match) {
|
|
649
|
+
case "StateChangeSlice" :
|
|
650
|
+
case "Aggregate" :
|
|
651
|
+
return true;
|
|
652
|
+
default:
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
629
655
|
}
|
|
630
656
|
|
|
631
657
|
async function runPlugin(plugin, pluginDir, findings, opaque) {
|
|
@@ -1092,6 +1118,7 @@ export {
|
|
|
1092
1118
|
compare,
|
|
1093
1119
|
pluginDirsIn,
|
|
1094
1120
|
roots,
|
|
1121
|
+
kindOfPath,
|
|
1095
1122
|
isViewPath,
|
|
1096
1123
|
isWritablePath,
|
|
1097
1124
|
runPlugin,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Marks a `string` field as a calendar day — `2026-03-02`, with no instant in it.
|
|
3
|
+
|
|
4
|
+
## Why this is not a `DateTime`
|
|
5
|
+
|
|
6
|
+
A birth date, a due date, an invoice date, an `effectiveFrom` — the value has no
|
|
7
|
+
time of day, and storing one as an instant creates the midnight bug: a renderer
|
|
8
|
+
that localizes `2026-03-02T00:00:00Z` shows *March 1st* to every reader west of
|
|
9
|
+
Greenwich. That is a wrong date on a screen produced by a correct renderer, and
|
|
10
|
+
no annotation can repair it, because the value itself has lost the distinction.
|
|
11
|
+
|
|
12
|
+
## The grammar
|
|
13
|
+
|
|
14
|
+
Sury's `S.isoDate`: `2026-03-02` accepts, `2026-13-02`, `09:00:00`,
|
|
15
|
+
`2026-03-02T09:00:00Z` and the empty string all reject. It emits
|
|
16
|
+
`format: "date"`, the standard JSON Schema keyword a consumer already reads.
|
|
17
|
+
|
|
18
|
+
## Why `CalendarDate` and not `Date`
|
|
19
|
+
|
|
20
|
+
`reventless-spec` compiles with `-open RescriptCore`, and its own modules resolve
|
|
21
|
+
unqualified inside the package — so a `Date.res` here would shadow the stdlib
|
|
22
|
+
`Date` for every file in it. The wire vocabulary id stays the short `"date"`;
|
|
23
|
+
only the module name carries the qualifier.
|
|
24
|
+
|
|
25
|
+
@example
|
|
26
|
+
```rescript
|
|
27
|
+
@schema
|
|
28
|
+
type state = {
|
|
29
|
+
invoiceId: string,
|
|
30
|
+
issuedOn: Reventless.CalendarDate.t,
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** The day's representation. Transparent `string`, like the rest of the branded
|
|
36
|
+
scalars: the marker refines an existing field rather than replacing it. */
|
|
37
|
+
type t = string
|
|
38
|
+
|
|
39
|
+
external unsafe: string => t = "%identity"
|
|
40
|
+
external toString: t => string = "%identity"
|
|
41
|
+
|
|
42
|
+
// Sury's rule, held once. `fromString` runs it rather than restating it, and
|
|
43
|
+
// `schema` is built from `fromString`, so there is exactly one grammar here.
|
|
44
|
+
let grammar: S.t<string> = S.isoDate
|
|
45
|
+
|
|
46
|
+
/** Validate a raw string as an ISO-8601 calendar day, saying why when it is not one. */
|
|
47
|
+
let fromString = (raw: string): result<t, string> =>
|
|
48
|
+
switch raw->S.parseOrThrow(~to=grammar) {
|
|
49
|
+
| value => Ok(value)
|
|
50
|
+
| exception _ => Error(`expected a calendar date (YYYY-MM-DD), got ${Semantic.showString(raw)}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The sury schema for a calendar-date field. Write the field's type as
|
|
54
|
+
`Reventless.CalendarDate.t` and sury-ppx resolves it. */
|
|
55
|
+
let schema: S.t<t> = S.string->Semantic.refined(~id=Semantic.Id.date, ~check=fromString)
|
|
56
|
+
|
|
57
|
+
/** Whether a field schema carries the calendar-date marker. */
|
|
58
|
+
let isCalendarDate = (fieldSchema: S.t<unknown>) => fieldSchema->Semantic.has(~id=Semantic.Id.date)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Sury from "sury";
|
|
5
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
6
|
+
|
|
7
|
+
let grammar = Sury.isoDate;
|
|
8
|
+
|
|
9
|
+
function fromString(raw) {
|
|
10
|
+
let value;
|
|
11
|
+
try {
|
|
12
|
+
value = S.parseOrThrow(raw, grammar);
|
|
13
|
+
} catch (exn) {
|
|
14
|
+
return {
|
|
15
|
+
TAG: "Error",
|
|
16
|
+
_0: `expected a calendar date (YYYY-MM-DD), got ` + Semantic$Reventless.showString(raw)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
TAG: "Ok",
|
|
21
|
+
_0: value
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let schema = Semantic$Reventless.refined(Sury.string, Semantic$Reventless.Id.date, fromString);
|
|
26
|
+
|
|
27
|
+
function isCalendarDate(fieldSchema) {
|
|
28
|
+
return Semantic$Reventless.has(fieldSchema, Semantic$Reventless.Id.date);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
grammar,
|
|
33
|
+
fromString,
|
|
34
|
+
schema,
|
|
35
|
+
isCalendarDate,
|
|
36
|
+
}
|
|
37
|
+
/* grammar Not a pure module */
|
|
@@ -41,10 +41,10 @@ who assumes parity with `Money` assumes wrong. When sury fixes the record
|
|
|
41
41
|
refinement the rule moves into the schema and `validate` stays as its single
|
|
42
42
|
definition — the relationship `Money.validateAmount` has with `amountSchema`.
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
is a
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
The *parts* are checked, though, and by their own type rather than by anything
|
|
45
|
+
here: each is a `DateTime`, so a string that is not a UTC instant is rejected at
|
|
46
|
+
decode. What is left for `validate` is the one rule that relates the two — a
|
|
47
|
+
range whose instants both parse, the earlier one second.
|
|
48
48
|
|
|
49
49
|
## How a field declares it
|
|
50
50
|
|
|
@@ -70,12 +70,12 @@ projection rebuild. It costs a log something only if it *collapses* an existing
|
|
|
70
70
|
@schema
|
|
71
71
|
type t = {
|
|
72
72
|
/** The instant the range opens, inclusive. */
|
|
73
|
-
start:
|
|
73
|
+
start: DateTime.t,
|
|
74
74
|
/** The instant the range closes, **exclusive** — the range does not contain
|
|
75
75
|
it. `@as("end")` puts `end` on the wire (where the UI's own `GanttChart`
|
|
76
76
|
already spells it that way); `end_` is the source spelling because `end`
|
|
77
77
|
is awkward as a bare ReScript field. */
|
|
78
|
-
@as("end") end_:
|
|
78
|
+
@as("end") end_: DateTime.t,
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/** The sury schema for a date-range field, carrying the `dateRange` semantic.
|
|
@@ -85,10 +85,10 @@ type t = {
|
|
|
85
85
|
is deliberately *not* refined in here — see the module doc. */
|
|
86
86
|
let schema: S.t<t> = schema->Semantic.mark(~id=Semantic.Id.dateRange)
|
|
87
87
|
|
|
88
|
-
/** An instant as milliseconds since the epoch
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
let millis =
|
|
88
|
+
/** An instant as milliseconds since the epoch. `DateTime`'s, so end-exclusivity
|
|
89
|
+
and the ordering rule are expressed against the same single parse the type
|
|
90
|
+
itself uses rather than a second copy of it here. */
|
|
91
|
+
let millis = DateTime.millis
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
94
|
Validate a range's ordering, saying why when it is reversed.
|
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Sury from "sury";
|
|
4
|
-
import * as DateTime$Reventless from "
|
|
4
|
+
import * as DateTime$Reventless from "./DateTime.res.mjs";
|
|
5
5
|
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
6
6
|
|
|
7
7
|
let schema = Sury.$schema(s => ({
|
|
8
|
-
start: s.m(DateTime$Reventless.
|
|
9
|
-
end: s.m(DateTime$Reventless.
|
|
8
|
+
start: s.m(DateTime$Reventless.schema),
|
|
9
|
+
end: s.m(DateTime$Reventless.schema)
|
|
10
10
|
}));
|
|
11
11
|
|
|
12
12
|
let schema$1 = Semantic$Reventless.mark(schema, Semantic$Reventless.Id.dateRange, undefined);
|
|
13
13
|
|
|
14
|
-
function millis(instant) {
|
|
15
|
-
return new Date(instant).getTime();
|
|
16
|
-
}
|
|
17
|
-
|
|
18
14
|
function validate(range) {
|
|
19
|
-
if (
|
|
15
|
+
if (DateTime$Reventless.millis(range.start) > DateTime$Reventless.millis(range.end)) {
|
|
20
16
|
return {
|
|
21
17
|
TAG: "Error",
|
|
22
18
|
_0: `a range ends before it starts: ` + range.start + ` is after ` + range.end + `. A range is [start, end) — the start is the earlier instant.`
|
|
@@ -37,21 +33,21 @@ function make(start, end_) {
|
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
function duration(range) {
|
|
40
|
-
return Math.trunc((
|
|
36
|
+
return Math.trunc((DateTime$Reventless.millis(range.end) - DateTime$Reventless.millis(range.start)) / 1000.0) | 0;
|
|
41
37
|
}
|
|
42
38
|
|
|
43
39
|
function contains(range, instant) {
|
|
44
|
-
let t =
|
|
45
|
-
if (t >=
|
|
46
|
-
return t <
|
|
40
|
+
let t = DateTime$Reventless.millis(instant);
|
|
41
|
+
if (t >= DateTime$Reventless.millis(range.start)) {
|
|
42
|
+
return t < DateTime$Reventless.millis(range.end);
|
|
47
43
|
} else {
|
|
48
44
|
return false;
|
|
49
45
|
}
|
|
50
46
|
}
|
|
51
47
|
|
|
52
48
|
function overlaps(a, b) {
|
|
53
|
-
if (
|
|
54
|
-
return
|
|
49
|
+
if (DateTime$Reventless.millis(a.start) < DateTime$Reventless.millis(b.end)) {
|
|
50
|
+
return DateTime$Reventless.millis(b.start) < DateTime$Reventless.millis(a.end);
|
|
55
51
|
} else {
|
|
56
52
|
return false;
|
|
57
53
|
}
|
|
@@ -61,6 +57,8 @@ function format(range) {
|
|
|
61
57
|
return range.start + ` – ` + range.end;
|
|
62
58
|
}
|
|
63
59
|
|
|
60
|
+
let millis = DateTime$Reventless.millis;
|
|
61
|
+
|
|
64
62
|
export {
|
|
65
63
|
schema$1 as schema,
|
|
66
64
|
millis,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Marks a `string` field as an ISO-8601 instant.
|
|
3
|
+
|
|
4
|
+
## The grammar
|
|
5
|
+
|
|
6
|
+
Sury's `S.isoDateTime`, and nothing added — the same rule the branded scalars
|
|
7
|
+
follow. `S.datetime` is the other binding and is not this one: it *transforms* to
|
|
8
|
+
`Js.Date.t`, changing the field's runtime type, where this keeps the string the
|
|
9
|
+
projection wrote.
|
|
10
|
+
|
|
11
|
+
## UTC only
|
|
12
|
+
|
|
13
|
+
`S.isoDateTime` accepts a `Z` instant and rejects an offset — `2026-03-02T09:00:00+01:00`
|
|
14
|
+
and a bare `2026-03-02T09:00:00` both fail. That is stricter than RFC 3339 and it
|
|
15
|
+
is the strictness worth adopting: every instant the framework produces comes from
|
|
16
|
+
`Message.nowAsISOString`, which is always `Z`. "Instants are stored in UTC" is a
|
|
17
|
+
fact a reader can rely on; "we accept whatever offset arrived" pushes the zone
|
|
18
|
+
question onto every consumer.
|
|
19
|
+
|
|
20
|
+
An inbound feed carrying a supplier's local offset converts at the boundary,
|
|
21
|
+
which is what an `InboundTranslation` slice is for — `Date.fromString` then
|
|
22
|
+
`toISOString`. Forgetting it is now a rejection rather than a row nobody can sort.
|
|
23
|
+
|
|
24
|
+
`SchemaType`/`SuryToJsonSchema` surface the marker as `format: "date-time"` on
|
|
25
|
+
the field's JSON Schema, which the AutoUI date heuristics key off (CalendarView,
|
|
26
|
+
TimelineView, date-axis charts).
|
|
27
|
+
|
|
28
|
+
A date with no instant in it — a birth date, a due date — is `CalendarDate`, not
|
|
29
|
+
this. Storing one as an instant creates the midnight bug.
|
|
30
|
+
|
|
31
|
+
@example
|
|
32
|
+
```rescript
|
|
33
|
+
@schema
|
|
34
|
+
type state = {
|
|
35
|
+
orderId: string,
|
|
36
|
+
placedAt: Reventless.DateTime.t,
|
|
37
|
+
shippedAt: option<Reventless.DateTime.t>,
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The instant's representation. Transparent `string`: the marker refines an
|
|
43
|
+
existing field rather than replacing it, so nothing stored changes, and
|
|
44
|
+
`placedAt: meta.time` keeps compiling. */
|
|
45
|
+
type t = string
|
|
46
|
+
|
|
47
|
+
external unsafe: string => t = "%identity"
|
|
48
|
+
external toString: t => string = "%identity"
|
|
49
|
+
|
|
50
|
+
// Sury's rule, held once. `fromString` runs it rather than restating it, and
|
|
51
|
+
// `schema` is built from `fromString`, so there is exactly one grammar here.
|
|
52
|
+
let grammar: S.t<string> = S.isoDateTime
|
|
53
|
+
|
|
54
|
+
/** Validate a raw string as a UTC ISO-8601 instant, saying why when it is not one. */
|
|
55
|
+
let fromString = (raw: string): result<t, string> =>
|
|
56
|
+
switch raw->S.parseOrThrow(~to=grammar) {
|
|
57
|
+
| value => Ok(value)
|
|
58
|
+
| exception _ =>
|
|
59
|
+
Error(`expected a UTC ISO-8601 instant, got ${Semantic.showString(raw)}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The sury schema for an instant field. Use with `@s.matches(Reventless.DateTime.schema)`,
|
|
63
|
+
or write the field's type as `Reventless.DateTime.t` and let sury-ppx resolve it. */
|
|
64
|
+
let schema: S.t<t> = S.string->Semantic.refined(~id=Semantic.Id.dateTime, ~check=fromString)
|
|
65
|
+
|
|
66
|
+
/** A sury string schema annotated as an instant, without the grammar.
|
|
67
|
+
|
|
68
|
+
@deprecated Use `schema`, which carries the same marker and checks the value. */
|
|
69
|
+
let string: S.t<string> = S.string->Semantic.mark(~id=Semantic.Id.dateTime)
|
|
70
|
+
|
|
71
|
+
/** Whether a field schema carries the date-time marker. */
|
|
72
|
+
let isDateTime = (fieldSchema: S.t<unknown>) => fieldSchema->Semantic.has(~id=Semantic.Id.dateTime)
|
|
73
|
+
|
|
74
|
+
/** An instant as milliseconds since the epoch — `NaN` if it does not parse. The
|
|
75
|
+
one place an instant becomes a number, so every comparison here and in
|
|
76
|
+
`DateRange` is expressed against a single parse. */
|
|
77
|
+
let millis = (instant: t): float => instant->Date.fromString->Date.getTime
|
|
78
|
+
|
|
79
|
+
/** Whether one instant is strictly earlier than another. */
|
|
80
|
+
let isBefore = (a: t, b: t): bool => millis(a) < millis(b)
|
|
81
|
+
|
|
82
|
+
/** Order two instants, oldest first. */
|
|
83
|
+
let compare = (a: t, b: t) => Float.compare(millis(a), millis(b))
|
|
84
|
+
|
|
85
|
+
/** The instant as text, to the minute, in UTC — `"2026-03-02 09:00"`.
|
|
86
|
+
Locale-independent, the way `Money.format` and `Duration.format` are: the
|
|
87
|
+
same value reads the same in every log line and every test. A value that is
|
|
88
|
+
not the shape the grammar admits reads back unchanged. */
|
|
89
|
+
let format = (instant: t): string =>
|
|
90
|
+
String.length(instant) >= 16
|
|
91
|
+
? String.slice(instant, ~start=0, ~end=10) ++ " " ++ String.slice(instant, ~start=11, ~end=16)
|
|
92
|
+
: instant
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Sury from "sury";
|
|
5
|
+
import * as Primitive_float from "@rescript/runtime/lib/es6/Primitive_float.js";
|
|
6
|
+
import * as Semantic$Reventless from "./Semantic.res.mjs";
|
|
7
|
+
|
|
8
|
+
let grammar = Sury.isoDateTime;
|
|
9
|
+
|
|
10
|
+
function fromString(raw) {
|
|
11
|
+
let value;
|
|
12
|
+
try {
|
|
13
|
+
value = S.parseOrThrow(raw, grammar);
|
|
14
|
+
} catch (exn) {
|
|
15
|
+
return {
|
|
16
|
+
TAG: "Error",
|
|
17
|
+
_0: `expected a UTC ISO-8601 instant, got ` + Semantic$Reventless.showString(raw)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
TAG: "Ok",
|
|
22
|
+
_0: value
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let schema = Semantic$Reventless.refined(Sury.string, Semantic$Reventless.Id.dateTime, fromString);
|
|
27
|
+
|
|
28
|
+
let string = Semantic$Reventless.mark(Sury.string, Semantic$Reventless.Id.dateTime, undefined);
|
|
29
|
+
|
|
30
|
+
function isDateTime(fieldSchema) {
|
|
31
|
+
return Semantic$Reventless.has(fieldSchema, Semantic$Reventless.Id.dateTime);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function millis(instant) {
|
|
35
|
+
return new Date(instant).getTime();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isBefore(a, b) {
|
|
39
|
+
return new Date(a).getTime() < new Date(b).getTime();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function compare(a, b) {
|
|
43
|
+
return Primitive_float.compare(new Date(a).getTime(), new Date(b).getTime());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function format(instant) {
|
|
47
|
+
if (instant.length >= 16) {
|
|
48
|
+
return instant.slice(0, 10) + " " + instant.slice(11, 16);
|
|
49
|
+
} else {
|
|
50
|
+
return instant;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
grammar,
|
|
56
|
+
fromString,
|
|
57
|
+
schema,
|
|
58
|
+
string,
|
|
59
|
+
isDateTime,
|
|
60
|
+
millis,
|
|
61
|
+
isBefore,
|
|
62
|
+
compare,
|
|
63
|
+
format,
|
|
64
|
+
}
|
|
65
|
+
/* grammar Not a pure module */
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
The picture a row is shown by, wherever a row is shown as a reference to it.
|
|
3
|
+
|
|
4
|
+
## Why this is derived once, here
|
|
5
|
+
|
|
6
|
+
The reference door (`{list}Refs`) names a row for a caller holding a pointer to
|
|
7
|
+
it, and a card or a tile that draws such a reference wants its picture for the
|
|
8
|
+
same reason it wants its name. Three backends answer that door — an AppSync
|
|
9
|
+
resolver over DynamoDB, the Postgres query Lambda, and the local GraphQL server
|
|
10
|
+
— and a rule about "where a row keeps its picture" spelled three times is a rule
|
|
11
|
+
the three would eventually disagree about. So the *field* is found once, from the
|
|
12
|
+
state schema, and each backend only reads it out.
|
|
13
|
+
|
|
14
|
+
## What counts as the picture
|
|
15
|
+
|
|
16
|
+
The first field, in declaration order, carrying an image semantic:
|
|
17
|
+
`uploadableImage` or `imageRef` (the field's value IS the ref) or
|
|
18
|
+
`captionedImage` (the ref sits inside the record, beside its text). A field
|
|
19
|
+
holding a set of them is read at `[0]`, because a set puts the primary member
|
|
20
|
+
first — the same member a card, a gallery tile and a list cell already draw.
|
|
21
|
+
|
|
22
|
+
Declaration order rather than a name rule: a view with two image fields has
|
|
23
|
+
already said which one comes first, and guessing from names would let a field
|
|
24
|
+
called `thumbnail` outrank the one the author put at the top.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Where the ref string sits, relative to the field that carries it. */
|
|
28
|
+
type shape =
|
|
29
|
+
| /** The field's own value is the ref. */ Scalar
|
|
30
|
+
| /** The ref is one member of the field's record. */ Member(string)
|
|
31
|
+
|
|
32
|
+
type source = {
|
|
33
|
+
field: string,
|
|
34
|
+
shape: shape,
|
|
35
|
+
/** The field holds a set, and the row is shown by its first member. */
|
|
36
|
+
fromArray: bool,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `CaptionedImage`'s own name for the reference it wraps. Named here rather
|
|
40
|
+
than spelled at each reader: the record is this package's, so a rename would
|
|
41
|
+
otherwise be caught nowhere. */
|
|
42
|
+
let captionedImageMember = "ref"
|
|
43
|
+
|
|
44
|
+
let shapeOf = (schema: S.t<unknown>): option<shape> =>
|
|
45
|
+
switch Semantic.getFrom(schema) {
|
|
46
|
+
| Some({id}) if id === Semantic.Id.uploadableImage || id === Semantic.Id.imageRef => Some(Scalar)
|
|
47
|
+
| Some({id}) if id === Semantic.Id.captionedImage => Some(Member(captionedImageMember))
|
|
48
|
+
| _ => None
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// sury keeps a homogeneous element schema in `additionalItems` and a tuple's
|
|
52
|
+
// positional schemas in `items`; both are read, as `StorageRef.getFieldStore`
|
|
53
|
+
// does, so the marker is found wherever the element sits.
|
|
54
|
+
let elementOf = (schema: S.t<unknown>): option<S.t<unknown>> =>
|
|
55
|
+
switch schema {
|
|
56
|
+
| Array({items, additionalItems}) =>
|
|
57
|
+
switch items->Array.get(0) {
|
|
58
|
+
| Some(_) as element => element
|
|
59
|
+
| None =>
|
|
60
|
+
switch additionalItems {
|
|
61
|
+
| Schema(element) => Some(element)
|
|
62
|
+
| _ => None
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
| _ => None
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let sourceOfField = (~field: string, schema: S.t<unknown>): option<source> => {
|
|
69
|
+
// The marker on an optional field lives inside the wrapper, and an array's
|
|
70
|
+
// lives on its element — so both are stepped through before asking.
|
|
71
|
+
let unwrapped = schema->Semantic.unwrapOptional->Option.getOr(schema)
|
|
72
|
+
switch shapeOf(unwrapped) {
|
|
73
|
+
| Some(shape) => Some({field, shape, fromArray: false})
|
|
74
|
+
| None =>
|
|
75
|
+
unwrapped
|
|
76
|
+
->elementOf
|
|
77
|
+
->Option.flatMap(shapeOf)
|
|
78
|
+
->Option.map(shape => {field, shape, fromArray: true})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The field a reference to this row shows it by, or nothing where the view
|
|
83
|
+
declares no picture — which is most of them. */
|
|
84
|
+
let sourceFrom = (stateSchema: S.t<unknown>): option<source> =>
|
|
85
|
+
switch stateSchema {
|
|
86
|
+
| Object({properties}) =>
|
|
87
|
+
properties
|
|
88
|
+
->Dict.toArray
|
|
89
|
+
->Array.filterMap(((field, schema)) => sourceOfField(~field, schema))
|
|
90
|
+
->Array.get(0)
|
|
91
|
+
| _ => None
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The ref one row carries, read out of the stored item. An empty string is read
|
|
95
|
+
as absent: a projection that wrote a placeholder said nothing about a
|
|
96
|
+
picture, and a consumer resolving `""` against a store gets a broken tile. */
|
|
97
|
+
let refFrom = (row: dict<JSON.t>, source: source): option<string> => {
|
|
98
|
+
let value = row->Dict.get(source.field)
|
|
99
|
+
let value = source.fromArray
|
|
100
|
+
? value->Option.flatMap(JSON.Decode.array)->Option.flatMap(members => members->Array.get(0))
|
|
101
|
+
: value
|
|
102
|
+
switch source.shape {
|
|
103
|
+
| Scalar => value->Option.flatMap(JSON.Decode.string)
|
|
104
|
+
| Member(member) =>
|
|
105
|
+
value
|
|
106
|
+
->Option.flatMap(JSON.Decode.object)
|
|
107
|
+
->Option.flatMap(d => d->Dict.get(member))
|
|
108
|
+
->Option.flatMap(JSON.Decode.string)
|
|
109
|
+
}->Option.filter(ref => ref != "")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The same read as a JavaScript expression, for the one backend whose resolver
|
|
113
|
+
is generated source rather than a function: the AppSync template. `null`
|
|
114
|
+
where the row carries nothing, which is what the field's type promises. */
|
|
115
|
+
let jsExpr = (~row: string, source: source): string => {
|
|
116
|
+
let at = `${row}['${source.field}']`
|
|
117
|
+
let at = source.fromArray ? `(${at} ?? [])[0]` : at
|
|
118
|
+
switch source.shape {
|
|
119
|
+
| Scalar => `${at} ?? null`
|
|
120
|
+
| Member(member) => `${at}?.['${member}'] ?? null`
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
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.res.mjs";
|
|
7
|
+
|
|
8
|
+
let captionedImageMember = "ref";
|
|
9
|
+
|
|
10
|
+
function shapeOf(schema) {
|
|
11
|
+
let match = Semantic$Reventless.getFrom(schema);
|
|
12
|
+
if (match === undefined) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
let id = match.id;
|
|
16
|
+
if (id === Semantic$Reventless.Id.uploadableImage || id === Semantic$Reventless.Id.imageRef) {
|
|
17
|
+
return "Scalar";
|
|
18
|
+
} else if (id === Semantic$Reventless.Id.captionedImage) {
|
|
19
|
+
return {
|
|
20
|
+
TAG: "Member",
|
|
21
|
+
_0: captionedImageMember
|
|
22
|
+
};
|
|
23
|
+
} else {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function elementOf(schema) {
|
|
29
|
+
if (schema.type !== "array") {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let additionalItems = schema.additionalItems;
|
|
33
|
+
let element = schema.items[0];
|
|
34
|
+
if (element !== undefined) {
|
|
35
|
+
return element;
|
|
36
|
+
} else if (additionalItems === "strip" || additionalItems === "strict") {
|
|
37
|
+
return;
|
|
38
|
+
} else {
|
|
39
|
+
return additionalItems;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sourceOfField(field, schema) {
|
|
44
|
+
let unwrapped = Stdlib_Option.getOr(Semantic$Reventless.unwrapOptional(schema), schema);
|
|
45
|
+
let shape = shapeOf(unwrapped);
|
|
46
|
+
if (shape !== undefined) {
|
|
47
|
+
return {
|
|
48
|
+
field: field,
|
|
49
|
+
shape: shape,
|
|
50
|
+
fromArray: false
|
|
51
|
+
};
|
|
52
|
+
} else {
|
|
53
|
+
return Stdlib_Option.map(Stdlib_Option.flatMap(elementOf(unwrapped), shapeOf), shape => ({
|
|
54
|
+
field: field,
|
|
55
|
+
shape: shape,
|
|
56
|
+
fromArray: true
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sourceFrom(stateSchema) {
|
|
62
|
+
if (stateSchema.type === "object") {
|
|
63
|
+
return Stdlib_Array.filterMap(Object.entries(stateSchema.properties), param => sourceOfField(param[0], param[1]))[0];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function refFrom(row, source) {
|
|
68
|
+
let value = row[source.field];
|
|
69
|
+
let value$1 = source.fromArray ? Stdlib_Option.flatMap(Stdlib_Option.flatMap(value, Stdlib_JSON.Decode.array), members => members[0]) : value;
|
|
70
|
+
let member = source.shape;
|
|
71
|
+
let tmp;
|
|
72
|
+
if (typeof member !== "object") {
|
|
73
|
+
tmp = Stdlib_Option.flatMap(value$1, Stdlib_JSON.Decode.string);
|
|
74
|
+
} else {
|
|
75
|
+
let member$1 = member._0;
|
|
76
|
+
tmp = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(value$1, Stdlib_JSON.Decode.object), d => d[member$1]), Stdlib_JSON.Decode.string);
|
|
77
|
+
}
|
|
78
|
+
return Stdlib_Option.filter(tmp, ref => ref !== "");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function jsExpr(row, source) {
|
|
82
|
+
let at = row + `['` + source.field + `']`;
|
|
83
|
+
let at$1 = source.fromArray ? `(` + at + ` ?? [])[0]` : at;
|
|
84
|
+
let member = source.shape;
|
|
85
|
+
if (typeof member !== "object") {
|
|
86
|
+
return at$1 + ` ?? null`;
|
|
87
|
+
} else {
|
|
88
|
+
return at$1 + `?.['` + member._0 + `'] ?? null`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export {
|
|
93
|
+
captionedImageMember,
|
|
94
|
+
shapeOf,
|
|
95
|
+
elementOf,
|
|
96
|
+
sourceOfField,
|
|
97
|
+
sourceFrom,
|
|
98
|
+
refFrom,
|
|
99
|
+
jsExpr,
|
|
100
|
+
}
|
|
101
|
+
/* Semantic-Reventless Not a pure module */
|
|
@@ -59,6 +59,9 @@ type t = {id: string, payload: payload}
|
|
|
59
59
|
`x-reventless-semantic` carries, shared with the annotation path. */
|
|
60
60
|
module Id = {
|
|
61
61
|
let dateTime = "dateTime"
|
|
62
|
+
// A day with no instant in it. Separate from `dateTime` because storing one as
|
|
63
|
+
// an instant is what produces the midnight bug — see `CalendarDate`.
|
|
64
|
+
let date = "date"
|
|
62
65
|
let reference = "reference"
|
|
63
66
|
let storageRef = "storageRef"
|
|
64
67
|
// Like `storageRef`, but inline-or-reference rather than always a ref path.
|
|
@@ -101,6 +104,11 @@ module Id = {
|
|
|
101
104
|
// A lat/lng pair. Cheapest to adopt: `{lat, lng}` is already the stored shape.
|
|
102
105
|
let geoPoint = "geoPoint"
|
|
103
106
|
|
|
107
|
+
// The ordered record of the states a row has been through. Marks a shape
|
|
108
|
+
// rather than a scalar's meaning, and rides here anyway: this is the marker
|
|
109
|
+
// the schema walk emits, so anything else would be invisible on the wire.
|
|
110
|
+
let lifecycleTrail = "lifecycleTrail"
|
|
111
|
+
|
|
104
112
|
// The first composite that is a union rather than an object. Collapses fields,
|
|
105
113
|
// so adopting it changes the wire and rebuilds a derived view.
|
|
106
114
|
let geolocation = "geolocation"
|
|
@@ -6,6 +6,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
6
6
|
|
|
7
7
|
let Id = {
|
|
8
8
|
dateTime: "dateTime",
|
|
9
|
+
date: "date",
|
|
9
10
|
reference: "reference",
|
|
10
11
|
storageRef: "storageRef",
|
|
11
12
|
offload: "offload",
|
|
@@ -25,6 +26,7 @@ let Id = {
|
|
|
25
26
|
money: "money",
|
|
26
27
|
dateRange: "dateRange",
|
|
27
28
|
geoPoint: "geoPoint",
|
|
29
|
+
lifecycleTrail: "lifecycleTrail",
|
|
28
30
|
geolocation: "geolocation"
|
|
29
31
|
};
|
|
30
32
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Where a record's lifecycle lives, and the trail of states it has been through.
|
|
3
|
+
|
|
4
|
+
`fieldName` is the single rule for which field holds a lifecycle — every
|
|
5
|
+
consumer resolves it here rather than restating it. `Trail` is the one field a
|
|
6
|
+
view declares to have each state it reaches recorded with the instant it was
|
|
7
|
+
reached; the projection machinery fills it, not the domain.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// A wire enum: a union whose non-null members are all string constants. A
|
|
11
|
+
// tagged union's members are objects, so the same test excludes it.
|
|
12
|
+
let isEnumSchema = (schema: S.t<unknown>): bool =>
|
|
13
|
+
switch schema {
|
|
14
|
+
| AnyOf({anyOf}) =>
|
|
15
|
+
let members = anyOf->Array.filter(v =>
|
|
16
|
+
switch v {
|
|
17
|
+
| Null(_) | Undefined(_) => false
|
|
18
|
+
| _ => true
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
members->Array.length > 0 &&
|
|
22
|
+
members->Array.every(v =>
|
|
23
|
+
switch v {
|
|
24
|
+
| String({const: ?Some(_)}) => true
|
|
25
|
+
| _ => false
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
| _ => false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A semantic or a DCB tag makes the field an instant, an id or a reference
|
|
32
|
+
// before it is ever an enum — the order `SchemaType.fromSury` resolves in.
|
|
33
|
+
let isLifecycleShape = (schema: S.t<unknown>): bool =>
|
|
34
|
+
Semantic.get(schema)->Option.isNone && !DcbTag.isTagged(schema) && isEnumSchema(schema)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
The field holding a record's lifecycle: `@lifecycle`, else an enum field
|
|
38
|
+
literally named `lifecycle`. Not keyed on `status`, a name that would guess.
|
|
39
|
+
*/
|
|
40
|
+
let fieldName = (stateSchema: S.t<unknown>): option<string> =>
|
|
41
|
+
switch StateAnnotations.getSpec(stateSchema) {
|
|
42
|
+
| Some({lifecycle: Some(_) as annotated}) => annotated
|
|
43
|
+
| _ =>
|
|
44
|
+
switch stateSchema {
|
|
45
|
+
| Object({properties}) =>
|
|
46
|
+
properties
|
|
47
|
+
->Dict.get("lifecycle")
|
|
48
|
+
->Option.flatMap(schema => isLifecycleShape(schema) ? Some("lifecycle") : None)
|
|
49
|
+
| _ => None
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
The ordered record of the states a row has been through.
|
|
55
|
+
|
|
56
|
+
Ordered rather than keyed by state because a lifecycle revisits states — a
|
|
57
|
+
reopened order is `Placed` twice, and a map has to choose which visit to keep.
|
|
58
|
+
A state the row never reached has no entry; there is no value to invent for it.
|
|
59
|
+
*/
|
|
60
|
+
module Trail = {
|
|
61
|
+
/** One state the row entered, and the instant it did. */
|
|
62
|
+
@schema
|
|
63
|
+
type entry<'state> = {
|
|
64
|
+
state: 'state,
|
|
65
|
+
at: DateTime.t,
|
|
66
|
+
/** First entry after a span the cap dropped. Absent while trails are kept
|
|
67
|
+
whole; declared now so capping later is not a contract change. */
|
|
68
|
+
afterGap?: bool,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type t<'state> = array<entry<'state>>
|
|
72
|
+
|
|
73
|
+
/** The schema for a trail field. Resolved by sury-ppx from the declared type
|
|
74
|
+
`Reventless.Lifecycle.Trail.t<lifecycle>`, so a view writes no annotation.
|
|
75
|
+
|
|
76
|
+
Marked with the shared semantic rather than a metadata id of its own: that
|
|
77
|
+
marker is the one the JSON Schema walk emits, so a private id would leave
|
|
78
|
+
the trail indistinguishable on the wire from any other array of objects —
|
|
79
|
+
and a consumer reduced to matching a field called `trail` by name. */
|
|
80
|
+
let schema = (stateSchema: S.t<'state>): S.t<t<'state>> =>
|
|
81
|
+
S.array(entrySchema(stateSchema))->Semantic.mark(~id=Semantic.Id.lifecycleTrail)
|
|
82
|
+
|
|
83
|
+
let isTrail = (schema: S.t<unknown>): bool =>
|
|
84
|
+
Semantic.has(schema, ~id=Semantic.Id.lifecycleTrail)
|
|
85
|
+
|
|
86
|
+
/** The trail field declared on a state record, if it declares one. */
|
|
87
|
+
let fieldName = (stateSchema: S.t<unknown>): option<string> =>
|
|
88
|
+
switch stateSchema {
|
|
89
|
+
| Object({properties}) =>
|
|
90
|
+
properties
|
|
91
|
+
->Dict.toArray
|
|
92
|
+
->Array.find(((_, schema)) => isTrail(schema))
|
|
93
|
+
->Option.map(((name, _)) => name)
|
|
94
|
+
| _ => None
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Append `{state, at}` to the trail a state's JSON holds at `field`. */
|
|
98
|
+
let record = (stateDict: dict<JSON.t>, ~field: string, ~state: JSON.t, ~at: string): unit => {
|
|
99
|
+
let entries = switch stateDict->Dict.get(field) {
|
|
100
|
+
| Some(Array(existing)) => existing
|
|
101
|
+
| _ => []
|
|
102
|
+
}
|
|
103
|
+
let entry = Dict.fromArray([("state", state), ("at", JSON.Encode.string(at))])
|
|
104
|
+
stateDict->Dict.set(field, entries->Array.concat([entry->JSON.Encode.object])->JSON.Encode.array)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Sury from "sury";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as DcbTag$Reventless from "../components/DcbTag.res.mjs";
|
|
6
|
+
import * as DateTime$Reventless from "../semantic/DateTime.res.mjs";
|
|
7
|
+
import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
|
|
8
|
+
import * as StateAnnotations$Reventless from "../components/StateAnnotations.res.mjs";
|
|
9
|
+
|
|
10
|
+
function isEnumSchema(schema) {
|
|
11
|
+
if (schema.type !== "anyOf") {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
let members = schema.anyOf.filter(v => {
|
|
15
|
+
switch (v.type) {
|
|
16
|
+
case "null" :
|
|
17
|
+
case "undefined" :
|
|
18
|
+
return false;
|
|
19
|
+
default:
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
if (members.length !== 0) {
|
|
24
|
+
return members.every(v => {
|
|
25
|
+
if (v.type === "string") {
|
|
26
|
+
return v.const !== undefined;
|
|
27
|
+
} else {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
} else {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isLifecycleShape(schema) {
|
|
37
|
+
if (Stdlib_Option.isNone(Semantic$Reventless.get(schema)) && !DcbTag$Reventless.isTagged(schema)) {
|
|
38
|
+
return isEnumSchema(schema);
|
|
39
|
+
} else {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fieldName(stateSchema) {
|
|
45
|
+
let match = StateAnnotations$Reventless.getSpec(stateSchema);
|
|
46
|
+
if (match !== undefined) {
|
|
47
|
+
let annotated = match.lifecycle;
|
|
48
|
+
if (annotated !== undefined) {
|
|
49
|
+
return annotated;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (stateSchema.type === "object") {
|
|
53
|
+
return Stdlib_Option.flatMap(stateSchema.properties["lifecycle"], schema => {
|
|
54
|
+
if (isLifecycleShape(schema)) {
|
|
55
|
+
return "lifecycle";
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function entrySchema(_stateSchema) {
|
|
62
|
+
return Sury.$schema(s => ({
|
|
63
|
+
state: s.m(_stateSchema),
|
|
64
|
+
at: s.m(DateTime$Reventless.schema),
|
|
65
|
+
afterGap: s.m(Sury.$option(Sury.bool))
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function schema(stateSchema) {
|
|
70
|
+
return Semantic$Reventless.mark(Sury.array(entrySchema(stateSchema)), Semantic$Reventless.Id.lifecycleTrail, undefined);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isTrail(schema) {
|
|
74
|
+
return Semantic$Reventless.has(schema, Semantic$Reventless.Id.lifecycleTrail);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fieldName$1(stateSchema) {
|
|
78
|
+
if (stateSchema.type === "object") {
|
|
79
|
+
return Stdlib_Option.map(Object.entries(stateSchema.properties).find(param => Semantic$Reventless.has(param[1], Semantic$Reventless.Id.lifecycleTrail)), param => param[0]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function record(stateDict, field, state, at) {
|
|
84
|
+
let match = stateDict[field];
|
|
85
|
+
let entries = match !== undefined ? (
|
|
86
|
+
Array.isArray(match) ? match : []
|
|
87
|
+
) : [];
|
|
88
|
+
let entry = Object.fromEntries([
|
|
89
|
+
[
|
|
90
|
+
"state",
|
|
91
|
+
state
|
|
92
|
+
],
|
|
93
|
+
[
|
|
94
|
+
"at",
|
|
95
|
+
at
|
|
96
|
+
]
|
|
97
|
+
]);
|
|
98
|
+
stateDict[field] = entries.concat([entry]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let Trail = {
|
|
102
|
+
entrySchema: entrySchema,
|
|
103
|
+
schema: schema,
|
|
104
|
+
isTrail: isTrail,
|
|
105
|
+
fieldName: fieldName$1,
|
|
106
|
+
record: record
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export {
|
|
110
|
+
isEnumSchema,
|
|
111
|
+
isLifecycleShape,
|
|
112
|
+
fieldName,
|
|
113
|
+
Trail,
|
|
114
|
+
}
|
|
115
|
+
/* sury Not a pure module */
|
package/src/types/DateTime.res
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
Marks a `string` state field as an ISO-8601 date-time.
|
|
3
|
-
|
|
4
|
-
Sury has no string-typed datetime format (`S.datetime` transforms to `Js.Date.t`,
|
|
5
|
-
changing the field's runtime type), so this mirrors the `DcbTag.string`
|
|
6
|
-
precedent: a `S.t<string>` carrying sury metadata that downstream schema walkers
|
|
7
|
-
detect. `SchemaType`/`SuryToJsonSchema` surface it as `format: "date-time"` on
|
|
8
|
-
the field's JSON Schema, which the AutoUI date heuristics key off (CalendarView,
|
|
9
|
-
TimelineView, date-axis charts).
|
|
10
|
-
|
|
11
|
-
Use on a producer/storage timestamp a projection writes into its state — most
|
|
12
|
-
commonly a `meta.time`-derived field such as `placedAt` / `shippedAt`:
|
|
13
|
-
|
|
14
|
-
@example
|
|
15
|
-
```rescript
|
|
16
|
-
@schema
|
|
17
|
-
type state = {
|
|
18
|
-
orderId: string,
|
|
19
|
-
placedAt: @s.matches(Reventless.DateTime.string) string,
|
|
20
|
-
}
|
|
21
|
-
```
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/** A sury string schema annotated as an ISO-8601 date-time field.
|
|
25
|
-
Use with `@s.matches(Reventless.DateTime.string)`. */
|
|
26
|
-
let string: S.t<string> = S.string->Semantic.mark(~id=Semantic.Id.dateTime)
|
|
27
|
-
|
|
28
|
-
/** Whether a field schema carries the date-time marker. */
|
|
29
|
-
let isDateTime = (fieldSchema: S.t<unknown>) => fieldSchema->Semantic.has(~id=Semantic.Id.dateTime)
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
-
|
|
3
|
-
import * as Sury from "sury";
|
|
4
|
-
import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
|
|
5
|
-
|
|
6
|
-
let string = Semantic$Reventless.mark(Sury.string, Semantic$Reventless.Id.dateTime, undefined);
|
|
7
|
-
|
|
8
|
-
function isDateTime(fieldSchema) {
|
|
9
|
-
return Semantic$Reventless.has(fieldSchema, Semantic$Reventless.Id.dateTime);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export {
|
|
13
|
-
string,
|
|
14
|
-
isDateTime,
|
|
15
|
-
}
|
|
16
|
-
/* string Not a pure module */
|