@simtlix/simfinity-core 3.5.3 → 3.5.6

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,11 +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 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`, since the client did not send the value. 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.
20
+
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()`.
20
22
 
21
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.
22
24
 
23
- 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`).
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.
24
26
 
25
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.
26
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.5.3",
3
+ "version": "3.5.6",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -15,6 +15,7 @@
15
15
  "./auth/expressions": "./src/auth/expressions.js",
16
16
  "./auth/rules": "./src/auth/rules.js",
17
17
  "./internal/object-id": "./src/auth/object-id.js",
18
+ "./internal/relation-storage": "./src/relation-storage.js",
18
19
  "./plugins": "./src/plugins.js",
19
20
  "./scalars": "./src/scalars.js",
20
21
  "./validators": "./src/validators.js"
package/src/query-plan.js CHANGED
@@ -4,12 +4,17 @@ import SimfinityError from './errors/simfinity.error.js';
4
4
  const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
5
5
  const operators = new Set(['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE']);
6
6
  const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
7
+ // `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
8
+ // that name is filtered like any other.
9
+ const listReserved = new Set(['AND', 'OR', 'sort', 'pagination']);
10
+ const controlArgs = (operation) => (operation === 'aggregate' ? reserved : listReserved);
7
11
  const listOf = (value) => (Array.isArray(value) ? value : []);
8
12
 
9
13
  /**
10
14
  * Internal: the model paths named by generated query arguments, as `{ segments, group }` entries.
11
15
  * Covers filter keys and relation terms, AND/OR conditions, list sort terms, and aggregation
12
- * groupId and fact paths; aggregate sort terms name result keys. `group` is the AND/OR group
16
+ * groupId and fact paths; aggregate sort terms name result keys. Outside aggregate mode a key
17
+ * named `aggregation` is a field filter like any other. `group` is the AND/OR group
13
18
  * object whose `conditions` hold the path, or null for the other forms. Malformed entries are
14
19
  * left to the backends.
15
20
  */
@@ -27,8 +32,9 @@ export const collectQueryPathEntries = (args, operation = 'find') => {
27
32
  }
28
33
  for (const item of [...listOf(group.AND), ...listOf(group.OR)]) visit(item);
29
34
  };
35
+ const controls = controlArgs(operation);
30
36
  for (const [name, value] of Object.entries(args)) {
31
- if (reserved.has(name) || value == null) continue;
37
+ if (controls.has(name) || value == null) continue;
32
38
  const terms = listOf(value.terms).filter((term) => typeof term?.path === 'string' && term.path);
33
39
  if (terms.length) for (const term of terms) add(`${name}.${term.path}`);
34
40
  else add(name);
@@ -100,9 +106,9 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
100
106
  if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
101
107
  return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
102
108
  };
