@reventlessdev/reventless-core 3.0.0-alpha.241 → 3.0.0-alpha.242

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.242 (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.241 (2026-08-19)
7
14
 
8
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.241",
3
+ "version": "3.0.0-alpha.242",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -31,19 +31,19 @@
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
32
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
33
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
34
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
35
34
  "@reventlessdev/rescript-node": "2.0.0-alpha.8",
35
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
37
37
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.146",
39
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.118",
41
- "@reventlessdev/reventless-interop": "3.0.0-alpha.32"
39
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.32",
40
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.147",
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.119"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-rc.1",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.71"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.72"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "12.3.0"
@@ -4,6 +4,8 @@
4
4
 
5
5
  open ReventlessInfra.Api
6
6
 
7
+ let log = Logger.fromEnv()
8
+
7
9
  // Every aggregate-derived mutation returns the CommandResult union — the
8
10
  // shape carries the outcome from CommandTopic processing (Accepted / Rejected
9
11
  // for sync slices, Pending for async). Consumers select sub-fields via inline
@@ -85,6 +87,37 @@ let rec fromSchemaType = (
85
87
  // reaches the UI through the field's JSON Schema, which is the channel that
86
88
  // can express it.
87
89
  | Semantic(_, inner) => fromSchemaType(~required, ~asInput, inner, collectedTypes, seenTypes)
90
+ // A union declares one object type per arm and a `union` naming them. Both
91
+ // names come from the schema — see `Reventless.TaggedUnion` — so the same union
92
+ // is the same type wherever it appears, which is what lets AppSync merge each
93
+ // source API's copy back into one, and what lets the write path stamp a
94
+ // `__typename` the SDL will recognise.
95
+ | TaggedUnion(name, arms) =>
96
+ if asInput {
97
+ // GraphQL has no input unions. A command that wants one takes its arms as
98
+ // separate mutations, which is what the command decomposition already
99
+ // does. Emitting the union type here would produce an SDL AppSync rejects
100
+ // outright, so the field keeps the `String` it renders as today — said out
101
+ // loud, because it is not what the declaration asks for.
102
+ log.warn(
103
+ ~comp="GraphQL_FragmentGenerator",
104
+ `union "${name}" is used in an input position; GraphQL has no input unions, so the field is emitted as String. Take the arms as separate mutations instead.`,
105
+ )
106
+ `String${bang}`
107
+ } else {
108
+ if !(seenTypes->Set.has(name)) {
109
+ seenTypes->Set.add(name)
110
+ // The member types are collected by the recursion, which emits each arm
111
+ // as the object type it is. `~required=false` only so the reference comes
112
+ // back without a `!` — a union's member list carries no nullability.
113
+ let memberNames =
114
+ arms->Array.map(((_, armType)) =>
115
+ fromSchemaType(~required=false, ~asInput, armType, collectedTypes, seenTypes)
116
+ )
117
+ collectedTypes->Array.push(`union ${name} = ${memberNames->Array.join(" | ")}`)
118
+ }
119
+ `${name}${bang}`
120
+ }
88
121
  | Unknown => `String${bang}`
89
122
  }
90
123
  }
