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

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.
@@ -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,108 @@
1
+ open JestGlobals
2
+ open TaggedUnionFixtures
3
+
4
+ // Two markers ride on one schema; a lost one emits the field as `String` with
5
+ // nothing reporting it. Also pins a negative: the name does NOT come from
6
+ // `semanticCompositeNames`, so an entry there would be inert.
7
+
8
+ module GeoCustomersSpec = {
9
+ module Id = Reventless.Id.StringPure
10
+ let name = "SemanticGeolocationCustomers"
11
+ let moduleUrl: string = %raw(`import.meta.url`)
12
+
13
+ @schema
14
+ type state = {customerId: string, geolocation: Reventless.Geolocation.t}
15
+
16
+ let config = Reventless.ReadModel.config()
17
+ let subIdConfig = None
18
+ }
19
+
20
+ let fragment = GraphQL_FragmentGenerator.generate(
21
+ ~mutationEntries=[],
22
+ ~queryEntries=[
23
+ queryEntryFor(
24
+ ~returnTypeName="SemanticGeoCustomer",
25
+ ~specName=GeoCustomersSpec.name,
26
+ GeoCustomersSpec.stateSchema,
27
+ ),
28
+ ],
29
+ )
30
+
31
+ let types = GraphQL_Stitcher.decode(fragment).types
32
+ let typeDefFor = needle => types->Array.find(t => t->String.includes(needle))->Option.getOr("")
33
+
34
+ describe("the Geolocation semantic reaches the IR as a named union", () => {
35
+ let field = switch SchemaType.fromSuryObject(
36
+ ~typeName="SemanticGeoCustomer",
37
+ GeoCustomersSpec.stateSchema->S.castToUnknown,
38
+ ) {
39
+ | Some(fields) => fields->Dict.get("geolocation")
40
+ | None => None
41
+ }
42
+
43
+ // If the walk stopped at the marker, the field would classify as `Unknown`.
44
+ testSync("the semantic marker does not swallow the union", () => {
45
+ switch field {
46
+ | Some(Semantic({id}, TaggedUnion(name, arms))) =>
47
+ expect((id, name, arms->Array.length))->toEqual((
48
+ Reventless.Semantic.Id.geolocation,
49
+ "Geolocation",
50
+ 3,
51
+ ))
52
+ | _ => expect("geolocation")->toBe("Semantic(geolocation, TaggedUnion(Geolocation, 3 arms))")
53
+ }
54
+ })
55
+
56
+ // A path-composed name would carry the spec's name; this one must not.
57
+ testSync("the union is named from the schema, not from where it was found", () => {
58
+ switch field {
59
+ | Some(Semantic(_, TaggedUnion(name, _))) =>
60
+ expect(name->String.includes("Customer"))->toBe(false)
61
+ expect(name)->toBe("Geolocation")
62
+ | _ => expect("geolocation")->toBe("a named TaggedUnion")
63
+ }
64
+ })
65
+
66
+ // An entry there would leave this passing — it decides nothing.
67
+ testSync("semanticCompositeNames does not name this union", () => {
68
+ expect(SchemaType.canonicalName(Reventless.Semantic.Id.geolocation))->toEqual(None)
69
+ })
70
+ })
71
+
72
+ // The breaking half, asserted rather than assumed.
73
+ describe("a row in the pre-union shape does not decode", () => {
74
+ let oldShapedRow = JSON.parseOrThrow(`{
75
+ "customerId": "c1",
76
+ "location": {"lat": 48.2082, "lng": 16.3738},
77
+ "locationStatus": "Located",
78
+ "locationNote": null
79
+ }`)
80
+
81
+ testSync("the three-field shape is refused against the union schema", () => {
82
+ let decodes = switch oldShapedRow->S.parseOrThrow(~to=GeoCustomersSpec.stateSchema) {
83
+ | _ => true
84
+ | exception _ => false
85
+ }
86
+ expect(decodes)->toBe(false)
87
+ })
88
+ })
89
+
90
+ describe("the Geolocation semantic emits its three member types", () => {
91
+ testSync("the union declaration lists all three members", () => {
92
+ let union = typeDefFor("union Geolocation")
93
+ expect(union->String.includes("GeolocationPending"))->toBe(true)
94
+ expect(union->String.includes("GeolocationLocated"))->toBe(true)
95
+ expect(union->String.includes("GeolocationUnresolvable"))->toBe(true)
96
+ })
97
+
98
+ // Also says a composite nested in an arm keeps its own canonical name.
99
+ testSync("the located member carries a GeoPoint", () => {
100
+ expect(typeDefFor("type GeolocationLocated")->String.includes("GeoPoint"))->toBe(true)
101
+ })
102
+
103
+ testSync("the field is typed as the union", () => {
104
+ expect(typeDefFor("type SemanticGeoCustomer")->String.includes("geolocation: Geolocation"))->toBe(
105
+ true,
106
+ )
107
+ })
108
+ })
@@ -0,0 +1,122 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as Sury from "sury";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Semantic$Reventless from "@reventlessdev/reventless-spec/src/semantic/Semantic.res.mjs";
7
+ import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
8
+ import * as Geolocation$Reventless from "@reventlessdev/reventless-spec/src/semantic/Geolocation.res.mjs";
9
+ import * as SchemaType$ReventlessCore from "../../src/components/Api/SchemaType.res.mjs";
10
+ import * as GraphQL_Stitcher$ReventlessCore from "../../src/components/Api/GraphQL_Stitcher.res.mjs";
11
+ import * as TaggedUnionFixtures$ReventlessCore from "../fixtures/TaggedUnionFixtures.res.mjs";
12
+ import * as GraphQL_FragmentGenerator$ReventlessCore from "../../src/components/Api/GraphQL_FragmentGenerator.res.mjs";
13
+
14
+ let name = "SemanticGeolocationCustomers";
15
+
16
+ let moduleUrl = import.meta.url;
17
+
18
+ let stateSchema = Sury.$schema(s => ({
19
+ customerId: s.m(Sury.string),
20
+ geolocation: s.m(Geolocation$Reventless.schema)
21
+ }));
22
+
23
+ let config = ReadModel$Reventless.config(undefined, undefined, undefined);
24
+
25
+ let GeoCustomersSpec = {
26
+ Id: undefined,
27
+ name: name,
28
+ moduleUrl: moduleUrl,
29
+ stateSchema: stateSchema,
30
+ config: config,
31
+ subIdConfig: undefined,
32
+ authorization: "AllowAuthenticated",
33
+ visibility: "Public"
34
+ };
35
+
36
+ let fragment = GraphQL_FragmentGenerator$ReventlessCore.generate([], [TaggedUnionFixtures$ReventlessCore.queryEntryFor("SemanticGeoCustomer", name, stateSchema)]);
37
+
38
+ let types = GraphQL_Stitcher$ReventlessCore.decode(fragment).types;
39
+
40
+ function typeDefFor(needle) {
41
+ return Stdlib_Option.getOr(types.find(t => t.includes(needle)), "");
42
+ }
43
+
44
+ globalThis.describe("the Geolocation semantic reaches the IR as a named union", () => {
45
+ let fields = SchemaType$ReventlessCore.fromSuryObject("SemanticGeoCustomer", stateSchema);
46
+ let field = fields !== undefined ? fields["geolocation"] : undefined;
47
+ globalThis.test("the semantic marker does not swallow the union", () => {
48
+ if (typeof field === "object" && field.TAG === "Semantic") {
49
+ let match = field._1;
50
+ if (typeof match === "object" && match.TAG === "TaggedUnion") {
51
+ globalThis.expect([
52
+ field._0.id,
53
+ match._0,
54
+ match._1.length
55
+ ]).toEqual([
56
+ Semantic$Reventless.Id.geolocation,
57
+ "Geolocation",
58
+ 3
59
+ ]);
60
+ return;
61
+ }
62
+ }
63
+ globalThis.expect("geolocation").toBe("Semantic(geolocation, TaggedUnion(Geolocation, 3 arms))");
64
+ });
65
+ globalThis.test("the union is named from the schema, not from where it was found", () => {
66
+ if (typeof field === "object" && field.TAG === "Semantic") {
67
+ let match = field._1;
68
+ if (typeof match === "object" && match.TAG === "TaggedUnion") {
69
+ let name = match._0;
70
+ globalThis.expect(name.includes("Customer")).toBe(false);
71
+ globalThis.expect(name).toBe("Geolocation");
72
+ return;
73
+ }
74
+ }
75
+ globalThis.expect("geolocation").toBe("a named TaggedUnion");
76
+ });
77
+ globalThis.test("semanticCompositeNames does not name this union", () => {
78
+ globalThis.expect(SchemaType$ReventlessCore.canonicalName(Semantic$Reventless.Id.geolocation)).toEqual(undefined);
79
+ });
80
+ });
81
+
82
+ globalThis.describe("a row in the pre-union shape does not decode", () => {
83
+ let oldShapedRow = JSON.parse(`{
84
+ "customerId": "c1",
85
+ "location": {"lat": 48.2082, "lng": 16.3738},
86
+ "locationStatus": "Located",
87
+ "locationNote": null
88
+ }`);
89
+ globalThis.test("the three-field shape is refused against the union schema", () => {
90
+ let decodes;
91
+ try {
92
+ S.parseOrThrow(oldShapedRow, stateSchema);
93
+ decodes = true;
94
+ } catch (exn) {
95
+ decodes = false;
96
+ }
97
+ globalThis.expect(decodes).toBe(false);
98
+ });
99
+ });
100
+
101
+ globalThis.describe("the Geolocation semantic emits its three member types", () => {
102
+ globalThis.test("the union declaration lists all three members", () => {
103
+ let union = typeDefFor("union Geolocation");
104
+ globalThis.expect(union.includes("GeolocationPending")).toBe(true);
105
+ globalThis.expect(union.includes("GeolocationLocated")).toBe(true);
106
+ globalThis.expect(union.includes("GeolocationUnresolvable")).toBe(true);
107
+ });
108
+ globalThis.test("the located member carries a GeoPoint", () => {
109
+ globalThis.expect(typeDefFor("type GeolocationLocated").includes("GeoPoint")).toBe(true);
110
+ });
111
+ globalThis.test("the field is typed as the union", () => {
112
+ globalThis.expect(typeDefFor("type SemanticGeoCustomer").includes("geolocation: Geolocation")).toBe(true);
113
+ });
114
+ });
115
+
116
+ export {
117
+ GeoCustomersSpec,
118
+ fragment,
119
+ types,
120
+ typeDefFor,
121
+ }
122
+ /* moduleUrl Not a pure module */
@@ -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
+ })