@simtlix/simfinity-core 3.5.6 → 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';
@@ -24,6 +25,34 @@ import './introspection.js';
24
25
  // created is application code, such as a masking resolver, recorded once per type so later
25
26
  // in-place wrapping (for example by the auth plugin) does not change the snapshot.
26
27
  const generatedResolvers = new WeakSet();
28
+ // A caller that runs one operation per call, such as simfinity-mcp, can collect the pagination
29
+ // count through the root value instead of the shared context: a function stored under this key
30
+ // receives the count. The key is not exported; simfinity-mcp defines the same Symbol.for key.
31
+ const COUNT_SINK = Symbol.for('simfinity.countSink');
32
+ const countSinkOf = (value) => {
33
+ if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return undefined;
34
+ const sink = value[COUNT_SINK];
35
+ return typeof sink === 'function' ? sink : undefined;
36
+ };
37
+ // The root value arrives as parent, which resolver wrappers that forward only (parent, args,
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.
46
+ const reportCount = (parent, info, context, count) => {
47
+ const sink = countSinkFor(parent, info);
48
+ if (sink) {
49
+ sink(count);
50
+ return true;
51
+ }
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);
55
+ };
27
56
  const applicationResolvedFields = new WeakMap();
28
57
  const markGenerated = (resolve) => {
29
58
  generatedResolvers.add(resolve);
@@ -181,6 +210,31 @@ const GraphQLJSON = new GraphQLScalarType({
181
210
  },
182
211
  });
183
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
+
184
238
  // graphql-js wraps a value thrown while resolving a field (locatedError): the wrapper has the field
185
239
  // path and repeats the thrown error's message. A thrown non-Error becomes a NonErrorThrown whose
186
240
  // message prints the value.
@@ -318,6 +372,14 @@ export const createRuntime = (adapter) => {
318
372
  const typesDict = { types: {} };
319
373
  const waitingInputType = {};
320
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();
321
383
  const registeredMutations = {};
322
384
  const middlewares = [];
323
385
 
@@ -499,13 +561,24 @@ const QLTypeAggregationExpression = new GraphQLInputObjectType({
499
561
  }),
500
562
  });
501
563
 
502
- 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({
503
567
  name: 'QLTypeAggregationResult',
504
568
  fields: () => ({
505
- groupId: { type: GraphQLJSON },
506
- facts: { type: GraphQLJSON },
569
+ groupId: { type: valueType },
570
+ facts: { type: valueType },
507
571
  }),
508
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
+ };
509
582
 
510
583
  const isNonNullOfType = (fieldEntryType, graphQLType) => {
511
584
  let isOfType = false;
@@ -516,6 +589,7 @@ const isNonNullOfType = (fieldEntryType, graphQLType) => {
516
589
  };
517
590
 
518
591
  const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
592
+ const isAbstractOutputType = (type) => type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType;
519
593
 
520
594
  const unwrapListAndNonNull = (type) => {
521
595
  let unwrapped = type;
@@ -533,59 +607,69 @@ const wrapListInputType = (itemType, listShape, preserveOuterNonNull) => {
533
607
  : listType;
534
608
  };
535
609
 
610
+ const capitalize = (name) => name.charAt(0).toUpperCase() + name.slice(1);
611
+
536
612
  /**
537
- * 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.
538
618
  * @param {string} inputNamePrefix - The prefix for the input type name.
539
- * @param {GraphQLInputObjectType} originalType - The original input type.
619
+ * @param {GraphQLObjectType} itemType - The collection's item type.
540
620
  * @param {string} fieldToExclude - The name of the field to exclude.
541
- * @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.
542
624
  */
543
- const createTypeWithExcludedField = (inputNamePrefix, originalType, fieldToExclude,
544
- connectionFieldName = fieldToExclude) => {
545
- const originalFields = originalType.getFields();
546
- const newFields = Object.fromEntries(
547
- Object.entries(originalFields)
548
- .filter(([fieldName]) => fieldName !== fieldToExclude)
549
- .map(([fieldName, field]) => [fieldName, {
550
- type: field.type,
551
- description: field.description,
552
- defaultValue: field.defaultValue,
553
- }]),
554
- );
555
-
556
- return new GraphQLInputObjectType({
557
- name: `${inputNamePrefix}${originalType.name}For${connectionFieldName.charAt(0).toUpperCase() + connectionFieldName.slice(1)}`,
558
- 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
+ ),
559
646
  });
647
+ if (!shared) excludedFieldInputTypes.set(name, { itemType, fieldToExclude, optionalFields, type });
648
+ return type;
560
649
  };
561
650
 
562
- const createOneToManyInputType = (inputNamePrefix, fieldEntryName,
563
- inputType, updateInputType, connection, itemNonNull = false) => {
564
- let inputTypeForAdd = inputType;
565
-
566
- if (connection.declaredFieldName) {
567
- 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(
568
657
  inputNamePrefix,
569
- inputType,
658
+ itemType,
570
659
  connection.graphqlFieldName || connection.declaredFieldName,
571
660
  connection.declaredFieldName,
572
- );
573
- }
661
+ optionalFields,
662
+ )
663
+ : null;
664
+ const listOf = (type) => new GraphQLList(itemNonNull ? new GraphQLNonNull(type) : type);
574
665
 
575
666
  return new GraphQLInputObjectType({
576
- name: `OneToMany${inputNamePrefix}${fieldEntryName}`,
667
+ name,
577
668
  fields: () => ({
578
- added: {
579
- type: new GraphQLList(itemNonNull
580
- ? new GraphQLNonNull(inputTypeForAdd) : inputTypeForAdd),
581
- },
582
- updated: {
583
- type: new GraphQLList(itemNonNull
584
- ? new GraphQLNonNull(updateInputType) : updateInputType),
585
- },
586
- deleted: {
587
- type: new GraphQLList(itemNonNull ? new GraphQLNonNull(GraphQLID) : GraphQLID),
588
- },
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) },
589
673
  }),
590
674
  });
