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

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 (51) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +6 -6
  3. package/src/admin/Platform_Admin_Structure.res +16 -0
  4. package/src/admin/Platform_Admin_Structure.res.mjs +14 -3
  5. package/src/admin/Platform_BakedManifest.res +154 -0
  6. package/src/admin/Platform_BakedManifest.res.mjs +156 -0
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +19 -2
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +30 -2
  9. package/src/components/Api/Api_Ids.res +63 -0
  10. package/src/components/Api/Api_Ids.res.mjs +43 -0
  11. package/src/components/Api/GraphQL_FragmentGenerator.res +121 -28
  12. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +97 -27
  13. package/src/components/Api/OwnerScopeDiagnostics.res +54 -0
  14. package/src/components/Api/OwnerScopeDiagnostics.res.mjs +27 -0
  15. package/src/components/Api/QueryDbListQuery.res +38 -6
  16. package/src/components/Api/QueryDbListQuery.res.mjs +14 -5
  17. package/src/components/Api/SchemaType.res +36 -2
  18. package/src/components/Api/SchemaType.res.mjs +31 -10
  19. package/src/components/Api/SuryToJsonSchema.res +19 -0
  20. package/src/components/Api/SuryToJsonSchema.res.mjs +18 -4
  21. package/src/components/CommandGenerator/CommandGenerator_Callback.res +48 -0
  22. package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +27 -1
  23. package/src/plugin/component/Plugin_Structure.res +78 -3
  24. package/src/plugin/component/Plugin_Structure.res.mjs +38 -9
  25. package/tests/admin/Platform_BakedManifestTest.res +244 -0
  26. package/tests/admin/Platform_BakedManifestTest.res.mjs +294 -0
  27. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +73 -0
  28. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +135 -6
  29. package/tests/admin/Platform_PluginStructuresApiTest.res +12 -0
  30. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +27 -2
  31. package/tests/api/Api_IdsTest.res +143 -0
  32. package/tests/api/Api_IdsTest.res.mjs +97 -0
  33. package/tests/api/GraphQL_FragmentGeneratorTest.res +169 -0
  34. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +179 -0
  35. package/tests/api/OwnerScopeDiagnosticsTest.res +109 -0
  36. package/tests/api/OwnerScopeDiagnosticsTest.res.mjs +91 -0
  37. package/tests/api/SuryToJsonSchemaTest.res +45 -0
  38. package/tests/api/SuryToJsonSchemaTest.res.mjs +23 -0
  39. package/tests/commandgenerator/OwnerStampingTest.res +174 -0
  40. package/tests/commandgenerator/OwnerStampingTest.res.mjs +195 -0
  41. package/tests/plugin/PluginStructureAccessTest.res +110 -0
  42. package/tests/plugin/PluginStructureAccessTest.res.mjs +144 -0
  43. package/tests/plugin/PluginStructureTest.res +90 -0
  44. package/tests/plugin/PluginStructureTest.res.mjs +86 -8
  45. package/tests/plugin/StateChangeSlice/PsGatedCommands.res +28 -0
  46. package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +84 -0
  47. package/tests/plugin/StateViewSlice/PsCategoriesView.res +17 -0
  48. package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +57 -0
  49. package/tests/plugin/StateViewSlice/PsGatedView.res +15 -0
  50. package/tests/plugin/StateViewSlice/PsGatedView.res.mjs +60 -0
  51. package/tests/plugin/pluginDefinitionRequiredScalars.txt +4 -0
