@reventlessdev/reventless-core 3.0.0-alpha.222 → 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 (30) hide show
  1. package/CHANGELOG.md +32 -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 +33 -2
  6. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +18 -2
  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 +41 -24
  16. package/src/plugin/component/Plugin_Structure.res.mjs +25 -18
  17. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +118 -7
  18. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +168 -7
  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 +159 -0
  26. package/tests/plugin/PluginStructureTest.res.mjs +160 -6
  27. package/tests/plugin/StateChangeSlice/PsReserveStock.res +44 -0
  28. package/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs +67 -0
  29. package/tests/plugin/StateViewSlice/PsCategoriesView.res +17 -0
  30. package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +57 -0
@@ -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 */
@@ -78,6 +78,15 @@ module PsAnnotatedViewSlice: ReventlessInfra.StateViewSlice.T = {
78
78
  type component = svsComponent
79
79
  let make = (~dcbEventLog as _, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
80
80
  }
81
+ module PsCategoriesViewSlice: ReventlessInfra.StateViewSlice.T = {
82
+ module Spec = PsCategoriesView
83
+ module Projection = {
84
+ let project = PsCategoriesView.project
85
+ let moduleUrl = PsCategoriesView.moduleUrl
86
+ }
87
+ type component = svsComponent
88
+ let make = (~dcbEventLog as _, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
89
+ }
81
90
 
82
91
  module PsAttachInvoiceSlice: ReventlessInfra.StateChangeSlice.T = {
83
92
  module Spec = PsAttachInvoice
@@ -121,6 +130,20 @@ module PsChangePhotoSlice: ReventlessInfra.StateChangeSlice.T = {
121
130
  let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
122
131
  }
123
132
 
133
+ module PsReserveStockSlice: ReventlessInfra.StateChangeSlice.T = {
134
+ module Spec = PsReserveStock
135
+ module Behavior = {
136
+ type state = PsReserveStock.state
137
+ let initialState = PsReserveStock.initialState
138
+ let evolve = PsReserveStock.evolve
139
+ let decide = PsReserveStock.decide
140
+ let moduleUrl = PsReserveStock.moduleUrl
141
+ }
142
+ let isAsync = false
143
+ type component = scsComponent
144
+ let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
145
+ }
146
+
124
147
  let structure = Plugin_Structure.make(
125
148
  ~name="TestPlugin",
126
149
  ~stateChangeSlices=[module(PsPlaceOrderSlice), module(PsShipOrderSlice)],
@@ -546,6 +569,87 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
546
569
  })
547
570
  })
548
571
 
572
+ // The singular is published because it is not derivable from the plural without
573
+ // re-implementing `Api_Naming.singularize` — and a consumer that guesses gets an
574
+ // irregular plural wrong for both the detail query and the filter input type.
575
+ describe("singleQueryField — the singular the schema actually serves", () => {
576
+ let withIrregularPlural = Plugin_Structure.make(
577
+ ~name="TestPlugin",
578
+ ~stateChangeSlices=[],
579
+ ~stateViewSlices=[module(PsCategoriesViewSlice)],
580
+ )
581
+ let categories = withIrregularPlural.stateViewSlices->Array.getUnsafe(0)
582
+
583
+ // "strip a trailing `s`" would publish `TestPlugin_Categorie`, a field the
584
+ // generated SDL does not contain.
585
+ testSync("an `-ies` plural publishes the `y` singular, not a truncation", () => {
586
+ expect((categories.queryField, categories.singleQueryField))->toEqual((
587
+ "TestPlugin_Categories",
588
+ Some("TestPlugin_Category"),
589
+ ))
590
+ })
591
+
592
+ testSync("every built def states the name Api_Naming returned", () => {
593
+ let stated = structure.stateViewSlices->Array.every(q =>
594
+ q.singleQueryField ==
595
+ Some(
596
+ Api_Naming.queryFieldNamesForStateView(
597
+ ~plugin="TestPlugin",
598
+ ~viewName=q.name,
599
+ ).singleFieldName,
600
+ )
601
+ )
602
+ expect(stated)->toEqual(true)
603
+ })
604
+ })
605
+
606
+ // Which field identifies a row, and whether the author said so or this side
607
+ // worked it out — the same declaration-vs-guess distinction `labelFieldSource`
608
+ // publishes, for the field the generated filter and order-by are built from.
609
+ describe("idField / idFieldSource — the key and its provenance", () => {
610
+ let keyOf = (q: Reventless.Plugin.queryableDef) => (q.idField, q.idFieldSource)
611
+ let withCategories = Plugin_Structure.make(
612
+ ~name="TestPlugin",
613
+ ~stateChangeSlices=[],
614
+ ~stateViewSlices=[module(PsCategoriesViewSlice)],
615
+ )
616
+
617
+ // Orders holds `orderId` + `customerName`; the component name matches the
618
+ // former, so the rung is the one a client could also reach on its own.
619
+ testSync("a name-matching `*Id` field is a guess the consumer can also make", () =>
620
+ expect(keyOf(structure.stateViewSlices->Array.getUnsafe(0)))->toEqual((
621
+ Some("orderId"),
622
+ Some("convention"),
623
+ ))
624
+ )
625
+
626
+ // AvailableProducts: the name would ask for `availableProductId`, which is not
627
+ // a field — `productId` wins because it is the only candidate there is.
628
+ testSync("a sole `*Id` field is a fact only this side can see", () =>
629
+ expect(keyOf(structure.stateViewSlices->Array.getUnsafe(1)))->toEqual((
630
+ Some("productId"),
631
+ Some("sole"),
632
+ ))
633
+ )
634
+
635
+ testSync("an `-ies` component name singularises to its key", () =>
636
+ expect(keyOf(withCategories.stateViewSlices->Array.getUnsafe(0)))->toEqual((
637
+ Some("categoryId"),
638
+ Some("convention"),
639
+ ))
640
+ )
641
+
642
+ // AnnotatedView declares `@id itemId` beside an `@index` field `ownerId`, and
643
+ // its name matches neither — inference alone would decline here, so reporting
644
+ // `itemId` proves the declaration produced it.
645
+ testSync("a declared @id is used where inference would decline", () =>
646
+ expect(keyOf(structure.stateViewSlices->Array.getUnsafe(3)))->toEqual((
647
+ Some("itemId"),
648
+ Some("annotation"),
649
+ ))
650
+ )
651
+ })
652
+
549
653
  describe("statusField — the shape rule", () => {
550
654
  let statusOf = schema =>
551
655
  Plugin_Structure.statusFieldFromStateSchema(~entityName="Test", schema->S.castToUnknown)
@@ -960,4 +1064,59 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
960
1064
  )->toEqual([true, true, true, false, false])
961
1065
  })
