@simtlix/simfinity-core 3.5.7 → 3.5.9

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
@@ -9,6 +9,7 @@ import {
9
9
 
10
10
  import SimfinityError from './errors/simfinity.error.js';
11
11
  import InternalServerError from './errors/internal-server.error.js';
12
+ import { ForbiddenError } from './auth/errors.js';
12
13
  import QLOperator from './const/QLOperator.js';
13
14
  import QLValue from './const/QLValue.js';
14
15
  import QLSort from './const/QLSort.js';
@@ -18,7 +19,9 @@ import { isThenable } from './auth/thenable.js';
18
19
  import {
19
20
  getFieldStorageName, getListShape, markStoredIdentity, normalizeConnectionField,
20
21
  } from './relation-storage.js';
21
- import './introspection.js';
22
+ import {
23
+ fieldMetadataNamesFixed, fieldMetadataTypes, fixFieldMetadataNames, renameFieldMetadataTypes,
24
+ } from './introspection.js';
22
25
 
23
26
  // Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
24
27
  // created is application code, such as a masking resolver, recorded once per type so later
@@ -34,15 +37,23 @@ const countSinkOf = (value) => {
34
37
  return typeof sink === 'function' ? sink : undefined;
35
38
  };
36
39
  // The root value arrives as parent, which resolver wrappers that forward only (parent, args,
37
- // context) still pass on; info.rootValue is the fallback. Without a sink the count goes to
38
- // context.count, where Envelop/Apollo/Yoga count plugins read it.
40
+ // context) still pass on; info.rootValue is the fallback.
41
+ const countSinkFor = (parent, info) => countSinkOf(parent) || (info ? countSinkOf(info.rootValue) : undefined);
42
+ // Without a sink the count goes to context.count, where Envelop/Apollo/Yoga count plugins read it.
43
+ // With neither, such as a graphql() call without contextValue, nothing could read a count, so the
44
+ // count query is skipped.
45
+ const canReportCount = (parent, info, context) => countSinkFor(parent, info) !== undefined
46
+ || (context !== null && (typeof context === 'object' || typeof context === 'function'));
47
+ // Returns whether the count was stored.
39
48
  const reportCount = (parent, info, context, count) => {
40
- const sink = countSinkOf(parent) || (info ? countSinkOf(info.rootValue) : undefined);
49
+ const sink = countSinkFor(parent, info);
41
50
  if (sink) {
42
51
  sink(count);
43
- return;
52
+ return true;
44
53
  }
45
- context.count = count;
54
+ // Reflect.set returns false for a frozen, sealed or read-only context, where an assignment would
55
+ // throw after both queries ran.
56
+ return Reflect.set(context, 'count', count);
46
57
  };
47
58
  const applicationResolvedFields = new WeakMap();
48
59
  const markGenerated = (resolve) => {
@@ -201,6 +212,94 @@ const GraphQLJSON = new GraphQLScalarType({
201
212
  },
202
213
  });
203
214
 
215
+ // The type called `name` that a schema built from these roots would contain, other than through
216
+ // `skip`, following the references GraphQLSchema collects: fields, arguments, interfaces, union
217
+ // members and input fields. With `inputFields` false, input objects are matched by their own name
218
+ // only, and their fields are never read.
219
+ const findSchemaType = (roots, name, skip, { inputFields = true } = {}) => {
220
+ const visited = new Set();
221
+ const pending = [...roots];
222
+ while (pending.length) {
223
+ const type = getNamedType(pending.pop());
224
+ if (!type || type === skip || visited.has(type)) continue;
225
+ visited.add(type);
226
+ if (type.name === name) return type;
227
+ if (type instanceof GraphQLUnionType) pending.push(...type.getTypes());
228
+ if (type instanceof GraphQLObjectType || type instanceof GraphQLInterfaceType) {
229
+ pending.push(...type.getInterfaces());
230
+ for (const field of Object.values(type.getFields())) {
231
+ pending.push(field.type, ...field.args.map((arg) => arg.type));
232
+ }
233
+ }
234
+ if (inputFields && type instanceof GraphQLInputObjectType) {
235
+ for (const field of Object.values(type.getFields())) pending.push(field.type);
236
+ }
237
+ }
238
+ return null;
239
+ };
240
+
241
+ // Every schema contains Simfinity's field metadata types, the types of `__Field.extensions`, named
242
+ // FieldExtensionsType and RelationType unless a clash renamed them. Returns the type of a schema built
243
+ // from these roots that has the name of one of them, or null (#162).
244
+ const fieldMetadataClash = (roots, options) => fieldMetadataTypes()
245
+ .map((own) => findSchemaType(roots, own.name, own, options)).find(Boolean) || null;
246
+
247
+ // The first schema that contains the metadata types fixes their names for the process, so a later
248
+ // schema with a type of the same name is rejected.
249
+ const reservedTypeNameError = (clash) => new SimfinityError(
250
+ `Type ${clash.name} has the name of a type that Simfinity adds to every schema for __Field.extensions, `
251
+ + 'and a schema created earlier in this process already contains that type under this name. Rename '
252
+ + `${clash.name}, or create this schema first, so that Simfinity gives its types other names, such as `
253
+ + 'SimfinityFieldExtensionsType and SimfinityRelationType',
254
+ 'RESERVED_TYPE_NAME',
255
+ 500,
256
+ );
257
+ // The check before anything is built reads only what the output type check before it reads: output
258
+ // types, their fields, arguments, interfaces and union members. Input objects, a registered
259
+ // mutation's input among them, are checked by their own name, because their fields function may read
260
+ // generated inputs (getInputType) that do not exist yet, and graphql-js keeps what it returns. A clash
261
+ // inside an input's fields is found when the schema is constructed.
262
+ const assertFieldMetadataNamesFree = (roots) => {
263
+ const clash = fieldMetadataNamesFixed() && fieldMetadataClash(roots, { inputFields: false });
264
+ if (clash) throw reservedTypeNameError(clash);
265
+ };
266
+
267
+ // Constructs the schema of these roots. When one of its types has the name of a field metadata type,
268
+ // those are renamed SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended
269
+ // when the schema also has those names, in every schema the process constructs from then on. A
270
+ // construction that fails puts the previous metadata types back, so only a schema that exists fixes
271
+ // the names.
272
+ const constructSchema = (query, mutation) => {
273
+ const roots = [query, mutation];
274
+ const clash = fieldMetadataClash(roots);
275
+ if (clash && fieldMetadataNamesFixed()) throw reservedTypeNameError(clash);
276
+ let names = null;
277
+ if (clash) {
278
+ const taken = (name) => findSchemaType(roots, name) !== null;
279
+ let suffix = '';
280
+ for (let n = 2; taken(`SimfinityFieldExtensionsType${suffix}`) || taken(`SimfinityRelationType${suffix}`); n += 1) {
281
+ suffix = String(n);
282
+ }
283
+ names = [`SimfinityFieldExtensionsType${suffix}`, `SimfinityRelationType${suffix}`];
284
+ }
285
+ const restore = names ? renameFieldMetadataTypes(...names) : null;
286
+ let schema;
287
+ try {
288
+ schema = new GraphQLSchema({ query, mutation });
289
+ } catch (error) {
290
+ if (restore) restore();
291
+ throw error;
292
+ }
293
+ fixFieldMetadataNames();
294
+ if (names) {
295
+ console.warn(`Configuration issue: the schema has a type named ${clash.name}, the name of a type that `
296
+ + `Simfinity adds to every schema for __Field.extensions, so Simfinity names its types ${names[0]} and `
297
+ + `${names[1]} in every schema this process creates from now on. Use these names in named fragments `
298
+ + 'and permission maps.');
299
+ }
300
+ return schema;
301
+ };
302
+
204
303
  // graphql-js wraps a value thrown while resolving a field (locatedError): the wrapper has the field
205
304
  // path and repeats the thrown error's message. A thrown non-Error becomes a NonErrorThrown whose
206
305
  // message prints the value.
@@ -338,6 +437,14 @@ export const createRuntime = (adapter) => {
338
437
  const typesDict = { types: {} };
339
438
  const waitingInputType = {};
340
439
  const typesDictForUpdate = { types: {} };
440
+ // `added` item inputs of referenced collections by generated name, so collections that reach the
441
+ // same child through the same connectionField share one input type.
442
+ const excludedFieldInputTypes = new Map();
443
+ // Self-referencing collection field name -> the types whose collection inputs were built with the
444
+ // unqualified names OneToManyA<field> and OneToManyU<field>; type -> its self-referencing
445
+ // collections whose inputs are named after the type instead (see claimSelfCollectionNames).
446
+ const selfCollectionNameHolders = new Map();
447
+ const qualifiedSelfCollections = new Map();
341
448
  const registeredMutations = {};
342
449
  const middlewares = [];
343
450
 
@@ -519,13 +626,24 @@ const QLTypeAggregationExpression = new GraphQLInputObjectType({
519
626
  }),
520
627
  });