@@ -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 */
@@ -0,0 +1,109 @@
1
+ open JestGlobals
2
+
3
+ // This file asserts on EMITTED log output, so it pins the level it needs rather
4
+ // than inheriting whatever the runner exported — `LOG_LEVEL=silent` would
5
+ // suppress the warning and turn every case below green for the wrong reason.
6
+ // `Logger` re-reads process.env on each call, so a file-scope assignment holds.
7
+ NodeProcess.env->Dict.set("LOG_LEVEL", "warn")
8
+
9
+ type consoleObj = {mutable log: string => unit}
10
+ @val external console: consoleObj = "console"
11
+
12
+ let capture = (fn: unit => unit): array<string> => {
13
+ let lines: array<string> = []
14
+ let original = console.log
15
+ console.log = s => lines->Array.push(s)
16
+ let result = try {
17
+ fn()
18
+ Ok()
19
+ } catch {
20
+ | e => Error(e)
21
+ }
22
+ console.log = original
23
+ switch result {
24
+ | Ok() => lines
25
+ | Error(e) => throw(e)
26
+ }
27
+ }
28
+
29
+ let warnFor = (~view, ~ownerField, ~elevated) => {
30
+ OwnerScopeDiagnostics.resetWarnings()
31
+ Reventless.OwnerScope.setElevatedGroups(elevated)
32
+ let lines = capture(() =>
33
+ OwnerScopeDiagnostics.warnIfNoElevatedGroups(~comp="Test", ~view, ~ownerField)
34
+ )
35
+ Reventless.OwnerScope.setElevatedGroups([])
36
+ lines
37
+ }
38
+
39
+ describe("OwnerScopeDiagnostics:", () => {
40
+ testSync("an owner-scoped view with no elevated groups warns", () =>
41
+ expect(warnFor(~view="Orders", ~ownerField=Some("customerId"), ~elevated=[])->Array.length)->toBe(
42
+ 1,
43
+ )
44
+ )
45
+
46
+ // The consequence is the whole point of the line — an operator reading
47
+ // "configure elevated groups" without being told that admins are currently
48
+ // scoped has no reason to treat it as urgent.
49
+ testSync("the warning names the view, the field, and what will happen", () => {
50
+ let line =
51
+ warnFor(~view="Orders", ~ownerField=Some("customerId"), ~elevated=[])
52
+ ->Array.get(0)
53
+ ->Option.getOr("")
54
+ expect((
55
+ line->String.includes("Orders"),
56
+ line->String.includes("customerId"),
57
+ line->String.includes("only their own rows"),
58
+ ))->toEqual((true, true, true))
59
+ })
60
+
61
+ testSync("a view with no owner field is silent", () =>
62
+ expect(warnFor(~view="Products", ~ownerField=None, ~elevated=[])->Array.length)->toBe(0)
63
+ )
64
+
65
+ testSync("a configured deployment is silent", () =>
66
+ expect(
67
+ warnFor(~view="Orders", ~ownerField=Some("customerId"), ~elevated=["Admin"])->Array.length,
68
+ )->toBe(0)
69
+ )
70
+
71
+ // Registration runs more than once per process — tests build several
72
+ // platforms, a deploy walks every plugin. A line repeated per construction is
73
+ // one people learn to scroll past.
74
+ testSync("the same view warns once, not once per registration", () => {
75
+ OwnerScopeDiagnostics.resetWarnings()
76
+ Reventless.OwnerScope.setElevatedGroups([])
77
+ let lines = capture(() => {
78
+ OwnerScopeDiagnostics.warnIfNoElevatedGroups(
79
+ ~comp="Test",
80
+ ~view="Orders",
81
+ ~ownerField=Some("customerId"),
82
+ )
83
+ OwnerScopeDiagnostics.warnIfNoElevatedGroups(
84
+ ~comp="Test",
85
+ ~view="Orders",
86
+ ~ownerField=Some("customerId"),
87
+ )
88
+ })
89
+ expect(lines->Array.length)->toBe(1)
90
+ })
91
+
92
+ testSync("a second view gets its own warning", () => {
93
+ OwnerScopeDiagnostics.resetWarnings()
94
+ Reventless.OwnerScope.setElevatedGroups([])
95
+ let lines = capture(() => {
96
+ OwnerScopeDiagnostics.warnIfNoElevatedGroups(
97
+ ~comp="Test",
98
+ ~view="Orders",
99
+ ~ownerField=Some("customerId"),
100
+ )
101
+ OwnerScopeDiagnostics.warnIfNoElevatedGroups(
102
+ ~comp="Test",
103
+ ~view="Invoices",
104
+ ~ownerField=Some("payerId"),
105
+ )
106
+ })
107
+ expect(lines->Array.length)->toBe(2)
108
+ })
109
+ })
@@ -0,0 +1,91 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
5
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
6
+ import * as OwnerScopeDiagnostics$ReventlessCore from "../../src/components/Api/OwnerScopeDiagnostics.res.mjs";
7
+
8
+ process.env["LOG_LEVEL"] = "warn";
9
+
10
+ function capture(fn) {
11
+ let lines = [];
12
+ let original = console.log;
13
+ console.log = s => {
14
+ lines.push(s);
15
+ };
16
+ let result;
17
+ try {
18
+ fn();
19
+ result = {
20
+ TAG: "Ok",
21
+ _0: undefined
22
+ };
23
+ } catch (raw_e) {
24
+ let e = Primitive_exceptions.internalToException(raw_e);
25
+ result = {
26
+ TAG: "Error",
27
+ _0: e
28
+ };
29
+ }
30
+ console.log = original;
31
+ if (result.TAG === "Ok") {
32
+ return lines;
33
+ }
34
+ throw result._0;
35
+ }
36
+
37
+ function warnFor(view, ownerField, elevated) {
38
+ OwnerScopeDiagnostics$ReventlessCore.resetWarnings();
39
+ OwnerScope$Reventless.setElevatedGroups(elevated);
40
+ let lines = capture(() => OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("Test", view, ownerField));
41
+ OwnerScope$Reventless.setElevatedGroups([]);
42
+ return lines;
43
+ }
44
+
45
+ globalThis.describe("OwnerScopeDiagnostics:", () => {
46
+ globalThis.test("an owner-scoped view with no elevated groups warns", () => {
47
+ globalThis.expect(warnFor("Orders", "customerId", []).length).toBe(1);
48
+ });
49
+ globalThis.test("the warning names the view, the field, and what will happen", () => {
50
+ let line = Stdlib_Option.getOr(warnFor("Orders", "customerId", [])[0], "");
51
+ globalThis.expect([
52
+ line.includes("Orders"),
53
+ line.includes("customerId"),
54
+ line.includes("only their own rows")
55
+ ]).toEqual([
56
+ true,
57
+ true,
58
+ true
59
+ ]);
60
+ });
61
+ globalThis.test("a view with no owner field is silent", () => {
62
+ globalThis.expect(warnFor("Products", undefined, []).length).toBe(0);
63
+ });
64
+ globalThis.test("a configured deployment is silent", () => {
65
+ globalThis.expect(warnFor("Orders", "customerId", ["Admin"]).length).toBe(0);
66
+ });
67
+ globalThis.test("the same view warns once, not once per registration", () => {
68
+ OwnerScopeDiagnostics$ReventlessCore.resetWarnings();
69
+ OwnerScope$Reventless.setElevatedGroups([]);
70
+ let lines = capture(() => {
71
+ OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("Test", "Orders", "customerId");
72
+ OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("Test", "Orders", "customerId");
73
+ });
74
+ globalThis.expect(lines.length).toBe(1);
75
+ });
76
+ globalThis.test("a second view gets its own warning", () => {
77
+ OwnerScopeDiagnostics$ReventlessCore.resetWarnings();
78
+ OwnerScope$Reventless.setElevatedGroups([]);
79
+ let lines = capture(() => {
80
+ OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("Test", "Orders", "customerId");
81
+ OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("Test", "Invoices", "payerId");
82
+ });
83
+ globalThis.expect(lines.length).toBe(2);
84
+ });
85
+ });
86
+
87
+ export {
88
+ capture,
89
+ warnFor,
90
+ }
91
+ /* Not a pure module */
@@ -73,6 +73,51 @@ describe("SuryToJsonSchema:", () => {
73
73
  })
