@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.131",
3
+ "version": "3.0.0-alpha.132",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -201,6 +201,7 @@ enum Platform_PluginKind {
201
201
 
202
202
  type Platform_PluginRef {
203
203
  id: ID!
204
+ image: String
204
205
  label: String!
205
206
  retired: Boolean!
206
207
  retiredState: String
@@ -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 => "StateChangeSlice"
43
- | StateViewSlice => "StateViewSlice"
44
- | StateViewSliceStream => "StateViewSliceStream"
45
- | AutomationSlice => "AutomationSlice"
46
- | InboundTranslationSlice => "InboundTranslationSlice"
47
- | OutboundTranslationSlice => "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
- | "StateViewSliceStream" | "StateViewSliceStreams" => Some(StateViewSliceStream)
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 "StateChangeSlice";
23
+ return "StateChange";
24
24
  case "StateViewSlice" :
25
- return "StateViewSlice";
25
+ return "StateView";
26
26
  case "StateViewSliceStream" :
27
- return "StateViewSliceStream";
27
+ return "StateViewStream";
28
28
  case "AutomationSlice" :
29
- return "AutomationSlice";
29
+ return "Automation";
30
30
  case "InboundTranslationSlice" :
31
- return "InboundTranslationSlice";
31
+ return "InboundTranslation";
32
32
  case "OutboundTranslationSlice" :
33
- return "OutboundTranslationSlice";
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
- /** Sidecar paths that describe a queryable, and those that describe a writable.
901
- Told apart by the folder the source sits in, which is the same vocabulary the
902
- plugin generator and the PPX already read a component's kind from. */
903
- let isViewPath = (path: string) =>
904
- ["/ReadModel/", "/ReadModelStream/", "/StateViewSlice/", "/StateViewSliceStream/"]->Array.some(
905
- seg => path->String.includes(seg),
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
- ["/Aggregate/", "/StateChangeSlice/"]->Array.some(seg => path->String.includes(seg))
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
- return [
617
- "/ReadModel/",
618
- "/ReadModelStream/",
619
- "/StateViewSlice/",
620
- "/StateViewSliceStream/"
621
- ].some(seg => path.includes(seg));
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
- return [
626
- "/Aggregate/",
627
- "/StateChangeSlice/"
628
- ].some(seg => path.includes(seg));
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
- Parsing is `Date.fromString` on each instant. A range whose strings do not parse
45
- is a decode-time problem the `DateTime` marker does not currently catch either,
46
- so there is no second validation layer here — a reversed *parseable* range is
47
- what `validate` catches, and an unparseable one is out of both their scope.
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: @s.matches(DateTime.string) string,
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_: @s.matches(DateTime.string) string,
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 — `NaN` if it does not parse. The
89
- one place a range's strings become numbers, so end-exclusivity and the
90
- ordering rule are all expressed against a single parse. */
91
- let millis = (instant: string): float => instant->Date.fromString->Date.getTime
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 "../types/DateTime.res.mjs";
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.string),
9
- end: s.m(DateTime$Reventless.string)
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 (new Date(range.start).getTime() > new Date(range.end).getTime()) {
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((new Date(range.end).getTime() - new Date(range.start).getTime()) / 1000.0) | 0;
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 = new Date(instant).getTime();
45
- if (t >= new Date(range.start).getTime()) {
46
- return t < new Date(range.end).getTime();
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 (new Date(a.start).getTime() < new Date(b.end).getTime()) {
54
- return new Date(b.start).getTime() < new Date(a.end).getTime();
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 */
@@ -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 */