@simtlix/simfinity-core 3.5.2 → 3.5.5

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
- import { configureQueryLimits, getQueryMaxPageSize } from './query-limits.js';
1
+ import { configureQueryLimits, getQueryMaxPageSize, paginationStages } 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, defaultFieldResolver, getNamedType,
6
8
  } from 'graphql';
7
9
 
8
10
  import SimfinityError from './errors/simfinity.error.js';
@@ -11,6 +13,11 @@ 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';
17
+ import { isThenable } from './auth/thenable.js';
18
+ import {
19
+ getFieldStorageName, getListShape, markStoredIdentity, normalizeConnectionField,
20
+ } from './relation-storage.js';
14
21
  import './introspection.js';
15
22
 
16
23
  // Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
@@ -28,8 +35,73 @@ const listApplicationResolvedFields = (gqltype) => new Set(Object.entries(gqltyp
28
35
  const hasApplicationResolver = (gqltype, fieldName) => (
29
36
  applicationResolvedFields.get(gqltype) || listApplicationResolvedFields(gqltype)
30
37
  ).has(fieldName);
38
+ // Object types whose relation fields hold resolvers and filter arguments generated by one runtime,
39
+ // mapped to that runtime. Those resolvers read through its adapter, registrations and middleware,
40
+ // so a bound type cannot be registered in, or reached from, another runtime's schema.
41
+ const runtimeBoundTypes = new WeakMap();
31
42
  const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER_PATH', 403);
32
- const SCOPE_IGNORED_ARGS = new Set(['sort', 'pagination', 'aggregation']);
43
+
44
+ // Query scopes restrict reads, so a scope Simfinity cannot run must fail rather than be skipped.
45
+ const SCOPE_OPERATIONS = ['find', 'get_by_id', 'aggregate'];
46
+ const invalidScope = (typeName, detail) => new SimfinityError(
47
+ `${typeName} extensions.scope ${detail}`, 'INVALID_SCOPE', 500,
48
+ );
49
+ const validateScope = (gqltype) => {
50
+ const extensions = gqltype.extensions || {};
51
+ if (!Object.hasOwn(extensions, 'scope')) return;
52
+ const { scope } = extensions;
53
+ if (!scope || typeof scope !== 'object' || Array.isArray(scope)
54
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(scope))) {
55
+ throw invalidScope(gqltype.name, 'must be a plain object of find, get_by_id or aggregate functions');
56
+ }
57
+ // Every own string key, enumerable or not, because getScope reads any of them.
58
+ for (const operation of Object.getOwnPropertyNames(scope)) {
59
+ if (!SCOPE_OPERATIONS.includes(operation)) {
60
+ throw invalidScope(gqltype.name, `has unknown operation ${operation}; use find, get_by_id or aggregate`);
61
+ }
62
+ if (typeof scope[operation] !== 'function') {
63
+ throw invalidScope(gqltype.name, `${operation} must be a function`);
64
+ }
65
+ }
66
+ };
67
+
68
+ // Returns the type's scope function for an operation, or null when it declares none. Revalidates
69
+ // on each call because extensions stay mutable after createSchema.
70
+ const getScope = (gqltype, operation) => {
71
+ if (!gqltype?.extensions || !Object.hasOwn(gqltype.extensions, 'scope')) return null;
72
+ validateScope(gqltype);
73
+ const { scope } = gqltype.extensions;
74
+ return Object.hasOwn(scope, operation) ? scope[operation] : null;
75
+ };
76
+
77
+ const requireRegisteredType = (dict, type, fieldName, ownerName) => {
78
+ const registration = dict.types[type.name];
79
+ if (!registration) {
80
+ throw new SimfinityError(`Field ${ownerName ? `${ownerName}.` : ''}${fieldName} references ${type.name}, which is not `
81
+ + 'registered; register it with connect() or addNoEndpointType()', 'UNREGISTERED_RELATION_TARGET', 500);
82
+ }
83
+ return registration;
84
+ };
85
+ // A joined scope is a find scope, so `aggregation` is a field filter there, as on list queries.
86
+ const SCOPE_IGNORED_ARGS = new Set(['sort', 'pagination']);
87
+
88
+ // A joined scope cannot sort or paginate the records it restricts. Its sort and pagination values are
89
+ // checked for shape only (INVALID_SORT: non-empty terms with a field and an ASC or DESC order;
90
+ // INVALID_PAGINATION: as on direct queries), then ignored; sort field names are not resolved.
91
+ const assertScopeControlArg = (name, value) => {
92
+ if (name === 'pagination') {
93
+ paginationStages(value, false);
94
+ return;
95
+ }
96
+ if (!Array.isArray(value.terms) || !value.terms.length) {
97
+ throw new SimfinityError('Sort requires non-empty terms', 'INVALID_SORT', 400);
98
+ }
99
+ for (const term of value.terms) {
100
+ if (typeof term?.field !== 'string' || !['ASC', 'DESC'].includes(term.order)) {
101
+ throw new SimfinityError('Sort terms require a field and an ASC or DESC order', 'INVALID_SORT', 400);
102
+ }
103
+ }
104
+ };
33
105
 
34
106
  // Moves a joined type's scope group below the relation path that entered it.
35
107
  const prefixScopeGroup = (group, prefix) => {
@@ -53,7 +125,11 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
53
125
  const conditions = [];
54
126
  const groups = [];
55
127
  for (const [name, value] of Object.entries(scopeArgs)) {
56
- if (value == null || SCOPE_IGNORED_ARGS.has(name)) continue;
128
+ if (value == null) continue;
129
+ if (SCOPE_IGNORED_ARGS.has(name)) {
130
+ assertScopeControlArg(name, value);
131
+ continue;
132
+ }
57
133
  if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix)));
58
134
  else if (name === 'OR') {
59
135
  const branches = [].concat(value);
@@ -105,22 +181,85 @@ const GraphQLJSON = new GraphQLScalarType({
105
181
  },
106
182
  });
