@simtlix/simfinity-core 3.5.8 → 3.6.0

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. An adapter can define `readByIdIsInert(model)`, returning `true` when a `getById` of that model has no side effects and `update()` and `delete()` reject the same malformed IDs with `NOT_VALID_ID` (400) and return null for a missing record; the runtime then skips the reads before deletes without an `onDelete` hook and before updates that merge no embedded value and have neither `onUpdating` nor nested collection items. The SQL adapter always reports it, and the MongoDB adapter does for models without `find`, `findOne`, `findOneAndUpdate` or `findOneAndDelete` middleware and without application `_id` setters or overrides of the statics those reads and writes call. 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.6.0",
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.
@@ -362,6 +427,21 @@ const cloneInput = (value, type) => {
362
427
  return value;
363
428
  };
364
429
 
430
+ // Each transaction attempt works on its own copy of the request input, since hooks, validators and
431
+ // state actions may change it. The first copy is taken now, before the transaction starts, and is
432
+ // handed out once; a retry copies the request input again, which no attempt receives or changes.
433
+ const attemptInputs = (input, type) => {
434
+ let first = cloneInput(input, type);
435
+ let taken = false;
436
+ return () => {
437
+ if (taken) return cloneInput(input, type);
438
+ taken = true;
439
+ const value = first;
440
+ first = undefined;
441
+ return value;
442
+ };
443
+ };
444
+
365
445
  export const createRuntime = (adapter) => {
366
446
  if (!adapter) {
367
447
  throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
@@ -609,6 +689,10 @@ const wrapListInputType = (itemType, listShape, preserveOuterNonNull) => {
609
689
 
610
690
  const capitalize = (name) => name.charAt(0).toUpperCase() + name.slice(1);
611
691
 
692
+ // A GraphQL input object needs at least one field, so a generated input that would have none is
693
+ // left out, together with the operations, fields and mutations that would take it (#161).
694
+ const hasInputFields = (inputType) => Object.keys(inputType.getFields()).length > 0;
695
+
612
696
  /**
613
697
  * The `added` item input of a referenced collection: the item type's create input without the
614
698
  * back-reference that the parent supplies. Its fields are read from the item's create input when
@@ -649,9 +733,11 @@ const createTypeWithExcludedField = (inputNamePrefix, itemType, fieldToExclude,
649
733
  };
650
734
 
651
735
  // 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.
736
+ // the item type's inputs need not exist yet. `added` or `updated` is left out when its item input
737
+ // would have no fields, such as `added` for a child whose only writable field is the back-reference;
738
+ // `deleted` always remains. `owner` names the collection, as `Type.field`, in the warning.
653
739
  const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
654
- itemNonNull = false, optionalFields = []) => {
740
+ itemNonNull = false, optionalFields = [], owner = name) => {
655
741
  const inputTypeForAdd = connection.declaredFieldName
656
742
  ? createTypeWithExcludedField(
657
743
  inputNamePrefix,
@@ -663,17 +749,40 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
663
749
  : null;
664
750
  const listOf = (type) => new GraphQLList(itemNonNull ? new GraphQLNonNull(type) : type);
665
751
 
752
+ const omittedOperation = {
753
+ added: () => `because ${itemType.name} has no writable fields`
754
+ + `${connection.declaredFieldName ? ` besides its connectionField ${connection.declaredFieldName}` : ''}; `
755
+ + `create ${itemType.name} records with its own mutations or a registered mutation.`,
756
+ updated: () => `because ${itemType.name} has no writable fields and no writable id; update `
757
+ + `${itemType.name} records with a registered mutation.`,
758
+ };
759
+
666
760
  return new GraphQLInputObjectType({
667
761
  name,
668
- fields: () => ({
669
- added: { type: listOf(inputTypeForAdd || getInputType(itemType)) },
670
- updated: { type: listOf(typesDictForUpdate.types[itemType.name].inputType) },
762
+ fields: () => {
763
+ const fields = {};
764
+ const itemInputs = {
765
+ added: inputTypeForAdd || getInputType(itemType),
766
+ updated: typesDictForUpdate.types[itemType.name].inputType,
767
+ };
768
+ for (const [operation, itemInput] of Object.entries(itemInputs)) {
769
+ if (hasInputFields(itemInput)) {
770
+ fields[operation] = { type: listOf(itemInput) };
771
+ } else {
772
+ warnOnce(itemType, `emptyItemInput:${owner}:${operation}`, `Configuration issue: ${owner} has no `
773
+ + `\`${operation}\` operation in generated inputs, ${omittedOperation[operation]()}`);
774
+ }
775
+ }
671
776
  // Null IDs are skipped, so `deleted` accepts them whatever the collection's item nullability.
672
- deleted: { type: new GraphQLList(GraphQLID) },
673
- }),
777
+ fields.deleted = { type: new GraphQLList(GraphQLID) };
778
+ return fields;
779
+ },
674
780
  });
675
781
  };
676
782
 
783
+ // What graphQLListInputType returns for a list of an embedded type whose input has no fields.
784
+ const NO_INPUT_FIELDS = Symbol('no input fields');
785
+
677
786
  const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
678
787
  inputNamePrefix, connectionField, preserveOuterNonNull, ownerName) => {
679
788
  const listShape = getListShape(fieldEntry.type);
@@ -686,13 +795,15 @@ const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
686
795
  if (!fieldEntry.extensions?.relation?.embedded) {
687
796
  const oneToMany = createOneToManyInputType(`OneToMany${inputNamePrefix}${fieldEntryName}`,
688
797
  inputNamePrefix, itemType, normalizeConnectionField(itemType, connectionField),
689
- listShape.itemNonNull);
798
+ listShape.itemNonNull, [], `${ownerName}.${fieldEntryName}`);
690
799
  return preserveOuterNonNull && listShape.outerNonNull
691
800
  ? new GraphQLNonNull(oneToMany)
692
801
  : oneToMany;
693
802
  }
694
803
  if (registration.inputType) {
695
- return wrapListInputType(registration.inputType, listShape, preserveOuterNonNull);
804
+ return hasInputFields(registration.inputType)
805
+ ? wrapListInputType(registration.inputType, listShape, preserveOuterNonNull)
806
+ : NO_INPUT_FIELDS;
696
807
  }
697
808
  } else if (itemType instanceof GraphQLScalarType || itemType instanceof GraphQLEnumType) {
698
809
  return wrapListInputType(itemType, listShape, preserveOuterNonNull);
@@ -725,14 +836,47 @@ const selfCollectionInputType = (gqltype, fieldEntryName, fieldEntry, forUpdate,
725
836
  // optional there, as such items had no such fields before; items added by an update keep them
726
837
  // as the type declares them.
727
838
  forUpdate ? [] : selfFields,
839
+ `${gqltype.name}.${fieldEntryName}`,
728
840
  );
729
841
  return !forUpdate && listShape.outerNonNull ? new GraphQLNonNull(oneToMany) : oneToMany;
730
842
  };
731
843
 
844
+ // An embedded field, single or list, whose embedded type's create or update input has no fields is
845
+ // left out of that input of its owner. Only the create input is empty when the type's writable fields
846
+ // hold only ids, which create inputs leave out; generated updates still set the field then.
847
+ const warnEmptyEmbeddedInputs = (gqltype, fieldName, embeddedTypeName, noCreate, noUpdate) => {
848
+ if (!noCreate && !noUpdate) return;
849
+ const field = `Configuration issue: ${gqltype.name}.${fieldName} `;
850
+ if (!noUpdate) {
851
+ warnOnce(gqltype, `emptyEmbeddedInput:${fieldName}`, `${field}is left out of generated create inputs, because `
852
+ + `the create input of its embedded type ${embeddedTypeName} would have no fields (create inputs leave out `
853
+ + 'ids), so generated creates cannot set it, while generated updates still can; to set it on create, use a '
854
+ + 'controller or a registered mutation.');
855
+ return;
856
+ }
857
+ const inputs = [noCreate && 'create', 'update'].filter(Boolean).join(' and ');
858
+ warnOnce(gqltype, `emptyEmbeddedInput:${fieldName}`, `${field}is left out of generated ${inputs} inputs, because `
859
+ + `its embedded type ${embeddedTypeName} has no writable fields, so generated mutations cannot set it; set it `
860
+ + 'in a controller or a registered mutation, and mark it readOnly to state that it is output-only.');
861
+ };
862
+
863
+ // A non-null embedded field, single or list, that generated create inputs cannot set. Generated
864
+ // mutations that reach the owner's create input would store records without it, so such a schema is
865
+ // rejected; one whose generated mutations cannot reach that input only leaves the field out.
866
+ const requiredEmbeddedWithoutInputError = (gqltype, { field, embeddedTypeName }) => new SimfinityError(
867
+ `${gqltype.name}.${field} is non-null but its embedded type ${embeddedTypeName} has no writable fields, so `
868
+ + 'generated create inputs cannot set it; mark it readOnly and set it in a controller, or make it nullable',
869
+ 'INVALID_MODEL',
870
+ 400,
871
+ );
872
+ // Registration -> the non-null embedded fields its create input left out.
873
+ const requiredFieldsWithoutCreateInput = new WeakMap();
874
+
732
875
  // Builds a type's create and update inputs, or returns every field whose input cannot be built yet:
733
876
  // `{ 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) => {
877
+ // inputs the field waits for, and is absent when the field can have no generated input. `reachable`
878
+ // holds the types whose inputs the generated mutations of the schema reach.
879
+ const buildInputType = (gqltype, reachable) => {
736
880
  const argTypes = gqltype.getFields();
737
881
 
738
882
  const fieldsArgs = {};
@@ -740,6 +884,13 @@ const buildInputType = (gqltype) => {
740
884
 
741
885
  const selfReferenceCollections = {};
742
886
  const unresolvedFields = [];
887
+ const requiredWithoutCreateInput = [];
888
+ // Leaves a non-null embedded field without a create input out, or rejects the schema.
889
+ const omitRequiredEmbedded = (field, embeddedTypeName) => {
890
+ const omitted = { field, embeddedTypeName };
891
+ if (reachable.has(gqltype)) throw requiredEmbeddedWithoutInputError(gqltype, omitted);
892
+ requiredWithoutCreateInput.push(omitted);
893
+ };
743
894
 
744
895
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
745
896
  const fieldArg = {};
@@ -774,8 +925,17 @@ const buildInputType = (gqltype) => {
774
925
  } else if (requireRegisteredType(typesDict, unwrapNonNull(fieldEntry.type), fieldEntryName,
775
926
  gqltype.name).inputType
776
927
  && typesDictForUpdate.types[fieldEntryNameValue].inputType) {
777
- fieldArg.type = typesDict.types[fieldEntryNameValue].inputType;
778
- fieldArgForUpdate.type = typesDictForUpdate.types[fieldEntryNameValue].inputType;
928
+ // Create and update inputs are decided apart: an embedded type whose only writable
929
+ // field is `id` has one only for updates.
930
+ const embeddedCreate = typesDict.types[fieldEntryNameValue].inputType;
931
+ const embeddedUpdate = typesDictForUpdate.types[fieldEntryNameValue].inputType;
932
+ if (hasInputFields(embeddedCreate)) fieldArg.type = embeddedCreate;
933
+ else if (fieldEntry.type instanceof GraphQLNonNull) {
934
+ omitRequiredEmbedded(fieldEntryName, fieldEntryNameValue);
935
+ }
936
+ if (hasInputFields(embeddedUpdate)) fieldArgForUpdate.type = embeddedUpdate;
937
+ warnEmptyEmbeddedInputs(gqltype, fieldEntryName, fieldEntryNameValue,
938
+ !fieldArg.type, !fieldArgForUpdate.type);
779
939
  } else {
780
940
  unresolvedFields.push({ field: fieldEntryName, waitsFor: fieldEntryNameValue });
781
941
  }
@@ -802,8 +962,11 @@ const buildInputType = (gqltype) => {
802
962
  fieldEntryName, `${gqltype.name}U`,
803
963
  fieldEntry.extensions?.relation?.connectionField, false, gqltype.name);
804
964
  if (listInputTypeForAdd && listInputTypeForUpdate) {
805
- fieldArg.type = listInputTypeForAdd;
806
- fieldArgForUpdate.type = listInputTypeForUpdate;
965
+ if (listInputTypeForAdd !== NO_INPUT_FIELDS) fieldArg.type = listInputTypeForAdd;
966
+ else if (listShape.outerNonNull) omitRequiredEmbedded(fieldEntryName, listShape.itemType.name);
967
+ if (listInputTypeForUpdate !== NO_INPUT_FIELDS) fieldArgForUpdate.type = listInputTypeForUpdate;
968
+ warnEmptyEmbeddedInputs(gqltype, fieldEntryName, listShape.itemType.name,
969
+ listInputTypeForAdd === NO_INPUT_FIELDS, listInputTypeForUpdate === NO_INPUT_FIELDS);
807
970
  } else {
808
971
  // An object item here is embedded and its inputs are not built yet; any other item,
809
972
  // such as a list, has no generated input.
@@ -858,7 +1021,9 @@ const buildInputType = (gqltype) => {
858
1021
  fields: fieldsArgForUpdate,
859
1022
  });
860
1023
 
861
- return { inputTypeBody: inputTypeForAdd, inputTypeBodyForUpdate: inputTypeForUpdate };
1024
+ return {
1025
+ inputTypeBody: inputTypeForAdd, inputTypeBodyForUpdate: inputTypeForUpdate, requiredWithoutCreateInput,
1026
+ };
862
1027
  };
863
1028
 
864
1029
  const getInputType = (type) => typesDict.types[type.name].inputType;
@@ -964,6 +1129,14 @@ const unresolvedInputTypesMessage = (pendingTypeNames, unresolvedByType) => {
964
1129
 
965
1130
  const buildPendingInputTypes = (waitingForInputType, includedMutationTypes) => {
966
1131
  claimSelfCollectionNames(waitingForInputType, includedMutationTypes);
1132
+ // Inputs built for an earlier schema, which did not reach them, may leave out a non-null embedded
1133
+ // field that this schema's generated mutations would need; inputs built now are checked as built.
1134
+ const reachable = typesWithReachableInputs(includedMutationTypes);
1135
+ for (const gqltype of reachable) {
1136
+ const registration = typesDict.types[gqltype.name];
1137
+ const [omitted] = (registration.inputType && requiredFieldsWithoutCreateInput.get(registration)) || [];
1138
+ if (omitted) throw requiredEmbeddedWithoutInputError(gqltype, omitted);
1139
+ }
967
1140
  let pending = waitingForInputType;
968
1141
  let previousPendingCount = Object.keys(pending).length + 1;
969
1142
  // Type name -> the fields whose input could not be built in the last pass.
@@ -983,10 +1156,11 @@ const buildPendingInputTypes = (waitingForInputType, includedMutationTypes) => {
983
1156
  const { gqltype } = value;
984
1157
  if (typesDict.types[gqltype.name].inputType) continue;
985
1158
 
986
- const result = buildInputType(gqltype);
1159
+ const result = buildInputType(gqltype, reachable);
987
1160
  if (result.inputTypeBody && result.inputTypeBodyForUpdate) {
988
1161
  typesDict.types[gqltype.name].inputType = result.inputTypeBody;
989
1162
  typesDictForUpdate.types[gqltype.name].inputType = result.inputTypeBodyForUpdate;
1163
+ requiredFieldsWithoutCreateInput.set(typesDict.types[gqltype.name], result.requiredWithoutCreateInput);
990
1164
  } else {
991
1165
  stillWaiting[key] = value;
992
1166
  unresolvedFields[gqltype.name] = result.unresolvedFields;
@@ -1087,10 +1261,10 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
1087
1261
  };
1088
1262
 
1089
1263
  const executeRegisteredMutation = (args, callback, context, session, inputModel) => {
1090
- const inputSnapshot = cloneInput(args, inputModel);
1264
+ const nextInput = attemptInputs(args, inputModel);
1091
1265
  return adapter.withTransaction(
1092
1266
  session,
1093
- (mySession) => callback(cloneInput(inputSnapshot, inputModel), mySession, context),
1267
+ (mySession) => callback(nextInput(), mySession, context),
1094
1268
  );
1095
1269
  };
1096
1270
 
@@ -1111,10 +1285,23 @@ const iterateOnCollectionFields = async (materializedModel, gqltype, objectId, s
1111
1285
  }
1112
1286
  };
1113
1287
 
1114
- const onDelete = async (Model, controller, id, session, context) => {
1115
- const currentObject = await adapter.getById(Model, id, session, { plain: true });
1288
+ // Whether the runtime may skip a read by ID whose result it does not use: the adapter reports that
1289
+ // such a read of the model has no side effects, such as query middleware, and fails only where the
1290
+ // update or delete by that ID fails too. Without the report, those reads run as before.
1291
+ const readByIdIsInert = (Model) => typeof adapter.readByIdIsInert === 'function'
1292
+ && adapter.readByIdIsInert(Model) === true;
1116
1293
 
1117
- if (controller && controller.onDelete) {
1294
+ // The record is read for the onDelete hook; adapter.delete casts the ID, deletes and returns the
1295
+ // record itself. Without a hook, the read is skipped when it is inert. A nested delete passes the
1296
+ // child it has already read as `current`.
1297
+ const onDelete = async (Model, controller, id, session, context, current) => {
1298
+ const hasHook = !!(controller && controller.onDelete);
1299
+ let currentObject = current;
1300
+ if (currentObject === undefined && (hasHook || !readByIdIsInert(Model))) {
1301
+ currentObject = await adapter.getById(Model, id, session, { plain: true });
1302
+ }
1303
+
1304
+ if (hasHook) {
1118
1305
  await controller.onDelete(currentObject, session, context);
1119
1306
  }
1120
1307
 
@@ -1187,13 +1374,35 @@ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
1187
1374
  }
1188
1375
  };
1189
1376
 
1190
- const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
1377
+ // Whether an update reads the stored embedded values of a type with embedded fields. They are used
1378
+ // to merge a singular embedded object, and a non-empty embedded list is completed only for an
1379
+ // existing record; empty lists and nulls use nothing. A top-level update also keeps the read before
1380
+ // onUpdating, so that a malformed ID fails before the hook, and before nested collection items, so
1381
+ // that the SQL adapter locks the parent row before child writes; otherwise it skips the read only
1382
+ // when it is inert. A state action or a nested update has already read and checked the record
1383
+ // (`recordChecked`).
1384
+ const readsStoredEmbedded = (Model, controller, materializedModel, embeddedFieldNames, recordChecked) => {
1385
+ const usesStored = embeddedFieldNames.some((name) => {
1386
+ const value = ownValue(materializedModel.modelArgs, name);
1387
+ return value && (!Array.isArray(value) || value.length > 0);
1388
+ });
1389
+ if (usesStored) return true;
1390
+ if (recordChecked) return false;
1391
+ return !!(controller && controller.onUpdating)
1392
+ || Object.values(materializedModel.collectionFields).some((field) => field
1393
+ && ['added', 'updated', 'deleted'].some((key) => field[key] && hasNestedItems(field[key])))
1394
+ || !readByIdIsInert(Model);
1395
+ };
1396
+
1397
+ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context,
1398
+ recordChecked = false) => {
1191
1399
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
1192
1400
  const objectId = args.id;
1193
1401
  const argTypes = gqltype.getFields();
1194
1402
  const embeddedFieldNames = getEmbeddedFieldNames(gqltype);
1195
1403
 
1196
- if (embeddedFieldNames.length > 0) {
1404
+ if (embeddedFieldNames.length > 0
1405
+ && readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked)) {
1197
1406
  const projection = Object.fromEntries(embeddedFieldNames.map((name) => [name, 1]));
1198
1407
  const currentObject = await adapter.getById(Model, objectId, session, {
1199
1408
  projection,
@@ -1206,7 +1415,6 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1206
1415
  const newObjectData = ownValue(materializedModel.modelArgs, fieldEntryName);
1207
1416
  if (newObjectData) {
1208
1417
  if (Array.isArray(newObjectData)) {
1209
- materializedModel.modelArgs[fieldEntryName] = newObjectData;
1210
1418
  completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName], newObjectData);
1211
1419
  } else {
1212
1420
  materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
@@ -1268,7 +1476,7 @@ const onStateChanged = async (Model, gqltype, controller, args, session, actionF
1268
1476
  }
1269
1477
 
1270
1478
  args.state = stateValue(actionField.to);
1271
- let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context);
1479
+ let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context, true);
1272
1480
  result = adapter.toObject(result);
1273
1481
  result.state = actionField.to.value;
1274
1482
  return result;
@@ -1384,13 +1592,11 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
1384
1592
  const limit = getMaxNestedOperations();
1385
1593
  if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
1386
1594
  if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
1387
- const inputSnapshot = operation === operations.DELETE ? args : cloneInput(args, inputType);
1595
+ const nextInput = operation === operations.DELETE ? () => args : attemptInputs(args, inputType);
1388
1596
  return adapter.withTransaction(
1389
1597
  session,
1390
1598
  async (mySession) => {
1391
- const attemptArgs = operation === operations.DELETE
1392
- ? inputSnapshot
1393
- : cloneInput(inputSnapshot, inputType);
1599
+ const attemptArgs = nextInput();
1394
1600
  switch (operation) {
1395
1601
  case operations.SAVE:
1396
1602
  return onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
@@ -1435,9 +1641,12 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1435
1641
  const args = operationType === operations.DELETE ? { id: element } : { input: element };
1436
1642
  await executeMiddleware({ type, args, operation: operationType, context });
1437
1643
 
1644
+ // The ownership check reads the child (the SQL adapter locks its row). onDelete receives this
1645
+ // record, and an update reads the child again only to merge embedded values.
1646
+ let child;
1438
1647
  if (operationType === operations.UPDATE || operationType === operations.DELETE) {
1439
1648
  const id = operationType === operations.DELETE ? args.id : args.input.id;
1440
- const child = await adapter.getById(type.model, id, session, { plain: true, lock: true });
1649
+ child = await adapter.getById(type.model, id, session, { plain: true, lock: true });
1441
1650
  if (!child) {
1442
1651
  throw new SimfinityError(`${collectionGQLType.name} ${id} is not valid`, 'NOT_VALID_ID', 404);
1443
1652
  }
@@ -1453,10 +1662,10 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
1453
1662
  break;
1454
1663
  case operations.UPDATE:
1455
1664
  await onUpdateSubject(type.model, collectionGQLType, type.controller,
1456
- args.input, session, linkToParent, context);
1665
+ args.input, session, linkToParent, context, true);
1457
1666
  break;
1458
1667
  case operations.DELETE:
1459
- await onDelete(type.model, type.controller, args.id, session, context);
1668
+ await onDelete(type.model, type.controller, args.id, session, context, child);
1460
1669
  }
1461
1670
  }
1462
1671
  };
@@ -1707,9 +1916,22 @@ const resolveById = async (type, args, context, requiredId) => {
1707
1916
  return results[0] || null;
1708
1917
  };
1709
1918
 
1919
+ // Why a type's generated create or update input has no fields, for the warnings of buildMutation. An
1920
+ // update input also needs a writable id.
1921
+ const withoutWritableFields = (type, { noWritableId = false } = {}) => {
1922
+ const stateField = type.gqltype.getFields().state;
1923
+ const managedState = type.stateMachine && stateField && !stateField.extensions?.readOnly;
1924
+ const state = managedState ? ' besides its state, which its state machine manages' : '';
1925
+ const id = noWritableId ? `${state ? ',' : ''} and no writable id` : '';
1926
+ return `${type.gqltype.name} has no writable fields${state}${id}`;
1927
+ };
1928
+
1710
1929
  // Every generated mutation field carries extensions.simfinityMutation with its operation (and, for
1711
1930
  // a state-machine action, the action and its from/to states), so tools such as the MCP package can
1712
1931
  // classify it without guessing from its name. Extensions do not appear in SDL or introspection.
1932
+ // An input object needs a field, so a type whose create input has none gets no add mutation, and
1933
+ // one whose update input has none, which only a type without a writable id can have, gets no update
1934
+ // mutation or state machine actions either (#161).
1713
1935
  const buildMutation = (name, includedMutationTypes, includedCustomMutations) => {
1714
1936
  const rootQueryArgs = {};
1715
1937
  rootQueryArgs.name = name;
@@ -1722,24 +1944,33 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1722
1944
  if (type.endpoint) {
1723
1945
  const argsObject = { input: { type: new GraphQLNonNull(type.inputType) } };
1724
1946
 
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
- };
1947
+ if (hasInputFields(type.inputType)) {
1948
+ rootQueryArgs.fields[`add${type.simpleEntityEndpointName}`] = {
1949
+ type: type.gqltype,
1950
+ description: 'add',
1951
+ args: argsObject,
1952
+ extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.SAVE } },
1953
+ async resolve(parent, args, context) {
1954
+ const params = {
1955
+ type,
1956
+ args,
1957
+ operation: operations.SAVE,
1958
+ context,
1959
+ };
1737
1960
 
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
- };
1961
+ await executeMiddleware(params);
1962
+ return executeOperation(type.model, type.gqltype, type.controller,
1963
+ args.input, operations.SAVE, null, null, context, type.inputType);
1964
+ },
1965
+ };
1966
+ } else {
1967
+ warnOnce(type.gqltype, 'emptyCreateInput', `Configuration issue: ${withoutWritableFields(type)}, so the `
1968
+ + `schema has no add${type.simpleEntityEndpointName} mutation; create its records with a registered `
1969
+ + (type.stateMachine
1970
+ ? 'mutation, which must set the initial state (saveObject() sets it).'
1971
+ : `mutation that takes no input or an input of its own, since getInputType(${type.gqltype.name}) `
1972
+ + 'has no fields.'));
1973
+ }
1743
1974
  rootQueryArgs.fields[`delete${type.simpleEntityEndpointName}`] = {
1744
1975
  type: type.gqltype,
1745
1976
  description: 'delete',
@@ -1764,7 +1995,12 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1764
1995
 
1765
1996
  for (const type of Object.values(typesDictForUpdate.types)) {
1766
1997
  if (!shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)) {
1767
- if (type.endpoint) {
1998
+ if (type.endpoint && !hasInputFields(type.inputType)) {
1999
+ const reason = withoutWritableFields(type, { noWritableId: true });
2000
+ warnOnce(type.gqltype, 'emptyUpdateInput', `Configuration issue: ${reason}, so its generated update input `
2001
+ + `would have no fields and the schema has no update${type.simpleEntityEndpointName} mutation`
2002
+ + `${type.stateMachine ? ' and no state machine actions' : ''}.`);
2003
+ } else if (type.endpoint) {
1768
2004
  const argsObject = { input: { type: new GraphQLNonNull(type.inputType) } };
1769
2005
  rootQueryArgs.fields[`update${type.simpleEntityEndpointName}`] = {
1770
2006
  type: type.gqltype,
@@ -1967,6 +2203,102 @@ const installIdResolver = (gqltype) => {
1967
2203
  if (idField && !idField.resolve) idField.resolve = markGenerated((parent) => parent._id ?? parent.id);
1968
2204
  };
1969
2205
 
2206
+ // A rendered embedded value that may hold no data: a plain object, or a hydrated record, which
2207
+ // converts itself with toObject() (MongoDB documents and their nested paths do). Promises, arrays,
2208
+ // functions and other objects, such as dates or identifiers, are values.
2209
+ const isRecordObject = (value) => {
2210
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || isThenable(value)) return false;
2211
+ const prototype = Object.getPrototypeOf(value);
2212
+ return prototype === Object.prototype || prototype === null || typeof value.toObject === 'function';
2213
+ };
2214
+ const isPlainObject = (value) => {
2215
+ const prototype = Object.getPrototypeOf(value);
2216
+ return prototype === Object.prototype || prototype === null;
2217
+ };
2218
+
2219
+ // The members an embedded value stores. The rule below reads them instead of the value, so no getter
2220
+ // or virtual of the application runs, whatever the selection. An adapter that hydrates records
2221
+ // returns their stored data through rawEmbeddedValue (MongoDB: a nested path's raw value, a
2222
+ // subdocument's _doc); an absent stored value has no members. A plain object is read as it is.
2223
+ // Returns null for anything else, such as a record the adapter does not convert, which counts as data.
2224
+ const storedMembersOf = (value) => {
2225
+ if (!isRecordObject(value)) return null;
2226
+ const stored = typeof adapter.rawEmbeddedValue === 'function' ? adapter.rawEmbeddedValue(value) : value;
2227
+ if (stored === null || stored === undefined) return {};
2228
+ return typeof stored === 'object' && !Array.isArray(stored) && !isThenable(stored) && isPlainObject(stored)
2229
+ ? stored : null;
2230
+ };
2231
+
2232
+ // A stored member: an own data property, read without calling accessors. An accessor is present,
2233
+ // whatever it would return.
2234
+ const PRESENT = Symbol('present');
2235
+ const ownStoredValue = (stored, key) => {
2236
+ const descriptor = Object.getOwnPropertyDescriptor(stored, key);
2237
+ if (!descriptor) return undefined;
2238
+ if (!('value' in descriptor)) return PRESENT;
2239
+ return isObjectMember(key, descriptor.value) ? undefined : descriptor.value;
2240
+ };
2241
+ // A referenced collection stores nothing in the object. `id` reads the stored identity, `_id` before
2242
+ // `id`, as the generated entity resolver (installIdResolver) and Mongoose's id virtual do. Every other
2243
+ // member is stored under its storage name.
2244
+ const isReferencedCollection = (member) => {
2245
+ const relation = member.extensions?.relation;
2246
+ return Boolean(relation && !relation.embedded && getListShape(member.type));
2247
+ };
2248
+ const storedMemberValue = (stored, memberName, member) => {
2249
+ if (isReferencedCollection(member)) return undefined;
2250
+ const identity = memberName === 'id' ? ownStoredValue(stored, '_id') : undefined;
2251
+ return identity ?? ownStoredValue(stored, getFieldStorageName(memberName, member));
2252
+ };
2253
+
2254
+ // Whether stored members hold no data: every member is null or undefined, an empty list, or a
2255
+ // singular embedded object that holds no data. Any other value, a promise or an accessor included,
2256
+ // is data. An object that contains itself, which only a custom result can, holds data.
2257
+ const holdsNoData = (stored, type, ancestors = []) => !ancestors.includes(stored)
2258
+ && Object.entries(type.getFields()).every(([memberName, member]) => {
2259
+ const value = storedMemberValue(stored, memberName, member);
2260
+ if (value === null || value === undefined) return true;
2261
+ if (Array.isArray(value)) return value.length === 0;
2262
+ const memberType = unwrapNonNull(member.type);
2263
+ if (member.extensions?.relation?.embedded !== true || !(memberType instanceof GraphQLObjectType)) return false;
2264
+ const nested = storedMembersOf(value);
2265
+ return nested !== null && holdsNoData(nested, memberType, [...ancestors, stored]);
2266
+ });
2267
+
2268
+ // The members that count: non-null members that generated inputs accept. As on writes
2269
+ // (completeEmbeddedValue), `id` and readOnly members do not; nor do interface and union members,
2270
+ // which buildInputType leaves out; nor do referenced collections or members with an application
2271
+ // resolver, which may compute a value.
2272
+ const countsAsRequired = (type, memberName, member) => member.type instanceof GraphQLNonNull
2273
+ && !isReferencedCollection(member) && memberName !== 'id' && !member.extensions?.readOnly
2274
+ && !isAbstractOutputType(unwrapListAndNonNull(member.type)) && !hasApplicationResolver(type, memberName);
2275
+ const hasRequiredMember = (type) => Object.entries(type.getFields())
2276
+ .some(([memberName, member]) => countsAsRequired(type, memberName, member));
2277
+
2278
+ // Whether rendering such an object fails: a member that counts is null or undefined, or is a singular
2279
+ // embedded object that misses one of its own. A promise or an accessor counts as present.
2280
+ const missesRequiredMember = (stored, type) => Object.entries(type.getFields()).some(([memberName, member]) => {
2281
+ if (!countsAsRequired(type, memberName, member)) return false;
2282
+ const value = storedMemberValue(stored, memberName, member);
2283
+ if (value === null || value === undefined) return true;
2284
+ if (member.extensions?.relation?.embedded !== true || !(member.type.ofType instanceof GraphQLObjectType)) return false;
2285
+ const nested = storedMembersOf(value);
2286
+ return nested !== null && missesRequiredMember(nested, member.type.ofType);
2287
+ });
2288
+
2289
+ // A type without a member that counts never reads as null, so its values are returned untouched,
2290
+ // with no member read. Reading stored members never changes the result otherwise: a value whose
2291
+ // members cannot be read, because an adapter hook or a property read throws, is returned as it is.
2292
+ const readEmbeddedObject = (value, type) => {
2293
+ if (!(type instanceof GraphQLObjectType) || !hasRequiredMember(type)) return value;
2294
+ try {
2295
+ const stored = storedMembersOf(value);
2296
+ return stored !== null && holdsNoData(stored, type) && missesRequiredMember(stored, type) ? null : value;
2297
+ } catch {
2298
+ return value;
2299
+ }
2300
+ };
2301
+
1970
2302
  // An adapter that hydrates stored records may render an explicitly null singular embedded object as
1971
2303
  // an object (MongoDB nested paths do); readEmbeddedValue returns null for it. Nullable singular
1972
2304
  // embedded fields of registered types, and of the embedded types they reach, read through it. The
@@ -1975,11 +2307,24 @@ const installIdResolver = (gqltype) => {
1975
2307
  // The value is read as graphql's default resolver reads it, so a method on the parent, such as one a
1976
2308
  // custom mutation result supplies, is still called; a promise it returns is read once it settles.
1977
2309
  // Only a field named like an Object.prototype member that reads as that member reads as null.
2310
+ // Such an adapter may also render an absent object, or store an omitted one, as an object that holds
2311
+ // nothing but its list defaults (MongoDB nested paths do). An object that holds no data and misses a
2312
+ // required member (see missesRequiredMember) cannot be rendered, so it reads as null, as an absent
2313
+ // object does on other read paths, whatever the selection. The rule reads the stored members
2314
+ // (storedMembersOf), never running a getter or virtual, and depends only on the value and its type,
2315
+ // so runtimes that share the type, with or without the hook, read the same. An object a
2316
+ // generated create or update writes does not read as null, since those writes require the members
2317
+ // counted, with one exception: Mongoose minimizes empty objects when it saves a new document, so a
2318
+ // create that writes an empty object for a required embedded member (`geo: {}`) stores that member
2319
+ // as absent. An object that holds nothing else then reads as null, as an omitted one does, except
2320
+ // where Mongoose hydrates the document (by-ID and reference reads, update responses) and the member is
2321
+ // a nested path, which it renders as `{}`. Generated models declare every embedded member as a nested
2322
+ // path except one named `type`, a single nested subdocument. PostgreSQL rejects such a create
2323
+ // (REQUIRED_VALUE).
1978
2324
  const readEmbeddedField = markGenerated((parent, args, context, info) => {
1979
2325
  const value = readOwnField(parent, args, context, info);
1980
- return isThenable(value)
1981
- ? value.then((resolved) => adapter.readEmbeddedValue(resolved))
1982
- : adapter.readEmbeddedValue(value);
2326
+ const read = (resolved) => readEmbeddedObject(adapter.readEmbeddedValue(resolved), getNamedType(info.returnType));
2327
+ return isThenable(value) ? value.then(read) : read(value);
1983
2328
  });
1984
2329
 
1985
2330
  const installEmbeddedValueResolvers = (gqltype, visited) => {
@@ -2042,6 +2387,20 @@ const assertCollectionConnections = () => {
2042
2387
 
2043
2388
  const getRegistrations = () => Object.values(typesDict.types);
2044
2389
 
2390
+ // Application types that a schema with these allowlists contains, whatever else it reaches: the
2391
+ // endpoint types of its queries and mutations, and the input and output types of its registered
2392
+ // mutations.
2393
+ const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => [
2394
+ ...Object.values(typesDict.types)
2395
+ .filter((type) => type.endpoint
2396
+ && ((type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
2397
+ || !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)))
2398
+ .map((type) => type.gqltype),
2399
+ ...Object.entries(registeredMutations)
2400
+ .filter(([entry]) => !shouldNotBeIncludedInSchema(includedCustomMutations, entry))
2401
+ .flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean)),
2402
+ ];
2403
+
2045
2404
  const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => {
2046
2405
  // Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
2047
2406
  Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
@@ -2049,6 +2408,11 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2049
2408
  schemaTypes.forEach(assertNotBoundElsewhere);
2050
2409
  schemaTypes.forEach(assertFieldsNotBoundElsewhere);
2051
2410
  assertCollectionConnections();
2411
+ // An application type with the name of a field metadata type that an earlier schema fixed; one that
2412
+ // only generated types or the fields of an input object reach is found when the schema is
2413
+ // constructed.
2414
+ assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes,
2415
+ includedCustomMutations));
2052
2416
  const referencedTypes = markReferencedTypesForModelGeneration();
2053
2417
  // Entities, which keep their own stored identity: connect() types and relation targets.
2054
2418
  Object.values(typesDict.types).forEach((typeInfo) => {
@@ -2110,7 +2474,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2110
2474
  query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON));
2111
2475
  }
2112
2476
 
2113
- const schema = new GraphQLSchema({ query, mutation });
2477
+ const schema = constructSchema(query, mutation);
2114
2478
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
2115
2479
  // custom mutation result, so another runtime cannot later generate resolvers this schema executes.
2116
2480
  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,11 +295,44 @@ 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;
326
+ /**
327
+ * Optional. Return true when a `getById` of `model` has no side effects (no query middleware or
328
+ * hooks that guard, count or change reads) and cannot fail where `update` or `delete` by the same
329
+ * ID would succeed: both reject a malformed ID with `NOT_VALID_ID` (400) as the read does, and
330
+ * return null for a missing record. The runtime may then skip a read whose result it does not use:
331
+ * the read before a delete without an `onDelete` hook, and, for a type with embedded fields, the
332
+ * read before an update that merges no embedded value and has neither `onUpdating` nor nested
333
+ * collection items. When it is absent or returns false, those reads run.
334
+ */
335
+ readByIdIsInert?(model: Model): boolean;
284
336
  prepareUpdate(set: Record<string, any>, unset: Record<string, string>): any;
285
337
  update(model: Model, id: any, changes: any, session?: Session): any;
286
338
  delete(model: Model, id: any, session?: Session): any;
@@ -430,11 +482,19 @@ export interface AuthPluginOptions {
430
482
  debug?: boolean;
431
483
  }
432
484
 
433
- declare class UnauthenticatedError extends SimfinityError {
485
+ /**
486
+ * Authentication error: code `UNAUTHENTICATED`, status 401, default message `Authentication required`.
487
+ * The same class as `auth.UnauthenticatedError` and the `/auth` and `/auth/errors` subpath exports.
488
+ */
489
+ export class UnauthenticatedError extends SimfinityError {
434
490
  constructor(message?: string);
435
491
  }
436
492
 
437
- declare class ForbiddenError extends SimfinityError {
493
+ /**
494
+ * Authorization error: code `FORBIDDEN`, status 403, default message `Access denied`.
495
+ * The same class as `auth.ForbiddenError` and the `/auth` and `/auth/errors` subpath exports.
496
+ */
497
+ export class ForbiddenError extends SimfinityError {
438
498
  constructor(message?: string);
439
499
  }
440
500
 
@@ -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