962
1066
  })
1067
+
1068
+ // A plural reference is declared exactly like a singular one, and the ppx puts
1069
+ // the marker where `Reference.to_` returns it: on the element schema, one level
1070
+ // inside the array. Reading the field's own schema therefore answers "no
1071
+ // reference" for every `@ref` array — and the consuming side treats a missing
1072
+ // declaration as licence to guess, so the field resolves to whatever its name
1073
+ // suggests instead of what the author wrote.
1074
+ describe("plural references reach the manifest", () => {
1075
+ let withPluralRefs = Plugin_Structure.make(
1076
+ ~name="TestPlugin",
1077
+ ~stateChangeSlices=[module(PsReserveStockSlice)],
1078
+ )
1079
+ let slice = withPluralRefs.stateChangeSlices->Array.getUnsafe(0)
1080
+ let referencesOf = refs =>
1081
+ refs->Array.map(({Reventless.Plugin.fieldName: f, entity}) => (f, entity))
1082
+
1083
+ // Scalar and plural in one command: the plural forms are the regression, the
1084
+ // scalar one is here so a fix that reaches them by breaking it cannot pass.
1085
+ testSync("a command collects its array refs alongside its scalar one", () => {
1086
+ let cmd = slice.commands->Array.getUnsafe(0)
1087
+ expect(cmd.references->referencesOf)->toEqual([
1088
+ ("customerId", "Customers"),
1089
+ ("productIds", "AvailableProducts"),
1090
+ ("warehouseIds", "Warehouses"),
1091
+ ])
1092
+ })
1093
+
1094
+ // Optionality is a statement about presence. An optional array puts the
1095
+ // element two wrappers down, which is the shape most likely to be missed by
1096
+ // an unwrap that only handles one.
1097
+ testSync("an optional array ref is collected under the field's own name", () => {
1098
+ let cmd = slice.commands->Array.getUnsafe(0)
1099
+ expect(
1100
+ cmd.references->Array.find(({fieldName}) => fieldName == "warehouseIds"),
1101
+ )->toEqual(Some({Reventless.Plugin.fieldName: "warehouseIds", entity: "Warehouses", plugin: None}))
1102
+ })
1103
+
1104
+ // The event walk is a separate function over the same field dicts. Left to
1105
+ // itself it would be fixed by accident or not at all.
1106
+ testSync("an event collects its array ref too", () => {
1107
+ let evt = slice.events->Array.getUnsafe(0)
1108
+ expect(evt.references->referencesOf)->toEqual([("productIds", "AvailableProducts")])
1109
+ })
1110
+
1111
+ // The declared entity, not the one `productIds` reads like. Before the
1112
+ // element unwrap this field resolved by heuristic to `Products`.
1113
+ testSync("the declared entity wins over the one the field name suggests", () => {
1114
+ let cmd = slice.commands->Array.getUnsafe(0)
1115
+ expect(
1116
+ cmd.references
1117
+ ->Array.find(({fieldName}) => fieldName == "productIds")
1118
+ ->Option.map(({entity}) => entity),
1119
+ )->toEqual(Some("AvailableProducts"))
1120
+ })
1121
+ })
963
1122
  })