@simtlix/simfinity-core 3.5.3 → 3.5.5

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.5",
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,10 +1,10 @@
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,
5
5
  GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
6
6
  GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
7
- Kind, getNamedType,
7
+ Kind, defaultFieldResolver, getNamedType,
8
8
  } from 'graphql';
9
9
 
10
10
  import SimfinityError from './errors/simfinity.error.js';
@@ -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);
@@ -475,46 +502,6 @@ const unwrapListAndNonNull = (type) => {
475
502
  return unwrapped;
476
503
  };
477
504
 
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
505
  const wrapListInputType = (itemType, listShape, preserveOuterNonNull) => {
519
506
  const wrappedItem = listShape.itemNonNull ? new GraphQLNonNull(itemType) : itemType;
520
507
  const listType = new GraphQLList(wrappedItem);
@@ -821,8 +808,7 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
821
808
  || isNonNullOfType(fieldEntry.type, GraphQLObjectType)) {
822
809
  if (fieldEntry.extensions && fieldEntry.extensions.relation) {
823
810
  if (!fieldEntry.extensions.relation.embedded) {
824
- const connectionField = fieldEntry.extensions.relation.connectionField || fieldEntryName;
825
- modelArgs[connectionField] = adapter.castId(args[fieldEntryName].id);
811
+ modelArgs[getFieldStorageName(fieldEntryName, fieldEntry)] = adapter.castId(args[fieldEntryName].id);
826
812
  } else {
827
813
  const fieldType = fieldEntry.type instanceof GraphQLNonNull
828
814
  ? fieldEntry.type.ofType : fieldEntry.type;
@@ -959,6 +945,25 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
959
945
  }
960
946
  };
961
947
 
948
+ // An explicit null for a nullable member of a singular embedded patch clears that member, as a
949
+ // top-level null clears a field. Lists become empty lists; other members, including nested embedded
950
+ // objects and references (under their storage name), are stored as null rather than removed, since
951
+ // a removed nested object is materialized again from its list defaults. A null for a non-null member
952
+ // keeps the stored value, also as at the top level. Nested objects in the patch replace the stored
953
+ // ones wholesale.
954
+ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
955
+ if (!patch || typeof patch !== 'object') return;
956
+ const members = unwrapNonNull(fieldEntry.type).getFields();
957
+ for (const [memberName, value] of Object.entries(patch)) {
958
+ const member = members[memberName];
959
+ if (value !== null || !member || member.type instanceof GraphQLNonNull) continue;
960
+ const relation = member.extensions?.relation;
961
+ const listShape = getListShape(member.type);
962
+ if (relation && !relation.embedded && listShape) continue;
963
+ merged[getFieldStorageName(memberName, member)] = listShape ? [] : null;
964
+ }
965
+ };
966
+
962
967
  const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
963
968
  const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
964
969
  const objectId = args.id;
@@ -982,6 +987,8 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
982
987
  completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName], newObjectData);
983
988
  } else {
984
989
  materializedModel.modelArgs[fieldEntryName] = { ...oldObjectData, ...newObjectData };
990
+ clearNullEmbeddedMembers(materializedModel.modelArgs[fieldEntryName],
991
+ argTypes[fieldEntryName], args[fieldEntryName]);
985
992
  completeEmbeddedValue(fieldEntryName, argTypes[fieldEntryName],
986
993
  materializedModel.modelArgs[fieldEntryName], newObjectData);
987
994
  }
@@ -992,13 +999,9 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
992
999
 
993
1000
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
994
1001
  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
1002
  materializedModel.modelArgs.$unset = {
1000
1003
  ...materializedModel.modelArgs.$unset,
1001
- [storageFieldName]: '',
1004
+ [getFieldStorageName(fieldEntryName, fieldEntry)]: '',
1002
1005
  };
1003
1006
  }
1004
1007
  }
@@ -1079,6 +1082,7 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
1079
1082
 
