@simtlix/simfinity-core 3.5.7 → 3.5.8

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';
@@ -34,15 +35,23 @@ const countSinkOf = (value) => {
34
35
  return typeof sink === 'function' ? sink : undefined;
35
36
  };
36
37
  // 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.
38
+ // context) still pass on; info.rootValue is the fallback.
39
+ const countSinkFor = (parent, info) => countSinkOf(parent) || (info ? countSinkOf(info.rootValue) : undefined);
40
+ // Without a sink the count goes to context.count, where Envelop/Apollo/Yoga count plugins read it.
41
+ // With neither, such as a graphql() call without contextValue, nothing could read a count, so the
42
+ // count query is skipped.
43
+ const canReportCount = (parent, info, context) => countSinkFor(parent, info) !== undefined
44
+ || (context !== null && (typeof context === 'object' || typeof context === 'function'));
45
+ // Returns whether the count was stored.
39
46
  const reportCount = (parent, info, context, count) => {
40
- const sink = countSinkOf(parent) || (info ? countSinkOf(info.rootValue) : undefined);
47
+ const sink = countSinkFor(parent, info);
41
48
  if (sink) {
42
49
  sink(count);
43
- return;
50
+ return true;
44
51
  }
45
- context.count = count;
52
+ // Reflect.set returns false for a frozen, sealed or read-only context, where an assignment would
53
+ // throw after both queries ran.
54
+ return Reflect.set(context, 'count', count);
46
55
  };
47
56
  const applicationResolvedFields = new WeakMap();
48
57
  const markGenerated = (resolve) => {
@@ -201,6 +210,31 @@ const GraphQLJSON = new GraphQLScalarType({
201
210
  },
202
211
  });
203
212
 
213
+ // The type called `name` that a schema built from these roots would contain, other than through
214
+ // `skip`, following the references GraphQLSchema collects: fields, arguments, interfaces, union
215
+ // members and input fields.
216
+ const findSchemaType = (roots, name, skip) => {
217
+ const visited = new Set();
218
+ const pending = [...roots];
219
+ while (pending.length) {
220
+ const type = getNamedType(pending.pop());
221
+ if (!type || type === skip || visited.has(type)) continue;
222
+ visited.add(type);
223
+ if (type.name === name) return type;
224
+ if (type instanceof GraphQLUnionType) pending.push(...type.getTypes());
225
+ if (type instanceof GraphQLObjectType || type instanceof GraphQLInterfaceType) {
226
+ pending.push(...type.getInterfaces());
227
+ for (const field of Object.values(type.getFields())) {
228
+ pending.push(field.type, ...field.args.map((arg) => arg.type));
229
+ }
230
+ }
231
+ if (type instanceof GraphQLInputObjectType) {
232
+ for (const field of Object.values(type.getFields())) pending.push(field.type);
233
+ }
234
+ }
235
+ return null;
236
+ };
237
+
204
238
  // graphql-js wraps a value thrown while resolving a field (locatedError): the wrapper has the field
205
239
  // path and repeats the thrown error's message. A thrown non-Error becomes a NonErrorThrown whose
206
240
  // message prints the value.
@@ -338,6 +372,14 @@ export const createRuntime = (adapter) => {
338
372
  const typesDict = { types: {} };
339
373
  const waitingInputType = {};
340
374
  const typesDictForUpdate = { types: {} };
375
+ // `added` item inputs of referenced collections by generated name, so collections that reach the
376
+ // same child through the same connectionField share one input type.
377
+ const excludedFieldInputTypes = new Map();
378
+ // Self-referencing collection field name -> the types whose collection inputs were built with the
379
+ // unqualified names OneToManyA<field> and OneToManyU<field>; type -> its self-referencing
380
+ // collections whose inputs are named after the type instead (see claimSelfCollectionNames).
381
+ const selfCollectionNameHolders = new Map();
382
+ const qualifiedSelfCollections = new Map();
341
383
  const registeredMutations = {};
342
384
  const middlewares = [];
343
385
 
@@ -519,13 +561,24 @@ const QLTypeAggregationExpression = new GraphQLInputObjectType({
519
561
  }),
520
562
  });
521
563
 
