@simtlix/simfinity-js 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
@@ -6,7 +6,7 @@ MongoDB/Mongoose adapter and compatibility facade for Simfinity. This package ge
6
6
  npm install @simtlix/simfinity-js graphql@^16.11.0 mongoose@^8.24.2
7
7
  ```
8
8
 
9
- `graphql` and `mongoose` are peer dependencies: the facade uses your application's copies, and npm reports an out-of-range version as a peer conflict instead of installing a second copy. The MCP transports (`createMCPServer`, `startStdioMCPServer`, `createHTTPMCPHandler`) need the optional peer `@modelcontextprotocol/sdk@^1.13.0`; install it only when you use them. `generateMCPTools` works without it. The package does not install `graphql-middleware`.
9
+ `graphql` and `mongoose` are peer dependencies: the facade uses your application's copies, and npm reports an out-of-range version as a peer conflict instead of installing a second copy. The MCP transports (`createMCPServer`, `startStdioMCPServer`, `createHTTPMCPHandler`) need the optional peer `@modelcontextprotocol/sdk@^1.31.0`; install it only when you use them. `generateMCPTools` works without it. The package does not install `graphql-middleware`.
10
10
 
11
11
  ```javascript
12
12
  import mongoose from 'mongoose';
@@ -24,6 +24,8 @@ Generated single-reference reads can be batched within a request. Models with an
24
24
 
25
25
  Generated models store enum internal values with their own type (numbers, booleans or strings; other combinations as given) and support embedded fields named `type`. Range filters, sorts and aggregates on numeric and boolean enums follow that type; PostgreSQL uses their text form. Earlier versions stored numeric and boolean enum values as strings; convert existing documents once as described in the [schema guide](https://simtlix.github.io/simfinity.js/guide/schema.html#what-gets-generated).
26
26
 
27
+ The facade registers generated models on your application's default Mongoose instance and does not change global Mongoose options such as `strictQuery`. `createMongoAdapter().castId` accepts only an ObjectId or its 24-character hexadecimal string and throws `NOT_VALID_ID` (400) otherwise; create new identifiers with `new mongoose.Types.ObjectId()`. Owned transactions retry transient failures after a short random delay, and a write conflict that outlasts the retries fails with `TRANSACTION_RETRY_EXCEEDED` (409).
28
+
27
29
  Mongoose 8.24.2 or later within version 8 is required to exclude the update-casting prototype-pollution vulnerability. Existing applications should update Mongoose and their dependency lockfile, then run `npm audit`; upgrading Simfinity does not refresh every transitive dependency already locked by the application.
28
30
 
29
31
  ## Optional transactional reference integrity
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-js",
3
- "version": "3.5.3",
3
+ "version": "3.5.6",
4
4
  "description": "MongoDB/Mongoose adapter and compatibility facade for Simfinity GraphQL APIs",
5
5
  "main": "src/index.js",
6
6
  "types": "./types/index.d.ts",
@@ -16,7 +16,7 @@
16
16
  "directory": "packages/mongodb"
17
17
  },
18
18
  "peerDependencies": {
19
- "@modelcontextprotocol/sdk": "^1.13.0",
19
+ "@modelcontextprotocol/sdk": "^1.31.0",
20
20
  "graphql": "^16.11.0",
21
21
  "mongoose": "^8.24.2"
22
22
  },
@@ -29,8 +29,8 @@
29
29
  "owner": "simtlix"
30
30
  },
31
31
  "dependencies": {
32
- "@simtlix/simfinity-core": "3.5.3",
33
- "@simtlix/simfinity-mcp": "3.5.3"
32
+ "@simtlix/simfinity-core": "3.5.6",
33
+ "@simtlix/simfinity-mcp": "3.5.6"
34
34
  },