1080
1083
  const saveObject = async (typeName, args, session, context) => {
1081
1084
  const type = typesDict.types[typeName];
1085
+ assertCollectionWrites(type.gqltype, args);
1082
1086
  const snapshot = cloneInput(args, type.inputType);
1083
1087
  return adapter.withTransaction(session, (transaction) => onSaveObject(
1084
1088
  type.model, type.gqltype, type.controller, cloneInput(snapshot, type.inputType), transaction, null, context,
@@ -1109,10 +1113,50 @@ const assertNestedOperations = (gqltype, input, limit, state = { count: 0 }) =>
1109
1113
  }
1110
1114
  };
1111
1115
 
1116
+ // A referenced collection links each child to its parent through the child's connectionField.
1117
+ const hasConnectionField = (relation) => typeof relation.connectionField === 'string'
1118
+ && relation.connectionField !== '';
1119
+ const isUnlinkedCollection = (field) => {
1120
+ const relation = field.extensions?.relation;
1121
+ const listShape = getListShape(field.type);
1122
+ return !!relation && !relation.embedded && !!listShape
1123
+ && listShape.itemType instanceof GraphQLObjectType && !hasConnectionField(relation);
1124
+ };
1125
+ // Null entries are skipped by every nested operation, so a list of only nulls writes nothing.
1126
+ const hasNestedItems = (items) => !Array.isArray(items) || items.some((item) => item !== null);
1127
+ const unlinkedCollectionError = (gqltype, fieldName) => new SimfinityError(
1128
+ `${gqltype.name}.${fieldName} cannot store nested items because it has no connectionField`,
1129
+ 'INVALID_MODEL',
1130
+ 500,
1131
+ );
1132
+
1133
+ // Rejects nested items for a collection without a connectionField, at every nesting level that
1134
+ // iterateOnCollectionFields executes, before anything is written or any controller hook runs. Such a
1135
+ // collection only exists with its own resolver or as readOnly; createSchema rejects the others.
1136
+ const assertCollectionWrites = (gqltype, input) => {
1137
+ if (!input || typeof input !== 'object') return;
1138
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
1139
+ const value = input[fieldName];
1140
+ const relation = field.extensions?.relation;
1141
+ const listShape = getListShape(field.type);
1142
+ if (value == null || !relation || relation.embedded || !listShape
1143
+ || !(listShape.itemType instanceof GraphQLObjectType)) continue;
1144
+ for (const key of ['added', 'updated', 'deleted']) {
1145
+ const items = value[key];
1146
+ if (!items) continue;
1147
+ if (!hasConnectionField(relation) && hasNestedItems(items)) throw unlinkedCollectionError(gqltype, fieldName);
1148
+ if (key !== 'deleted' && Array.isArray(items)) {
1149
+ for (const item of items) assertCollectionWrites(listShape.itemType, item);
1150
+ }
1151
+ }
1152
+ }
1153
+ };
1154
+
1112
1155
  const executeOperation = (Model, gqltype, controller, args, operation, actionField,
1113
1156
  session, context, inputType) => {
1114
1157
  const limit = getMaxNestedOperations();
1115
1158
  if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
1159
+ if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
1116
1160
  const inputSnapshot = operation === operations.DELETE ? args : cloneInput(args, inputType);
1117
1161
  return adapter.withTransaction(
1118
1162
  session,
@@ -1142,10 +1186,12 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
1142
1186
  collectionFieldsList, operationType, context) => {
1143
1187
  const argTypes = gqltype.getFields();
1144
1188
  const collectionGQLType = unwrapListAndNonNull(argTypes[collectionField].type);
1145
- const connection = normalizeConnectionField(
1146
- collectionGQLType,
1147
- argTypes[collectionField].extensions.relation.connectionField,
1148
- );
1189
+ const { relation } = argTypes[collectionField].extensions;
1190
+ // Checked before the operation too; this also covers items that nested middleware added.
1191
+ if (!hasConnectionField(relation) && hasNestedItems(collectionFieldsList)) {
1192
+ throw unlinkedCollectionError(gqltype, collectionField);
1193
+ }
1194
+ const connection = normalizeConnectionField(collectionGQLType, relation.connectionField);
1149
1195
 
1150
1196
  const type = operationType === operations.UPDATE
1151
1197
  ? typesDictForUpdate.types[collectionGQLType.name] : typesDict.types[collectionGQLType.name];
@@ -1568,10 +1614,6 @@ const buildRootQuery = (name, includedTypes) => {
1568
1614
  if (!shouldNotBeIncludedInSchema(includedTypes, type.gqltype)) {
1569
1615
  const wasAddedAsNoEnpointType = !type.simpleEntityEndpointName;
1570
1616
  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
1617
  rootQueryArgs.fields[type.simpleEntityEndpointName] = {
1576
1618
  type: type.gqltype,
1577
1619
  args: { id: { type: GraphQLID } },
@@ -1580,9 +1622,7 @@ const buildRootQuery = (name, includedTypes) => {
1580
1622
  },
1581
1623
  };
1582
1624
 
1583
- const argTypes = type.gqltype.getFields();
1584
-
1585
- const argsObject = createArgsForQuery(argTypes);
1625
+ const argsObject = createArgsForQuery(type.gqltype);
1586
1626
 
1587
1627
  rootQueryArgs.fields[type.listEntitiesEndpointName] = {
1588
1628
  type: new GraphQLList(type.gqltype),
@@ -1616,6 +1656,11 @@ const buildRootQuery = (name, includedTypes) => {
1616
1656
  };
1617
1657
 
1618
1658
  const aggregateArgsObject = { ...argsObject };
1659
+ if (Object.hasOwn(type.gqltype.getFields(), 'aggregation')) {
1660
+ warnOnce(type.gqltype, 'aggregation', `Configuration issue: ${type.gqltype.name}.aggregation has the name `
1661
+ + `of the aggregation argument of ${type.listEntitiesEndpointName}_aggregate, so that endpoint cannot `
1662
+ + 'filter it with a top-level argument; filter it in AND/OR group conditions or rename the field.');
1663
+ }
1619
1664
  aggregateArgsObject.aggregation = {
1620
1665
  type: new GraphQLNonNull(QLTypeAggregationExpression),
1621
1666
  };
@@ -1645,7 +1690,9 @@ const buildRootQuery = (name, includedTypes) => {
1645
1690
  return new GraphQLObjectType(rootQueryArgs);
1646
1691
  };
1647
1692
 
1693
+ // Returns the registrations that a non-embedded relation of a registered type targets.
1648
1694
  const markReferencedTypesForModelGeneration = () => {
1695
+ const referenced = new Set();
1649
1696
  Object.values(typesDict.types).forEach((typeInfo) => {
1650
1697
  Object.values(typeInfo.gqltype.getFields()).forEach((fieldEntry) => {
1651
1698
  const relation = fieldEntry.extensions?.relation;
@@ -1653,11 +1700,79 @@ const markReferencedTypesForModelGeneration = () => {
1653
1700
 
1654
1701
  const relatedType = unwrapListAndNonNull(fieldEntry.type);
1655
1702
  const relatedTypeInfo = typesDict.types[relatedType.name];
1703
+ if (relatedTypeInfo) referenced.add(relatedTypeInfo);
1656
1704
  if (relatedTypeInfo && !relatedTypeInfo.endpoint) {
1657
1705
  relatedTypeInfo.needsModel = true;
1658
1706
  }
1659
1707
  });
1660
1708
  });
1709
+ return referenced;
1710
+ };
1711
+
1712
+ // Entity records keep their identity in `_id`; MongoDB records have no `id` value, while the SQL
1713
+ // store and other adapters may set both. Embedded-only types keep the default resolver, which
1714
+ // returns their declared `id` member.
1715
+ const installIdResolver = (gqltype) => {
1716
+ const idField = gqltype.getFields().id;
1717
+ if (idField && !idField.resolve) idField.resolve = markGenerated((parent) => parent._id ?? parent.id);
1718
+ };
1719
+
1720
+ // An adapter that hydrates stored records may render an explicitly null singular embedded object as
1721
+ // an object (MongoDB nested paths do); readEmbeddedValue returns null for it. Nullable singular
1722
+ // embedded fields of registered types, and of the embedded types they reach, read through it. The
1723
+ // hook depends only on the value and reads no data, so these resolvers do not bind their types to
1724
+ // this runtime: another runtime that reaches a shared type reads the same value through it.
1725
+ // The value is read as graphql's default resolver reads it, so a method on the parent, such as one a
1726
+ // custom mutation result supplies, is still called; a promise it returns is read once it settles.
1727
+ const readEmbeddedField = markGenerated((parent, args, context, info) => {
1728
+ const value = defaultFieldResolver(parent, args, context, info);
1729
+ return isThenable(value)
1730
+ ? value.then((resolved) => adapter.readEmbeddedValue(resolved))
1731
+ : adapter.readEmbeddedValue(value);
1732
+ });
1733
+
1734
+ const installEmbeddedValueResolvers = (gqltype, visited) => {
1735
+ if (!(gqltype instanceof GraphQLObjectType) || visited.has(gqltype)) return;
1736
+ visited.add(gqltype);
1737
+ for (const fieldEntry of Object.values(gqltype.getFields())) {
1738
+ if (fieldEntry.extensions?.relation?.embedded !== true) continue;
1739
+ const listShape = getListShape(fieldEntry.type);
1740
+ if (!listShape && !(fieldEntry.type instanceof GraphQLNonNull) && !fieldEntry.resolve) {
1741
+ fieldEntry.resolve = readEmbeddedField;
1742
+ }
1743
+ installEmbeddedValueResolvers(listShape ? listShape.itemType : unwrapNonNull(fieldEntry.type), visited);
1744
+ }
1745
+ };
1746
+
1747
+ // Configuration issues this runtime already reported, per type, so a repeated createSchema call or a
1748
+ // type reached from several places logs each one once.
1749
+ const reportedIssues = new WeakMap();
1750
+ const warnOnce = (gqltype, key, message) => {
1751
+ if (!reportedIssues.has(gqltype)) reportedIssues.set(gqltype, new Set());
1752
+ const reported = reportedIssues.get(gqltype);
1753
+ if (reported.has(key)) return;
1754
+ reported.add(key);
1755
+ console.warn(message);
1756
+ };
1757
+
1758
+ // A writable referenced collection without a child connectionField would store unlinked children
1759
+ // through generated nested writes and read none back, so it is rejected before anything is built.
1760
+ // One with its own resolver only warns, and its nested writes are rejected per request; a readOnly
1761
+ // one warns unless it has its own resolver.
1762
+ const assertCollectionConnections = () => {
1763
+ for (const { gqltype } of Object.values(typesDict.types)) {
1764
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
1765
+ if (!isUnlinkedCollection(field)) continue;
1766
+ const problem = `${gqltype.name}.${fieldName} requires a child connectionField`;
1767
+ const readOnly = !!field.extensions.readOnly;
1768
+ const ownResolver = hasApplicationResolver(gqltype, fieldName);
1769
+ if (!readOnly && !ownResolver) throw new SimfinityError(problem, 'INVALID_MODEL', 400);
1770
+ if (!readOnly || !ownResolver) {
1771
+ warnOnce(gqltype, `connectionField:${fieldName}`,
1772
+ `Configuration issue: ${problem}; nested writes through it are rejected`);
1773
+ }
1774
+ }
1775
+ }
1661
1776
  };
1662
1777
 
1663
1778
  const getRegistrations = () => Object.values(typesDict.types);
@@ -1668,7 +1783,12 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1668
1783
  const schemaTypes = collectSchemaOutputTypes();
1669
1784
  schemaTypes.forEach(assertNotBoundElsewhere);
1670
1785
  schemaTypes.forEach(assertFieldsNotBoundElsewhere);
1671
- markReferencedTypesForModelGeneration();
1786
+ assertCollectionConnections();
1787
+ const referencedTypes = markReferencedTypesForModelGeneration();
1788
+ // Entities, which keep their own stored identity: connect() types and relation targets.
1789
+ Object.values(typesDict.types).forEach((typeInfo) => {
1790
+ typeInfo.storedIdentity = typeInfo.endpoint === true || referencedTypes.has(typeInfo);
1791
+ });
1672
1792
  if (adapter.prepare) {
1673
1793
  adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
1674
1794
  }
@@ -1688,12 +1808,23 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
1688
1808
  Object.keys(typesDict.types).forEach((typeName) => {
1689
1809
  if (typesDictForUpdate.types[typeName]) {
1690
1810
  typesDictForUpdate.types[typeName].model = typesDict.types[typeName].model;
1811
+ typesDictForUpdate.types[typeName].storedIdentity = typesDict.types[typeName].storedIdentity;
1691
1812
  }
1692
1813
  });
1693
1814
 
1815
+ // Every entity resolves `id`, whatever the query and mutation allowlists include. The type object
1816
+ // is marked too, so query paths of any runtime that shares it follow the same identity.
1817
+ const embeddedValueTypes = new Set();
1694
1818
  Object.values(typesDict.types).forEach((typeInfo) => {
1695
1819
  if (typeInfo.gqltype) {
1820
+ if (typeInfo.storedIdentity) {
1821
+ markStoredIdentity(typeInfo.gqltype);
1822
+ installIdResolver(typeInfo.gqltype);
1823
+ }
1696
1824
  autoGenerateResolvers(typeInfo.gqltype);
1825
+ if (typeof adapter.readEmbeddedValue === 'function') {
1826
+ installEmbeddedValueResolvers(typeInfo.gqltype, embeddedValueTypes);
1827
+ }
1697
1828
  }
1698
1829
  });
1699
1830
 
@@ -1749,6 +1880,16 @@ const claimRelationField = (fieldEntry, resolve, extensions = {}) => {
1749
1880
  relationFieldOwners.set(fieldEntry.resolve, runtimeIdentity);
1750
1881
  };
1751
1882
 
1883
+ // The identifier a single reference reads is stored, not sent by the client, so one the adapter
1884
+ // rejects as malformed, such as a value written outside Simfinity, is a data error rather than a bad
1885
+ // request. Missing records and other errors keep their own classification.
1886
+ const storedReferenceError = (error, gqltype, fieldName) => {
1887
+ if (!(error instanceof SimfinityError) || error.getCode() !== 'NOT_VALID_ID' || error.getStatus() !== 400) {
1888
+ return error;
1889
+ }
1890
+ return new InternalServerError(`${gqltype.name}.${fieldName} stores an invalid identifier`, error);
1891
+ };
1892
+
1752
1893
  const autoGenerateResolvers = (gqltype) => {
1753
1894
  assertFieldsNotBoundElsewhere(gqltype);
1754
1895
  const fields = gqltype.getFields();
@@ -1766,8 +1907,7 @@ const autoGenerateResolvers = (gqltype) => {
1766
1907
  relation.connectionField || fieldName,
1767
1908
  );
1768
1909
  const relatedTypeInfo = requireRegisteredType(typesDict, relatedType, fieldName, gqltype.name);
1769
- const argsObject = createArgsForQuery(relatedTypeInfo.gqltype.getFields());
1770
- if (connection.graphqlFieldName) delete argsObject[connection.graphqlFieldName];
1910
+ const argsObject = createArgsForQuery(relatedTypeInfo.gqltype, connection.graphqlFieldName);
1771
1911
 
1772
1912
  fieldEntry.args = formatArgs(Object.entries(argsObject));
1773
1913
  claimRelationField(fieldEntry, async (parent, args, context) => {
@@ -1783,7 +1923,7 @@ const autoGenerateResolvers = (gqltype) => {
1783
1923
  relatedTypeInfo.model,
1784
1924
  relatedTypeInfo.gqltype,
1785
1925
  connection.storageFieldName,
1786
- parent.id || parent._id,
1926
+ parent._id ?? parent.id,
1787
1927
  params.args,
1788
1928
  null,
1789
1929
  );
@@ -1792,16 +1932,22 @@ const autoGenerateResolvers = (gqltype) => {
1792
1932
  } else if (fieldEntry.type instanceof GraphQLObjectType
1793
1933
  || (fieldEntry.type instanceof GraphQLNonNull && fieldEntry.type.ofType instanceof GraphQLObjectType)) {
1794
1934
  const relatedType = unwrapNonNull(fieldEntry.type);
1795
- const connectionField = relation.connectionField || fieldName;
1935
+ const connectionField = getFieldStorageName(fieldName, fieldEntry);
1796
1936
 
1797
1937
  claimRelationField(fieldEntry, async (parent, args, context) => {
1798
1938
  const relatedTypeInfo = typesDict.types[relatedType.name];
1799
1939
  if (!relatedTypeInfo || !relatedTypeInfo.model) {
1800
1940
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
1801
1941
  }
1802
- const relatedId = parent[connectionField] || parent[fieldName];
1803
- const id = relatedId?._id || relatedId;
1804
- return id ? resolveById(relatedTypeInfo, { id: String(id) }, context, id) : null;
1942
+ // Any stored identifier except null or an empty string, so numeric IDs such as 0 resolve.
1943
+ const relatedId = parent[connectionField] ?? parent[fieldName];
1944
+ const id = relatedId?._id ?? relatedId;
1945
+ if (id == null || id === '') return null;
1946
+ try {
1947
+ return await resolveById(relatedTypeInfo, { id: String(id) }, context, id);
1948
+ } catch (error) {
1949
+ throw storedReferenceError(error, gqltype, fieldName);
1950
+ }
1805
1951
  });
1806
1952
  runtimeBoundTypes.set(gqltype, runtimeIdentity);
1807
1953
  }
@@ -1858,10 +2004,24 @@ const addNoEndpointType = (gqltype) => {
1858
2004
  typesDictForUpdate.types[gqltype.name] = { ...typesDict.types[gqltype.name] };
1859
2005
  };
1860
2006
 
1861
- const createArgsForQuery = (argTypes) => {
2007
+ // Arguments that every list query and collection field generates, after the field filters.
2008
+ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
2009
+
2010
+ // `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
2011
+ // A field named like a generated argument gets no top-level filter either; it is reported once.
2012
+ const createArgsForQuery = (gqltype, omit = null) => {
1862
2013
  const argsObject = {};
2014
+ const argTypes = gqltype.getFields();
2015
+ for (const name of LIST_QUERY_ARGS) {
2016
+ if (name !== omit && Object.hasOwn(argTypes, name)) {
2017
+ warnOnce(gqltype, name, `Configuration issue: ${gqltype.name}.${name} has the name of a generated list `
2018
+ + 'query argument, so list queries and collection fields cannot filter it with a top-level argument; '
2019
+ + 'filter it in AND/OR group conditions or rename the field.');
2020
+ }
2021
+ }
1863
2022
 
1864
2023
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
2024
+ if (fieldEntryName === omit) continue;
1865
2025
  argsObject[fieldEntryName] = {};
1866
2026
 
1867
2027
  if (fieldEntry.type instanceof GraphQLScalarType
package/types/index.d.ts CHANGED
@@ -66,6 +66,10 @@ export class SimfinityError extends Error {
66
66
  getCode(): string | undefined;
67
67
  getStatus(): number | undefined;
68
68
  getTimestamp(): string;
69
+ /** Set on errors that wrap a driver error, such as MongoDB's TRANSACTION_RETRY_EXCEEDED. */
70
+ cause?: unknown;
71
+ /** Set on errors that wrap a driver error, such as MongoDB's TRANSACTION_RETRY_EXCEEDED; returns `cause`. */
72
+ getCause?(): unknown;
69
73
  }
70
74
 
71
75
  export class InternalServerError extends SimfinityError {
@@ -129,6 +133,14 @@ export interface RuntimeRegistration<Model = any, Session = any> extends ModelRe
129
133
  controller?: EntityController<Session> | null;
130
134
  onModelCreated?: ((model: Model) => void) | null;
131
135
  stateMachine?: StateMachine<Session> | null;
136
+ /**
137
+ * Set by `createSchema()` before `adapter.prepare()`: true for entity types, those registered
138
+ * with `connect()` and the targets of non-embedded relations, whose `id` resolves to the stored
139
+ * `_id`, falling back to `id`. False for types this runtime uses only as embedded values. The `id`
140
+ * rule follows the type object, so a type that another runtime sharing it treats as an entity
141
+ * resolves `id` the same way here.
142
+ */
143
+ storedIdentity?: boolean;
132
144
  }
133
145
  export interface MiddlewareContext {
134
146
  args: any;
@@ -194,9 +206,15 @@ export interface Runtime<Model = any, Session = any> {
194
206
  * (`TYPE_BOUND_TO_OTHER_RUNTIME`) any reachable type that another runtime bound or reserved, or
195
207
  * whose field was copied with `toConfig()` after another runtime generated its relation
196
208
  * 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
209
+ * `UNREGISTERED_RELATION_TARGET` (500). A writable non-embedded list relation without a
210
+ * non-empty child `connectionField`, whose resolver this runtime would generate, throws
211
+ * `INVALID_MODEL` (400) before `adapter.prepare()`; one with an application resolver or
212
+ * `readOnly` only logs a `Configuration issue` warning, and its nested writes fail with
213
+ * `INVALID_MODEL` (500). Fields named like generated query arguments also log a warning. The
214
+ * first schema binds the types whose relation resolvers it generates to this runtime and
215
+ * reserves reachable types that still have a
199
216
  * non-embedded relation field without a resolver, such as an unregistered custom mutation result.
217
+ * Every entity type resolves `id` to `_id ?? id`, whatever the allowlists include.
200
218
  */
201
219
  createSchema(includedQueryTypes?: GraphQLObjectType[] | null, includedMutationTypes?: GraphQLObjectType[] | null, includedCustomMutations?: string[] | null): GraphQLSchema;
202
220
  getModel(type: GraphQLObjectType | { name: string }): Model | null | undefined;
@@ -225,12 +243,29 @@ export interface DatabaseAdapter<Model = any, Session = any> {
225
243
  validateRegistration?(registration: RuntimeRegistration<Model, Session>): void;
226
244
  prepare?(registrations: RuntimeRegistration<Model, Session>[], options: { createCollection: boolean }): void;
227
245
  createModel(type: GraphQLObjectType, callback: ((model: Model) => void) | null | undefined, options: { createCollection: boolean }): Model;
246
+ /**
247
+ * Casts a reference or batch key to the stored identifier type. Throw a `SimfinityError` with
248
+ * code `NOT_VALID_ID` (400) for a malformed value; never create a new identifier. A batched
249
+ * reference read whose ID throws or is normalized reads that ID alone with `getById`. When
250
+ * `getById` rejects a stored reference with that error, the generated reference field fails with
251
+ * an `InternalServerError` whose cause is that error, since the client did not send the value.
252
+ */
228
253
  castId(value: any): any;
229
254
  stateValue?(state: { name: string; value: any }): any;
230
255
  withTransaction<T>(session: Session | null | undefined, callback: (session: Session) => Promise<T> | T, model?: Model): Promise<T>;
231
256
  newRecord(model: Model, data: any, session?: Session): any;
232
257
  saveRecord(model: Model, record: any, session?: Session): any;
233
258
  toObject(record: any): any;
259
+ /**
260
+ * Optional. When defined, every nullable, singular embedded object field without a resolver
261
+ * passes its value through `readEmbeddedValue(value)`. The field is read as graphql's default
262
+ * resolver reads it: a method on the parent is called with (args, context, info), and the hook
263
+ * receives its result once a returned promise settles. Return null for a value that a hydrated
264
+ * record renders as an object although the stored value is an explicit null; return any other
265
+ * value unchanged. It must depend only on the value: these resolvers read no data and do not bind
266
+ * their types, so a runtime that reaches a shared type may read through another runtime's hook.
267
+ */
268
+ readEmbeddedValue?(value: any): any;
234
269
  getById(model: Model, id: any, session?: Session | null, options?: { projection?: Record<string, number>; plain?: boolean; lock?: boolean; requiredId?: any; context?: any }): any;
235
270
  /** 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
271
  getByIds?(model: Model, ids: any[], options?: { context?: any }): any;