591
675
  };
@@ -596,26 +680,19 @@ const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
596
680
  if (!listShape) return null;
597
681
  const { itemType } = listShape;
598
682
 
599
- if (itemType instanceof GraphQLObjectType
600
- && requireRegisteredType(dict, itemType, fieldEntryName, ownerName).inputType) {
601
- if (!fieldEntry.extensions || !fieldEntry.extensions.relation
602
- || !fieldEntry.extensions.relation.embedded) {
603
- const oneToMany = createOneToManyInputType(inputNamePrefix, fieldEntryName,
604
- typesDict.types[itemType.name].inputType,
605
- typesDictForUpdate.types[itemType.name].inputType,
606
- 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),
607
689
  listShape.itemNonNull);
608
690
  return preserveOuterNonNull && listShape.outerNonNull
609
691
  ? new GraphQLNonNull(oneToMany)
610
692
  : oneToMany;
611
693
  }
612
- if (fieldEntry.extensions && fieldEntry.extensions.relation
613
- && fieldEntry.extensions.relation.embedded) {
614
- return wrapListInputType(
615
- dict.types[itemType.name].inputType,
616
- listShape,
617
- preserveOuterNonNull,
618
- );
694
+ if (registration.inputType) {
695
+ return wrapListInputType(registration.inputType, listShape, preserveOuterNonNull);
619
696
  }
620
697
  } else if (itemType instanceof GraphQLScalarType || itemType instanceof GraphQLEnumType) {
621
698
  return wrapListInputType(itemType, listShape, preserveOuterNonNull);
@@ -623,6 +700,38 @@ const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
623
700
  return null;
624
701
  };
625
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.
626
735
  const buildInputType = (gqltype) => {
627
736
  const argTypes = gqltype.getFields();
628
737
 
@@ -630,6 +739,7 @@ const buildInputType = (gqltype) => {
630
739
  const fieldsArgForUpdate = {};
631
740
 
632
741
  const selfReferenceCollections = {};
742
+ const unresolvedFields = [];
633
743
 
634
744
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
635
745
  const fieldArg = {};
@@ -667,11 +777,19 @@ const buildInputType = (gqltype) => {
667
777
  fieldArg.type = typesDict.types[fieldEntryNameValue].inputType;
668
778
  fieldArgForUpdate.type = typesDictForUpdate.types[fieldEntryNameValue].inputType;
669
779
  } else {
670
- return null;
780
+ unresolvedFields.push({ field: fieldEntryName, waitsFor: fieldEntryNameValue });
671
781
  }
672
- } 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.
673
785
  console.warn(`Configuration issue: Field ${fieldEntryName} does not define extensions.relation`);
674
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.');
675
793
  } else if (getListShape(fieldEntry.type)) {
676
794
  const listShape = getListShape(fieldEntry.type);
677
795
  if (listShape.itemType === gqltype) {
@@ -687,7 +805,11 @@ const buildInputType = (gqltype) => {
687
805
  fieldArg.type = listInputTypeForAdd;
688
806
  fieldArgForUpdate.type = listInputTypeForUpdate;
689
807
  } else {
690
- 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 });
691
813
  }
