@simtlix/simfinity-core 3.5.8 → 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/README.md CHANGED
@@ -16,13 +16,13 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
16
16
  - `preventCreatingCollection` to pass validation-only storage setup to an adapter;
17
17
  - `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size and nested-operation limits.
18
18
 
19
- The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize or reject it. `castId` should throw a `NOT_VALID_ID` (400) `SimfinityError` for a malformed value and must not create a new identifier. When `getById` rejects a stored reference value that way, including one that `castId` rejected first, the generated reference field fails with an `InternalServerError` (500), since the client did not send the value. `update()` resolves to the updated record as stored, or null; nested collection children of that record are linked to and checked against its `_id`, else `id`, else the requested ID. An adapter that hydrates records can define `readEmbeddedValue(value)`: every nullable, singular embedded object field without a resolver then passes its value through it, so an explicitly stored `null` reads as `null`. The field is read as graphql's default resolver reads it, so a method on the parent is called and a promise it returns is awaited before the hook receives the value. The MongoDB adapter defines it.
19
+ The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize or reject it. `castId` should throw a `NOT_VALID_ID` (400) `SimfinityError` for a malformed value and must not create a new identifier. When `getById` rejects a stored reference value that way, including one that `castId` rejected first, the generated reference field fails with an `InternalServerError` (500), since the client did not send the value. `update()` resolves to the updated record as stored, or null; nested collection children of that record are linked to and checked against its `_id`, else `id`, else the requested ID. An adapter that hydrates records can define `readEmbeddedValue(value)`: every nullable, singular embedded object field without a resolver then passes its value through it, so an explicitly stored `null` reads as `null`. The field is read as graphql's default resolver reads it, so a method on the parent is called and a promise it returns is awaited before the hook receives the value. Simfinity then reads as `null` an object that holds no data when a required member would read as `null`, not counting `id`, `readOnly`, interface and union members, members with an application resolver and referenced collections; runtimes that share the type read the same. It reads the stored members, never calling a getter, and counts a stored `_id` as `id`; it reads nothing from objects whose type has no member that counts, and an error while reading returns the object unchanged. An adapter whose hydrated records render members through getters can also define `rawEmbeddedValue(value)`, which returns a record's stored data as a plain object, or the record unchanged when that data does not show what it renders; a value that is not a plain object counts as data. The MongoDB adapter defines both.
20
20
 
21
21
  Entity types, those registered with `connect()` and the targets of non-embedded relations, resolve `id` to `_id`, falling back to `id`, whatever the `createSchema()` allowlists include; types used only as embedded values keep their declared `id`. Embedded copies of an entity type resolve the same way, so they return their declared `id` unless the adapter stores an `_id` for them, as MongoDB does for embedded list items. The resolver is set on the type object, so it applies in every runtime that shares the type. `createSchema()` marks these registrations with `storedIdentity: true` before calling `adapter.prepare()`.
22
22
 
23
23
  When a runtime first creates a schema, it binds each GraphQL object type whose non-embedded relation fields receive its generated resolvers. It also reserves each reachable object type that still has a non-embedded relation field without a resolver, such as an unregistered custom mutation result. Registering a bound or reserved type in another runtime, or building another runtime's schema that reaches it through fields, interfaces, union members or custom mutation results, throws `TYPE_BOUND_TO_OTHER_RUNTIME` (409). `createSchema()` also rejects a type with a relation field copied, by `toConfig()` or a field spread, after another runtime generated that field's resolver. Create type objects once per runtime, for example with a factory function. Types without non-embedded relation fields, and types whose relation fields all have application resolvers, can be shared, and another runtime can use a `toConfig()` copy made before the first runtime built its schema. A shared custom mutation result type with an unresolved relation field is rejected.
24
24
 
25
- Registration and `createSchema()` validate `extensions.scope` (`INVALID_SCOPE`) and reject list or embedded relations to unregistered types (`UNREGISTERED_RELATION_TARGET`); `use()` rejects non-functions (`INVALID_MIDDLEWARE`). Before `adapter.prepare()`, `createSchema()` rejects a writable non-embedded list relation without a non-empty child `connectionField` whose resolver it would generate (`INVALID_MODEL`, 400). A collection without `connectionField` that has an application resolver, or is `readOnly`, only logs a `Configuration issue` warning, unless it is both, and nested writes through it fail with `INVALID_MODEL` (500) before anything is written; adapters that describe the whole model, such as PostgreSQL and transactional MongoDB, still reject it in `prepare()`. Fields named like generated query arguments (`sort`, `pagination`, `AND`, `OR`, and `aggregation` on aggregate endpoints) also log a warning once per runtime.
25
+ Registration and `createSchema()` validate `extensions.scope` (`INVALID_SCOPE`) and reject list or embedded relations to unregistered types (`UNREGISTERED_RELATION_TARGET`); `use()` rejects non-functions (`INVALID_MIDDLEWARE`). Before `adapter.prepare()`, `createSchema()` rejects a writable non-embedded list relation without a non-empty child `connectionField` whose resolver it would generate (`INVALID_MODEL`, 400). A collection without `connectionField` that has an application resolver, or is `readOnly`, only logs a `Configuration issue` warning, unless it is both, and nested writes through it fail with `INVALID_MODEL` (500) before anything is written; adapters that describe the whole model, such as PostgreSQL and transactional MongoDB, still reject it in `prepare()`. Fields named like generated query arguments (`sort`, `pagination`, `AND`, `OR`, and `aggregation` on aggregate endpoints) also log a warning once per runtime. Generated inputs that would have no fields are left out with a `Configuration issue` warning, with the collection operations, embedded fields and mutations that would take them; a non-null embedded field whose embedded type has no writable fields throws `INVALID_MODEL` (400) when generated mutations reach its owner. An application type named `FieldExtensionsType` or `RelationType` makes Simfinity rename its introspection metadata types to `SimfinityFieldExtensionsType` and `SimfinityRelationType` when it is in the process's first Simfinity schema. That schema fixes the names: in a later `createSchema()`, an application type with one of the current names, the original ones or the fallback names after a rename, throws `RESERVED_TYPE_NAME` (500), before `adapter.prepare()` when it is an included endpoint type, a registered mutation's input or output type, an output type they reach or a field argument's type, and when the schema is constructed otherwise, such as for a type inside an input object's fields.
26
26
 
27
27
  Generated and custom mutations run through `adapter.withTransaction`. Retried attempts receive fresh clones of GraphQL input objects, lists, and dates while enum values keep their identity. An adapter can define `stateValue` when its persisted enum representation differs from GraphQL enum names.
28
28
 
@@ -34,6 +34,6 @@ Importing the runtime installs Simfinity's `__Field.extensions` introspection fi
34
34
 
35
35
  `createQueryPlan` and `resolveModelPath` turn Simfinity filter, sort, pagination, and aggregation inputs into a driver-neutral plan resolved against that metadata.
36
36
 
37
- The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `buildErrorFormatter`, `configureQueryLimits`, `configureMutationLimits`, `QLOperator`, `QLSort`, and `QLValue`. `buildErrorFormatter` returns a `GraphQLError` whose `originalError` is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
37
+ The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `UnauthenticatedError` and `ForbiddenError` (the same classes as `auth.UnauthenticatedError` and `auth.ForbiddenError`), `buildErrorFormatter`, `configureQueryLimits`, `configureMutationLimits`, `QLOperator`, `QLSort`, and `QLValue`. `buildErrorFormatter` returns a `GraphQLError` whose `originalError` is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
38
38
 
39
39
  The same helpers are available from the `@simtlix/simfinity-core/auth`, `/auth/errors`, `/auth/expressions`, `/auth/rules`, `/plugins`, `/scalars` and `/validators` subpaths, each with TypeScript declarations whose types match the root namespaces. `moduleResolution` `node16`, `nodenext` and `bundler` read them through the export map, and `node10` through `typesVersions`. The `internal/*` subpaths are not public API and have no declarations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.5.8",
3
+ "version": "3.5.9",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -13,6 +13,9 @@ export { default as QLValue } from './const/QLValue.js';
13
13
  export { configureQueryLimits, paginationStages } from './query-limits.js';
14
14
  export { configureMutationLimits } from './mutation-limits.js';
15
15
  export { default as auth } from './auth/index.js';
16
+ // The same classes as auth.UnauthenticatedError and auth.ForbiddenError; types/index.d.ts has
17
+ // always declared them as root exports.
18
+ export { UnauthenticatedError, ForbiddenError } from './auth/errors.js';
16
19
  export { default as plugins } from './plugins.js';
17
20
  export { default as scalars } from './scalars.js';
18
21
  export { default as validators } from './validators.js';
@@ -5,12 +5,11 @@ import {
5
5
  __Field,
6
6
  } from 'graphql';
7
7
 
8
- // getFields() supports both lazy and already materialized introspection fields.
9
- // Reuse the shared field on repeated module evaluation to preserve type identity.
10
- const introspectionFields = __Field.getFields();
11
- if (!introspectionFields.extensions) {
8
+ // The object types of `__Field.extensions`: FieldExtensionsType and the RelationType of its
9
+ // `relation` field, under the given names.
10
+ const createFieldMetadataType = (extensionsName, relationName) => {
12
11
  const RelationType = new GraphQLObjectType({
13
- name: 'RelationType',
12
+ name: relationName,
14
13
  fields: () => ({
15
14
  embedded: { type: GraphQLBoolean },
16
15
  connectionField: { type: GraphQLString },
@@ -18,20 +17,55 @@ if (!introspectionFields.extensions) {
18
17
  }),
19
18
  });
20
19
 
21
- const FieldExtensionsType = new GraphQLObjectType({
22
- name: 'FieldExtensionsType',
20
+ return new GraphQLObjectType({
21
+ name: extensionsName,
23
22
  fields: () => ({
24
23
  relation: { type: RelationType },
25
24
  stateMachine: { type: GraphQLBoolean },
26
25
  readOnly: { type: GraphQLBoolean },
27
26
  }),
28
27
  });
28
+ };
29
29
 
30
+ // getFields() supports both lazy and already materialized introspection fields.
31
+ // Reuse the shared field on repeated module evaluation to preserve type identity.
32
+ const introspectionFields = __Field.getFields();
33
+ if (!introspectionFields.extensions) {
30
34
  introspectionFields.extensions = {
31
- type: FieldExtensionsType,
35
+ type: createFieldMetadataType('FieldExtensionsType', 'RelationType'),
32
36
  name: 'extensions',
33
37
  resolve: (obj) => obj.extensions,
34
38
  args: [],
35
39
  isDeprecated: false,
36
40
  };
37
41
  }
42
+
43
+ // Set on the shared extensions field, by any evaluation of this module, once a Simfinity schema
44
+ // contains the metadata types; their names do not change after that.
45
+ const NAMES_FIXED = Symbol.for('simfinity.fieldMetadataNamesFixed');
46
+ const extensionsField = () => __Field.getFields().extensions;
47
+
48
+ /** The metadata types `__Field.extensions` returns now: [FieldExtensionsType, RelationType]. */
49
+ export const fieldMetadataTypes = () => {
50
+ const extensionsType = extensionsField().type;
51
+ return [extensionsType, extensionsType.getFields().relation.type];
52
+ };
53
+
54
+ export const fieldMetadataNamesFixed = () => extensionsField()[NAMES_FIXED] === true;
55
+
56
+ export const fixFieldMetadataNames = () => {
57
+ extensionsField()[NAMES_FIXED] = true;
58
+ };
59
+
60
+ // Every schema constructed afterwards, in the whole process, contains the metadata types under the
61
+ // new names. The extensions field object keeps its identity, so other evaluations of this module and
62
+ // the auth plugin, which read its type when they need it, follow the change. Returns a function that
63
+ // puts the previous type objects back, for a schema whose construction fails.
64
+ export const renameFieldMetadataTypes = (extensionsName, relationName) => {
65
+ const field = extensionsField();
66
+ const previous = field.type;
67
+ field.type = createFieldMetadataType(extensionsName, relationName);
68
+ return () => {
69
+ field.type = previous;
70
+ };
71
+ };
package/src/runtime.js CHANGED
@@ -19,7 +19,9 @@ import { isThenable } from './auth/thenable.js';
19
19
  import {
20
20
  getFieldStorageName, getListShape, markStoredIdentity, normalizeConnectionField,
21
21
  } from './relation-storage.js';
