@reventlessdev/reventless-spec 3.0.0-alpha.126 → 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.
@@ -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 */
@@ -0,0 +1,119 @@
1
+ /**
2
+ A reference to a member of a collection the row already holds.
3
+
4
+ The distinction this type exists to make is **input versus selection**. A field
5
+ typed {!UploadableImage} says "give me a new file", and a UI reading it binds an
6
+ upload endpoint. A field that must name a picture the row *already has* — remove
7
+ this one, make that one primary, caption the third — says something else
8
+ entirely, and before this type there was no way to say it: every such field was
9
+ typed as the uploadable it selected among, so every one of them offered an
10
+ uploader. On a remove command that is not merely odd, it is the opposite of what
11
+ the command does.
12
+
13
+ ## What it declares, and what it does not
14
+
15
+ It declares where the candidates are: a field of the row, by name. It declares
16
+ no store — nothing is provisioned, no upload endpoint is bound — for the same
17
+ reason {!ImageRef} declares none, and with the same consequence: a consumer
18
+ asking `StorageRef.getFieldStore` gets `None`.
19
+
20
+ The *semantic* is content-agnostic: one id serves images and documents, and what
21
+ a member is comes from `~content`. That argument is optional and, where a caller
22
+ gives it, redundant with the collection's own element type — deliberately, and
23
+ for the reason {!UploadableImage} exists at all rather than a storage ref beside
24
+ a content annotation. A consumer rendering one cell holds that field's schema and
25
+ nothing else; it cannot reach a sibling's element type, so a declaration that
26
+ made it look would be a declaration it could not read. Omit it and such a
27
+ consumer falls back to its own rules, which is the right answer for a member type
28
+ this vocabulary has no word for.
29
+
30
+ ## Why not `Reference.to_`
31
+
32
+ A reference resolves its candidates by querying the target view's table. These
33
+ candidates are on the row already in hand, so there is no query: same shape of
34
+ answer, different resolution path, and pointing a query at it would scan a table
35
+ to re-read a value the caller is holding. `Reference.toWithoutDcbTag` is the
36
+ precedent that a payload can be reused without dragging DCB tagging along; this
37
+ is the converse — a payload that resolves locally.
38
+
39
+ ## Where it goes
40
+
41
+ **On a command field**, and there it means *pick one of these*:
42
+
43
+ ```rescript
44
+ @schema type command =
45
+ | RemoveProductImage({
46
+ productId: @s.matches(DcbTag.string) string,
47
+ productImage: @s.matches(Reventless.MemberRef.of_(~view="Products", ~field="productImages")) string,
48
+ })
49
+ ```
50
+
51
+ `~view` names the collection's view. Omit it on a declaration made *on* that
52
+ view's own state, where the answer is "this record" — the wrapper walk below
53
+ supports that position, and `getFieldTarget` reads it. Nothing in the framework
54
+ declares one there today: a view that carried a scalar *and* the set it was
55
+ drawn from needed a marker saying the two were one thing, and a view whose
56
+ primary is simply the first member has no second field to reconcile.
57
+ */
58
+
59
+ /** Transparent `string`, as every ref-shaped semantic here is: the marker
60
+ refines an existing field rather than replacing it, so nothing stored
61
+ changes when a field adopts it. */
62
+ type t = string
63
+
64
+ external unsafe: string => t = "%identity"
65
+ external toString: t => string = "%identity"
66
+
67
+ /** Which collection a field names a member of. */
68
+ type target = Semantic.memberTarget
69
+
70
+ /**
71
+ The sury schema for a field that selects a member of `field`.
72
+
73
+ `~view` names the collection's view; omit it on a declaration made *on* that
74
+ view's own state, where the answer is "this record". `~plugin` qualifies a view
75
+ another plugin owns. `~content` names what the members are — pass
76
+ `Semantic.Id.imageRef` for a set of pictures — so a reader holding this field
77
+ alone still knows what it is drawing.
78
+
79
+ No grammar is checked. The value is whatever the collection's own element type
80
+ admits — a storage ref today, and checking the ref grammar a second time here
81
+ would put {!StorageRef}'s rules in a second place to drift from. What makes a
82
+ selection valid is that the row holds it, and only the decider knows that.
83
+ */
84
+ let of_ = (
85
+ ~plugin: option<string>=?,
86
+ ~view: option<string>=?,
87
+ ~content: option<string>=?,
88
+ ~field: string,
89
+ ): S.t<t> =>
90
+ S.string->Semantic.mark(
91
+ ~id=Semantic.Id.memberRef,
92
+ ~payload=MemberOf({view, field, plugin, content}),
93
+ )
94
+
95
+ /** The collection a schema names, if it carries the marker. */
96
+ let getTarget = (schema: S.t<'a>): option<target> =>
97
+ switch Semantic.get(schema) {
98
+ | Some({payload: MemberOf(target)}) => Some(target)
99
+ | _ => None
100
+ }
101
+
102
+ /**
103
+ The collection a *field* names, looking through the wrappers around its value.
104
+
105
+ The distinction `Reference.getFieldTarget` draws applies here for the same
106
+ reason: `of_` returns an element schema, so on an `array<string>` of selections
107
+ the marker sits on the string inside the array and the field's own schema
108
+ carries nothing. Only the optional wrapper and the array element are followed —
109
+ a marker on a nested record's field belongs to that field.
110
+ */
111
+ let rec getFieldTarget = (schema: S.t<unknown>): option<target> =>
112
+ switch getTarget(schema) {
113
+ | Some(_) as found => found
114
+ | None =>
115
+ switch schema->Semantic.unwrapOptional->Option.getOr(schema) {
116
+ | Array({additionalItems: Schema(item)}) => getFieldTarget(item)
117
+ | _ => None
118
+ }
119
+ }
@@ -0,0 +1,57 @@
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 Semantic$Reventless from "./Semantic.res.mjs";
6
+
7
+ function of_(plugin, view, content, field) {
8
+ return Semantic$Reventless.mark(Sury.string, Semantic$Reventless.Id.memberRef, {
9
+ TAG: "MemberOf",
10
+ _0: {
11
+ view: view,
12
+ field: field,
13
+ plugin: plugin,
14
+ content: content
15
+ }
16
+ });
17
+ }
18
+
19
+ function getTarget(schema) {
20
+ let match = Semantic$Reventless.get(schema);
21
+ if (match === undefined) {
22
+ return;
23
+ }
24
+ let target = match.payload;
25
+ if (typeof target !== "object" || target.TAG !== "MemberOf") {
26
+ return;
27
+ } else {
28
+ return target._0;
29
+ }
30
+ }
31
+
32
+ function getFieldTarget(_schema) {
33
+ while (true) {
34
+ let schema = _schema;
35
+ let found = getTarget(schema);
36
+ if (found !== undefined) {
37
+ return found;
38
+ }
39
+ let match = Stdlib_Option.getOr(Semantic$Reventless.unwrapOptional(schema), schema);
40
+ if (match.type !== "array") {
41
+ return;
42
+ }
43
+ let item = match.additionalItems;
44
+ if (item === "strip" || item === "strict") {
45
+ return;
46
+ }
47
+ _schema = item;
48
+ continue;
49
+ };
50
+ }
51
+
52
+ export {
53
+ of_,
54
+ getTarget,
55
+ getFieldTarget,
56
+ }
57
+ /* sury Not a pure module */
@@ -45,6 +45,30 @@ let channelToString = (channel: channel): string =>
45
45
  | Push => "Push"
46
46
  }