522
- const QLTypeAggregationResult = new GraphQLObjectType({
564
+ // Aggregation results use Simfinity's JSON scalar, or the application's own scalar named JSON when
565
+ // the schema has one, such as GraphQLJSON from graphql-scalars, so the two do not collide.
566
+ const createAggregationResultType = (valueType) => new GraphQLObjectType({
523
567
  name: 'QLTypeAggregationResult',
524
568
  fields: () => ({
525
- groupId: { type: GraphQLJSON },
526
- facts: { type: GraphQLJSON },
569
+ groupId: { type: valueType },
570
+ facts: { type: valueType },
527
571
  }),
528
572
  });
573
+ const QLTypeAggregationResult = createAggregationResultType(GraphQLJSON);
574
+ const applicationJSONResultTypes = new WeakMap();
575
+ const aggregationResultTypeFor = (valueType) => {
576
+ if (valueType === GraphQLJSON) return QLTypeAggregationResult;
577
+ if (!applicationJSONResultTypes.has(valueType)) {
578
+ applicationJSONResultTypes.set(valueType, createAggregationResultType(valueType));
579
+ }
580
+ return applicationJSONResultTypes.get(valueType);
581
+ };
529
582
 
530
583
  const isNonNullOfType = (fieldEntryType, graphQLType) => {
531
584
  let isOfType = false;
@@ -536,6 +589,7 @@ const isNonNullOfType = (fieldEntryType, graphQLType) => {
536
589
  };
537
590
 
538
591
  const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
592
+ const isAbstractOutputType = (type) => type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType;
539
593
 
540
594
  const unwrapListAndNonNull = (type) => {
541
595
  let unwrapped = type;
@@ -553,59 +607,69 @@ const wrapListInputType = (itemType, listShape, preserveOuterNonNull) => {
553
607
  : listType;
554
608
  };
555
609
 
610
+ const capitalize = (name) => name.charAt(0).toUpperCase() + name.slice(1);
611
+
556
612
  /**
557
- * Creates a new GraphQLInputObjectType with a field excluded.
613
+ * The `added` item input of a referenced collection: the item type's create input without the
614
+ * back-reference that the parent supplies. Its fields are read from the item's create input when
615
+ * the schema is built, so the item's input may be built after this type, as in cycles of
616
+ * collections. `optionalFields` lose their outer non-null wrapper. Collections that reach the same
617
+ * item through the same connectionField with the same prefix share one type.
558
618
  * @param {string} inputNamePrefix - The prefix for the input type name.
559
- * @param {GraphQLInputObjectType} originalType - The original input type.
619
+ * @param {GraphQLObjectType} itemType - The collection's item type.
560
620
  * @param {string} fieldToExclude - The name of the field to exclude.
561
- * @returns {GraphQLInputObjectType} A new input type without the specified field.
621
+ * @param {string} [connectionFieldName] - The declared connectionField, which names the type.
622
+ * @param {string[]} [optionalFields] - Fields of the item's create input that become optional.
623
+ * @returns {GraphQLInputObjectType} An input type without the specified field.
562
624
  */
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,
625
+ const createTypeWithExcludedField = (inputNamePrefix, itemType, fieldToExclude,
626
+ connectionFieldName = fieldToExclude, optionalFields = []) => {
627
+ // The item's create input is `${itemType.name}Input`, so the name is the one it always had.
628
+ const name = `${inputNamePrefix}${itemType.name}InputFor${capitalize(connectionFieldName)}`;
629
+ const shared = excludedFieldInputTypes.get(name);
630
+ if (shared && shared.itemType === itemType && shared.fieldToExclude === fieldToExclude
631
+ && shared.optionalFields.join() === optionalFields.join()) {
632
+ return shared.type;
633
+ }
634
+ // Any other type with this name is a new one, so a real name clash still fails as before.
635
+ const type = new GraphQLInputObjectType({
636
+ name,
637
+ fields: () => Object.fromEntries(
638
+ Object.entries(getInputType(itemType).getFields())
639
+ .filter(([fieldName]) => fieldName !== fieldToExclude)
640
+ .map(([fieldName, field]) => [fieldName, {
641
+ type: optionalFields.includes(fieldName) ? unwrapNonNull(field.type) : field.type,
642
+ description: field.description,
643
+ defaultValue: field.defaultValue,
644
+ }]),
645
+ ),
579
646
  });
647
+ if (!shared) excludedFieldInputTypes.set(name, { itemType, fieldToExclude, optionalFields, type });
648
+ return type;
580
649
  };
581
650
 
582
- const createOneToManyInputType = (inputNamePrefix, fieldEntryName,
583
- inputType, updateInputType, connection, itemNonNull = false) => {
584
- let inputTypeForAdd = inputType;
585
-
586
- if (connection.declaredFieldName) {
587
- inputTypeForAdd = createTypeWithExcludedField(
651
+ // A referenced collection's operation input. The item inputs are read when the schema is built, so
652
+ // the item type's inputs need not exist yet.
653
+ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
654
+ itemNonNull = false, optionalFields = []) => {
655
+ const inputTypeForAdd = connection.declaredFieldName
656
+ ? createTypeWithExcludedField(
588
657
  inputNamePrefix,
589
- inputType,
658
+ itemType,
590
659
  connection.graphqlFieldName || connection.declaredFieldName,
591
660
  connection.declaredFieldName,
592
- );
593
- }
661
+ optionalFields,
662
+ )
663
+ : null;
664
+ const listOf = (type) => new GraphQLList(itemNonNull ? new GraphQLNonNull(type) : type);
594
665
 
595
666
  return new GraphQLInputObjectType({
596
- name: `OneToMany${inputNamePrefix}${fieldEntryName}`,
667
+ name,
597
668
  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
- },
669
+ added: { type: listOf(inputTypeForAdd || getInputType(itemType)) },
670
+ updated: { type: listOf(typesDictForUpdate.types[itemType.name].inputType) },
671
+ // Null IDs are skipped, so `deleted` accepts them whatever the collection's item nullability.
672
+ deleted: { type: new GraphQLList(GraphQLID) },
609
673
  }),
610
674
  });
611
675
  };
@@ -616,26 +680,19 @@ const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
616
680
  if (!listShape) return null;
617
681
  const { itemType } = listShape;
618
682
 
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),
683
+ if (itemType instanceof GraphQLObjectType) {
684
+ const registration = requireRegisteredType(dict, itemType, fieldEntryName, ownerName);
685
+ // A referenced collection reads its item inputs lazily, so it never waits for them.
686
+ if (!fieldEntry.extensions?.relation?.embedded) {
687
+ const oneToMany = createOneToManyInputType(`OneToMany${inputNamePrefix}${fieldEntryName}`,
688
+ inputNamePrefix, itemType, normalizeConnectionField(itemType, connectionField),
627
689
  listShape.itemNonNull);
628
690
  return preserveOuterNonNull && listShape.outerNonNull
629
691
  ? new GraphQLNonNull(oneToMany)
630
692
  : oneToMany;
631
693
  }
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
- );
694
+ if (registration.inputType) {
695
+ return wrapListInputType(registration.inputType, listShape, preserveOuterNonNull);
639
696
  }
