@reventlessdev/reventless-core 3.0.0-alpha.223 → 3.0.0-alpha.224

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.
Files changed (28) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +7 -7
  3. package/src/admin/Platform_Admin_Structure.res +10 -0
  4. package/src/admin/Platform_Admin_Structure.res.mjs +6 -1
  5. package/src/admin/Platform_ComponentDefinitionsApi.res +11 -1
  6. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +13 -1
  7. package/src/components/Api/Api_Ids.res +63 -0
  8. package/src/components/Api/Api_Ids.res.mjs +43 -0
  9. package/src/components/Api/GraphQL_FragmentGenerator.res +121 -28
  10. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +97 -27
  11. package/src/components/Api/QueryDbListQuery.res +13 -5
  12. package/src/components/Api/QueryDbListQuery.res.mjs +5 -3
  13. package/src/components/Api/SchemaType.res +36 -2
  14. package/src/components/Api/SchemaType.res.mjs +31 -10
  15. package/src/plugin/component/Plugin_Structure.res +19 -0
  16. package/src/plugin/component/Plugin_Structure.res.mjs +11 -2
  17. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +63 -0
  18. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +117 -4
  19. package/tests/admin/Platform_PluginStructuresApiTest.res +10 -0
  20. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +23 -2
  21. package/tests/api/Api_IdsTest.res +143 -0
  22. package/tests/api/Api_IdsTest.res.mjs +97 -0
  23. package/tests/api/GraphQL_FragmentGeneratorTest.res +169 -0
  24. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +179 -0
  25. package/tests/plugin/PluginStructureTest.res +90 -0
  26. package/tests/plugin/PluginStructureTest.res.mjs +86 -8
  27. package/tests/plugin/StateViewSlice/PsCategoriesView.res +17 -0
  28. package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +57 -0