107
183
 
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
- }
184
+ // graphql-js wraps a value thrown while resolving a field (locatedError): the wrapper has the field
185
+ // path and repeats the thrown error's message. A thrown non-Error becomes a NonErrorThrown whose
186
+ // message prints the value.
187
+ const isFieldErrorWrapper = (error) => Array.isArray(error.path) && error.originalError != null
188
+ && error.message === error.originalError.message
189
+ // locatedError adds no code of its own, so a thrown GraphQLError with its own code is not a wrapper.
190
+ && (error.originalError instanceof SimfinityError || typeof error.extensions?.code !== 'string'
191
+ || error.extensions === error.originalError.extensions);
192
+ const thrownValueOf = (error) => (error.name === 'NonErrorThrown' && Object.hasOwn(error, 'thrownValue')
193
+ ? error.thrownValue : error);
194
+
195
+ // The SimfinityError behind a GraphQLError, such as one a custom scalar threw, followed only through
196
+ // GraphQLErrors; any other hidden cause is ignored.
197
+ const simfinityCauseOf = (error) => {
198
+ let cause = error.originalError;
199
+ while (cause instanceof GraphQLError) cause = cause.originalError;
200
+ return cause instanceof SimfinityError ? cause : null;
201
+ };
116
202
 
117
- if (callback) {
118
- const formattedError = callback(result);
119
- return formattedError || result;
120
- }
121
- return result;
122
- };
123
- return formatError;
203
+ // Request errors (syntax, validation, variable coercion) and application GraphQLErrors keep their own
204
+ // message and extensions, such as the http status a server adds. A SimfinityError cause supplies only
205
+ // the code and status, so a scalar's or application's hidden cause never replaces them.
206
+ const classifyGraphQLError = (error) => {
207
+ const simfinity = simfinityCauseOf(error)?.extensions ?? {};
208
+ const extensions = error.extensions || {};
209
+ const code = [simfinity.code, extensions.code].find((value) => typeof value === 'string') ?? 'BAD_REQUEST';
210
+ const status = [simfinity.status, extensions.status].find(Number.isInteger)
211
+ ?? (code === 'INTERNAL_SERVER_ERROR' ? 500 : 400);
212
+ const classified = new SimfinityError(error.message, code, status);
213
+ classified.extensions = { ...extensions, ...classified.extensions };
214
+ return classified;
215
+ };
216
+
217
+ const classifyValue = (value) => {
218
+ if (value instanceof SimfinityError) return value;
219
+ if (value instanceof GraphQLError) return classifyGraphQLError(value);
220
+ // Never keep a non-Error value, such as a server's hook payload with request variables, as the cause.
221
+ return value instanceof Error
222
+ ? new InternalServerError(value.message, value)
223
+ : new InternalServerError('Unexpected error value');
224
+ };
225
+
226
+ // Errors raised while executing a field have a path; request errors (syntax, validation, variable
227
+ // coercion) never do. GraphQL servers pass the GraphQLError that graphql-js created around the value
228
+ // a resolver threw, and that value is classified, without unwrapping a GraphQLError the resolver
229
+ // threw any further. A thrown GraphQLError that already has a path is passed as is.
230
+ // graphql-js raises its own code-less GraphQLErrors there too when it cannot complete a value (scalar
231
+ // or enum serialization, isTypeOf, abstract type resolution, a non-iterable list), and some print the
232
+ // value. So a GraphQLError raised there is an application error only with its own code or a
233
+ // SimfinityError cause; any other is unexpected.
234
+ const classifyError = (error) => {
235
+ if (!(error instanceof GraphQLError)) return classifyValue(error);
236
+ if (!Array.isArray(error.path)) {
237
+ // Scalar validation errors can wrap a plain Error unchanged, with or without nodes. A server
238
+ // may report a source-stream failure in the same shape, so only an explicit internal cause
239
+ // identifies it as unexpected; applications must mark those failures at their source.
240
+ const cause = simfinityCauseOf(error);
241
+ return cause instanceof InternalServerError ? cause : classifyValue(error);
242
+ }
243
+ const thrown = isFieldErrorWrapper(error) ? thrownValueOf(error.originalError) : error;
244
+ if (thrown instanceof GraphQLError && typeof thrown.extensions?.code !== 'string' && !simfinityCauseOf(thrown)) {
245
+ return new InternalServerError(thrown.message, thrown);
246
+ }
247
+ return classifyValue(thrown);
248
+ };
249
+
250
+ export const buildErrorFormatter = (callback) => (error) => {
251
+ const classified = classifyError(error);
252
+ const chosen = (callback && callback(classified)) || classified;
253
+ if (chosen instanceof GraphQLError) return chosen;
254
+ const located = error instanceof GraphQLError ? error : null;
255
+ return new GraphQLError(chosen.message, {
256
+ nodes: located?.nodes,
257
+ source: located?.source,
258
+ positions: located?.positions,
259
+ path: located?.path,
260
+ originalError: chosen,
261
+ extensions: chosen.extensions && typeof chosen.extensions === 'object' ? { ...chosen.extensions } : {},
262
+ });
124
263
  };
125
264
 
126
265
  export { SimfinityError, InternalServerError };
@@ -152,6 +291,7 @@ export const createRuntime = (adapter) => {
152
291
  }
153
292
 
154
293
  const stateValue = adapter.stateValue || ((state) => state.name);
294
+ const runtimeIdentity = {};
155
295
  const typesDict = { types: {} };
156
296
  const waitingInputType = {};
157
297
  const typesDictForUpdate = { types: {} };
@@ -167,6 +307,9 @@ export const createRuntime = (adapter) => {
167
307
  };
168
308
 
169
309
  const use = (middleware) => {
310
+ if (typeof middleware !== 'function') {
311
+ throw new SimfinityError('Middleware must be a function', 'INVALID_MIDDLEWARE', 500);
312
+ }
170
313
  middlewares.push(middleware);
171
314
  };
172
315
 
@@ -176,6 +319,69 @@ export const createRuntime = (adapter) => {
176
319
  preventCollectionCreation = !!prevent;
177
320
  };
178
321
 