640
697
  } else if (itemType instanceof GraphQLScalarType || itemType instanceof GraphQLEnumType) {
641
698
  return wrapListInputType(itemType, listShape, preserveOuterNonNull);
@@ -643,6 +700,38 @@ const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
643
700
  return null;
644
701
  };
645
702
 
703
+ // Writable list fields whose items are the type itself, as buildInputType selects them. Their
704
+ // collection inputs are named OneToManyA<field> and OneToManyU<field> (see claimSelfCollectionNames).
705
+ const selfCollectionFieldNames = (gqltype) => {
706
+ const hasStateMachine = !!typesDict.types[gqltype.name]?.stateMachine;
707
+ return Object.entries(gqltype.getFields())
708
+ .filter(([fieldName, field]) => !field.extensions?.readOnly
709
+ && !(fieldName === 'state' && hasStateMachine)
710
+ && getListShape(field.type)?.itemType === gqltype)
711
+ .map(([fieldName]) => fieldName);
712
+ };
713
+
714
+ const selfCollectionInputType = (gqltype, fieldEntryName, fieldEntry, forUpdate, selfFields) => {
715
+ const listShape = getListShape(fieldEntry.type);
716
+ const qualifier = qualifiedSelfCollections.get(gqltype)?.has(fieldEntryName) ? gqltype.name : '';
717
+ const kind = forUpdate ? 'U' : 'A';
718
+ const oneToMany = createOneToManyInputType(
719
+ `OneToMany${qualifier}${kind}${fieldEntryName}`,
720
+ kind,
721
+ gqltype,
722
+ normalizeConnectionField(gqltype, fieldEntry.extensions?.relation?.connectionField),
723
+ listShape.itemNonNull,
724
+ // Items an add mutation creates may carry their own self-referencing collections. These stay
725
+ // optional there, as such items had no such fields before; items added by an update keep them
726
+ // as the type declares them.
727
+ forUpdate ? [] : selfFields,
728
+ );
729
+ return !forUpdate && listShape.outerNonNull ? new GraphQLNonNull(oneToMany) : oneToMany;
730
+ };
731
+
732
+ // Builds a type's create and update inputs, or returns every field whose input cannot be built yet:
733
+ // `{ unresolvedFields: [{ field, waitsFor }] }`, where `waitsFor` names the embedded type whose
734
+ // inputs the field waits for, and is absent when the field can have no generated input.
646
735
  const buildInputType = (gqltype) => {
647
736
  const argTypes = gqltype.getFields();
648
737
 
@@ -650,6 +739,7 @@ const buildInputType = (gqltype) => {
650
739
  const fieldsArgForUpdate = {};
651
740
 
652
741
  const selfReferenceCollections = {};
742
+ const unresolvedFields = [];
653
743
 
654
744
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
655
745
  const fieldArg = {};
@@ -687,11 +777,19 @@ const buildInputType = (gqltype) => {
687
777
  fieldArg.type = typesDict.types[fieldEntryNameValue].inputType;
688
778
  fieldArgForUpdate.type = typesDictForUpdate.types[fieldEntryNameValue].inputType;
689
779
  } else {
690
- return null;
780
+ unresolvedFields.push({ field: fieldEntryName, waitsFor: fieldEntryNameValue });
691
781
  }
692
- } else {
782
+ } else if (unresolvedFields.length === 0) {
783
+ // Fields after one that cannot be built yet are only checked, so this logs as many times
784
+ // as when the build stopped at that field.
693
785
  console.warn(`Configuration issue: Field ${fieldEntryName} does not define extensions.relation`);
694
786
  }
787
+ } else if (isAbstractOutputType(unwrapListAndNonNull(fieldEntry.type))) {
788
+ // Generated inputs cannot write an interface or union value, single or in a list, so the
789
+ // field is output-only. readOnly fields never get here.
790
+ warnOnce(gqltype, `abstract:${fieldEntryName}`, `Configuration issue: ${gqltype.name}.${fieldEntryName} `
791
+ + 'has an interface or union type, so generated inputs leave it out and generated mutations cannot '
792
+ + 'set it; resolve it yourself, and mark it readOnly to state that it is output-only.');
695
793
  } else if (getListShape(fieldEntry.type)) {
696
794
  const listShape = getListShape(fieldEntry.type);
697
795
  if (listShape.itemType === gqltype) {
@@ -707,7 +805,11 @@ const buildInputType = (gqltype) => {
707
805
  fieldArg.type = listInputTypeForAdd;
708
806
  fieldArgForUpdate.type = listInputTypeForUpdate;
709
807
  } else {
710
- return null;
808
+ // An object item here is embedded and its inputs are not built yet; any other item,
809
+ // such as a list, has no generated input.
810
+ unresolvedFields.push(listShape.itemType instanceof GraphQLObjectType
811
+ ? { field: fieldEntryName, waitsFor: listShape.itemType.name }
812
+ : { field: fieldEntryName });
711
813
  }
712
814
  }
713
815
  }
