@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 +7 -0
- package/package.json +1 -1
- package/src/components/TaggedUnion.res +238 -0
- package/src/components/TaggedUnion.res.mjs +193 -0
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
|
@@ -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 */
|