692
814
  }
693
815
  }
@@ -709,94 +831,165 @@ const buildInputType = (gqltype) => {
709
831
  }
710
832
  }
711
833
 
712
- 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({
713
851
  name: `${gqltype.name}Input`,
714
852
  description: gqltype.description,
715
853
  fields: fieldsArgs,
716
- };
717
-
718
- const inputTypeBodyForUpdate = {
854
+ });
855
+ const inputTypeForUpdate = new GraphQLInputObjectType({
719
856
  name: `${gqltype.name}InputForUpdate`,
720
857
  description: gqltype.description,
721
858
  fields: fieldsArgForUpdate,
722
- };
723
-
724
- const inputTypeForAdd = new GraphQLInputObjectType(inputTypeBody);
725
- const inputTypeForUpdate = new GraphQLInputObjectType(inputTypeBodyForUpdate);
726
-
727
- const inputTypeForAddFields = inputTypeForAdd._fields();
728
-
729
- Object.keys(selfReferenceCollections).forEach((fieldEntryName) => {
730
- if (Object.prototype.hasOwnProperty.call(selfReferenceCollections, fieldEntryName)) {
731
- const fieldEntry = selfReferenceCollections[fieldEntryName];
732
- const listShape = getListShape(fieldEntry.type);
733
- const oneToMany = createOneToManyInputType('A', fieldEntryName,
734
- inputTypeForAdd, inputTypeForUpdate,
735
- normalizeConnectionField(
736
- gqltype,
737
- fieldEntry.extensions?.relation?.connectionField,
738
- ), listShape.itemNonNull);
739
- inputTypeForAddFields[fieldEntryName] = {
740
- type: listShape.outerNonNull ? new GraphQLNonNull(oneToMany) : oneToMany,
741
- name: fieldEntryName,
742
- };
743
- }
744
859
  });
745
860
 
746
- inputTypeForAdd._fields = () => inputTypeForAddFields;
747
-
748
- const inputTypeForUpdateFields = inputTypeForUpdate._fields();
749
-
750
- Object.keys(selfReferenceCollections).forEach((fieldEntryName) => {
751
- if (Object.prototype.hasOwnProperty.call(selfReferenceCollections, fieldEntryName)) {
752
- const fieldEntry = selfReferenceCollections[fieldEntryName];
753
- const listShape = getListShape(fieldEntry.type);
754
- inputTypeForUpdateFields[fieldEntryName] = {
755
- type: createOneToManyInputType('U', fieldEntryName,
756
- inputTypeForAdd, inputTypeForUpdate,
757
- normalizeConnectionField(
758
- gqltype,
759
- fieldEntry.extensions?.relation?.connectionField,
760
- ), listShape.itemNonNull),
761
- name: fieldEntryName,
762
- };
763
- }
764
- });
765
-
766
- inputTypeForUpdate._fields = () => inputTypeForUpdateFields;
767
-
768
861
  return { inputTypeBody: inputTypeForAdd, inputTypeBodyForUpdate: inputTypeForUpdate };
769
862
  };
770
863
 
771
864
  const getInputType = (type) => typesDict.types[type.name].inputType;
772
865
 
773
- 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);
774
967
  let pending = waitingForInputType;
775
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 = {};
776
971
 