@@ -729,94 +831,165 @@ const buildInputType = (gqltype) => {
729
831
  }
730
832
  }
731
833
 
732
- const inputTypeBody = {
834
+ if (unresolvedFields.length > 0) return { unresolvedFields };
835
+
836
+ // Self-referencing collections follow the other fields, as they always have. Their item inputs
837
+ // are read when the schema is built, after these input types exist.
838
+ const selfFields = Object.keys(selfReferenceCollections);
839
+ for (const [fieldEntryName, fieldEntry] of Object.entries(selfReferenceCollections)) {
840
+ fieldsArgs[fieldEntryName] = {
841
+ type: selfCollectionInputType(gqltype, fieldEntryName, fieldEntry, false, selfFields),
842
+ description: fieldEntry.description,
843
+ };
844
+ fieldsArgForUpdate[fieldEntryName] = {
845
+ type: selfCollectionInputType(gqltype, fieldEntryName, fieldEntry, true, selfFields),
846
+ description: fieldEntry.description,
847
+ };
848
+ }
849
+
850
+ const inputTypeForAdd = new GraphQLInputObjectType({
733
851
  name: `${gqltype.name}Input`,
734
852
  description: gqltype.description,
735
853
  fields: fieldsArgs,
736
- };
737
-
738
- const inputTypeBodyForUpdate = {
854
+ });
855
+ const inputTypeForUpdate = new GraphQLInputObjectType({
739
856
  name: `${gqltype.name}InputForUpdate`,
740
857
  description: gqltype.description,
741
858
  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
859
  });
785
860
 
786
- inputTypeForUpdate._fields = () => inputTypeForUpdateFields;
787
-
788
861
  return { inputTypeBody: inputTypeForAdd, inputTypeBodyForUpdate: inputTypeForUpdate };
789
862
  };
790
863
 
791
864
  const getInputType = (type) => typesDict.types[type.name].inputType;
792
865
 