47
47
 
48
+ /**
49
+ The `From:` header a deployment's email sender presents as: the bare address, or
50
+ a display name in front of it.
51
+
52
+ Provider-neutral for the reason everything else here is — the header is the
53
+ RFC's, not a transport's, and two backends formatting it apart would present the
54
+ same deployment under two names. It is applied where the sender is *provisioned*
55
+ rather than at the send, so a transport receives one string and never has to know
56
+ whether a name was configured.
57
+
58
+ Quoted and escaped unconditionally rather than only when the name looks like it
59
+ needs it. The unquoted form excludes characters an ordinary shop name carries — a
60
+ comma above all, which would otherwise split the header into two addresses — and a
61
+ rule applied only where it looks necessary is a rule that gets the exceptions
62
+ wrong.
63
+ */
64
+ let fromHeader = (~displayName: option<string>, ~address: string): string =>
65
+ switch displayName {
66
+ | None => address
67
+ | Some(name) =>
68
+ let escaped = name->String.replaceAll("\\", "\\\\")->String.replaceAll("\"", "\\\"")
69
+ `"${escaped}" <${address}>`
70
+ }
71
+
48
72
  /**
49
73
  What to say.
50
74
 
@@ -23,6 +23,14 @@ function channelToString(channel) {
23
23
  }
24
24
  }
25
25
 
26
+ function fromHeader(displayName, address) {
27
+ if (displayName === undefined) {
28
+ return address;
29
+ }
30
+ let escaped = displayName.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
31
+ return `"` + escaped + `" <` + address + `>`;
32
+ }
33
+
26
34
  function retriable(failure) {
27
35
  switch (failure.TAG) {
28
36
  case "Unavailable" :
@@ -50,6 +58,7 @@ function supports(provider, recipient) {
50
58
  export {
51
59
  channelOf,
52
60
  channelToString,
61
+ fromHeader,
53
62
  retriable,
54
63
  failureReason,
55
64
  supports,
@@ -110,7 +110,7 @@ function getStore(schema) {
110
110
  return;
111
111
  }
112
112
  let target = match.payload;
113
- if (typeof target !== "object" || target.TAG === "ReferenceTo" || match.id !== Semantic$Reventless.Id.offload) {
113
+ if (typeof target !== "object" || target.TAG !== "StoredIn" || match.id !== Semantic$Reventless.Id.offload) {
114
114
  return;
115
115
  } else {
116
116
  return target._0;
@@ -123,7 +123,7 @@ function getThreshold(schema) {
123
123
  return;
124
124
  }
125
125
  let match$1 = match.payload;
126
- if (typeof match$1 !== "object" || match$1.TAG === "ReferenceTo" || match.id !== Semantic$Reventless.Id.offload) {
126
+ if (typeof match$1 !== "object" || match$1.TAG !== "StoredIn" || match.id !== Semantic$Reventless.Id.offload) {
127
127
  return;
128
128
  } else {
129
129
  return match$1._0.threshold;
@@ -1,24 +1,9 @@
1
1
  /**
2
- Marks a `float` field as a percentage, expressed **0–100**.
2
+ Marks a `float` field as a percentage, expressed **0–100**, not 0–1.
3
3
 
4
- ## Why 0–100 and not 0–1
5
-
6
- Both conventions are defensible in the abstract, so the tie is broken by the
7
- consumer that already exists: the dashboard gauges a field with this semantic
8
- against fixed bounds of 0 and 100, and formats `42.0` as `"42%"`. Under a 0–1
9
- convention every value would render as a rounding error near zero — a gauge
10
- pinned at empty and a label reading `"0.42%"`.
11
-
12
- That failure is quiet, and it is quiet in the worst way: the numbers are
13
- *present* and *wrong*, and the layer at fault is not the one showing the symptom.
14
- Agreeing with the renderer costs nothing; disagreeing costs an afternoon.
15
-
16
- A fraction is still perfectly good arithmetic — it just multiplies by 100 before
17
- it becomes this type.
18
-
19
- ## The grammar
20
-
21
- A finite number in `[0, 100]`. Fractions are allowed: `99.95` is a percentage.
4
+ The scale is what every consumer gauges and formats against, so a fraction
5
+ multiplies by 100 before it becomes this type. A finite number in `[0, 100]`;
6
+ fractions are allowed — `99.95` is a percentage.
22
7
 
23
8
  @example
24
9
  ```rescript
@@ -30,7 +15,7 @@ A finite number in `[0, 100]`. Fractions are allowed: `99.95` is a percentage.
30
15
  */