@@ -0,0 +1,143 @@
1
+ // The id contract: which form each door accepts, in one place.
2
+ //
3
+ // A row advertises a Relay global id (`btoa("<Type>:<localId>")`) while the
4
+ // storage key is the local id. Before this, the typed doors took only the local
5
+ // id, so the obvious `X(id: row.id)` returned null with no error. These pin the
6
+ // rule that makes either form work — and, just as importantly, the cases where a
7
+ // string must NOT be treated as a global id.
8
+
9
+ open JestGlobals
10
+
11
+ @val external btoa: string => string = "btoa"
12
+
13
+ describe("Api_Ids.encode / decode", () => {
14
+ testSync("round-trips a type name and a local id", () => {
15
+ let g = Api_Ids.encode(~typeName="Ordering_Customer", ~localId="cust-42")
16
+ expect(g)->toEqual(btoa("Ordering_Customer:cust-42"))
17
+ expect(Api_Ids.decode(g))->toEqual(Some(("Ordering_Customer", "cust-42")))
18
+ })
19
+
20
+ // Ids carrying a colon of their own: only the first one separates.
21
+ testSync("a local id containing a colon survives the round trip", () => {
22
+ let g = Api_Ids.encode(~typeName="Platform_Plugin", ~localId="Catalog@1.0.0:beta")
23
+ expect(Api_Ids.decode(g))->toEqual(Some(("Platform_Plugin", "Catalog@1.0.0:beta")))
24
+ })
25
+
26
+ testSync("a plain storage key is not a global id", () =>
27
+ expect(Api_Ids.decode("cust-42"))->toEqual(None)
28
+ )
29
+
30
+ // `atob` accepts this and yields text with no colon — decoding must still say no.
31
+ testSync("valid base64 without a colon is not a global id", () =>
32
+ expect(Api_Ids.decode(btoa("nocolonhere")))->toEqual(None)
33
+ )
34
+
35
+ // A leading colon would mean an empty type name — nothing `node` could resolve.
36
+ testSync("a decoded value starting with the separator is not a global id", () =>
37
+ expect(Api_Ids.decode(btoa(":cust-42")))->toEqual(None)
38
+ )
39
+ })
40
+
41
+ describe("Api_Ids.alternateKey — the fallback a door tries on a miss", () => {
42
+ testSync("a global id offers the storage key inside it", () =>
43
+ expect(Api_Ids.alternateKey(Api_Ids.encode(~typeName="Catalog_Product", ~localId="p-1")))
44
+ ->toEqual(Some("p-1"))
45
+ )
46
+
47
+ testSync("a plain key offers nothing, so the lookup is never retried", () =>
48
+ expect(Api_Ids.alternateKey("p-1"))->toEqual(None)
49
+ )
50
+
51
+ // The reason doors try the raw key FIRST and only fall back here: a storage key
52
+ // that happens to be valid base64 must keep resolving to its own row.
53
+ testSync("a key that merely looks like base64 is not silently rewritten", () => {
54
+ let looksBase64 = btoa("Type:inner")
55
+ expect(Api_Ids.alternateKey(looksBase64))->toEqual(Some("inner"))
56
+ // …but the door asks for `looksBase64` first, so a row stored under that exact
57
+ // string wins. `alternateKey` only ever supplies the second attempt.
58
+ expect(Api_Ids.alternateKey("cGxhaW4="))->toEqual(None)
59
+ })
60
+ })
61
+
62
+ // A resolver that stamps the Relay `id` onto a row must not stamp it onto the
63
+ // *stored* row: `JSON.Decode.object` hands back the very object the in-memory
64
+ // QueryDb holds, so a `Dict.set` writes through. The symptom was a row whose
65
+ // advertised id depended on query history — a list answered the storage key
66
+ // until someone loaded the detail page, and the global id ever after.
67
+ describe("stamping an id onto a row leaves the stored row alone", () => {
68
+ let storedRow = () => {
69
+ let d = Dict.make()
70
+ d->Dict.set("id", JSON.Encode.string("cust-42"))
71
+ d->Dict.set("email", JSON.Encode.string("a@b.c"))
72
+ JSON.Encode.object(d)
73
+ }
74
+
75
+ testSync("Dict.copy before set — the pattern the resolvers use", () => {
76
+ let row = storedRow()
77
+ let stamped = {
78
+ let obj = row->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
79
+ obj->Dict.set(
80
+ "id",
81
+ Api_Ids.encode(~typeName="Ordering_Customer", ~localId="cust-42")->JSON.Encode.string,
82
+ )
83
+ JSON.Encode.object(obj)
84
+ }
85
+ let idOf = j =>
86
+ j->JSON.Decode.object->Option.flatMap(d => d->Dict.get("id"))->Option.flatMap(JSON.Decode.string)
87
+ expect(idOf(stamped))->toEqual(Some(Api_Ids.encode(~typeName="Ordering_Customer", ~localId="cust-42")))
88
+ // The stored row still carries its storage key.
89
+ expect(idOf(row))->toEqual(Some("cust-42"))
90
+ })
91
+ })
92
+
93
+ // `filter.ids` is the door that has always taken either form. Which side needs
94
+ // decoding flipped when the typed doors stopped stamping global ids onto rows:
95
+ // the row now carries its storage key and the *argument* may be the global one.
96
+ describe("filter.ids matches whichever form each side holds", () => {
97
+ let row = (id: string) => {
98
+ let d = Dict.make()
99
+ d->Dict.set("id", JSON.Encode.string(id))
100
+ d->Dict.set("name", JSON.Encode.string("Alpha"))
101
+ JSON.Encode.object(d)
102
+ }
103
+ let idsIn = (~rowId, ~filterIds) => {
104
+ let args = Dict.make()
105
+ args->Dict.set(
106
+ "filter",
107
+ JSON.Encode.object(
108
+ Dict.fromArray([("ids", JSON.Encode.array(filterIds->Array.map(JSON.Encode.string)))]),
109
+ ),
110
+ )
111
+ let conn = QueryDbListQuery.run(
112
+ ~items=[row(rowId)],
113
+ ~argsDict=args,
114
+ ~capability=GraphQL_FragmentGenerator.emptyCapability,
115
+ ~labelField="name",
116
+ )
117
+ conn
118
+ ->JSON.Decode.object
119
+ ->Option.flatMap(d => d->Dict.get("edges"))
120
+ ->Option.flatMap(JSON.Decode.array)
121
+ ->Option.getOr([])
122
+ ->Array.length
123
+ }
124
+ let globalFor = localId => Api_Ids.encode(~typeName="Catalog_Product", ~localId)
125
+
126
+ testSync("storage key row, storage key filter", () =>
127
+ expect(idsIn(~rowId="p-1", ~filterIds=["p-1"]))->toBe(1)
128
+ )
129
+
130
+ // The case the flip fixes: a client passes back the global id it was given by
131
+ // `node`, while the list rows carry storage keys.
132
+ testSync("storage key row, global id filter", () =>
133
+ expect(idsIn(~rowId="p-1", ~filterIds=[globalFor("p-1")]))->toBe(1)
134
+ )
135
+
136
+ testSync("global id row, storage key filter", () =>
137
+ expect(idsIn(~rowId=globalFor("p-1"), ~filterIds=["p-1"]))->toBe(1)
138
+ )
139
+
140
+ testSync("a filter for another row still excludes it", () =>
141
+ expect(idsIn(~rowId="p-1", ~filterIds=[globalFor("p-2")]))->toBe(0)
142
+ )
143
+ })
@@ -0,0 +1,97 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Api_Ids$ReventlessCore from "../../src/components/Api/Api_Ids.res.mjs";
6
+ import * as QueryDbListQuery$ReventlessCore from "../../src/components/Api/QueryDbListQuery.res.mjs";
7
+ import * as GraphQL_FragmentGenerator$ReventlessCore from "../../src/components/Api/GraphQL_FragmentGenerator.res.mjs";
8
+
9
+ globalThis.describe("Api_Ids.encode / decode", () => {
10
+ globalThis.test("round-trips a type name and a local id", () => {
11
+ let g = Api_Ids$ReventlessCore.encode("Ordering_Customer", "cust-42");
12
+ globalThis.expect(g).toEqual(btoa("Ordering_Customer:cust-42"));
13
+ globalThis.expect(Api_Ids$ReventlessCore.decode(g)).toEqual([
14
+ "Ordering_Customer",
15
+ "cust-42"
16
+ ]);
17
+ });
18
+ globalThis.test("a local id containing a colon survives the round trip", () => {
19
+ let g = Api_Ids$ReventlessCore.encode("Platform_Plugin", "Catalog@1.0.0:beta");
20
+ globalThis.expect(Api_Ids$ReventlessCore.decode(g)).toEqual([
21
+ "Platform_Plugin",
22
+ "Catalog@1.0.0:beta"
23
+ ]);
24
+ });
25
+ globalThis.test("a plain storage key is not a global id", () => {
26
+ globalThis.expect(Api_Ids$ReventlessCore.decode("cust-42")).toEqual(undefined);
27
+ });
28
+ globalThis.test("valid base64 without a colon is not a global id", () => {
29
+ globalThis.expect(Api_Ids$ReventlessCore.decode(btoa("nocolonhere"))).toEqual(undefined);
30
+ });
31
+ globalThis.test("a decoded value starting with the separator is not a global id", () => {
32
+ globalThis.expect(Api_Ids$ReventlessCore.decode(btoa(":cust-42"))).toEqual(undefined);
33
+ });
34
+ });
35
+
36
+ globalThis.describe("Api_Ids.alternateKey — the fallback a door tries on a miss", () => {
37
+ globalThis.test("a global id offers the storage key inside it", () => {
38
+ globalThis.expect(Api_Ids$ReventlessCore.alternateKey(Api_Ids$ReventlessCore.encode("Catalog_Product", "p-1"))).toEqual("p-1");
39
+ });
40
+ globalThis.test("a plain key offers nothing, so the lookup is never retried", () => {
41
+ globalThis.expect(Api_Ids$ReventlessCore.alternateKey("p-1")).toEqual(undefined);
42
+ });
43
+ globalThis.test("a key that merely looks like base64 is not silently rewritten", () => {
44
+ let looksBase64 = btoa("Type:inner");
45
+ globalThis.expect(Api_Ids$ReventlessCore.alternateKey(looksBase64)).toEqual("inner");
46
+ globalThis.expect(Api_Ids$ReventlessCore.alternateKey("cGxhaW4=")).toEqual(undefined);
47
+ });
48
+ });
49
+
50
+ globalThis.describe("stamping an id onto a row leaves the stored row alone", () => {
51
+ let storedRow = () => {
52
+ let d = {};
53
+ d["id"] = "cust-42";
54
+ d["email"] = "a@b.c";
55
+ return d;
56
+ };
57
+ globalThis.test("Dict.copy before set — the pattern the resolvers use", () => {
58
+ let row = storedRow();
59
+ let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(row), {}, prim => Object.assign({}, prim));
60
+ obj["id"] = Api_Ids$ReventlessCore.encode("Ordering_Customer", "cust-42");
61
+ let idOf = j => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(j), d => d["id"]), Stdlib_JSON.Decode.string);
62
+ globalThis.expect(idOf(obj)).toEqual(Api_Ids$ReventlessCore.encode("Ordering_Customer", "cust-42"));
63
+ globalThis.expect(idOf(row)).toEqual("cust-42");
64
+ });
65
+ });
66
+
67
+ globalThis.describe("filter.ids matches whichever form each side holds", () => {
68
+ let row = id => {
69
+ let d = {};
70
+ d["id"] = id;
71
+ d["name"] = "Alpha";
72
+ return d;
73
+ };
74
+ let idsIn = (rowId, filterIds) => {
75
+ let args = {};
76
+ args["filter"] = Object.fromEntries([[
77
+ "ids",
78
+ filterIds.map(prim => prim)
79
+ ]]);
80
+ let conn = QueryDbListQuery$ReventlessCore.run([row(rowId)], args, GraphQL_FragmentGenerator$ReventlessCore.emptyCapability, "name", undefined);
81
+ return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(conn), d => d["edges"]), Stdlib_JSON.Decode.array), []).length;
82
+ };
83
+ globalThis.test("storage key row, storage key filter", () => {
84
+ globalThis.expect(idsIn("p-1", ["p-1"])).toBe(1);
85
+ });
86
+ globalThis.test("storage key row, global id filter", () => {
87
+ globalThis.expect(idsIn("p-1", [Api_Ids$ReventlessCore.encode("Catalog_Product", "p-1")])).toBe(1);
88
+ });
89
+ globalThis.test("global id row, storage key filter", () => {
90
+ globalThis.expect(idsIn(Api_Ids$ReventlessCore.encode("Catalog_Product", "p-1"), ["p-1"])).toBe(1);
91
+ });
92
+ globalThis.test("a filter for another row still excludes it", () => {
93
+ globalThis.expect(idsIn("p-1", [Api_Ids$ReventlessCore.encode("Catalog_Product", "p-2")])).toBe(0);
94
+ });
95
+ });
96
+
97
+ /* Not a pure module */
@@ -87,3 +87,172 @@ describe("GraphQL_FragmentGenerator slice TODO rows", () => {
87
87
  expect(item->String.includes("address: String!"))->toBe(true)
88
88
  })
