@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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
|
|
5
|
+
function encode(typeName, localId) {
|
|
6
|
+
return btoa(typeName + `:` + localId);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function decode(globalId) {
|
|
10
|
+
try {
|
|
11
|
+
let decoded = atob(globalId);
|
|
12
|
+
let idx = decoded.indexOf(":");
|
|
13
|
+
if (idx > 0) {
|
|
14
|
+
return [
|
|
15
|
+
decoded.slice(0, idx),
|
|
16
|
+
decoded.slice(idx + 1 | 0, decoded.length)
|
|
17
|
+
];
|
|
18
|
+
} else {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
} catch (exn) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toLocalId(id) {
|
|
27
|
+
return Stdlib_Option.map(decode(id), param => param[1]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function alternateKey(id) {
|
|
31
|
+
let localId = toLocalId(id);
|
|
32
|
+
if (localId !== undefined && localId !== id) {
|
|
33
|
+
return localId;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
encode,
|
|
39
|
+
decode,
|
|
40
|
+
toLocalId,
|
|
41
|
+
alternateKey,
|
|
42
|
+
}
|
|
43
|
+
/* No side effect */
|
|
@@ -62,10 +62,28 @@ let rec fromSchemaType = (
|
|
|
62
62
|
collectedTypes->Array.push(`enum ${name} {\n ${valuesStr}\n}`)
|
|
63
63
|
}
|
|
64
64
|
`${name}${bang}`
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
65
|
+
// A semantic composite is one named type rather than one per field that uses
|
|
66
|
+
// it. The name comes from the semantic rather than the field path, so every
|
|
67
|
+
// plugin emits a byte-identical definition — which is what lets AppSync union
|
|
68
|
+
// the copies from each source API back into one, the way it already does for
|
|
69
|
+
// the `Node` / `PageInfo` / `SortOrder` base types.
|
|
70
|
+
//
|
|
71
|
+
// GraphQL forbids one name serving as both an object and an input, so the
|
|
72
|
+
// input position takes an `Input` suffix. Only these types are suffixed:
|
|
73
|
+
// positionally-named objects can't collide, since their names already carry
|
|
74
|
+
// the command or read model they belong to.
|
|
75
|
+
| Semantic({id}, ObjectRef(name, fields)) if SchemaType.canonicalName(id)->Option.isSome =>
|
|
76
|
+
let typeName = asInput ? name ++ "Input" : name
|
|
77
|
+
if !(seenTypes->Set.has(typeName)) {
|
|
78
|
+
seenTypes->Set.add(typeName)
|
|
79
|
+
let typeDef = objectRefToGraphQL(~asInput, typeName, fields, collectedTypes, seenTypes)
|
|
80
|
+
collectedTypes->Array.push(typeDef)
|
|
81
|
+
}
|
|
82
|
+
`${typeName}${bang}`
|
|
83
|
+
// Every other semantic carries the value's shape, not its meaning: a storage
|
|
84
|
+
// ref is a String on the wire exactly as it is in the event log. The semantic
|
|
85
|
+
// reaches the UI through the field's JSON Schema, which is the channel that
|
|
86
|
+
// can express it.
|
|
69
87
|
| Semantic(_, inner) => fromSchemaType(~required, ~asInput, inner, collectedTypes, seenTypes)
|
|
70
88
|
| Unknown => `String${bang}`
|
|
71
89
|
}
|
|
@@ -187,30 +205,93 @@ let rec scalarOfSchemaType = (st: SchemaType.schemaType): string =>
|
|
|
187
205
|
| _ => "String"
|
|
188
206
|
}
|
|
189
207
|
|
|
190
|
-
|
|
208
|
+
// A `*Id`-suffixed field name, case-sensitively. Deliberately NOT
|
|
209
|
+
// `SchemaType.isIdFieldName`, which lowercases before testing the suffix and so
|
|
210
|
+
// accepts `paid` and `valid` — harmless where it is used, but here it would
|
|
211
|
+
// nominate an ordinary word as a row's key.
|
|
212
|
+
let isKeyFieldName = (name: string): bool =>
|
|
213
|
+
name->String.length > 2 && name->String.endsWith("Id")
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
The field that identifies a row, and which rung answered:
|
|
217
|
+
|
|
218
|
+
- `"annotation"` — the state declares `@id`. Nothing outranks it.
|
|
219
|
+
- `"convention"` — a field named `<singular entity name>Id` exists
|
|
220
|
+
(`Products` → `productId`). A guess, but one that can only fire on a field
|
|
221
|
+
that is actually there.
|
|
222
|
+
- `"sole"` — the state has exactly one `*Id` field, so there is nothing else it
|
|
223
|
+
could be (`AvailableProducts` → `productId`).
|
|
224
|
+
|
|
225
|
+
`None` is the honest answer for a state with several `*Id` fields and no name
|
|
226
|
+
match (`ProductDemand`: `productId` + `categoryId`), or with none at all — those
|
|
227
|
+
need `@id`. Convention outranks sole so a view carrying one foreign key and no
|
|
228
|
+
key of its own is not keyed by the foreign key.
|
|
229
|
+
*/
|
|
230
|
+
let resolveKeyField = (~entityName: string, schema: S.t<unknown>): option<(string, string)> => {
|
|
231
|
+
let declared = switch Reventless.StateAnnotations.getSpec(schema) {
|
|
232
|
+
| Some({ids}) => ids->Array.get(0)
|
|
233
|
+
| None => None
|
|
234
|
+
}
|
|
235
|
+
switch declared {
|
|
236
|
+
| Some(field) => Some((field, "annotation"))
|
|
237
|
+
| None =>
|
|
238
|
+
let candidates =
|
|
239
|
+
SchemaType.fromSuryObject(~typeName="", schema)
|
|
240
|
+
->Option.getOr(Dict.make())
|
|
241
|
+
->Dict.keysToArray
|
|
242
|
+
->Array.filter(isKeyFieldName)
|
|
243
|
+
let singular = entityName->Api_Naming.stripViewSuffix->Api_Naming.singularize
|
|
244
|
+
let conventional =
|
|
245
|
+
singular->String.slice(~start=0, ~end=1)->String.toLowerCase ++
|
|
246
|
+
singular->String.slice(~start=1, ~end=singular->String.length) ++ "Id"
|
|
247
|
+
if candidates->Array.includes(conventional) {
|
|
248
|
+
Some((conventional, "convention"))
|
|
249
|
+
} else if candidates->Array.length == 1 {
|
|
250
|
+
Some((candidates->Array.getUnsafe(0), "sole"))
|
|
251
|
+
} else {
|
|
252
|
+
None
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The component name the key-field convention is read against. `specName` is the
|
|
258
|
+
// read model's own `Spec.name`; without it, `returnTypeName` minus its plugin
|
|
259
|
+
// prefix is the same string (`Catalog_Product` → `Product`).
|
|
260
|
+
let entityNameOf = (entry: ReventlessInfra.Api.querySchemaEntry): string =>
|
|
261
|
+
switch entry.specName {
|
|
262
|
+
| Some(n) => n
|
|
263
|
+
| None =>
|
|
264
|
+
switch entry.returnTypeName->String.lastIndexOf("_") {
|
|
265
|
+
| -1 => entry.returnTypeName
|
|
266
|
+
| i =>
|
|
267
|
+
entry.returnTypeName->String.slice(~start=i + 1, ~end=entry.returnTypeName->String.length)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let deriveServerCapability = (~entityName: string, schema: S.t<unknown>): serverCapability => {
|
|
272
|
+
let fieldTypes = SchemaType.fromSuryObject(~typeName="", schema)->Option.getOr(Dict.make())
|
|
273
|
+
let scalarOf = (fieldName: string): string =>
|
|
274
|
+
fieldTypes->Dict.get(fieldName)->Option.mapOr("String", scalarOfSchemaType)
|
|
275
|
+
|
|
276
|
+
let filterFields: array<filterField> = []
|
|
277
|
+
let sortFields: array<string> = []
|
|
278
|
+
let seenFilter: Set.t<string> = Set.make()
|
|
279
|
+
let seenSort: Set.t<string> = Set.make()
|
|
280
|
+
|
|
281
|
+
let pushFilter = (name, ~range) =>
|
|
282
|
+
if !(seenFilter->Set.has(name)) {
|
|
283
|
+
seenFilter->Set.add(name)
|
|
284
|
+
filterFields->Array.push({name, gqlType: scalarOf(name), range})
|
|
285
|
+
}
|
|
286
|
+
let pushSort = name =>
|
|
287
|
+
if !(seenSort->Set.has(name)) {
|
|
288
|
+
seenSort->Set.add(name)
|
|
289
|
+
sortFields->Array.push(name)
|
|
290
|
+
}
|
|
291
|
+
|
|
191
292
|
switch Reventless.StateAnnotations.getSpec(schema) {
|
|
192
|
-
| None =>
|
|
293
|
+
| None => ()
|
|
193
294
|
| Some(spec) =>
|
|
194
|
-
let fieldTypes = SchemaType.fromSuryObject(~typeName="", schema)->Option.getOr(Dict.make())
|
|
195
|
-
let scalarOf = (fieldName: string): string =>
|
|
196
|
-
fieldTypes->Dict.get(fieldName)->Option.mapOr("String", scalarOfSchemaType)
|
|
197
|
-
|
|
198
|
-
let filterFields: array<filterField> = []
|
|
199
|
-
let sortFields: array<string> = []
|
|
200
|
-
let seenFilter: Set.t<string> = Set.make()
|
|
201
|
-
let seenSort: Set.t<string> = Set.make()
|
|
202
|
-
|
|
203
|
-
let pushFilter = (name, ~range) =>
|
|
204
|
-
if !(seenFilter->Set.has(name)) {
|
|
205
|
-
seenFilter->Set.add(name)
|
|
206
|
-
filterFields->Array.push({name, gqlType: scalarOf(name), range})
|
|
207
|
-
}
|
|
208
|
-
let pushSort = name =>
|
|
209
|
-
if !(seenSort->Set.has(name)) {
|
|
210
|
-
seenSort->Set.add(name)
|
|
211
|
-
sortFields->Array.push(name)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
295
|
spec.ids->Array.forEach(name => {
|
|
215
296
|
pushFilter(name, ~range=false)
|
|
216
297
|
pushSort(name)
|
|
@@ -234,9 +315,21 @@ let deriveServerCapability = (schema: S.t<unknown>): serverCapability => {
|
|
|
234
315
|
// path is needed here.
|
|
235
316
|
spec.scan->Array.forEach(name => pushFilter(name, ~range=false))
|
|
236
317
|
spec.scanSort->Array.forEach(name => pushSort(name))
|
|
318
|
+
}
|
|
237
319
|
|
|
238
|
-
|
|
320
|
+
// A state that declares nothing structural used to land here with an empty
|
|
321
|
+
// capability — no per-field filter and no order-by at all, so every narrowing
|
|
322
|
+
// a client asked for happened client-side over one page. Its key is knowable
|
|
323
|
+
// without the annotation in the common cases; take it. Pushed last, and both
|
|
324
|
+
// pushes dedupe, so a declared `@id` keeps its position and this is a no-op.
|
|
325
|
+
switch resolveKeyField(~entityName, schema) {
|
|
326
|
+
| Some((field, _rung)) =>
|
|
327
|
+
pushFilter(field, ~range=false)
|
|
328
|
+
pushSort(field)
|
|
329
|
+
| None => ()
|
|
239
330
|
}
|
|
331
|
+
|
|
332
|
+
{filterFields, sortFields}
|
|
240
333
|
}
|
|
241
334
|
|
|
242
335
|
// Returns one warning per `@scanSort` field that is NOT also a sort key of the
|
|
@@ -564,7 +657,7 @@ let generate = (
|
|
|
564
657
|
deriveConnectionTypes(~singularTypeName=entry.returnTypeName)
|
|
565
658
|
->Array.forEach(t => types->Array.push(t))
|
|
566
659
|
}
|
|
567
|
-
let capability = deriveServerCapability(entry.stateSchema)
|
|
660
|
+
let capability = deriveServerCapability(~entityName=entityNameOf(entry), entry.stateSchema)
|
|
568
661
|
let connectionFilterTypeName = entry.returnTypeName ++ "Filter"
|
|
569
662
|
if !(seenTypes->Set.has(connectionFilterTypeName)) {
|
|
570
663
|
seenTypes->Set.add(connectionFilterTypeName)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
|
|
6
|
+
import * as Api_Naming$ReventlessCore from "./Api_Naming.res.mjs";
|
|
6
7
|
import * as SchemaType$ReventlessCore from "./SchemaType.res.mjs";
|
|
7
8
|
import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
|
|
8
9
|
import * as GraphQL_Stitcher$ReventlessCore from "./GraphQL_Stitcher.res.mjs";
|
|
@@ -72,7 +73,20 @@ function fromSchemaType(_required, _asInputOpt, _st, collectedTypes, seenTypes)
|
|
|
72
73
|
}
|
|
73
74
|
return name$1 + bang;
|
|
74
75
|
case "Semantic" :
|
|
75
|
-
|
|
76
|
+
let inner = st._1;
|
|
77
|
+
if (typeof inner === "object" && inner.TAG === "ObjectRef") {
|
|
78
|
+
let name$2 = inner._0;
|
|
79
|
+
if (Stdlib_Option.isSome(SchemaType$ReventlessCore.canonicalName(st._0.id))) {
|
|
80
|
+
let typeName = asInput ? name$2 + "Input" : name$2;
|
|
81
|
+
if (!seenTypes.has(typeName)) {
|
|
82
|
+
seenTypes.add(typeName);
|
|
83
|
+
let typeDef$1 = objectRefToGraphQL(asInput, typeName, inner._1, collectedTypes, seenTypes);
|
|
84
|
+
collectedTypes.push(typeDef$1);
|
|
85
|
+
}
|
|
86
|
+
return typeName + bang;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
_st = inner;
|
|
76
90
|
_asInputOpt = asInput;
|
|
77
91
|
continue;
|
|
78
92
|
}
|
|
@@ -172,11 +186,55 @@ function scalarOfSchemaType(_st) {
|
|
|
172
186
|
};
|
|
173
187
|
}
|
|
174
188
|
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
189
|
+
function isKeyFieldName(name) {
|
|
190
|
+
if (name.length > 2) {
|
|
191
|
+
return name.endsWith("Id");
|
|
192
|
+
} else {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveKeyField(entityName, schema) {
|
|
198
|
+
let match = StateAnnotations$Reventless.getSpec(schema);
|
|
199
|
+
let declared = match !== undefined ? match.ids[0] : undefined;
|
|
200
|
+
if (declared !== undefined) {
|
|
201
|
+
return [
|
|
202
|
+
declared,
|
|
203
|
+
"annotation"
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
let candidates = Object.keys(Stdlib_Option.getOr(SchemaType$ReventlessCore.fromSuryObject("", schema), {})).filter(isKeyFieldName);
|
|
207
|
+
let singular = Api_Naming$ReventlessCore.singularize(Api_Naming$ReventlessCore.stripViewSuffix(entityName));
|
|
208
|
+
let conventional = singular.slice(0, 1).toLowerCase() + singular.slice(1, singular.length) + "Id";
|
|
209
|
+
if (candidates.includes(conventional)) {
|
|
210
|
+
return [
|
|
211
|
+
conventional,
|
|
212
|
+
"convention"
|
|
213
|
+
];
|
|
214
|
+
} else if (candidates.length === 1) {
|
|
215
|
+
return [
|
|
216
|
+
candidates[0],
|
|
217
|
+
"sole"
|
|
218
|
+
];
|
|
219
|
+
} else {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function entityNameOf(entry) {
|
|
225
|
+
let n = entry.specName;
|
|
226
|
+
if (n !== undefined) {
|
|
227
|
+
return n;
|
|
228
|
+
}
|
|
229
|
+
let i = entry.returnTypeName.lastIndexOf("_");
|
|
230
|
+
if (i !== -1) {
|
|
231
|
+
return entry.returnTypeName.slice(i + 1 | 0, entry.returnTypeName.length);
|
|
232
|
+
} else {
|
|
233
|
+
return entry.returnTypeName;
|
|
179
234
|
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function deriveServerCapability(entityName, schema) {
|
|
180
238
|
let fieldTypes = Stdlib_Option.getOr(SchemaType$ReventlessCore.fromSuryObject("", schema), {});
|
|
181
239
|
let scalarOf = fieldName => Stdlib_Option.mapOr(fieldTypes[fieldName], "String", scalarOfSchemaType);
|
|
182
240
|
let filterFields = [];
|
|
@@ -201,26 +259,35 @@ function deriveServerCapability(schema) {
|
|
|
201
259
|
return;
|
|
202
260
|
}
|
|
203
261
|
};
|
|
204
|
-
spec.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
pushFilter(name,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
262
|
+
let spec = StateAnnotations$Reventless.getSpec(schema);
|
|
263
|
+
if (spec !== undefined) {
|
|
264
|
+
spec.ids.forEach(name => {
|
|
265
|
+
pushFilter(name, false);
|
|
266
|
+
pushSort(name);
|
|
267
|
+
});
|
|
268
|
+
spec.compositeIds.forEach(name => pushFilter(name, false));
|
|
269
|
+
spec.subIds.forEach(name => {
|
|
270
|
+
pushFilter(name, true);
|
|
271
|
+
pushSort(name);
|
|
272
|
+
});
|
|
273
|
+
spec.compositeSubIds.forEach(name => {
|
|
274
|
+
pushFilter(name, true);
|
|
275
|
+
pushSort(name);
|
|
276
|
+
});
|
|
277
|
+
spec.indexes.forEach(param => {
|
|
278
|
+
let name = param[0];
|
|
279
|
+
pushFilter(name, false);
|
|
280
|
+
pushSort(name);
|
|
281
|
+
});
|
|
282
|
+
spec.scan.forEach(name => pushFilter(name, false));
|
|
283
|
+
spec.scanSort.forEach(pushSort);
|
|
284
|
+
}
|
|
285
|
+
let match = resolveKeyField(entityName, schema);
|
|
286
|
+
if (match !== undefined) {
|
|
287
|
+
let field = match[0];
|
|
288
|
+
pushFilter(field, false);
|
|
289
|
+
pushSort(field);
|
|
290
|
+
}
|
|
224
291
|
return {
|
|
225
292
|
filterFields: filterFields,
|
|
226
293
|
sortFields: sortFields
|
|
@@ -435,7 +502,7 @@ function generate(mutationEntries, queryEntries) {
|
|
|
435
502
|
types.push(t);
|
|
436
503
|
});
|
|
437
504
|
}
|
|
438
|
-
let capability = deriveServerCapability(entry.stateSchema);
|
|
505
|
+
let capability = deriveServerCapability(entityNameOf(entry), entry.stateSchema);
|
|
439
506
|
let connectionFilterTypeName = entry.returnTypeName + "Filter";
|
|
440
507
|
if (!seenTypes.has(connectionFilterTypeName)) {
|
|
441
508
|
seenTypes.add(connectionFilterTypeName);
|
|
@@ -491,6 +558,9 @@ export {
|
|
|
491
558
|
deriveSubIdFilterType,
|
|
492
559
|
emptyCapability,
|
|
493
560
|
scalarOfSchemaType,
|
|
561
|
+
isKeyFieldName,
|
|
562
|
+
resolveKeyField,
|
|
563
|
+
entityNameOf,
|
|
494
564
|
deriveServerCapability,
|
|
495
565
|
validateScanSortAlignment,
|
|
496
566
|
deriveConnectionFilterType,
|
|
@@ -503,4 +573,4 @@ export {
|
|
|
503
573
|
deriveMutationFieldFromObject,
|
|
504
574
|
generate,
|
|
505
575
|
}
|
|
506
|
-
/*
|
|
576
|
+
/* Api_Naming-ReventlessCore Not a pure module */
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The one thing a deployment can get wrong about `@owner` without anything
|
|
2
|
+
// failing.
|
|
3
|
+
//
|
|
4
|
+
// `OwnerScope.elevatedGroups` defaults to `[]`, which is the safe default: it
|
|
5
|
+
// scopes everybody rather than exempting somebody by accident. But a deployment
|
|
6
|
+
// that never calls `setElevatedGroups` gets that default silently, and then
|
|
7
|
+
// *administrators are scoped too* — an operator opening an owner-scoped view
|
|
8
|
+
// sees only the rows they happen to own.
|
|
9
|
+
//
|
|
10
|
+
// Nothing errors, and it is invisible from outside: an operator whose elevated
|
|
11
|
+
// list is empty and an operator who genuinely owns nothing produce the identical
|
|
12
|
+
// empty page. That is the same unfalsifiability that makes a denied read
|
|
13
|
+
// indistinguishable from an empty one, arriving by a different route — so it is
|
|
14
|
+
// worth a line at registration rather than a support ticket later.
|
|
15
|
+
//
|
|
16
|
+
// Lives in core rather than in each adapter so the rule and its wording are
|
|
17
|
+
// stated once. Local and AWS both register owner-scoped views; two copies of
|
|
18
|
+
// this check would be two chances to word the consequence differently, or to
|
|
19
|
+
// fix one and not the other.
|
|
20
|
+
|
|
21
|
+
let log = Logger.fromEnv()
|
|
22
|
+
|
|
23
|
+
// Warned once per read model. Resolver registration can run more than once in a
|
|
24
|
+
// process (tests build several platforms; a deploy walks every plugin), and a
|
|
25
|
+
// warning repeated per construction is a warning people learn to scroll past.
|
|
26
|
+
let warned: Set.t<string> = Set.make()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
Warn when `view` declares an `@owner` field but no group is exempt from scoping.
|
|
30
|
+
|
|
31
|
+
`~comp` names the calling adapter so the line points at the transport it came
|
|
32
|
+
from. Silent when the view declares no owner, and silent once the deployment has
|
|
33
|
+
configured any elevated group — this is a "you probably forgot" signal, not a
|
|
34
|
+
policy about which groups are correct.
|
|
35
|
+
*/
|
|
36
|
+
let warnIfNoElevatedGroups = (~comp: string, ~view: string, ~ownerField: option<string>) =>
|
|
37
|
+
switch ownerField {
|
|
38
|
+
| Some(field) if Reventless.OwnerScope.elevatedGroups()->Array.length == 0 =>
|
|
39
|
+
if !(warned->Set.has(view)) {
|
|
40
|
+
warned->Set.add(view)
|
|
41
|
+
log.warn(
|
|
42
|
+
~comp,
|
|
43
|
+
`${view}: "${field}" is declared @owner, but no elevated groups are configured. ` ++
|
|
44
|
+
"Every caller — administrators included — will see only their own rows. " ++
|
|
45
|
+
"Call OwnerScope.setElevatedGroups([...]) before components are built, or set " ++
|
|
46
|
+
"REVENTLESS_ELEVATED_GROUPS — the environment is what reaches a deployment's " ++
|
|
47
|
+
"separate function runtimes, which a call in the deploy program does not.",
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
| _ => ()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Test seam: forget what has already been warned about. */
|
|
54
|
+
let resetWarnings = () => warned->Set.clear
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
4
|
+
import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
|
|
5
|
+
|
|
6
|
+
let log = Logger$ReventlessCore.fromEnv();
|
|
7
|
+
|
|
8
|
+
let warned = new Set();
|
|
9
|
+
|
|
10
|
+
function warnIfNoElevatedGroups(comp, view, ownerField) {
|
|
11
|
+
if (ownerField !== undefined && OwnerScope$Reventless.elevatedGroups().length === 0 && !warned.has(view)) {
|
|
12
|
+
warned.add(view);
|
|
13
|
+
return log.warn(comp, undefined, view + `: "` + ownerField + `" is declared @owner, but no elevated groups are configured. ` + "Every caller — administrators included — will see only their own rows. Call OwnerScope.setElevatedGroups([...]) before components are built, or set REVENTLESS_ELEVATED_GROUPS — the environment is what reaches a deployment's separate function runtimes, which a call in the deploy program does not.");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resetWarnings() {
|
|
18
|
+
warned.clear();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
log,
|
|
23
|
+
warned,
|
|
24
|
+
warnIfNoElevatedGroups,
|
|
25
|
+
resetWarnings,
|
|
26
|
+
}
|
|
27
|
+
/* log Not a pure module */
|
|
@@ -71,15 +71,32 @@ let buildConnection = (
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// Compute the Relay connection response (edges + pageInfo) for `items` under the
|
|
74
|
-
// GraphQL args. `decodeLocalId` maps a possibly-Relay-encoded id to its local id
|
|
75
|
-
//
|
|
76
|
-
//
|
|
74
|
+
// GraphQL args. `decodeLocalId` maps a possibly-Relay-encoded id to its local id;
|
|
75
|
+
// it defaults to the shared rule so every caller applies the same one — the AWS
|
|
76
|
+
// Lambda used to pass a no-op here, which silently made `filter.ids` reject a
|
|
77
|
+
// global id on that provider only. Everything else is pure over the item array.
|
|
78
|
+
// `ownerScope` is `(fieldName, requiredValue)`, and it is deliberately NOT read
|
|
79
|
+
// out of `argsDict`. Two reasons, both fatal to the obvious alternative of
|
|
80
|
+
// injecting `filter.<owner>Eq`:
|
|
81
|
+
//
|
|
82
|
+
// - the owner field usually is not in `capability.filterFields` at all (it is
|
|
83
|
+
// admitted only by `@id` / `@index` / `@scan` and friends), so there is no
|
|
84
|
+
// filter input to inject into; and
|
|
85
|
+
// - `filter` is the caller's own argument. A predicate that decides what the
|
|
86
|
+
// caller may see must arrive on a channel the caller cannot name or
|
|
87
|
+
// overwrite.
|
|
88
|
+
//
|
|
89
|
+
// It joins the same `filtered` pass as everything else, which puts it BEFORE
|
|
90
|
+
// ordering and pagination. Narrowing a materialised page afterwards would return
|
|
91
|
+
// short pages with valid-looking cursors — a bug that reads as "the list
|
|
92
|
+
// sometimes ends early" rather than as an access-control fault.
|
|
77
93
|
let run = (
|
|
78
94
|
~items: array<JSON.t>,
|
|
79
95
|
~argsDict: Dict.t<JSON.t>,
|
|
80
96
|
~capability: GraphQL_FragmentGenerator.serverCapability,
|
|
81
97
|
~labelField: string,
|
|
82
|
-
~decodeLocalId: string => option<string
|
|
98
|
+
~decodeLocalId: string => option<string>=Api_Ids.toLocalId,
|
|
99
|
+
~ownerScope: option<(string, string)>=?,
|
|
83
100
|
): JSON.t => {
|
|
84
101
|
let filterDict =
|
|
85
102
|
argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
|
|
@@ -145,15 +162,30 @@ let run = (
|
|
|
145
162
|
getLabel(item)->String.toLowerCase->String.startsWith(p->String.toLowerCase)
|
|
146
163
|
| _ => true
|
|
147
164
|
}
|
|
165
|
+
// Either id form matches, in both directions: the argument may be a Relay
|
|
166
|
+
// global id while the row carries its storage key (the normal case — every
|
|
167
|
+
// door but `node` reports the storage key), and the row may carry a global id
|
|
168
|
+
// where some caller stamped one. Decoding only the row's id, as this did,
|
|
169
|
+
// covered just the second.
|
|
148
170
|
let passIds = switch ids {
|
|
149
171
|
| Some(idList) if idList->Array.length > 0 =>
|
|
150
172
|
let itemId = getId(item)
|
|
151
173
|
let itemLocalId = decodeLocalId(itemId)
|
|
152
|
-
idList->Array.some(i =>
|
|
174
|
+
idList->Array.some(i =>
|
|
175
|
+
i == itemId || itemLocalId == Some(i) || decodeLocalId(i) == Some(itemId)
|
|
176
|
+
)
|
|
153
177
|
| _ => true
|
|
154
178
|
}
|
|
155
179
|
let passPerField = perFieldChecks->Array.every(check => check(item))
|
|
156
|
-
|
|
180
|
+
// A row that does not state an owner never matches a scoped read. Treating a
|
|
181
|
+
// missing field as "belongs to everyone" would expose exactly the rows that
|
|
182
|
+
// predate the annotation, which are the ones nobody remembers to check.
|
|
183
|
+
let passOwner = switch ownerScope {
|
|
184
|
+
| Some((field, required)) =>
|
|
185
|
+
getFieldString(item, field)->Option.mapOr(false, v => v == required)
|
|
186
|
+
| None => true
|
|
187
|
+
}
|
|
188
|
+
passSearch && passPrefix && passIds && passPerField && passOwner
|
|
157
189
|
})
|
|
158
190
|
|
|
159
191
|
let orderByDict = argsDict->Dict.get("orderBy")->Option.flatMap(JSON.Decode.object)
|
|
@@ -5,6 +5,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
|
5
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
6
|
import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js";
|
|
7
7
|
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
8
|
+
import * as Api_Ids$ReventlessCore from "./Api_Ids.res.mjs";
|
|
8
9
|
|
|
9
10
|
function encodeCursor(value) {
|
|
10
11
|
return btoa(value);
|
|
@@ -47,7 +48,8 @@ function buildConnection(pageItems, hasNextPage, hasPreviousPage, cursorValueOf)
|
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function run(items, argsDict, capability, labelField,
|
|
51
|
+
function run(items, argsDict, capability, labelField, decodeLocalIdOpt, ownerScope) {
|
|
52
|
+
let decodeLocalId = decodeLocalIdOpt !== undefined ? decodeLocalIdOpt : Api_Ids$ReventlessCore.toLocalId;
|
|
51
53
|
let filterDict = Stdlib_Option.getOr(Stdlib_Option.flatMap(argsDict["filter"], Stdlib_JSON.Decode.object), {});
|
|
52
54
|
let search = Stdlib_Option.flatMap(filterDict["search"], Stdlib_JSON.Decode.string);
|
|
53
55
|
let searchPrefix = Stdlib_Option.flatMap(filterDict["searchPrefix"], Stdlib_JSON.Decode.string);
|
|
@@ -83,18 +85,25 @@ function run(items, argsDict, capability, labelField, decodeLocalId) {
|
|
|
83
85
|
let itemId = getId(item);
|
|
84
86
|
let itemLocalId = decodeLocalId(itemId);
|
|
85
87
|
passIds = ids.some(i => {
|
|
86
|
-
if (i === itemId) {
|
|
88
|
+
if (i === itemId || Primitive_object.equal(itemLocalId, i)) {
|
|
87
89
|
return true;
|
|
88
90
|
} else {
|
|
89
|
-
return Primitive_object.equal(
|
|
91
|
+
return Primitive_object.equal(decodeLocalId(i), itemId);
|
|
90
92
|
}
|
|
91
93
|
});
|
|
92
94
|
} else {
|
|
93
95
|
passIds = true;
|
|
94
96
|
}
|
|
95
97
|
let passPerField = perFieldChecks.every(check => check(item));
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
let passOwner;
|
|
99
|
+
if (ownerScope !== undefined) {
|
|
100
|
+
let required = ownerScope[1];
|
|
101
|
+
passOwner = Stdlib_Option.mapOr(getFieldString(item, ownerScope[0]), false, v => v === required);
|
|
102
|
+
} else {
|
|
103
|
+
passOwner = true;
|
|
104
|
+
}
|
|
105
|
+
if (passSearch && passPrefix && passIds && passPerField) {
|
|
106
|
+
return passOwner;
|
|
98
107
|
} else {
|
|
99
108
|
return false;
|
|
100
109
|
}
|
|
@@ -32,8 +32,34 @@ let isIdsFieldName = (name: string): bool => {
|
|
|
32
32
|
len > 3 && String.endsWith(lower, "ids")
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
The GraphQL type name a semantic **composite** is emitted under.
|
|
37
|
+
|
|
38
|
+
The branded scalars are absent on purpose: they refine a `string` or a number
|
|
39
|
+
without changing its shape, so each is already a `String` or a `Float` in GraphQL
|
|
40
|
+
and has no definition that could be duplicated. Only the semantics that turn a
|
|
41
|
+
field into an object or an enum need a name of their own — without one, the same
|
|
42
|
+
type is emitted once per *field* that uses it, under the field path that reached
|
|
43
|
+
it (`Catalog_AddProductPriceCurrency`, `Ordering_SyncNewProductPriceCurrency`, …
|
|
44
|
+
six copies of ISO 4217 in one example schema).
|
|
45
|
+
|
|
46
|
+
Naming them is what makes the definitions identical across plugins, which is the
|
|
47
|
+
property merged-API composition needs: AppSync unions same-named types from
|
|
48
|
+
different source APIs, and a union of identical definitions is that definition.
|
|
49
|
+
The `Node` / `PageInfo` / `SortOrder` base types already rely on exactly this.
|
|
50
|
+
*/
|
|
51
|
+
let semanticCompositeNames = [
|
|
52
|
+
(Reventless.Semantic.Id.money, "Money"),
|
|
53
|
+
(Reventless.Semantic.Id.dateRange, "DateRange"),
|
|
54
|
+
(Reventless.Semantic.Id.geoPoint, "GeoPoint"),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
let canonicalName = (id: string): option<string> =>
|
|
58
|
+
semanticCompositeNames
|
|
59
|
+
->Array.find(((semanticId, _)) => semanticId == id)
|
|
60
|
+
->Option.map(((_, name)) => name)
|
|
61
|
+
|
|
35
62
|
let rec fromSury = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): schemaType => {
|
|
36
|
-
let shape = shapeOf(~parentName, ~fieldName, schema)
|
|
37
63
|
// Semantics the IR already has a dedicated shape for keep it: `dateTime` and
|
|
38
64
|
// `reference` are read below via `isDateTime` / `getTarget`, both of which now
|
|
39
65
|
// consult the generic marker, and their `format` output is a published
|
|
@@ -42,8 +68,16 @@ let rec fromSury = (~parentName: string, ~fieldName: string, schema: S.t<unknown
|
|
|
42
68
|
switch Reventless.Semantic.get(schema) {
|
|
43
69
|
| Some({id} as sem)
|
|
44
70
|
if id !== Reventless.Semantic.Id.dateTime && id !== Reventless.Semantic.Id.reference =>
|
|
71
|
+
// A composite is one type wherever it appears, so its shape is walked under
|
|
72
|
+
// its own name rather than under the field path that reached it. That
|
|
73
|
+
// renames what is nested inside it too — `Money`'s currency enum becomes
|
|
74
|
+
// `MoneyCurrency` for every field, rather than one enum per price field.
|
|
75
|
+
let shape = switch canonicalName(id) {
|
|
76
|
+
| Some(name) => shapeOf(~parentName=name, ~fieldName="", schema)
|
|
77
|
+
| None => shapeOf(~parentName, ~fieldName, schema)
|
|
78
|
+
}
|
|
45
79
|
Semantic(sem, shape)
|
|
46
|
-
| _ =>
|
|
80
|
+
| _ => shapeOf(~parentName, ~fieldName, schema)
|
|
47
81
|
}
|
|
48
82
|
}
|
|
49
83
|
|