793
- const buildPendingInputTypes = (waitingForInputType) => {
866
+ // Registered types whose generated inputs the generated mutations of this schema can reach: the
867
+ // included endpoint types and, through their writable fields, embedded types and the items of
868
+ // collections.
869
+ const typesWithReachableInputs = (includedMutationTypes) => {
870
+ const reached = new Set();
871
+ const visit = (gqltype) => {
872
+ if (reached.has(gqltype)) return;
873
+ reached.add(gqltype);
874
+ for (const field of Object.values(gqltype.getFields())) {
875
+ if (field.extensions?.readOnly) continue;
876
+ const listShape = getListShape(field.type);
877
+ const target = listShape ? listShape.itemType : unwrapNonNull(field.type);
878
+ // A single reference is an IdInputType, so it reaches no input of its target.
879
+ if (target instanceof GraphQLObjectType && typesDict.types[target.name]
880
+ && (listShape || field.extensions?.relation?.embedded)) {
881
+ visit(typesDict.types[target.name].gqltype);
882
+ }
883
+ }
884
+ };
885
+ for (const type of Object.values(typesDict.types)) {
886
+ if (type.endpoint && !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)) {
887
+ visit(type.gqltype);
888
+ }
889
+ }
890
+ return reached;
891
+ };
892
+
893
+ // A self-referencing collection's inputs are OneToManyA<field> and OneToManyU<field>. When several
894
+ // types that the generated mutations reach have one with the same field name, the first registered
895
+ // keeps those names and the others get OneToMany<Type>A<field> and OneToMany<Type>U<field>, as other
896
+ // collections do. Types the mutations cannot reach keep the unqualified names, as such inputs never
897
+ // collided. Names are fixed when a type's inputs are built, at its first schema.
898
+ // A type is qualified only when this schema also reaches an earlier type that holds the unqualified
899
+ // names and is still the type registered under its name, since both inputs then meet in one schema,
900
+ // which failed with a duplicate type name before such names were qualified. Otherwise, as with a
901
+ // type registered again under the same name or after an earlier createSchema(), it keeps the
902
+ // unqualified names.
903
+ const claimSelfCollectionNames = (pending, includedMutationTypes) => {
904
+ const reachable = typesWithReachableInputs(includedMutationTypes);
905
+ const meets = (type) => typesDict.types[type.name]?.gqltype === type && reachable.has(type);
906
+ for (const { gqltype } of Object.values(pending)) {
907
+ if (typesDict.types[gqltype.name].inputType) continue;
908
+ for (const fieldName of selfCollectionFieldNames(gqltype)) {
909
+ if (!selfCollectionNameHolders.has(fieldName)) selfCollectionNameHolders.set(fieldName, []);
910
+ const holders = selfCollectionNameHolders.get(fieldName);
911
+ const owner = reachable.has(gqltype) && holders.find((type) => type !== gqltype && meets(type));
912
+ if (!owner) {
913
+ if (!holders.includes(gqltype)) holders.push(gqltype);
914
+ } else {
915
+ if (!qualifiedSelfCollections.has(gqltype)) qualifiedSelfCollections.set(gqltype, new Set());
916
+ qualifiedSelfCollections.get(gqltype).add(fieldName);
917
+ warnOnce(gqltype, `selfCollectionName:${fieldName}`, `Configuration issue: ${gqltype.name}.${fieldName} `
918
+ + `and ${owner.name}.${fieldName} are self-referencing collections with the same name, so the inputs `
919
+ + `of ${gqltype.name}.${fieldName} are named OneToMany${gqltype.name}A${fieldName} and `
920
+ + `OneToMany${gqltype.name}U${fieldName}, while ${owner.name}.${fieldName} keeps OneToManyA${fieldName} `
921
+ + `and OneToManyU${fieldName}. To choose which type keeps those names, register it first, or make `
922
+ + 'the other type unreachable from the generated mutations.');
923
+ }
924
+ }
925
+ }
926
+ };
927
+
928
+ // The INPUT_TYPE_UNRESOLVED message. It names every field whose input could not be built, by type:
929
+ // the causes (lists whose items have no generated input, and embedded fields whose types contain
930
+ // each other) and, apart from them, fields that only wait for another listed type.
931
+ const unresolvedInputTypesMessage = (pendingTypeNames, unresolvedByType) => {
932
+ // Whether the inputs of type `from` wait, through embedded fields, for those of type `to`.
933
+ const waitsOn = (from, to, seen = new Set()) => {
934
+ if (from === to) return true;
935
+ if (seen.has(from)) return false;
936
+ seen.add(from);
937
+ return (unresolvedByType[from] || [])
938
+ .some(({ waitsFor }) => waitsFor !== undefined && waitsOn(waitsFor, to, seen));
939
+ };
940
+ const lists = [];
941
+ const cycles = [];
942
+ const waiting = [];
943
+ for (const [typeName, fields] of Object.entries(unresolvedByType)) {
944
+ for (const { field, waitsFor } of fields) {
945
+ const name = `${typeName}.${field}`;
946
+ if (waitsFor === undefined) lists.push(name);
947
+ else if (waitsOn(waitsFor, typeName)) cycles.push(name);
948
+ else waiting.push(`${name} waits for ${waitsFor}`);
949
+ }
950
+ }
951
+ const causes = [
952
+ lists.length > 0 && `${lists.join(', ')} (writable lists whose items have no generated input, such as lists of lists)`,
953
+ cycles.length > 0 && `${cycles.join(', ')} (embedded types that contain each other)`,
954
+ ].filter(Boolean);
955
+ return `Could not build input types for: ${pendingTypeNames.join(', ')}. `
956
+ + 'Check for circular or misconfigured relations'
957
+ + (causes.length > 0
958
+ ? `: no input can be generated for ${causes.join(' or for ')}; mark such a field readOnly.`
959
+ : '.')
960
+ + (waiting.length > 0
961
+ ? ` These fields only wait for another listed type and build once it does: ${waiting.join(', ')}.`
962
+ : '');
963
+ };
964
+
965
+ const buildPendingInputTypes = (waitingForInputType, includedMutationTypes) => {
966
+ claimSelfCollectionNames(waitingForInputType, includedMutationTypes);
794
967
  let pending = waitingForInputType;
795
968
  let previousPendingCount = Object.keys(pending).length + 1;
969
+ // Type name -> the fields whose input could not be built in the last pass.
970
+ let unresolvedFields = {};
796
971
 
797
972
  while (Object.keys(pending).length > 0) {
798
973
  const currentCount = Object.keys(pending).length;
799
974
  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
- );
975
+ throw new SimfinityError(unresolvedInputTypesMessage(Object.keys(pending), unresolvedFields),
976
+ 'INPUT_TYPE_UNRESOLVED', 500);
806
977
  }