89
89
  })
90
+
91
+ // A state that declares no `@id` used to produce `emptyCapability` — no per-field
92
+ // filter and no order-by at all, so every narrowing a client asked for happened
93
+ // client-side over one page. The key is knowable without the annotation in the
94
+ // common cases; these pin which cases those are, and which still need `@id`.
95
+ describe("resolveKeyField — the ladder", () => {
96
+ let rungFor = (~entityName, schema) =>
97
+ GraphQL_FragmentGenerator.resolveKeyField(~entityName, schema->S.castToUnknown)
98
+
99
+ testSync("a lone `*Id` field is the only thing the key could be", () => {
100
+ let schema = S.schema(s => {"productId": s.matches(S.string), "name": s.matches(S.string)})
101
+ expect(rungFor(~entityName="AvailableProducts", schema))->toEqual(Some(("productId", "sole")))
102
+ })
103
+
104
+ testSync("several `*Id` fields are disambiguated by the component name", () => {
105
+ let schema = S.schema(s =>
106
+ {"productId": s.matches(S.string), "categoryId": s.matches(S.string)}
107
+ )
108
+ expect(rungFor(~entityName="Products", schema))->toEqual(Some(("productId", "convention")))
109
+ })
110
+
111
+ // The `-ies` plural the naive singulariser gets wrong, on the key side too.
112
+ testSync("the convention rung singularises the way Api_Naming does", () => {
113
+ let schema = S.schema(s =>
114
+ {"categoryId": s.matches(S.string), "ownerId": s.matches(S.string)}
115
+ )
116
+ expect(rungFor(~entityName="Categories", schema))->toEqual(Some(("categoryId", "convention")))
117
+ })
118
+
119
+ // ProductDemand: two `*Id` fields, and the name yields `productDemandId`, which
120
+ // is not a field. Declining is the honest answer — this is what `@id` is for.
121
+ testSync("several `*Id` fields with no name match declines", () => {
122
+ let schema = S.schema(s =>
123
+ {"productId": s.matches(S.string), "categoryId": s.matches(S.string)}
124
+ )
125
+ expect(rungFor(~entityName="ProductDemand", schema))->toEqual(None)
126
+ })
127
+
128
+ testSync("no `*Id` field at all declines", () => {
129
+ let schema = S.schema(s => {"email": s.matches(S.string), "address": s.matches(S.string)})
130
+ expect(rungFor(~entityName="Customers", schema))->toEqual(None)
131
+ })
132
+
133
+ // `paid` / `valid` end with a lowercase "id". SchemaType.isIdFieldName accepts
134
+ // them (it lowercases first, which is right for its own use); a key field must
135
+ // not be nominated that way.
136
+ testSync("a word merely ending in `id` is not a key", () => {
137
+ let schema = S.schema(s => {"paid": s.matches(S.string), "valid": s.matches(S.string)})
138
+ expect(rungFor(~entityName="Invoices", schema))->toEqual(None)
139
+ })
140
+ })
141
+
142
+ describe("deriveServerCapability — inferred keys reach the SDL surface", () => {
143
+ let capabilityFor = (~entityName, schema) =>
144
+ GraphQL_FragmentGenerator.deriveServerCapability(~entityName, schema->S.castToUnknown)
145
+
146
+ testSync("an inferred key yields an eq filter and a sort field", () => {
147
+ let schema = S.schema(s => {"orderId": s.matches(S.string), "total": s.matches(S.float)})
148
+ let c = capabilityFor(~entityName="Orders", schema)
149
+ expect(c.filterFields->Array.map(f => f.name))->toEqual(["orderId"])
150
+ expect(c.sortFields)->toEqual(["orderId"])
151
+ })
152
+
153
+ testSync("an unresolvable key still yields nothing — the old behaviour, now narrowed", () => {
154
+ let schema = S.schema(s => {"email": s.matches(S.string)})
155
+ let c = capabilityFor(~entityName="Customers", schema)
156
+ expect((c.filterFields->Array.length, c.sortFields->Array.length))->toEqual((0, 0))
157
+ })
158
+
159
+ testSync("the filter input carries the inferred key's eq field", () => {
160
+ let schema = S.schema(s => {"orderId": s.matches(S.string)})
161
+ let input = GraphQL_FragmentGenerator.deriveConnectionFilterType(
162
+ ~filterTypeName="Ordering_OrderFilter",
163
+ ~capability=capabilityFor(~entityName="Orders", schema),
164
+ )
165
+ // `ID`, not `String`: a `*Id`-named field is an EntityId in the IR, and the
166
+ // filter input mirrors the field's own GraphQL type.
167
+ expect(input->String.includes("orderIdEq: ID"))->toBe(true)
168
+ })
169
+
170
+ // No OrderBy type is emitted at all when nothing is sortable, so an inferred
171
+ // key is the difference between having the type and not having it.
172
+ testSync("the order-by pair exists because of the inferred key", () => {
173
+ let schema = S.schema(s => {"orderId": s.matches(S.string)})
174
+ let types = GraphQL_FragmentGenerator.deriveConnectionOrderByType(
175
+ ~singularTypeName="Ordering_Order",
176
+ ~capability=capabilityFor(~entityName="Orders", schema),
177
+ )
178
+ expect(types->Array.length)->toBe(2)
179
+ expect(types->Array.join("\n")->String.includes("enum Ordering_OrderOrderField"))->toBe(true)
180
+ })
181
+ })
182
+
183
+ // A semantic composite is one type wherever it appears. Named from the field
184
+ // path that reached it, two commands taking a price produced two identical
185
+ // `{amount, currency}` inputs and, beside each, its own copy of the 165-value
186
+ // ISO 4217 enum — six copies of it across one example schema.
187
+ module AddProductCmd = {
188
+ @schema
189
+ type command =
190
+ | AddProduct({productId: string, price: Reventless.Money.t})
191
+ | ArchiveProduct({productId: string})
192
+ }
193
+
194
+ module ChangePriceCmd = {
195
+ @schema
196
+ type command =
197
+ | ChangeProductPrice({productId: string, price: Reventless.Money.t})
198
+ | ClearProductPrice({productId: string})
199
+ }
200
+
201
+ describe("semantic composites are named once, not once per field", () => {
202
+ let countDefs = (types, keyword, name) =>
203
+ types->Array.filter(t => t->String.startsWith(`${keyword} ${name} {`))->Array.length
204
+
205
+ let fragmentTypes = GraphQL_Stitcher.decode(
206
+ GraphQL_FragmentGenerator.generate(
207
+ ~mutationEntries=[
208
+ {
209
+ ReventlessInfra.Api.fieldNames: ["Catalog_AddProduct"],
210
+ commandSchema: AddProductCmd.commandSchema->S.castToUnknown,
211
+ injectIdArg: false,
212
+ },
213
+ {
214
+ ReventlessInfra.Api.fieldNames: ["Catalog_ChangeProductPrice"],
215
+ commandSchema: ChangePriceCmd.commandSchema->S.castToUnknown,
216
+ injectIdArg: false,
217
+ },
218
+ ],
219
+ ~queryEntries=[],
220
+ ),
221
+ ).types
222
+
223
+ testSync("two commands taking a price share one input definition", () =>
224
+ expect(fragmentTypes->countDefs("input", "MoneyInput"))->toBe(1)
225
+ )
226
+
227
+ testSync("and one currency enum between them", () =>
228
+ expect(fragmentTypes->countDefs("enum", "MoneyCurrency"))->toBe(1)
229
+ )
230
+
231
+ // The control: these are the names the field-path naming produced, and their
232
+ // absence is the whole change. Without this the assertions above would pass
233
+ // just as well against a schema that emitted both namings.
234
+ testSync("nothing is named after the field path that reached it", () =>
235
+ expect(fragmentTypes->Array.some(t => t->String.includes("AddProductPrice")))->toBe(false)
236
+ )
237
+
238
+ // GraphQL forbids one name serving as both an object and an input, so the two
239
+ // positions differ by suffix — while sharing the enum, which has no such
240
+ // restriction and would otherwise be emitted twice over.
241
+ testSync("the output position keeps the bare name", () => {
242
+ let types = GraphQL_FragmentGenerator.deriveObjectTypeWithNested(
243
+ ~typeName="Catalog_Product",
244
+ ~includeIdParam=false,
245
+ S.schema(s =>
246
+ {
247
+ "productId": s.matches(S.string),
248
+ "price": s.matches(Reventless.Money.schema),
249
+ }
250
+ )->S.castToUnknown,
251
+ )
252
+ expect((
253
+ types->countDefs("type", "Money"),
254
+ types->countDefs("enum", "MoneyCurrency"),
255
+ types->Array.some(t => t->String.includes("price: Money!")),
256
+ ))->toEqual((1, 1, true))
257
+ })
258
+ })
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Money$Reventless from "@reventlessdev/reventless-spec/src/semantic/Money.res.mjs";
5
6
  import * as GraphQL_Stitcher$ReventlessCore from "../../src/components/Api/GraphQL_Stitcher.res.mjs";