322
+ const assertNotBoundElsewhere = (gqltype) => {
323
+ const owner = runtimeBoundTypes.get(gqltype);
324
+ if (owner !== undefined && owner !== runtimeIdentity) {
325
+ throw new SimfinityError(
326
+ `Type ${gqltype.name} is bound to another Simfinity runtime. Its relation fields resolve through the runtime `
327
+ + 'that first created a schema reaching the type; define separate GraphQL types for each runtime, for example with a factory function',
328
+ 'TYPE_BOUND_TO_OTHER_RUNTIME',
329
+ 409,
330
+ );
331
+ }
332
+ };
333
+
334
+ const isForeign = (owner) => owner !== undefined && owner !== runtimeIdentity;
335
+ // A field copied, with toConfig() or a field spread, from a type after another runtime generated
336
+ // its relation resolver keeps that resolver or its extensions object. The extensions object counts
337
+ // only for a field whose resolver is untagged, such as one other code wrapped in place. A field
338
+ // without a resolver gets this runtime's own. graphql-js never runs interface field resolvers, so
339
+ // interface fields are not checked; the types they reference are.
340
+ const assertFieldsNotBoundElsewhere = (gqltype) => {
341
+ if (gqltype instanceof GraphQLInterfaceType) return;
342
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
343
+ if (!field.resolve) continue;
344
+ const resolverOwner = relationFieldOwners.get(field.resolve);
345
+ if (isForeign(resolverOwner)
346
+ || (resolverOwner === undefined && isForeign(relationFieldOwners.get(field.extensions)))) {
347
+ throw new SimfinityError(
348
+ `Field ${gqltype.name}.${fieldName} was copied from a relation field that another Simfinity runtime generated, `
349
+ + 'with its resolver or its extensions; define the field for each runtime instead of copying it from a type '
350
+ + 'that runtime already used',
351
+ 'TYPE_BOUND_TO_OTHER_RUNTIME',
352
+ 409,
353
+ );
354
+ }
355
+ }
356
+ };
357
+
358
+ // Object and interface types a schema of this runtime can reach from its registered types and
359
+ // custom mutation results, following the same references as GraphQLSchema.
360
+ const collectSchemaOutputTypes = () => {
361
+ const visited = new Set();
362
+ const outputTypes = [];
363
+ const visit = (type) => {
364
+ const named = type && getNamedType(type);
365
+ if (!named || visited.has(named)) return;
366
+ visited.add(named);
367
+ if (named instanceof GraphQLUnionType) {
368
+ named.getTypes().forEach(visit);
369
+ return;
370
+ }
371
+ if (!(named instanceof GraphQLObjectType || named instanceof GraphQLInterfaceType)) return;
372
+ outputTypes.push(named);
373
+ named.getInterfaces().forEach(visit);
374
+ for (const field of Object.values(named.getFields())) visit(field.type);
375
+ };
376
+ for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
377
+ for (const { outputModel } of Object.values(registeredMutations)) visit(outputModel);
378
+ return outputTypes;
379
+ };
380
+
381
+ const hasUnresolvedRelation = (gqltype) => Object.values(gqltype.getFields()).some(
382
+ (field) => field.extensions?.relation && !field.extensions.relation.embedded && !field.resolve,
383
+ );
384
+
179
385
  const QLFilter = new GraphQLInputObjectType({
180
386
  name: 'QLFilter',
181
387
  fields: () => ({
@@ -296,46 +502,6 @@ const unwrapListAndNonNull = (type) => {
296
502
  return unwrapped;
297
503
  };
298
504
 
299
- const getListShape = (type) => {
300
- const outerNonNull = type instanceof GraphQLNonNull;
301
- const listType = unwrapNonNull(type);
302
- if (!(listType instanceof GraphQLList)) return null;
303
- const itemNonNull = listType.ofType instanceof GraphQLNonNull;
304
- return {
305
- outerNonNull,
306
- itemNonNull,
307
- itemType: unwrapNonNull(listType.ofType),
308
- };
309
- };
310
-
311
- const getFieldStorageName = (fieldName, field) => {
312
- const relation = field.extensions?.relation;
313
- return relation && !relation.embedded && !getListShape(field.type)
314
- ? relation.connectionField || fieldName
315
- : fieldName;
316
- };
317
-
318
- const normalizeConnectionField = (childType, declaredFieldName) => {
319
- if (!declaredFieldName) {
320
- return { declaredFieldName, graphqlFieldName: null, storageFieldName: null };
321
- }
322
- const childFields = childType.getFields();
323
- let graphqlFieldName = childFields[declaredFieldName] ? declaredFieldName : null;
324
- if (!graphqlFieldName) {
325
- const aliasedField = Object.entries(childFields).find(([fieldName, field]) => (
326
- getFieldStorageName(fieldName, field) === declaredFieldName
327
- ));
328
- graphqlFieldName = aliasedField?.[0] || null;
329
- }
330
- return {
331
- declaredFieldName,
332
- graphqlFieldName,
333
- storageFieldName: graphqlFieldName
334
- ? getFieldStorageName(graphqlFieldName, childFields[graphqlFieldName])
335
- : declaredFieldName,
336
- };
337
- };
338
-
339
505
  const wrapListInputType = (itemType, listShape, preserveOuterNonNull) => {
340
506
  const wrappedItem = listShape.itemNonNull ? new GraphQLNonNull(itemType) : itemType;
341
507
  const listType = new GraphQLList(wrappedItem);
@@ -402,12 +568,13 @@ const createOneToManyInputType = (inputNamePrefix, fieldEntryName,
402
568
  };
403
569
 
404
570
  const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
405
- inputNamePrefix, connectionField, preserveOuterNonNull) => {
571
+ inputNamePrefix, connectionField, preserveOuterNonNull, ownerName) => {
406
572
  const listShape = getListShape(fieldEntry.type);
407
573
  if (!listShape) return null;
408
574
  const { itemType } = listShape;
409
575
 
410
- if (itemType instanceof GraphQLObjectType && dict.types[itemType.name].inputType) {
576
+ if (itemType instanceof GraphQLObjectType
577
+ && requireRegisteredType(dict, itemType, fieldEntryName, ownerName).inputType) {
411
578
  if (!fieldEntry.extensions || !fieldEntry.extensions.relation
412
579
  || !fieldEntry.extensions.relation.embedded) {
413
580
  const oneToMany = createOneToManyInputType(inputNamePrefix, fieldEntryName,
@@ -471,7 +638,8 @@ const buildInputType = (gqltype) => {
471
638
  fieldArg.type = fieldEntry.type instanceof GraphQLNonNull
472
639
  ? new GraphQLNonNull(IdInputType) : IdInputType;
473
640
  fieldArgForUpdate.type = IdInputType;
474
- } else if (typesDict.types[fieldEntryNameValue].inputType
641
+ } else if (requireRegisteredType(typesDict, unwrapNonNull(fieldEntry.type), fieldEntryName,
642
+ gqltype.name).inputType
475
643
  && typesDictForUpdate.types[fieldEntryNameValue].inputType) {
476
644
  fieldArg.type = typesDict.types[fieldEntryNameValue].inputType;
477
645
  fieldArgForUpdate.type = typesDictForUpdate.types[fieldEntryNameValue].inputType;
@@ -488,10 +656,10 @@ const buildInputType = (gqltype) => {
488
656
  } else {
489
657
  const listInputTypeForAdd = graphQLListInputType(typesDict, fieldEntry,
490
658
  fieldEntryName, `${gqltype.name}A`,
491
- fieldEntry.extensions?.relation?.connectionField, true);
659
+ fieldEntry.extensions?.relation?.connectionField, true, gqltype.name);
492
660
  const listInputTypeForUpdate = graphQLListInputType(typesDictForUpdate, fieldEntry,
493
661
  fieldEntryName, `${gqltype.name}U`,
494
- fieldEntry.extensions?.relation?.connectionField, false);
662
+ fieldEntry.extensions?.relation?.connectionField, false, gqltype.name);
495
663
  if (listInputTypeForAdd && listInputTypeForUpdate) {
496
664
  fieldArg.type = listInputTypeForAdd;
497
665
  fieldArgForUpdate.type = listInputTypeForUpdate;
@@ -640,8 +808,7 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
640
808
  || isNonNullOfType(fieldEntry.type, GraphQLObjectType)) {
641
809
  if (fieldEntry.extensions && fieldEntry.extensions.relation) {
642
810
  if (!fieldEntry.extensions.relation.embedded) {
643
- const connectionField = fieldEntry.extensions.relation.connectionField || fieldEntryName;
644
- modelArgs[connectionField] = adapter.castId(args[fieldEntryName].id);
811
+ modelArgs[getFieldStorageName(fieldEntryName, fieldEntry)] = adapter.castId(args[fieldEntryName].id);
645
812
  } else {
646
813
  const fieldType = fieldEntry.type instanceof GraphQLNonNull
647
814
  ? fieldEntry.type.ofType : fieldEntry.type;
@@ -778,6 +945,25 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
778
945
  }
779
946
  };
780
947
 
948
+ // An explicit null for a nullable member of a singular embedded patch clears that member, as a
949
+ // top-level null clears a field. Lists become empty lists; other members, including nested embedded
950
+ // objects and references (under their storage name), are stored as null rather than removed, since
951
+ // a removed nested object is materialized again from its list defaults. A null for a non-null member
952
+ // keeps the stored value, also as at the top level. Nested objects in the patch replace the stored
953
+ // ones wholesale.
954
+ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
955
+ if (!patch || typeof patch !== 'object') return;
956
+ const members = unwrapNonNull(fieldEntry.type).getFields();
957
+ for (const [memberName, value] of Object.entries(patch)) {
958
+ const member = members[memberName];
959
+ if (value !== null || !member || member.type instanceof GraphQLNonNull) continue;
960
+ const relation = member.extensions?.relation;
961
+ const listShape = getListShape(member.type);
962
+ if (relation && !relation.embedded && listShape) continue;
963
+ merged[getFieldStorageName(memberName, member)] = listShape ? [] : null;
964
+ }
965
+ };
966
+
781
967
  const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
782
968
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
783
969
  const objectId = args.id;
@@ -801,6 +987,8 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
801
987
  completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName], newObjectData);
802
988
  } else {
803
989
  materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
990
+ clearNullEmbeddedMembers(materializedModel.modelArgs[fieldEntryName],
991
+ argTypes[fieldEntryName], args[fieldEntryName]);
804
992
  completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName],
805
993
  materializedModel.modelArgs[fieldEntryName], newObjectData);
806
994
  }
@@ -811,13 +999,9 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
811
999
 
812
1000
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
813
1001
  if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull)) {
814
- const relation = fieldEntry.extensions?.relation;
815
- const storageFieldName = relation && !relation.embedded && !(unwrapNonNull(fieldEntry.type) instanceof GraphQLList)
816
- ? relation.connectionField || fieldEntryName
817
- : fieldEntryName;
818
1002
  materializedModel.modelArgs.$unset = {
819
1003
  ...materializedModel.modelArgs.$unset,
820
- [storageFieldName]: '',
1004
+ [getFieldStorageName(fieldEntryName, fieldEntry)]: '',
821
1005
  };
822
1006
  }
823
1007
  }
@@ -898,14 +1082,81 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
898
1082
 
899
1083
  const saveObject = async (typeName, args, session, context) => {
900
1084
  const type = typesDict.types[typeName];
1085
+ assertCollectionWrites(type.gqltype, args);
901
1086
  const snapshot = cloneInput(args, type.inputType);
902
1087
  return adapter.withTransaction(session, (transaction) => onSaveObject(
903
1088
  type.model, type.gqltype, type.controller, cloneInput(snapshot, type.inputType), transaction, null, context,
904
1089
  ), type.model);
905
1090
  };
906
1091
 
1092
+ // Counts the added, updated and deleted entries of referenced collections, at every nesting level that
1093
+ // iterateOnCollectionFields executes, and stops once the configured maximum is exceeded.
1094
+ const assertNestedOperations = (gqltype, input, limit, state = { count: 0 }) => {
1095
+ if (!input || typeof input !== 'object') return;
1096
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
1097
+ const value = input[fieldName];
1098
+ const relation = field.extensions?.relation;
1099
+ const listShape = getListShape(field.type);
1100
+ if (value == null || !relation || relation.embedded || !listShape
1101
+ || !(listShape.itemType instanceof GraphQLObjectType)) continue;
1102
+ for (const key of ['added', 'updated', 'deleted']) {
1103
+ if (!Array.isArray(value[key])) continue;
1104
+ state.count += value[key].length;
1105
+ if (state.count > limit) {
1106
+ throw new SimfinityError(`A mutation may perform at most ${limit} nested collection operations`,
1107
+ 'NESTED_OPERATIONS_EXCEEDED', 400);
1108
+ }
1109
+ if (key !== 'deleted') {
1110
+ for (const item of value[key]) assertNestedOperations(listShape.itemType, item, limit, state);
1111
+ }
1112
+ }
1113
+ }
1114
+ };
1115
+
1116
+ // A referenced collection links each child to its parent through the child's connectionField.
1117
+ const hasConnectionField = (relation) => typeof relation.connectionField === 'string'
1118
+ && relation.connectionField !== '';
1119
+ const isUnlinkedCollection = (field) => {
1120
+ const relation = field.extensions?.relation;
1121
+ const listShape = getListShape(field.type);
1122
+ return !!relation && !relation.embedded && !!listShape
1123
+ && listShape.itemType instanceof GraphQLObjectType && !hasConnectionField(relation);
1124
+ };
1125
+ // Null entries are skipped by every nested operation, so a list of only nulls writes nothing.
1126
+ const hasNestedItems = (items) => !Array.isArray(items) || items.some((item) => item !== null);
1127
+ const unlinkedCollectionError = (gqltype, fieldName) => new SimfinityError(
1128
+ `${gqltype.name}.${fieldName} cannot store nested items because it has no connectionField`,
1129
+ 'INVALID_MODEL',
1130
+ 500,
1131
+ );
1132
+
1133
+ // Rejects nested items for a collection without a connectionField, at every nesting level that
1134
+ // iterateOnCollectionFields executes, before anything is written or any controller hook runs. Such a
1135
+ // collection only exists with its own resolver or as readOnly; createSchema rejects the others.
1136
+ const assertCollectionWrites = (gqltype, input) => {
1137
+ if (!input || typeof input !== 'object') return;
1138
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
1139
+ const value = input[fieldName];
1140
+ const relation = field.extensions?.relation;
1141
+ const listShape = getListShape(field.type);
1142
+ if (value == null || !relation || relation.embedded || !listShape
1143
+ || !(listShape.itemType instanceof GraphQLObjectType)) continue;
1144
+ for (const key of ['added', 'updated', 'deleted']) {
1145
+ const items = value[key];
1146
+ if (!items) continue;
1147
+ if (!hasConnectionField(relation) && hasNestedItems(items)) throw unlinkedCollectionError(gqltype, fieldName);
1148
+ if (key !== 'deleted' && Array.isArray(items)) {
1149
+ for (const item of items) assertCollectionWrites(listShape.itemType, item);
1150
+ }
1151
+ }
1152
+ }
1153
+ };
1154
+
907
1155
  const executeOperation = (Model, gqltype, controller, args, operation, actionField,
908
1156
  session, context, inputType) => {
1157
+ const limit = getMaxNestedOperations();
1158
+ if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
1159
+ if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
909
1160
  const inputSnapshot = operation === operations.DELETE ? args : cloneInput(args, inputType);
910
1161
  return adapter.withTransaction(
911
1162
  session,
@@ -935,10 +1186,12 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
935
1186
  collectionFieldsList, operationType, context) => {
936
1187
  const argTypes = gqltype.getFields();
937
1188
  const collectionGQLType = unwrapListAndNonNull(argTypes[collectionField].type);
938
- const connection = normalizeConnectionField(
939
- collectionGQLType,
940
- argTypes[collectionField].extensions.relation.connectionField,
941
- );
1189
+ const { relation } = argTypes[collectionField].extensions;
1190
+ // Checked before the operation too; this also covers items that nested middleware added.
1191
+ if (!hasConnectionField(relation) && hasNestedItems(collectionFieldsList)) {
1192
+ throw unlinkedCollectionError(gqltype, collectionField);
1193
+ }
1194
+ const connection = normalizeConnectionField(collectionGQLType, relation.connectionField);
942
1195
 
943
1196
  const type = operationType === operations.UPDATE
944
1197
  ? typesDictForUpdate.types[collectionGQLType.name] : typesDict.types[collectionGQLType.name];
@@ -982,39 +1235,41 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
982
1235
  const shouldNotBeIncludedInSchema = (includedTypes,
983
1236
  type) => includedTypes && !includedTypes.includes(type);
984
1237
 
1238
+ // Runs the chain in registration order. The rest of the chain runs at most once, and is awaited
1239
+ // even when a middleware calls next() without awaiting it, so its errors still cancel the operation
1240
+ // and never become unhandled rejections; a middleware that fails after starting it rejects only once
1241
+ // it settles. A next() called after the middleware finished does nothing, so later middleware never
1242
+ // runs after the operation.
985
1243
  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)));
