@reventlessdev/reventless-core 3.0.0-alpha.240 → 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.
@@ -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 =>
@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect";
8
8
  import * as Stream from "effect/Stream";
9
9
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
10
10
  import * as Message$ReventlessCore from "../../Message.res.mjs";
11
+ import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
11
12
 
12
13
  function Make(ReadModelSpec) {
13
14
  return Ops => {
@@ -76,12 +77,14 @@ function Make(ReadModelSpec) {
76
77
  dict[attrName$1] = value$1;
77
78
  });
78
79
  };
80
+ let stampUnionMembers = dict => TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
79
81
  let save = async (id, state, saveMode, ttl) => {
80
82
  let dict = Stdlib_JSON.Decode.object(Message$ReventlessCore.encode(state, ReadModelSpec.stateSchema));
81
83
  if (dict !== undefined) {
82
84
  dict["id"] = Message$ReventlessCore.encode(id, ReadModelSpec.Id.schema);
83
85
  injectSubId(dict, state);
84
86
  injectCompositeIndexAttrs(dict);
87
+ TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
85
88
  return await Ops.jsonOps.save(ReadModelSpec.Id.toString(id), dict, saveMode, ttl);
86
89
  } else {
87
90
  return {
@@ -107,6 +110,7 @@ function Make(ReadModelSpec) {
107
110
  dict["id"] = Message$ReventlessCore.encode(id, ReadModelSpec.Id.schema);
108
111
  injectSubId(dict, state);
109
112
  injectCompositeIndexAttrs(dict);
113
+ TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
110
114
  return {
111
115
  TAG: "Ok",
112
116
  _0: batch.concat([[
@@ -150,6 +154,7 @@ function Make(ReadModelSpec) {
150
154
  load: load,
151
155
  injectSubId: injectSubId,
152
156
  injectCompositeIndexAttrs: injectCompositeIndexAttrs,
157
+ stampUnionMembers: stampUnionMembers,
153
158
  save: save,
154
159
  saveBatch: saveBatch,
155
160
  count: count,
@@ -23,6 +23,11 @@ let rec isLabelShape = (t: SchemaType.schemaType): bool =>
23
23
  switch t {
24
24
  | ScalarString => true
25
25
  | Nullable(inner) => isLabelShape(inner)
26
+ // Written out rather than left to the catch-all: a union is a composite whose
27
+ // rendering depends on which arm a row is in, so no single string names the
28
+ // record. The label picker's fallback rung is a *guess*, and this is the shape
29
+ // it would guess most confidently and most wrongly.
30
+ | TaggedUnion(_, _) => false
26
31
  | _ => false
27
32
  }
28
33
 
@@ -33,6 +38,10 @@ let rec isLifecycleShape = (t: SchemaType.schemaType): bool =>
33
38
  switch t {
34
39
  | Enum(_, _) => true
35
40
  | Nullable(inner) => isLifecycleShape(inner)
41
+ // A union is a closed set of *shapes*, not of values, and `allowedStates`
42
+ // compares values. The same answer the ppx gives `@lifecycle` on a union
43
+ // field, which is a compile error — this is the convention rung agreeing.
44
+ | TaggedUnion(_, _) => false
36
45
  | _ => false
37
46
  }
38
47
 
@@ -32,11 +32,13 @@ function isLabelShape(_t) {
32
32
  if (typeof t !== "object") {
33
33
  return t === "ScalarString";
34
34
  }
35
- if (t.TAG !== "Nullable") {
36
- return false;
35
+ switch (t.TAG) {
36
+ case "Nullable" :
37
+ _t = t._0;
38
+ continue;
39
+ default:
40
+ return false;
37
41
  }
38
- _t = t._0;
39
- continue;
40
42
  };
41
43
  }
42
44
 
@@ -28,9 +28,22 @@ let rec describeSchema = (schema: S.t<unknown>): string =>
28
28
  | _ => true
29
29
  }
30
30
  )
31
- switch nonNull->Array.get(0) {
32
- | Some(inner) => "option<" ++ describeSchema(inner) ++ ">"
33
- | None => "unknown"
31
+ let isOptional = nonNull->Array.length < anyOf->Array.length
32
+ switch nonNull {
33
+ | [inner] => "option<" ++ describeSchema(inner) ++ ">"
34
+ | [] => "unknown"
35
+ | members =>
36
+ // A field holding a variant. Described by every arm, sorted, rather than by
37
+ // the first one: the structural hash is computed from this string, and a
38
+ // description that mentions one arm is blind to a change in any other —
39
+ // which is drift detection reporting "no change" for a changed schema.
40
+ let inner =
41
+ members
42
+ ->Array.map(describeSchema)
43
+ ->Array.toSorted(String.compare)
44
+ ->Array.join("|")
45
+ let union = "union<" ++ inner ++ ">"
46
+ isOptional ? "option<" ++ union ++ ">" : union
34
47
  }
35
48
  | Array({additionalItems}) =>
36
49
  let itemDesc = switch additionalItems {
@@ -26,7 +26,8 @@ function describeSchema(schema) {
26
26
  let inner = Object.entries(schema.properties).filter(param => param[0] !== "TAG").toSorted((param, param$1) => Primitive_string.compare(param[0], param$1[0])).map(param => param[0] + ":" + describeSchema(param[1])).join(",");
27
27
  return "{" + inner + "}";
28
28
  case "anyOf" :
29
- let nonNull = schema.anyOf.filter(v => {
29
+ let anyOf = schema.anyOf;
30
+ let nonNull = anyOf.filter(v => {
30
31
  switch (v.type) {
31
32
  case "null" :
32
33
  case "undefined" :
@@ -35,12 +36,22 @@ function describeSchema(schema) {
35
36
  return true;
36
37
  }
37
38
  });
38
- let inner$1 = nonNull[0];
39
- if (inner$1 !== undefined) {
40
- return "option<" + describeSchema(inner$1) + ">";
41
- } else {
42
- return "unknown";
39
+ let isOptional = nonNull.length < anyOf.length;
40
+ let len = nonNull.length;
41
+ if (len !== 1) {
42
+ if (len === 0) {
43
+ return "unknown";
44
+ }
45
+ let inner$1 = nonNull.map(describeSchema).toSorted(Primitive_string.compare).join("|");
46
+ let union = "union<" + inner$1 + ">";
47
+ if (isOptional) {
48
+ return "option<" + union + ">";
49
+ } else {
50
+ return union;
51
+ }
43
52
  }
53
+ let inner$2 = nonNull[0];
54
+ return "option<" + describeSchema(inner$2) + ">";
44
55
  default:
45
56
  return "unknown";
46
57
  }
@@ -10,7 +10,7 @@
10
10
  // this (spec-level `permission` outranks the legacy `{tableName, group}` pair),
11
11
  // and so does the `requiredAccess` the platform publishes — so a shell can gate
12
12
  // the Plugins view up front instead of offering it and meeting a refusal.
13
- @@reventless.authorize(Reventless.Authorization.AllowGroups(["Admin"]))
13
+ @@reventless.authorize(AllowGroups(["Admin"]))
14
14
 
15
15
  // Current view — one row per plugin **name**, holding the currently-active
16
16
  // version's definition. Keyed by the aggregate id (= plugin name). The status
@@ -269,7 +269,7 @@ describe("the Plugins queryableDef is what the generic helpers produce", () => {
269
269
  // nested object types whose own fields would match a naive `includes`.
270
270
  let props =
271
271
  rm.schema
272
- ->JSON.parseExn
272
+ ->JSON.parseOrThrow
273
273
  ->JSON.Decode.object
274
274
  ->Option.flatMap(o => o->Dict.get("properties"))
275
275
  ->Option.flatMap(JSON.Decode.object)
@@ -289,7 +289,7 @@ describe("the Plugins queryableDef is what the generic helpers produce", () => {
289
289
  testSync("while the internal fields are not published at all", () => {
290
290
  let props =
291
291
  rm.schema
292
- ->JSON.parseExn
292
+ ->JSON.parseOrThrow
293
293
  ->JSON.Decode.object
294
294
  ->Option.flatMap(o => o->Dict.get("properties"))
295
295
  ->Option.flatMap(JSON.Decode.object)
@@ -0,0 +1,198 @@
1
+ open JestGlobals
2
+ open TaggedUnionFixtures
3
+
4
+ let fragmentFor = (~returnTypeName, ~specName, stateSchema) =>
5
+ GraphQL_FragmentGenerator.generate(
6
+ ~mutationEntries=[],
7
+ ~queryEntries=[queryEntryFor(~returnTypeName, ~specName, stateSchema)],
8
+ )
9
+
10
+ let typesOf = fragment => GraphQL_Stitcher.decode(fragment).types
11
+ let typeDefFor = (fragment, needle) =>
12
+ typesOf(fragment)->Array.find(t => t->String.includes(needle))->Option.getOr("")
13
+
14
+ // ── The IR ────────────────────────────────────────────────────────────────
15
+
16
+ describe("SchemaType classifies a named union", () => {
17
+ let shapeOfField = (schema, field) =>
18
+ switch SchemaType.fromSuryObject(~typeName="X", schema->S.castToUnknown) {
19
+ | Some(fields) => fields->Dict.get(field)
20
+ | None => None
21
+ }
22
+
23
+ testSync("arms are keyed by TAG and carry the member type's own name", () => {
24
+ switch shapeOfField(CustomersSpec.stateSchema, "geolocation") {
25
+ | Some(TaggedUnion(name, arms)) =>
26
+ expect(name)->toBe("Geolocation")
27
+ expect(arms->Array.map(((tag, _)) => tag))->toEqual(["Pending", "Located", "Unresolvable"])
28
+ switch arms->Array.find(((tag, _)) => tag === "Located") {
29
+ | Some((_, ObjectRef(memberName, fields))) =>
30
+ expect(memberName)->toBe("GeolocationLocated")
31
+ expect(fields->Dict.keysToArray)->toEqual(["point"])
32
+ | _ => expect("Located arm")->toBe("an ObjectRef")
33
+ }
34
+ | other =>
35
+ expect(other->Option.isSome ? "some other shape" : "nothing")->toBe("a TaggedUnion")
36
+ }
37
+ })
38
+
39
+ testSync("an optional union keeps its arms behind Nullable", () => {
40
+ switch shapeOfField(SightingsSpec.stateSchema, "lastSeen") {
41
+ | Some(Nullable(TaggedUnion(name, arms))) =>
42
+ expect((name, arms->Array.length))->toEqual(("Geolocation", 3))
43
+ | _ => expect("lastSeen")->toBe("Nullable(TaggedUnion(...))")
44
+ }
45
+ })
46
+
47
+ testSync("an array of unions keeps its arms behind ArrayOf", () => {
48
+ switch shapeOfField(SightingsSpec.stateSchema, "history") {
49
+ | Some(ArrayOf(TaggedUnion(name, _))) => expect(name)->toBe("Geolocation")
50
+ | _ => expect("history")->toBe("ArrayOf(TaggedUnion(...))")
51
+ }
52
+ })
53
+
54
+ // The silent degradation this plan set out to end: an unclassifiable union
55
+ // rendered as `String!` with nothing said anywhere.
56
+ testSync("an unnamed union is reported by field and by reason", () => {
57
+ switch SchemaType.unclassifiedUnions(UnnamedSpec.stateSchema->S.castToUnknown) {
58
+ | [{path, reason}] =>
59
+ expect(path)->toBe("verdict")
60
+ expect(reason->String.includes("no name"))->toBe(true)
61
+ | found =>
62
+ expect(found->Array.length)->toBe(1)
63
+ }
64
+ })
65
+
66
+ testSync("a classified union and an enum are not reported", () => {
67
+ expect(
68
+ SchemaType.unclassifiedUnions(CustomersSpec.stateSchema->S.castToUnknown)->Array.length,
69
+ )->toBe(0)
70
+ })
71
+ })
72
+
73
+ // ── The SDL ───────────────────────────────────────────────────────────────
74
+
75
+ describe("GraphQL_FragmentGenerator emits a union", () => {
76
+ let fragment = fragmentFor(
77
+ ~returnTypeName="Ordering_TuCustomer",
78
+ ~specName="TaggedUnionCustomers",
79
+ CustomersSpec.stateSchema,
80
+ )
81
+
82
+ testSync("the field is typed as the union", () => {
83
+ expect(
84
+ typeDefFor(fragment, "type Ordering_TuCustomer ")->String.includes(
85
+ "geolocation: Geolocation!",
86
+ ),
87
+ )->toBe(true)
88
+ })
89
+
90
+ testSync("the union names one member type per arm", () => {
91
+ expect(typeDefFor(fragment, "union Geolocation"))->toBe(
92
+ "union Geolocation = GeolocationPending | GeolocationLocated | GeolocationUnresolvable",
93
+ )
94
+ })
95
+
96
+ testSync("each member type declares the arm's own fields, and no TAG", () => {
97
+ let located = typeDefFor(fragment, "type GeolocationLocated ")
98
+ expect(located->String.includes("point: GeoPoint!"))->toBe(true)
99
+ expect(located->String.includes("TAG"))->toBe(false)
100
+ expect(
101
+ typeDefFor(fragment, "type GeolocationUnresolvable ")->String.includes("reason: String!"),
102
+ )->toBe(true)
103
+ })
104
+
105
+ // The merged-API property. AppSync unions same-named types from each source
106
+ // API, and a union of identical definitions is that definition — so a second
107
+ // plugin's copy has to be byte-identical, not merely equivalent. This is the
108
+ // assertion that fails quietly: a name composed from the field path would pass
109
+ // every other test in this file.
110
+ testSync("a second plugin's copy of the union is byte-identical", () => {
111
+ let other = fragmentFor(
112
+ ~returnTypeName="Catalog_TuSighting",
113
+ ~specName="TaggedUnionSightings",
114
+ SightingsSpec.stateSchema,
115
+ )
116
+ let unionOf = f => typeDefFor(f, "union Geolocation")
117
+ expect(unionOf(other))->toBe(unionOf(fragment))
118
+ expect(typeDefFor(other, "type GeolocationLocated "))->toBe(
119
+ typeDefFor(fragment, "type GeolocationLocated "),
120
+ )
121
+ })
122
+
123
+ testSync("an optional union is nullable and an array of them is a list", () => {
124
+ let sightings = fragmentFor(
125
+ ~returnTypeName="Catalog_TuSighting",
126
+ ~specName="TaggedUnionSightings",
127
+ SightingsSpec.stateSchema,
128
+ )
129
+ let row = typeDefFor(sightings, "type Catalog_TuSighting ")
130
+ expect(row->String.includes("lastSeen: Geolocation\n"))->toBe(true)
131
+ expect(row->String.includes("history: [Geolocation!]!"))->toBe(true)
132
+ })
133
+
134
+ // Unchanged behaviour, pinned: without a name there is no type to emit, and
135
+ // inventing one would publish a contract nothing can stamp a `__typename` for.
136
+ testSync("an unnamed union stays a String", () => {
137
+ let unnamed = fragmentFor(
138
+ ~returnTypeName="Ordering_TuCase",
139
+ ~specName="TaggedUnionUnnamed",
140
+ UnnamedSpec.stateSchema,
141
+ )
142
+ expect(
143
+ typeDefFor(unnamed, "type Ordering_TuCase ")->String.includes("verdict: String!"),
144
+ )->toBe(true)
145
+ expect(typesOf(unnamed)->Array.some(t => t->String.startsWith("union ")))->toBe(false)
146
+ })
147
+ })
148
+
149
+ // ── The JSON Schema ───────────────────────────────────────────────────────
150
+
151
+ let objAt = (json, key) =>
152
+ json->JSON.Decode.object->Option.flatMap(o => o->Dict.get(key))
153
+
154
+ describe("SuryToJsonSchema emits the arms as oneOf", () => {
155
+ let json = SuryToJsonSchema.deriveObjectSchema(CustomersSpec.stateSchema->S.castToUnknown)
156
+ let field =
157
+ objAt(json, "properties")
158
+ ->Option.flatMap(p => p->JSON.Decode.object)
159
+ ->Option.flatMap(p => p->Dict.get("geolocation"))
160
+ ->Option.getOr(JSON.Encode.null)
161
+
162
+ testSync("the field names the union it is", () => {
163
+ expect(objAt(field, "x-reventless-union")->Option.flatMap(JSON.Decode.string))->toEqual(
164
+ Some("Geolocation"),
165
+ )
166
+ })
167
+
168
+ testSync("one member per arm, each discriminated by its TAG const", () => {
169
+ let members =
170
+ objAt(field, "oneOf")->Option.flatMap(JSON.Decode.array)->Option.getOr([])
171
+ expect(members->Array.length)->toBe(3)
172
+ let tags = members->Array.filterMap(m =>
173
+ objAt(m, "properties")
174
+ ->Option.flatMap(JSON.Decode.object)
175
+ ->Option.flatMap(p => p->Dict.get("TAG"))
176
+ ->Option.flatMap(t => objAt(t, "const"))
177
+ ->Option.flatMap(JSON.Decode.string)
178
+ )
179
+ expect(tags)->toEqual(["Pending", "Located", "Unresolvable"])
180
+ })
181
+
182
+ // Without this a reader has to re-derive `<UnionName><Arm>` in a second repo to
183
+ // map a raw live-channel payload onto an inline fragment.
184
+ testSync("each member publishes the GraphQL type it is emitted as", () => {
185
+ let names =
186
+ objAt(field, "oneOf")
187
+ ->Option.flatMap(JSON.Decode.array)
188
+ ->Option.getOr([])
189
+ ->Array.filterMap(m =>
190
+ objAt(m, "x-reventless-union-member")->Option.flatMap(JSON.Decode.string)
191
+ )
192
+ expect(names)->toEqual([
193
+ "GeolocationPending",
194
+ "GeolocationLocated",
195
+ "GeolocationUnresolvable",
196
+ ])
197
+ })
198
+ })