777
972
  while (Object.keys(pending).length > 0) {
778
973
  const currentCount = Object.keys(pending).length;
779
974
  if (currentCount >= previousPendingCount) {
780
- const unresolved = Object.keys(pending).join(', ');
781
- throw new SimfinityError(
782
- `Could not build input types for: ${unresolved}. Check for circular or misconfigured relations.`,
783
- 'INPUT_TYPE_UNRESOLVED',
784
- 500,
785
- );
975
+ throw new SimfinityError(unresolvedInputTypesMessage(Object.keys(pending), unresolvedFields),
976
+ 'INPUT_TYPE_UNRESOLVED', 500);
786
977
  }
787
978
  previousPendingCount = currentCount;
788
979
 
789
980
  const stillWaiting = {};
981
+ unresolvedFields = {};
790
982
  for (const [key, value] of Object.entries(pending)) {
791
983
  const { gqltype } = value;
792
984
  if (typesDict.types[gqltype.name].inputType) continue;
793
985
 
794
986
  const result = buildInputType(gqltype);
795
- if (result && result.inputTypeBody && result.inputTypeBodyForUpdate) {
987
+ if (result.inputTypeBody && result.inputTypeBodyForUpdate) {
796
988
  typesDict.types[gqltype.name].inputType = result.inputTypeBody;
797
989
  typesDictForUpdate.types[gqltype.name].inputType = result.inputTypeBodyForUpdate;
798
990
  } else {
799
991
  stillWaiting[key] = value;
992
+ unresolvedFields[gqltype.name] = result.unresolvedFields;
800
993
  }
801
994
  }
802
995
  pending = stillWaiting;
@@ -1050,7 +1243,11 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1050
1243
  if (!result) throw new SimfinityError(`${gqltype.name} ${objectId} is not valid`, 'NOT_VALID_ID', 404);
1051
1244
 
1052
1245
  if (materializedModel.collectionFields) {
1053
- 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);
1054
1251
  }
1055
1252
 
1056
1253
  if (controller && controller.onUpdated) {
@@ -1212,7 +1409,9 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
1212
1409
  );
1213
1410
  };
1214
1411
 
1215
- 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,
1216
1415
  collectionFieldsList, operationType, context) => {
1217
1416
  const argTypes = gqltype.getFields();
1218
1417
  const collectionGQLType = unwrapListAndNonNull(argTypes[collectionField].type);
@@ -1226,7 +1425,7 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
1226
1425
  const type = operationType === operations.UPDATE
1227
1426
  ? typesDictForUpdate.types[collectionGQLType.name] : typesDict.types[collectionGQLType.name];
1228
1427
  const linkToParent = (item) => {
1229
- item[connection.storageFieldName] = objectId;
1428
+ item[connection.storageFieldName] = parentKey;
1230
1429
  if (item.$unset) delete item.$unset[connection.storageFieldName];
1231
1430
  if (item.$set) delete item.$set[connection.storageFieldName];
1232
1431
  };
@@ -1242,8 +1441,8 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
1242
1441
  if (!child) {
1243
1442
  throw new SimfinityError(`${collectionGQLType.name} ${id} is not valid`, 'NOT_VALID_ID', 404);
1244
1443
  }
1245
- if (String(child[connection.storageFieldName]) !== String(objectId)) {
1246
- 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');
1247
1446
  }
1248
1447
  }
1249
1448
 
@@ -1508,12 +1707,15 @@ const resolveById = async (type, args, context, requiredId) => {
1508
1707
  return results[0] || null;
1509
1708
  };
1510
1709
 