35
35
  "files": [
36
36
  "src",
@@ -1,15 +1,42 @@
1
1
  import mongoose from 'mongoose';
2
- import { getNamedType, isListType, isNonNullType } from 'graphql';
2
+ import { SimfinityError } from '@simtlix/simfinity-core';
3
+ import { getListShape, normalizeConnectionField } from '@simtlix/simfinity-core/internal/relation-storage';
3
4
 
4
5
  import { createMongoModel } from './models.js';
5
6
  import { createMongoQueries } from './queries.js';
6
7
  import { withMongoTransaction } from './transactions.js';
7
8
  import { createMongoIntegrity } from './integrity.js';
8
-
9
- mongoose.set('strictQuery', false);
9
+ import {
10
+ castModelId, castObjectId, castPathValue, invalidId, isQueryKeyCastFailure, mapIdCastError,
11
+ } from './ids.js';
10
12
 
11
13
  const withSession = (query, session) => (session ? query.session(session) : query);
12
14
 
15
+ // A write that fails only on malformed ObjectId values reports NOT_VALID_ID, not a driver error.
16
+ const mapIdCastErrors = async (write) => {
17
+ try {
18
+ return await write();
19
+ } catch (error) {
20
+ throw mapIdCastError(error);
21
+ }
22
+ };
23
+
24
+ // By-ID queries take the raw ID, so the model's setters and key cast run once, in Mongoose's own
25
+ // query lifecycle. Keep the chainable Query and map a failed key cast only when it runs; with
26
+ // `mapError`, also map other failures, such as malformed ObjectId values in an update.
27
+ const mapQueryIdCastErrors = (query, mapError = (error) => error) => {
28
+ const { exec } = query;
29
+ if (typeof exec !== 'function') return query;
30
+ query.exec = async function execMappingIdCasts(...args) {
31
+ try {
32
+ return await exec.apply(this, args);
33
+ } catch (error) {
34
+ throw isQueryKeyCastFailure(this, error) ? invalidId() : mapError(error);
35
+ }
36
+ };
37
+ return query;
38
+ };
39
+
13
40
  // Even a shared hook can branch on `this.op` or the filter shape. Preserve findOne semantics
14
41
  // whenever either query operation has hooks instead of inferring equivalence from function identity.
15
42
  const hasNoFindHooks = (hooks) => {
@@ -45,20 +72,22 @@ export const createMongoAdapter = (options) => {
45
72
  for (const registration of registrations) {
46
73
  for (const field of Object.values(registration.gqltype.getFields())) {
47
74
  const relation = field.extensions?.relation;
48
- const listType = isNonNullType(field.type) ? field.type.ofType : field.type;
49
- if (!relation?.connectionField || relation.embedded || !isListType(listType)) continue;
50
- const childType = getNamedType(field.type);
51
- const childFields = childType.getFields();
52
- const connectionExists = childFields[relation.connectionField]
53
- || Object.entries(childFields).some(([fieldName, childField]) => {
54
- const childRelation = childField.extensions?.relation;
55
- return childRelation && !childRelation.embedded
56
- && (childRelation.connectionField || fieldName) === relation.connectionField;
57
- });
58
- if (!connectionExists) addPrivateConnectionField(
59
- childType.name,
60
- relation.connectionField,
61
- );
75
+ const listShape = getListShape(field.type);
76
+ if (!relation?.connectionField || relation.embedded || !listShape) continue;
77
+ // Same rule as core: only a direct or aliased singular reference on the child stores the
78
+ // link. Otherwise, including a child collection that reuses the name (chained or
79
+ // self-referencing collections), the child gets a private ObjectId field.
80
+ const connection = normalizeConnectionField(listShape.itemType, relation.connectionField);
81
+ if (!connection.graphqlFieldName) {
82
+ // Mongoose drops a stored path named constructor, so the link would never be written.
83
+ if (connection.storageFieldName === 'constructor') {
84
+ throw new SimfinityError(
85
+ `${listShape.itemType.name}.constructor cannot be stored on MongoDB: Mongoose drops a stored path named constructor`,
86
+ 'INVALID_MODEL', 400,
87
+ );
88
+ }
89
+ addPrivateConnectionField(listShape.itemType.name, connection.storageFieldName);
90
+ }
62
91
  }
63
92
  }
64
93
  },
@@ -71,9 +100,8 @@ export const createMongoAdapter = (options) => {
71
100
  if (onModelCreated) onModelCreated(model);
72
101
  }, integrity.enabled ? { ...modelOptions, createCollection: false } : modelOptions);
73
102
  },
74
- castId(value) {
75
- return new mongoose.Types.ObjectId(value);
76
- },
103
+ // Relation inputs and batch keys: only an ObjectId or its hex form, never a newly minted one.
104
+ castId: castObjectId,
77
105
  initialize: integrity.initialize,
78
106
  withTransaction: integrity.enabled ? integrity.withTransaction : withMongoTransaction,