@@ -124,12 +157,26 @@ let deriveFieldType = (
124
157
 
125
158
  // ── Object type derivation ─────────────────────────────────────────────────
126
159
 
160
+ // A union field the walk cannot classify is emitted as `String`, and that has
161
+ // been true and silent for as long as the fallthrough has existed. Saying it here
162
+ // — at the one point every generated object type is derived — costs a walk of the
163
+ // schema and turns a field that will fail at execution into a line in the deploy
164
+ // log naming the type and the field.
165
+ let reportUnclassifiedUnions = (~typeName: string, schema: S.t<unknown>): unit =>
166
+ SchemaType.unclassifiedUnions(schema)->Array.forEach(({path, reason}) =>
167
+ log.warn(
168
+ ~comp="GraphQL_FragmentGenerator",
169
+ `${typeName}.${path} is emitted as String: ${reason}`,
170
+ )
171
+ )
172
+
127
173
  let deriveObjectTypeWithNested = (
128
174
  ~typeName: string,
129
175
  ~excludeFields: array<string>=[],
130
176
  ~includeIdParam: bool=true,
131
177
  schema: S.t<unknown>,
132
- ): array<string> =>
178
+ ): array<string> => {
179
+ reportUnclassifiedUnions(~typeName, schema)
133
180
  switch SchemaType.fromSuryObject(~typeName, schema) {
134
181
  | Some(fields) =>
135
182
  // `@internal` joins the caller's exclusions rather than replacing them. The
@@ -175,6 +222,7 @@ let deriveObjectTypeWithNested = (
175
222
  Array.concat(collectedTypes, [mainTypeWithId])
176
223
  | None => []
177
224
  }
225
+ }
178
226
 
179
227
  let derivePluralWrapperType = (~pluralTypeName: string, ~singularTypeName: string): string =>
180
228
  `type ${pluralTypeName} {\n nextToken: String\n scannedCount: Int!\n items: [${singularTypeName}!]!\n}`
@@ -3,12 +3,15 @@
3
3
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
6
+ import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
6
7
  import * as Api_Naming$ReventlessCore from "./Api_Naming.res.mjs";
7
8
  import * as SchemaType$ReventlessCore from "./SchemaType.res.mjs";
8
9
  import * as SchemaWalker$ReventlessCore from "../../plugin/component/SchemaWalker.res.mjs";
9
10
  import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
10
11
  import * as GraphQL_Stitcher$ReventlessCore from "./GraphQL_Stitcher.res.mjs";
11
12
 
13
+ let log = Logger$ReventlessCore.fromEnv();
14
+
12
15
  let commandResultSdlTypes = [
13
16
  `union CommandResult = CommandAccepted | CommandRejected | CommandPending`,
14
17
  `type CommandAccepted {
@@ -90,6 +93,18 @@ function fromSchemaType(_required, _asInputOpt, _st, collectedTypes, seenTypes)
90
93
  _st = inner;
91
94
  _asInputOpt = asInput;
92
95
  continue;
96
+ case "TaggedUnion" :
97
+ let name$3 = st._0;
98
+ if (asInput) {
99
+ log.warn("GraphQL_FragmentGenerator", undefined, `union "` + name$3 + `" is used in an input position; GraphQL has no input unions, so the field is emitted as String. Take the arms as separate mutations instead.`);
100
+ return `String` + bang;
101
+ }
102
+ if (!seenTypes.has(name$3)) {
103
+ seenTypes.add(name$3);
104
+ let memberNames = st._1.map(param => fromSchemaType(false, asInput, param[1], collectedTypes, seenTypes));
105
+ collectedTypes.push(`union ` + name$3 + ` = ` + memberNames.join(" | "));
106
+ }
107
+ return name$3 + bang;
93
108
  }
94
109
  }
95
110
  };
@@ -110,9 +125,14 @@ function deriveFieldType(parentTypeName, fieldName, required, fieldSchema, colle
110
125
  return fromSchemaType(required, undefined, st, collectedTypes, seenTypes);
111
126
  }
112
127
 
128
+ function reportUnclassifiedUnions(typeName, schema) {
129
+ SchemaType$ReventlessCore.unclassifiedUnions(schema).forEach(param => log.warn("GraphQL_FragmentGenerator", undefined, typeName + `.` + param.path + ` is emitted as String: ` + param.reason));
130
+ }
131
+
113
132
  function deriveObjectTypeWithNested(typeName, excludeFieldsOpt, includeIdParamOpt, schema) {
114
133
  let excludeFields = excludeFieldsOpt !== undefined ? excludeFieldsOpt : [];
115
134
  let includeIdParam = includeIdParamOpt !== undefined ? includeIdParamOpt : true;
135
+ reportUnclassifiedUnions(typeName, schema);
116
136
  let fields = SchemaType$ReventlessCore.fromSuryObject(typeName, schema);
117
137
  if (fields === undefined) {
118
138
  return [];
@@ -580,10 +600,12 @@ function generate(mutationEntries, queryEntries) {
580
600
  }
581
601
 
582
602
  export {
603
+ log,
583
604
  commandResultSdlTypes,
584
605
  fromSchemaType,
585
606
  objectRefToGraphQL,
586
607
  deriveFieldType,
608
+ reportUnclassifiedUnions,
587
609
  deriveObjectTypeWithNested,
588
610
  derivePluralWrapperType,
589
611
  deriveConnectionTypes,
@@ -611,4 +633,4 @@ export {
611
633
  mutationArgTypes,
612
634
  generate,
613
635
  }
614
- /* Api_Naming-ReventlessCore Not a pure module */
636
+ /* log Not a pure module */
@@ -15,6 +15,14 @@ type rec schemaType =
15
15
  are *not* expressed this way: they long predate the generic marker and
16
16
  their JSON Schema `format` output is a published contract. */
17
17
  Semantic(Reventless.Semantic.t, schemaType)
18
+ | /** A variant used as a field: the union's name, and one arm per constructor
19
+ keyed by the `TAG` sury discriminates on. Each arm is the `ObjectRef` its
20
+ member type is emitted from, so the arm's own name travels with it and
21
+ every consumer that can already render an object renders an arm.
22
+
23
+ Only a *named* union reaches this case — see `Reventless.TaggedUnion`,
24
+ which owns the name and the arm rules both. */
25
+ TaggedUnion(string, array<(string, schemaType)>)
18
26
  | Unknown
19
27
 
20
28
  let isTagged = Reventless.DcbTag.isTagged
@@ -162,7 +170,35 @@ and shapeOf = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): s
162
170
  let enum = Enum(enumName, constValues)
163
171
  isOptional ? Nullable(enum) : enum
164
172
  } else {
165
- Unknown
173
+ // A union of payload-carrying arms. Its name is read off the schema
174
+ // rather than composed from the field path the way the enum above is:
175
+ // the same union is one type wherever it appears, and the write-time
176
+ // `__typename` stamp has only the schema to derive it from.
177
+ switch Reventless.TaggedUnion.classify(schema) {
178
+ | Some((unionName, arms)) =>
179
+ let armTypes = arms->Array.map(({tag, schema: armSchema}) => {
180
+ let memberName = Reventless.TaggedUnion.memberTypeName(~union=unionName, ~arm=tag)
181
+ let fields = Dict.make()
182
+ switch armSchema {
183
+ | Object({properties}) =>
184
+ properties
185
+ ->Dict.toArray
186
+ ->Array.forEach(((propName, propSchema)) =>
187
+ if propName !== "TAG" {
188
+ fields->Dict.set(
189
+ propName,
190
+ fromSury(~parentName=memberName, ~fieldName=propName, propSchema),
191
+ )
192
+ }
193
+ )
194
+ | _ => ()
195
+ }
196
+ (tag, ObjectRef(memberName, fields))
197
+ })
198
+ let union = TaggedUnion(unionName, armTypes)
199
+ isOptional ? Nullable(union) : union
200
+ | None => Unknown
201
+ }
166
202
  }
167
203
  }
168
204
  | Null(_) => Nullable(ScalarString)
@@ -171,6 +207,102 @@ and shapeOf = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): s
171
207
  }
172
208
  }
173
209
 
210
+ /**
211
+ A field whose schema is a union the walk could not classify, and why.
212
+
213
+ `Unknown` is emitted as `String`, which is a lie a client only discovers at
214
+ execution time — and for a union field it is the *silent* half of a failure
215
+ whose loud half is a null in a non-nullable position. Reporting is deliberately
216
+ narrow: a field is only named here if its schema is a union of two or more real
217
+ members, which is a shape somebody wrote on purpose. An opaque `JSON.t` field
218
+ also lands on `Unknown` and is left alone, because nothing better was ever
219
+ available for it.
220
+ */
221
+ type unclassifiedUnion = {path: string, reason: string}
222
+
223
+ // Walks the schema, not a value, so a self-referential schema has nothing to
224
+ // terminate it. Bounded rather than cycle-detected: this is a report, and a
225
+ // field nested a dozen records deep is past the depth the SDL emitter itself
226
+ // renders usefully.
227
+ let maxReportDepth = 12
228
+
229
+ let rec collectUnclassifiedUnions = (
230
+ ~path: string,
231
+ ~depth: int=0,
232
+ schema: S.t<unknown>,
233
+ out: array<unclassifiedUnion>,
234
+ ): unit =>
235
+ if depth > maxReportDepth {
236
+ ()
237
+ } else {
238
+ let under = (name: string) => path === "" ? name : path ++ "." ++ name
239
+ let recurse = (~path, schema) => collectUnclassifiedUnions(~path, ~depth=depth + 1, schema, out)
240
+ switch schema {
241
+ | Object({properties}) =>
242
+ properties
243
+ ->Dict.toArray
244
+ ->Array.forEach(((name, propSchema)) =>
245
+ if name !== "TAG" {
246
+ recurse(~path=under(name), propSchema)
247
+ }
248
+ )
249
+ | Array({items, additionalItems}) =>
250
+ let itemSchema = switch items->Array.get(0) {
251
+ | Some(s) => Some(s)
252
+ | None =>
253
+ switch additionalItems {
254
+ | Schema(s) => Some(s)
255
+ | _ => None
256
+ }
257
+ }
258
+ switch itemSchema {
259
+ | Some(s) => recurse(~path=path ++ "[]", s)
260
+ | None => ()
261
+ }
262
+ | AnyOf({anyOf}) =>
263
+ let members = anyOf->Array.filter(v =>
264
+ switch v {
265
+ | Null(_) | Undefined(_) => false
266
+ | _ => true
267
+ }
268
+ )
269
+ switch members {
270
+ | [inner] => recurse(~path, inner)
271
+ | _ =>
272
+ let allConst = members->Array.every(v =>
273
+ switch v {
274
+ | String({const: ?Some(_)}) => true
275
+ | _ => false
276
+ }
277
+ )
278
+ if allConst {
279
+ ()
280
+ } else {
281
+ switch Reventless.TaggedUnion.classify(schema) {
282
+ | Some((_, arms)) =>
283
+ arms->Array.forEach(({tag, schema: armSchema}) => recurse(~path=under(tag), armSchema))
284
+ | None =>
285
+ let reason = if Reventless.TaggedUnion.armsOf(schema)->Option.isSome {
286
+ `its arms are well-formed but the union carries no name. Declare it in a spec file, where the ppx names it, or mark the schema with \`Reventless.TaggedUnion.named\`.`
287
+ } else {
288
+ `its ${members
289
+ ->Array.length
290
+ ->Int.toString} members are neither all string literals (an enum) nor all tagged objects each declaring at least one named field of its own (a union).`
291
+ }
292
+ out->Array.push({path, reason})
293
+ }
294
+ }
295
+ }
296
+ | _ => ()
297
+ }
298
+ }
299
+
300
+ let unclassifiedUnions = (schema: S.t<unknown>): array<unclassifiedUnion> => {
301
+ let out = []
302
+ collectUnclassifiedUnions(~path="", schema, out)
303
+ out
304
+ }
305
+
174
306
  /**
175
307
  The names of an object's optional properties, read from sury rather than from
176
308
  the IR.
@@ -6,6 +6,7 @@ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/component
6
6
  import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
7
7
  import * as Semantic$Reventless from "@reventlessdev/reventless-spec/src/semantic/Semantic.res.mjs";
8
8
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
9
+ import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
9
10
 
10
11
  function isIdFieldName(name) {
11
12
  let lower = name.toLowerCase();
@@ -147,28 +148,147 @@ function shapeOf(parentName, fieldName, schema) {
147
148
  return c;
148
149
  }
149
150
  });
150
- if (constValues.length !== nonNullVariants.length || constValues.length === 0) {
151
+ if (constValues.length === nonNullVariants.length && constValues.length !== 0) {
152
+ let enumName = parentName + fieldName.charAt(0).toUpperCase() + fieldName.slice(1, fieldName.length);
153
+ let $$enum = {
154
+ TAG: "Enum",
155
+ _0: enumName,
156
+ _1: constValues
157
+ };
158
+ if (isOptional) {
159
+ return {
160
+ TAG: "Nullable",
161
+ _0: $$enum
162
+ };
163
+ } else {
164
+ return $$enum;
165
+ }
166
+ }
167
+ let match = TaggedUnion$Reventless.classify(schema);
168
+ if (match === undefined) {
151
169
  return "Unknown";
152
170
  }
153
- let enumName = parentName + fieldName.charAt(0).toUpperCase() + fieldName.slice(1, fieldName.length);
154
- let $$enum = {
155
- TAG: "Enum",
156
- _0: enumName,
157
- _1: constValues
171
+ let unionName = match[0];
172
+ let armTypes = match[1].map(param => {
173
+ let armSchema = param.schema;
174
+ let tag = param.tag;
175
+ let memberName = TaggedUnion$Reventless.memberTypeName(unionName, tag);
176
+ let fields = {};
177
+ if (armSchema.type === "object") {
178
+ Object.entries(armSchema.properties).forEach(param => {
179
+ let propName = param[0];
180
+ if (propName !== "TAG") {
181
+ fields[propName] = fromSury(memberName, propName, param[1]);
182
+ return;
183
+ }
184
+ });
185
+ }
186
+ return [
187
+ tag,
188
+ {
189
+ TAG: "ObjectRef",
190
+ _0: memberName,
191
+ _1: fields
192
+ }
193
+ ];
194
+ });
195
+ let union = {
196
+ TAG: "TaggedUnion",
197
+ _0: unionName,
198
+ _1: armTypes
158
199
  };
159
200
  if (isOptional) {
160
201
  return {
161
202
  TAG: "Nullable",
162
- _0: $$enum
203
+ _0: union
163
204
  };
164
205
  } else {
165
- return $$enum;
206
+ return union;
166
207
  }
167
208
  default:
168
209
  return "Unknown";
169
210
  }
170
211
  }
171
212
 
213
+ function collectUnclassifiedUnions(path, depthOpt, schema, out) {
214
+ let depth = depthOpt !== undefined ? depthOpt : 0;
215
+ if (depth > 12) {
216
+ return;
217
+ }
218
+ let under = name => {
219
+ if (path === "") {
220
+ return name;
221
+ } else {
222
+ return path + "." + name;
223
+ }
224
+ };
225
+ let recurse = (path, schema) => collectUnclassifiedUnions(path, depth + 1 | 0, schema, out);
226
+ switch (schema.type) {
227
+ case "array" :
228
+ let additionalItems = schema.additionalItems;
229
+ let s = schema.items[0];
230
+ let itemSchema = s !== undefined ? s : (
231
+ additionalItems === "strip" || additionalItems === "strict" ? undefined : additionalItems
232
+ );
233
+ if (itemSchema !== undefined) {
234
+ return recurse(path + "[]", itemSchema);
235
+ } else {
236
+ return;
237
+ }
238
+ case "object" :
239
+ Object.entries(schema.properties).forEach(param => {
240
+ let name = param[0];
241
+ if (name !== "TAG") {
242
+ return recurse(under(name), param[1]);
243
+ }
244
+ });
245
+ return;
246
+ case "anyOf" :
247
+ let members = schema.anyOf.filter(v => {
248
+ switch (v.type) {
249
+ case "null" :
250
+ case "undefined" :
251
+ return false;
252
+ default:
253
+ return true;
254
+ }
255
+ });
256
+ if (members.length !== 1) {
257
+ let allConst = members.every(v => {
258
+ if (v.type === "string") {
259
+ return v.const !== undefined;
260
+ } else {
261
+ return false;
262
+ }
263
+ });
264
+ if (allConst) {
265
+ return;
266
+ }
267
+ let match = TaggedUnion$Reventless.classify(schema);
268
+ if (match !== undefined) {
269
+ match[1].forEach(param => recurse(under(param.tag), param.schema));
270
+ return;
271
+ }
272
+ let reason = Stdlib_Option.isSome(TaggedUnion$Reventless.armsOf(schema)) ? `its arms are well-formed but the union carries no name. Declare it in a spec file, where the ppx names it, or mark the schema with \`Reventless.TaggedUnion.named\`.` : `its ` + members.length.toString() + ` members are neither all string literals (an enum) nor all tagged objects each declaring at least one named field of its own (a union).`;
273
+ out.push({
274
+ path: path,
275
+ reason: reason
276
+ });
277
+ return;
278
+ }
279
+ let inner = members[0];
280
+ return recurse(path, inner);
281
+ default:
282
+ return;
283
+ }
284
+ }
285
+
286
+ function unclassifiedUnions(schema) {
287
+ let out = [];
288
+ collectUnclassifiedUnions("", undefined, schema, out);
289
+ return out;
290
+ }
291
+
172
292
  function optionalFieldNames(schema) {
173
293
  if (schema.type === "object") {
174
294
  return Stdlib_Array.filterMap(Object.entries(schema.properties), param => {
@@ -209,6 +329,8 @@ let isTagged = DcbTag$Reventless.isTagged;
209
329
 
210
330
  let isDateTime = DateTime$Reventless.isDateTime;
211
331
 
332
+ let maxReportDepth = 12;
333
+
212
334
  export {
213
335
  isTagged,
214
336
  isDateTime,
@@ -218,6 +340,9 @@ export {
218
340
  canonicalName,
219
341
  fromSury,
220
342
  shapeOf,
343
+ maxReportDepth,
344
+ collectUnclassifiedUnions,
345
+ unclassifiedUnions,
221
346
  optionalFieldNames,
222
347
  fromSuryObject,
223
348
  }
@@ -196,9 +196,45 @@ let rec fromSchemaType = (st: SchemaType.schemaType): JSON.t =>
196
196
  ("enum", JSON.Encode.array(values->Array.map(JSON.Encode.string))),
197
197
  ])
198
198
  | Semantic(sem, inner) => fromSchemaType(inner)->withSemantic(sem)
199
+ // A union is `oneOf` its arms, each an object carrying the `TAG` const it is
200
+ // discriminated by. The discriminator is what tells a union apart from a
201
+ // nullable object, which is also a `oneOf` of objects — a reader that misses
202
+ // it selects one arm's fields as though they were the field's own and produces
203
+ // a query that looks plausible and is invalid.
204
+ | TaggedUnion(name, arms) =>
205
+ let members = arms->Array.map(((tag, armType)) => armToJsonSchema(~tag, armType))
206
+ jsonObject([("oneOf", JSON.Encode.array(members)), ("x-reventless-union", str(name))])
199
207
  | Unknown => jsonObject([("type", str("string"))])
200
208
  }
201
209
 
210
+ // One arm. `TAG` is declared rather than filtered out — the raw value on the
211
+ // wire and in the live change channel carries it, and it is the only thing in
212
+ // the payload that says which arm this is. The GraphQL member type name rides
213
+ // alongside so a reader mapping a raw payload to a selection does not have to
214
+ // re-derive the naming rule in a second repo.
215
+ and armToJsonSchema = (~tag: string, armType: SchemaType.schemaType): JSON.t =>
216
+ switch armType {
217
+ | ObjectRef(memberName, fields) =>
218
+ let base = objectRefToJsonSchema(fields)
219
+ switch base->JSON.Decode.object {
220
+ | Some(obj) =>
221
+ switch obj->Dict.get("properties")->Option.flatMap(JSON.Decode.object) {
222
+ | Some(props) =>
223
+ props->Dict.set("TAG", jsonObject([("type", str("string")), ("const", str(tag))]))
224
+ | None => ()
225
+ }
226
+ let required = switch obj->Dict.get("required")->Option.flatMap(JSON.Decode.array) {
227
+ | Some(existing) => Array.concat([str("TAG")], existing)
228
+ | None => [str("TAG")]
229
+ }
230
+ obj->Dict.set("required", JSON.Encode.array(required))
231
+ obj->Dict.set("x-reventless-union-member", str(memberName))
232
+ JSON.Encode.object(obj)
233
+ | None => base
234
+ }
235
+ | other => fromSchemaType(other)
236
+ }
237
+
202
238
  // Attach a type-carried semantic to a field's JSON Schema.
203
239
  //
204
240
  // This is the type path's emission point, and it is deliberately *not*
@@ -246,10 +246,54 @@ function fromSchemaType(st) {
246
246
  ]);
247
247
  case "Semantic" :
248
248
  return withSemantic(fromSchemaType(st._1), st._0);
249
+ case "TaggedUnion" :
250
+ let members = st._1.map(param => armToJsonSchema(param[0], param[1]));
251
+ return Object.fromEntries([
252
+ [
253
+ "oneOf",
254
+ members
255
+ ],
256
+ [
257
+ "x-reventless-union",
258
+ st._0
259
+ ]
260
+ ]);
249
261
  }
250
262
  }
251
263
  }
252
264
 
265
+ function armToJsonSchema(tag, armType) {
266
+ if (typeof armType !== "object") {
267
+ return fromSchemaType(armType);
268
+ }
269
+ if (armType.TAG !== "ObjectRef") {
270
+ return fromSchemaType(armType);
271
+ }
272
+ let base = objectRefToJsonSchema(undefined, undefined, undefined, armType._1);
273
+ let obj = Stdlib_JSON.Decode.object(base);
274
+ if (obj === undefined) {
275
+ return base;
276
+ }
277
+ let props = Stdlib_Option.flatMap(obj["properties"], Stdlib_JSON.Decode.object);
278
+ if (props !== undefined) {
279
+ props["TAG"] = Object.fromEntries([
280
+ [
281
+ "type",
282
+ "string"
283
+ ],
284
+ [
285
+ "const",
286
+ tag
287
+ ]
288
+ ]);
289
+ }
290
+ let existing = Stdlib_Option.flatMap(obj["required"], Stdlib_JSON.Decode.array);
291
+ let required = existing !== undefined ? ["TAG"].concat(existing) : ["TAG"];
292
+ obj["required"] = required;
293
+ obj["x-reventless-union-member"] = armType._0;
294
+ return obj;
295
+ }
296
+
253
297
  function withSemantic(fieldSchema, sem) {
254
298
  let obj = Stdlib_JSON.Decode.object(fieldSchema);
255
299
  if (obj === undefined) {
@@ -368,6 +412,7 @@ export {
368
412
  mergeAnnotations,
369
413
  isNullableType,
370
414
  fromSchemaType,
415
+ armToJsonSchema,
371
416
  withSemantic,
372
417
  objectRefToJsonSchema,
373
418
  deriveObjectSchema,
@@ -67,12 +67,24 @@ module Make = (ReadModelSpec: Reventless.ReadModel.Spec, Ops: Ops) => {
67
67
  })
68
68
  }
69
69
 
70
+ // Every union value in the row carries the GraphQL member type it is, written
71
+ // once here rather than by each of the fourteen read doors across three
72
+ // backends — and, unlike a read-time stamp, it is also on the row the live
73
+ // change channel hands over as raw JSON. A view with no union field is walked
74
+ // and left exactly as it was.
75
+ let stampUnionMembers = dict =>
76
+ Reventless.TaggedUnion.stampInto(
77
+ ~schema=ReadModelSpec.stateSchema->S.castToUnknown,
78
+ JSON.Encode.object(dict),
79
+ )
80
+
70
81
  let save = async (id, state, saveMode, ttl) =>
71
82
  switch state->Message.encode(ReadModelSpec.stateSchema)->JSON.Decode.object {
72
83
  | Some(dict) =>
73
84
  dict->Dict.set("id", id->Message.encode(ReadModelSpec.Id.schema))
74
85
  injectSubId(dict, state)
75
86
  injectCompositeIndexAttrs(dict)
87
+ stampUnionMembers(dict)
76
88
  let json = JSON.Encode.object(dict)
77
89
  await Ops.jsonOps.save(id->ReadModelSpec.Id.toString, json, saveMode, ttl)
78
90
  | None =>
@@ -87,6 +99,7 @@ module Make = (ReadModelSpec: Reventless.ReadModel.Spec, Ops: Ops) => {
87
99
  dict->Dict.set("id", id->Message.encode(ReadModelSpec.Id.schema))
88
100
  injectSubId(dict, state)
89
101
  injectCompositeIndexAttrs(dict)
102
+ stampUnionMembers(dict)
90
103
  let json = JSON.Encode.object(dict)
91
104
  Ok(batch->Array.concat([(id->ReadModelSpec.Id.toString, json, ttl)]))
92
105
  | None =>