@reventlessdev/reventless-core 3.0.0-alpha.244 → 3.0.0-alpha.246
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 +15 -0
- package/package.json +6 -6
- package/src/components/Api/Api_Naming.res +58 -0
- package/src/components/Api/Api_Naming.res.mjs +44 -0
- package/src/components/Api/GraphQL_FragmentGenerator.res +61 -1
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +44 -3
- package/src/components/Api/GraphQL_SchemaInspector.res.mjs +2 -2
- package/src/components/Dcb/Dcb_Builder.res +3 -0
- package/src/components/Dcb/Dcb_Builder.res.mjs +4 -1
- package/src/components/QueryDb/QueryDb_Operations.res +45 -1
- package/src/components/QueryDb/QueryDb_Operations.res.mjs +26 -4
- package/src/plugin/component/Plugin_Builder.res +2 -0
- package/src/plugin/component/Plugin_Builder.res.mjs +3 -1
- package/tests/api/GraphQL_FragmentGeneratorTest.res +141 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +109 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.246 (2026-08-21)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **core:** make @resolves/[@resolves](https://github.com/resolves)Many work end to end ([4c52957](https://github.com/ReventlessDev/reventless-core/commit/4c5295759dcb4b3eda4f0f4f2c1bc387fed88fcb))
|
|
11
|
+
* **deps:** update sury to 11.0.0-rc.2 to fix unreachable union constructors ([fa5744f](https://github.com/ReventlessDev/reventless-core/commit/fa5744fed8de975e2f14725c856c6e5ce7d04a74))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.245 (2026-08-20)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **core:** report a union value stored without its member type ([3a834b5](https://github.com/ReventlessDev/reventless-core/commit/3a834b51d1d9441f5dfcaebec5fac365063c905e))
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.244 (2026-08-20)
|
|
7
22
|
|
|
8
23
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.246",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"sury": "11.0.0-rc.
|
|
29
|
+
"sury": "11.0.0-rc.2",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
32
32
|
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
|
|
38
38
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
39
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
39
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.149",
|
|
40
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.33",
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.121"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
45
|
-
"sury-ppx": "11.0.0-rc.
|
|
45
|
+
"sury-ppx": "11.0.0-rc.2",
|
|
46
46
|
"@reventlessdev/reventless-ppx": "1.0.0-alpha.72"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
@@ -128,6 +128,64 @@ let queryFieldNamesForReadModel = (~plugin: string, ~name: string, ~connectionSp
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// The GraphQL type a queryable is exposed as, from its spec name alone. Both
|
|
132
|
+
// `queryFieldNamesFor*` above name it this way, so a `@resolves` target reached
|
|
133
|
+
// by spec name lands on the same type whichever kind declares it.
|
|
134
|
+
let returnTypeNameFor = (~plugin: string, ~name: string) =>
|
|
135
|
+
`${plugin}_${singularize(stripViewSuffix(name))}`
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
The cross-table fields `@resolves` / `@resolvesMany` add to a queryable's object
|
|
139
|
+
type, named for the SDL. `plugin` owns the declaring view.
|
|
140
|
+
|
|
141
|
+
A target in another plugin is refused rather than emitted: under merged-API
|
|
142
|
+
composition each plugin's document must be valid standalone, so a field
|
|
143
|
+
returning a type the plugin does not define fails the merge — and on AWS the
|
|
144
|
+
target table's `dynamodb:*` grant is attached to the *declaring* plugin's API
|
|
145
|
+
role, so even a schema that merged would be refused at read time.
|
|
146
|
+
*/
|
|
147
|
+
let resolvedFieldsOfConfig = (
|
|
148
|
+
~plugin: string,
|
|
149
|
+
config: Reventless.ReadModel.config,
|
|
150
|
+
): array<ReventlessInfra.Api.resolvedFieldEntry> => {
|
|
151
|
+
let assertSamePlugin = (~pluginName: option<string>, ~tableName: string, ~fieldName: string) =>
|
|
152
|
+
switch pluginName {
|
|
153
|
+
| Some(p) if p != plugin =>
|
|
154
|
+
JsError.throwWithMessage(
|
|
155
|
+
`Field \`${fieldName}\` resolves to "${tableName}" in plugin "${p}", but cross-plugin ` ++
|
|
156
|
+
`resolvers are not supported: a plugin's GraphQL document must be valid standalone, and ` ++
|
|
157
|
+
`"${p}"'s table is not readable by "${plugin}"'s API role. Query the other view directly, ` ++
|
|
158
|
+
`or project the fields you need into this one.`,
|
|
159
|
+
)
|
|
160
|
+
| _ => ()
|
|
161
|
+
}
|
|
162
|
+
let single = config.idResolvers->Array.map(({source, target}) => {
|
|
163
|
+
let (fieldName, multi) = switch source.resolvedField {
|
|
164
|
+
| Single(f) => (f, false)
|
|
165
|
+
| Multi(f) => (f, true)
|
|
166
|
+
}
|
|
167
|
+
assertSamePlugin(~pluginName=target.pluginName, ~tableName=target.tableName, ~fieldName)
|
|
168
|
+
{
|
|
169
|
+
ReventlessInfra.Api.fieldName,
|
|
170
|
+
typeName: returnTypeNameFor(~plugin, ~name=target.tableName),
|
|
171
|
+
multi,
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
let many = config.idsResolvers->Array.map(({source, target}) => {
|
|
175
|
+
assertSamePlugin(
|
|
176
|
+
~pluginName=target.pluginName,
|
|
177
|
+
~tableName=target.tableName,
|
|
178
|
+
~fieldName=source.resolvedField,
|
|
179
|
+
)
|
|
180
|
+
{
|
|
181
|
+
ReventlessInfra.Api.fieldName: source.resolvedField,
|
|
182
|
+
typeName: returnTypeNameFor(~plugin, ~name=target.tableName),
|
|
183
|
+
multi: true,
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
Array.concat(single, many)
|
|
187
|
+
}
|
|
188
|
+
|
|
131
189
|
let queryFieldNamesForStateView = (~plugin: string, ~viewName: string, ~connectionSpec: bool=true): queryNames => {
|
|
132
190
|
let entity = stripViewSuffix(viewName)
|
|
133
191
|
let singular = singularize(entity)
|
|
@@ -101,6 +101,48 @@ function queryFieldNamesForReadModel(plugin, name, connectionSpecOpt) {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
function returnTypeNameFor(plugin, name) {
|
|
105
|
+
return plugin + `_` + singularize(stripViewSuffix(name));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolvedFieldsOfConfig(plugin, config) {
|
|
109
|
+
let assertSamePlugin = (pluginName, tableName, fieldName) => {
|
|
110
|
+
if (pluginName !== undefined && pluginName !== plugin) {
|
|
111
|
+
return Stdlib_JsError.throwWithMessage(`Field \`` + fieldName + `\` resolves to "` + tableName + `" in plugin "` + pluginName + `", but cross-plugin resolvers are not supported: a plugin's GraphQL document must be valid standalone, and ` + (`"` + pluginName + `"'s table is not readable by "` + plugin + `"'s API role. Query the other view directly, `) + `or project the fields you need into this one.`);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
let single = config.idResolvers.map(param => {
|
|
115
|
+
let target = param.target;
|
|
116
|
+
let f = param.source.resolvedField;
|
|
117
|
+
let match;
|
|
118
|
+
match = f.TAG === "Single" ? [
|
|
119
|
+
f._0,
|
|
120
|
+
false
|
|
121
|
+
] : [
|
|
122
|
+
f._0,
|
|
123
|
+
true
|
|
124
|
+
];
|
|
125
|
+
let fieldName = match[0];
|
|
126
|
+
assertSamePlugin(target.pluginName, target.tableName, fieldName);
|
|
127
|
+
return {
|
|
128
|
+
fieldName: fieldName,
|
|
129
|
+
typeName: returnTypeNameFor(plugin, target.tableName),
|
|
130
|
+
multi: match[1]
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
let many = config.idsResolvers.map(param => {
|
|
134
|
+
let target = param.target;
|
|
135
|
+
let source = param.source;
|
|
136
|
+
assertSamePlugin(target.pluginName, target.tableName, source.resolvedField);
|
|
137
|
+
return {
|
|
138
|
+
fieldName: source.resolvedField,
|
|
139
|
+
typeName: returnTypeNameFor(plugin, target.tableName),
|
|
140
|
+
multi: true
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
return single.concat(many);
|
|
144
|
+
}
|
|
145
|
+
|
|
104
146
|
function queryFieldNamesForStateView(plugin, viewName, connectionSpecOpt) {
|
|
105
147
|
let connectionSpec = connectionSpecOpt !== undefined ? connectionSpecOpt : true;
|
|
106
148
|
let entity = stripViewSuffix(viewName);
|
|
@@ -147,6 +189,8 @@ export {
|
|
|
147
189
|
sliceMutationFieldFor,
|
|
148
190
|
canonicalPlural,
|
|
149
191
|
queryFieldNamesForReadModel,
|
|
192
|
+
returnTypeNameFor,
|
|
193
|
+
resolvedFieldsOfConfig,
|
|
150
194
|
queryFieldNamesForStateView,
|
|
151
195
|
queryFieldNamesForSliceQueryDb,
|
|
152
196
|
adminField,
|
|
@@ -170,10 +170,18 @@ let reportUnclassifiedUnions = (~typeName: string, schema: S.t<unknown>): unit =
|
|
|
170
170
|
)
|
|
171
171
|
)
|
|
172
172
|
|
|
173
|
+
// The SDL line for one `@resolves` / `@resolvesMany` field. Both forms are
|
|
174
|
+
// nullable: the single form has no row when the foreign key names one that was
|
|
175
|
+
// never written, and the batch form drops missing ids rather than padding with
|
|
176
|
+
// nulls, so the list is shorter — never null inside.
|
|
177
|
+
let resolvedFieldSdl = ({fieldName, typeName, multi}: resolvedFieldEntry): string =>
|
|
178
|
+
multi ? ` ${fieldName}: [${typeName}!]` : ` ${fieldName}: ${typeName}`
|
|
179
|
+
|
|
173
180
|
let deriveObjectTypeWithNested = (
|
|
174
181
|
~typeName: string,
|
|
175
182
|
~excludeFields: array<string>=[],
|
|
176
183
|
~includeIdParam: bool=true,
|
|
184
|
+
~resolvedFields: array<resolvedFieldEntry>=[],
|
|
177
185
|
schema: S.t<unknown>,
|
|
178
186
|
): array<string> => {
|
|
179
187
|
reportUnclassifiedUnions(~typeName, schema)
|
|
@@ -219,7 +227,24 @@ let deriveObjectTypeWithNested = (
|
|
|
219
227
|
} else {
|
|
220
228
|
mainType
|
|
221
229
|
}
|
|
222
|
-
|
|
230
|
+
// A cross-table field shares the type with the state record's own fields, so
|
|
231
|
+
// a name already taken would emit the field twice and fail the whole document
|
|
232
|
+
// — for every plugin in the merge, not just this one. Refuse at build time,
|
|
233
|
+
// naming both halves.
|
|
234
|
+
resolvedFields->Array.forEach(({fieldName}) =>
|
|
235
|
+
if filteredFields->Dict.get(fieldName)->Option.isSome || (includeIdParam && fieldName == "id") {
|
|
236
|
+
JsError.throwWithMessage(
|
|
237
|
+
`${typeName}.${fieldName} is declared by @resolves/@resolvesMany and is already a field of the state record. Name the resolved field something the state does not use.`,
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
let mainTypeComplete = if resolvedFields->Array.length == 0 {
|
|
242
|
+
mainTypeWithId
|
|
243
|
+
} else {
|
|
244
|
+
let body = mainTypeWithId->String.slice(~start=0, ~end=mainTypeWithId->String.length - 1)
|
|
245
|
+
body ++ resolvedFields->Array.map(resolvedFieldSdl)->Array.join("\n") ++ "\n}"
|
|
246
|
+
}
|
|
247
|
+
Array.concat(collectedTypes, [mainTypeComplete])
|
|
223
248
|
| None => []
|
|
224
249
|
}
|
|
225
250
|
}
|
|
@@ -763,6 +788,40 @@ let generate = (
|
|
|
763
788
|
}
|
|
764
789
|
})
|
|
765
790
|
|
|
791
|
+
// Two things a cross-table field has to satisfy, both checked before anything
|
|
792
|
+
// is emitted so the report names the declaration rather than the merge:
|
|
793
|
+
//
|
|
794
|
+
// The type it returns has to be one this fragment defines. A plugin's document
|
|
795
|
+
// is validated standalone before it is merged, and a field returning a name
|
|
796
|
+
// nothing declares fails the whole document rather than just the field.
|
|
797
|
+
//
|
|
798
|
+
// And the target has to be guarded the way the parent is. A nested field is
|
|
799
|
+
// reached through its parent, so it answers under the parent's authorization —
|
|
800
|
+
// a target that guards itself differently would be handed over by the wider
|
|
801
|
+
// door, which is the kind of hole nothing downstream can see. Equal rules pass,
|
|
802
|
+
// and so does a target open to everyone, which can only narrow.
|
|
803
|
+
let permissionOf = (entry: querySchemaEntry) =>
|
|
804
|
+
entry.permission->Option.getOr(Reventless.Authorization.AllowAuthenticated)
|
|
805
|
+
queryEntries->Array.forEach(entry =>
|
|
806
|
+
entry.resolvedFields
|
|
807
|
+
->Option.getOr([])
|
|
808
|
+
->Array.forEach(({fieldName, typeName}) =>
|
|
809
|
+
switch queryEntries->Array.find(e => e.returnTypeName == typeName) {
|
|
810
|
+
| None =>
|
|
811
|
+
JsError.throwWithMessage(
|
|
812
|
+
`${entry.returnTypeName}.${fieldName} is declared by @resolves/@resolvesMany and resolves to "${typeName}", which this plugin does not expose as a queryable. The target must be a ReadModel or StateViewSlice of the same plugin, named by its spec name.`,
|
|
813
|
+
)
|
|
814
|
+
| Some(target) =>
|
|
815
|
+
let targetPermission = permissionOf(target)
|
|
816
|
+
if targetPermission != permissionOf(entry) && targetPermission != AllowAnonymous {
|
|
817
|
+
JsError.throwWithMessage(
|
|
818
|
+
`${entry.returnTypeName}.${fieldName} resolves to "${typeName}", which declares a different authorization. A cross-table field is read through its parent and would answer under ${entry.returnTypeName}'s rule, handing over rows ${typeName}'s own door withholds. Give the two views the same authorization, or query ${typeName} directly.`,
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
)
|
|
823
|
+
)
|
|
824
|
+
|
|
766
825
|
queryEntries->Array.forEach(entry => {
|
|
767
826
|
let includeIdParam = entry.includeIdParam->Option.getOr(true)
|
|
768
827
|
let connectionSpec = entry.connectionSpec->Option.getOr(true)
|
|
@@ -776,6 +835,7 @@ let generate = (
|
|
|
776
835
|
~typeName=entry.returnTypeName,
|
|
777
836
|
~excludeFields,
|
|
778
837
|
~includeIdParam,
|
|
838
|
+
~resolvedFields=entry.resolvedFields->Option.getOr([]),
|
|
779
839
|
entry.stateSchema,
|
|
780
840
|
)
|
|
781
841
|
nestedTypes->Array.forEach(t => {
|
|
@@ -3,6 +3,8 @@
|
|
|
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 Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
7
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
6
8
|
import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
7
9
|
import * as Api_Naming$ReventlessCore from "./Api_Naming.res.mjs";
|
|
8
10
|
import * as SchemaType$ReventlessCore from "./SchemaType.res.mjs";
|
|
@@ -129,9 +131,20 @@ function reportUnclassifiedUnions(typeName, schema) {
|
|
|
129
131
|
SchemaType$ReventlessCore.unclassifiedUnions(schema).forEach(param => log.warn("GraphQL_FragmentGenerator", undefined, typeName + `.` + param.path + ` is emitted as String: ` + param.reason));
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
function
|
|
134
|
+
function resolvedFieldSdl(param) {
|
|
135
|
+
let typeName = param.typeName;
|
|
136
|
+
let fieldName = param.fieldName;
|
|
137
|
+
if (param.multi) {
|
|
138
|
+
return ` ` + fieldName + `: [` + typeName + `!]`;
|
|
139
|
+
} else {
|
|
140
|
+
return ` ` + fieldName + `: ` + typeName;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function deriveObjectTypeWithNested(typeName, excludeFieldsOpt, includeIdParamOpt, resolvedFieldsOpt, schema) {
|
|
133
145
|
let excludeFields = excludeFieldsOpt !== undefined ? excludeFieldsOpt : [];
|
|
134
146
|
let includeIdParam = includeIdParamOpt !== undefined ? includeIdParamOpt : true;
|
|
147
|
+
let resolvedFields = resolvedFieldsOpt !== undefined ? resolvedFieldsOpt : [];
|
|
135
148
|
reportUnclassifiedUnions(typeName, schema);
|
|
136
149
|
let fields = SchemaType$ReventlessCore.fromSuryObject(typeName, schema);
|
|
137
150
|
if (fields === undefined) {
|
|
@@ -158,7 +171,20 @@ function deriveObjectTypeWithNested(typeName, excludeFieldsOpt, includeIdParamOp
|
|
|
158
171
|
seenTypes.add(typeName);
|
|
159
172
|
let mainType = objectRefToGraphQL(undefined, typeName, filteredFields, collectedTypes, seenTypes);
|
|
160
173
|
let mainTypeWithId = includeIdParam ? mainType.replace(`type ` + typeName + ` {\n`, `type ` + typeName + ` implements Node {\n id: ID!\n`) : mainType;
|
|
161
|
-
|
|
174
|
+
resolvedFields.forEach(param => {
|
|
175
|
+
let fieldName = param.fieldName;
|
|
176
|
+
if (Stdlib_Option.isSome(filteredFields[fieldName]) || includeIdParam && fieldName === "id") {
|
|
177
|
+
return Stdlib_JsError.throwWithMessage(typeName + `.` + fieldName + ` is declared by @resolves/@resolvesMany and is already a field of the state record. Name the resolved field something the state does not use.`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
let mainTypeComplete;
|
|
181
|
+
if (resolvedFields.length === 0) {
|
|
182
|
+
mainTypeComplete = mainTypeWithId;
|
|
183
|
+
} else {
|
|
184
|
+
let body = mainTypeWithId.slice(0, mainTypeWithId.length - 1 | 0);
|
|
185
|
+
mainTypeComplete = body + resolvedFields.map(resolvedFieldSdl).join("\n") + "\n}";
|
|
186
|
+
}
|
|
187
|
+
return collectedTypes.concat([mainTypeComplete]);
|
|
162
188
|
}
|
|
163
189
|
|
|
164
190
|
function derivePluralWrapperType(pluralTypeName, singularTypeName) {
|
|
@@ -504,6 +530,20 @@ function generate(mutationEntries, queryEntries) {
|
|
|
504
530
|
return;
|
|
505
531
|
}
|
|
506
532
|
});
|
|
533
|
+
queryEntries.forEach(entry => {
|
|
534
|
+
Stdlib_Option.getOr(entry.resolvedFields, []).forEach(param => {
|
|
535
|
+
let typeName = param.typeName;
|
|
536
|
+
let fieldName = param.fieldName;
|
|
537
|
+
let target = queryEntries.find(e => e.returnTypeName === typeName);
|
|
538
|
+
if (target === undefined) {
|
|
539
|
+
return Stdlib_JsError.throwWithMessage(entry.returnTypeName + `.` + fieldName + ` is declared by @resolves/@resolvesMany and resolves to "` + typeName + `", which this plugin does not expose as a queryable. The target must be a ReadModel or StateViewSlice of the same plugin, named by its spec name.`);
|
|
540
|
+
}
|
|
541
|
+
let targetPermission = Stdlib_Option.getOr(target.permission, "AllowAuthenticated");
|
|
542
|
+
if (Primitive_object.notequal(targetPermission, Stdlib_Option.getOr(entry.permission, "AllowAuthenticated")) && targetPermission !== "AllowAnonymous") {
|
|
543
|
+
return Stdlib_JsError.throwWithMessage(entry.returnTypeName + `.` + fieldName + ` resolves to "` + typeName + `", which declares a different authorization. A cross-table field is read through its parent and would answer under ` + entry.returnTypeName + `'s rule, handing over rows ` + typeName + `'s own door withholds. Give the two views the same authorization, or query ` + typeName + ` directly.`);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
});
|
|
507
547
|
queryEntries.forEach(entry => {
|
|
508
548
|
let includeIdParam = Stdlib_Option.getOr(entry.includeIdParam, true);
|
|
509
549
|
let connectionSpec = Stdlib_Option.getOr(entry.connectionSpec, true);
|
|
@@ -511,7 +551,7 @@ function generate(mutationEntries, queryEntries) {
|
|
|
511
551
|
seenTypes.add(entry.returnTypeName);
|
|
512
552
|
let fields = entry.excludeFields;
|
|
513
553
|
let excludeFields = fields !== undefined ? fields : [];
|
|
514
|
-
let nestedTypes = deriveObjectTypeWithNested(entry.returnTypeName, excludeFields, includeIdParam, entry.stateSchema);
|
|
554
|
+
let nestedTypes = deriveObjectTypeWithNested(entry.returnTypeName, excludeFields, includeIdParam, Stdlib_Option.getOr(entry.resolvedFields, []), entry.stateSchema);
|
|
515
555
|
nestedTypes.forEach(t => {
|
|
516
556
|
types.push(t);
|
|
517
557
|
});
|
|
@@ -606,6 +646,7 @@ export {
|
|
|
606
646
|
objectRefToGraphQL,
|
|
607
647
|
deriveFieldType,
|
|
608
648
|
reportUnclassifiedUnions,
|
|
649
|
+
resolvedFieldSdl,
|
|
609
650
|
deriveObjectTypeWithNested,
|
|
610
651
|
derivePluralWrapperType,
|
|
611
652
|
deriveConnectionTypes,
|
|
@@ -13,7 +13,7 @@ function inspectScalar(schema) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function inspectObjectType(typeName, schema) {
|
|
16
|
-
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, schema);
|
|
16
|
+
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, undefined, schema);
|
|
17
17
|
return types[types.length - 1 | 0];
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -42,7 +42,7 @@ function inspectMutationFields(fieldPrefix, commandSchema) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function inspectQueryFields(name, typeName, stateSchema) {
|
|
45
|
-
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, stateSchema);
|
|
45
|
+
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, undefined, stateSchema);
|
|
46
46
|
let typeDef = types[types.length - 1 | 0];
|
|
47
47
|
let singleQuery = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectQueryField(name, typeName, undefined, undefined);
|
|
48
48
|
let pluralName = name + "s";
|
|
@@ -1095,6 +1095,8 @@ module Make = (
|
|
|
1095
1095
|
let subIdField = V.Spec.subIdConfig->Option.map(c => c.subIdField)
|
|
1096
1096
|
let indexes = V.Spec.config.indexes
|
|
1097
1097
|
let indexQueries = if indexes->Array.length > 0 {Some(indexes)} else {None}
|
|
1098
|
+
let resolved = Api_Naming.resolvedFieldsOfConfig(~plugin=name, V.Spec.config)
|
|
1099
|
+
let resolvedFields = if resolved->Array.length > 0 {Some(resolved)} else {None}
|
|
1098
1100
|
{
|
|
1099
1101
|
ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
|
|
1100
1102
|
listFieldName: qn.listFieldName,
|
|
@@ -1107,6 +1109,7 @@ module Make = (
|
|
|
1107
1109
|
connectionSpec: true,
|
|
1108
1110
|
subIdField: ?subIdField,
|
|
1109
1111
|
indexQueries: ?indexQueries,
|
|
1112
|
+
resolvedFields: ?resolvedFields,
|
|
1110
1113
|
}
|
|
1111
1114
|
})
|
|
1112
1115
|
|
|
@@ -603,6 +603,8 @@ function Make(DcbEventLogStorage) {
|
|
|
603
603
|
let subIdField = Stdlib_Option.map(V.Spec.subIdConfig, c => c.subIdField);
|
|
604
604
|
let indexes = V.Spec.config.indexes;
|
|
605
605
|
let indexQueries = indexes.length !== 0 ? indexes : undefined;
|
|
606
|
+
let resolved = Api_Naming$ReventlessCore.resolvedFieldsOfConfig(name, V.Spec.config);
|
|
607
|
+
let resolvedFields = resolved.length !== 0 ? resolved : undefined;
|
|
606
608
|
return {
|
|
607
609
|
singleFieldName: qn.singleFieldName,
|
|
608
610
|
listFieldName: qn.listFieldName,
|
|
@@ -614,7 +616,8 @@ function Make(DcbEventLogStorage) {
|
|
|
614
616
|
includeIdParam: qn.includeIdParam,
|
|
615
617
|
connectionSpec: true,
|
|
616
618
|
subIdField: subIdField,
|
|
617
|
-
indexQueries: indexQueries
|
|
619
|
+
indexQueries: indexQueries,
|
|
620
|
+
resolvedFields: resolvedFields
|
|
618
621
|
};
|
|
619
622
|
});
|
|
620
623
|
let automationEntries = automationSlices.map(A => {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
let log = Logger.fromEnv()
|
|
2
|
+
|
|
1
3
|
module type Ops = {
|
|
2
4
|
let jsonOps: QueryDb.operations<string, JSON.t>
|
|
3
5
|
}
|
|
@@ -72,11 +74,53 @@ module Make = (ReadModelSpec: Reventless.ReadModel.Spec, Ops: Ops) => {
|
|
|
72
74
|
// backends — and, unlike a read-time stamp, it is also on the row the live
|
|
73
75
|
// change channel hands over as raw JSON. A view with no union field is walked
|
|
74
76
|
// and left exactly as it was.
|
|
75
|
-
|
|
77
|
+
// The union fields this view declares, read once. Empty for most views, and
|
|
78
|
+
// the whole diagnostic below costs nothing when it is.
|
|
79
|
+
let declaredUnionFields =
|
|
80
|
+
Reventless.TaggedUnion.fieldsOf(ReadModelSpec.stateSchema->S.castToUnknown)
|
|
81
|
+
|
|
82
|
+
// Logged once per instantiation, and unconditionally: "none" has to be
|
|
83
|
+
// distinguishable from "never instantiated", because a schema that resolves no
|
|
84
|
+
// union fields and a stamp that failed to run leave the same stored row.
|
|
85
|
+
log.debug(
|
|
86
|
+
~comp="QueryDb",
|
|
87
|
+
`${ReadModelSpec.name}: union fields ${switch declaredUnionFields {
|
|
88
|
+
| [] => "none"
|
|
89
|
+
| fields =>
|
|
90
|
+
fields->Array.map(((f, n)) => `${f}:${n->Option.getOr("<unnamed>")}`)->Array.join(", ")
|
|
91
|
+
}}`,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
// A union value stored without `__typename` is the failure D1 exists to
|
|
95
|
+
// prevent, and it is silent: the member resolves to null, the null takes its
|
|
96
|
+
// non-nullable parent, and a list loses the row rather than erroring. Nothing
|
|
97
|
+
// downstream can tell that from "no such row", so it is reported here — the
|
|
98
|
+
// one place that knows both the schema and the row.
|
|
99
|
+
let reportUnstamped = dict =>
|
|
100
|
+
declaredUnionFields->Array.forEach(((field, unionName)) =>
|
|
101
|
+
switch dict->Dict.get(field)->Option.flatMap(JSON.Decode.object) {
|
|
102
|
+
| Some(value) if value->Dict.get("TAG")->Option.isSome =>
|
|
103
|
+
if value->Dict.get(Reventless.TaggedUnion.typenameKey)->Option.isNone {
|
|
104
|
+
log.warn(
|
|
105
|
+
~comp="QueryDb",
|
|
106
|
+
`${ReadModelSpec.name}.${field}: stored a union value with no ${Reventless.TaggedUnion.typenameKey} ` ++
|
|
107
|
+
`(union ${unionName->Option.getOr("<unnamed>")}). The field will resolve to null and take its ` ++
|
|
108
|
+
`parent with it. Union fields seen on this spec: ${declaredUnionFields
|
|
109
|
+
->Array.map(((f, _)) => f)
|
|
110
|
+
->Array.join(", ")}`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
| _ => ()
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
let stampUnionMembers = dict => {
|
|
76
118
|
Reventless.TaggedUnion.stampInto(
|
|
77
119
|
~schema=ReadModelSpec.stateSchema->S.castToUnknown,
|
|
78
120
|
JSON.Encode.object(dict),
|
|
79
121
|
)
|
|
122
|
+
reportUnstamped(dict)
|
|
123
|
+
}
|
|
80
124
|
|
|
81
125
|
let save = async (id, state, saveMode, ttl) =>
|
|
82
126
|
switch state->Message.encode(ReadModelSpec.stateSchema)->JSON.Decode.object {
|
|
@@ -7,9 +7,12 @@ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
|
7
7
|
import * as Effect from "effect/Effect";
|
|
8
8
|
import * as Stream from "effect/Stream";
|
|
9
9
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
10
|
+
import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
10
11
|
import * as Message$ReventlessCore from "../../Message.res.mjs";
|
|
11
12
|
import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
|
|
12
13
|
|
|
14
|
+
let log = Logger$ReventlessCore.fromEnv();
|
|
15
|
+
|
|
13
16
|
function Make(ReadModelSpec) {
|
|
14
17
|
return Ops => {
|
|
15
18
|
let decode = (id, stateJson) => {
|
|
@@ -77,14 +80,30 @@ function Make(ReadModelSpec) {
|
|
|
77
80
|
dict[attrName$1] = value$1;
|
|
78
81
|
});
|
|
79
82
|
};
|
|
80
|
-
let
|
|
83
|
+
let declaredUnionFields = TaggedUnion$Reventless.fieldsOf(ReadModelSpec.stateSchema);
|
|
84
|
+
log.debug("QueryDb", undefined, ReadModelSpec.name + `: union fields ` + (
|
|
85
|
+
declaredUnionFields.length !== 0 ? declaredUnionFields.map(param => param[0] + `:` + Stdlib_Option.getOr(param[1], "<unnamed>")).join(", ") : "none"
|
|
86
|
+
));
|
|
87
|
+
let reportUnstamped = dict => {
|
|
88
|
+
declaredUnionFields.forEach(param => {
|
|
89
|
+
let field = param[0];
|
|
90
|
+
let value = Stdlib_Option.flatMap(dict[field], Stdlib_JSON.Decode.object);
|
|
91
|
+
if (value !== undefined && Stdlib_Option.isSome(value["TAG"]) && Stdlib_Option.isNone(value[TaggedUnion$Reventless.typenameKey])) {
|
|
92
|
+
return log.warn("QueryDb", undefined, ReadModelSpec.name + `.` + field + `: stored a union value with no ` + TaggedUnion$Reventless.typenameKey + ` ` + (`(union ` + Stdlib_Option.getOr(param[1], "<unnamed>") + `). The field will resolve to null and take its `) + (`parent with it. Union fields seen on this spec: ` + declaredUnionFields.map(param => param[0]).join(", ")));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
let stampUnionMembers = dict => {
|
|
97
|
+
TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
|
|
98
|
+
reportUnstamped(dict);
|
|
99
|
+
};
|
|
81
100
|
let save = async (id, state, saveMode, ttl) => {
|
|
82
101
|
let dict = Stdlib_JSON.Decode.object(Message$ReventlessCore.encode(state, ReadModelSpec.stateSchema));
|
|
83
102
|
if (dict !== undefined) {
|
|
84
103
|
dict["id"] = Message$ReventlessCore.encode(id, ReadModelSpec.Id.schema);
|
|
85
104
|
injectSubId(dict, state);
|
|
86
105
|
injectCompositeIndexAttrs(dict);
|
|
87
|
-
|
|
106
|
+
stampUnionMembers(dict);
|
|
88
107
|
return await Ops.jsonOps.save(ReadModelSpec.Id.toString(id), dict, saveMode, ttl);
|
|
89
108
|
} else {
|
|
90
109
|
return {
|
|
@@ -110,7 +129,7 @@ function Make(ReadModelSpec) {
|
|
|
110
129
|
dict["id"] = Message$ReventlessCore.encode(id, ReadModelSpec.Id.schema);
|
|
111
130
|
injectSubId(dict, state);
|
|
112
131
|
injectCompositeIndexAttrs(dict);
|
|
113
|
-
|
|
132
|
+
stampUnionMembers(dict);
|
|
114
133
|
return {
|
|
115
134
|
TAG: "Ok",
|
|
116
135
|
_0: batch.concat([[
|
|
@@ -154,6 +173,8 @@ function Make(ReadModelSpec) {
|
|
|
154
173
|
load: load,
|
|
155
174
|
injectSubId: injectSubId,
|
|
156
175
|
injectCompositeIndexAttrs: injectCompositeIndexAttrs,
|
|
176
|
+
declaredUnionFields: declaredUnionFields,
|
|
177
|
+
reportUnstamped: reportUnstamped,
|
|
157
178
|
stampUnionMembers: stampUnionMembers,
|
|
158
179
|
save: save,
|
|
159
180
|
saveBatch: saveBatch,
|
|
@@ -165,6 +186,7 @@ function Make(ReadModelSpec) {
|
|
|
165
186
|
}
|
|
166
187
|
|
|
167
188
|
export {
|
|
189
|
+
log,
|
|
168
190
|
Make,
|
|
169
191
|
}
|
|
170
|
-
/*
|
|
192
|
+
/* log Not a pure module */
|
|
@@ -255,6 +255,7 @@ module Make = (
|
|
|
255
255
|
// its SDL did not declare. The DCB and admin entries have populated this
|
|
256
256
|
// all along; the omission here was the asymmetry, not the rule.
|
|
257
257
|
let indexes = R.Spec.config.indexes
|
|
258
|
+
let resolvedFields = Api_Naming.resolvedFieldsOfConfig(~plugin=name, R.Spec.config)
|
|
258
259
|
{
|
|
259
260
|
ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
|
|
260
261
|
listFieldName: qn.listFieldName,
|
|
@@ -265,6 +266,7 @@ module Make = (
|
|
|
265
266
|
connectionSpec: true,
|
|
266
267
|
subIdField: ?subIdField,
|
|
267
268
|
indexQueries: ?(indexes->Array.length > 0 ? Some(indexes) : None),
|
|
269
|
+
resolvedFields: ?(resolvedFields->Array.length > 0 ? Some(resolvedFields) : None),
|
|
268
270
|
}
|
|
269
271
|
})
|
|
270
272
|
|
|
@@ -196,6 +196,7 @@ function Make(Spec) {
|
|
|
196
196
|
let qn = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(extra$1, R.Spec.name, undefined);
|
|
197
197
|
let subIdField = Stdlib_Option.map(R.Spec.subIdConfig, c => c.subIdField);
|
|
198
198
|
let indexes = R.Spec.config.indexes;
|
|
199
|
+
let resolvedFields = Api_Naming$ReventlessCore.resolvedFieldsOfConfig(extra$1, R.Spec.config);
|
|
199
200
|
return {
|
|
200
201
|
singleFieldName: qn.singleFieldName,
|
|
201
202
|
listFieldName: qn.listFieldName,
|
|
@@ -205,7 +206,8 @@ function Make(Spec) {
|
|
|
205
206
|
permission: R.Spec.authorization,
|
|
206
207
|
connectionSpec: true,
|
|
207
208
|
subIdField: subIdField,
|
|
208
|
-
indexQueries: indexes.length !== 0 ? indexes : undefined
|
|
209
|
+
indexQueries: indexes.length !== 0 ? indexes : undefined,
|
|
210
|
+
resolvedFields: resolvedFields.length !== 0 ? resolvedFields : undefined
|
|
209
211
|
};
|
|
210
212
|
});
|
|
211
213
|
let queryEntries = queryEntriesFromReadModels.concat(dcbResult.queryEntries);
|
|
@@ -572,3 +572,144 @@ describe("@internal keeps a field off the generated type", () => {
|
|
|
572
572
|
))
|
|
573
573
|
})
|
|
574
574
|
})
|
|
575
|
+
|
|
576
|
+
// `@resolves` / `@resolvesMany` add a field to a queryable's object type that the
|
|
577
|
+
// state record does not carry — the row on the other side of a foreign key. The
|
|
578
|
+
// PPX has written those configs into `config.idResolvers` / `config.idsResolvers`
|
|
579
|
+
// all along and nothing emitted the field, so every backend provisioned a
|
|
580
|
+
// resolver for a field its own schema never declared.
|
|
581
|
+
|
|
582
|
+
module ResolvedFields = {
|
|
583
|
+
@schema
|
|
584
|
+
type product = {productId: string, name: string}
|
|
585
|
+
|
|
586
|
+
@schema
|
|
587
|
+
type order = {orderId: string, productId: string, productIds: array<string>}
|
|
588
|
+
|
|
589
|
+
let entries = (
|
|
590
|
+
~resolvedFields,
|
|
591
|
+
~targetPermission: option<Reventless.Authorization.permission>=?,
|
|
592
|
+
): array<ReventlessInfra.Api.querySchemaEntry> => [
|
|
593
|
+
{
|
|
594
|
+
singleFieldName: "Catalog_Order",
|
|
595
|
+
listFieldName: "Catalog_Orders",
|
|
596
|
+
returnTypeName: "Catalog_Order",
|
|
597
|
+
stateSchema: orderSchema->S.castToUnknown,
|
|
598
|
+
authorization: None,
|
|
599
|
+
resolvedFields: ?resolvedFields,
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
singleFieldName: "Catalog_Product",
|
|
603
|
+
listFieldName: "Catalog_Products",
|
|
604
|
+
returnTypeName: "Catalog_Product",
|
|
605
|
+
stateSchema: productSchema->S.castToUnknown,
|
|
606
|
+
authorization: None,
|
|
607
|
+
permission: ?targetPermission,
|
|
608
|
+
},
|
|
609
|
+
]
|
|
610
|
+
|
|
611
|
+
let bothFields: array<ReventlessInfra.Api.resolvedFieldEntry> = [
|
|
612
|
+
{fieldName: "product", typeName: "Catalog_Product", multi: false},
|
|
613
|
+
{fieldName: "products", typeName: "Catalog_Product", multi: true},
|
|
614
|
+
]
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
describe("@resolves / @resolvesMany reach the SDL", () => {
|
|
618
|
+
let refused = f =>
|
|
619
|
+
try {
|
|
620
|
+
let _ = f()
|
|
621
|
+
false
|
|
622
|
+
} catch {
|
|
623
|
+
| _ => true
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let orderType = (~resolvedFields, ~targetPermission=?) =>
|
|
627
|
+
GraphQL_Stitcher.decode(
|
|
628
|
+
GraphQL_FragmentGenerator.generate(
|
|
629
|
+
~mutationEntries=[],
|
|
630
|
+
~queryEntries=ResolvedFields.entries(~resolvedFields, ~targetPermission?),
|
|
631
|
+
),
|
|
632
|
+
).types
|
|
633
|
+
->Array.find(t => t->String.startsWith("type Catalog_Order "))
|
|
634
|
+
->Option.getOr("")
|
|
635
|
+
|
|
636
|
+
testSync("the single form returns the target's type", () =>
|
|
637
|
+
expect(
|
|
638
|
+
orderType(~resolvedFields=Some(ResolvedFields.bothFields))->String.includes(
|
|
639
|
+
"product: Catalog_Product",
|
|
640
|
+
),
|
|
641
|
+
)->toBe(true)
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
// A list of non-null items rather than `[T]`: the batch read drops ids it did
|
|
645
|
+
// not find instead of padding the gap with nulls, so the field is shorter.
|
|
646
|
+
testSync("the batch form returns a list of the target's type", () =>
|
|
647
|
+
expect(
|
|
648
|
+
orderType(~resolvedFields=Some(ResolvedFields.bothFields))->String.includes(
|
|
649
|
+
"products: [Catalog_Product!]",
|
|
650
|
+
),
|
|
651
|
+
)->toBe(true)
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
testSync("the state's own fields are untouched", () => {
|
|
655
|
+
let sdl = orderType(~resolvedFields=Some(ResolvedFields.bothFields))
|
|
656
|
+
expect((sdl->String.includes("orderId:"), sdl->String.includes("id: ID!")))->toEqual((
|
|
657
|
+
true,
|
|
658
|
+
true,
|
|
659
|
+
))
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
// The control: without the entry the type is exactly what it was, which is what
|
|
663
|
+
// made the annotation inert rather than wrong.
|
|
664
|
+
testSync("a view that declares none gets no extra field", () =>
|
|
665
|
+
expect(orderType(~resolvedFields=None)->String.includes("Catalog_Product"))->toBe(false)
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
// A plugin's document is validated standalone before the merge, so a field
|
|
669
|
+
// returning a name nothing declares fails the whole document — every type in
|
|
670
|
+
// it, not just this field. Refused where the declaration is, naming it.
|
|
671
|
+
testSync("a target this plugin does not expose is refused", () =>
|
|
672
|
+
expect(
|
|
673
|
+
refused(() =>
|
|
674
|
+
orderType(
|
|
675
|
+
~resolvedFields=Some([{fieldName: "supplier", typeName: "Supply_Supplier", multi: false}]),
|
|
676
|
+
)
|
|
677
|
+
),
|
|
678
|
+
)->toBe(true)
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
// A nested field is read through its parent, so it answers under the parent's
|
|
682
|
+
// rule. A target guarded more narrowly than the view that embeds it would be
|
|
683
|
+
// handed over by the wider door.
|
|
684
|
+
testSync("a target guarded differently from the parent is refused", () =>
|
|
685
|
+
expect(
|
|
686
|
+
refused(() =>
|
|
687
|
+
orderType(
|
|
688
|
+
~resolvedFields=Some(ResolvedFields.bothFields),
|
|
689
|
+
~targetPermission=AllowGroups(["Admin"]),
|
|
690
|
+
)
|
|
691
|
+
),
|
|
692
|
+
)->toBe(true)
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
// A target open to everyone can only narrow, so it needs no refusal.
|
|
696
|
+
testSync("a target open to everyone is allowed under an authenticated parent", () =>
|
|
697
|
+
expect(
|
|
698
|
+
refused(() =>
|
|
699
|
+
orderType(~resolvedFields=Some(ResolvedFields.bothFields), ~targetPermission=AllowAnonymous)
|
|
700
|
+
),
|
|
701
|
+
)->toBe(false)
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
testSync("a resolved field colliding with a state field is refused", () =>
|
|
705
|
+
expect(
|
|
706
|
+
refused(() =>
|
|
707
|
+
orderType(
|
|
708
|
+
~resolvedFields=Some([
|
|
709
|
+
{fieldName: "productId", typeName: "Catalog_Product", multi: false},
|
|
710
|
+
]),
|
|
711
|
+
)
|
|
712
|
+
),
|
|
713
|
+
)->toBe(true)
|
|
714
|
+
)
|
|
715
|
+
})
|
|
@@ -246,7 +246,7 @@ globalThis.describe("semantic composites are named once, not once per field", ()
|
|
|
246
246
|
globalThis.expect(fragmentTypes.some(t => t.includes("AddProductPrice"))).toBe(false);
|
|
247
247
|
});
|
|
248
248
|
globalThis.test("the output position keeps the bare name", () => {
|
|
249
|
-
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Catalog_Product", undefined, false, Sury.$schema(s => ({
|
|
249
|
+
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Catalog_Product", undefined, false, undefined, Sury.$schema(s => ({
|
|
250
250
|
productId: s.m(Sury.string),
|
|
251
251
|
price: s.m(Money$Reventless.schema)
|
|
252
252
|
})));
|
|
@@ -430,7 +430,7 @@ globalThis.describe("the by-index door", () => {
|
|
|
430
430
|
});
|
|
431
431
|
|
|
432
432
|
globalThis.describe("@internal keeps a field off the generated type", () => {
|
|
433
|
-
let types = excludeFields => GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Platform_Widget", excludeFields, undefined, PsInternalFieldsReadModel$ReventlessCore.stateSchema).join("\n");
|
|
433
|
+
let types = excludeFields => GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Platform_Widget", excludeFields, undefined, undefined, PsInternalFieldsReadModel$ReventlessCore.stateSchema).join("\n");
|
|
434
434
|
globalThis.test("the declared fields are absent", () => {
|
|
435
435
|
globalThis.expect([
|
|
436
436
|
types(undefined).includes("eventCollector"),
|
|
@@ -471,6 +471,112 @@ globalThis.describe("@internal keeps a field off the generated type", () => {
|
|
|
471
471
|
});
|
|
472
472
|
});
|
|
473
473
|
|
|
474
|
+
let productSchema = Sury.$schema(s => ({
|
|
475
|
+
productId: s.m(Sury.string),
|
|
476
|
+
name: s.m(Sury.string)
|
|
477
|
+
}));
|
|
478
|
+
|
|
479
|
+
let orderSchema = Sury.$schema(s => ({
|
|
480
|
+
orderId: s.m(Sury.string),
|
|
481
|
+
productId: s.m(Sury.string),
|
|
482
|
+
productIds: s.m(Sury.array(Sury.string))
|
|
483
|
+
}));
|
|
484
|
+
|
|
485
|
+
function entries(resolvedFields, targetPermission) {
|
|
486
|
+
return [
|
|
487
|
+
{
|
|
488
|
+
singleFieldName: "Catalog_Order",
|
|
489
|
+
listFieldName: "Catalog_Orders",
|
|
490
|
+
returnTypeName: "Catalog_Order",
|
|
491
|
+
stateSchema: orderSchema,
|
|
492
|
+
authorization: undefined,
|
|
493
|
+
resolvedFields: resolvedFields
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
singleFieldName: "Catalog_Product",
|
|
497
|
+
listFieldName: "Catalog_Products",
|
|
498
|
+
returnTypeName: "Catalog_Product",
|
|
499
|
+
stateSchema: productSchema,
|
|
500
|
+
authorization: undefined,
|
|
501
|
+
permission: targetPermission
|
|
502
|
+
}
|
|
503
|
+
];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let bothFields = [
|
|
507
|
+
{
|
|
508
|
+
fieldName: "product",
|
|
509
|
+
typeName: "Catalog_Product",
|
|
510
|
+
multi: false
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
fieldName: "products",
|
|
514
|
+
typeName: "Catalog_Product",
|
|
515
|
+
multi: true
|
|
516
|
+
}
|
|
517
|
+
];
|
|
518
|
+
|
|
519
|
+
let ResolvedFields = {
|
|
520
|
+
productSchema: productSchema,
|
|
521
|
+
orderSchema: orderSchema,
|
|
522
|
+
entries: entries,
|
|
523
|
+
bothFields: bothFields
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
globalThis.describe("@resolves / @resolvesMany reach the SDL", () => {
|
|
527
|
+
let refused = f => {
|
|
528
|
+
try {
|
|
529
|
+
f();
|
|
530
|
+
return false;
|
|
531
|
+
} catch (exn) {
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
let orderType = (resolvedFields, targetPermission) => Stdlib_Option.getOr(GraphQL_Stitcher$ReventlessCore.decode(GraphQL_FragmentGenerator$ReventlessCore.generate([], entries(resolvedFields, targetPermission))).types.find(t => t.startsWith("type Catalog_Order ")), "");
|
|
536
|
+
globalThis.test("the single form returns the target's type", () => {
|
|
537
|
+
globalThis.expect(orderType(bothFields, undefined).includes("product: Catalog_Product")).toBe(true);
|
|
538
|
+
});
|
|
539
|
+
globalThis.test("the batch form returns a list of the target's type", () => {
|
|
540
|
+
globalThis.expect(orderType(bothFields, undefined).includes("products: [Catalog_Product!]")).toBe(true);
|
|
541
|
+
});
|
|
542
|
+
globalThis.test("the state's own fields are untouched", () => {
|
|
543
|
+
let sdl = orderType(bothFields, undefined);
|
|
544
|
+
globalThis.expect([
|
|
545
|
+
sdl.includes("orderId:"),
|
|
546
|
+
sdl.includes("id: ID!")
|
|
547
|
+
]).toEqual([
|
|
548
|
+
true,
|
|
549
|
+
true
|
|
550
|
+
]);
|
|
551
|
+
});
|
|
552
|
+
globalThis.test("a view that declares none gets no extra field", () => {
|
|
553
|
+
globalThis.expect(orderType(undefined, undefined).includes("Catalog_Product")).toBe(false);
|
|
554
|
+
});
|
|
555
|
+
globalThis.test("a target this plugin does not expose is refused", () => {
|
|
556
|
+
globalThis.expect(refused(() => orderType([{
|
|
557
|
+
fieldName: "supplier",
|
|
558
|
+
typeName: "Supply_Supplier",
|
|
559
|
+
multi: false
|
|
560
|
+
}], undefined))).toBe(true);
|
|
561
|
+
});
|
|
562
|
+
globalThis.test("a target guarded differently from the parent is refused", () => {
|
|
563
|
+
globalThis.expect(refused(() => orderType(bothFields, {
|
|
564
|
+
TAG: "AllowGroups",
|
|
565
|
+
_0: ["Admin"]
|
|
566
|
+
}))).toBe(true);
|
|
567
|
+
});
|
|
568
|
+
globalThis.test("a target open to everyone is allowed under an authenticated parent", () => {
|
|
569
|
+
globalThis.expect(refused(() => orderType(bothFields, "AllowAnonymous"))).toBe(false);
|
|
570
|
+
});
|
|
571
|
+
globalThis.test("a resolved field colliding with a state field is refused", () => {
|
|
572
|
+
globalThis.expect(refused(() => orderType([{
|
|
573
|
+
fieldName: "productId",
|
|
574
|
+
typeName: "Catalog_Product",
|
|
575
|
+
multi: false
|
|
576
|
+
}], undefined))).toBe(true);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
474
580
|
export {
|
|
475
581
|
OrderSliceCmd,
|
|
476
582
|
mutationFor,
|
|
@@ -482,5 +588,6 @@ export {
|
|
|
482
588
|
RefDoorRow,
|
|
483
589
|
queryFor,
|
|
484
590
|
refDoorFragment,
|
|
591
|
+
ResolvedFields,
|
|
485
592
|
}
|
|
486
593
|
/* commandSchema Not a pure module */
|