31
16
 
32
17
  /** The percentage's representation. Transparent `float`: the marker refines an
33
- existing numeric field rather than replacing it, so nothing stored changes. */
18
+ existing numeric field, so nothing stored changes. */
34
19
  type t = float
35
20
 
36
21
  external unsafe: float => t = "%identity"
@@ -51,3 +36,7 @@ let fromFloat = (raw: float): result<t, string> =>
51
36
 
52
37
  /** The sury schema for a percentage field. Use with `@s.matches(Reventless.Percent.schema)`. */
53
38
  let schema: S.t<t> = S.float->Semantic.refined(~id=Semantic.Id.percent, ~check=fromFloat)
39
+
40
+ /** The percentage as text — `"42%"`, `"99.95%"`. Locale-independent, the way
41
+ `Money.format` is. */
42
+ let format = (p: t): string => Float.toString(p) ++ "%"
@@ -26,8 +26,13 @@ function fromFloat(raw) {
26
26
 
27
27
  let schema = Semantic$Reventless.refined(Sury.float, Semantic$Reventless.Id.percent, fromFloat);
28
28
 
29
+ function format(p) {
30
+ return p.toString() + "%";
31
+ }
32
+
29
33
  export {
30
34
  fromFloat,
31
35
  schema,
36
+ format,
32
37
  }
33
38
  /* schema Not a pure module */
@@ -18,11 +18,39 @@ type referenceTarget = {entity: string, plugin: option<string>}
18
18
  when it defers to the platform default. */
19
19
  type storeTarget = {plugin: option<string>, store: string, threshold: option<int>}
20
20
 
21
+ /**
22
+ Which collection a value names a member of.
23
+
24
+ `field` is the collection's field name on the row — the only thing a consumer
25
+ needs, because the candidates are already loaded. `view` names the view that
26
+ collection belongs to and is absent where the answer is "the row this field is
27
+ on", which is every declaration made on a view's own state. `plugin` is absent
28
+ for the declaring plugin's own view, as everywhere else here.
29
+
30
+ Unlike `referenceTarget` this is deliberately not resolvable by query: the
31
+ members are on the row, so a consumer that went looking for a table has
32
+ misread it.
33
+
34
+ `content` names what a member *is* — an `imageRef`/`fileRef` id — and is the one
35
+ part of this that is not about the collection. It rides here for the reason
36
+ `uploadableImage` exists at all rather than `storageRef` beside a content
37
+ annotation: the collection's own element type already says it, but a consumer
38
+ holding one field's schema cannot reach a sibling's, and one that renders a cell
39
+ holds exactly that. Absent leaves the reader to its own rules, which is what a
40
+ host attaching something the vocabulary has no word for should get. */
41
+ type memberTarget = {
42
+ view: option<string>,
43
+ field: string,
44
+ plugin: option<string>,
45
+ content: option<string>,
46
+ }
47
+
21
48
  /** Per-semantic detail, for the semantics that carry any. */
22
49
  type payload =
23
50
  | Plain
24
51
  | ReferenceTo(referenceTarget)
25
52
  | StoredIn(storeTarget)
53
+ | MemberOf(memberTarget)
26
54
 
27
55
  /** A field's semantic: the vocabulary id, plus its detail. */
28
56
  type t = {id: string, payload: payload}
@@ -45,6 +73,16 @@ module Id = {
45
73
  let imageRef = "imageRef"
46
74
  let fileRef = "fileRef"
47
75
 
76
+ // An image and the text that goes with it, in one value. Declares its store
77
+ // the way `uploadableImage` does, on the record rather than on the reference
78
+ // inside it, so a field reader finds it through an array wrapper.
79
+ let captionedImage = "captionedImage"
80
+
81
+ // A selection rather than an input: the value names a member of a collection
82
+ // the row already holds. Declares no store, so nothing is provisioned and no
83
+ // upload endpoint is bound — the distinction `uploadableImage` could not make.
84
+ let memberRef = "memberRef"
85
+
48
86
  // Branded scalars: a refinement, so adopting one changes nothing stored.
49
87
  let email = "email"
50
88
  let phone = "phone"
@@ -116,6 +154,31 @@ let unwrapOptional = (schema: S.t<unknown>): option<S.t<unknown>> =>
116
154
  | _ => None
117
155
  }
118
156
 
157
+ /**
158
+ One arm of a command or event union, found by its tag.
159
+
160
+ A union with exactly one constructor is emitted as a bare object carrying the
161
+ TAG rather than a one-element `AnyOf`, so both shapes are matched.
162
+ */
163
+ let unionVariant = (schema: S.t<unknown>, ~variant: string): option<S.t<unknown>> => {
164
+ let isVariant = (properties: dict<S.t<unknown>>) =>
165
+ switch properties->Dict.get("TAG") {
166
+ | Some(String({const: ?Some(name)})) => name == variant
167
+ | _ => false
168
+ }
169
+ switch schema {
170
+ | AnyOf({anyOf}) =>
171
+ anyOf->Array.find(arm =>
172
+ switch arm {
173
+ | Object({properties}) => isVariant(properties)
174
+ | _ => false
175
+ }
176
+ )
177
+ | Object({properties}) => isVariant(properties) ? Some(schema) : None
178
+ | _ => None
179
+ }
180
+ }
181
+
119
182
  /**
120
183
  The semantic a field's schema carries, if any.
121
184