1710
+ // Every generated mutation field carries extensions.simfinityMutation with its operation (and, for
1711
+ // a state-machine action, the action and its from/to states), so tools such as the MCP package can
1712
+ // classify it without guessing from its name. Extensions do not appear in SDL or introspection.
1511
1713
  const buildMutation = (name, includedMutationTypes, includedCustomMutations) => {
1512
1714
  const rootQueryArgs = {};
1513
1715
  rootQueryArgs.name = name;
1514
1716
  rootQueryArgs.fields = {};
1515
1717
 
1516
- buildPendingInputTypes(waitingInputType);
1718
+ buildPendingInputTypes(waitingInputType, includedMutationTypes);
1517
1719
 
1518
1720
  for (const type of Object.values(typesDict.types)) {
1519
1721
  if (!shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)) {
@@ -1524,6 +1726,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1524
1726
  type: type.gqltype,
1525
1727
  description: 'add',
1526
1728
  args: argsObject,
1729
+ extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.SAVE } },
1527
1730
  async resolve(parent, args, context) {
1528
1731
  const params = {
1529
1732
  type,
@@ -1541,6 +1744,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1541
1744
  type: type.gqltype,
1542
1745
  description: 'delete',
1543
1746
  args: { id: { type: new GraphQLNonNull(GraphQLID) } },
1747
+ extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.DELETE } },
1544
1748
  async resolve(parent, args, context) {
1545
1749
  const params = {
1546
1750
  type,
@@ -1566,6 +1770,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1566
1770
  type: type.gqltype,
1567
1771
  description: 'update',
1568
1772
  args: argsObject,
1773
+ extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.UPDATE } },
1569
1774
  async resolve(parent, args, context) {
1570
1775
  const params = {
1571
1776
  type,
@@ -1586,6 +1791,15 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1586
1791
  type: type.gqltype,
1587
1792
  description: actionField.description,
1588
1793
  args: argsObject,
1794
+ extensions: {
1795
+ simfinityMutation: {
1796
+ typeName: type.gqltype.name,
1797
+ operation: operations.STATE_CHANGED,
1798
+ action: actionName,
1799
+ from: actionField.from && actionField.from.name,
1800
+ to: actionField.to && actionField.to.name,
1801
+ },
1802
+ },
1589
1803
  async resolve(parent, args, context) {
1590
1804
  const params = {
1591
1805
  type,
@@ -1617,6 +1831,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1617
1831
  type: registeredMutation.outputModel,
1618
1832
  description: registeredMutation.description,
1619
1833
  args: argsObject,
1834
+ extensions: { simfinityMutation: { operation: operations.CUSTOM_MUTATION } },
1620
1835
  async resolve(parent, args, context) {
1621
1836
  const params = {
1622
1837
  args,
@@ -1635,7 +1850,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1635
1850
  return new GraphQLObjectType(rootQueryArgs);
1636
1851
  };
1637
1852
 
1638
- const buildRootQuery = (name, includedTypes) => {
1853
+ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult) => {
1639
1854
  const rootQueryArgs = {};
1640
1855
  rootQueryArgs.name = name;
1641
1856
  rootQueryArgs.fields = {};
@@ -1657,8 +1872,10 @@ const buildRootQuery = (name, includedTypes) => {
1657
1872
  rootQueryArgs.fields[type.listEntitiesEndpointName] = {
1658
1873
  type: new GraphQLList(type.gqltype),
1659
1874
  args: argsObject,
1660
- extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find' } },
1661
- async resolve(parent, args, context) {
1875
+ // countSink: this resolver reports pagination counts through reportCount, so a caller
1876
+ // that supplies a root-value sink can pass its context unchanged.
1877
+ extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
1878
+ async resolve(parent, args, context, info) {
1662
1879
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
1663
1880
  const params = {
1664
1881
  type,
@@ -1670,7 +1887,8 @@ const buildRootQuery = (name, includedTypes) => {
1670
1887
  await executeScope(params);
1671
1888
  await applyJoinedScopes(joinedScopes, params);
1672
1889
  const queryArgs = params.args;
1673
- const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count);
1890
+ const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
1891
+ && canReportCount(parent, info, context);
1674
1892
 
1675
1893
  const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
1676
1894
  const countPromise = wantsCount
@@ -1678,8 +1896,10 @@ const buildRootQuery = (name, includedTypes) => {
1678
1896
  : null;
1679
1897
 
1680
1898
  const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
1681
- if (wantsCount) {
1682
- context.count = 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.');
1683
1903
  }
1684
1904
  return result;
1685
1905
  },
@@ -1696,7 +1916,7 @@ const buildRootQuery = (name, includedTypes) => {
1696
1916
  };
1697
1917
 
1698
1918
  rootQueryArgs.fields[`${type.listEntitiesEndpointName}_aggregate`] = {
1699
- type: new GraphQLList(QLTypeAggregationResult),
1919
+ type: new GraphQLList(aggregationResultType),
1700
1920
  args: aggregateArgsObject,
1701
1921
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
1702
1922
  async resolve(parent, args, context) {
@@ -1876,17 +2096,21 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1876
2096
  const objectMemberTypes = new Set();
1877
2097
  Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
1878
2098
 
1879
- const query = buildRootQuery('RootQueryType', includedQueryTypes);
2099
+ let query = buildRootQuery('RootQueryType', includedQueryTypes);
1880
2100
  Object.values(typesDict.types).forEach(({ gqltype }) => {
1881
2101
  if (gqltype && !applicationResolvedFields.has(gqltype)) {
1882
2102
  applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
1883
2103
  }
1884
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
+ }
1885
2112
 
1886
- const schema = new GraphQLSchema({
1887
- query,
1888
- mutation: buildMutation('Mutation', includedMutationTypes, includedCustomMutations),
1889
- });
2113
+ const schema = new GraphQLSchema({ query, mutation });
1890
2114
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
1891
2115
  // custom mutation result, so another runtime cannot later generate resolvers this schema executes.
1892
2116
  schemaTypes.forEach((type) => {
@@ -2009,6 +2233,14 @@ const autoGenerateResolvers = (gqltype) => {
2009
2233
  }
2010
2234
  };
2011
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
+
2012
2244
  const connect = (model, gqltype, simpleEntityEndpointName,
2013
2245
  listEntitiesEndpointName, controller, onModelCreated, stateMachine) => {
2014
2246
  const registration = {
@@ -2025,6 +2257,7 @@ const connect = (model, gqltype, simpleEntityEndpointName,
2025
2257
  validateScope(gqltype);
2026
2258
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
2027
2259
 
2260
+ invalidateCollectionInputs(gqltype);
2028
2261
  waitingInputType[gqltype.name] = {
2029
2262
  model,
2030
2263
  gqltype,
@@ -2054,6 +2287,7 @@ const addNoEndpointType = (gqltype) => {
2054
2287
  validateScope(gqltype);
2055
2288
  if (adapter.validateRegistration) adapter.validateRegistration(registration);
2056
2289
 
2290
+ invalidateCollectionInputs(gqltype);
2057
2291
  waitingInputType[gqltype.name] = { gqltype };
2058
2292
  typesDict.types[gqltype.name] = registration;
2059
2293
  typesDictForUpdate.types[gqltype.name] = { ...typesDict.types[gqltype.name] };
@@ -2077,27 +2311,31 @@ const createArgsForQuery = (gqltype, omit = null) => {
2077
2311
 
2078
2312
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
2079
2313
  if (fieldEntryName === omit) continue;
2080
- argsObject[fieldEntryName] = {};
2314
+ let filterType = null;
2081
2315
 
2082
2316
  if (fieldEntry.type instanceof GraphQLScalarType
2083
2317
  || isNonNullOfType(fieldEntry.type, GraphQLScalarType)
2084
2318
  || fieldEntry.type instanceof GraphQLEnumType
2085
2319
  || isNonNullOfType(fieldEntry.type, GraphQLEnumType)) {
2086
- argsObject[fieldEntryName].type = QLFilter;
2320
+ filterType = QLFilter;
2087
2321
  } else if (fieldEntry.type instanceof GraphQLObjectType
2088
2322
  || isNonNullOfType(fieldEntry.type, GraphQLObjectType)) {
2089
- argsObject[fieldEntryName].type = QLTypeFilterExpression;
2323
+ filterType = QLTypeFilterExpression;
2090
2324
  } else if (getListShape(fieldEntry.type)) {
2091
2325
  const listOfType = getListShape(fieldEntry.type).itemType;
2092
2326
  if (listOfType instanceof GraphQLScalarType
2093
2327
  || isNonNullOfType(listOfType, GraphQLScalarType)
2094
2328
  || listOfType instanceof GraphQLEnumType
2095
2329
  || isNonNullOfType(listOfType, GraphQLEnumType)) {
2096
- argsObject[fieldEntryName].type = QLFilter;
2330
+ filterType = QLFilter;
2097
2331
  } else {
2098
- 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;
2099
2335
  }
2100
2336
  }
2337
+ // A single interface or union field has no generated filter, so it gets no argument.
2338
+ if (filterType) argsObject[fieldEntryName] = { type: filterType };
2101
2339
  }
2102
2340
 
2103
2341
  argsObject.pagination = {};