521
628
 
522
- const QLTypeAggregationResult = new GraphQLObjectType({
629
+ // Aggregation results use Simfinity's JSON scalar, or the application's own scalar named JSON when
630
+ // the schema has one, such as GraphQLJSON from graphql-scalars, so the two do not collide.
631
+ const createAggregationResultType = (valueType) => new GraphQLObjectType({
523
632
  name: 'QLTypeAggregationResult',
524
633
  fields: () => ({
525
- groupId: { type: GraphQLJSON },
526
- facts: { type: GraphQLJSON },
634
+ groupId: { type: valueType },
635
+ facts: { type: valueType },
527
636
  }),
528
637
  });
638
+ const QLTypeAggregationResult = createAggregationResultType(GraphQLJSON);
639
+ const applicationJSONResultTypes = new WeakMap();
640
+ const aggregationResultTypeFor = (valueType) => {
641
+ if (valueType === GraphQLJSON) return QLTypeAggregationResult;
642
+ if (!applicationJSONResultTypes.has(valueType)) {
643
+ applicationJSONResultTypes.set(valueType, createAggregationResultType(valueType));
644
+ }
645
+ return applicationJSONResultTypes.get(valueType);
646
+ };
529
647
 
530
648
  const isNonNullOfType = (fieldEntryType, graphQLType) => {
531
649
  let isOfType = false;
@@ -536,6 +654,7 @@ const isNonNullOfType = (fieldEntryType, graphQLType) => {
536
654
  };
537
655
 
538
656
  const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
657
+ const isAbstractOutputType = (type) => type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType;
539
658
 
540
659
  const unwrapListAndNonNull = (type) => {
541
660
  let unwrapped = type;
@@ -553,89 +672,123 @@ const wrapListInputType = (itemType, listShape, preserveOuterNonNull) => {
553
672
  : listType;
554
673
  };
555
674
 
675
+ const capitalize = (name) => name.charAt(0).toUpperCase() + name.slice(1);
676
+
677
+ // A GraphQL input object needs at least one field, so a generated input that would have none is
678
+ // left out, together with the operations, fields and mutations that would take it (#161).
679
+ const hasInputFields = (inputType) => Object.keys(inputType.getFields()).length > 0;
680
+
556
681
  /**
557
- * Creates a new GraphQLInputObjectType with a field excluded.
682
+ * The `added` item input of a referenced collection: the item type's create input without the
683
+ * back-reference that the parent supplies. Its fields are read from the item's create input when
684
+ * the schema is built, so the item's input may be built after this type, as in cycles of
685
+ * collections. `optionalFields` lose their outer non-null wrapper. Collections that reach the same
686
+ * item through the same connectionField with the same prefix share one type.
558
687
  * @param {string} inputNamePrefix - The prefix for the input type name.
559
- * @param {GraphQLInputObjectType} originalType - The original input type.
688
+ * @param {GraphQLObjectType} itemType - The collection's item type.
560
689
  * @param {string} fieldToExclude - The name of the field to exclude.
561
- * @returns {GraphQLInputObjectType} A new input type without the specified field.
690
+ * @param {string} [connectionFieldName] - The declared connectionField, which names the type.
691
+ * @param {string[]} [optionalFields] - Fields of the item's create input that become optional.
692
+ * @returns {GraphQLInputObjectType} An input type without the specified field.
562
693
  */
563
- const createTypeWithExcludedField = (inputNamePrefix, originalType, fieldToExclude,
564
- connectionFieldName = fieldToExclude) => {
565
- const originalFields = originalType.getFields();
566
- const newFields = Object.fromEntries(
567
- Object.entries(originalFields)
568
- .filter(([fieldName]) => fieldName !== fieldToExclude)
569
- .map(([fieldName, field]) => [fieldName, {
570
- type: field.type,
571
- description: field.description,
572
- defaultValue: field.defaultValue,
573
- }]),
574
- );
575
-
576
- return new GraphQLInputObjectType({
577
- name: `${inputNamePrefix}${originalType.name}For${connectionFieldName.charAt(0).toUpperCase() + connectionFieldName.slice(1)}`,
578
- fields: newFields,
694
+ const createTypeWithExcludedField = (inputNamePrefix, itemType, fieldToExclude,
695
+ connectionFieldName = fieldToExclude, optionalFields = []) => {
696
+ // The item's create input is `${itemType.name}Input`, so the name is the one it always had.
697
+ const name = `${inputNamePrefix}${itemType.name}InputFor${capitalize(connectionFieldName)}`;
698
+ const shared = excludedFieldInputTypes.get(name);
699
+ if (shared && shared.itemType === itemType && shared.fieldToExclude === fieldToExclude
700
+ && shared.optionalFields.join() === optionalFields.join()) {
701
+ return shared.type;
702
+ }
703
+ // Any other type with this name is a new one, so a real name clash still fails as before.
704
+ const type = new GraphQLInputObjectType({
705
+ name,
706
+ fields: () => Object.fromEntries(
707
+ Object.entries(getInputType(itemType).getFields())
708
+ .filter(([fieldName]) => fieldName !== fieldToExclude)
709
+ .map(([fieldName, field]) => [fieldName, {
710
+ type: optionalFields.includes(fieldName) ? unwrapNonNull(field.type) : field.type,
711
+ description: field.description,
712
+ defaultValue: field.defaultValue,
713
+ }]),
714
+ ),
579
715
  });
580
- };
581
-
582
- const createOneToManyInputType = (inputNamePrefix, fieldEntryName,
583
- inputType, updateInputType, connection, itemNonNull = false) => {
584
- let inputTypeForAdd = inputType;
585
-
586
- if (connection.declaredFieldName) {
587
- inputTypeForAdd = createTypeWithExcludedField(
716
+ if (!shared) excludedFieldInputTypes.set(name, { itemType, fieldToExclude, optionalFields, type });
717
+ return type;
718
+ };
719
+
720
+ // A referenced collection's operation input. The item inputs are read when the schema is built, so
721
+ // the item type's inputs need not exist yet. `added` or `updated` is left out when its item input
722
+ // would have no fields, such as `added` for a child whose only writable field is the back-reference;
723
+ // `deleted` always remains. `owner` names the collection, as `Type.field`, in the warning.
724
+ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
725
+ itemNonNull = false, optionalFields = [], owner = name) => {
726
+ const inputTypeForAdd = connection.declaredFieldName
727
+ ? createTypeWithExcludedField(
588
728
  inputNamePrefix,
589
- inputType,
729
+ itemType,
590
730
  connection.graphqlFieldName || connection.declaredFieldName,
591
731
  connection.declaredFieldName,
592
- );
593
- }
732
+ optionalFields,
733
+ )
734
+ : null;
735
+ const listOf = (type) => new GraphQLList(itemNonNull ? new GraphQLNonNull(type) : type);
736
+
737
+ const omittedOperation = {
738
+ added: () => `because ${itemType.name} has no writable fields`
739
+ + `${connection.declaredFieldName ? ` besides its connectionField ${connection.declaredFieldName}` : ''}; `
740
+ + `create ${itemType.name} records with its own mutations or a registered mutation.`,
741
+ updated: () => `because ${itemType.name} has no writable fields and no writable id; update `
742
+ + `${itemType.name} records with a registered mutation.`,
743
+ };
594
744
 
595
745
  return new GraphQLInputObjectType({
596
- name: `OneToMany${inputNamePrefix}${fieldEntryName}`,
597
- fields: () => ({
598
- added: {
599
- type: new GraphQLList(itemNonNull
600
- ? new GraphQLNonNull(inputTypeForAdd) : inputTypeForAdd),
601
- },
602
- updated: {
603
- type: new GraphQLList(itemNonNull
604
- ? new GraphQLNonNull(updateInputType) : updateInputType),
605
- },
606
- deleted: {
607
- type: new GraphQLList(itemNonNull ? new GraphQLNonNull(GraphQLID) : GraphQLID),
608
- },
609
- }),
746
+ name,
747
+ fields: () => {
748
+ const fields = {};
749
+ const itemInputs = {
750
+ added: inputTypeForAdd || getInputType(itemType),
751
+ updated: typesDictForUpdate.types[itemType.name].inputType,
752
+ };
753
+ for (const [operation, itemInput] of Object.entries(itemInputs)) {
754
+ if (hasInputFields(itemInput)) {
755
+ fields[operation] = { type: listOf(itemInput) };
756
+ } else {
757
+ warnOnce(itemType, `emptyItemInput:${owner}:${operation}`, `Configuration issue: ${owner} has no `
758
+ + `\`${operation}\` operation in generated inputs, ${omittedOperation[operation]()}`);
759
+ }
760
+ }
761
+ // Null IDs are skipped, so `deleted` accepts them whatever the collection's item nullability.
762
+ fields.deleted = { type: new GraphQLList(GraphQLID) };
763
+ return fields;
764
+ },
610
765
  });
611
766
  };
612
767
 
768
+ // What graphQLListInputType returns for a list of an embedded type whose input has no fields.
769
+ const NO_INPUT_FIELDS = Symbol('no input fields');
770
+
613
771
  const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
614
772
  inputNamePrefix, connectionField, preserveOuterNonNull, ownerName) => {
615
773
  const listShape = getListShape(fieldEntry.type);
616
774
  if (!listShape) return null;
617
775
  const { itemType } = listShape;
618
776
 
619
- if (itemType instanceof GraphQLObjectType
620
- && requireRegisteredType(dict, itemType, fieldEntryName, ownerName).inputType) {
621
- if (!fieldEntry.extensions || !fieldEntry.extensions.relation
622
- || !fieldEntry.extensions.relation.embedded) {
623
- const oneToMany = createOneToManyInputType(inputNamePrefix, fieldEntryName,
624
- typesDict.types[itemType.name].inputType,
625
- typesDictForUpdate.types[itemType.name].inputType,
626
- normalizeConnectionField(itemType, connectionField),
627
- listShape.itemNonNull);
777
+ if (itemType instanceof GraphQLObjectType) {
778
+ const registration = requireRegisteredType(dict, itemType, fieldEntryName, ownerName);
779
+ // A referenced collection reads its item inputs lazily, so it never waits for them.
780
+ if (!fieldEntry.extensions?.relation?.embedded) {
781
+ const oneToMany = createOneToManyInputType(`OneToMany${inputNamePrefix}${fieldEntryName}`,
782
+ inputNamePrefix, itemType, normalizeConnectionField(itemType, connectionField),
783
+ listShape.itemNonNull, [], `${ownerName}.${fieldEntryName}`);
628
784
  return preserveOuterNonNull && listShape.outerNonNull
629
785
  ? new GraphQLNonNull(oneToMany)
630
786
  : oneToMany;
631
787
  }
632
- if (fieldEntry.extensions && fieldEntry.extensions.relation
633
- && fieldEntry.extensions.relation.embedded) {
634
- return wrapListInputType(
635
- dict.types[itemType.name].inputType,
636
- listShape,
637
- preserveOuterNonNull,
638
- );
788
+ if (registration.inputType) {
789
+ return hasInputFields(registration.inputType)
790
+ ? wrapListInputType(registration.inputType, listShape, preserveOuterNonNull)
791
+ : NO_INPUT_FIELDS;
639
792
  }
640
793
  } else if (itemType instanceof GraphQLScalarType || itemType instanceof GraphQLEnumType) {
641
794
  return wrapListInputType(itemType, listShape, preserveOuterNonNull);
@@ -643,13 +796,86 @@ const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
643
796
  return null;
644
797
  };
645
798
 
646
- const buildInputType = (gqltype) => {
799
+ // Writable list fields whose items are the type itself, as buildInputType selects them. Their
800
+ // collection inputs are named OneToManyA<field> and OneToManyU<field> (see claimSelfCollectionNames).
801
+ const selfCollectionFieldNames = (gqltype) => {
802
+ const hasStateMachine = !!typesDict.types[gqltype.name]?.stateMachine;
803
+ return Object.entries(gqltype.getFields())
804
+ .filter(([fieldName, field]) => !field.extensions?.readOnly
805
+ && !(fieldName === 'state' && hasStateMachine)
806
+ && getListShape(field.type)?.itemType === gqltype)
807
+ .map(([fieldName]) => fieldName);
808
+ };
809
+
810
+ const selfCollectionInputType = (gqltype, fieldEntryName, fieldEntry, forUpdate, selfFields) => {
811
+ const listShape = getListShape(fieldEntry.type);
812
+ const qualifier = qualifiedSelfCollections.get(gqltype)?.has(fieldEntryName) ? gqltype.name : '';
813
+ const kind = forUpdate ? 'U' : 'A';
814
+ const oneToMany = createOneToManyInputType(
815
+ `OneToMany${qualifier}${kind}${fieldEntryName}`,
816
+ kind,
817
+ gqltype,
818
+ normalizeConnectionField(gqltype, fieldEntry.extensions?.relation?.connectionField),
819
+ listShape.itemNonNull,
820
+ // Items an add mutation creates may carry their own self-referencing collections. These stay
821
+ // optional there, as such items had no such fields before; items added by an update keep them
822
+ // as the type declares them.
823
+ forUpdate ? [] : selfFields,
824
+ `${gqltype.name}.${fieldEntryName}`,
825
+ );
826
+ return !forUpdate && listShape.outerNonNull ? new GraphQLNonNull(oneToMany) : oneToMany;
827
+ };
828
+
829
+ // An embedded field, single or list, whose embedded type's create or update input has no fields is
830
+ // left out of that input of its owner. Only the create input is empty when the type's writable fields
831
+ // hold only ids, which create inputs leave out; generated updates still set the field then.
832
+ const warnEmptyEmbeddedInputs = (gqltype, fieldName, embeddedTypeName, noCreate, noUpdate) => {
833
+ if (!noCreate && !noUpdate) return;
834
+ const field = `Configuration issue: ${gqltype.name}.${fieldName} `;
835
+ if (!noUpdate) {
836
+ warnOnce(gqltype, `emptyEmbeddedInput:${fieldName}`, `${field}is left out of generated create inputs, because `
837
+ + `the create input of its embedded type ${embeddedTypeName} would have no fields (create inputs leave out `
838
+ + 'ids), so generated creates cannot set it, while generated updates still can; to set it on create, use a '
839
+ + 'controller or a registered mutation.');
840
+ return;
841
+ }
842
+ const inputs = [noCreate && 'create', 'update'].filter(Boolean).join(' and ');
843
+ warnOnce(gqltype, `emptyEmbeddedInput:${fieldName}`, `${field}is left out of generated ${inputs} inputs, because `
844
+ + `its embedded type ${embeddedTypeName} has no writable fields, so generated mutations cannot set it; set it `
845
+ + 'in a controller or a registered mutation, and mark it readOnly to state that it is output-only.');
846
+ };
847
+
848
+ // A non-null embedded field, single or list, that generated create inputs cannot set. Generated
849
+ // mutations that reach the owner's create input would store records without it, so such a schema is
850
+ // rejected; one whose generated mutations cannot reach that input only leaves the field out.
851
+ const requiredEmbeddedWithoutInputError = (gqltype, { field, embeddedTypeName }) => new SimfinityError(
852
+ `${gqltype.name}.${field} is non-null but its embedded type ${embeddedTypeName} has no writable fields, so `
853
+ + 'generated create inputs cannot set it; mark it readOnly and set it in a controller, or make it nullable',
854
+ 'INVALID_MODEL',
855
+ 400,
856
+ );
857
+ // Registration -> the non-null embedded fields its create input left out.
858
+ const requiredFieldsWithoutCreateInput = new WeakMap();
859
+
860
+ // Builds a type's create and update inputs, or returns every field whose input cannot be built yet:
861
+ // `{ unresolvedFields: [{ field, waitsFor }] }`, where `waitsFor` names the embedded type whose
862
+ // inputs the field waits for, and is absent when the field can have no generated input. `reachable`
863
+ // holds the types whose inputs the generated mutations of the schema reach.
864
+ const buildInputType = (gqltype, reachable) => {
647
865
  const argTypes = gqltype.getFields();
648
866
 
649
867
  const fieldsArgs = {};
650
868
  const fieldsArgForUpdate = {};
651
869
 
652
870
  const selfReferenceCollections = {};
871
+ const unresolvedFields = [];
872
+ const requiredWithoutCreateInput = [];
873
+ // Leaves a non-null embedded field without a create input out, or rejects the schema.
874
+ const omitRequiredEmbedded = (field, embeddedTypeName) => {
875
+ const omitted = { field, embeddedTypeName };
876
+ if (reachable.has(gqltype)) throw requiredEmbeddedWithoutInputError(gqltype, omitted);
877
+ requiredWithoutCreateInput.push(omitted);
878
+ };
653
879
 
654
880
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
655
881
  const fieldArg = {};
@@ -684,14 +910,31 @@ const buildInputType = (gqltype) => {
684
910
  } else if (requireRegisteredType(typesDict, unwrapNonNull(fieldEntry.type), fieldEntryName,
685
911
  gqltype.name).inputType
686
912
  && typesDictForUpdate.types[fieldEntryNameValue].inputType) {
687
- fieldArg.type = typesDict.types[fieldEntryNameValue].inputType;
688
- fieldArgForUpdate.type = typesDictForUpdate.types[fieldEntryNameValue].inputType;
913
+ // Create and update inputs are decided apart: an embedded type whose only writable
914
+ // field is `id` has one only for updates.
915
+ const embeddedCreate = typesDict.types[fieldEntryNameValue].inputType;
916
+ const embeddedUpdate = typesDictForUpdate.types[fieldEntryNameValue].inputType;
917
+ if (hasInputFields(embeddedCreate)) fieldArg.type = embeddedCreate;
918
+ else if (fieldEntry.type instanceof GraphQLNonNull) {
919
+ omitRequiredEmbedded(fieldEntryName, fieldEntryNameValue);
920
+ }
921
+ if (hasInputFields(embeddedUpdate)) fieldArgForUpdate.type = embeddedUpdate;
922
+ warnEmptyEmbeddedInputs(gqltype, fieldEntryName, fieldEntryNameValue,
923
+ !fieldArg.type, !fieldArgForUpdate.type);
689
924
  } else {
690
- return null;
925
+ unresolvedFields.push({ field: fieldEntryName, waitsFor: fieldEntryNameValue });
691
926
  }
692
- } else {
927
+ } else if (unresolvedFields.length === 0) {
928
+ // Fields after one that cannot be built yet are only checked, so this logs as many times
929
+ // as when the build stopped at that field.
693
930
  console.warn(`Configuration issue: Field ${fieldEntryName} does not define extensions.relation`);
694
931
  }
932
+ } else if (isAbstractOutputType(unwrapListAndNonNull(fieldEntry.type))) {
933
+ // Generated inputs cannot write an interface or union value, single or in a list, so the
934
+ // field is output-only. readOnly fields never get here.
935
+ warnOnce(gqltype, `abstract:${fieldEntryName}`, `Configuration issue: ${gqltype.name}.${fieldEntryName} `
936
+ + 'has an interface or union type, so generated inputs leave it out and generated mutations cannot '
937
+ + 'set it; resolve it yourself, and mark it readOnly to state that it is output-only.');
695
938
  } else if (getListShape(fieldEntry.type)) {
696
939
  const listShape = getListShape(fieldEntry.type);
697
940
  if (listShape.itemType === gqltype) {
@@ -704,10 +947,17 @@ const buildInputType = (gqltype) => {
704
947
  fieldEntryName, `${gqltype.name}U`,
705
948
  fieldEntry.extensions?.relation?.connectionField, false, gqltype.name);
706
949
  if (listInputTypeForAdd && listInputTypeForUpdate) {
707
- fieldArg.type = listInputTypeForAdd;
708
- fieldArgForUpdate.type = listInputTypeForUpdate;
950
+ if (listInputTypeForAdd !== NO_INPUT_FIELDS) fieldArg.type = listInputTypeForAdd;
951
+ else if (listShape.outerNonNull) omitRequiredEmbedded(fieldEntryName, listShape.itemType.name);
952
+ if (listInputTypeForUpdate !== NO_INPUT_FIELDS) fieldArgForUpdate.type = listInputTypeForUpdate;
953
+ warnEmptyEmbeddedInputs(gqltype, fieldEntryName, listShape.itemType.name,
954
+ listInputTypeForAdd === NO_INPUT_FIELDS, listInputTypeForUpdate === NO_INPUT_FIELDS);
709
955
  } else {
710
- return null;
956
+ // An object item here is embedded and its inputs are not built yet; any other item,
957
+ // such as a list, has no generated input.
958
+ unresolvedFields.push(listShape.itemType instanceof GraphQLObjectType
959
+ ? { field: fieldEntryName, waitsFor: listShape.itemType.name }
960
+ : { field: fieldEntryName });
711
961
  }
712
962
  }
713
963
  }
@@ -729,94 +979,176 @@ const buildInputType = (gqltype) => {
729
979
  }
730
980
  }
731
981
 
732
- const inputTypeBody = {
982
+ if (unresolvedFields.length > 0) return { unresolvedFields };
983
+
984
+ // Self-referencing collections follow the other fields, as they always have. Their item inputs
985
+ // are read when the schema is built, after these input types exist.
986
+ const selfFields = Object.keys(selfReferenceCollections);
987
+ for (const [fieldEntryName, fieldEntry] of Object.entries(selfReferenceCollections)) {
988
+ fieldsArgs[fieldEntryName] = {
989
+ type: selfCollectionInputType(gqltype, fieldEntryName, fieldEntry, false, selfFields),
990
+ description: fieldEntry.description,
991
+ };
992
+ fieldsArgForUpdate[fieldEntryName] = {
993
+ type: selfCollectionInputType(gqltype, fieldEntryName, fieldEntry, true, selfFields),
994
+ description: fieldEntry.description,
995
+ };
996
+ }
997
+
998
+ const inputTypeForAdd = new GraphQLInputObjectType({
733
999
  name: `${gqltype.name}Input`,
734
1000
  description: gqltype.description,
735
1001
  fields: fieldsArgs,
736
- };
737
-
738
- const inputTypeBodyForUpdate = {
1002
+ });
1003
+ const inputTypeForUpdate = new GraphQLInputObjectType({
739
1004
  name: `${gqltype.name}InputForUpdate`,
740
1005
  description: gqltype.description,
741
1006
  fields: fieldsArgForUpdate,
742
- };
743
-
744
- const inputTypeForAdd = new GraphQLInputObjectType(inputTypeBody);
745
- const inputTypeForUpdate = new GraphQLInputObjectType(inputTypeBodyForUpdate);
746
-
747
- const inputTypeForAddFields = inputTypeForAdd._fields();
748
-
749
- Object.keys(selfReferenceCollections).forEach((fieldEntryName) => {
750
- if (Object.prototype.hasOwnProperty.call(selfReferenceCollections, fieldEntryName)) {
751
- const fieldEntry = selfReferenceCollections[fieldEntryName];
752
- const listShape = getListShape(fieldEntry.type);
753
- const oneToMany = createOneToManyInputType('A', fieldEntryName,
754
- inputTypeForAdd, inputTypeForUpdate,
755
- normalizeConnectionField(
756
- gqltype,
757
- fieldEntry.extensions?.relation?.connectionField,
758
- ), listShape.itemNonNull);
759
- inputTypeForAddFields[fieldEntryName] = {
760
- type: listShape.outerNonNull ? new GraphQLNonNull(oneToMany) : oneToMany,
761
- name: fieldEntryName,
762
- };
763
- }
764
- });
765
-
766
- inputTypeForAdd._fields = () => inputTypeForAddFields;
767
-
768
- const inputTypeForUpdateFields = inputTypeForUpdate._fields();
769
-
770
- Object.keys(selfReferenceCollections).forEach((fieldEntryName) => {
771
- if (Object.prototype.hasOwnProperty.call(selfReferenceCollections, fieldEntryName)) {
772
- const fieldEntry = selfReferenceCollections[fieldEntryName];
773
- const listShape = getListShape(fieldEntry.type);
774
- inputTypeForUpdateFields[fieldEntryName] = {
775
- type: createOneToManyInputType('U', fieldEntryName,
776
- inputTypeForAdd, inputTypeForUpdate,
777
- normalizeConnectionField(
778
- gqltype,
779
- fieldEntry.extensions?.relation?.connectionField,
780
- ), listShape.itemNonNull),
781
- name: fieldEntryName,
782
- };
783
- }
784
1007
  });
785
1008
 
786
- inputTypeForUpdate._fields = () => inputTypeForUpdateFields;
787
-
788
- return { inputTypeBody: inputTypeForAdd, inputTypeBodyForUpdate: inputTypeForUpdate };
1009
+ return {
1010
+ inputTypeBody: inputTypeForAdd, inputTypeBodyForUpdate: inputTypeForUpdate, requiredWithoutCreateInput,
1011
+ };
789
1012
  };
790
1013
 
791
1014
  const getInputType = (type) => typesDict.types[type.name].inputType;
792
1015
 
793
- const buildPendingInputTypes = (waitingForInputType) => {
1016
+ // Registered types whose generated inputs the generated mutations of this schema can reach: the
1017
+ // included endpoint types and, through their writable fields, embedded types and the items of
1018
+ // collections.
1019
+ const typesWithReachableInputs = (includedMutationTypes) => {
1020
+ const reached = new Set();
1021
+ const visit = (gqltype) => {
1022
+ if (reached.has(gqltype)) return;
1023
+ reached.add(gqltype);
1024
+ for (const field of Object.values(gqltype.getFields())) {
1025
+ if (field.extensions?.readOnly) continue;
1026
+ const listShape = getListShape(field.type);
1027
+ const target = listShape ? listShape.itemType : unwrapNonNull(field.type);
1028
+ // A single reference is an IdInputType, so it reaches no input of its target.
1029
+ if (target instanceof GraphQLObjectType && typesDict.types[target.name]
1030
+ && (listShape || field.extensions?.relation?.embedded)) {
1031
+ visit(typesDict.types[target.name].gqltype);
1032
+ }
1033
+ }
1034
+ };
1035
+ for (const type of Object.values(typesDict.types)) {
1036
+ if (type.endpoint && !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)) {
1037
+ visit(type.gqltype);
1038
+ }
1039
+ }
1040
+ return reached;
1041
+ };
1042
+
1043
+ // A self-referencing collection's inputs are OneToManyA<field> and OneToManyU<field>. When several
1044
+ // types that the generated mutations reach have one with the same field name, the first registered
1045
+ // keeps those names and the others get OneToMany<Type>A<field> and OneToMany<Type>U<field>, as other
1046
+ // collections do. Types the mutations cannot reach keep the unqualified names, as such inputs never
1047
+ // collided. Names are fixed when a type's inputs are built, at its first schema.
1048
+ // A type is qualified only when this schema also reaches an earlier type that holds the unqualified
1049
+ // names and is still the type registered under its name, since both inputs then meet in one schema,
1050
+ // which failed with a duplicate type name before such names were qualified. Otherwise, as with a
1051
+ // type registered again under the same name or after an earlier createSchema(), it keeps the
1052
+ // unqualified names.
1053
+ const claimSelfCollectionNames = (pending, includedMutationTypes) => {
1054
+ const reachable = typesWithReachableInputs(includedMutationTypes);
1055
+ const meets = (type) => typesDict.types[type.name]?.gqltype === type && reachable.has(type);
1056
+ for (const { gqltype } of Object.values(pending)) {
1057
+ if (typesDict.types[gqltype.name].inputType) continue;
1058
+ for (const fieldName of selfCollectionFieldNames(gqltype)) {
1059
+ if (!selfCollectionNameHolders.has(fieldName)) selfCollectionNameHolders.set(fieldName, []);
1060
+ const holders = selfCollectionNameHolders.get(fieldName);
1061
+ const owner = reachable.has(gqltype) && holders.find((type) => type !== gqltype && meets(type));
1062
+ if (!owner) {
1063
+ if (!holders.includes(gqltype)) holders.push(gqltype);
1064
+ } else {
1065
+ if (!qualifiedSelfCollections.has(gqltype)) qualifiedSelfCollections.set(gqltype, new Set());
1066
+ qualifiedSelfCollections.get(gqltype).add(fieldName);
1067
+ warnOnce(gqltype, `selfCollectionName:${fieldName}`, `Configuration issue: ${gqltype.name}.${fieldName} `
1068
+ + `and ${owner.name}.${fieldName} are self-referencing collections with the same name, so the inputs `
1069
+ + `of ${gqltype.name}.${fieldName} are named OneToMany${gqltype.name}A${fieldName} and `
1070
+ + `OneToMany${gqltype.name}U${fieldName}, while ${owner.name}.${fieldName} keeps OneToManyA${fieldName} `
1071
+ + `and OneToManyU${fieldName}. To choose which type keeps those names, register it first, or make `
1072
+ + 'the other type unreachable from the generated mutations.');
1073
+ }
1074
+ }
1075
+ }
1076
+ };
1077
+
1078
+ // The INPUT_TYPE_UNRESOLVED message. It names every field whose input could not be built, by type:
1079
+ // the causes (lists whose items have no generated input, and embedded fields whose types contain
1080
+ // each other) and, apart from them, fields that only wait for another listed type.
1081
+ const unresolvedInputTypesMessage = (pendingTypeNames, unresolvedByType) => {
1082
+ // Whether the inputs of type `from` wait, through embedded fields, for those of type `to`.
1083
+ const waitsOn = (from, to, seen = new Set()) => {
1084
+ if (from === to) return true;
1085
+ if (seen.has(from)) return false;
1086
+ seen.add(from);
1087
+ return (unresolvedByType[from] || [])
1088
+ .some(({ waitsFor }) => waitsFor !== undefined && waitsOn(waitsFor, to, seen));
1089
+ };
1090
+ const lists = [];
1091
+ const cycles = [];
1092
+ const waiting = [];
1093
+ for (const [typeName, fields] of Object.entries(unresolvedByType)) {
1094
+ for (const { field, waitsFor } of fields) {
1095
+ const name = `${typeName}.${field}`;
1096
+ if (waitsFor === undefined) lists.push(name);
1097
+ else if (waitsOn(waitsFor, typeName)) cycles.push(name);
1098
+ else waiting.push(`${name} waits for ${waitsFor}`);
1099
+ }
1100
+ }
1101
+ const causes = [
1102
+ lists.length > 0 && `${lists.join(', ')} (writable lists whose items have no generated input, such as lists of lists)`,
1103
+ cycles.length > 0 && `${cycles.join(', ')} (embedded types that contain each other)`,
1104
+ ].filter(Boolean);
1105
+ return `Could not build input types for: ${pendingTypeNames.join(', ')}. `
1106
+ + 'Check for circular or misconfigured relations'
1107
+ + (causes.length > 0
1108
+ ? `: no input can be generated for ${causes.join(' or for ')}; mark such a field readOnly.`
1109
+ : '.')
1110
+ + (waiting.length > 0
1111
+ ? ` These fields only wait for another listed type and build once it does: ${waiting.join(', ')}.`
1112
+ : '');
1113
+ };
1114
+
1115
+ const buildPendingInputTypes = (waitingForInputType, includedMutationTypes) => {
1116
+ claimSelfCollectionNames(waitingForInputType, includedMutationTypes);
1117
+ // Inputs built for an earlier schema, which did not reach them, may leave out a non-null embedded
1118
+ // field that this schema's generated mutations would need; inputs built now are checked as built.
1119
+ const reachable = typesWithReachableInputs(includedMutationTypes);
1120
+ for (const gqltype of reachable) {
1121
+ const registration = typesDict.types[gqltype.name];
1122
+ const [omitted] = (registration.inputType && requiredFieldsWithoutCreateInput.get(registration)) || [];
1123
+ if (omitted) throw requiredEmbeddedWithoutInputError(gqltype, omitted);
1124
+ }
794
1125
  let pending = waitingForInputType;
795
1126
  let previousPendingCount = Object.keys(pending).length + 1;
1127
+ // Type name -> the fields whose input could not be built in the last pass.
1128
+ let unresolvedFields = {};
796
1129
 
797
1130
  while (Object.keys(pending).length > 0) {
798
1131
  const currentCount = Object.keys(pending).length;
799
1132
  if (currentCount >= previousPendingCount) {
800
- const unresolved = Object.keys(pending).join(', ');
801
- throw new SimfinityError(
802
- `Could not build input types for: ${unresolved}. Check for circular or misconfigured relations.`,
803
- 'INPUT_TYPE_UNRESOLVED',
804
- 500,
805
- );
1133
+ throw new SimfinityError(unresolvedInputTypesMessage(Object.keys(pending), unresolvedFields),
1134
+ 'INPUT_TYPE_UNRESOLVED', 500);
806
1135
  }
807
1136
  previousPendingCount = currentCount;
808
1137
 
809
1138
  const stillWaiting = {};
1139
+ unresolvedFields = {};
810
1140
  for (const [key, value] of Object.entries(pending)) {
811
1141
  const { gqltype } = value;
812
1142
  if (typesDict.types[gqltype.name].inputType) continue;
813
1143
 
814
- const result = buildInputType(gqltype);
815
- if (result && result.inputTypeBody && result.inputTypeBodyForUpdate) {
1144
+ const result = buildInputType(gqltype, reachable);
1145
+ if (result.inputTypeBody && result.inputTypeBodyForUpdate) {
816
1146
  typesDict.types[gqltype.name].inputType = result.inputTypeBody;
817
1147
  typesDictForUpdate.types[gqltype.name].inputType = result.inputTypeBodyForUpdate;
1148
+ requiredFieldsWithoutCreateInput.set(typesDict.types[gqltype.name], result.requiredWithoutCreateInput);
818
1149
  } else {
819
1150
  stillWaiting[key] = value;
1151
+ unresolvedFields[gqltype.name] = result.unresolvedFields;
820
1152
  }
821
1153
  }
822
1154
  pending = stillWaiting;
@@ -1070,7 +1402,11 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1070
1402
  if (!result) throw new SimfinityError(`${gqltype.name} ${objectId} is not valid`, 'NOT_VALID_ID', 404);
1071
1403
 
1072
1404
  if (materializedModel.collectionFields) {
1073
- await iterateOnCollectionFields(materializedModel, gqltype, objectId, session, context);
1405
+ // Children link to, and are owned through, the parent's stored key, as when a new parent is
1406
+ // saved: the client may send a spelling of the ID that the adapter accepts but does not store,
1407
+ // such as an uppercase ObjectId or UUID. It is the key the generated collection field reads
1408
+ // children by (`_id`, else `id`); an adapter whose update result has neither keeps the requested ID.
1409
+ await iterateOnCollectionFields(materializedModel, gqltype, result._id ?? result.id ?? objectId, session, context);
1074
1410
  }
1075
1411
 
1076
1412
  if (controller && controller.onUpdated) {
@@ -1232,7 +1568,9 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
1232
1568
  );
1233
1569
  };
1234
1570
 
1235
- const executeItemFunction = async (gqltype, collectionField, objectId, session,
1571
+ // parentKey is the parent's stored key: children are linked to it, and an updated or deleted child
1572
+ // must already store it in its connectionField.
1573
+ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1236
1574
  collectionFieldsList, operationType, context) => {
1237
1575
  const argTypes = gqltype.getFields();
1238
1576
  const collectionGQLType = unwrapListAndNonNull(argTypes[collectionField].type);
@@ -1246,7 +1584,7 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
1246
1584
  const type = operationType === operations.UPDATE
1247
1585
  ? typesDictForUpdate.types[collectionGQLType.name] : typesDict.types[collectionGQLType.name];
1248
1586
  const linkToParent = (item) => {
1249
- item[connection.storageFieldName] = objectId;
1587
+ item[connection.storageFieldName] = parentKey;
1250
1588
  if (item.$unset) delete item.$unset[connection.storageFieldName];
1251
1589
  if (item.$set) delete item.$set[connection.storageFieldName];
1252
1590
  };
@@ -1262,8 +1600,8 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
1262
1600
  if (!child) {
1263
1601
  throw new SimfinityError(`${collectionGQLType.name} ${id} is not valid`, 'NOT_VALID_ID', 404);
1264
1602
  }
1265
- if (String(child[connection.storageFieldName]) !== String(objectId)) {
1266
- throw new SimfinityError('Child does not belong to this parent', 'FORBIDDEN', 403);
1603
+ if (String(child[connection.storageFieldName]) !== String(parentKey)) {
1604
+ throw new ForbiddenError('Child does not belong to this parent');
1267
1605
  }
1268
1606
  }
1269
1607
 
@@ -1528,39 +1866,61 @@ const resolveById = async (type, args, context, requiredId) => {
1528
1866
  return results[0] || null;
1529
1867
  };
1530
1868
 
1869
+ // Why a type's generated create or update input has no fields, for the warnings of buildMutation. An
1870
+ // update input also needs a writable id.
1871
+ const withoutWritableFields = (type, { noWritableId = false } = {}) => {
1872
+ const stateField = type.gqltype.getFields().state;
1873
+ const managedState = type.stateMachine && stateField && !stateField.extensions?.readOnly;
1874
+ const state = managedState ? ' besides its state, which its state machine manages' : '';
1875
+ const id = noWritableId ? `${state ? ',' : ''} and no writable id` : '';
1876
+ return `${type.gqltype.name} has no writable fields${state}${id}`;
1877
+ };
1878
+
1531
1879
  // Every generated mutation field carries extensions.simfinityMutation with its operation (and, for
1532
1880
  // a state-machine action, the action and its from/to states), so tools such as the MCP package can
1533
1881
  // classify it without guessing from its name. Extensions do not appear in SDL or introspection.
1882
+ // An input object needs a field, so a type whose create input has none gets no add mutation, and
1883
+ // one whose update input has none, which only a type without a writable id can have, gets no update
1884
+ // mutation or state machine actions either (#161).
1534
1885
  const buildMutation = (name, includedMutationTypes, includedCustomMutations) => {
1535
1886
  const rootQueryArgs = {};
1536
1887
  rootQueryArgs.name = name;
1537
1888
  rootQueryArgs.fields = {};
1538
1889
 
1539
- buildPendingInputTypes(waitingInputType);
1890
+ buildPendingInputTypes(waitingInputType, includedMutationTypes);
1540
1891
 
1541
1892
  for (const type of Object.values(typesDict.types)) {
1542
1893
  if (!shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)) {
1543
1894
  if (type.endpoint) {
1544
1895
  const argsObject = { input: { type: new GraphQLNonNull(type.inputType) } };
1545
1896
 
1546
- rootQueryArgs.fields[`add${type.simpleEntityEndpointName}`] = {
1547
- type: type.gqltype,
1548
- description: 'add',
1549
- args: argsObject,
1550
- extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.SAVE } },
1551
- async resolve(parent, args, context) {
1552
- const params = {
1553
- type,
1554
- args,
1555
- operation: operations.SAVE,
1556
- context,
1557
- };
1897
+ if (hasInputFields(type.inputType)) {
1898
+ rootQueryArgs.fields[`add${type.simpleEntityEndpointName}`] = {
1899
+ type: type.gqltype,
1900
+ description: 'add',
1901
+ args: argsObject,
1902
+ extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.SAVE } },
1903
+ async resolve(parent, args, context) {
1904
+ const params = {
1905
+ type,
1906
+ args,
1907
+ operation: operations.SAVE,
1908
+ context,
1909
+ };
1558
1910
 
1559
- await executeMiddleware(params);
1560
- return executeOperation(type.model, type.gqltype, type.controller,
1561
- args.input, operations.SAVE, null, null, context, type.inputType);
1562
- },
1563
- };
1911
+ await executeMiddleware(params);
1912
+ return executeOperation(type.model, type.gqltype, type.controller,
1913
+ args.input, operations.SAVE, null, null, context, type.inputType);
1914
+ },
1915
+ };
1916
+ } else {
1917
+ warnOnce(type.gqltype, 'emptyCreateInput', `Configuration issue: ${withoutWritableFields(type)}, so the `
1918
+ + `schema has no add${type.simpleEntityEndpointName} mutation; create its records with a registered `
1919
+ + (type.stateMachine
1920
+ ? 'mutation, which must set the initial state (saveObject() sets it).'
1921
+ : `mutation that takes no input or an input of its own, since getInputType(${type.gqltype.name}) `
1922
+ + 'has no fields.'));
1923
+ }
1564
1924
  rootQueryArgs.fields[`delete${type.simpleEntityEndpointName}`] = {
1565
1925
  type: type.gqltype,
1566
1926
  description: 'delete',
@@ -1585,7 +1945,12 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1585
1945
 
1586
1946
  for (const type of Object.values(typesDictForUpdate.types)) {
1587
1947
  if (!shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)) {
1588
- if (type.endpoint) {
1948
+ if (type.endpoint && !hasInputFields(type.inputType)) {
1949
+ const reason = withoutWritableFields(type, { noWritableId: true });
1950
+ warnOnce(type.gqltype, 'emptyUpdateInput', `Configuration issue: ${reason}, so its generated update input `
1951
+ + `would have no fields and the schema has no update${type.simpleEntityEndpointName} mutation`
1952
+ + `${type.stateMachine ? ' and no state machine actions' : ''}.`);
1953
+ } else if (type.endpoint) {
1589
1954
  const argsObject = { input: { type: new GraphQLNonNull(type.inputType) } };
1590
1955
  rootQueryArgs.fields[`update${type.simpleEntityEndpointName}`] = {
1591
1956
  type: type.gqltype,
@@ -1671,7 +2036,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1671
2036
  return new GraphQLObjectType(rootQueryArgs);
1672
2037
  };
1673
2038
 
1674
- const buildRootQuery = (name, includedTypes) => {
2039
+ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult) => {
1675
2040
  const rootQueryArgs = {};
1676
2041
  rootQueryArgs.name = name;
1677
2042
  rootQueryArgs.fields = {};
@@ -1708,7 +2073,8 @@ const buildRootQuery = (name, includedTypes) => {
1708
2073
  await executeScope(params);
1709
2074
  await applyJoinedScopes(joinedScopes, params);
1710
2075
  const queryArgs = params.args;
1711
- const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count);
2076
+ const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
2077
+ && canReportCount(parent, info, context);
1712
2078
 
1713
2079
  const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
1714
2080
  const countPromise = wantsCount
@@ -1716,8 +2082,10 @@ const buildRootQuery = (name, includedTypes) => {
1716
2082
  : null;
1717
2083
 
1718
2084
  const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
1719
- if (wantsCount) {
1720
- reportCount(parent, info, context, resultCount);
2085
+ if (wantsCount && !reportCount(parent, info, context, resultCount)) {
2086
+ warnOnce(runtimeIdentity, 'count-context', 'Configuration issue: the GraphQL context does not accept '
2087
+ + 'a count property (it is frozen, sealed or has a read-only count), so pagination counts are not '
2088
+ + 'reported; pass a fresh mutable context object for each request.');
1721
2089
  }
1722
2090
  return result;
1723
2091
  },
@@ -1734,7 +2102,7 @@ const buildRootQuery = (name, includedTypes) => {
1734
2102
  };
1735
2103
 
1736
2104
  rootQueryArgs.fields[`${type.listEntitiesEndpointName}_aggregate`] = {
1737
- type: new GraphQLList(QLTypeAggregationResult),
2105
+ type: new GraphQLList(aggregationResultType),
1738
2106
  args: aggregateArgsObject,
1739
2107
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
1740
2108
  async resolve(parent, args, context) {
@@ -1785,6 +2153,102 @@ const installIdResolver = (gqltype) => {
1785
2153
  if (idField && !idField.resolve) idField.resolve = markGenerated((parent) => parent._id ?? parent.id);
1786
2154
  };
1787
2155
 
2156
+ // A rendered embedded value that may hold no data: a plain object, or a hydrated record, which
2157
+ // converts itself with toObject() (MongoDB documents and their nested paths do). Promises, arrays,
2158
+ // functions and other objects, such as dates or identifiers, are values.
2159
+ const isRecordObject = (value) => {
2160
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || isThenable(value)) return false;
2161
+ const prototype = Object.getPrototypeOf(value);
2162
+ return prototype === Object.prototype || prototype === null || typeof value.toObject === 'function';
2163
+ };
2164
+ const isPlainObject = (value) => {
2165
+ const prototype = Object.getPrototypeOf(value);
2166
+ return prototype === Object.prototype || prototype === null;
2167
+ };
2168
+
2169
+ // The members an embedded value stores. The rule below reads them instead of the value, so no getter
2170
+ // or virtual of the application runs, whatever the selection. An adapter that hydrates records
2171
+ // returns their stored data through rawEmbeddedValue (MongoDB: a nested path's raw value, a
2172
+ // subdocument's _doc); an absent stored value has no members. A plain object is read as it is.
2173
+ // Returns null for anything else, such as a record the adapter does not convert, which counts as data.
2174
+ const storedMembersOf = (value) => {
2175
+ if (!isRecordObject(value)) return null;
2176
+ const stored = typeof adapter.rawEmbeddedValue === 'function' ? adapter.rawEmbeddedValue(value) : value;
2177
+ if (stored === null || stored === undefined) return {};
2178
+ return typeof stored === 'object' && !Array.isArray(stored) && !isThenable(stored) && isPlainObject(stored)
2179
+ ? stored : null;
2180
+ };
2181
+
2182
+ // A stored member: an own data property, read without calling accessors. An accessor is present,
2183
+ // whatever it would return.
2184
+ const PRESENT = Symbol('present');
2185
+ const ownStoredValue = (stored, key) => {
2186
+ const descriptor = Object.getOwnPropertyDescriptor(stored, key);
2187
+ if (!descriptor) return undefined;
2188
+ if (!('value' in descriptor)) return PRESENT;
2189
+ return isObjectMember(key, descriptor.value) ? undefined : descriptor.value;
2190
+ };
2191
+ // A referenced collection stores nothing in the object. `id` reads the stored identity, `_id` before
2192
+ // `id`, as the generated entity resolver (installIdResolver) and Mongoose's id virtual do. Every other
2193
+ // member is stored under its storage name.
2194
+ const isReferencedCollection = (member) => {
2195
+ const relation = member.extensions?.relation;
2196
+ return Boolean(relation && !relation.embedded && getListShape(member.type));
2197
+ };
2198
+ const storedMemberValue = (stored, memberName, member) => {
2199
+ if (isReferencedCollection(member)) return undefined;
2200
+ const identity = memberName === 'id' ? ownStoredValue(stored, '_id') : undefined;
2201
+ return identity ?? ownStoredValue(stored, getFieldStorageName(memberName, member));
2202
+ };
2203
+
2204
+ // Whether stored members hold no data: every member is null or undefined, an empty list, or a
2205
+ // singular embedded object that holds no data. Any other value, a promise or an accessor included,
2206
+ // is data. An object that contains itself, which only a custom result can, holds data.
2207
+ const holdsNoData = (stored, type, ancestors = []) => !ancestors.includes(stored)
2208
+ && Object.entries(type.getFields()).every(([memberName, member]) => {
2209
+ const value = storedMemberValue(stored, memberName, member);
2210
+ if (value === null || value === undefined) return true;
2211
+ if (Array.isArray(value)) return value.length === 0;
2212
+ const memberType = unwrapNonNull(member.type);
2213
+ if (member.extensions?.relation?.embedded !== true || !(memberType instanceof GraphQLObjectType)) return false;
2214
+ const nested = storedMembersOf(value);
2215
+ return nested !== null && holdsNoData(nested, memberType, [...ancestors, stored]);
2216
+ });
2217
+
2218
+ // The members that count: non-null members that generated inputs accept. As on writes
2219
+ // (completeEmbeddedValue), `id` and readOnly members do not; nor do interface and union members,
2220
+ // which buildInputType leaves out; nor do referenced collections or members with an application
2221
+ // resolver, which may compute a value.
2222
+ const countsAsRequired = (type, memberName, member) => member.type instanceof GraphQLNonNull
2223
+ && !isReferencedCollection(member) && memberName !== 'id' && !member.extensions?.readOnly
2224
+ && !isAbstractOutputType(unwrapListAndNonNull(member.type)) && !hasApplicationResolver(type, memberName);
2225
+ const hasRequiredMember = (type) => Object.entries(type.getFields())
2226
+ .some(([memberName, member]) => countsAsRequired(type, memberName, member));
2227
+
2228
+ // Whether rendering such an object fails: a member that counts is null or undefined, or is a singular
2229
+ // embedded object that misses one of its own. A promise or an accessor counts as present.
2230
+ const missesRequiredMember = (stored, type) => Object.entries(type.getFields()).some(([memberName, member]) => {
2231
+ if (!countsAsRequired(type, memberName, member)) return false;
2232
+ const value = storedMemberValue(stored, memberName, member);
2233
+ if (value === null || value === undefined) return true;
2234
+ if (member.extensions?.relation?.embedded !== true || !(member.type.ofType instanceof GraphQLObjectType)) return false;
2235
+ const nested = storedMembersOf(value);
2236
+ return nested !== null && missesRequiredMember(nested, member.type.ofType);
2237
+ });
2238
+
2239
+ // A type without a member that counts never reads as null, so its values are returned untouched,
2240
+ // with no member read. Reading stored members never changes the result otherwise: a value whose
2241
+ // members cannot be read, because an adapter hook or a property read throws, is returned as it is.
2242
+ const readEmbeddedObject = (value, type) => {
2243
+ if (!(type instanceof GraphQLObjectType) || !hasRequiredMember(type)) return value;
2244
+ try {
2245
+ const stored = storedMembersOf(value);
2246
+ return stored !== null && holdsNoData(stored, type) && missesRequiredMember(stored, type) ? null : value;
2247
+ } catch {
2248
+ return value;
2249
+ }
2250
+ };
2251
+
1788
2252
  // An adapter that hydrates stored records may render an explicitly null singular embedded object as
1789
2253
  // an object (MongoDB nested paths do); readEmbeddedValue returns null for it. Nullable singular
1790
2254
  // embedded fields of registered types, and of the embedded types they reach, read through it. The
@@ -1793,11 +2257,24 @@ const installIdResolver = (gqltype) => {
1793
2257
  // The value is read as graphql's default resolver reads it, so a method on the parent, such as one a
1794
2258
  // custom mutation result supplies, is still called; a promise it returns is read once it settles.
1795
2259
  // Only a field named like an Object.prototype member that reads as that member reads as null.
2260
+ // Such an adapter may also render an absent object, or store an omitted one, as an object that holds
2261
+ // nothing but its list defaults (MongoDB nested paths do). An object that holds no data and misses a
2262
+ // required member (see missesRequiredMember) cannot be rendered, so it reads as null, as an absent
2263
+ // object does on other read paths, whatever the selection. The rule reads the stored members
2264
+ // (storedMembersOf), never running a getter or virtual, and depends only on the value and its type,
2265
+ // so runtimes that share the type, with or without the hook, read the same. An object a
2266
+ // generated create or update writes does not read as null, since those writes require the members
2267
+ // counted, with one exception: Mongoose minimizes empty objects when it saves a new document, so a
2268
+ // create that writes an empty object for a required embedded member (`geo: {}`) stores that member
2269
+ // as absent. An object that holds nothing else then reads as null, as an omitted one does, except
2270
+ // where Mongoose hydrates the document (by-ID and reference reads, update responses) and the member is
2271
+ // a nested path, which it renders as `{}`. Generated models declare every embedded member as a nested
2272
+ // path except one named `type`, a single nested subdocument. PostgreSQL rejects such a create
2273
+ // (REQUIRED_VALUE).
1796
2274
  const readEmbeddedField = markGenerated((parent, args, context, info) => {
1797
2275
  const value = readOwnField(parent, args, context, info);
1798
- return isThenable(value)
1799
- ? value.then((resolved) => adapter.readEmbeddedValue(resolved))
1800
- : adapter.readEmbeddedValue(value);
2276
+ const read = (resolved) => readEmbeddedObject(adapter.readEmbeddedValue(resolved), getNamedType(info.returnType));
2277
+ return isThenable(value) ? value.then(read) : read(value);
1801
2278
  });
1802
2279
 
1803
2280
  const installEmbeddedValueResolvers = (gqltype, visited) => {
@@ -1860,6 +2337,20 @@ const assertCollectionConnections = () => {
1860
2337
 
1861
2338
  const getRegistrations = () => Object.values(typesDict.types);
1862
2339
 
2340
+ // Application types that a schema with these allowlists contains, whatever else it reaches: the
2341
+ // endpoint types of its queries and mutations, and the input and output types of its registered
2342
+ // mutations.
2343
+ const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => [
2344
+ ...Object.values(typesDict.types)
2345
+ .filter((type) => type.endpoint
2346
+ && ((type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
2347
+ || !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)))
2348
+ .map((type) => type.gqltype),
2349
+ ...Object.entries(registeredMutations)
2350
+ .filter(([entry]) => !shouldNotBeIncludedInSchema(includedCustomMutations, entry))
2351
+ .flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean)),
2352
+ ];
2353
+
1863
2354
  const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => {
1864
2355
  // Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
1865
2356
  Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
@@ -1867,6 +2358,11 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1867
2358
  schemaTypes.forEach(assertNotBoundElsewhere);
1868
2359
  schemaTypes.forEach(assertFieldsNotBoundElsewhere);
1869
2360
  assertCollectionConnections();
2361
+ // An application type with the name of a field metadata type that an earlier schema fixed; one that
2362
+ // only generated types or the fields of an input object reach is found when the schema is
2363
+ // constructed.
2364
+ assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes,
2365
+ includedCustomMutations));
1870
2366
  const referencedTypes = markReferencedTypesForModelGeneration();
1871
2367
  // Entities, which keep their own stored identity: connect() types and relation targets.
1872
2368
  Object.values(typesDict.types).forEach((typeInfo) => {
@@ -1914,17 +2410,21 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1914
2410
  const objectMemberTypes = new Set();
1915
2411
  Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
1916
2412
 
1917
- const query = buildRootQuery('RootQueryType', includedQueryTypes);
2413
+ let query = buildRootQuery('RootQueryType', includedQueryTypes);
1918
2414
  Object.values(typesDict.types).forEach(({ gqltype }) => {
1919
2415
  if (gqltype && !applicationResolvedFields.has(gqltype)) {
1920
2416
  applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
1921
2417
  }
1922
2418
  });
2419
+ const mutation = buildMutation('Mutation', includedMutationTypes, includedCustomMutations);
2420
+ // An application scalar named JSON that this schema contains also types aggregation results. Any
2421
+ // other type named JSON still collides, and GraphQLSchema reports the duplicate name.
2422
+ const applicationJSON = findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult);
2423
+ if (applicationJSON instanceof GraphQLScalarType) {
2424
+ query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON));
2425
+ }
1923
2426
 
1924
- const schema = new GraphQLSchema({
1925
- query,
1926
- mutation: buildMutation('Mutation', includedMutationTypes, includedCustomMutations),
1927
- });
2427
+ const schema = constructSchema(query, mutation);
1928
2428
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
1929
2429
  // custom mutation result, so another runtime cannot later generate resolvers this schema executes.
1930
2430
  schemaTypes.forEach((type) => {
@@ -2047,6 +2547,14 @@ const autoGenerateResolvers = (gqltype) => {
2047
2547
  }
2048
2548
  };
2049
2549
 
2550
+ // A replaced registration builds fresh inputs. Its cached collection projections may already
2551
+ // contain fields from the previous input graph; keep those alive only in earlier schemas.
2552
+ const invalidateCollectionInputs = (gqltype) => {
2553
+ for (const [name, cached] of excludedFieldInputTypes) {
2554
+ if (cached.itemType.name === gqltype.name) excludedFieldInputTypes.delete(name);
2555
+ }
2556
+ };
2557
+
2050
2558
  const connect = (model, gqltype, simpleEntityEndpointName,
2051
2559
  listEntitiesEndpointName, controller, onModelCreated, stateMachine) => {
2052
2560
  const registration = {
@@ -2063,6 +2571,7 @@ const connect = (model, gqltype, simpleEntityEndpointName,
2063
2571
  validateScope(gqltype);
2064
2572
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
2065
2573
 
2574
+ invalidateCollectionInputs(gqltype);
2066
2575
  waitingInputType[gqltype.name] = {
2067
2576
  model,
2068
2577
  gqltype,
@@ -2092,6 +2601,7 @@ const addNoEndpointType = (gqltype) => {
2092
2601
  validateScope(gqltype);
2093
2602
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
2094
2603
 
2604
+ invalidateCollectionInputs(gqltype);
2095
2605
  waitingInputType[gqltype.name] = { gqltype };
2096
2606
  typesDict.types[gqltype.name] = registration;
2097
2607
  typesDictForUpdate.types[gqltype.name] = { ...typesDict.types[gqltype.name] };
@@ -2115,27 +2625,31 @@ const createArgsForQuery = (gqltype, omit = null) => {
2115
2625
 
2116
2626
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
2117
2627
  if (fieldEntryName === omit) continue;
2118
- argsObject[fieldEntryName] = {};
2628
+ let filterType = null;
2119
2629
 
2120
2630
  if (fieldEntry.type instanceof GraphQLScalarType
2121
2631
  || isNonNullOfType(fieldEntry.type, GraphQLScalarType)
2122
2632
  || fieldEntry.type instanceof GraphQLEnumType
2123
2633
  || isNonNullOfType(fieldEntry.type, GraphQLEnumType)) {
2124
- argsObject[fieldEntryName].type = QLFilter;
2634
+ filterType = QLFilter;
2125
2635
  } else if (fieldEntry.type instanceof GraphQLObjectType
2126
2636
  || isNonNullOfType(fieldEntry.type, GraphQLObjectType)) {
2127
- argsObject[fieldEntryName].type = QLTypeFilterExpression;
2637
+ filterType = QLTypeFilterExpression;
2128
2638
  } else if (getListShape(fieldEntry.type)) {
2129
2639
  const listOfType = getListShape(fieldEntry.type).itemType;
2130
2640
  if (listOfType instanceof GraphQLScalarType
2131
2641
  || isNonNullOfType(listOfType, GraphQLScalarType)
2132
2642
  || listOfType instanceof GraphQLEnumType
2133
2643
  || isNonNullOfType(listOfType, GraphQLEnumType)) {
2134
- argsObject[fieldEntryName].type = QLFilter;
2644
+ filterType = QLFilter;
2135
2645
  } else {
2136
- argsObject[fieldEntryName].type = QLTypeFilterExpression;
2646
+ // Includes lists of interfaces and unions, which have no generated filter either. Their
2647
+ // argument has always been generated and is kept, so requests that send it as null stay valid.
2648
+ filterType = QLTypeFilterExpression;
2137
2649
  }
2138
2650
  }
2651
+ // A single interface or union field has no generated filter, so it gets no argument.
2652
+ if (filterType) argsObject[fieldEntryName] = { type: filterType };
2139
2653
  }
2140
2654
 
2141
2655
  argsObject.pagination = {};