@simtlix/simfinity-core 3.5.2 → 3.5.3
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/README.md +8 -3
- package/package.json +1 -1
- package/src/auth/expressions.js +77 -16
- package/src/auth/index.js +44 -8
- package/src/auth/rules.js +54 -10
- package/src/auth/thenable.js +17 -0
- package/src/index.js +1 -0
- package/src/mutation-limits.js +24 -0
- package/src/relation-owners.js +5 -0
- package/src/runtime.js +293 -57
- package/src/scalars/factory.js +30 -10
- package/src/scalars/pattern.js +16 -0
- package/src/scalars.js +3 -2
- package/src/validators.js +4 -4
- package/types/index.d.ts +144 -7
package/src/runtime.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { configureQueryLimits, getQueryMaxPageSize } from './query-limits.js';
|
|
2
|
+
import { configureMutationLimits, getMaxNestedOperations } from './mutation-limits.js';
|
|
2
3
|
import {
|
|
3
4
|
GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
|
|
4
5
|
GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
|
|
5
|
-
GraphQLInt, GraphQLEnumType, GraphQLBoolean,
|
|
6
|
+
GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
|
|
7
|
+
Kind, getNamedType,
|
|
6
8
|
} from 'graphql';
|
|
7
9
|
|
|
8
10
|
import SimfinityError from './errors/simfinity.error.js';
|
|
@@ -11,6 +13,7 @@ import QLOperator from './const/QLOperator.js';
|
|
|
11
13
|
import QLValue from './const/QLValue.js';
|
|
12
14
|
import QLSort from './const/QLSort.js';
|
|
13
15
|
import { collectQueryPathEntries, collectQueryPaths, walkQueryPath } from './query-plan.js';
|
|
16
|
+
import { relationFieldOwners } from './relation-owners.js';
|
|
14
17
|
import './introspection.js';
|
|
15
18
|
|
|
16
19
|
// Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
|
|
@@ -28,7 +31,53 @@ const listApplicationResolvedFields = (gqltype) => new Set(Object.entries(gqltyp
|
|
|
28
31
|
const hasApplicationResolver = (gqltype, fieldName) => (
|
|
29
32
|
applicationResolvedFields.get(gqltype) || listApplicationResolvedFields(gqltype)
|
|
30
33
|
).has(fieldName);
|
|
34
|
+
// Object types whose relation fields hold resolvers and filter arguments generated by one runtime,
|
|
35
|
+
// mapped to that runtime. Those resolvers read through its adapter, registrations and middleware,
|
|
36
|
+
// so a bound type cannot be registered in, or reached from, another runtime's schema.
|
|
37
|
+
const runtimeBoundTypes = new WeakMap();
|
|
31
38
|
const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER_PATH', 403);
|
|
39
|
+
|
|
40
|
+
// Query scopes restrict reads, so a scope Simfinity cannot run must fail rather than be skipped.
|
|
41
|
+
const SCOPE_OPERATIONS = ['find', 'get_by_id', 'aggregate'];
|
|
42
|
+
const invalidScope = (typeName, detail) => new SimfinityError(
|
|
43
|
+
`${typeName} extensions.scope ${detail}`, 'INVALID_SCOPE', 500,
|
|
44
|
+
);
|
|
45
|
+
const validateScope = (gqltype) => {
|
|
46
|
+
const extensions = gqltype.extensions || {};
|
|
47
|
+
if (!Object.hasOwn(extensions, 'scope')) return;
|
|
48
|
+
const { scope } = extensions;
|
|
49
|
+
if (!scope || typeof scope !== 'object' || Array.isArray(scope)
|
|
50
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(scope))) {
|
|
51
|
+
throw invalidScope(gqltype.name, 'must be a plain object of find, get_by_id or aggregate functions');
|
|
52
|
+
}
|
|
53
|
+
// Every own string key, enumerable or not, because getScope reads any of them.
|
|
54
|
+
for (const operation of Object.getOwnPropertyNames(scope)) {
|
|
55
|
+
if (!SCOPE_OPERATIONS.includes(operation)) {
|
|
56
|
+
throw invalidScope(gqltype.name, `has unknown operation ${operation}; use find, get_by_id or aggregate`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof scope[operation] !== 'function') {
|
|
59
|
+
throw invalidScope(gqltype.name, `${operation} must be a function`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Returns the type's scope function for an operation, or null when it declares none. Revalidates
|
|
65
|
+
// on each call because extensions stay mutable after createSchema.
|
|
66
|
+
const getScope = (gqltype, operation) => {
|
|
67
|
+
if (!gqltype?.extensions || !Object.hasOwn(gqltype.extensions, 'scope')) return null;
|
|
68
|
+
validateScope(gqltype);
|
|
69
|
+
const { scope } = gqltype.extensions;
|
|
70
|
+
return Object.hasOwn(scope, operation) ? scope[operation] : null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const requireRegisteredType = (dict, type, fieldName, ownerName) => {
|
|
74
|
+
const registration = dict.types[type.name];
|
|
75
|
+
if (!registration) {
|
|
76
|
+
throw new SimfinityError(`Field ${ownerName ? `${ownerName}.` : ''}${fieldName} references ${type.name}, which is not `
|
|
77
|
+
+ 'registered; register it with connect() or addNoEndpointType()', 'UNREGISTERED_RELATION_TARGET', 500);
|
|
78
|
+
}
|
|
79
|
+
return registration;
|
|
80
|
+
};
|
|
32
81
|
const SCOPE_IGNORED_ARGS = new Set(['sort', 'pagination', 'aggregation']);
|
|
33
82
|
|
|
34
83
|
// Moves a joined type's scope group below the relation path that entered it.
|
|
@@ -105,22 +154,85 @@ const GraphQLJSON = new GraphQLScalarType({
|
|
|
105
154
|
},
|
|
106
155
|
});
|
|
107
156
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
157
|
+
// graphql-js wraps a value thrown while resolving a field (locatedError): the wrapper has the field
|
|
158
|
+
// path and repeats the thrown error's message. A thrown non-Error becomes a NonErrorThrown whose
|
|
159
|
+
// message prints the value.
|
|
160
|
+
const isFieldErrorWrapper = (error) => Array.isArray(error.path) && error.originalError != null
|
|
161
|
+
&& error.message === error.originalError.message
|
|
162
|
+
// locatedError adds no code of its own, so a thrown GraphQLError with its own code is not a wrapper.
|
|
163
|
+
&& (error.originalError instanceof SimfinityError || typeof error.extensions?.code !== 'string'
|
|
164
|
+
|| error.extensions === error.originalError.extensions);
|
|
165
|
+
const thrownValueOf = (error) => (error.name === 'NonErrorThrown' && Object.hasOwn(error, 'thrownValue')
|
|
166
|
+
? error.thrownValue : error);
|
|
167
|
+
|
|
168
|
+
// The SimfinityError behind a GraphQLError, such as one a custom scalar threw, followed only through
|
|
169
|
+
// GraphQLErrors; any other hidden cause is ignored.
|
|
170
|
+
const simfinityCauseOf = (error) => {
|
|
171
|
+
let cause = error.originalError;
|
|
172
|
+
while (cause instanceof GraphQLError) cause = cause.originalError;
|
|
173
|
+
return cause instanceof SimfinityError ? cause : null;
|
|
174
|
+
};
|
|
116
175
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
};
|
|
123
|
-
|
|
176
|
+
// Request errors (syntax, validation, variable coercion) and application GraphQLErrors keep their own
|
|
177
|
+
// message and extensions, such as the http status a server adds. A SimfinityError cause supplies only
|
|
178
|
+
// the code and status, so a scalar's or application's hidden cause never replaces them.
|
|
179
|
+
const classifyGraphQLError = (error) => {
|
|
180
|
+
const simfinity = simfinityCauseOf(error)?.extensions ?? {};
|
|
181
|
+
const extensions = error.extensions || {};
|
|
182
|
+
const code = [simfinity.code, extensions.code].find((value) => typeof value === 'string') ?? 'BAD_REQUEST';
|
|
183
|
+
const status = [simfinity.status, extensions.status].find(Number.isInteger)
|
|
184
|
+
?? (code === 'INTERNAL_SERVER_ERROR' ? 500 : 400);
|
|
185
|
+
const classified = new SimfinityError(error.message, code, status);
|
|
186
|
+
classified.extensions = { ...extensions, ...classified.extensions };
|
|
187
|
+
return classified;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const classifyValue = (value) => {
|
|
191
|
+
if (value instanceof SimfinityError) return value;
|
|
192
|
+
if (value instanceof GraphQLError) return classifyGraphQLError(value);
|
|
193
|
+
// Never keep a non-Error value, such as a server's hook payload with request variables, as the cause.
|
|
194
|
+
return value instanceof Error
|
|
195
|
+
? new InternalServerError(value.message, value)
|
|
196
|
+
: new InternalServerError('Unexpected error value');
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Errors raised while executing a field have a path; request errors (syntax, validation, variable
|
|
200
|
+
// coercion) never do. GraphQL servers pass the GraphQLError that graphql-js created around the value
|
|
201
|
+
// a resolver threw, and that value is classified, without unwrapping a GraphQLError the resolver
|
|
202
|
+
// threw any further. A thrown GraphQLError that already has a path is passed as is.
|
|
203
|
+
// graphql-js raises its own code-less GraphQLErrors there too when it cannot complete a value (scalar
|
|
204
|
+
// or enum serialization, isTypeOf, abstract type resolution, a non-iterable list), and some print the
|
|
205
|
+
// value. So a GraphQLError raised there is an application error only with its own code or a
|
|
206
|
+
// SimfinityError cause; any other is unexpected.
|
|
207
|
+
const classifyError = (error) => {
|
|
208
|
+
if (!(error instanceof GraphQLError)) return classifyValue(error);
|
|
209
|
+
if (!Array.isArray(error.path)) {
|
|
210
|
+
// Scalar validation errors can wrap a plain Error unchanged, with or without nodes. A server
|
|
211
|
+
// may report a source-stream failure in the same shape, so only an explicit internal cause
|
|
212
|
+
// identifies it as unexpected; applications must mark those failures at their source.
|
|
213
|
+
const cause = simfinityCauseOf(error);
|
|
214
|
+
return cause instanceof InternalServerError ? cause : classifyValue(error);
|
|
215
|
+
}
|
|
216
|
+
const thrown = isFieldErrorWrapper(error) ? thrownValueOf(error.originalError) : error;
|
|
217
|
+
if (thrown instanceof GraphQLError && typeof thrown.extensions?.code !== 'string' && !simfinityCauseOf(thrown)) {
|
|
218
|
+
return new InternalServerError(thrown.message, thrown);
|
|
219
|
+
}
|
|
220
|
+
return classifyValue(thrown);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const buildErrorFormatter = (callback) => (error) => {
|
|
224
|
+
const classified = classifyError(error);
|
|
225
|
+
const chosen = (callback && callback(classified)) || classified;
|
|
226
|
+
if (chosen instanceof GraphQLError) return chosen;
|
|
227
|
+
const located = error instanceof GraphQLError ? error : null;
|
|
228
|
+
return new GraphQLError(chosen.message, {
|
|
229
|
+
nodes: located?.nodes,
|
|
230
|
+
source: located?.source,
|
|
231
|
+
positions: located?.positions,
|
|
232
|
+
path: located?.path,
|
|
233
|
+
originalError: chosen,
|
|
234
|
+
extensions: chosen.extensions && typeof chosen.extensions === 'object' ? { ...chosen.extensions } : {},
|
|
235
|
+
});
|
|
124
236
|
};
|
|
125
237
|
|
|
126
238
|
export { SimfinityError, InternalServerError };
|
|
@@ -152,6 +264,7 @@ export const createRuntime = (adapter) => {
|
|
|
152
264
|
}
|
|
153
265
|
|
|
154
266
|
const stateValue = adapter.stateValue || ((state) => state.name);
|
|
267
|
+
const runtimeIdentity = {};
|
|
155
268
|
const typesDict = { types: {} };
|
|
156
269
|
const waitingInputType = {};
|
|
157
270
|
const typesDictForUpdate = { types: {} };
|
|
@@ -167,6 +280,9 @@ export const createRuntime = (adapter) => {
|
|
|
167
280
|
};
|
|
168
281
|
|
|
169
282
|
const use = (middleware) => {
|
|
283
|
+
if (typeof middleware !== 'function') {
|
|
284
|
+
throw new SimfinityError('Middleware must be a function', 'INVALID_MIDDLEWARE', 500);
|
|
285
|
+
}
|
|
170
286
|
middlewares.push(middleware);
|
|
171
287
|
};
|
|
172
288
|
|
|
@@ -176,6 +292,69 @@ export const createRuntime = (adapter) => {
|
|
|
176
292
|
preventCollectionCreation = !!prevent;
|
|
177
293
|
};
|
|
178
294
|
|
|
295
|
+
const assertNotBoundElsewhere = (gqltype) => {
|
|
296
|
+
const owner = runtimeBoundTypes.get(gqltype);
|
|
297
|
+
if (owner !== undefined && owner !== runtimeIdentity) {
|
|
298
|
+
throw new SimfinityError(
|
|
299
|
+
`Type ${gqltype.name} is bound to another Simfinity runtime. Its relation fields resolve through the runtime `
|
|
300
|
+
+ 'that first created a schema reaching the type; define separate GraphQL types for each runtime, for example with a factory function',
|
|
301
|
+
'TYPE_BOUND_TO_OTHER_RUNTIME',
|
|
302
|
+
409,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const isForeign = (owner) => owner !== undefined && owner !== runtimeIdentity;
|
|
308
|
+
// A field copied, with toConfig() or a field spread, from a type after another runtime generated
|
|
309
|
+
// its relation resolver keeps that resolver or its extensions object. The extensions object counts
|
|
310
|
+
// only for a field whose resolver is untagged, such as one other code wrapped in place. A field
|
|
311
|
+
// without a resolver gets this runtime's own. graphql-js never runs interface field resolvers, so
|
|
312
|
+
// interface fields are not checked; the types they reference are.
|
|
313
|
+
const assertFieldsNotBoundElsewhere = (gqltype) => {
|
|
314
|
+
if (gqltype instanceof GraphQLInterfaceType) return;
|
|
315
|
+
for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
|
|
316
|
+
if (!field.resolve) continue;
|
|
317
|
+
const resolverOwner = relationFieldOwners.get(field.resolve);
|
|
318
|
+
if (isForeign(resolverOwner)
|
|
319
|
+
|| (resolverOwner === undefined && isForeign(relationFieldOwners.get(field.extensions)))) {
|
|
320
|
+
throw new SimfinityError(
|
|
321
|
+
`Field ${gqltype.name}.${fieldName} was copied from a relation field that another Simfinity runtime generated, `
|
|
322
|
+
+ 'with its resolver or its extensions; define the field for each runtime instead of copying it from a type '
|
|
323
|
+
+ 'that runtime already used',
|
|
324
|
+
'TYPE_BOUND_TO_OTHER_RUNTIME',
|
|
325
|
+
409,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// Object and interface types a schema of this runtime can reach from its registered types and
|
|
332
|
+
// custom mutation results, following the same references as GraphQLSchema.
|
|
333
|
+
const collectSchemaOutputTypes = () => {
|
|
334
|
+
const visited = new Set();
|
|
335
|
+
const outputTypes = [];
|
|
336
|
+
const visit = (type) => {
|
|
337
|
+
const named = type && getNamedType(type);
|
|
338
|
+
if (!named || visited.has(named)) return;
|
|
339
|
+
visited.add(named);
|
|
340
|
+
if (named instanceof GraphQLUnionType) {
|
|
341
|
+
named.getTypes().forEach(visit);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!(named instanceof GraphQLObjectType || named instanceof GraphQLInterfaceType)) return;
|
|
345
|
+
outputTypes.push(named);
|
|
346
|
+
named.getInterfaces().forEach(visit);
|
|
347
|
+
for (const field of Object.values(named.getFields())) visit(field.type);
|
|
348
|
+
};
|
|
349
|
+
for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
|
|
350
|
+
for (const { outputModel } of Object.values(registeredMutations)) visit(outputModel);
|
|
351
|
+
return outputTypes;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const hasUnresolvedRelation = (gqltype) => Object.values(gqltype.getFields()).some(
|
|
355
|
+
(field) => field.extensions?.relation && !field.extensions.relation.embedded && !field.resolve,
|
|
356
|
+
);
|
|
357
|
+
|
|
179
358
|
const QLFilter = new GraphQLInputObjectType({
|
|
180
359
|
name: 'QLFilter',
|
|
181
360
|
fields: () => ({
|
|
@@ -402,12 +581,13 @@ const createOneToManyInputType = (inputNamePrefix, fieldEntryName,
|
|
|
402
581
|
};
|
|
403
582
|
|
|
404
583
|
const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
|
|
405
|
-
inputNamePrefix, connectionField, preserveOuterNonNull) => {
|
|
584
|
+
inputNamePrefix, connectionField, preserveOuterNonNull, ownerName) => {
|
|
406
585
|
const listShape = getListShape(fieldEntry.type);
|
|
407
586
|
if (!listShape) return null;
|
|
408
587
|
const { itemType } = listShape;
|
|
409
588
|
|
|
410
|
-
if (itemType instanceof GraphQLObjectType
|
|
589
|
+
if (itemType instanceof GraphQLObjectType
|
|
590
|
+
&& requireRegisteredType(dict, itemType, fieldEntryName, ownerName).inputType) {
|
|
411
591
|
if (!fieldEntry.extensions || !fieldEntry.extensions.relation
|
|
412
592
|
|| !fieldEntry.extensions.relation.embedded) {
|
|
413
593
|
const oneToMany = createOneToManyInputType(inputNamePrefix, fieldEntryName,
|
|
@@ -471,7 +651,8 @@ const buildInputType = (gqltype) => {
|
|
|
471
651
|
fieldArg.type = fieldEntry.type instanceof GraphQLNonNull
|
|
472
652
|
? new GraphQLNonNull(IdInputType) : IdInputType;
|
|
473
653
|
fieldArgForUpdate.type = IdInputType;
|
|
474
|
-
} else if (typesDict.
|
|
654
|
+
} else if (requireRegisteredType(typesDict, unwrapNonNull(fieldEntry.type), fieldEntryName,
|
|
655
|
+
gqltype.name).inputType
|
|
475
656
|
&& typesDictForUpdate.types[fieldEntryNameValue].inputType) {
|
|
476
657
|
fieldArg.type = typesDict.types[fieldEntryNameValue].inputType;
|
|
477
658
|
fieldArgForUpdate.type = typesDictForUpdate.types[fieldEntryNameValue].inputType;
|
|
@@ -488,10 +669,10 @@ const buildInputType = (gqltype) => {
|
|
|
488
669
|
} else {
|
|
489
670
|
const listInputTypeForAdd = graphQLListInputType(typesDict, fieldEntry,
|
|
490
671
|
fieldEntryName, `${gqltype.name}A`,
|
|
491
|
-
fieldEntry.extensions?.relation?.connectionField, true);
|
|
672
|
+
fieldEntry.extensions?.relation?.connectionField, true, gqltype.name);
|
|
492
673
|
const listInputTypeForUpdate = graphQLListInputType(typesDictForUpdate, fieldEntry,
|
|
493
674
|
fieldEntryName, `${gqltype.name}U`,
|
|
494
|
-
fieldEntry.extensions?.relation?.connectionField, false);
|
|
675
|
+
fieldEntry.extensions?.relation?.connectionField, false, gqltype.name);
|
|
495
676
|
if (listInputTypeForAdd && listInputTypeForUpdate) {
|
|
496
677
|
fieldArg.type = listInputTypeForAdd;
|
|
497
678
|
fieldArgForUpdate.type = listInputTypeForUpdate;
|
|
@@ -904,8 +1085,34 @@ const saveObject = async (typeName, args, session, context) => {
|
|
|
904
1085
|
), type.model);
|
|
905
1086
|
};
|
|
906
1087
|
|
|
1088
|
+
// Counts the added, updated and deleted entries of referenced collections, at every nesting level that
|
|
1089
|
+
// iterateOnCollectionFields executes, and stops once the configured maximum is exceeded.
|
|
1090
|
+
const assertNestedOperations = (gqltype, input, limit, state = { count: 0 }) => {
|
|
1091
|
+
if (!input || typeof input !== 'object') return;
|
|
1092
|
+
for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
|
|
1093
|
+
const value = input[fieldName];
|
|
1094
|
+
const relation = field.extensions?.relation;
|
|
1095
|
+
const listShape = getListShape(field.type);
|
|
1096
|
+
if (value == null || !relation || relation.embedded || !listShape
|
|
1097
|
+
|| !(listShape.itemType instanceof GraphQLObjectType)) continue;
|
|
1098
|
+
for (const key of ['added', 'updated', 'deleted']) {
|
|
1099
|
+
if (!Array.isArray(value[key])) continue;
|
|
1100
|
+
state.count += value[key].length;
|
|
1101
|
+
if (state.count > limit) {
|
|
1102
|
+
throw new SimfinityError(`A mutation may perform at most ${limit} nested collection operations`,
|
|
1103
|
+
'NESTED_OPERATIONS_EXCEEDED', 400);
|
|
1104
|
+
}
|
|
1105
|
+
if (key !== 'deleted') {
|
|
1106
|
+
for (const item of value[key]) assertNestedOperations(listShape.itemType, item, limit, state);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
|
|
907
1112
|
const executeOperation = (Model, gqltype, controller, args, operation, actionField,
|
|
908
1113
|
session, context, inputType) => {
|
|
1114
|
+
const limit = getMaxNestedOperations();
|
|
1115
|
+
if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
|
|
909
1116
|
const inputSnapshot = operation === operations.DELETE ? args : cloneInput(args, inputType);
|
|
910
1117
|
return adapter.withTransaction(
|
|
911
1118
|
session,
|
|
@@ -982,39 +1189,41 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
|
|
|
982
1189
|
const shouldNotBeIncludedInSchema = (includedTypes,
|
|
983
1190
|
type) => includedTypes && !includedTypes.includes(type);
|
|
984
1191
|
|
|
1192
|
+
// Runs the chain in registration order. The rest of the chain runs at most once, and is awaited
|
|
1193
|
+
// even when a middleware calls next() without awaiting it, so its errors still cancel the operation
|
|
1194
|
+
// and never become unhandled rejections; a middleware that fails after starting it rejects only once
|
|
1195
|
+
// it settles. A next() called after the middleware finished does nothing, so later middleware never
|
|
1196
|
+
// runs after the operation.
|
|
985
1197
|
const executeMiddleware = async (context) => {
|
|
986
|
-
const
|
|
987
|
-
if (
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1198
|
+
const run = async (index) => {
|
|
1199
|
+
if (index >= middlewares.length) return;
|
|
1200
|
+
let downstream;
|
|
1201
|
+
let finished = false;
|
|
1202
|
+
const next = () => {
|
|
1203
|
+
if (!downstream && !finished) {
|
|
1204
|
+
downstream = run(index + 1);
|
|
1205
|
+
// Handled here because the middleware may await other work first; awaited below.
|
|
1206
|
+
downstream.catch(() => {});
|
|
994
1207
|
}
|
|
1208
|
+
return downstream ?? Promise.resolve();
|
|
995
1209
|
};
|
|
1210
|
+
try {
|
|
1211
|
+
await middlewares[index](context, next);
|
|
1212
|
+
} catch (error) {
|
|
1213
|
+
finished = true;
|
|
1214
|
+
if (downstream) await downstream.catch(() => {});
|
|
1215
|
+
throw error;
|
|
1216
|
+
}
|
|
1217
|
+
finished = true;
|
|
1218
|
+
if (downstream) await downstream;
|
|
996
1219
|
};
|
|
997
|
-
|
|
998
|
-
await buildNext(middlewares)();
|
|
1220
|
+
await run(0);
|
|
999
1221
|
};
|
|
1000
1222
|
|
|
1001
1223
|
const executeScope = async (params) => {
|
|
1002
1224
|
const { type, args, operation, context } = params;
|
|
1003
|
-
|
|
1004
|
-
if (!
|
|
1005
|
-
return null;
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
const extensions = type.gqltype.extensions;
|
|
1009
|
-
if (!extensions.scope || !extensions.scope[operation]) {
|
|
1010
|
-
return null;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
const scopeFunction = extensions.scope[operation];
|
|
1014
|
-
if (typeof scopeFunction !== 'function') {
|
|
1015
|
-
return null;
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1225
|
+
const scopeFunction = getScope(type?.gqltype, operation);
|
|
1226
|
+
if (!scopeFunction) return null;
|
|
1018
1227
|
return scopeFunction({ type, args, operation, context });
|
|
1019
1228
|
};
|
|
1020
1229
|
|
|
@@ -1049,7 +1258,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
|
|
|
1049
1258
|
const relation = field.extensions?.relation;
|
|
1050
1259
|
if (!relation || relation.embedded) return;
|
|
1051
1260
|
const target = typesDict.types[unwrapListAndNonNull(field.type).name];
|
|
1052
|
-
if (
|
|
1261
|
+
if (!getScope(target?.gqltype, 'find')) return;
|
|
1053
1262
|
if (!joinedScopes.has(path)) {
|
|
1054
1263
|
joinedScopes.set(path, {
|
|
1055
1264
|
target,
|
|
@@ -1210,7 +1419,7 @@ const loadReference = (type, id, requiredId, context) => {
|
|
|
1210
1419
|
const resolveById = async (type, args, context, requiredId) => {
|
|
1211
1420
|
const requestedId = args.id;
|
|
1212
1421
|
await executeMiddleware({ type, args, operation: 'get_by_id', context });
|
|
1213
|
-
if (!type.gqltype
|
|
1422
|
+
if (!getScope(type.gqltype, 'get_by_id')) {
|
|
1214
1423
|
if (requiredId != null && args.id === requestedId && typeof context === 'object' && context !== null
|
|
1215
1424
|
&& typeof adapter.getByIds === 'function' && type.gqltype.getFields().id) {
|
|
1216
1425
|
return loadReference(type, args.id, requiredId, context);
|
|
@@ -1454,6 +1663,11 @@ const markReferencedTypesForModelGeneration = () => {
|
|
|
1454
1663
|
const getRegistrations = () => Object.values(typesDict.types);
|
|
1455
1664
|
|
|
1456
1665
|
const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => {
|
|
1666
|
+
// Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
|
|
1667
|
+
Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
|
|
1668
|
+
const schemaTypes = collectSchemaOutputTypes();
|
|
1669
|
+
schemaTypes.forEach(assertNotBoundElsewhere);
|
|
1670
|
+
schemaTypes.forEach(assertFieldsNotBoundElsewhere);
|
|
1457
1671
|
markReferencedTypesForModelGeneration();
|
|
1458
1672
|
if (adapter.prepare) {
|
|
1459
1673
|
adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
|
|
@@ -1490,10 +1704,18 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
1490
1704
|
}
|
|
1491
1705
|
});
|
|
1492
1706
|
|
|
1493
|
-
|
|
1707
|
+
const schema = new GraphQLSchema({
|
|
1494
1708
|
query,
|
|
1495
1709
|
mutation: buildMutation('Mutation', includedMutationTypes, includedCustomMutations),
|
|
1496
1710
|
});
|
|
1711
|
+
// Reserve reachable types whose relation fields still have no resolver, such as an unregistered
|
|
1712
|
+
// custom mutation result, so another runtime cannot later generate resolvers this schema executes.
|
|
1713
|
+
schemaTypes.forEach((type) => {
|
|
1714
|
+
if (type instanceof GraphQLObjectType && hasUnresolvedRelation(type)) {
|
|
1715
|
+
runtimeBoundTypes.set(type, runtimeIdentity);
|
|
1716
|
+
}
|
|
1717
|
+
});
|
|
1718
|
+
return schema;
|
|
1497
1719
|
};
|
|
1498
1720
|
|
|
1499
1721
|
const getModel = (gqltype) => typesDict.types[gqltype.name]?.model;
|
|
@@ -1517,7 +1739,18 @@ const registerMutation = (name, description, inputModel, outputModel, callback)
|
|
|
1517
1739
|
};
|
|
1518
1740
|
};
|
|
1519
1741
|
|
|
1742
|
+
// Gives the field a new extensions object as well as the resolver, so a copy made before keeps
|
|
1743
|
+
// untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
|
|
1744
|
+
// copying it, when a type is rebuilt from toConfig().
|
|
1745
|
+
const claimRelationField = (fieldEntry, resolve, extensions = {}) => {
|
|
1746
|
+
fieldEntry.extensions = Object.assign(Object.create(null), fieldEntry.extensions, extensions);
|
|
1747
|
+
fieldEntry.resolve = markGenerated(resolve);
|
|
1748
|
+
relationFieldOwners.set(fieldEntry.extensions, runtimeIdentity);
|
|
1749
|
+
relationFieldOwners.set(fieldEntry.resolve, runtimeIdentity);
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1520
1752
|
const autoGenerateResolvers = (gqltype) => {
|
|
1753
|
+
assertFieldsNotBoundElsewhere(gqltype);
|
|
1521
1754
|
const fields = gqltype.getFields();
|
|
1522
1755
|
|
|
1523
1756
|
for (const [fieldName, fieldEntry] of Object.entries(fields)) {
|
|
@@ -1532,16 +1765,12 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1532
1765
|
relatedType,
|
|
1533
1766
|
relation.connectionField || fieldName,
|
|
1534
1767
|
);
|
|
1535
|
-
const relatedTypeInfo = typesDict
|
|
1768
|
+
const relatedTypeInfo = requireRegisteredType(typesDict, relatedType, fieldName, gqltype.name);
|
|
1536
1769
|
const argsObject = createArgsForQuery(relatedTypeInfo.gqltype.getFields());
|
|
1537
1770
|
if (connection.graphqlFieldName) delete argsObject[connection.graphqlFieldName];
|
|
1538
1771
|
|
|
1539
1772
|
fieldEntry.args = formatArgs(Object.entries(argsObject));
|
|
1540
|
-
fieldEntry
|
|
1541
|
-
...fieldEntry.extensions,
|
|
1542
|
-
simfinityQuery: { typeName: relatedType.name, operation: 'find' },
|
|
1543
|
-
};
|
|
1544
|
-
fieldEntry.resolve = markGenerated(async (parent, args, context) => {
|
|
1773
|
+
claimRelationField(fieldEntry, async (parent, args, context) => {
|
|
1545
1774
|
if (!relatedTypeInfo || !relatedTypeInfo.model) {
|
|
1546
1775
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
1547
1776
|
}
|
|
@@ -1558,13 +1787,14 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1558
1787
|
params.args,
|
|
1559
1788
|
null,
|
|
1560
1789
|
);
|
|
1561
|
-
});
|
|
1790
|
+
}, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
|
|
1791
|
+
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
1562
1792
|
} else if (fieldEntry.type instanceof GraphQLObjectType
|
|
1563
1793
|
|| (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType)) {
|
|
1564
1794
|
const relatedType = unwrapNonNull(fieldEntry.type);
|
|
1565
1795
|
const connectionField = relation.connectionField || fieldName;
|
|
1566
1796
|
|
|
1567
|
-
fieldEntry
|
|
1797
|
+
claimRelationField(fieldEntry, async (parent, args, context) => {
|
|
1568
1798
|
const relatedTypeInfo = typesDict.types[relatedType.name];
|
|
1569
1799
|
if (!relatedTypeInfo || !relatedTypeInfo.model) {
|
|
1570
1800
|
throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
|
|
@@ -1573,6 +1803,7 @@ const autoGenerateResolvers = (gqltype) => {
|
|
|
1573
1803
|
const id = relatedId?._id || relatedId;
|
|
1574
1804
|
return id ? resolveById(relatedTypeInfo, { id: String(id) }, context, id) : null;
|
|
1575
1805
|
});
|
|
1806
|
+
runtimeBoundTypes.set(gqltype, runtimeIdentity);
|
|
1576
1807
|
}
|
|
1577
1808
|
}
|
|
1578
1809
|
};
|
|
@@ -1589,6 +1820,8 @@ const connect = (model, gqltype, simpleEntityEndpointName,
|
|
|
1589
1820
|
stateMachine,
|
|
1590
1821
|
onModelCreated,
|
|
1591
1822
|
};
|
|
1823
|
+
assertNotBoundElsewhere(gqltype);
|
|
1824
|
+
validateScope(gqltype);
|
|
1592
1825
|
if (adapter.validateRegistration) adapter.validateRegistration(registration);
|
|
1593
1826
|
|
|
1594
1827
|
waitingInputType[gqltype.name] = {
|
|
@@ -1601,6 +1834,7 @@ const connect = (model, gqltype, simpleEntityEndpointName,
|
|
|
1601
1834
|
};
|
|
1602
1835
|
|
|
1603
1836
|
const addNoEndpointType = (gqltype) => {
|
|
1837
|
+
assertNotBoundElsewhere(gqltype);
|
|
1604
1838
|
const fields = gqltype.getFields();
|
|
1605
1839
|
let needsModel = false;
|
|
1606
1840
|
for (const fieldEntry of Object.values(fields)) {
|
|
@@ -1616,6 +1850,7 @@ const addNoEndpointType = (gqltype) => {
|
|
|
1616
1850
|
const registration = {
|
|
1617
1851
|
gqltype, endpoint: false, model: null, needsModel,
|
|
1618
1852
|
};
|
|
1853
|
+
validateScope(gqltype);
|
|
1619
1854
|
if (adapter.validateRegistration) adapter.validateRegistration(registration);
|
|
1620
1855
|
|
|
1621
1856
|
waitingInputType[gqltype.name] = { gqltype };
|
|
@@ -1683,6 +1918,7 @@ function formatArgs(argsArray) {
|
|
|
1683
1918
|
|
|
1684
1919
|
return {
|
|
1685
1920
|
configureQueryLimits,
|
|
1921
|
+
configureMutationLimits,
|
|
1686
1922
|
connect,
|
|
1687
1923
|
addNoEndpointType,
|
|
1688
1924
|
createSchema,
|
package/src/scalars/factory.js
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { GraphQLScalarType, GraphQLString, GraphQLInt, GraphQLFloat, GraphQLBoolean, GraphQLID, Kind } from 'graphql';
|
|
2
2
|
|
|
3
|
+
// The base each validated scalar delegates parsing to. A base exists before its scalar, so these links cannot cycle.
|
|
4
|
+
const validatedBases = new WeakMap();
|
|
5
|
+
|
|
6
|
+
// Follow validated-scalar chains to the scalar that defines the literal syntax. Any other scalar is the root, even
|
|
7
|
+
// with a hand-set `baseScalarType` storage hint, because its own parseLiteral decides which literals it takes.
|
|
8
|
+
const findRootScalar = (scalarType) => {
|
|
9
|
+
let root = scalarType;
|
|
10
|
+
while (validatedBases.has(root)) {
|
|
11
|
+
root = validatedBases.get(root);
|
|
12
|
+
}
|
|
13
|
+
return root;
|
|
14
|
+
};
|
|
15
|
+
|
|
3
16
|
export function createValidatedScalar(name, description, baseScalarType, validate) {
|
|
4
17
|
if (!baseScalarType) {
|
|
5
18
|
throw new Error('baseScalarType is required');
|
|
@@ -16,16 +29,21 @@ export function createValidatedScalar(name, description, baseScalarType, validat
|
|
|
16
29
|
throw new Error('baseScalarType must be a standard GraphQL scalar type or a custom scalar with a valid name');
|
|
17
30
|
}
|
|
18
31
|
|
|
32
|
+
// Float takes integer literals too, as GraphQLFloat does, so an integral default printed as `5` stays valid.
|
|
19
33
|
const kindMap = {
|
|
20
|
-
String: Kind.STRING,
|
|
21
|
-
Int: Kind.INT,
|
|
22
|
-
Float: Kind.FLOAT,
|
|
23
|
-
Boolean: Kind.BOOLEAN,
|
|
24
|
-
ID: Kind.STRING,
|
|
34
|
+
String: [Kind.STRING],
|
|
35
|
+
Int: [Kind.INT],
|
|
36
|
+
Float: [Kind.FLOAT, Kind.INT],
|
|
37
|
+
Boolean: [Kind.BOOLEAN],
|
|
38
|
+
ID: [Kind.STRING],
|
|
25
39
|
};
|
|
26
40
|
|
|
27
|
-
|
|
41
|
+
// Custom root scalars define their own literal kinds, so their parseLiteral decides.
|
|
42
|
+
const rootScalarType = findRootScalar(baseScalarType);
|
|
43
|
+
const baseKinds = Object.hasOwn(kindMap, rootScalarType.name) ? kindMap[rootScalarType.name] : undefined;
|
|
28
44
|
|
|
45
|
+
// Input is parsed by the base first, so validators judge the same internal value for variables and literals.
|
|
46
|
+
// An undefined result is the base rejecting the input; GraphQL reports it without running the validator.
|
|
29
47
|
const scalar = new GraphQLScalarType({
|
|
30
48
|
name: `${name}_${baseScalarType.name}`,
|
|
31
49
|
description,
|
|
@@ -34,19 +52,21 @@ export function createValidatedScalar(name, description, baseScalarType, validat
|
|
|
34
52
|
return baseScalarType.serialize(value);
|
|
35
53
|
},
|
|
36
54
|
parseValue(value) {
|
|
37
|
-
|
|
38
|
-
|
|
55
|
+
const parsed = baseScalarType.parseValue(value);
|
|
56
|
+
if (parsed !== undefined) validate(parsed);
|
|
57
|
+
return parsed;
|
|
39
58
|
},
|
|
40
59
|
parseLiteral(ast, variables) {
|
|
41
|
-
if (ast.kind
|
|
60
|
+
if (baseKinds !== undefined && !baseKinds.includes(ast.kind)) {
|
|
42
61
|
throw new Error(`${name}_${baseScalarType.name} must be a ${baseScalarType.name}`);
|
|
43
62
|
}
|
|
44
63
|
const value = baseScalarType.parseLiteral(ast, variables);
|
|
45
|
-
validate(value);
|
|
64
|
+
if (value !== undefined) validate(value);
|
|
46
65
|
return value;
|
|
47
66
|
},
|
|
48
67
|
});
|
|
49
68
|
|
|
50
69
|
scalar.baseScalarType = baseScalarType;
|
|
70
|
+
validatedBases.set(scalar, baseScalarType);
|
|
51
71
|
return scalar;
|
|
52
72
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A cross-realm RegExp fails `instanceof RegExp` but keeps this tag.
|
|
2
|
+
const isRegExp = (value) => Object.prototype.toString.call(value) === '[object RegExp]';
|
|
3
|
+
|
|
4
|
+
/** Private RegExp copy shared by `validators.pattern()` and `createPatternStringScalar()`; not a published subpath. */
|
|
5
|
+
export const compilePattern = (pattern) => {
|
|
6
|
+
if (typeof pattern !== 'string' && !isRegExp(pattern)) {
|
|
7
|
+
throw new TypeError('pattern must be a RegExp or a string');
|
|
8
|
+
}
|
|
9
|
+
return new RegExp(pattern);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Tests each value from its first character, so `g` and `y` keep no state between values. */
|
|
13
|
+
export const matchesPattern = (regex, value) => {
|
|
14
|
+
regex.lastIndex = 0;
|
|
15
|
+
return regex.test(value);
|
|
16
|
+
};
|
package/src/scalars.js
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
} from 'graphql';
|
|
4
4
|
import { createValidatedScalar } from './scalars/factory.js';
|
|
5
5
|
import { isEmailFormat } from './scalars/email.js';
|
|
6
|
+
import { compilePattern, matchesPattern } from './scalars/pattern.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Email scalar - validates email format
|
|
@@ -152,7 +153,7 @@ export const createBoundedFloatScalar = (name, min, max) => {
|
|
|
152
153
|
* @returns {GraphQLScalarType} A scalar type with pattern validation
|
|
153
154
|
*/
|
|
154
155
|
export const createPatternStringScalar = (name, pattern, message) => {
|
|
155
|
-
const regex =
|
|
156
|
+
const regex = compilePattern(pattern);
|
|
156
157
|
const errorMessage = message || 'Value does not match required pattern';
|
|
157
158
|
|
|
158
159
|
return createValidatedScalar(
|
|
@@ -163,7 +164,7 @@ export const createPatternStringScalar = (name, pattern, message) => {
|
|
|
163
164
|
if (typeof value !== 'string') {
|
|
164
165
|
throw new Error('Value must be a string');
|
|
165
166
|
}
|
|
166
|
-
if (!regex
|
|
167
|
+
if (!matchesPattern(regex, value)) {
|
|
167
168
|
throw new Error(errorMessage);
|
|
168
169
|
}
|
|
169
170
|
},
|