74
74
  })
75
75
 
76
+ describe("deriveObjectSchema with an owner field:", () => {
77
+ let ownerOf = (json, name) =>
78
+ getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-owner"))
79
+
80
+ let json = SuryToJsonSchema.deriveObjectSchema(
81
+ S.schema(s =>
82
+ {
83
+ "customerId": s.matches(Reventless.Owner.string),
84
+ "note": s.matches(S.string),
85
+ }
86
+ )->S.castToUnknown,
87
+ )
88
+
89
+ testSync("the marked field carries x-reventless-owner", () =>
90
+ expect(ownerOf(json, "customerId"))->toEqual(Some(JSON.Encode.bool(true)))
91
+ )
92
+
93
+ // The control: ownership is opt-in, and an unmarked string must stay one.
94
+ // Without this, a bug that marked every field would still pass above.
95
+ testSync("an unmarked string field carries nothing", () =>
96
+ expect(ownerOf(json, "note"))->toBe(None)
97
+ )
98
+
99
+ testSync("the field is still a plain string on the wire", () =>
100
+ expect(
101
+ getPropertyOf(json, "customerId")->Option.flatMap(s => getProperty(s, "type")),
102
+ )->toEqual(Some(JSON.Encode.string("string")))
103
+ )
104
+
105
+ // An optional field keeps its marker inside the union wrapper. A reader that
106
+ // only inspects the outer schema answers "not the owner" here — which for a
107
+ // predicate that scopes reads means an unscoped view, silently. This is the
108
+ // case `Owner.isFieldOwner` exists for.
109
+ testSync("an optional owner field is still recognised", () => {
110
+ let optJson = SuryToJsonSchema.deriveObjectSchema(
111
+ S.schema(s =>
112
+ {
113
+ "customerId": s.matches(S.option(Reventless.Owner.string)),
114
+ }
115
+ )->S.castToUnknown,
116
+ )
117
+ expect(ownerOf(optJson, "customerId"))->toEqual(Some(JSON.Encode.bool(true)))
118
+ })
119
+ })
120
+
76
121
  describe("deriveObjectSchema with stateAnnotations metadata:", () => {
77
122
  let withSpec = (schema, spec) =>
78
123
  schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
@@ -6,6 +6,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Email$Reventless from "@reventlessdev/reventless-spec/src/semantic/Email.res.mjs";
8
8
  import * as Money$Reventless from "@reventlessdev/reventless-spec/src/semantic/Money.res.mjs";
9
+ import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
9
10
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
10
11
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
11
12
  import * as Percent$Reventless from "@reventlessdev/reventless-spec/src/semantic/Percent.res.mjs";
@@ -106,6 +107,28 @@ globalThis.describe("SuryToJsonSchema:", () => {
106
107
  globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(json, "name"), s => getProperty(s, "format"))).toBe(undefined);
107
108
  });
