@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/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, Kind,
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
- export const buildErrorFormatter = (callback) => {
109
- const formatError = (err) => {
110
- let result = null;
111
- if (err instanceof SimfinityError) {
112
- result = err;
113
- } else {
114
- result = new InternalServerError(err.message, err);
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
- if (callback) {
118
- const formattedError = callback(result);
119
- return formattedError || result;
120
- }
121
- return result;
122
- };
123
- return formatError;
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 && dict.types[itemType.name].inputType) {
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.types[fieldEntryNameValue].inputType
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 buildNext = (middlewaresParam) => {
987
- if (!middlewaresParam) {
988
- return async () => {};
989
- }
990
- return async () => {
991
- const middleware = middlewaresParam[0];
992
- if (middleware) {
993
- await middleware(context, buildNext(middlewaresParam.slice(1)));
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 (!type || !type.gqltype || !type.gqltype.extensions) {
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 (typeof target?.gqltype?.extensions?.scope?.find !== 'function') return;
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.extensions?.scope?.get_by_id) {
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
- return new GraphQLSchema({
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.types[relatedType.name];
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.extensions = {
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.resolve = markGenerated(async (parent, args, context) => {
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,
@@ -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
- const baseKind = kindMap[baseScalarType.name] || Kind.STRING;
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
- validate(value);
38
- return baseScalarType.parseValue(value);
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 !== baseKind) {
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 = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
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.test(value)) {
167
+ if (!matchesPattern(regex, value)) {
167
168
  throw new Error(errorMessage);
168
169
  }
169
170
  },