@reventlessdev/reventless-spec 3.0.0-alpha.118 → 3.0.0-alpha.119

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,13 @@
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.119 (2026-08-20)
7
+
8
+ ### Features
9
+
10
+ * **api:** emit a tagged-union state field as a GraphQL union ([3a380c0](https://github.com/ReventlessDev/reventless-core/commit/3a380c0ab055b87048d90a852ec1664f6aab6b00))
11
+
12
+
6
13
  # 3.0.0-alpha.118 (2026-08-18)
7
14
 
8
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.118",
3
+ "version": "3.0.0-alpha.119",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -0,0 +1,238 @@
1
+ /**
2
+ A variant used as a **field** of a queryable's state — one fact with several
3
+ shapes, rather than several fields that have to be kept in step by hand.
4
+
5
+ ```rescript
6
+ @schema
7
+ type geolocation =
8
+ | Pending({requestedFor: string})
9
+ | Located({point: Reventless.GeoPoint.t})
10
+ | Unresolvable({reason: string})
11
+ ```
12
+
13
+ The value is stored the way sury encodes it — `{"TAG":"Located","point":{…}}` —
14
+ and reaches GraphQL as a union of one object type per arm. Both halves need the
15
+ *same* name for the union, and neither can derive it from the other: the SDL
16
+ emitter walks a schema it reaches through a field path, and the write path has
17
+ only the schema in hand. So the name is carried **on the schema**, set once at
18
+ the declaration by `named` — the ppx does it for a variant a state record uses,
19
+ and a framework type carrying a union does it beside its `Semantic.mark`.
20
+
21
+ A union with no name is not emitted as one. It falls through to the IR's
22
+ `Unknown`, which is a `String` in the SDL — the behaviour that predates this
23
+ module — except that it is now reported at deploy time instead of silently.
24
+ */
25
+ let unionNameId: S.Metadata.Id.t<string> = S.Metadata.Id.make(
26
+ ~namespace="reventless",
27
+ ~name="taggedUnionName",
28
+ )
29
+
30
+ /**
31
+ Names a union, so that the type emitted for it and the `__typename` stamped into
32
+ every stored value of it agree.
33
+
34
+ Written at the declaration, never at the field: two fields holding the same union
35
+ hold the same type, and naming it per field is what produces one GraphQL type per
36
+ field path — the mistake `semanticCompositeNames` exists to undo for `Money`.
37
+ */
38
+ let named = (~name: string, schema: S.t<'a>): S.t<'a> =>
39
+ schema->S.Metadata.set(~id=unionNameId, name)
40
+
41
+ /**
42
+ The name a union field's schema carries.
43
+
44
+ Read straight off the field's schema, wrapper included: sury's `option` keeps the
45
+ metadata of what it wraps, which matters because it does *not* keep the union as
46
+ a nested schema — `option<t>` flattens the arms and the `undefined` into one
47
+ `anyOf`, leaving nothing inside to consult. `TaggedUnionTest` pins that, since a
48
+ sury release that stopped preserving it would turn every optional union field
49
+ into a `String` with no compile error anywhere.
50
+ */
51
+ let getName = (schema: S.t<unknown>): option<string> => S.Metadata.get(schema, ~id=unionNameId)
52
+
53
+ /**
54
+ The GraphQL type emitted for one arm: the union's name with the arm's own
55
+ appended (`Geolocation` + `Located` = `GeolocationLocated`).
56
+
57
+ One derivation, used by the SDL emitter and by the write-time stamp. A second
58
+ spelling of this rule anywhere is a `__typename` that resolves to no member,
59
+ which GraphQL reports as a null — and a null in a non-nullable field takes its
60
+ parent with it.
61
+ */
62
+ let memberTypeName = (~union: string, ~arm: string): string => union ++ arm
63
+
64
+ /** The key a stored union value carries its member type under. */
65
+ let typenameKey = "__typename"
66
+
67
+ /** One arm: the constructor name sury writes into `TAG`, and the arm's schema. */
68
+ type arm = {tag: string, schema: S.t<unknown>}
69
+
70
+ // A payload sury named rather than the author: `| Located(GeoPoint.t)` encodes to
71
+ // `{"TAG":"Located","_0":{…}}`, and `_0` would be published as an SDL field name
72
+ // and as a stored key. The ppx refuses the shape at its declaration; this refuses
73
+ // it again for a union declared where the ppx cannot see it, by declining to
74
+ // classify the union at all.
75
+ let isPositionalName = (name: string): bool =>
76
+ name->String.startsWith("_") &&
77
+ name->String.length > 1 &&
78
+ name
79
+ ->String.slice(~start=1, ~end=name->String.length)
80
+ ->String.split("")
81
+ ->Array.every(c => c >= "0" && c <= "9")
82
+
83
+ /**
84
+ The arms of a schema that is a union of tagged objects, or `None`.
85
+
86
+ Refuses three shapes, all for reasons that are GraphQL's rather than sury's, and
87
+ all of which encode and decode perfectly well:
88
+
89
+ - a payload-less arm (`| Pending`), which sury writes as the bare string
90
+ `"Pending"` — a union member must be an object type;
91
+ - an arm with no field of its own (`| Pending({})`), which would imply a member
92
+ type with zero fields;
93
+ - a positional payload, whose field name is the compiler's `_0`.
94
+
95
+ Declining leaves the field an `Unknown`, which is reported where it is emitted.
96
+ */
97
+ let armsOf = (schema: S.t<unknown>): option<array<arm>> =>
98
+ switch schema {
99
+ | AnyOf({anyOf}) =>
100
+ let members = anyOf->Array.filter(v =>
101
+ switch v {
102
+ | Null(_) | Undefined(_) => false
103
+ | _ => true
104
+ }
105
+ )
106
+ if members->Array.length < 2 {
107
+ None
108
+ } else {
109
+ let arms = members->Array.filterMap(member =>
110
+ switch member {
111
+ | Object({properties}) =>
112
+ switch properties->Dict.get("TAG") {
113
+ | Some(String({const: ?Some(tag)})) =>
114
+ let fields = properties->Dict.toArray->Array.filter(((name, _)) => name !== "TAG")
115
+ if (
116
+ fields->Array.length == 0 ||
117
+ fields->Array.some(((name, _)) => isPositionalName(name))
118
+ ) {
119
+ None
120
+ } else {
121
+ Some({tag, schema: member})
122
+ }
123
+ | _ => None
124
+ }
125
+ | _ => None
126
+ }
127
+ )
128
+ arms->Array.length == members->Array.length ? Some(arms) : None
129
+ }
130
+ | _ => None
131
+ }
132
+
133
+ /** A named union of tagged objects: its name and its arms, or `None`. */
134
+ let classify = (schema: S.t<unknown>): option<(string, array<arm>)> =>
135
+ switch (getName(schema), armsOf(schema)) {
136
+ | (Some(name), Some(arms)) => Some((name, arms))
137
+ | _ => None
138
+ }
139
+
140
+ /**
141
+ Whether a schema is a union of tagged objects that carries no name — the one case
142
+ worth telling a deploy about, since it is a union the author meant and the SDL
143
+ cannot emit.
144
+ */
145
+ let isUnnamedUnion = (schema: S.t<unknown>): bool =>
146
+ getName(schema)->Option.isNone && armsOf(schema)->Option.isSome
147
+
148
+ /**
149
+ Stamps `__typename` into every union value inside an encoded row, in place.
150
+
151
+ Written once at save rather than by each read door: both AppSync and graphql-js
152
+ resolve a union member from `__typename` on the value they are handed, and the
153
+ AppSync resolvers hand back the stored item unchanged. The doors that would each
154
+ have to stamp number fourteen across three backends, and the live change channel
155
+ — which carries the row as raw JSON, past the typed field entirely — is reachable
156
+ from none of them. Stamping here is one place, and it is the only one both
157
+ channels share.
158
+
159
+ Driven by the schema, so a row of a view with no union field is walked and left
160
+ byte-identical.
161
+ */
162
+ let rec stampInto = (~schema: S.t<unknown>, json: JSON.t): unit =>
163
+ switch classify(schema) {
164
+ | Some((name, arms)) =>
165
+ switch json->JSON.Decode.object {
166
+ | Some(obj) =>
167
+ switch obj->Dict.get("TAG")->Option.flatMap(JSON.Decode.string) {
168
+ | Some(tag) =>
169
+ obj->Dict.set(typenameKey, JSON.Encode.string(memberTypeName(~union=name, ~arm=tag)))
170
+ // An arm's own fields may hold unions too, so the arm is walked as the
171
+ // record it is — the union case above cannot recurse into it, since a
172
+ // union's schema says nothing about which arm this value took.
173
+ switch arms->Array.find(a => a.tag === tag) {
174
+ | Some({schema: armSchema}) => stampMembers(~schema=armSchema, json)
175
+ | None => ()
176
+ }
177
+ | None => ()
178
+ }
179
+ | None => ()
180
+ }
181
+ | None => stampMembers(~schema, json)
182
+ }
183
+
184
+ and stampMembers = (~schema: S.t<unknown>, json: JSON.t): unit =>
185
+ switch schema {
186
+ | Object({properties}) =>
187
+ switch json->JSON.Decode.object {
188
+ | Some(obj) =>
189
+ properties
190
+ ->Dict.toArray
191
+ ->Array.forEach(((name, propSchema)) =>
192
+ switch obj->Dict.get(name) {
193
+ | Some(value) => stampInto(~schema=propSchema, value)
194
+ | None => ()
195
+ }
196
+ )
197
+ | None => ()
198
+ }
199
+ | Array({items, additionalItems}) =>
200
+ let itemSchema = switch items->Array.get(0) {
201
+ | Some(itemSchema) => Some(itemSchema)
202
+ | None =>
203
+ switch additionalItems {
204
+ | Schema(s) => Some(s)
205
+ | _ => None
206
+ }
207
+ }
208
+ switch (itemSchema, json->JSON.Decode.array) {
209
+ | (Some(itemSchema), Some(values)) =>
210
+ values->Array.forEach(value => stampInto(~schema=itemSchema, value))
211
+ | _ => ()
212
+ }
213
+ | _ =>
214
+ // An optional field wraps its schema in a union with `undefined`; the value
215
+ // that reached us is the inner one either way.
216
+ switch Semantic.unwrapOptional(schema) {
217
+ | Some(inner) => stampInto(~schema=inner, json)
218
+ | None => ()
219
+ }
220
+ }
221
+
222
+ /**
223
+ The union fields an object schema declares, named and unnamed alike, with the
224
+ name where there is one.
225
+
226
+ The SDL emitter and the deploy-time report both need to say *which field* — a
227
+ report that names only the view leaves the author grepping.
228
+ */
229
+ let fieldsOf = (schema: S.t<unknown>): array<(string, option<string>)> =>
230
+ switch schema {
231
+ | Object({properties}) =>
232
+ properties
233
+ ->Dict.toArray
234
+ ->Array.filterMap(((name, propSchema)) =>
235
+ armsOf(propSchema)->Option.isSome ? Some((name, getName(propSchema))) : None
236
+ )
237
+ | _ => []
238
+ }
@@ -0,0 +1,193 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
8
+
9
+ let unionNameId = Sury.$Metadata_Id_make("reventless", "taggedUnionName");
10
+
11
+ function named(name, schema) {
12
+ return Sury.$Metadata_set(schema, unionNameId, name);
13
+ }
14
+
15
+ function getName(schema) {
16
+ return Sury.$Metadata_get(schema, unionNameId);
17
+ }
18
+
19
+ function memberTypeName(union, arm) {
20
+ return union + arm;
21
+ }
22
+
23
+ let typenameKey = "__typename";
24
+
25
+ function isPositionalName(name) {
26
+ if (name.startsWith("_") && name.length > 1) {
27
+ return name.slice(1, name.length).split("").every(c => {
28
+ if (c >= "0") {
29
+ return c <= "9";
30
+ } else {
31
+ return false;
32
+ }
33
+ });
34
+ } else {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function armsOf(schema) {
40
+ if (schema.type !== "anyOf") {
41
+ return;
42
+ }
43
+ let members = schema.anyOf.filter(v => {
44
+ switch (v.type) {
45
+ case "null" :
46
+ case "undefined" :
47
+ return false;
48
+ default:
49
+ return true;
50
+ }
51
+ });
52
+ if (members.length < 2) {
53
+ return;
54
+ }
55
+ let arms = Stdlib_Array.filterMap(members, member => {
56
+ if (member.type !== "object") {
57
+ return;
58
+ }
59
+ let properties = member.properties;
60
+ let match = properties["TAG"];
61
+ if (match === undefined) {
62
+ return;
63
+ }
64
+ if (match.type !== "string") {
65
+ return;
66
+ }
67
+ let tag = match.const;
68
+ if (tag === undefined) {
69
+ return;
70
+ }
71
+ let fields = Object.entries(properties).filter(param => param[0] !== "TAG");
72
+ if (fields.length === 0 || fields.some(param => isPositionalName(param[0]))) {
73
+ return;
74
+ } else {
75
+ return {
76
+ tag: tag,
77
+ schema: member
78
+ };
79
+ }
80
+ });
81
+ if (arms.length === members.length) {
82
+ return arms;
83
+ }
84
+ }
85
+
86
+ function classify(schema) {
87
+ let match = Sury.$Metadata_get(schema, unionNameId);
88
+ let match$1 = armsOf(schema);
89
+ if (match !== undefined && match$1 !== undefined) {
90
+ return [
91
+ match,
92
+ match$1
93
+ ];
94
+ }
95
+ }
96
+
97
+ function isUnnamedUnion(schema) {
98
+ if (Stdlib_Option.isNone(Sury.$Metadata_get(schema, unionNameId))) {
99
+ return Stdlib_Option.isSome(armsOf(schema));
100
+ } else {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function stampInto(schema, json) {
106
+ let match = classify(schema);
107
+ if (match === undefined) {
108
+ return stampMembers(schema, json);
109
+ }
110
+ let obj = Stdlib_JSON.Decode.object(json);
111
+ if (obj === undefined) {
112
+ return;
113
+ }
114
+ let tag = Stdlib_Option.flatMap(obj["TAG"], Stdlib_JSON.Decode.string);
115
+ if (tag === undefined) {
116
+ return;
117
+ }
118
+ obj[typenameKey] = match[0] + tag;
119
+ let match$1 = match[1].find(a => a.tag === tag);
120
+ if (match$1 !== undefined) {
121
+ return stampMembers(match$1.schema, json);
122
+ }
123
+ }
124
+
125
+ function stampMembers(schema, json) {
126
+ switch (schema.type) {
127
+ case "array" :
128
+ let additionalItems = schema.additionalItems;
129
+ let itemSchema = schema.items[0];
130
+ let itemSchema$1 = itemSchema !== undefined ? itemSchema : (
131
+ additionalItems === "strip" || additionalItems === "strict" ? undefined : additionalItems
132
+ );
133
+ let match = Stdlib_JSON.Decode.array(json);
134
+ if (itemSchema$1 !== undefined && match !== undefined) {
135
+ match.forEach(value => stampInto(itemSchema$1, value));
136
+ return;
137
+ } else {
138
+ return;
139
+ }
140
+ case "object" :
141
+ let obj = Stdlib_JSON.Decode.object(json);
142
+ if (obj !== undefined) {
143
+ Object.entries(schema.properties).forEach(param => {
144
+ let value = obj[param[0]];
145
+ if (value !== undefined) {
146
+ return stampInto(param[1], value);
147
+ }
148
+ });
149
+ return;
150
+ } else {
151
+ return;
152
+ }
153
+ default:
154
+ let inner = Semantic$Reventless.unwrapOptional(schema);
155
+ if (inner !== undefined) {
156
+ return stampInto(inner, json);
157
+ } else {
158
+ return;
159
+ }
160
+ }
161
+ }
162
+
163
+ function fieldsOf(schema) {
164
+ if (schema.type === "object") {
165
+ return Stdlib_Array.filterMap(Object.entries(schema.properties), param => {
166
+ let propSchema = param[1];
167
+ if (Stdlib_Option.isSome(armsOf(propSchema))) {
168
+ return [
169
+ param[0],
170
+ Sury.$Metadata_get(propSchema, unionNameId)
171
+ ];
172
+ }
173
+ });
174
+ } else {
175
+ return [];
176
+ }
177
+ }
178
+
179
+ export {
180
+ unionNameId,
181
+ named,
182
+ getName,
183
+ memberTypeName,
184
+ typenameKey,
185
+ isPositionalName,
186
+ armsOf,
187
+ classify,
188
+ isUnnamedUnion,
189
+ stampInto,
190
+ stampMembers,
191
+ fieldsOf,
192
+ }
193
+ /* unionNameId Not a pure module */