1244
+ const run = async (index) => {
1245
+ if (index >= middlewares.length) return;
1246
+ let downstream;
1247
+ let finished = false;
1248
+ const next = () => {
1249
+ if (!downstream && !finished) {
1250
+ downstream = run(index + 1);
1251
+ // Handled here because the middleware may await other work first; awaited below.
1252
+ downstream.catch(() => {});
994
1253
  }
1254
+ return downstream ?? Promise.resolve();
995
1255
  };
1256
+ try {
1257
+ await middlewares[index](context, next);
1258
+ } catch (error) {
1259
+ finished = true;
1260
+ if (downstream) await downstream.catch(() => {});
1261
+ throw error;
1262
+ }
1263
+ finished = true;
1264
+ if (downstream) await downstream;
996
1265
  };
997
-
998
- await buildNext(middlewares)();
1266
+ await run(0);
999
1267
  };
1000
1268
 
1001
1269
  const executeScope = async (params) => {
1002
1270
  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
-
1271
+ const scopeFunction = getScope(type?.gqltype, operation);
1272
+ if (!scopeFunction) return null;
1018
1273
  return scopeFunction({ type, args, operation, context });
1019
1274
  };
1020
1275
 
@@ -1049,7 +1304,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
1049
1304
  const relation = field.extensions?.relation;