79
107
  newRecord(Model, data, session) {
@@ -83,17 +111,27 @@ export const createMongoAdapter = (options) => {
83
111
  },
84
112
  saveRecord(Model, record, session) {
85
113
  if (integrity.enabled) return integrity.saveRecord(Model, record, session === undefined ? record.$session() : session);
86
- return record.save();
114
+ return mapIdCastErrors(() => record.save());
87
115
  },
88
116
  toObject(record) {
89
117
  return typeof record?.toObject === 'function' ? record.toObject() : record;
90
118
  },
119
+ // A hydrated document renders a singular embedded path as an object even when the stored value
120
+ // is an explicit null. Only that stored null reads as null; an absent value keeps Mongoose's
121
+ // materialized object, as legacy documents rely on.
122
+ readEmbeddedValue(value) {
123
+ // The nested accessor's toJSON is called without a receiver on purpose. It reads the schema's
124
+ // toJSON virtuals option from `this` (guarded by `this &&`), and with that option set a stored
125
+ // null renders as `{}`. Without a receiver it reads the raw path value of its own document.
126
+ return value?.$__isNested === true && typeof value.toJSON === 'function' && value.toJSON.call(null) === null
127
+ ? null : value;
128
+ },
91
129
  getById(Model, id, session, { projection, plain, requiredId } = {}) {
92
130
  integrity.assertReady();
93
131
  let query = withSession(requiredId == null ? Model.findById(id, projection)
94
132
  : Model.findOne({ $and: [{ _id: requiredId }, { _id: id }] }, projection), session);
95
133
  if (plain) query = query.lean();
96
- return query;
134
+ return mapQueryIdCastErrors(query);
97
135
  },
98
136
  getByIds(Model, ids) {
99
137
  integrity.assertReady();
@@ -108,19 +146,22 @@ export const createMongoAdapter = (options) => {
108
146
  },
109
147
  update(Model, id, update, session) {
110
148
  if (integrity.enabled) return integrity.updateRecord(Model, id, update, session);
111
- return withSession(Model.findByIdAndUpdate(id, update, { new: true }), session);
149
+ return mapQueryIdCastErrors(withSession(
150
+ Model.findByIdAndUpdate(id, update, { new: true }), session,
151
+ ), mapIdCastError);
112
152
  },
113
153
  delete(Model, id, session) {
114
154
  if (integrity.enabled) {
115
155
  integrity.assertWrite(Model, session);
116
156
  return integrity.deleteRecord(Model, id, session);
117
157
  }
118
- return withSession(Model.findByIdAndDelete(id), session);
158
+ return mapQueryIdCastErrors(withSession(Model.findByIdAndDelete(id), session));
119
159
  },
120
160
  async find(Model, gqltype, args, session, { requiredId } = {}) {
121
161
  integrity.assertReady();
122
162
  const pipeline = await queries.buildQuery(args, gqltype);
123
- if (requiredId != null) pipeline.unshift({ $match: { _id: Model.schema.path('_id').cast(requiredId) } });
163
+ // Mongoose does not cast aggregation stages, so cast the key type here, without setters.
164
+ if (requiredId != null) pipeline.unshift({ $match: { _id: castModelId(Model, requiredId) } });
124
165
  if (pipeline.length === 0) return withSession(Model.find({}), session);
125
166
  return withSession(Model.aggregate(pipeline), session);
126
167
  },
@@ -138,8 +179,15 @@ export const createMongoAdapter = (options) => {
138
179
  async findChildren(Model, gqltype, connectionField, parentId, args, session) {
139
180
  integrity.assertReady();
140
181
  const pipeline = await queries.buildQuery(args, gqltype);
141
- const path = Model.schema.path(connectionField);
142
- pipeline.unshift({ $match: { [connectionField]: path ? path.cast(parentId) : parentId } });
182
+ let parentKey;
183
+ try {
184
+ parentKey = castPathValue(Model.schema.path(connectionField), parentId);
185
+ } catch (error) {
186
+ // A stored parent key that the connection path cannot cast has no children.
187
+ if (error?.extensions?.code === 'NOT_VALID_ID') return [];
188
+ throw error;
189
+ }
190
+ pipeline.unshift({ $match: { [connectionField]: parentKey } });
143
191
  return withSession(Model.aggregate(pipeline), session);
144
192
  },
145
193
  };
@@ -0,0 +1,60 @@
1
+ import mongoose from 'mongoose';
2
+ import { SimfinityError } from '@simtlix/simfinity-core';
3
+
4
+ // Identifier casts shared by the adapter, the query builder and the integrity layer. A malformed
5
+ // identifier is the client's error, so it becomes NOT_VALID_ID (400) instead of a driver error, and
6
+ // the message never echoes the value.
7
+
8
+ export const invalidId = () => new SimfinityError('Invalid identifier', 'NOT_VALID_ID', 400);
9
+
10
+ // Names also match a CastError or BSONError from another mongoose or bson copy used by a supplied model.
11
+ const isCastFailure = (error) => error?.name === 'CastError' || error?.name === 'BSONError';
12
+
13
+ // Accepts only an ObjectId or its 24-character hex form, so a missing or numeric value never mints a
14
+ // new identifier.
15
+ export const castObjectId = (value) => {
16
+ try {
17
+ if (mongoose.isObjectIdOrHexString(value)) return new mongoose.Types.ObjectId(value);
18
+ } catch {
19
+ // Fall through: an ObjectId-like value that does not convert is malformed.
20
+ }
21
+ throw invalidId();
22
+ };
23
+
24
+ // Casts with the model's own path type, as Mongoose would at query time, so supplied models with a
25
+ // String or Number key keep working. Mongoose matches nothing for null or undefined.
26
+ export const castPathValue = (schemaType, value) => {
27
+ if (value == null || !schemaType) return value;
28
+ try {
29
+ return schemaType.cast(value);
30
+ } catch (error) {
31
+ if (isCastFailure(error)) throw invalidId();
32
+ throw error;
33
+ }
34
+ };
35
+
36
+ export const castModelId = (Model, value) => castPathValue(Model.schema.path('_id'), value);
37
+
38
+ // A Mongoose query casts its filter, with the model's setters, when it runs, and keeps a failure as
39
+ // its error flag. A failed cast of the record key there is a malformed identifier, whatever the key
40
+ // type; a key cast failure in an update document, or an error that error middleware substitutes, is not.
41
+ export const isQueryKeyCastFailure = (query, error) => error?.name === 'CastError' && error.path === '_id'
42
+ && typeof query?.error === 'function' && query.error() === error;
43
+
44
+ // A write that fails only because ObjectId values do not cast: an ObjectId CastError, an embedded or
45
+ // array cast wrapping one, or a ValidationError made only of such casts. Any other failure keeps its
46
+ // own error.
47
+ const isObjectIdCastFailure = (error) => {
48
+ if (error?.name === 'CastError') {
49
+ if (error.kind === 'ObjectId') return true;
50
+ const wraps = error.kind === 'embedded' || error.kind === 'Array' || /^\[.*\]$/.test(error.kind ?? '');
51
+ return wraps && isObjectIdCastFailure(error.reason);
52
+ }
53
+ if (error?.name === 'ValidationError') {
54
+ const nested = Object.values(error.errors ?? {});
55
+ return nested.length > 0 && nested.every(isObjectIdCastFailure);
56
+ }
57
+ return false;
58
+ };
59
+
60
+ export const mapIdCastError = (error) => (isObjectIdCastFailure(error) ? invalidId() : error);
@@ -1,6 +1,7 @@
1
1
  import mongoose from 'mongoose';
2
2
  import { describeModels, SimfinityError } from '@simtlix/simfinity-core';
3
3
  import { withMongoTransaction } from './transactions.js';
4
+ import { castModelId, mapIdCastError } from './ids.js';
4
5
 
5
6
  const LOCK_FIELD = '_simfinityReferenceLock';
6
7
  // Startup audit bounds: distinct target ids checked per query and ids remembered per target.
@@ -108,7 +109,8 @@ export const createMongoIntegrity = (options = {}) => {
108
109
  // A caller must not catch that error and commit a borrowed transaction.
109
110
  try { await session.abortTransaction(); } catch { /* Preserve the write error and retry labels. */ }
110
111
  }
111
- throw error;
112
+ // A malformed identifier is reported as NOT_VALID_ID; the transaction is still aborted.
113
+ throw mapIdCastError(error);
112
114
  }
113
115
  };
