@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.
- package/CHANGELOG.md +36 -0
- package/package.json +6 -6
- package/src/admin/Platform_Admin_Structure.res +16 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +14 -3
- package/src/admin/Platform_BakedManifest.res +154 -0
- package/src/admin/Platform_BakedManifest.res.mjs +156 -0
- package/src/admin/Platform_ComponentDefinitionsApi.res +19 -2
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +30 -2
- package/src/components/Api/Api_Ids.res +63 -0
- package/src/components/Api/Api_Ids.res.mjs +43 -0
- package/src/components/Api/GraphQL_FragmentGenerator.res +121 -28
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +97 -27
- package/src/components/Api/OwnerScopeDiagnostics.res +54 -0
- package/src/components/Api/OwnerScopeDiagnostics.res.mjs +27 -0
- package/src/components/Api/QueryDbListQuery.res +38 -6
- package/src/components/Api/QueryDbListQuery.res.mjs +14 -5
- package/src/components/Api/SchemaType.res +36 -2
- package/src/components/Api/SchemaType.res.mjs +31 -10
- package/src/components/Api/SuryToJsonSchema.res +19 -0
- package/src/components/Api/SuryToJsonSchema.res.mjs +18 -4
- package/src/components/CommandGenerator/CommandGenerator_Callback.res +48 -0
- package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +27 -1
- package/src/plugin/component/Plugin_Structure.res +78 -3
- package/src/plugin/component/Plugin_Structure.res.mjs +38 -9
- package/tests/admin/Platform_BakedManifestTest.res +244 -0
- package/tests/admin/Platform_BakedManifestTest.res.mjs +294 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +73 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +135 -6
- package/tests/admin/Platform_PluginStructuresApiTest.res +12 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +27 -2
- package/tests/api/Api_IdsTest.res +143 -0
- package/tests/api/Api_IdsTest.res.mjs +97 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res +169 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +179 -0
- package/tests/api/OwnerScopeDiagnosticsTest.res +109 -0
- package/tests/api/OwnerScopeDiagnosticsTest.res.mjs +91 -0
- package/tests/api/SuryToJsonSchemaTest.res +45 -0
- package/tests/api/SuryToJsonSchemaTest.res.mjs +23 -0
- package/tests/commandgenerator/OwnerStampingTest.res +174 -0
- package/tests/commandgenerator/OwnerStampingTest.res.mjs +195 -0
- package/tests/plugin/PluginStructureAccessTest.res +110 -0
- package/tests/plugin/PluginStructureAccessTest.res.mjs +144 -0
- package/tests/plugin/PluginStructureTest.res +90 -0
- package/tests/plugin/PluginStructureTest.res.mjs +86 -8
- package/tests/plugin/StateChangeSlice/PsGatedCommands.res +28 -0
- package/tests/plugin/StateChangeSlice/PsGatedCommands.res.mjs +84 -0
- package/tests/plugin/StateViewSlice/PsCategoriesView.res +17 -0
- package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +57 -0
- package/tests/plugin/StateViewSlice/PsGatedView.res +15 -0
- package/tests/plugin/StateViewSlice/PsGatedView.res.mjs +60 -0
- 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", () => {
|