1050
1305
  if (!relation || relation.embedded) return;
1051
1306
  const target = typesDict.types[unwrapListAndNonNull(field.type).name];
1052
- if (typeof target?.gqltype?.extensions?.scope?.find !== 'function') return;
1307
+ if (!getScope(target?.gqltype, 'find')) return;
1053
1308
  if (!joinedScopes.has(path)) {
1054
1309
  joinedScopes.set(path, {
1055
1310
  target,
@@ -1210,7 +1465,7 @@ const loadReference = (type, id, requiredId, context) => {
1210
1465
  const resolveById = async (type, args, context, requiredId) => {
1211
1466
  const requestedId = args.id;
1212
1467
  await executeMiddleware({ type, args, operation: 'get_by_id', context });
1213
- if (!type.gqltype.extensions?.scope?.get_by_id) {
1468
+ if (!getScope(type.gqltype, 'get_by_id')) {
1214
1469
  if (requiredId != null && args.id === requestedId && typeof context === 'object' && context !== null
1215
1470
  && typeof adapter.getByIds === 'function' && type.gqltype.getFields().id) {
1216
1471
  return loadReference(type, args.id, requiredId, context);
@@ -1359,10 +1614,6 @@ const buildRootQuery = (name, includedTypes) => {
1359
1614
  if (!shouldNotBeIncludedInSchema(includedTypes, type.gqltype)) {
1360
1615
  const wasAddedAsNoEnpointType = !type.simpleEntityEndpointName;
1361
1616
  if (!wasAddedAsNoEnpointType) {
1362
- if (type.gqltype.getFields().id && !type.gqltype.getFields().id.resolve) {
1363
- type.gqltype.getFields().id.resolve = markGenerated((parent) => parent._id);
1364
- }
1365
-
1366
1617
  rootQueryArgs.fields[type.simpleEntityEndpointName] = {
1367
1618
  type: type.gqltype,
1368
1619
  args: { id: { type: GraphQLID } },
@@ -1371,9 +1622,7 @@ const buildRootQuery = (name, includedTypes) => {
1371
1622
  },
1372
1623
  };
1373
1624
 
1374
- const argTypes = type.gqltype.getFields();
1375
-
1376
- const argsObject = createArgsForQuery(argTypes);
1625
+ const argsObject = createArgsForQuery(type.gqltype);
1377
1626
 
1378
1627
  rootQueryArgs.fields[type.listEntitiesEndpointName] = {
1379
1628
  type: new GraphQLList(type.gqltype),
@@ -1407,6 +1656,11 @@ const buildRootQuery = (name, includedTypes) => {
1407
1656
  };
1408
1657
 
1409
1658
  const aggregateArgsObject = { ...argsObject };
1659
+ if (Object.hasOwn(type.gqltype.getFields(), 'aggregation')) {
1660
+ warnOnce(type.gqltype, 'aggregation', `Configuration issue: ${type.gqltype.name}.aggregation has the name `
1661
+ + `of the aggregation argument of ${type.listEntitiesEndpointName}_aggregate, so that endpoint cannot `
1662
+ + 'filter it with a top-level argument; filter it in AND/OR group conditions or rename the field.');
1663
+ }
1410
1664
  aggregateArgsObject.aggregation = {
1411
1665
  type: new GraphQLNonNull(QLTypeAggregationExpression),
1412
1666
  };
@@ -1436,7 +1690,9 @@ const buildRootQuery = (name, includedTypes) => {
1436
1690
  return new GraphQLObjectType(rootQueryArgs);
1437
1691
  };
1438
1692
 
1693
+ // Returns the registrations that a non-embedded relation of a registered type targets.
1439
1694
  const markReferencedTypesForModelGeneration = () => {
1695
+ const referenced = new Set();
1440
1696
  Object.values(typesDict.types).forEach((typeInfo) => {
1441
1697
  Object.values(typeInfo.gqltype.getFields()).forEach((fieldEntry) => {
1442
1698
  const relation = fieldEntry.extensions?.relation;
@@ -1444,17 +1700,95 @@ const markReferencedTypesForModelGeneration = () => {
1444
1700
 
1445
1701
  const relatedType = unwrapListAndNonNull(fieldEntry.type);
1446
1702
  const relatedTypeInfo = typesDict.types[relatedType.name];
1703
+ if (relatedTypeInfo) referenced.add(relatedTypeInfo);
1447
1704
  if (relatedTypeInfo && !relatedTypeInfo.endpoint) {
1448
1705
  relatedTypeInfo.needsModel = true;
1449
1706
  }
1450
1707
  });
1451
1708
  });
1709
+ return referenced;
1710
+ };
1711
+
1712
+ // Entity records keep their identity in `_id`; MongoDB records have no `id` value, while the SQL
1713
+ // store and other adapters may set both. Embedded-only types keep the default resolver, which
1714
+ // returns their declared `id` member.
1715
+ const installIdResolver = (gqltype) => {
1716
+ const idField = gqltype.getFields().id;
1717
+ if (idField && !idField.resolve) idField.resolve = markGenerated((parent) => parent._id ?? parent.id);
1718
+ };
1719
+
1720
+ // An adapter that hydrates stored records may render an explicitly null singular embedded object as
1721
+ // an object (MongoDB nested paths do); readEmbeddedValue returns null for it. Nullable singular
1722
+ // embedded fields of registered types, and of the embedded types they reach, read through it. The
1723
+ // hook depends only on the value and reads no data, so these resolvers do not bind their types to
1724
+ // this runtime: another runtime that reaches a shared type reads the same value through it.
1725
+ // The value is read as graphql's default resolver reads it, so a method on the parent, such as one a
1726
+ // custom mutation result supplies, is still called; a promise it returns is read once it settles.
1727
+ const readEmbeddedField = markGenerated((parent, args, context, info) => {
1728
+ const value = defaultFieldResolver(parent, args, context, info);
1729
+ return isThenable(value)
1730
+ ? value.then((resolved) => adapter.readEmbeddedValue(resolved))
1731
+ : adapter.readEmbeddedValue(value);
1732
+ });
1733
+
1734
+ const installEmbeddedValueResolvers = (gqltype, visited) => {
1735
+ if (!(gqltype instanceof GraphQLObjectType) || visited.has(gqltype)) return;
1736
+ visited.add(gqltype);
1737
+ for (const fieldEntry of Object.values(gqltype.getFields())) {
1738
+ if (fieldEntry.extensions?.relation?.embedded !== true) continue;
1739
+ const listShape = getListShape(fieldEntry.type);
1740
+ if (!listShape && !(fieldEntry.type instanceof GraphQLNonNull) && !fieldEntry.resolve) {
1741
+ fieldEntry.resolve = readEmbeddedField;
1742
+ }
1743
+ installEmbeddedValueResolvers(listShape ? listShape.itemType : unwrapNonNull(fieldEntry.type), visited);
1744
+ }
1745
+ };
1746
+
1747
+ // Configuration issues this runtime already reported, per type, so a repeated createSchema call or a
1748
+ // type reached from several places logs each one once.
1749
+ const reportedIssues = new WeakMap();
1750
+ const warnOnce = (gqltype, key, message) => {
1751
+ if (!reportedIssues.has(gqltype)) reportedIssues.set(gqltype, new Set());
1752
+ const reported = reportedIssues.get(gqltype);
1753
+ if (reported.has(key)) return;
1754
+ reported.add(key);
1755
+ console.warn(message);
1756
+ };
1757
+
1758
+ // A writable referenced collection without a child connectionField would store unlinked children
1759
+ // through generated nested writes and read none back, so it is rejected before anything is built.
1760
+ // One with its own resolver only warns, and its nested writes are rejected per request; a readOnly
1761
+ // one warns unless it has its own resolver.
1762
+ const assertCollectionConnections = () => {
1763
+ for (const { gqltype } of Object.values(typesDict.types)) {
1764
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
1765
+ if (!isUnlinkedCollection(field)) continue;
1766
+ const problem = `${gqltype.name}.${fieldName} requires a child connectionField`;
1767
+ const readOnly = !!field.extensions.readOnly;
1768
+ const ownResolver = hasApplicationResolver(gqltype, fieldName);
1769
+ if (!readOnly && !ownResolver) throw new SimfinityError(problem, 'INVALID_MODEL', 400);
1770
+ if (!readOnly || !ownResolver) {
1771
+ warnOnce(gqltype, `connectionField:${fieldName}`,
1772
+ `Configuration issue: ${problem}; nested writes through it are rejected`);
1773
+ }
1774
+ }
1775
+ }
1452
1776
  };
1453
1777
 
1454
1778
  const getRegistrations = () => Object.values(typesDict.types);
1455
1779
 
1456
1780
  const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => {
1457
- markReferencedTypesForModelGeneration();
1781
+ // Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
1782
+ Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
1783
+ const schemaTypes = collectSchemaOutputTypes();
1784
+ schemaTypes.forEach(assertNotBoundElsewhere);
1785
+ schemaTypes.forEach(assertFieldsNotBoundElsewhere);
1786
+ assertCollectionConnections();
1787
+ const referencedTypes = markReferencedTypesForModelGeneration();
1788
+ // Entities, which keep their own stored identity: connect() types and relation targets.
1789
+ Object.values(typesDict.types).forEach((typeInfo) => {
1790
+ typeInfo.storedIdentity = typeInfo.endpoint === true || referencedTypes.has(typeInfo);
1791
+ });
1458
1792
  if (adapter.prepare) {
1459
1793
  adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
1460
1794
  }
@@ -1474,12 +1808,23 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1474
1808
  Object.keys(typesDict.types).forEach((typeName) => {
1475
1809
  if (typesDictForUpdate.types[typeName]) {
1476
1810
  typesDictForUpdate.types[typeName].model = typesDict.types[typeName].model;
1811
+ typesDictForUpdate.types[typeName].storedIdentity = typesDict.types[typeName].storedIdentity;
1477
1812
  }
1478
1813
  });
1479
1814
 
1815
+ // Every entity resolves `id`, whatever the query and mutation allowlists include. The type object
1816
+ // is marked too, so query paths of any runtime that shares it follow the same identity.
1817
+ const embeddedValueTypes = new Set();
1480
1818
  Object.values(typesDict.types).forEach((typeInfo) => {
1481
1819
  if (typeInfo.gqltype) {
1820
+ if (typeInfo.storedIdentity) {
1821
+ markStoredIdentity(typeInfo.gqltype);
1822
+ installIdResolver(typeInfo.gqltype);
1823
+ }
1482
1824
  autoGenerateResolvers(typeInfo.gqltype);
1825
+ if (typeof adapter.readEmbeddedValue === 'function') {
1826
+ installEmbeddedValueResolvers(typeInfo.gqltype, embeddedValueTypes);
1827
+ }
1483
1828
  }
1484
1829
  });
1485
1830
 
@@ -1490,10 +1835,18 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1490
1835
  }
1491
1836
  });
1492
1837
 
1493
- return new GraphQLSchema({
1838
+ const schema = new GraphQLSchema({
1494
1839
  query,
1495
1840
  mutation: buildMutation('Mutation', includedMutationTypes, includedCustomMutations),
1496
1841
  });
1842
+ // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
1843
+ // custom mutation result, so another runtime cannot later generate resolvers this schema executes.
1844
+ schemaTypes.forEach((type) => {
1845
+ if (type instanceof GraphQLObjectType && hasUnresolvedRelation(type)) {
1846
+ runtimeBoundTypes.set(type, runtimeIdentity);
1847
+ }
1848
+ });
1849
+ return schema;
1497
1850
  };
1498
1851
 
1499
1852
  const getModel = (gqltype) => typesDict.types[gqltype.name]?.model;
@@ -1517,7 +1870,28 @@ const registerMutation = (name, description, inputModel, outputModel, callback)
1517
1870
  };
1518
1871
  };
1519
1872
 
1873
+ // Gives the field a new extensions object as well as the resolver, so a copy made before keeps
1874
+ // untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
1875
+ // copying it, when a type is rebuilt from toConfig().
1876
+ const claimRelationField = (fieldEntry, resolve, extensions = {}) => {
1877
+ fieldEntry.extensions = Object.assign(Object.create(null), fieldEntry.extensions, extensions);
1878
+ fieldEntry.resolve = markGenerated(resolve);
1879
+ relationFieldOwners.set(fieldEntry.extensions, runtimeIdentity);
1880
+ relationFieldOwners.set(fieldEntry.resolve, runtimeIdentity);
1881
+ };
1882
+
1883
+ // The identifier a single reference reads is stored, not sent by the client, so one the adapter
1884
+ // rejects as malformed, such as a value written outside Simfinity, is a data error rather than a bad
1885
+ // request. Missing records and other errors keep their own classification.
1886
+ const storedReferenceError = (error, gqltype, fieldName) => {
1887
+ if (!(error instanceof SimfinityError) || error.getCode() !== 'NOT_VALID_ID' || error.getStatus() !== 400) {
1888
+ return error;
1889
+ }
1890
+ return new InternalServerError(`${gqltype.name}.${fieldName} stores an invalid identifier`, error);
1891
+ };
1892
+
1520
1893
  const autoGenerateResolvers = (gqltype) => {
1894
+ assertFieldsNotBoundElsewhere(gqltype);
1521
1895
  const fields = gqltype.getFields();
1522
1896
 
1523
1897
  for (const [fieldName, fieldEntry] of Object.entries(fields)) {
@@ -1532,16 +1906,11 @@ const autoGenerateResolvers = (gqltype) => {
1532
1906
  relatedType,
1533
1907
  relation.connectionField || fieldName,
1534
1908
  );
1535
- const relatedTypeInfo = typesDict.types[relatedType.name];
1536
- const argsObject = createArgsForQuery(relatedTypeInfo.gqltype.getFields());
1537
- if (connection.graphqlFieldName) delete argsObject[connection.graphqlFieldName];
1909
+ const relatedTypeInfo = requireRegisteredType(typesDict, relatedType, fieldName, gqltype.name);
1910
+ const argsObject = createArgsForQuery(relatedTypeInfo.gqltype, connection.graphqlFieldName);
1538
1911
 
1539
1912
  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) => {
1913
+ claimRelationField(fieldEntry, async (parent, args, context) => {
1545
1914
  if (!relatedTypeInfo || !relatedTypeInfo.model) {
1546
1915
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
1547
1916
  }
@@ -1554,25 +1923,33 @@ const autoGenerateResolvers = (gqltype) => {
1554
1923
  relatedTypeInfo.model,
1555
1924
  relatedTypeInfo.gqltype,
1556
1925
  connection.storageFieldName,
1557
- parent.id || parent._id,
1926
+ parent._id ?? parent.id,
1558
1927
  params.args,
1559
1928
  null,
1560
1929
  );
1561
- });
1930
+ }, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
1931
+ runtimeBoundTypes.set(gqltype, runtimeIdentity);
1562
1932
  } else if (fieldEntry.type instanceof GraphQLObjectType
1563
1933
  || (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType)) {
1564
1934
  const relatedType = unwrapNonNull(fieldEntry.type);
1565
- const connectionField = relation.connectionField || fieldName;
1935
+ const connectionField = getFieldStorageName(fieldName, fieldEntry);
1566
1936
 
1567
- fieldEntry.resolve = markGenerated(async (parent, args, context) => {
1937
+ claimRelationField(fieldEntry, async (parent, args, context) => {
1568
1938
  const relatedTypeInfo = typesDict.types[relatedType.name];
1569
1939
  if (!relatedTypeInfo || !relatedTypeInfo.model) {
1570
1940
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
1571
1941
  }
1572
- const relatedId = parent[connectionField] || parent[fieldName];
1573
- const id = relatedId?._id || relatedId;
1574
- return id ? resolveById(relatedTypeInfo, { id: String(id) }, context, id) : null;
1942
+ // Any stored identifier except null or an empty string, so numeric IDs such as 0 resolve.
1943
+ const relatedId = parent[connectionField] ?? parent[fieldName];
1944
+ const id = relatedId?._id ?? relatedId;
1945
+ if (id == null || id === '') return null;
1946
+ try {
1947
+ return await resolveById(relatedTypeInfo, { id: String(id) }, context, id);
1948
+ } catch (error) {
1949
+ throw storedReferenceError(error, gqltype, fieldName);
1950
+ }
1575
1951
  });
1952
+ runtimeBoundTypes.set(gqltype, runtimeIdentity);
1576
1953
  }
1577
1954
  }
1578
1955
  };
@@ -1589,6 +1966,8 @@ const connect = (model, gqltype, simpleEntityEndpointName,
1589
1966
  stateMachine,
1590
1967
  onModelCreated,
1591
1968
  };
1969
+ assertNotBoundElsewhere(gqltype);
1970
+ validateScope(gqltype);
1592
1971
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
1593
1972
 
1594
1973
  waitingInputType[gqltype.name] = {
@@ -1601,6 +1980,7 @@ const connect = (model, gqltype, simpleEntityEndpointName,
1601
1980
  };
1602
1981
 
1603
1982
  const addNoEndpointType = (gqltype) => {
1983
+ assertNotBoundElsewhere(gqltype);
1604
1984
  const fields = gqltype.getFields();
1605
1985
  let needsModel = false;
1606
1986
  for (const fieldEntry of Object.values(fields)) {
@@ -1616,6 +1996,7 @@ const addNoEndpointType = (gqltype) => {
1616
1996
  const registration = {
1617
1997
  gqltype, endpoint: false, model: null, needsModel,
1618
1998
  };
1999
+ validateScope(gqltype);
1619
2000
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
1620
2001
 
1621
2002
  waitingInputType[gqltype.name] = { gqltype };
@@ -1623,10 +2004,24 @@ const addNoEndpointType = (gqltype) => {
1623
2004
  typesDictForUpdate.types[gqltype.name] = { ...typesDict.types[gqltype.name] };
1624
2005
  };
1625
2006
 
1626
- const createArgsForQuery = (argTypes) => {
2007
+ // Arguments that every list query and collection field generates, after the field filters.
2008
+ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
2009
+
2010
+ // `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
2011
+ // A field named like a generated argument gets no top-level filter either; it is reported once.
2012
+ const createArgsForQuery = (gqltype, omit = null) => {
1627
2013
  const argsObject = {};
2014
+ const argTypes = gqltype.getFields();
2015
+ for (const name of LIST_QUERY_ARGS) {
2016
+ if (name !== omit && Object.hasOwn(argTypes, name)) {
2017
+ warnOnce(gqltype, name, `Configuration issue: ${gqltype.name}.${name} has the name of a generated list `
2018
+ + 'query argument, so list queries and collection fields cannot filter it with a top-level argument; '
2019
+ + 'filter it in AND/OR group conditions or rename the field.');
2020
+ }
2021
+ }
1628
2022
 
1629
2023
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
2024
+ if (fieldEntryName === omit) continue;
1630
2025
  argsObject[fieldEntryName] = {};
1631
2026
 
1632
2027
  if (fieldEntry.type instanceof GraphQLScalarType
@@ -1683,6 +2078,7 @@ function formatArgs(argsArray) {
1683
2078
 
1684
2079
  return {
1685
2080
  configureQueryLimits,
2081
+ configureMutationLimits,
1686
2082
  connect,
1687
2083
  addNoEndpointType,
1688
2084
  createSchema,