114
116
 
@@ -254,10 +256,12 @@ export const createMongoIntegrity = (options = {}) => {
254
256
  return saved;
255
257
  });
256
258
  },
259
+ // Protected writes cast the key before querying, without setters, as 3.5.3 did: the lock, the
260
+ // reference checks and the identity checks must all use the key requested before middleware runs.
257
261
  async updateRecord(Model, id, update, session) {
258
262
  assertWrite(Model, session);
259
263
  return abortOnWriteError(session, async () => {
260
- const objectId = Model.schema.path('_id').cast(id);
264
+ const objectId = castModelId(Model, id);
261
265
  const updated = await Model.findByIdAndUpdate(objectId, update, { new: true }).session(session);
262
266
  if (!updated) {
263
267
  // A middleware can hide a completed write with a null result. Abort
@@ -272,7 +276,7 @@ export const createMongoIntegrity = (options = {}) => {
272
276
  },
273
277
  async deleteRecord(Model, id, session) {
274
278
  return abortOnWriteError(session, async () => {
275
- const objectId = Model.schema.path('_id').cast(id);
279
+ const objectId = castModelId(Model, id);
276
280
  if (!await lock(Model, objectId, session)) return null;
277
281
  for (const reference of incoming.get(Model) || []) {
278
282
  const query = reference.Model === Model
@@ -11,6 +11,8 @@ import {
11
11
  GraphQLString,
12
12
  } from 'graphql';
13
13
  import mongoose from 'mongoose';
14
+ import { SimfinityError } from '@simtlix/simfinity-core';
15
+ import { getFieldStorageName } from '@simtlix/simfinity-core/internal/relation-storage';
14
16
 
15
17
  const isNonNullOfType = (fieldEntryType, graphQLType) => (
16
18
  fieldEntryType instanceof GraphQLNonNull && fieldEntryType.ofType instanceof graphQLType
@@ -70,6 +72,21 @@ const enumStorageType = (enumType, fieldName) => {
70
72
  return mongoose.Schema.Types.Mixed;
71
73
  };
72
74
 
75
+ // Mongoose cannot store some fields named like Object.prototype members, because it looks paths up
76
+ // on plain objects that inherit those members. An embedded object so named fails to build, the items
77
+ // of an embedded list so named come back from the driver as raw BSON buffers whose members read as
78
+ // null, and a stored path named `constructor` is dropped from every write. Other scalars, scalar lists and references with
79
+ // these names are stored as usual.
80
+ const unstorableField = (gqlType, fieldEntryName, reason) => new SimfinityError(
81
+ `${gqlType.name}.${fieldEntryName} cannot be stored on MongoDB: ${reason}`, 'INVALID_MODEL', 400,
82
+ );
83
+
84
+ const assertEmbeddedFieldName = (gqlType, fieldEntryName) => {
85
+ if (fieldEntryName in Object.prototype) {
86
+ throw unstorableField(gqlType, fieldEntryName, 'embedded fields cannot be named like Object.prototype members');
87
+ }
88
+ };
89
+
73
90
  const generateSchemaDefinition = (gqlType, nested = false) => {
74
91
  const argTypes = gqlType.getFields();
75
92
  const schemaArg = {};
@@ -90,9 +107,9 @@ const generateSchemaDefinition = (gqlType, nested = false) => {
90
107
  } else if (type instanceof GraphQLObjectType || isNonNullOfType(type, GraphQLObjectType)) {
91
108
  if (fieldEntry.extensions && fieldEntry.extensions.relation) {
92
109
  if (!fieldEntry.extensions.relation.embedded) {
93
- const key = fieldEntry.extensions.relation.connectionField || fieldEntry.name;
94
- schemaArg[key] = mongoose.Schema.Types.ObjectId;
110
+ schemaArg[getFieldStorageName(fieldEntryName, fieldEntry)] = mongoose.Schema.Types.ObjectId;
95
111
  } else {
112
+ assertEmbeddedFieldName(gqlType, fieldEntryName);
96
113
  const entryType = unwrapNonNull(type);
97
114
  if (entryType === gqlType) {
98
115
  throw new Error('A type cannot have a field of its same type and embedded');
@@ -108,6 +125,7 @@ const generateSchemaDefinition = (gqlType, nested = false) => {
108
125
  const itemType = getListItemType(type);
109
126
  if (fieldEntry.extensions && fieldEntry.extensions.relation) {
110
127
  if (fieldEntry.extensions.relation.embedded) {
128
+ assertEmbeddedFieldName(gqlType, fieldEntryName);
111
129
  if (itemType === gqlType) {
112
130
  throw new Error('A type cannot have a field of its same type and embedded');
113
131
  }
@@ -129,6 +147,11 @@ const generateSchemaDefinition = (gqlType, nested = false) => {
129
147
  } else if (isGraphQLisoDate(getEffectiveTypeName(unwrapNonNull(type)))) {
130
148
  schemaArg[fieldEntryName] = Date;
131
149
  }
150
+
151
+ // Whichever stored field takes the `constructor` path, also a reference whose connectionField it is.
152
+ if (Object.hasOwn(schemaArg, 'constructor')) {
153
+ throw unstorableField(gqlType, fieldEntryName, 'Mongoose drops a stored path named constructor');
154
+ }
132
155
  }
133
156
 
134
157
  // In a nested definition, Mongoose reads a `type` key as the type of the whole embedded path.
@@ -9,15 +9,17 @@ import {
9
9
  import mongoose from 'mongoose';
10
10
 
11
11
  import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity-core';
12
+ import {
13
+ getFieldStorageName, getListShape, hasStoredIdentity, normalizeConnectionField,
14
+ } from '@simtlix/simfinity-core/internal/relation-storage';
12
15
 
16
+ import { castObjectId } from './ids.js';
13
17
  import { resolveStorageScalar } from './models.js';
14
18
 
15
19
  const isNonNullOfType = (fieldEntryType, graphQLType) => (
16
20
  fieldEntryType instanceof GraphQLNonNull && fieldEntryType.ofType instanceof graphQLType
17
21
  );
18
22
 
19
- const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
20
-
21
23
  const unwrapListAndNonNull = (type) => {
22
24
  let unwrapped = type;
23
25
  while (unwrapped instanceof GraphQLList || unwrapped instanceof GraphQLNonNull) {
@@ -26,28 +28,6 @@ const unwrapListAndNonNull = (type) => {
26
28
  return unwrapped;
27
29
  };
28
30
 
29
- const isListType = (type) => unwrapNonNull(type) instanceof GraphQLList;
30
-
31
- const getFieldStorageName = (fieldName, field) => {
32
- const relation = field.extensions?.relation;
33
- return relation && !relation.embedded && !isListType(field.type)
34
- ? relation.connectionField || fieldName
35
- : fieldName;
36
- };
37
-
38
- const getConnectionStorageName = (childType, declaredFieldName) => {
39
- const childFields = childType.getFields();
40
- if (childFields[declaredFieldName]) {
41
- return getFieldStorageName(declaredFieldName, childFields[declaredFieldName]);
42
- }
43
- const aliasedField = Object.entries(childFields).find(([fieldName, field]) => (
44
- getFieldStorageName(fieldName, field) === declaredFieldName
45
- ));
46
- return aliasedField
47
- ? getFieldStorageName(aliasedField[0], aliasedField[1])
48
- : declaredFieldName;
49
- };
50
-
51
31
  const getEffectiveTypeName = (type) => (
52
32
  type instanceof GraphQLScalarType ? resolveStorageScalar(type).name : type.name
53
33
  );
@@ -153,6 +133,19 @@ const queryNamedType = (type) => {
153
133
  return result;
154
134
  };
155
135
 
136
+ // `id` is the record key `_id` at an entity boundary: the root or a joined document. Inside an
137
+ // embedded object it follows what reads return. Entity types read the stored subdocument `_id`
138
+ // through the generated id resolver; that resolver lives on the type object, so any runtime that
139
+ // marked the type an entity decides it, not only this one. Every other embedded object stores and
140
+ // returns its declared `id` member. A supplied subdocument schema without an `id` path keeps `_id`,
141
+ // which only hydrated reads expose, through Mongoose's `id` virtual; list reads return null for it.
142
+ const identifierLeaf = (model, schemaPath, gqltype) => {
143
+ if (!schemaPath) return '_id';
144
+ const schema = model?.schema;
145
+ if (!schema?.path(`${schemaPath}._id`)) return 'id';
146
+ return hasStoredIdentity(gqltype) || !schema.path(`${schemaPath}.id`) ? '_id' : 'id';
147
+ };
148
+
156
149
  // Resolve GraphQL paths and storage paths together; supplied models own ID casting.
157
150
  const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
158
151
  assertValidFilterPath(path);
@@ -176,9 +169,11 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
176
169
  } else {
177
170
  const relatedModel = getModel(fieldType);
178
171
  if (!relatedModel) throw filterError(`Related model is not available for ${path}`, 'INVALID_FILTER_PATH');
179
- const isList = unwrapNonNull(field.type) instanceof GraphQLList;
180
- const declaredField = relation.connectionField || part;
181
- const connField = isList ? getConnectionStorageName(fieldType, declaredField) : declaredField;
172
+ const isList = !!getListShape(field.type);
173
+ // A collection without connectionField keeps reading through its field name, as before.
174
+ const connField = isList
175
+ ? normalizeConnectionField(fieldType, relation.connectionField || part).storageFieldName
176
+ : getFieldStorageName(part, field);
182
177
  const localLeaf = isList ? '_id' : connField;
183
178
  const alias = allocateLookupAlias(aggregationsIncluded, parts.slice(0, index + 1).join('.'));
184
179
  aggregateClauses[alias] = buildRelationLookup({
@@ -194,7 +189,7 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
194
189
  currentType = fieldType;
195
190
  } else {
196
191
  if (index !== parts.length - 1) throw filterError(`Cannot traverse scalar field in ${path}`, 'INVALID_FILTER_PATH');
197
- const leaf = part === 'id' ? '_id' : part;
192
+ const leaf = part === 'id' ? identifierLeaf(currentModel, schemaPath, currentType) : part;
198
193
  const storagePath = schemaPath ? `${schemaPath}.${leaf}` : leaf;
199
194
  return {
200
195
  mongoPath: mongoPath ? `${mongoPath}.${leaf}` : leaf,
@@ -228,7 +223,7 @@ const castFilterValue = (value, resolved) => {
228
223
  if (Number.isNaN(date.getTime())) throw new Error('Invalid date');
229
224
  return date;
230
225
  }
231
- if (scalar === GraphQLID && !schemaType) return new mongoose.Types.ObjectId(value);
226
+ if (scalar === GraphQLID && !schemaType) return castObjectId(value);
232
227
  if (scalar instanceof GraphQLEnumType) {
233
228
  const entry = scalar.getValues().find((item) => item.name === value)
234
229
  || scalar.getValues().find((item) => item.value === value);
@@ -289,9 +284,9 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
289
284
 
290
285
  const MAX_FILTER_GROUP_DEPTH = 5;
291
286
 
292
- const validateLogicalLists = (group) => {
287
+ const validateLogicalLists = (group, keys = ['AND', 'OR', 'conditions']) => {
293
288
  if (!group || typeof group !== 'object' || Array.isArray(group)) throw filterError('Expected a filter group object');
294
- for (const key of ['AND', 'OR', 'conditions']) {
289
+ for (const key of keys) {
295
290
  if (group[key] != null && !Array.isArray(group[key])) throw filterError(`${key} requires an array`);
296
291
  }
297
292
  };
@@ -367,17 +362,21 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
367
362
  return { $and: parts };
368
363
  };
369
364
 
370
- const RESERVED_QUERY_KEYS = new Set(['pagination', 'sort', 'AND', 'OR', 'aggregation']);
365
+ const FIND_CONTROL_KEYS = new Set(['pagination', 'sort', 'AND', 'OR']);
366
+ // `aggregation` is the expression argument only on aggregate queries; elsewhere it names a field.
367
+ const AGGREGATE_CONTROL_KEYS = new Set([...FIND_CONTROL_KEYS, 'aggregation']);
371
368
 
372
- const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggregationsIncluded) => {
373
- validateLogicalLists(input);
369
+ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggregationsIncluded, operation = 'find') => {
370
+ // Top-level arguments are field filters plus AND/OR groups; `conditions` is a key only inside groups.
371
+ validateLogicalLists(input, ['AND', 'OR']);
372
+ const controlKeys = operation === 'aggregate' ? AGGREGATE_CONTROL_KEYS : FIND_CONTROL_KEYS;
374
373
  const flatMatchConditions = {};
375
374
  const repeatedConditions = [];
376
375
  let hasFlat = false;
377
376
  const fields = gqltype.getFields();
378
377
 
379
378
  for (const [key, filterField] of Object.entries(input)) {
380
- if (RESERVED_QUERY_KEYS.has(key)) continue;
379
+ if (controlKeys.has(key)) continue;
381
380
  const qlField = fields[key];
382
381
  const result = await buildQueryTerms(filterField, qlField, key, gqltype, aggregationsIncluded);
383
382
  if (!result) continue;
@@ -448,7 +447,7 @@ const buildQuery = async (input, gqltype, isCount) => {
448
447
  const aggregationsIncluded = {};
449
448
  const paging = paginationStages(input.pagination, true);
450
449
 
451
- const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded);
450
+ const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded, 'find');
452
451
  if (matchStage) aggregateClauses.push(matchStage);
453
452
 
454
453
  if (isCount) {
@@ -476,7 +475,7 @@ const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
476
475
  const aggregationsIncluded = {};
477
476
  const paging = paginationStages(input.pagination, false);
478
477
 
479
- const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded);
478
+ const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded, 'aggregate');
480
479
  if (matchStage) aggregateClauses.push(matchStage);
481
480
 
482
481
  const { groupId, facts } = aggregationExpression ?? {};
@@ -3,6 +3,23 @@ import { SimfinityError } from '@simtlix/simfinity-core';
3
3
 
4
4
  const MAX_TRANSIENT_RETRIES = 5;
5
5
  const MAX_COMMIT_RETRIES = 5;
6
+ // Full-jitter exponential backoff before each complete-transaction retry: a uniform wait below
7
+ // 10, 20, 40, 80 and 160 ms, so conflicting transactions do not retry in lock-step.
8
+ const RETRY_BASE_DELAY_MS = 10;
9
+ const WRITE_CONFLICT = 112;
10
+
11
+ const retryDelay = (attempt) => Math.random() * RETRY_BASE_DELAY_MS * 2 ** attempt;
12
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
13
+ const isWriteConflict = (error) => error?.code === WRITE_CONFLICT || error?.codeName === 'WriteConflict';
14
+
15
+ // Same code and status as PostgreSQL's exhausted serialization and deadlock retries. The driver
16
+ // error stays available to logging, as on InternalServerError.
17
+ const retryExceeded = (error) => {
18
+ const exceeded = new SimfinityError('Concurrent write could not be completed', 'TRANSACTION_RETRY_EXCEEDED', 409);
19
+ exceeded.cause = error;
20
+ exceeded.getCause = () => exceeded.cause;
21
+ return exceeded;
22
+ };
6
23
 
7
24
  const commitWithRetry = async (session) => {
8
25
  for (let attempt = 0; ; attempt++) {
@@ -45,7 +62,7 @@ export const withMongoTransaction = async (session, body, model, transactionOpti
45
62
  ? await mongoose.startSession() : await connection.startSession();
46
63
  let failed = false;
47
64
  try {
48
- for (let attempt = 0; attempt <= MAX_TRANSIENT_RETRIES; attempt++) {
65
+ for (let attempt = 0; ; attempt++) {
49
66
  if (transactionOptions) await mySession.startTransaction(transactionOptions);
50
67
  else await mySession.startTransaction();
51
68
  try {
@@ -65,14 +82,16 @@ export const withMongoTransaction = async (session, body, model, transactionOpti
65
82
  throw error;
66
83
  }
67
84
  }
68
- const isTransient = error?.errorLabels?.includes('TransientTransactionError');
69
- if (isTransient && attempt < MAX_TRANSIENT_RETRIES) {
85
+ if (!error?.errorLabels?.includes('TransientTransactionError')) throw error;
86
+ if (attempt < MAX_TRANSIENT_RETRIES) {
87
+ // Wait only once the failed attempt is aborted, so its locks are not held meanwhile.
88
+ await sleep(retryDelay(attempt));
70
89
  continue;
71
90
  }
72
- throw error;
91
+ // Other exhausted transient failures (network, NoSuchTransaction) are not concurrency.
92
+ throw isWriteConflict(error) ? retryExceeded(error) : error;
73
93
  }
74
94
  }
75
- throw new SimfinityError('Transaction exceeded retry limit', 'TRANSACTION_RETRY_EXCEEDED', 500);
76
95
  } catch (error) {
77
96
  failed = true;
78
97
  throw error;
package/types/index.d.ts CHANGED
@@ -53,6 +53,14 @@ export interface MongoAdapterOptions {
53
53
  }
54
54
  export interface MongoAdapter extends DatabaseAdapter {
55
55
  readonly referentialIntegrity: 'off' | 'transactional';
56
+ /**
57
+ * Accepts only an ObjectId or a 24-character hexadecimal string, normalized to lowercase, and
58
+ * throws `NOT_VALID_ID` (400) for anything else, including undefined, null and numbers. It never
59
+ * creates an identifier; use `new mongoose.Types.ObjectId()` for that.
60
+ */
61
+ castId(value: any): any;
62
+ /** Returns null for a hydrated nested path whose stored value is an explicit null; otherwise the value. */
63
+ readEmbeddedValue(value: any): any;
56
64
  /** After connecting MongoDB and createSchema(), await the transaction capability and existing-reference audit. */
57
65
  initialize(): Promise<void>;
58
66
  }
@@ -107,7 +115,10 @@ export function addNoEndpointType(gqltype: GraphQLObjectType): void;
107
115
  * holding a field copied with toConfig() after another runtime generated its
108
116
  * relation resolver, keeping the generated resolver or its extensions, are rejected (TYPE_BOUND_TO_OTHER_RUNTIME), before models
109
117
  * are created; a list or embedded relation to an unregistered type throws
110
- * UNREGISTERED_RELATION_TARGET.
118
+ * UNREGISTERED_RELATION_TARGET. A writable non-embedded list relation without a child
119
+ * connectionField, whose resolver Simfinity would generate, throws INVALID_MODEL (400). In default
120
+ * mode, one with its own resolver or readOnly only logs a warning and rejects nested writes
121
+ * (INVALID_MODEL, 500); referentialIntegrity 'transactional' rejects every such collection.
111
122
  */
112
123
  export function createSchema(
113
124
  includedQueryTypes?: GraphQLObjectType[] | null,
@@ -186,7 +197,10 @@ export function getInputType(type: GraphQLObjectType | { name: string }): GraphQ
186
197
  * The caller then owns retries, commit, abort, and cleanup; inactive sessions are rejected.
187
198
  * Exception: transactional reference integrity aborts on a violation, guarded-write error or ambiguous update result.
188
199
  * That mode requires snapshot read concern and majority write concern on supplied transactions.
189
- * Owned transactions retry transient failures and uncertain commits separately, up to five times each.
200
+ * Owned transactions retry transient failures after a short randomized backoff, and uncertain commits
201
+ * immediately, up to five times each. An exhausted write conflict throws TRANSACTION_RETRY_EXCEEDED
202
+ * (409) with the driver error as `cause`; a supplied session receives the raw driver error.
203
+ * Relation inputs need `{ id }`; a missing or malformed ID throws NOT_VALID_ID (400).
190
204
  */
191
205
 
192
206
  export function saveObject(
@@ -221,7 +235,12 @@ export interface FilterGroupInput {
221
235
  [key: string]: any;
222
236
  }
223
237
 
224
- /** Translate list-query arguments (filters, AND/OR, sort, pagination) into a MongoDB aggregation pipeline. */
238
+ /**
239
+ * Translate list-query arguments (filters, AND/OR, sort, pagination) into a MongoDB aggregation
240
+ * pipeline. `input.aggregation` filters a field named `aggregation`; the aggregate stage is never
241
+ * built here. An embedded `.id` path uses the declared `id` member, except for entity types embedded
242
+ * as lists and supplied subdocument schemas without an `id` path, which use the subdocument `_id`.
243
+ */
225
244
  export function buildQuery(
226
245
  input: Record<string, any>,
227
246
  gqltype: GraphQLObjectType,