108
109
  });
110
+ globalThis.describe("deriveObjectSchema with an owner field:", () => {
111
+ let ownerOf = (json, name) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, "x-reventless-owner"));
112
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(S.schema(s => ({
113
+ customerId: s.m(Owner$Reventless.string),
114
+ note: s.m(S.string)
115
+ })));
116
+ globalThis.test("the marked field carries x-reventless-owner", () => {
117
+ globalThis.expect(ownerOf(json, "customerId")).toEqual(true);
118
+ });
119
+ globalThis.test("an unmarked string field carries nothing", () => {
120
+ globalThis.expect(ownerOf(json, "note")).toBe(undefined);
121
+ });
122
+ globalThis.test("the field is still a plain string on the wire", () => {
123
+ globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(json, "customerId"), s => getProperty(s, "type"))).toEqual("string");
124
+ });
125
+ globalThis.test("an optional owner field is still recognised", () => {
126
+ let optJson = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(S.schema(s => ({
127
+ customerId: s.m(S.option(Owner$Reventless.string))
128
+ })));
129
+ globalThis.expect(ownerOf(optJson, "customerId")).toEqual(true);
130
+ });
131
+ });
109
132
  globalThis.describe("deriveObjectSchema with stateAnnotations metadata:", () => {
110
133
  let withSpec = (schema, spec) => S.Metadata.set(schema, StateAnnotations$Reventless.stateAnnotationsId, spec);
111
134
  globalThis.test("emits x-reventless-id on field listed in ids", () => {