807
978
  previousPendingCount = currentCount;
808
979
 
809
980
  const stillWaiting = {};
981
+ unresolvedFields = {};
810
982
  for (const [key, value] of Object.entries(pending)) {
811
983
  const { gqltype } = value;
812
984
  if (typesDict.types[gqltype.name].inputType) continue;
813
985
 
814
986
  const result = buildInputType(gqltype);
815
- if (result && result.inputTypeBody && result.inputTypeBodyForUpdate) {
987
+ if (result.inputTypeBody && result.inputTypeBodyForUpdate) {
816
988
  typesDict.types[gqltype.name].inputType = result.inputTypeBody;
817
989
  typesDictForUpdate.types[gqltype.name].inputType = result.inputTypeBodyForUpdate;
818
990
  } else {
819
991
  stillWaiting[key] = value;
992
+ unresolvedFields[gqltype.name] = result.unresolvedFields;
820
993
  }
821
994
  }
822
995
  pending = stillWaiting;
@@ -1070,7 +1243,11 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1070
1243
  if (!result) throw new SimfinityError(`${gqltype.name} ${objectId} is not valid`, 'NOT_VALID_ID', 404);
1071
1244
 
1072
1245
  if (materializedModel.collectionFields) {
1073
- await iterateOnCollectionFields(materializedModel, gqltype, objectId, session, context);
1246
+ // Children link to, and are owned through, the parent's stored key, as when a new parent is
1247
+ // saved: the client may send a spelling of the ID that the adapter accepts but does not store,
1248
+ // such as an uppercase ObjectId or UUID. It is the key the generated collection field reads
1249
+ // children by (`_id`, else `id`); an adapter whose update result has neither keeps the requested ID.
1250
+ await iterateOnCollectionFields(materializedModel, gqltype, result._id ?? result.id ?? objectId, session, context);
1074
1251
  }
1075
1252
 
1076
1253
  if (controller && controller.onUpdated) {
@@ -1232,7 +1409,9 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
1232
1409
  );
1233
1410
  };
1234
1411
 