6
7
  import * as GraphQL_FragmentGenerator$ReventlessCore from "../../src/components/Api/GraphQL_FragmentGenerator.res.mjs";
7
8
  import * as OutboundTranslationSlice_Callback$ReventlessCore from "../../src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs";
@@ -83,10 +84,188 @@ globalThis.describe("GraphQL_FragmentGenerator slice TODO rows", () => {
83
84
  });
84
85
  });
85
86
 
87
+ globalThis.describe("resolveKeyField — the ladder", () => {
88
+ globalThis.test("a lone `*Id` field is the only thing the key could be", () => {
89
+ let schema = S.schema(s => ({
90
+ productId: s.m(S.string),
91
+ name: s.m(S.string)
92
+ }));
93
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("AvailableProducts", schema)).toEqual([
94
+ "productId",
95
+ "sole"
96
+ ]);
97
+ });
98
+ globalThis.test("several `*Id` fields are disambiguated by the component name", () => {
99
+ let schema = S.schema(s => ({
100
+ productId: s.m(S.string),
101
+ categoryId: s.m(S.string)
102
+ }));
103
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Products", schema)).toEqual([
104
+ "productId",
105
+ "convention"
106
+ ]);
107
+ });
108
+ globalThis.test("the convention rung singularises the way Api_Naming does", () => {
109
+ let schema = S.schema(s => ({
110
+ categoryId: s.m(S.string),
111
+ ownerId: s.m(S.string)
112
+ }));
113
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Categories", schema)).toEqual([
114
+ "categoryId",
115
+ "convention"
116
+ ]);
117
+ });
118
+ globalThis.test("several `*Id` fields with no name match declines", () => {
119
+ let schema = S.schema(s => ({
120
+ productId: s.m(S.string),
121
+ categoryId: s.m(S.string)
122
+ }));
123
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("ProductDemand", schema)).toEqual(undefined);
124
+ });
125
+ globalThis.test("no `*Id` field at all declines", () => {
126
+ let schema = S.schema(s => ({
127
+ email: s.m(S.string),
128
+ address: s.m(S.string)
129
+ }));
130
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Customers", schema)).toEqual(undefined);
131
+ });
132
+ globalThis.test("a word merely ending in `id` is not a key", () => {
133
+ let schema = S.schema(s => ({
134
+ paid: s.m(S.string),
135
+ valid: s.m(S.string)
136
+ }));
137
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("Invoices", schema)).toEqual(undefined);
138
+ });
139
+ });
140
+
141
+ globalThis.describe("deriveServerCapability — inferred keys reach the SDL surface", () => {
142
+ globalThis.test("an inferred key yields an eq filter and a sort field", () => {
143
+ let schema = S.schema(s => ({
144
+ orderId: s.m(S.string),
145
+ total: s.m(S.float)
146
+ }));
147
+ let c = GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability("Orders", schema);
148
+ globalThis.expect(c.filterFields.map(f => f.name)).toEqual(["orderId"]);
149
+ globalThis.expect(c.sortFields).toEqual(["orderId"]);
150
+ });
151
+ globalThis.test("an unresolvable key still yields nothing — the old behaviour, now narrowed", () => {
152
+ let schema = S.schema(s => ({
153
+ email: s.m(S.string)
154
+ }));
155
+ let c = GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability("Customers", schema);
156
+ globalThis.expect([
157
+ c.filterFields.length,
158
+ c.sortFields.length
159
+ ]).toEqual([
160
+ 0,
161
+ 0
162
+ ]);
163
+ });
164
+ globalThis.test("the filter input carries the inferred key's eq field", () => {
165
+ let schema = S.schema(s => ({
166
+ orderId: s.m(S.string)
167
+ }));
168
+ let input = GraphQL_FragmentGenerator$ReventlessCore.deriveConnectionFilterType("Ordering_OrderFilter", GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability("Orders", schema));
169
+ globalThis.expect(input.includes("orderIdEq: ID")).toBe(true);
170
+ });
171
+ globalThis.test("the order-by pair exists because of the inferred key", () => {
172
+ let schema = S.schema(s => ({
173
+ orderId: s.m(S.string)
174
+ }));
175
+ let types = GraphQL_FragmentGenerator$ReventlessCore.deriveConnectionOrderByType("Ordering_Order", GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability("Orders", schema));
176
+ globalThis.expect(types.length).toBe(2);
177
+ globalThis.expect(types.join("\n").includes("enum Ordering_OrderOrderField")).toBe(true);
178
+ });
179
+ });
180
+
181
+ let commandSchema$1 = S.union([
182
+ S.schema(s => ({
183
+ TAG: "AddProduct",
184
+ productId: s.m(S.string),
185
+ price: s.m(Money$Reventless.schema)
186
+ })),
187
+ S.schema(s => ({
188
+ TAG: "ArchiveProduct",
189
+ productId: s.m(S.string)
190
+ }))
191
+ ]);
192
+
193
+ function commandAuthorization$1(param) {
194
+ return "AllowAuthenticated";
195
+ }
196
+
197
+ let AddProductCmd = {
198
+ commandSchema: commandSchema$1,
199
+ commandAuthorization: commandAuthorization$1
200
+ };
201
+
202
+ let commandSchema$2 = S.union([
203
+ S.schema(s => ({
204
+ TAG: "ChangeProductPrice",
205
+ productId: s.m(S.string),
206
+ price: s.m(Money$Reventless.schema)
207
+ })),
208
+ S.schema(s => ({
209
+ TAG: "ClearProductPrice",
210
+ productId: s.m(S.string)
211
+ }))
212
+ ]);
213
+
214
+ function commandAuthorization$2(param) {
215
+ return "AllowAuthenticated";
216
+ }
217
+
218
+ let ChangePriceCmd = {
219
+ commandSchema: commandSchema$2,
220
+ commandAuthorization: commandAuthorization$2
221
+ };
222
+
223
+ globalThis.describe("semantic composites are named once, not once per field", () => {
224
+ let countDefs = (types, keyword, name) => types.filter(t => t.startsWith(keyword + ` ` + name + ` {`)).length;
225
+ let fragmentTypes = GraphQL_Stitcher$ReventlessCore.decode(GraphQL_FragmentGenerator$ReventlessCore.generate([
226
+ {
227
+ fieldNames: ["Catalog_AddProduct"],
228
+ commandSchema: commandSchema$1,
229
+ injectIdArg: false
230
+ },
231
+ {
232
+ fieldNames: ["Catalog_ChangeProductPrice"],
233
+ commandSchema: commandSchema$2,
234
+ injectIdArg: false
235
+ }
236
+ ], [])).types;
237
+ globalThis.test("two commands taking a price share one input definition", () => {
238
+ globalThis.expect(countDefs(fragmentTypes, "input", "MoneyInput")).toBe(1);
239
+ });
240
+ globalThis.test("and one currency enum between them", () => {
241
+ globalThis.expect(countDefs(fragmentTypes, "enum", "MoneyCurrency")).toBe(1);
242
+ });
243
+ globalThis.test("nothing is named after the field path that reached it", () => {
244
+ globalThis.expect(fragmentTypes.some(t => t.includes("AddProductPrice"))).toBe(false);
245
+ });
246
+ globalThis.test("the output position keeps the bare name", () => {
247
+ let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Catalog_Product", undefined, false, S.schema(s => ({
248
+ productId: s.m(S.string),
249
+ price: s.m(Money$Reventless.schema)
250
+ })));
251
+ globalThis.expect([
252
+ countDefs(types, "type", "Money"),
253
+ countDefs(types, "enum", "MoneyCurrency"),
254
+ types.some(t => t.includes("price: Money!"))
255
+ ]).toEqual([
256
+ 1,
257
+ 1,
258
+ true
259
+ ]);
260
+ });
261
+ });
262
+
86
263
  export {
87
264
  OrderSliceCmd,
88
265
  mutationFor,
89
266
  GeocodeItem,
90
267
  typeDefFor,
268
+ AddProductCmd,
269
+ ChangePriceCmd,
91
270
  }
92
271
  /* commandSchema Not a pure module */