103
- const validateGroup = (value) => {
109
+ const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
104
110
  if (!value || typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter group object');
105
- for (const key of ['conditions', 'AND', 'OR']) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
111
+ for (const key of keys) if (value[key] != null && !Array.isArray(value[key])) fail(`${key} requires an array`);
106
112
  };
107
113
  const group = (value, depth = 0) => {
108
114
  validateGroup(value);
@@ -117,10 +123,12 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
117
123
  terms.push(combine('or', (value.OR || []).map((item) => group(item, depth + 1))));
118
124
  return combine('and', terms);
119
125
  };
120
- validateGroup(input);
126
+ // Top-level arguments are field filters plus AND/OR; `conditions` only lists conditions inside groups.
127
+ validateGroup(input, ['AND', 'OR']);
128
+ const controls = controlArgs(mode);
121
129
  const terms = [];
122
130
  for (const [name, value] of Object.entries(input)) {
123
- if (reserved.has(name) || value == null) continue;
131
+ if (controls.has(name) || value == null) continue;
124
132
  const field = resolveModelPath(models, entityName, name).at(-1);
125
133
  if (typeof value !== 'object' || Array.isArray(value)) fail('Expected a filter object');
126
134
  if (field.kind !== 'scalar' && !field.inferred && (!Array.isArray(value.terms) || !value.terms.length)) fail('Object filters require non-empty terms', 'MISSING_FILTER_PATH');
@@ -0,0 +1,58 @@
1
+ import { GraphQLList, GraphQLNonNull } from 'graphql';
2
+
3
+ // Internal helpers that decide where a relation stores its link and which types keep a stored
4
+ // identity. They are shared by the core runtime and the MongoDB adapter through the
5
+ // `./internal/relation-storage` subpath, which is not public API.
6
+
7
+ const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
8
+
9
+ export const getListShape = (type) => {
10
+ const outerNonNull = type instanceof GraphQLNonNull;
11
+ const listType = unwrapNonNull(type);
12
+ if (!(listType instanceof GraphQLList)) return null;
13
+ const itemNonNull = listType.ofType instanceof GraphQLNonNull;
14
+ return {
15
+ outerNonNull,
16
+ itemNonNull,
17
+ itemType: unwrapNonNull(listType.ofType),
18
+ };
19
+ };
20
+
21
+ export const getFieldStorageName = (fieldName, field) => {
22
+ const relation = field.extensions?.relation;
23
+ return relation && !relation.embedded && !getListShape(field.type)
24
+ ? relation.connectionField || fieldName
25
+ : fieldName;
26
+ };
27
+
28
+ // Entity types, which keep their own stored identity. The generated id resolver lives on the type
29
+ // object, which runtimes may share, so the identity rule is per type object, not per runtime
30
+ // registration: a store path for `id` follows the resolver any runtime installed on the type.
31
+ const storedIdentityTypes = new WeakSet();
32
+
33
+ export const markStoredIdentity = (gqltype) => {
34
+ storedIdentityTypes.add(gqltype);
35
+ };
36
+
37
+ export const hasStoredIdentity = (gqltype) => storedIdentityTypes.has(gqltype);
38
+
39
+ export const normalizeConnectionField = (childType, declaredFieldName) => {
40
+ if (!declaredFieldName) {
41
+ return { declaredFieldName, graphqlFieldName: null, storageFieldName: null };
42
+ }
43
+ const childFields = childType.getFields();
44
+ let graphqlFieldName = childFields[declaredFieldName] ? declaredFieldName : null;
45
+ if (!graphqlFieldName) {
46
+ const aliasedField = Object.entries(childFields).find(([fieldName, field]) => (
47
+ getFieldStorageName(fieldName, field) === declaredFieldName
48
+ ));
49
+ graphqlFieldName = aliasedField?.[0] || null;
50
+ }
51
+ return {
52
+ declaredFieldName,
53
+ graphqlFieldName,
54
+ storageFieldName: graphqlFieldName
55
+ ? getFieldStorageName(graphqlFieldName, childFields[graphqlFieldName])
56
+ : declaredFieldName,
57
+ };
58
+ };
package/src/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { configureQueryLimits, getQueryMaxPageSize } from './query-limits.js';
1
+ import { configureQueryLimits, getQueryMaxPageSize, paginationStages } from './query-limits.js';
2
2
  import { configureMutationLimits, getMaxNestedOperations } from './mutation-limits.js';
3
3
  import {
4
4
  GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
@@ -14,6 +14,10 @@ import QLValue from './const/QLValue.js';
14
14
  import QLSort from './const/QLSort.js';
15
15
  import { collectQueryPathEntries, collectQueryPaths, walkQueryPath } from './query-plan.js';
16
16
  import { relationFieldOwners } from './relation-owners.js';
17
+ import { isThenable } from './auth/thenable.js';
18
+ import {
19
+ getFieldStorageName, getListShape, markStoredIdentity, normalizeConnectionField,
20
+ } from './relation-storage.js';
17
21
  import './introspection.js';
18
22
 
19
23
  // Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
@@ -78,7 +82,26 @@ const requireRegisteredType = (dict, type, fieldName, ownerName) => {
78
82
  }
79
83
  return registration;
80
84
  };
81
- const SCOPE_IGNORED_ARGS = new Set(['sort', 'pagination', 'aggregation']);
85
+ // A joined scope is a find scope, so `aggregation` is a field filter there, as on list queries.
86
+ const SCOPE_IGNORED_ARGS = new Set(['sort', 'pagination']);
87
+
88
+ // A joined scope cannot sort or paginate the records it restricts. Its sort and pagination values are
89
+ // checked for shape only (INVALID_SORT: non-empty terms with a field and an ASC or DESC order;
90
+ // INVALID_PAGINATION: as on direct queries), then ignored; sort field names are not resolved.
91
+ const assertScopeControlArg = (name, value) => {
92
+ if (name === 'pagination') {
93
+ paginationStages(value, false);
94
+ return;
95
+ }
96
+ if (!Array.isArray(value.terms) || !value.terms.length) {
97
+ throw new SimfinityError('Sort requires non-empty terms', 'INVALID_SORT', 400);
98
+ }
99
+ for (const term of value.terms) {
100
+ if (typeof term?.field !== 'string' || !['ASC', 'DESC'].includes(term.order)) {
101
+ throw new SimfinityError('Sort terms require a field and an ASC or DESC order', 'INVALID_SORT', 400);
102
+ }
103
+ }
104
+ };
82
105
 
83
106
  // Moves a joined type's scope group below the relation path that entered it.
84
107
  const prefixScopeGroup = (group, prefix) => {
@@ -102,7 +125,11 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
102
125
  const conditions = [];
103
126
  const groups = [];
104
127
  for (const [name, value] of Object.entries(scopeArgs)) {
105
- if (value == null || SCOPE_IGNORED_ARGS.has(name)) continue;
128
+ if (value == null) continue;
129
+ if (SCOPE_IGNORED_ARGS.has(name)) {
130
+ assertScopeControlArg(name, value);
131
+ continue;
132
+ }
106
133
  if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix)));
107
134
  else if (name === 'OR') {
108
135
  const branches = [].concat(value);
@@ -237,6 +264,29 @@ export const buildErrorFormatter = (callback) => (error) => {
237
264
 
238
265
  export { SimfinityError, InternalServerError };
239
266
 
267
+ // A field named like an Object.prototype member (`constructor`, `toString`, ...) that reads as that
268
+ // very member holds no value: graphql input objects and stored records without the field inherit
269
+ // it, and MongoDB nested paths return it from their own accessors. Other values, overriding members
270
+ // such as class methods and getters, and every other name read as usual. The property is read once,
271
+ // so a getter runs once, as with a plain read.
272
+ const isObjectMember = (key, read) => key in Object.prototype && read === Object.prototype[key];
273
+ const ownValue = (value, key) => {
274
+ if (value == null) return undefined;
275
+ const read = value[key];
276
+ return isObjectMember(key, read) ? undefined : read;
277
+ };
278
+
279
+ // Output fields with such names read through this resolver instead of graphql's default one, which
280
+ // would call the inherited member. Otherwise it resolves as the default one does, calling a method
281
+ // with the parent as receiver, but reads the property only once. It reads no data, so installing it
282
+ // binds no type to a runtime.
283
+ const readOwnField = markGenerated((parent, args, context, info) => {
284
+ if (parent === null || (typeof parent !== 'object' && typeof parent !== 'function')) return undefined;
285
+ const property = parent[info.fieldName];
286
+ if (isObjectMember(info.fieldName, property)) return null;
287
+ return typeof property === 'function' ? property.call(parent, args, context, info) : property;
288
+ });
289
+
240
290
  const cloneInput = (value, type) => {
241
291
  if (value === null || value === undefined || !type) return value;
242
292
  if (type instanceof GraphQLNonNull) return cloneInput(value, type.ofType);
@@ -475,46 +525,6 @@ const unwrapListAndNonNull = (type) => {
475
525
  return unwrapped;
476
526
  };
477
527
 
478
- const getListShape = (type) => {
479
- const outerNonNull = type instanceof GraphQLNonNull;
480
- const listType = unwrapNonNull(type);
481
- if (!(listType instanceof GraphQLList)) return null;
482
- const itemNonNull = listType.ofType instanceof GraphQLNonNull;
483
- return {
484
- outerNonNull,
485
- itemNonNull,
486
- itemType: unwrapNonNull(listType.ofType),
487
- };
488
- };
489
-
490
- const getFieldStorageName = (fieldName, field) => {
491
- const relation = field.extensions?.relation;
492
- return relation && !relation.embedded && !getListShape(field.type)
493
- ? relation.connectionField || fieldName
494
- : fieldName;
495
- };
496
-
497
- const normalizeConnectionField = (childType, declaredFieldName) => {
498
- if (!declaredFieldName) {
499
- return { declaredFieldName, graphqlFieldName: null, storageFieldName: null };
500
- }
501
- const childFields = childType.getFields();
502
- let graphqlFieldName = childFields[declaredFieldName] ? declaredFieldName : null;
503
- if (!graphqlFieldName) {
504
- const aliasedField = Object.entries(childFields).find(([fieldName, field]) => (
505
- getFieldStorageName(fieldName, field) === declaredFieldName
506
- ));
507
- graphqlFieldName = aliasedField?.[0] || null;
508
- }
509
- return {
510
- declaredFieldName,
511
- graphqlFieldName,
512
- storageFieldName: graphqlFieldName
513
- ? getFieldStorageName(graphqlFieldName, childFields[graphqlFieldName])
514
- : declaredFieldName,
515
- };
516
- };
517
-
518
528
  const wrapListInputType = (itemType, listShape, preserveOuterNonNull) => {
519
529
  const wrappedItem = listShape.itemNonNull ? new GraphQLNonNull(itemType) : itemType;
520
530
  const listType = new GraphQLList(wrappedItem);
@@ -804,29 +814,29 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
804
814
  const collectionFields = {};
805
815
 
806
816
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
817
+ const fieldValue = ownValue(args, fieldEntryName);
807
818
  if (fieldEntry.extensions && fieldEntry.extensions.validations
808
819
  && fieldEntry.extensions.validations[operation]) {
809
820
  for (const validator of fieldEntry.extensions.validations[operation]) {
810
- await validator.validate(gqltype.name, fieldEntryName, args[fieldEntryName], session);
821
+ await validator.validate(gqltype.name, fieldEntryName, fieldValue, session);
811
822
  }
812
823
  }
813
824
 
814
- if (args[fieldEntryName] !== undefined && args[fieldEntryName] !== null) {
825
+ if (fieldValue !== undefined && fieldValue !== null) {
815
826
  if (fieldEntry.type instanceof GraphQLScalarType
816
827
  || fieldEntry.type instanceof GraphQLEnumType
817
828
  || isNonNullOfType(fieldEntry.type, GraphQLScalarType)
818
829
  || isNonNullOfType(fieldEntry.type, GraphQLEnumType)) {
819
- modelArgs[fieldEntryName] = args[fieldEntryName];
830
+ modelArgs[fieldEntryName] = fieldValue;
820
831
  } else if (fieldEntry.type instanceof GraphQLObjectType
821
832
  || isNonNullOfType(fieldEntry.type, GraphQLObjectType)) {
822
833
  if (fieldEntry.extensions && fieldEntry.extensions.relation) {
823
834
  if (!fieldEntry.extensions.relation.embedded) {
824
- const connectionField = fieldEntry.extensions.relation.connectionField || fieldEntryName;
825
- modelArgs[connectionField] = adapter.castId(args[fieldEntryName].id);
835
+ modelArgs[getFieldStorageName(fieldEntryName, fieldEntry)] = adapter.castId(fieldValue.id);
826
836
  } else {
827
837
  const fieldType = fieldEntry.type instanceof GraphQLNonNull
828
838
  ? fieldEntry.type.ofType : fieldEntry.type;
829
- modelArgs[fieldEntryName] = (await materializeModel(args[fieldEntryName], fieldType,
839
+ modelArgs[fieldEntryName] = (await materializeModel(fieldValue, fieldType,
830
840
  null, operation, session)).modelArgs;
831
841
  }
832
842
  } else {
@@ -841,11 +851,11 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
841
851
  if (itemType instanceof GraphQLObjectType && fieldEntry.extensions
842
852
  && fieldEntry.extensions.relation) {
843
853
  if (!fieldEntry.extensions.relation.embedded) {
844
- collectionFields[fieldEntryName] = args[fieldEntryName];
854
+ collectionFields[fieldEntryName] = fieldValue;
845
855
  } else if (fieldEntry.extensions.relation.embedded) {
846
856
  const collectionEntries = [];
847
857
 
848
- for (const element of args[fieldEntryName]) {
858
+ for (const element of fieldValue) {
849
859
  if (element === null) {
850
860
  collectionEntries.push(null);
851
861
  } else {
@@ -857,9 +867,15 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
857
867
  modelArgs[fieldEntryName] = collectionEntries;
858
868
  }
859
869
  } else if (itemType instanceof GraphQLScalarType || itemType instanceof GraphQLEnumType) {
860
- modelArgs[fieldEntryName] = args[fieldEntryName];
870
+ modelArgs[fieldEntryName] = fieldValue;
861
871
  }
862
872
  }
873
+ } else if (operation === 'CREATE' && fieldEntryName in Object.prototype && getListShape(fieldEntry.type)
874
+ && !(fieldEntry.extensions?.relation && !fieldEntry.extensions.relation.embedded)) {
875
+ // A create without a value stores [] for a list: SQL backends store it for every list, and
876
+ // Mongoose defaults to it, except for a list named like an Object.prototype member, where it
877
+ // finds the inherited member. Collections are not stored on the record.
878
+ modelArgs[fieldEntryName] = [];
863
879
  }
864
880
  }
865
881
 
@@ -945,9 +961,9 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
945
961
  const memberList = getListShape(member.type);
946
962
  if (relation && !relation.embedded && memberList) continue;
947
963
  const storageName = getFieldStorageName(memberName, member);
948
- if (item[storageName] === undefined && memberList) item[storageName] = [];
964
+ if (ownValue(item, storageName) === undefined && memberList) item[storageName] = [];
949
965
  if (memberName === 'id' || member.extensions?.readOnly) continue;
950
- const memberValue = item[storageName];
966
+ const memberValue = ownValue(item, storageName);
951
967
  if (memberValue === null || memberValue === undefined) {
952
968
  if (member.type instanceof GraphQLNonNull) {
953
969
  throw new SimfinityError(`Required value ${memberName} is missing`, 'REQUIRED_VALUE', 400);
@@ -959,6 +975,25 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
959
975
  }
960
976
  };
961
977
 
978
+ // An explicit null for a nullable member of a singular embedded patch clears that member, as a
979
+ // top-level null clears a field. Lists become empty lists; other members, including nested embedded
980
+ // objects and references (under their storage name), are stored as null rather than removed, since
981
+ // a removed nested object is materialized again from its list defaults. A null for a non-null member
982
+ // keeps the stored value, also as at the top level. Nested objects in the patch replace the stored
983
+ // ones wholesale.
984
+ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
985
+ if (!patch || typeof patch !== 'object') return;
986
+ const members = unwrapNonNull(fieldEntry.type).getFields();
987
+ for (const [memberName, value] of Object.entries(patch)) {
988
+ const member = members[memberName];
989
+ if (value !== null || !member || member.type instanceof GraphQLNonNull) continue;
990
+ const relation = member.extensions?.relation;
991
+ const listShape = getListShape(member.type);
992
+ if (relation && !relation.embedded && listShape) continue;
993
+ merged[getFieldStorageName(memberName, member)] = listShape ? [] : null;
994
+ }
995
+ };
996
+
962
997
  const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
963
998
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
964
999
  const objectId = args.id;
@@ -974,14 +1009,16 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
974
1009
  });
975
1010
  if (currentObject) {
976
1011
  for (const fieldEntryName of embeddedFieldNames) {
977
- const oldObjectData = currentObject[fieldEntryName];
978
- const newObjectData = materializedModel.modelArgs[fieldEntryName];
1012
+ const oldObjectData = ownValue(currentObject, fieldEntryName);
1013
+ const newObjectData = ownValue(materializedModel.modelArgs, fieldEntryName);
979
1014
  if (newObjectData) {
980
1015
  if (Array.isArray(newObjectData)) {
981
1016
  materializedModel.modelArgs[fieldEntryName] = newObjectData;
982
1017
  completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName], newObjectData);
983
1018
  } else {
984
1019
  materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
1020
+ clearNullEmbeddedMembers(materializedModel.modelArgs[fieldEntryName],
1021
+ argTypes[fieldEntryName], ownValue(args, fieldEntryName));
985
1022
  completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName],
986
1023
  materializedModel.modelArgs[fieldEntryName], newObjectData);
987
1024
  }
@@ -992,13 +1029,9 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
992
1029
 
993
1030
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
994
1031
  if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull)) {
995
- const relation = fieldEntry.extensions?.relation;
996
- const storageFieldName = relation && !relation.embedded && !(unwrapNonNull(fieldEntry.type) instanceof GraphQLList)
997
- ? relation.connectionField || fieldEntryName
998
- : fieldEntryName;
999
1032
  materializedModel.modelArgs.$unset = {
1000
1033
  ...materializedModel.modelArgs.$unset,
1001
- [storageFieldName]: '',
1034
+ [getFieldStorageName(fieldEntryName, fieldEntry)]: '',
1002
1035
  };
1003
1036
  }
1004
1037
  }
@@ -1079,6 +1112,7 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
1079
1112
 
1080
1113
  const saveObject = async (typeName, args, session, context) => {
1081
1114
  const type = typesDict.types[typeName];
1115
+ assertCollectionWrites(type.gqltype, args);
1082
1116
  const snapshot = cloneInput(args, type.inputType);
1083
1117
  return adapter.withTransaction(session, (transaction) => onSaveObject(
1084
1118
  type.model, type.gqltype, type.controller, cloneInput(snapshot, type.inputType), transaction, null, context,
@@ -1109,10 +1143,50 @@ const assertNestedOperations = (gqltype, input, limit, state = { count: 0 }) =>
1109
1143
  }
1110
1144
  };
1111
1145
 
1146
+ // A referenced collection links each child to its parent through the child's connectionField.
1147
+ const hasConnectionField = (relation) => typeof relation.connectionField === 'string'
1148
+ && relation.connectionField !== '';
1149
+ const isUnlinkedCollection = (field) => {
1150
+ const relation = field.extensions?.relation;
1151
+ const listShape = getListShape(field.type);
1152
+ return !!relation && !relation.embedded && !!listShape
1153
+ && listShape.itemType instanceof GraphQLObjectType && !hasConnectionField(relation);
1154
+ };
1155
+ // Null entries are skipped by every nested operation, so a list of only nulls writes nothing.
1156
+ const hasNestedItems = (items) => !Array.isArray(items) || items.some((item) => item !== null);
1157
+ const unlinkedCollectionError = (gqltype, fieldName) => new SimfinityError(
1158
+ `${gqltype.name}.${fieldName} cannot store nested items because it has no connectionField`,
1159
+ 'INVALID_MODEL',
1160
+ 500,
1161
+ );
1162
+
1163
+ // Rejects nested items for a collection without a connectionField, at every nesting level that
1164
+ // iterateOnCollectionFields executes, before anything is written or any controller hook runs. Such a
1165
+ // collection only exists with its own resolver or as readOnly; createSchema rejects the others.
1166
+ const assertCollectionWrites = (gqltype, input) => {
1167
+ if (!input || typeof input !== 'object') return;
1168
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
1169
+ const value = input[fieldName];
1170
+ const relation = field.extensions?.relation;
1171
+ const listShape = getListShape(field.type);
1172
+ if (value == null || !relation || relation.embedded || !listShape
1173
+ || !(listShape.itemType instanceof GraphQLObjectType)) continue;
1174
+ for (const key of ['added', 'updated', 'deleted']) {
1175
+ const items = value[key];
1176
+ if (!items) continue;
1177
+ if (!hasConnectionField(relation) && hasNestedItems(items)) throw unlinkedCollectionError(gqltype, fieldName);
1178
+ if (key !== 'deleted' && Array.isArray(items)) {
1179
+ for (const item of items) assertCollectionWrites(listShape.itemType, item);
1180
+ }
1181
+ }
1182
+ }
1183
+ };
1184
+
1112
1185
  const executeOperation = (Model, gqltype, controller, args, operation, actionField,
1113
1186
  session, context, inputType) => {
1114
1187
  const limit = getMaxNestedOperations();
1115
1188
  if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
1189
+ if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
1116
1190
  const inputSnapshot = operation === operations.DELETE ? args : cloneInput(args, inputType);
1117
1191
  return adapter.withTransaction(
1118
1192
  session,
@@ -1142,10 +1216,12 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
1142
1216
  collectionFieldsList, operationType, context) => {
1143
1217
  const argTypes = gqltype.getFields();
1144
1218
  const collectionGQLType = unwrapListAndNonNull(argTypes[collectionField].type);
1145
- const connection = normalizeConnectionField(
1146
- collectionGQLType,
1147
- argTypes[collectionField].extensions.relation.connectionField,
1148
- );
1219
+ const { relation } = argTypes[collectionField].extensions;
1220
+ // Checked before the operation too; this also covers items that nested middleware added.
1221
+ if (!hasConnectionField(relation) && hasNestedItems(collectionFieldsList)) {
1222
+ throw unlinkedCollectionError(gqltype, collectionField);
1223
+ }
1224
+ const connection = normalizeConnectionField(collectionGQLType, relation.connectionField);
1149
1225
 
1150
1226
  const type = operationType === operations.UPDATE
1151
1227
  ? typesDictForUpdate.types[collectionGQLType.name] : typesDict.types[collectionGQLType.name];
@@ -1568,10 +1644,6 @@ const buildRootQuery = (name, includedTypes) => {
1568
1644
  if (!shouldNotBeIncludedInSchema(includedTypes, type.gqltype)) {
1569
1645
  const wasAddedAsNoEnpointType = !type.simpleEntityEndpointName;
1570
1646
  if (!wasAddedAsNoEnpointType) {
1571
- if (type.gqltype.getFields().id && !type.gqltype.getFields().id.resolve) {
1572
- type.gqltype.getFields().id.resolve = markGenerated((parent) => parent._id);
1573
- }
1574
-
1575
1647
  rootQueryArgs.fields[type.simpleEntityEndpointName] = {
1576
1648
  type: type.gqltype,
1577
1649
  args: { id: { type: GraphQLID } },
@@ -1580,9 +1652,7 @@ const buildRootQuery = (name, includedTypes) => {
1580
1652
  },
1581
1653
  };
1582
1654
 
1583
- const argTypes = type.gqltype.getFields();
1584
-
1585
- const argsObject = createArgsForQuery(argTypes);
1655
+ const argsObject = createArgsForQuery(type.gqltype);
1586
1656
 
1587
1657
  rootQueryArgs.fields[type.listEntitiesEndpointName] = {
1588
1658
  type: new GraphQLList(type.gqltype),
@@ -1616,6 +1686,11 @@ const buildRootQuery = (name, includedTypes) => {
1616
1686
  };
1617
1687
 
1618
1688
  const aggregateArgsObject = { ...argsObject };
1689
+ if (Object.hasOwn(type.gqltype.getFields(), 'aggregation')) {
1690
+ warnOnce(type.gqltype, 'aggregation', `Configuration issue: ${type.gqltype.name}.aggregation has the name `
1691
+ + `of the aggregation argument of ${type.listEntitiesEndpointName}_aggregate, so that endpoint cannot `
1692
+ + 'filter it with a top-level argument; filter it in AND/OR group conditions or rename the field.');
1693
+ }
1619
1694
  aggregateArgsObject.aggregation = {
1620
1695
  type: new GraphQLNonNull(QLTypeAggregationExpression),
1621
1696
  };
@@ -1645,7 +1720,9 @@ const buildRootQuery = (name, includedTypes) => {
1645
1720
  return new GraphQLObjectType(rootQueryArgs);
1646
1721
  };
1647
1722
 
1723
+ // Returns the registrations that a non-embedded relation of a registered type targets.
1648
1724
  const markReferencedTypesForModelGeneration = () => {
1725
+ const referenced = new Set();
1649
1726
  Object.values(typesDict.types).forEach((typeInfo) => {
1650
1727
  Object.values(typeInfo.gqltype.getFields()).forEach((fieldEntry) => {
1651
1728
  const relation = fieldEntry.extensions?.relation;
@@ -1653,11 +1730,94 @@ const markReferencedTypesForModelGeneration = () => {
1653
1730
 
1654
1731
  const relatedType = unwrapListAndNonNull(fieldEntry.type);
1655
1732
  const relatedTypeInfo = typesDict.types[relatedType.name];
1733
+ if (relatedTypeInfo) referenced.add(relatedTypeInfo);
1656
1734
  if (relatedTypeInfo && !relatedTypeInfo.endpoint) {
1657
1735
  relatedTypeInfo.needsModel = true;
1658
1736
  }
1659
1737
  });
1660
1738
  });
1739
+ return referenced;
1740
+ };
1741
+
1742
+ // Entity records keep their identity in `_id`; MongoDB records have no `id` value, while the SQL
1743
+ // store and other adapters may set both. Embedded-only types keep the default resolver, which
1744
+ // returns their declared `id` member.
1745
+ const installIdResolver = (gqltype) => {
1746
+ const idField = gqltype.getFields().id;
1747
+ if (idField && !idField.resolve) idField.resolve = markGenerated((parent) => parent._id ?? parent.id);
1748
+ };
1749
+
1750
+ // An adapter that hydrates stored records may render an explicitly null singular embedded object as
1751
+ // an object (MongoDB nested paths do); readEmbeddedValue returns null for it. Nullable singular
1752
+ // embedded fields of registered types, and of the embedded types they reach, read through it. The
1753
+ // hook depends only on the value and reads no data, so these resolvers do not bind their types to
1754
+ // this runtime: another runtime that reaches a shared type reads the same value through it.
1755
+ // The value is read as graphql's default resolver reads it, so a method on the parent, such as one a
1756
+ // custom mutation result supplies, is still called; a promise it returns is read once it settles.
1757
+ // Only a field named like an Object.prototype member that reads as that member reads as null.
1758
+ const readEmbeddedField = markGenerated((parent, args, context, info) => {
1759
+ const value = readOwnField(parent, args, context, info);
1760
+ return isThenable(value)
1761
+ ? value.then((resolved) => adapter.readEmbeddedValue(resolved))
1762
+ : adapter.readEmbeddedValue(value);
1763
+ });
1764
+
1765
+ const installEmbeddedValueResolvers = (gqltype, visited) => {
1766
+ if (!(gqltype instanceof GraphQLObjectType) || visited.has(gqltype)) return;
1767
+ visited.add(gqltype);
1768
+ for (const fieldEntry of Object.values(gqltype.getFields())) {
1769
+ if (fieldEntry.extensions?.relation?.embedded !== true) continue;
1770
+ const listShape = getListShape(fieldEntry.type);
1771
+ if (!listShape && !(fieldEntry.type instanceof GraphQLNonNull) && !fieldEntry.resolve) {
1772
+ fieldEntry.resolve = readEmbeddedField;
1773
+ }
1774
+ installEmbeddedValueResolvers(listShape ? listShape.itemType : unwrapNonNull(fieldEntry.type), visited);
1775
+ }
1776
+ };
1777
+
1778
+ // Fields named like Object.prototype members, of registered types and the embedded types they reach,
1779
+ // read through readOwnField when they have no resolver yet; references and collections keep the ones
1780
+ // autoGenerateResolvers gives them.
1781
+ const installObjectMemberResolvers = (gqltype, visited) => {
1782
+ if (!(gqltype instanceof GraphQLObjectType) || visited.has(gqltype)) return;
1783
+ visited.add(gqltype);
1784
+ for (const [fieldName, fieldEntry] of Object.entries(gqltype.getFields())) {
1785
+ const relation = fieldEntry.extensions?.relation;
1786
+ if (relation && !relation.embedded) continue;
1787
+ if (fieldName in Object.prototype && !fieldEntry.resolve) fieldEntry.resolve = readOwnField;
1788
+ if (relation) installObjectMemberResolvers(unwrapListAndNonNull(fieldEntry.type), visited);
1789
+ }
1790
+ };
1791
+
1792
+ // Configuration issues this runtime already reported, per type, so a repeated createSchema call or a
1793
+ // type reached from several places logs each one once.
1794
+ const reportedIssues = new WeakMap();
1795
+ const warnOnce = (gqltype, key, message) => {
1796
+ if (!reportedIssues.has(gqltype)) reportedIssues.set(gqltype, new Set());
1797
+ const reported = reportedIssues.get(gqltype);
1798
+ if (reported.has(key)) return;
1799
+ reported.add(key);
1800
+ console.warn(message);
1801
+ };
1802
+
1803
+ // A writable referenced collection without a child connectionField would store unlinked children
1804
+ // through generated nested writes and read none back, so it is rejected before anything is built.
1805
+ // One with its own resolver only warns, and its nested writes are rejected per request; a readOnly
1806
+ // one warns unless it has its own resolver.
1807
+ const assertCollectionConnections = () => {
1808
+ for (const { gqltype } of Object.values(typesDict.types)) {
1809
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
1810
+ if (!isUnlinkedCollection(field)) continue;
1811
+ const problem = `${gqltype.name}.${fieldName} requires a child connectionField`;
1812
+ const readOnly = !!field.extensions.readOnly;
1813
+ const ownResolver = hasApplicationResolver(gqltype, fieldName);
1814
+ if (!readOnly && !ownResolver) throw new SimfinityError(problem, 'INVALID_MODEL', 400);
1815
+ if (!readOnly || !ownResolver) {
1816
+ warnOnce(gqltype, `connectionField:${fieldName}`,
1817
+ `Configuration issue: ${problem}; nested writes through it are rejected`);
1818
+ }
1819
+ }
1820
+ }
1661
1821
  };
1662
1822
 
1663
1823
  const getRegistrations = () => Object.values(typesDict.types);
@@ -1668,7 +1828,12 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1668
1828
  const schemaTypes = collectSchemaOutputTypes();
1669
1829
  schemaTypes.forEach(assertNotBoundElsewhere);
1670
1830
  schemaTypes.forEach(assertFieldsNotBoundElsewhere);
1671
- markReferencedTypesForModelGeneration();
1831
+ assertCollectionConnections();
1832
+ const referencedTypes = markReferencedTypesForModelGeneration();
1833
+ // Entities, which keep their own stored identity: connect() types and relation targets.
1834
+ Object.values(typesDict.types).forEach((typeInfo) => {
1835
+ typeInfo.storedIdentity = typeInfo.endpoint === true || referencedTypes.has(typeInfo);
1836
+ });
1672
1837
  if (adapter.prepare) {
1673
1838
  adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
1674
1839
  }
@@ -1688,14 +1853,28 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1688
1853
  Object.keys(typesDict.types).forEach((typeName) => {
1689
1854
  if (typesDictForUpdate.types[typeName]) {
1690
1855
  typesDictForUpdate.types[typeName].model = typesDict.types[typeName].model;
1856
+ typesDictForUpdate.types[typeName].storedIdentity = typesDict.types[typeName].storedIdentity;
1691
1857
  }
1692
1858
  });
1693
1859
 
1860
+ // Every entity resolves `id`, whatever the query and mutation allowlists include. The type object
1861
+ // is marked too, so query paths of any runtime that shares it follow the same identity.
1862
+ const embeddedValueTypes = new Set();
1694
1863
  Object.values(typesDict.types).forEach((typeInfo) => {
1695
1864
  if (typeInfo.gqltype) {
1865
+ if (typeInfo.storedIdentity) {
1866
+ markStoredIdentity(typeInfo.gqltype);
1867
+ installIdResolver(typeInfo.gqltype);
1868
+ }
1696
1869
  autoGenerateResolvers(typeInfo.gqltype);
1870
+ if (typeof adapter.readEmbeddedValue === 'function') {
1871
+ installEmbeddedValueResolvers(typeInfo.gqltype, embeddedValueTypes);
1872
+ }
1697
1873
  }
1698
1874
  });
1875
+ // After every type has its relation and embedded value resolvers, which these fields keep.
1876
+ const objectMemberTypes = new Set();
1877
+ Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
1699
1878
 
1700
1879
  const query = buildRootQuery('RootQueryType', includedQueryTypes);
1701
1880
  Object.values(typesDict.types).forEach(({ gqltype }) => {
@@ -1749,6 +1928,16 @@ const claimRelationField = (fieldEntry, resolve, extensions = {}) => {
1749
1928
  relationFieldOwners.set(fieldEntry.resolve, runtimeIdentity);
1750
1929
  };
1751
1930
 
1931
+ // The identifier a single reference reads is stored, not sent by the client, so one the adapter
1932
+ // rejects as malformed, such as a value written outside Simfinity, is a data error rather than a bad
1933
+ // request. Missing records and other errors keep their own classification.
1934
+ const storedReferenceError = (error, gqltype, fieldName) => {
1935
+ if (!(error instanceof SimfinityError) || error.getCode() !== 'NOT_VALID_ID' || error.getStatus() !== 400) {
1936
+ return error;
1937
+ }
1938
+ return new InternalServerError(`${gqltype.name}.${fieldName} stores an invalid identifier`, error);
1939
+ };
1940
+
1752
1941
  const autoGenerateResolvers = (gqltype) => {
1753
1942
  assertFieldsNotBoundElsewhere(gqltype);
1754
1943
  const fields = gqltype.getFields();
@@ -1766,8 +1955,7 @@ const autoGenerateResolvers = (gqltype) => {
1766
1955
  relation.connectionField || fieldName,
1767
1956
  );
1768
1957
  const relatedTypeInfo = requireRegisteredType(typesDict, relatedType, fieldName, gqltype.name);
1769
- const argsObject = createArgsForQuery(relatedTypeInfo.gqltype.getFields());
1770
- if (connection.graphqlFieldName) delete argsObject[connection.graphqlFieldName];
1958
+ const argsObject = createArgsForQuery(relatedTypeInfo.gqltype, connection.graphqlFieldName);
1771
1959
 
1772
1960
  fieldEntry.args = formatArgs(Object.entries(argsObject));
1773
1961
  claimRelationField(fieldEntry, async (parent, args, context) => {
@@ -1783,7 +1971,7 @@ const autoGenerateResolvers = (gqltype) => {
1783
1971
  relatedTypeInfo.model,
1784
1972
  relatedTypeInfo.gqltype,
1785
1973
  connection.storageFieldName,
1786
- parent.id || parent._id,
1974
+ parent._id ?? parent.id,
1787
1975
  params.args,
1788
1976
  null,
1789
1977
  );
@@ -1792,16 +1980,29 @@ const autoGenerateResolvers = (gqltype) => {
1792
1980
  } else if (fieldEntry.type instanceof GraphQLObjectType
1793
1981
  || (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType)) {
1794
1982
  const relatedType = unwrapNonNull(fieldEntry.type);
1795
- const connectionField = relation.connectionField || fieldName;
1983
+ const connectionField = getFieldStorageName(fieldName, fieldEntry);
1796
1984
 
1797
1985
  claimRelationField(fieldEntry, async (parent, args, context) => {
1798
1986
  const relatedTypeInfo = typesDict.types[relatedType.name];
1799
1987
  if (!relatedTypeInfo || !relatedTypeInfo.model) {
1800
1988
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
1801
1989
  }
1802
- const relatedId = parent[connectionField] || parent[fieldName];
1803
- const id = relatedId?._id || relatedId;
1804
- return id ? resolveById(relatedTypeInfo, { id: String(id) }, context, id) : null;
1990
+ // Any stored identifier except null or an empty string, so numeric IDs such as 0 resolve. A
1991
+ // function under the field name is a member, such as the class a MongoDB document inherits
1992
+ // `constructor` from, not an identifier.
1993
+ // The field name is read only when the stored identifier is missing.
1994
+ let relatedId = ownValue(parent, connectionField);
1995
+ if (relatedId == null) {
1996
+ const linked = ownValue(parent, fieldName);
1997
+ relatedId = typeof linked === 'function' ? undefined : linked;
1998
+ }
1999
+ const id = relatedId?._id ?? relatedId;
2000
+ if (id == null || id === '') return null;
2001
+ try {
2002
+ return await resolveById(relatedTypeInfo, { id: String(id) }, context, id);
2003
+ } catch (error) {
2004
+ throw storedReferenceError(error, gqltype, fieldName);
2005
+ }
1805
2006
  });
1806
2007
  runtimeBoundTypes.set(gqltype, runtimeIdentity);
1807
2008
  }
@@ -1858,10 +2059,24 @@ const addNoEndpointType = (gqltype) => {
1858
2059
  typesDictForUpdate.types[gqltype.name] = { ...typesDict.types[gqltype.name] };
1859
2060
  };
1860
2061
 
1861
- const createArgsForQuery = (argTypes) => {
2062
+ // Arguments that every list query and collection field generates, after the field filters.
2063
+ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
2064
+
2065
+ // `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
2066
+ // A field named like a generated argument gets no top-level filter either; it is reported once.
2067
+ const createArgsForQuery = (gqltype, omit = null) => {
1862
2068
  const argsObject = {};
2069
+ const argTypes = gqltype.getFields();
2070
+ for (const name of LIST_QUERY_ARGS) {
2071
+ if (name !== omit && Object.hasOwn(argTypes, name)) {
2072
+ warnOnce(gqltype, name, `Configuration issue: ${gqltype.name}.${name} has the name of a generated list `
2073
+ + 'query argument, so list queries and collection fields cannot filter it with a top-level argument; '
2074
+ + 'filter it in AND/OR group conditions or rename the field.');
2075
+ }
2076
+ }
1863
2077
 
1864
2078
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
2079
+ if (fieldEntryName === omit) continue;
1865
2080
  argsObject[fieldEntryName] = {};
1866
2081
 
1867
2082
  if (fieldEntry.type instanceof GraphQLScalarType
package/types/index.d.ts CHANGED
@@ -66,6 +66,13 @@ export class SimfinityError extends Error {
66
66
  getCode(): string | undefined;
67
67
  getStatus(): number | undefined;
68
68
  getTimestamp(): string;
69
+ /**
70
+ * Set on errors that wrap a driver error, such as TRANSACTION_RETRY_EXCEEDED, and on the PostgreSQL database errors
71
+ * that a SQL runtime maps or reports as DATABASE_ERROR; on those PostgreSQL errors it is not enumerable.
72
+ */
73
+ cause?: unknown;
74
+ /** Set on errors that wrap a driver error, such as TRANSACTION_RETRY_EXCEEDED, and on PostgreSQL database errors; returns `cause`. */
75
+ getCause?(): unknown;
69
76
  }
70
77
 
71
78
  export class InternalServerError extends SimfinityError {
@@ -129,6 +136,14 @@ export interface RuntimeRegistration<Model = any, Session = any> extends ModelRe
129
136
  controller?: EntityController<Session> | null;
130
137
  onModelCreated?: ((model: Model) => void) | null;
131
138
  stateMachine?: StateMachine<Session> | null;
139
+ /**
140
+ * Set by `createSchema()` before `adapter.prepare()`: true for entity types, those registered
141
+ * with `connect()` and the targets of non-embedded relations, whose `id` resolves to the stored
142
+ * `_id`, falling back to `id`. False for types this runtime uses only as embedded values. The `id`
143
+ * rule follows the type object, so a type that another runtime sharing it treats as an entity
144
+ * resolves `id` the same way here.
145
+ */
146
+ storedIdentity?: boolean;
132
147
  }
133
148
  export interface MiddlewareContext {
134
149
  args: any;
@@ -194,9 +209,15 @@ export interface Runtime<Model = any, Session = any> {
194
209
  * (`TYPE_BOUND_TO_OTHER_RUNTIME`) any reachable type that another runtime bound or reserved, or
195
210
  * whose field was copied with `toConfig()` after another runtime generated its relation
196
211
  * resolver, keeping the generated resolver or its extensions. A list or embedded relation to an unregistered type throws
197
- * `UNREGISTERED_RELATION_TARGET` (500). The first schema binds the types whose relation
198
- * resolvers it generates to this runtime, and reserves reachable types that still have a
212
+ * `UNREGISTERED_RELATION_TARGET` (500). A writable non-embedded list relation without a
213
+ * non-empty child `connectionField`, whose resolver this runtime would generate, throws
214
+ * `INVALID_MODEL` (400) before `adapter.prepare()`; one with an application resolver or
215
+ * `readOnly` only logs a `Configuration issue` warning, and its nested writes fail with
216
+ * `INVALID_MODEL` (500). Fields named like generated query arguments also log a warning. The
217
+ * first schema binds the types whose relation resolvers it generates to this runtime and
218
+ * reserves reachable types that still have a
199
219
  * non-embedded relation field without a resolver, such as an unregistered custom mutation result.
220
+ * Every entity type resolves `id` to `_id ?? id`, whatever the allowlists include.
200
221
  */
201
222
  createSchema(includedQueryTypes?: GraphQLObjectType[] | null, includedMutationTypes?: GraphQLObjectType[] | null, includedCustomMutations?: string[] | null): GraphQLSchema;
202
223
  getModel(type: GraphQLObjectType | { name: string }): Model | null | undefined;
@@ -225,12 +246,29 @@ export interface DatabaseAdapter<Model = any, Session = any> {
225
246
  validateRegistration?(registration: RuntimeRegistration<Model, Session>): void;
226
247
  prepare?(registrations: RuntimeRegistration<Model, Session>[], options: { createCollection: boolean }): void;
227
248
  createModel(type: GraphQLObjectType, callback: ((model: Model) => void) | null | undefined, options: { createCollection: boolean }): Model;
249
+ /**
250
+ * Casts a reference or batch key to the stored identifier type. Throw a `SimfinityError` with
251
+ * code `NOT_VALID_ID` (400) for a malformed value; never create a new identifier. A batched
252
+ * reference read whose ID throws or is normalized reads that ID alone with `getById`. When
253
+ * `getById` rejects a stored reference with that error, the generated reference field fails with
254
+ * an `InternalServerError` whose cause is that error, since the client did not send the value.
255
+ */
228
256
  castId(value: any): any;
229
257
  stateValue?(state: { name: string; value: any }): any;
230
258
  withTransaction<T>(session: Session | null | undefined, callback: (session: Session) => Promise<T> | T, model?: Model): Promise<T>;
231
259
  newRecord(model: Model, data: any, session?: Session): any;
232
260
  saveRecord(model: Model, record: any, session?: Session): any;
233
261
  toObject(record: any): any;
262
+ /**
263
+ * Optional. When defined, every nullable, singular embedded object field without a resolver
264
+ * passes its value through `readEmbeddedValue(value)`. The field is read as graphql's default
265
+ * resolver reads it: a method on the parent is called with (args, context, info), and the hook
266
+ * receives its result once a returned promise settles. Return null for a value that a hydrated
267
+ * record renders as an object although the stored value is an explicit null; return any other
268
+ * value unchanged. It must depend only on the value: these resolvers read no data and do not bind
269
+ * their types, so a runtime that reaches a shared type may read through another runtime's hook.
270
+ */
271
+ readEmbeddedValue?(value: any): any;
234
272
  getById(model: Model, id: any, session?: Session | null, options?: { projection?: Record<string, number>; plain?: boolean; lock?: boolean; requiredId?: any; context?: any }): any;
235
273
  /** Optional batch read: the records found for `ids`, in getById shape and any order. After a failure, the runtime reads each ID with getById. */
236
274
  getByIds?(model: Model, ids: any[], options?: { context?: any }): any;