1235
- const executeItemFunction = async (gqltype, collectionField, objectId, session,
1412
+ // parentKey is the parent's stored key: children are linked to it, and an updated or deleted child
1413
+ // must already store it in its connectionField.
1414
+ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1236
1415
  collectionFieldsList, operationType, context) => {
1237
1416
  const argTypes = gqltype.getFields();
1238
1417
  const collectionGQLType = unwrapListAndNonNull(argTypes[collectionField].type);
@@ -1246,7 +1425,7 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
1246
1425
  const type = operationType === operations.UPDATE
1247
1426
  ? typesDictForUpdate.types[collectionGQLType.name] : typesDict.types[collectionGQLType.name];
1248
1427
  const linkToParent = (item) => {
1249
- item[connection.storageFieldName] = objectId;
1428
+ item[connection.storageFieldName] = parentKey;
1250
1429
  if (item.$unset) delete item.$unset[connection.storageFieldName];
1251
1430
  if (item.$set) delete item.$set[connection.storageFieldName];
1252
1431
  };
@@ -1262,8 +1441,8 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
1262
1441
  if (!child) {
1263
1442
  throw new SimfinityError(`${collectionGQLType.name} ${id} is not valid`, 'NOT_VALID_ID', 404);
1264
1443
  }
1265
- if (String(child[connection.storageFieldName]) !== String(objectId)) {
1266
- throw new SimfinityError('Child does not belong to this parent', 'FORBIDDEN', 403);
1444
+ if (String(child[connection.storageFieldName]) !== String(parentKey)) {
1445
+ throw new ForbiddenError('Child does not belong to this parent');
1267
1446
  }
1268
1447
  }
1269
1448
 
@@ -1536,7 +1715,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1536
1715
  rootQueryArgs.name = name;
1537
1716
  rootQueryArgs.fields = {};
1538
1717
 
1539
- buildPendingInputTypes(waitingInputType);
1718
+ buildPendingInputTypes(waitingInputType, includedMutationTypes);
1540
1719
 
1541
1720
  for (const type of Object.values(typesDict.types)) {
1542
1721
  if (!shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)) {
@@ -1671,7 +1850,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1671
1850
  return new GraphQLObjectType(rootQueryArgs);
1672
1851
  };
1673
1852
 
1674
- const buildRootQuery = (name, includedTypes) => {
1853
+ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult) => {
1675
1854
  const rootQueryArgs = {};
1676
1855
  rootQueryArgs.name = name;
1677
1856
  rootQueryArgs.fields = {};
@@ -1708,7 +1887,8 @@ const buildRootQuery = (name, includedTypes) => {
1708
1887
  await executeScope(params);
1709
1888
  await applyJoinedScopes(joinedScopes, params);
1710
1889
  const queryArgs = params.args;
1711
- const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count);
1890
+ const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
1891
+ && canReportCount(parent, info, context);
1712
1892
 
1713
1893
  const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
1714
1894
  const countPromise = wantsCount
@@ -1716,8 +1896,10 @@ const buildRootQuery = (name, includedTypes) => {
1716
1896
  : null;
1717
1897
 
1718
1898
  const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
1719
- if (wantsCount) {
1720
- reportCount(parent, info, context, resultCount);
1899
+ if (wantsCount && !reportCount(parent, info, context, resultCount)) {
1900
+ warnOnce(runtimeIdentity, 'count-context', 'Configuration issue: the GraphQL context does not accept '
1901
+ + 'a count property (it is frozen, sealed or has a read-only count), so pagination counts are not '
1902
+ + 'reported; pass a fresh mutable context object for each request.');
1721
1903
  }
1722
1904
  return result;
1723
1905
  },
@@ -1734,7 +1916,7 @@ const buildRootQuery = (name, includedTypes) => {
1734
1916
  };
1735
1917
 
1736
1918
  rootQueryArgs.fields[`${type.listEntitiesEndpointName}_aggregate`] = {
1737
- type: new GraphQLList(QLTypeAggregationResult),
1919
+ type: new GraphQLList(aggregationResultType),
1738
1920
  args: aggregateArgsObject,
1739
1921
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
1740
1922
  async resolve(parent, args, context) {
@@ -1914,17 +2096,21 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1914
2096
  const objectMemberTypes = new Set();
1915
2097
  Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
1916
2098
 
1917
- const query = buildRootQuery('RootQueryType', includedQueryTypes);
2099
+ let query = buildRootQuery('RootQueryType', includedQueryTypes);
1918
2100
  Object.values(typesDict.types).forEach(({ gqltype }) => {
1919
2101
  if (gqltype && !applicationResolvedFields.has(gqltype)) {
1920
2102
  applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
1921
2103
  }
1922
2104
  });
2105
+ const mutation = buildMutation('Mutation', includedMutationTypes, includedCustomMutations);
2106
+ // An application scalar named JSON that this schema contains also types aggregation results. Any
2107
+ // other type named JSON still collides, and GraphQLSchema reports the duplicate name.
2108
+ const applicationJSON = findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult);
2109
+ if (applicationJSON instanceof GraphQLScalarType) {
2110
+ query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON));
2111
+ }
1923
2112
 
1924
- const schema = new GraphQLSchema({
1925
- query,
1926
- mutation: buildMutation('Mutation', includedMutationTypes, includedCustomMutations),
1927
- });
2113
+ const schema = new GraphQLSchema({ query, mutation });
1928
2114
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
1929
2115
  // custom mutation result, so another runtime cannot later generate resolvers this schema executes.
1930
2116
  schemaTypes.forEach((type) => {
@@ -2047,6 +2233,14 @@ const autoGenerateResolvers = (gqltype) => {
2047
2233
  }
2048
2234
  };
2049
2235
 
2236
+ // A replaced registration builds fresh inputs. Its cached collection projections may already
2237
+ // contain fields from the previous input graph; keep those alive only in earlier schemas.
2238
+ const invalidateCollectionInputs = (gqltype) => {
2239
+ for (const [name, cached] of excludedFieldInputTypes) {
2240
+ if (cached.itemType.name === gqltype.name) excludedFieldInputTypes.delete(name);
2241
+ }
2242
+ };
2243
+
2050
2244
  const connect = (model, gqltype, simpleEntityEndpointName,
2051
2245
  listEntitiesEndpointName, controller, onModelCreated, stateMachine) => {
2052
2246
  const registration = {
@@ -2063,6 +2257,7 @@ const connect = (model, gqltype, simpleEntityEndpointName,
2063
2257
  validateScope(gqltype);
2064
2258
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
2065
2259
 
2260
+ invalidateCollectionInputs(gqltype);
2066
2261
  waitingInputType[gqltype.name] = {
2067
2262
  model,
2068
2263
  gqltype,
@@ -2092,6 +2287,7 @@ const addNoEndpointType = (gqltype) => {
2092
2287
  validateScope(gqltype);
2093
2288
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
2094
2289
 
2290
+ invalidateCollectionInputs(gqltype);
2095
2291
  waitingInputType[gqltype.name] = { gqltype };
2096
2292
  typesDict.types[gqltype.name] = registration;
2097
2293
  typesDictForUpdate.types[gqltype.name] = { ...typesDict.types[gqltype.name] };
@@ -2115,27 +2311,31 @@ const createArgsForQuery = (gqltype, omit = null) => {
2115
2311
 
2116
2312
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
2117
2313
  if (fieldEntryName === omit) continue;
2118
- argsObject[fieldEntryName] = {};
2314
+ let filterType = null;
2119
2315
 
2120
2316
  if (fieldEntry.type instanceof GraphQLScalarType
2121
2317
  || isNonNullOfType(fieldEntry.type, GraphQLScalarType)
2122
2318
  || fieldEntry.type instanceof GraphQLEnumType
2123
2319
  || isNonNullOfType(fieldEntry.type, GraphQLEnumType)) {
2124
- argsObject[fieldEntryName].type = QLFilter;
2320
+ filterType = QLFilter;
2125
2321
  } else if (fieldEntry.type instanceof GraphQLObjectType
2126
2322
  || isNonNullOfType(fieldEntry.type, GraphQLObjectType)) {
2127
- argsObject[fieldEntryName].type = QLTypeFilterExpression;
2323
+ filterType = QLTypeFilterExpression;
2128
2324
  } else if (getListShape(fieldEntry.type)) {
2129
2325
  const listOfType = getListShape(fieldEntry.type).itemType;
2130
2326
  if (listOfType instanceof GraphQLScalarType
2131
2327
  || isNonNullOfType(listOfType, GraphQLScalarType)
2132
2328
  || listOfType instanceof GraphQLEnumType
2133
2329
  || isNonNullOfType(listOfType, GraphQLEnumType)) {
2134
- argsObject[fieldEntryName].type = QLFilter;
2330
+ filterType = QLFilter;
2135
2331
  } else {
2136
- argsObject[fieldEntryName].type = QLTypeFilterExpression;
2332
+ // Includes lists of interfaces and unions, which have no generated filter either. Their
2333
+ // argument has always been generated and is kept, so requests that send it as null stay valid.
2334
+ filterType = QLTypeFilterExpression;
2137
2335
  }
2138
2336
  }
2337
+ // A single interface or union field has no generated filter, so it gets no argument.
2338
+ if (filterType) argsObject[fieldEntryName] = { type: filterType };
2139
2339
  }
2140
2340
 
2141
2341
  argsObject.pagination = {};