22
- import './introspection.js';
22
+ import {
23
+ fieldMetadataNamesFixed, fieldMetadataTypes, fixFieldMetadataNames, renameFieldMetadataTypes,
24
+ } from './introspection.js';
23
25
 
24
26
  // Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
25
27
  // created is application code, such as a masking resolver, recorded once per type so later
@@ -212,8 +214,9 @@ const GraphQLJSON = new GraphQLScalarType({
212
214
 
213
215
  // The type called `name` that a schema built from these roots would contain, other than through
214
216
  // `skip`, following the references GraphQLSchema collects: fields, arguments, interfaces, union
215
- // members and input fields.
216
- const findSchemaType = (roots, name, skip) => {
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 } = {}) => {
217
220
  const visited = new Set();
218
221
  const pending = [...roots];
219
222
  while (pending.length) {
@@ -228,13 +231,75 @@ const findSchemaType = (roots, name, skip) => {
228
231
  pending.push(field.type, ...field.args.map((arg) => arg.type));
229
232
  }
230
233
  }
231
- if (type instanceof GraphQLInputObjectType) {
234
+ if (inputFields && type instanceof GraphQLInputObjectType) {
232
235
  for (const field of Object.values(type.getFields())) pending.push(field.type);
233
236
  }
234
237
  }
235
238
  return null;
236
239
  };
237
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
+
238
303
  // graphql-js wraps a value thrown while resolving a field (locatedError): the wrapper has the field
239
304
  // path and repeats the thrown error's message. A thrown non-Error becomes a NonErrorThrown whose
240
305
  // message prints the value.
@@ -609,6 +674,10 @@ const wrapListInputType = (itemType, listShape, preserveOuterNonNull) => {
609
674
 
610
675
  const capitalize = (name) => name.charAt(0).toUpperCase() + name.slice(1);
611
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
+
612
681
  /**
613
682
  * The `added` item input of a referenced collection: the item type's create input without the
614
683
  * back-reference that the parent supplies. Its fields are read from the item's create input when
@@ -649,9 +718,11 @@ const createTypeWithExcludedField = (inputNamePrefix, itemType, fieldToExclude,
649
718
  };
650
719
 
651
720
  // 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.
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.
653
724
  const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
654
- itemNonNull = false, optionalFields = []) => {
725
+ itemNonNull = false, optionalFields = [], owner = name) => {
655
726
  const inputTypeForAdd = connection.declaredFieldName
656
727
  ? createTypeWithExcludedField(
657
728
  inputNamePrefix,
@@ -663,17 +734,40 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
663
734
  : null;
664
735
  const listOf = (type) => new GraphQLList(itemNonNull ? new GraphQLNonNull(type) : type);
665
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
+ };
744
+
666
745
  return new GraphQLInputObjectType({
667
746
  name,
668
- fields: () => ({
669
- added: { type: listOf(inputTypeForAdd || getInputType(itemType)) },
670
- updated: { type: listOf(typesDictForUpdate.types[itemType.name].inputType) },
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
+ }
671
761
  // Null IDs are skipped, so `deleted` accepts them whatever the collection's item nullability.
672
- deleted: { type: new GraphQLList(GraphQLID) },
673
- }),
762
+ fields.deleted = { type: new GraphQLList(GraphQLID) };
763
+ return fields;
764
+ },
674
765
  });
675
766
  };
676
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
+
677
771
  const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
678
772
  inputNamePrefix, connectionField, preserveOuterNonNull, ownerName) => {
679
773
  const listShape = getListShape(fieldEntry.type);
@@ -686,13 +780,15 @@ const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
686
780
  if (!fieldEntry.extensions?.relation?.embedded) {
687
781
  const oneToMany = createOneToManyInputType(`OneToMany${inputNamePrefix}${fieldEntryName}`,
688
782
  inputNamePrefix, itemType, normalizeConnectionField(itemType, connectionField),
689
- listShape.itemNonNull);
783
+ listShape.itemNonNull, [], `${ownerName}.${fieldEntryName}`);
690
784
  return preserveOuterNonNull && listShape.outerNonNull
691
785
  ? new GraphQLNonNull(oneToMany)
692
786
  : oneToMany;
693
787
  }
694
788
  if (registration.inputType) {
695
- return wrapListInputType(registration.inputType, listShape, preserveOuterNonNull);
789
+ return hasInputFields(registration.inputType)
790
+ ? wrapListInputType(registration.inputType, listShape, preserveOuterNonNull)
791
+ : NO_INPUT_FIELDS;
696
792
  }
697
793
  } else if (itemType instanceof GraphQLScalarType || itemType instanceof GraphQLEnumType) {
698
794
  return wrapListInputType(itemType, listShape, preserveOuterNonNull);
@@ -725,14 +821,47 @@ const selfCollectionInputType = (gqltype, fieldEntryName, fieldEntry, forUpdate,
725
821
  // optional there, as such items had no such fields before; items added by an update keep them
726
822
  // as the type declares them.
727
823
  forUpdate ? [] : selfFields,
824
+ `${gqltype.name}.${fieldEntryName}`,
728
825
  );
729
826
  return !forUpdate && listShape.outerNonNull ? new GraphQLNonNull(oneToMany) : oneToMany;
730
827
  };
731
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
+
732
860
  // Builds a type's create and update inputs, or returns every field whose input cannot be built yet:
733
861
  // `{ 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.
735
- const buildInputType = (gqltype) => {
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) => {
736
865
  const argTypes = gqltype.getFields();
737
866
 
738
867
  const fieldsArgs = {};
@@ -740,6 +869,13 @@ const buildInputType = (gqltype) => {
740
869
 
741
870
  const selfReferenceCollections = {};
742
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
+ };
743
879
 
744
880
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
745
881
  const fieldArg = {};
@@ -774,8 +910,17 @@ const buildInputType = (gqltype) => {
774
910
  } else if (requireRegisteredType(typesDict, unwrapNonNull(fieldEntry.type), fieldEntryName,
775
911
  gqltype.name).inputType
776
912
  && typesDictForUpdate.types[fieldEntryNameValue].inputType) {
777
- fieldArg.type = typesDict.types[fieldEntryNameValue].inputType;
778
- 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);
779
924
  } else {
780
925
  unresolvedFields.push({ field: fieldEntryName, waitsFor: fieldEntryNameValue });
781
926
  }
@@ -802,8 +947,11 @@ const buildInputType = (gqltype) => {
802
947
  fieldEntryName, `${gqltype.name}U`,
803
948
  fieldEntry.extensions?.relation?.connectionField, false, gqltype.name);
804
949
  if (listInputTypeForAdd && listInputTypeForUpdate) {
805
- fieldArg.type = listInputTypeForAdd;
806
- 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);
807
955
  } else {
808
956
  // An object item here is embedded and its inputs are not built yet; any other item,
809
957
  // such as a list, has no generated input.
@@ -858,7 +1006,9 @@ const buildInputType = (gqltype) => {
858
1006
  fields: fieldsArgForUpdate,
859
1007
  });
860
1008
 
861
- return { inputTypeBody: inputTypeForAdd, inputTypeBodyForUpdate: inputTypeForUpdate };
1009
+ return {
1010
+ inputTypeBody: inputTypeForAdd, inputTypeBodyForUpdate: inputTypeForUpdate, requiredWithoutCreateInput,
1011
+ };
862
1012
  };
863
1013
 
864
1014
  const getInputType = (type) => typesDict.types[type.name].inputType;
@@ -964,6 +1114,14 @@ const unresolvedInputTypesMessage = (pendingTypeNames, unresolvedByType) => {
964
1114
 
965
1115
  const buildPendingInputTypes = (waitingForInputType, includedMutationTypes) => {
966
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
+ }
967
1125
  let pending = waitingForInputType;
968
1126
  let previousPendingCount = Object.keys(pending).length + 1;
969
1127
  // Type name -> the fields whose input could not be built in the last pass.
@@ -983,10 +1141,11 @@ const buildPendingInputTypes = (waitingForInputType, includedMutationTypes) => {
983
1141
  const { gqltype } = value;
984
1142
  if (typesDict.types[gqltype.name].inputType) continue;
985
1143
 
986
- const result = buildInputType(gqltype);
1144
+ const result = buildInputType(gqltype, reachable);
987
1145
  if (result.inputTypeBody && result.inputTypeBodyForUpdate) {
988
1146
  typesDict.types[gqltype.name].inputType = result.inputTypeBody;
989
1147
  typesDictForUpdate.types[gqltype.name].inputType = result.inputTypeBodyForUpdate;
1148
+ requiredFieldsWithoutCreateInput.set(typesDict.types[gqltype.name], result.requiredWithoutCreateInput);
990
1149
  } else {
991
1150
  stillWaiting[key] = value;
992
1151
  unresolvedFields[gqltype.name] = result.unresolvedFields;
@@ -1707,9 +1866,22 @@ const resolveById = async (type, args, context, requiredId) => {
1707
1866
  return results[0] || null;
1708
1867
  };
1709
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
+
1710
1879
  // Every generated mutation field carries extensions.simfinityMutation with its operation (and, for
1711
1880
  // a state-machine action, the action and its from/to states), so tools such as the MCP package can
1712
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).
1713
1885
  const buildMutation = (name, includedMutationTypes, includedCustomMutations) => {
1714
1886
  const rootQueryArgs = {};
1715
1887
  rootQueryArgs.name = name;
@@ -1722,24 +1894,33 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1722
1894
  if (type.endpoint) {
1723
1895
  const argsObject = { input: { type: new GraphQLNonNull(type.inputType) } };
1724
1896
 
1725
- rootQueryArgs.fields[`add${type.simpleEntityEndpointName}`] = {
1726
- type: type.gqltype,
1727
- description: 'add',
1728
- args: argsObject,
1729
- extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.SAVE } },
1730
- async resolve(parent, args, context) {
1731
- const params = {
1732
- type,
1733
- args,
1734
- operation: operations.SAVE,
1735
- context,
1736
- };
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
+ };
1737
1910
 
1738
- await executeMiddleware(params);
1739
- return executeOperation(type.model, type.gqltype, type.controller,
1740
- args.input, operations.SAVE, null, null, context, type.inputType);
1741
- },
1742
- };
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
+ }
1743
1924
  rootQueryArgs.fields[`delete${type.simpleEntityEndpointName}`] = {
1744
1925
  type: type.gqltype,
1745
1926
  description: 'delete',
@@ -1764,7 +1945,12 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1764
1945
 
1765
1946
  for (const type of Object.values(typesDictForUpdate.types)) {
1766
1947
  if (!shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)) {
1767
- 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) {
1768
1954
  const argsObject = { input: { type: new GraphQLNonNull(type.inputType) } };
1769
1955
  rootQueryArgs.fields[`update${type.simpleEntityEndpointName}`] = {
1770
1956
  type: type.gqltype,
@@ -1967,6 +2153,102 @@ const installIdResolver = (gqltype) => {
1967
2153
  if (idField && !idField.resolve) idField.resolve = markGenerated((parent) => parent._id ?? parent.id);
1968
2154
  };
1969
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
+
1970
2252
  // An adapter that hydrates stored records may render an explicitly null singular embedded object as
1971
2253
  // an object (MongoDB nested paths do); readEmbeddedValue returns null for it. Nullable singular
1972
2254
  // embedded fields of registered types, and of the embedded types they reach, read through it. The
@@ -1975,11 +2257,24 @@ const installIdResolver = (gqltype) => {
1975
2257
  // The value is read as graphql's default resolver reads it, so a method on the parent, such as one a
1976
2258
  // custom mutation result supplies, is still called; a promise it returns is read once it settles.
1977
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).
1978
2274
  const readEmbeddedField = markGenerated((parent, args, context, info) => {
1979
2275
  const value = readOwnField(parent, args, context, info);
1980
- return isThenable(value)
1981
- ? value.then((resolved) => adapter.readEmbeddedValue(resolved))
1982
- : adapter.readEmbeddedValue(value);
2276
+ const read = (resolved) => readEmbeddedObject(adapter.readEmbeddedValue(resolved), getNamedType(info.returnType));
2277
+ return isThenable(value) ? value.then(read) : read(value);
1983
2278
  });
1984
2279
 
1985
2280
  const installEmbeddedValueResolvers = (gqltype, visited) => {
@@ -2042,6 +2337,20 @@ const assertCollectionConnections = () => {
2042
2337
 
2043
2338
  const getRegistrations = () => Object.values(typesDict.types);
2044
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
+
2045
2354
  const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => {
2046
2355
  // Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
2047
2356
  Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
@@ -2049,6 +2358,11 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2049
2358
  schemaTypes.forEach(assertNotBoundElsewhere);
2050
2359
  schemaTypes.forEach(assertFieldsNotBoundElsewhere);
2051
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));
2052
2366
  const referencedTypes = markReferencedTypesForModelGeneration();
2053
2367
  // Entities, which keep their own stored identity: connect() types and relation targets.
2054
2368
  Object.values(typesDict.types).forEach((typeInfo) => {
@@ -2110,7 +2424,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2110
2424
  query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON));
2111
2425
  }
2112
2426
 
2113
- const schema = new GraphQLSchema({ query, mutation });
2427
+ const schema = constructSchema(query, mutation);
2114
2428
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
2115
2429
  // custom mutation result, so another runtime cannot later generate resolvers this schema executes.
2116
2430
  schemaTypes.forEach((type) => {
@@ -4,12 +4,8 @@
4
4
  */
5
5
  import { auth } from '../index.js';
6
6
 
7
- /** Authentication error: code `UNAUTHENTICATED`, status 401. */
8
- export const UnauthenticatedError: typeof auth.UnauthenticatedError;
9
- export type UnauthenticatedError = InstanceType<typeof auth.UnauthenticatedError>;
10
- /** Authorization error: code `FORBIDDEN`, status 403. */
11
- export const ForbiddenError: typeof auth.ForbiddenError;
12
- export type ForbiddenError = InstanceType<typeof auth.ForbiddenError>;
7
+ // The root classes themselves, which `auth.UnauthenticatedError` and `auth.ForbiddenError` also are.
8
+ export { UnauthenticatedError, ForbiddenError } from '../index.js';
13
9
  export const createAuthError: typeof auth.createAuthError;
14
10
 
15
11
  declare const errors: Pick<typeof auth, 'UnauthenticatedError' | 'ForbiddenError' | 'createAuthError'>;
package/types/index.d.ts CHANGED
@@ -227,11 +227,30 @@ export interface Runtime<Model = any, Session = any> {
227
227
  * reserves reachable types that still have a
228
228
  * non-embedded relation field without a resolver, such as an unregistered custom mutation result.
229
229
  * Every entity type resolves `id` to `_id ?? id`, whatever the allowlists include.
230
+ *
231
+ * A generated input that would have no fields is left out, with a `Configuration issue`
232
+ * warning: the `added` or `updated` operation of a collection whose child has no writable fields
233
+ * for it, an embedded field whose embedded type has none, `add<type>` for a type without
234
+ * writable fields, and `update<type>` and its state actions for a type without writable fields
235
+ * and without a writable `id`. A non-null embedded field whose embedded type has no writable
236
+ * fields throws `INVALID_MODEL` (400) while inputs are built when the generated mutations reach
237
+ * its owner's create input. When the process's first Simfinity schema has an application type
238
+ * named `FieldExtensionsType` or `RelationType`, Simfinity renames its `__Field.extensions`
239
+ * metadata types `SimfinityFieldExtensionsType` and `SimfinityRelationType` (numbered when
240
+ * taken) for every later schema and logs a warning; once those names are fixed, a type with one
241
+ * of them throws `RESERVED_TYPE_NAME` (500), before `adapter.prepare()` when it is an included
242
+ * endpoint type, a registered mutation's input or output type, an output type they reach or a
243
+ * field argument's type, and when the schema is constructed otherwise, such as for a type inside
244
+ * an input object's fields. The check before `adapter.prepare()` never reads an input object's
245
+ * fields, so a registered input's `fields` function may call `getInputType()`.
230
246
  */
231
247
  createSchema(includedQueryTypes?: GraphQLObjectType[] | null, includedMutationTypes?: GraphQLObjectType[] | null, includedCustomMutations?: string[] | null): GraphQLSchema;
232
248
  getModel(type: GraphQLObjectType | { name: string }): Model | null | undefined;
233
249
  getType(name: string | { name: string }): GraphQLObjectType | null | undefined;
234
- /** Available after createSchema has built input types. */
250
+ /**
251
+ * Available after createSchema has built input types. It has no fields for a type without
252
+ * writable fields; such inputs are not part of generated schemas.
253
+ */
235
254
  getInputType(type: GraphQLObjectType | { name: string }): GraphQLInputObjectType | undefined;
236
255
  getRegistrations(): RuntimeRegistration<Model, Session>[];
237
256
  /**
@@ -276,8 +295,31 @@ export interface DatabaseAdapter<Model = any, Session = any> {
276
295
  * record renders as an object although the stored value is an explicit null; return any other
277
296
  * value unchanged. It must depend only on the value: these resolvers read no data and do not bind
278
297
  * their types, so a runtime that reaches a shared type may read through another runtime's hook.
298
+ * Simfinity then reads as null an object that holds no data when a non-null member would read as
299
+ * null, whatever the selection. These members do not count: `id`, readOnly members, interface and
300
+ * union members, members with their own resolver and referenced collections. An object whose type
301
+ * has no member that counts is returned untouched. Otherwise Simfinity reads the stored members
302
+ * (see `rawEmbeddedValue`), never calling a getter, and counts a stored `_id` as `id`; an error
303
+ * while reading them returns the object unchanged. Runtimes that share the type read through the
304
+ * same resolver, with or without the hook. On MongoDB, a create that writes an empty object for a
305
+ * required embedded member stores it as absent (Mongoose minimizes it), so an object that holds
306
+ * nothing else reads as null too, except in by-ID and reference reads and update responses where
307
+ * the member is a nested path, as generated models declare every embedded member except one named
308
+ * `type`.
279
309
  */
280
310
  readEmbeddedValue?(value: any): any;
311
+ /**
312
+ * Optional, used with `readEmbeddedValue`. Returns the stored data of a hydrated embedded value
313
+ * as a plain object, without running getters or virtuals, such as a document's raw data; returns
314
+ * any other value unchanged. Return a hydrated value unchanged when its stored data does not show
315
+ * what it renders, such as one whose schema supplies members through getters, virtuals, methods
316
+ * or aliases: a value that is not a plain object counts as data, so Simfinity keeps it as it is.
317
+ * Simfinity calls it on the embedded objects it checks for data, including those it finds inside
318
+ * the returned data, and reads only own data properties of the result; a null or undefined result
319
+ * has no members. Without it, a plain object is read as it is, an accessor property counts as
320
+ * present without being called, and a value that is not a plain object counts as data.
321
+ */
322
+ rawEmbeddedValue?(value: any): any;
281
323
  getById(model: Model, id: any, session?: Session | null, options?: { projection?: Record<string, number>; plain?: boolean; lock?: boolean; requiredId?: any; context?: any }): any;
282
324
  /** Optional batch read: the records found for `ids`, in getById shape and any order. After a failure, the runtime reads each ID with getById. */
283
325
  getByIds?(model: Model, ids: any[], options?: { context?: any }): any;
@@ -430,11 +472,19 @@ export interface AuthPluginOptions {
430
472
  debug?: boolean;
431
473
  }
432
474
 
433
- declare class UnauthenticatedError extends SimfinityError {
475
+ /**
476
+ * Authentication error: code `UNAUTHENTICATED`, status 401, default message `Authentication required`.
477
+ * The same class as `auth.UnauthenticatedError` and the `/auth` and `/auth/errors` subpath exports.
478
+ */
479
+ export class UnauthenticatedError extends SimfinityError {
434
480
  constructor(message?: string);
435
481
  }
436
482
 
437
- declare class ForbiddenError extends SimfinityError {
483
+ /**
484
+ * Authorization error: code `FORBIDDEN`, status 403, default message `Access denied`.
485
+ * The same class as `auth.ForbiddenError` and the `/auth` and `/auth/errors` subpath exports.
486
+ */
487
+ export class ForbiddenError extends SimfinityError {
438
488
  constructor(message?: string);
439
489
  }
440
490
 
@@ -5,7 +5,9 @@
5
5
  import { plugins } from './index.js';
6
6
 
7
7
  export type { AuthPluginOptions, EnvelopSchemaPlugin, PermissionSchema } from './index.js';
8
- export const createAuthPlugin: typeof plugins.createAuthPlugin;
8
+ // The /auth declaration itself, as the runtime re-exports that binding, so the two subpaths can be
9
+ // re-exported together.
10
+ export { createAuthPlugin } from './auth/index.js';
9
11
  export const apolloCountPlugin: typeof plugins.apolloCountPlugin;
10
12
  export const envelopCountPlugin: typeof plugins.envelopCountPlugin;
11
13