@simtlix/simfinity-js 3.5.8 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,11 +20,11 @@ const schema = simfinity.createSchema();
20
20
 
21
21
  Use a MongoDB replica set or sharded cluster for transactional mutations. The library requires Node.js >=18.18.0 and GraphQL 16.
22
22
 
23
- Generated single-reference reads can be batched within a request. Models with any `find` or `findOne` pre/post query hook retain individual `findOne` reads, including hooks shared by both operations, to preserve access restrictions and other middleware behavior. See the [relationship contract](https://simtlix.github.io/simfinity.js/guide/relationships.html).
23
+ Generated single-reference reads can be batched within a request. Models with any `find` or `findOne` pre/post query hook retain individual `findOne` reads, including hooks shared by both operations, to preserve access restrictions and other middleware behavior. For the same reason, they keep the read that generated mutations otherwise skip before a delete without `onDelete` and before an update that merges no embedded value. So do models with `findOneAndUpdate` or `findOneAndDelete` hooks (including the one Mongoose's `timestamps` option adds) or with their own `findById`, `findOne`, `findByIdAndUpdate`, `findOneAndUpdate`, `findByIdAndDelete` or `findOneAndDelete` statics, which run before Mongoose casts the ID: the read rejects a malformed ID first. Models with application `_id` setters also keep the read, because setters can branch on the query operation before casting; Mongoose's built-in automatic ObjectId setter remains eligible for the optimization. See the [relationship contract](https://simtlix.github.io/simfinity.js/guide/relationships.html).
24
24
 
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).
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`. Embedded types that contain each other are rejected with `INVALID_MODEL` (`Embedded cycle at …`) when a generated model reaches them, and always in transactional mode; in the default mode, a cycle that only supplied models reach is not checked. 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).
27
+ The facade registers generated models on your application's default Mongoose instance and does not change global Mongoose options such as `strictQuery`. Supplied models may use another connection, such as one from `mongoose.createConnection()`: generated mutations and `saveObject()` use the model's connection, and, in the default `referentialIntegrity: 'off'` mode, custom mutations use the default connection unless it is unused, never opened and without compiled models, also on its `useDb()` descendants, and the registered models share one MongoDB client; with transactional reference integrity they use the first protected model's connection. `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
28
 
29
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.
30
30
 
@@ -50,7 +50,7 @@ Owned transactions use snapshot reads and majority commits. Supplied sessions ne
50
50
 
51
51
  The source now lives in `packages/mongodb` in the monorepo. Its public npm name remains `@simtlix/simfinity-js`; existing imports, including paths under `@simtlix/simfinity-js/src/`, retain their layout. Shared helpers, error identities and MCP compatibility exports are preserved. Internal Simfinity dependencies use the same exact release version.
52
52
 
53
- The legacy `src/` deep imports have no TypeScript declarations. In TypeScript, import `auth`, `plugins`, `scalars` and `validators` from the package root. Importing the typed `@simtlix/simfinity-core` subpaths, such as `@simtlix/simfinity-core/auth`, instead needs `@simtlix/simfinity-core` as a direct dependency pinned to this package's exact version.
53
+ Its legacy `src/` modules that re-export public API (`src/index.js`, `src/auth/index.js`, `src/auth/errors.js`, `src/auth/expressions.js`, `src/auth/rules.js`, `src/plugins.js`, `src/scalars.js`, `src/validators.js`, `src/mcp.js`, `src/const/*.js`, `src/errors/*.js`, and their extensionless aliases) ship TypeScript declarations with the types of the package root and the matching `@simtlix/simfinity-core` subpaths; `src/mongo/*` is internal and undeclared. New code should still import `auth`, `plugins`, `scalars` and `validators` from the package root. Importing the typed `@simtlix/simfinity-core` subpaths, such as `@simtlix/simfinity-core/auth`, instead needs `@simtlix/simfinity-core` as a direct dependency pinned to this package's exact version.
54
54
 
55
55
  For tooling that locates the package, use `require.resolve('@simtlix/simfinity-js/package.json')` or the package root without a trailing slash. Directory specifiers ending in `/` were not valid ESM imports and are not supported by the export map, including in `require.resolve`; use explicit filenames such as `src/auth/index.js`.
56
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-js",
3
- "version": "3.5.8",
3
+ "version": "3.6.0",
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",
@@ -29,8 +29,8 @@
29
29
  "owner": "simtlix"
30
30
  },
31
31
  "dependencies": {
32
- "@simtlix/simfinity-core": "3.5.8",
33
- "@simtlix/simfinity-mcp": "3.5.8"
32
+ "@simtlix/simfinity-core": "3.6.0",
33
+ "@simtlix/simfinity-mcp": "3.6.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
@@ -0,0 +1,2 @@
1
+ export * from '@simtlix/simfinity-core/auth/errors';
2
+ export { default } from '@simtlix/simfinity-core/auth/errors';
@@ -0,0 +1,2 @@
1
+ export * from '@simtlix/simfinity-core/auth/expressions';
2
+ export { default } from '@simtlix/simfinity-core/auth/expressions';
@@ -0,0 +1,2 @@
1
+ export * from '@simtlix/simfinity-core/auth';
2
+ export { default } from '@simtlix/simfinity-core/auth';
@@ -0,0 +1,2 @@
1
+ export * from '@simtlix/simfinity-core/auth/rules';
2
+ export { default } from '@simtlix/simfinity-core/auth/rules';
@@ -0,0 +1,2 @@
1
+ /** Declarations for the legacy `@simtlix/simfinity-js/src/const/QLOperator.js` deep import: core's `QLOperator`. */
2
+ export { QLOperator as default } from '@simtlix/simfinity-core';
@@ -0,0 +1,2 @@
1
+ /** Declarations for the legacy `@simtlix/simfinity-js/src/const/QLSort.js` deep import: core's `QLSort`. */
2
+ export { QLSort as default } from '@simtlix/simfinity-core';
@@ -0,0 +1,2 @@
1
+ /** Declarations for the legacy `@simtlix/simfinity-js/src/const/QLValue.js` deep import: core's `QLValue`. */
2
+ export { QLValue as default } from '@simtlix/simfinity-core';
@@ -0,0 +1,2 @@
1
+ /** Declarations for the legacy `@simtlix/simfinity-js/src/errors/internal-server.error.js` deep import: core's `InternalServerError`. */
2
+ export { InternalServerError as default } from '@simtlix/simfinity-core';
@@ -0,0 +1,2 @@
1
+ /** Declarations for the legacy `@simtlix/simfinity-js/src/errors/simfinity.error.js` deep import: core's `SimfinityError`. */
2
+ export { SimfinityError as default } from '@simtlix/simfinity-core';
package/src/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Declarations for the legacy `@simtlix/simfinity-js/src/index.js` (and `src`) deep import: the package root. */
2
+ export * from '../types/index.js';
package/src/mcp.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** Declarations for the legacy `@simtlix/simfinity-js/src/mcp.js` deep import, which re-exports `@simtlix/simfinity-mcp`. */
2
+ export * from '@simtlix/simfinity-mcp';
3
+ export { default } from '@simtlix/simfinity-mcp';
@@ -39,22 +39,145 @@ const mapQueryIdCastErrors = (query, mapError = (error) => error) => {
39
39
 
40
40
  // Even a shared hook can branch on `this.op` or the filter shape. Preserve findOne semantics
41
41
  // whenever either query operation has hooks instead of inferring equivalence from function identity.
42
- const hasNoFindHooks = (hooks) => {
42
+ const hasNoHooks = (hooks, operations) => {
43
43
  if (!(hooks?._pres instanceof Map) || !(hooks._posts instanceof Map)) return false;
44
- return ['find', 'findOne'].every((operation) => !hooks._pres.get(operation)?.length
44
+ return operations.every((operation) => !hooks._pres.get(operation)?.length
45
45
  && !hooks._posts.get(operation)?.length);
46
46
  };
47
47
 
48
- // getById reads through `findOne` and getByIds through `find`. Mongoose copies the schema's query
49
- // hooks to the model when compiling it, so both registries must allow the batched read.
50
- const batchesFindOne = (Model) => hasNoFindHooks(Model.schema?.s?.hooks)
51
- && (Model.Query?.prototype?._queryMiddleware == null || hasNoFindHooks(Model.Query.prototype._queryMiddleware));
48
+ // Mongoose copies the schema's query hooks to the model when compiling it, so both registries must
49
+ // be free of hooks for the operations.
50
+ const hasNoQueryHooks = (Model, operations) => hasNoHooks(Model.schema?.s?.hooks, operations)
51
+ && (Model.Query?.prototype?._queryMiddleware == null || hasNoHooks(Model.Query.prototype._queryMiddleware, operations));
52
+
53
+ // getById reads through `findOne` and getByIds through `find`.
54
+ const batchesFindOne = (Model) => hasNoQueryHooks(Model, ['find', 'findOne']);
55
+
56
+ // The statics that getById, update and delete call, directly or through each other.
57
+ const BY_ID_STATICS = ['findById', 'findOne', 'findByIdAndUpdate', 'findOneAndUpdate', 'findByIdAndDelete', 'findOneAndDelete'];
58
+ // Mongoose's automatic ObjectId setter only changes document assignments, never query filters.
59
+ const autoIdSetter = new mongoose.Schema.Types.ObjectId('_id').auto(true).setters.at(-1);
60
+
61
+ // update and delete write through findByIdAndUpdate and findByIdAndDelete, whose findOneAndUpdate and
62
+ // findOneAndDelete middleware runs before Mongoose casts the ID. Such middleware, or an application
63
+ // override of one of these statics (schema.static, loadClass), could guard the read or act on a
64
+ // malformed ID that the read by ID rejects first, so the runtime keeps that read. ID setters also
65
+ // run before casting and can branch on the query operation. A model from another Mongoose copy
66
+ // has other statics and keeps the read too.
67
+ const readsByIdAreInert = (Model) => BY_ID_STATICS.every((name) => Model[name] === mongoose.Model[name])
68
+ && Model.schema?.path('_id')?.setters?.every((setter) => setter === autoIdSetter)
69
+ && hasNoQueryHooks(Model, ['find', 'findOne', 'findOneAndUpdate', 'findOneAndDelete']);
70
+
71
+ // `useDb()` connections share their parent's MongoClient, and a session belongs to a client.
72
+ const clientOf = (connection) => connection.getClient?.() || connection;
73
+
74
+ // Whether a schema lets Mongoose render a member inside an embedded object from something other
75
+ // than its stored value: a getter on a path, a virtual, an alias or, on a subdocument, a method
76
+ // (GraphQL's default resolver calls one named like a member), at any depth (nested paths,
77
+ // subdocuments, document arrays, map values). Core cannot tell such an object's data without
78
+ // running them, so rawEmbeddedValue leaves it unconverted and core keeps it. Read from schema
79
+ // metadata only. The `id` virtual that Mongoose adds to a schema with an `_id` renders that `_id`,
80
+ // which core reads itself, so it does not count; one the application declares does: it has another
81
+ // getter, or the schema disables the automatic one.
82
+ const isAutomaticIdVirtual = (schema, name, virtual) => name === 'id' && virtual?.getters?.length === 1
83
+ && schema.options?.id !== false && schema.paths._id !== undefined && schema.paths.id === undefined;
84
+ const schemaTypeComputes = (schemaType, seen) => {
85
+ if (!(schemaType instanceof mongoose.SchemaType) || seen.has(schemaType)) return false;
86
+ seen.add(schemaType);
87
+ if (schemaType.getters?.length > 0) return true;
88
+ // schemaComputes is defined below: schemas and their paths contain each other.
89
+ if (schemaType.schema && schemaComputes(schemaType.schema, '', seen)) return true;
90
+ // Map values, array items and document array elements.
91
+ return [schemaType.$__schemaType, schemaType.$embeddedSchemaType, schemaType.caster]
92
+ .some((inner) => schemaTypeComputes(inner, seen));
93
+ };
94
+ // `prefix` is a nested path of the schema; '' is the whole schema, as a subdocument's own.
95
+ const schemaComputes = (schema, prefix, seen) => {
96
+ if (prefix === '') {
97
+ if (seen.has(schema)) return false;
98
+ seen.add(schema);
99
+ // Nested paths get no schema methods; a subdocument's own schema can.
100
+ if (Object.keys(schema.methods ?? {}).length > 0) return true;
101
+ }
102
+ const inside = (path) => prefix === '' || path.startsWith(`${prefix}.`);
103
+ return Object.entries(schema.paths ?? {}).some(([path, schemaType]) => inside(path) && schemaTypeComputes(schemaType, seen))
104
+ || Object.entries(schema.virtuals ?? {}).some(([name, virtual]) => inside(name)
105
+ && !(prefix === '' && isAutomaticIdVirtual(schema, name, virtual)))
106
+ // An alias stores its value under another path, and may be declared outside a nested path.
107
+ || Object.entries(schema.aliases ?? {}).some(([alias, path]) => inside(alias) || inside(path));
108
+ };
109
+ // Schemas do not change once their models are compiled, so each answer is computed once.
110
+ const computesCache = new WeakMap();
111
+ const cachedComputes = (key, prefix, compute) => {
112
+ let byPrefix = computesCache.get(key);
113
+ if (!byPrefix) {
114
+ byPrefix = new Map();
115
+ computesCache.set(key, byPrefix);
116
+ }
117
+ if (!byPrefix.has(prefix)) byPrefix.set(prefix, compute());
118
+ return byPrefix.get(prefix);
119
+ };
120
+ const declaresComputedMembers = (schema, prefix) => cachedComputes(schema, prefix, () => schemaComputes(schema, prefix, new Set()));
121
+ const declaresComputedValues = (schemaType) => cachedComputes(schemaType, '', () => schemaTypeComputes(schemaType, new Set()));
122
+ // A hydrated nested path is cached by its full path in its document's internal getters cache, which
123
+ // it shares; '' (the whole schema) if it is not found there.
124
+ const nestedPathOf = (nested) => {
125
+ const cache = nested.$__?.getters;
126
+ return (cache && Object.keys(cache).find((path) => cache[path] === nested)) ?? '';
127
+ };
52
128
 
53
129
  export const createMongoAdapter = (options) => {
54
130
  const integrity = createMongoIntegrity(options);
55
131
  let queries;
132
+ let getRegistrations = () => [];
56
133
  const privateConnectionFields = new Map();
57
134
 
135
+ // A registered custom mutation passes no model, so its owned session stays on the default
136
+ // mongoose.connection, as before. While that connection has never been opened (no client), a
137
+ // session there can only wait and time out. Use the one MongoDB client of the registered models
138
+ // instead, but only when nothing can need the default connection:
139
+ // - any model compiled on it, generated or the application's, registered or not, means the
140
+ // application opens it, maybe after the request arrived, and the callback may write that
141
+ // model with the session, which must then come from the default connection's client. The
142
+ // same holds for a model compiled on any useDb() descendant of it, such as
143
+ // mongoose.connection.useDb('tenant').useDb('audit'), which shares its client. A connection's
144
+ // otherDbs lists its direct useDb() children, not theirs, so the walk follows otherDbs at any
145
+ // depth.
146
+ // useDb(name, { noListener: true }) leaves a connection out of its parent's otherDbs, so neither
147
+ // it nor its descendants are seen, and native collection writes
148
+ // (mongoose.connection.collection()) compile no model;
149
+ // - registered models on several clients have no single client to choose.
150
+ // Once the default connection has a client, nothing changes.
151
+ const hasModels = (connection) => Object.keys(connection.models).length > 0;
152
+ // A child's otherDbs also lists its parent, so the walk visits each connection once, cycles
153
+ // included: a Set iteration also visits the entries added while it runs.
154
+ const defaultClientHasModels = () => {
155
+ const seen = new Set([mongoose.connection]);
156
+ for (const connection of seen) {
157
+ if (hasModels(connection)) return true;
158
+ for (const other of connection.otherDbs) seen.add(other);
159
+ }
160
+ return false;
161
+ };
162
+ const sessionModel = () => {
163
+ if (mongoose.connection.getClient() || defaultClientHasModels()) return undefined;
164
+ let found;
165
+ for (const { model } of getRegistrations()) {
166
+ const connection = model?.db;
167
+ if (!connection) continue;
168
+ if (connection === mongoose.connection) return undefined;
169
+ if (!found) found = model;
170
+ else if (clientOf(found.db) !== clientOf(connection)) return undefined;
171
+ }
172
+ return found;
173
+ };
174
+
175
+ // A supplied session or model is used as is; only an owned session without a model looks up
176
+ // the registrations.
177
+ const withOwnedTransaction = (session, body, Model, ...rest) => withMongoTransaction(
178
+ session, body, (Model || session) ? Model : sessionModel(), ...rest,
179
+ );
180
+
58
181
  const addPrivateConnectionField = (typeName, fieldName) => {
59
182
  if (!privateConnectionFields.has(typeName)) privateConnectionFields.set(typeName, new Set());
60
183
  privateConnectionFields.get(typeName).add(fieldName);
@@ -63,6 +186,7 @@ export const createMongoAdapter = (options) => {
63
186
  const adapter = {
64
187
  bind(binding) {
65
188
  integrity.bind(binding);
189
+ getRegistrations = binding.getRegistrations;
66
190
  queries = createMongoQueries(binding);
67
191
  adapter.buildQuery = queries.buildQuery;
68
192
  adapter.buildFilterGroupMatch = queries.buildFilterGroupMatch;
@@ -103,7 +227,7 @@ export const createMongoAdapter = (options) => {
103
227
  // Relation inputs and batch keys: only an ObjectId or its hex form, never a newly minted one.
104
228
  castId: castObjectId,
105
229
  initialize: integrity.initialize,
106
- withTransaction: integrity.enabled ? integrity.withTransaction : withMongoTransaction,
230
+ withTransaction: integrity.enabled ? integrity.withTransaction : withOwnedTransaction,
107
231
  newRecord(Model, data, session) {
108
232
  const record = new Model(data);
109
233
  record.$session(session);
@@ -118,7 +242,8 @@ export const createMongoAdapter = (options) => {
118
242
  },
119
243
  // A hydrated document renders a singular embedded path as an object even when the stored value
120
244
  // 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.
245
+ // materialized object; core then reads it as null when its stored members (rawEmbeddedValue)
246
+ // hold no data and miss a required member (runtime.js readEmbeddedObject).
122
247
  readEmbeddedValue(value) {
123
248
  // The nested accessor's toJSON is called without a receiver on purpose. It reads the schema's
124
249
  // toJSON virtuals option from `this` (guarded by `this &&`), and with that option set a stored
@@ -126,6 +251,30 @@ export const createMongoAdapter = (options) => {
126
251
  return value?.$__isNested === true && typeof value.toJSON === 'function' && value.toJSON.call(null) === null
127
252
  ? null : value;
128
253
  },
254
+ // The stored data of a hydrated embedded value, which core reads to tell whether it holds data
255
+ // without running schema getters or virtuals: a nested path's raw value (its toJSON without a
256
+ // receiver, as above), a subdocument's _doc or a Mongoose map's entries. The first two hold an
257
+ // absent nested path as {}, as Mongoose renders it, and keep subdocuments and maps hydrated,
258
+ // which core passes back here. A subdocument's automatic `id` virtual is not in _doc; core reads
259
+ // `_id` for `id`. A value whose schema declares a getter, a virtual, an alias or a subdocument
260
+ // method on a member at any depth inside it (declaresComputedMembers) is returned unchanged, so core counts it as data
261
+ // and keeps it, as 3.5.8 rendered it; no getter runs. Generated models declare none. Any other
262
+ // value is returned unchanged too.
263
+ rawEmbeddedValue(value) {
264
+ if (value?.$__isNested === true) {
265
+ // A schema from another Mongoose copy cannot be inspected; keep the value as 3.5.8 did.
266
+ if (typeof value.toJSON !== 'function' || !(value.$__schema instanceof mongoose.Schema)
267
+ || declaresComputedMembers(value.$__schema, nestedPathOf(value))) return value;
268
+ return value.toJSON.call(null);
269
+ }
270
+ // Iterating a map reads its stored values; its get() would run the value getters.
271
+ if (value instanceof mongoose.Types.Map) {
272
+ return declaresComputedValues(value.$__schemaType) ? value : Object.fromEntries(value);
273
+ }
274
+ if (!(value instanceof mongoose.Document) || !value._doc) return value;
275
+ const schema = value.$__schema ?? value.schema;
276
+ return !schema || declaresComputedMembers(schema, '') ? value : value._doc;
277
+ },
129
278
  getById(Model, id, session, { projection, plain, requiredId } = {}) {
130
279
  integrity.assertReady();
131
280
  let query = withSession(requiredId == null ? Model.findById(id, projection)
@@ -141,6 +290,12 @@ export const createMongoAdapter = (options) => {
141
290
  // Trusted, so that the `sanitizeFilter` option does not wrap the operator in `$eq`.
142
291
  return Model.find({ _id: mongoose.trusted({ $in: ids }) });
143
292
  },
293
+ // Without middleware or overrides of the by-ID statics (see readsByIdAreInert), a read by ID has
294
+ // no side effects, and a malformed ID fails the update or delete before application code runs,
295
+ // as it fails the read.
296
+ readByIdIsInert(Model) {
297
+ return readsByIdAreInert(Model);
298
+ },
144
299
  prepareUpdate(set, unset) {
145
300
  return Object.keys(unset).length > 0 ? { ...set, $unset: unset } : set;
146
301
  },
@@ -4,7 +4,7 @@ import { withMongoTransaction } from './transactions.js';
4
4
  import { castModelId, mapIdCastError } from './ids.js';
5
5
 
6
6
  const LOCK_FIELD = '_simfinityReferenceLock';
7
- // Startup audit bounds: distinct target ids checked per query and ids remembered per target.
7
+ // Distinct target ids per startup-audit query and per lock write, and ids the audit remembers per target.
8
8
  const AUDIT_BATCH_SIZE = 1000;
9
9
  const AUDIT_VERIFIED_LIMIT = 100000;
10
10
  const transactionOptions = { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } };
@@ -44,7 +44,10 @@ export const createMongoIntegrity = (options = {}) => {
44
44
  let ready = false;
45
45
  const models = new Map();
46
46
  const outgoing = new Map();
47
+ // Per target model: each referencing model once, with its paths to the target in declaration order.
47
48
  const incoming = new Map();
49
+ // Per model: _id and its reference paths, the fields modelReferences reads.
50
+ const projections = new Map();
48
51
 
49
52
  const assertRegistry = () => {
50
53
  const current = binding.getRegistrations();
@@ -100,6 +103,37 @@ export const createMongoIntegrity = (options = {}) => {
100
103
  return result.matchedCount > 0;
101
104
  };
102
105
 
106
+ // Lock the distinct targets of one record with one write per target collection (up to
107
+ // AUDIT_BATCH_SIZE ids each). A batch of several ids is read first in the transaction snapshot,
108
+ // so a missing target is a violation before the batch's write, whatever its position, and never
109
+ // a retried conflict with a concurrently locked target of the same batch. A single id keeps its
110
+ // single updateOne.
111
+ const lockTargets = async (references, session) => {
112
+ const byTarget = new Map();
113
+ for (const { target, id } of references) {
114
+ if (!byTarget.has(target)) byTarget.set(target, []);
115
+ byTarget.get(target).push(id);
116
+ }
117
+ for (const [target, ids] of byTarget) {
118
+ const Target = models.get(target);
119
+ for (let start = 0; start < ids.length; start += AUDIT_BATCH_SIZE) {
120
+ const batch = ids.slice(start, start + AUDIT_BATCH_SIZE);
121
+ if (batch.length === 1) {
122
+ if (!await lock(Target, batch[0], session)) throw violation();
123
+ continue;
124
+ }
125
+ const found = await Target.collection.find(
126
+ { _id: { $in: batch } }, { session, projection: { _id: 1 }, batchSize: batch.length + 1 },
127
+ ).toArray();
128
+ if (found.length !== batch.length) throw violation();
129
+ const result = await Target.collection.updateMany(
130
+ { _id: { $in: batch } }, { $set: { [LOCK_FIELD]: new mongoose.Types.ObjectId() } }, { session },
131
+ );
132
+ if (result.matchedCount !== batch.length) throw violation();
133
+ }
134
+ }
135
+ };
136
+
103
137
  const abortOnWriteError = async (session, body) => {
104
138
  try {
105
139
  return await body();
@@ -115,13 +149,12 @@ export const createMongoIntegrity = (options = {}) => {
115
149
  };
116
150
 
117
151
  const validateRecord = async (Model, record, session) => abortOnWriteError(session, async () => {
118
- // Read the stored post-image, including setters/defaults and save middleware changes.
152
+ // Read the stored post-image, including setters/defaults and save middleware changes:
153
+ // only _id and the reference paths.
119
154
  if (!record?._id) throw violation();
120
- const stored = await Model.collection.findOne({ _id: record._id }, { session });
155
+ const stored = await Model.collection.findOne({ _id: record._id }, { session, projection: projections.get(Model) });
121
156
  if (!stored) throw violation();
122
- for (const { target, id } of modelReferences(Model, stored)) {
123
- if (!await lock(models.get(target), id, session)) throw violation();
124
- }
157
+ await lockTargets(modelReferences(Model, stored), session);
125
158
  });
126
159
 
127
160
  const initialize = async () => {
@@ -154,11 +187,16 @@ export const createMongoIntegrity = (options = {}) => {
154
187
  }
155
188
  if (!connection || connection.readyState !== 1) throw invalid('Connect MongoDB before initializing reference integrity');
156
189
  for (const [Model, paths] of outgoing) {
190
+ const projection = { _id: 1 };
157
191
  for (const reference of paths) {
192
+ const path = reference.path.join('.');
193
+ projection[path] = 1;
158
194
  const target = models.get(reference.target);
159
- if (!incoming.has(target)) incoming.set(target, []);
160
- incoming.get(target).push({ Model, path: reference.path.join('.') });
195
+ if (!incoming.has(target)) incoming.set(target, new Map());
196
+ if (!incoming.get(target).has(Model)) incoming.get(target).set(Model, []);
197
+ if (!incoming.get(target).get(Model).includes(path)) incoming.get(target).get(Model).push(path);
161
198
  }
199
+ projections.set(Model, projection);
162
200
  }
163
201
  for (const Model of models.values()) {
164
202
  if (createCollection) await Model.createCollection();
@@ -188,9 +226,7 @@ export const createMongoIntegrity = (options = {}) => {
188
226
  try {
189
227
  session.startTransaction({ readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } });
190
228
  for (const Model of models.values()) {
191
- const projection = { _id: 1, [LOCK_FIELD]: 1 };
192
- for (const { path } of outgoing.get(Model)) projection[path.join('.')] = 1;
193
- const cursor = Model.collection.find({}, { session, projection });
229
+ const cursor = Model.collection.find({}, { session, projection: { ...projections.get(Model), [LOCK_FIELD]: 1 } });
194
230
  try {
195
231
  for await (const record of cursor) {
196
232
  if (record[LOCK_FIELD] != null && record[LOCK_FIELD]?._bsontype !== 'ObjectId') {
@@ -278,11 +314,15 @@ export const createMongoIntegrity = (options = {}) => {
278
314
  return abortOnWriteError(session, async () => {
279
315
  const objectId = castModelId(Model, id);
280
316
  if (!await lock(Model, objectId, session)) return null;
281
- for (const reference of incoming.get(Model) || []) {
282
- const query = reference.Model === Model
283
- ? { $and: [{ [reference.path]: objectId }, { _id: { $ne: objectId } }] }
284
- : { [reference.path]: objectId };
285
- if (await reference.Model.collection.findOne(query, { session, projection: { _id: 1 } })) throw violation();
317
+ // One query per referencing collection: the $or of its per-path queries, each branch served
318
+ // by its own path index. A self-reference ignores the deleted document itself. The $ne stays
319
+ // inside each branch: hoisted, the planner can pick a scan of the _id index instead.
320
+ for (const [Source, paths] of incoming.get(Model) || []) {
321
+ const matches = paths.map((path) => (Source === Model
322
+ ? { $and: [{ [path]: objectId }, { _id: { $ne: objectId } }] }
323
+ : { [path]: objectId }));
324
+ const query = matches.length === 1 ? matches[0] : { $or: matches };
325
+ if (await Source.collection.findOne(query, { session, projection: { _id: 1 } })) throw violation();
286
326
  }
287
327
  const deleted = await Model.findByIdAndDelete(objectId).session(session);
288
328
  if (!deleted?._id?.equals(objectId)
@@ -87,7 +87,17 @@ const assertEmbeddedFieldName = (gqlType, fieldEntryName) => {
87
87
  }
88
88
  };
89
89
 
90
- const generateSchemaDefinition = (gqlType, nested = false) => {
90
+ // An embedded field whose type is the type being generated, or a type that embeds it on this path,
91
+ // would nest without end. The path starts at the type whose model is generated. Sibling and nested
92
+ // fields may still embed the same type.
93
+ const assertNoEmbeddedCycle = (gqlType, entryType, ancestors, fieldPath) => {
94
+ if (entryType === gqlType) {
95
+ throw new SimfinityError('A type cannot have a field of its same type and embedded', 'INVALID_MODEL', 400);
96
+ }
97
+ if (ancestors.includes(entryType)) throw new SimfinityError(`Embedded cycle at ${fieldPath}`, 'INVALID_MODEL', 400);
98
+ };
99
+
100
+ const generateSchemaDefinition = (gqlType, nested = false, ancestors = [], path = gqlType.name) => {
91
101
  const argTypes = gqlType.getFields();
92
102
  const schemaArg = {};
93
103
 
@@ -111,10 +121,9 @@ const generateSchemaDefinition = (gqlType, nested = false) => {
111
121
  } else {
112
122
  assertEmbeddedFieldName(gqlType, fieldEntryName);
113
123
  const entryType = unwrapNonNull(type);
114
- if (entryType === gqlType) {
115
- throw new Error('A type cannot have a field of its same type and embedded');
116
- }
117
- const definition = generateSchemaDefinition(entryType, true);
124
+ const fieldPath = `${path}.${fieldEntryName}`;
125
+ assertNoEmbeddedCycle(gqlType, entryType, ancestors, fieldPath);
126
+ const definition = generateSchemaDefinition(entryType, true, [...ancestors, gqlType], fieldPath);
118
127
  // A nested object under a `type` key can only be a subdocument; like other embedded objects, it has no _id.
119
128
  schemaArg[fieldEntryName] = nested && fieldEntryName === 'type'
120
129
  ? new mongoose.Schema(definition, { _id: false })
@@ -126,10 +135,9 @@ const generateSchemaDefinition = (gqlType, nested = false) => {
126
135
  if (fieldEntry.extensions && fieldEntry.extensions.relation) {
127
136
  if (fieldEntry.extensions.relation.embedded) {
128
137
  assertEmbeddedFieldName(gqlType, fieldEntryName);
129
- if (itemType === gqlType) {
130
- throw new Error('A type cannot have a field of its same type and embedded');
131
- }
132
- schemaArg[fieldEntryName] = [generateSchemaDefinition(itemType, true)];
138
+ const fieldPath = `${path}.${fieldEntryName}`;
139
+ assertNoEmbeddedCycle(gqlType, itemType, ancestors, fieldPath);
140
+ schemaArg[fieldEntryName] = [generateSchemaDefinition(itemType, true, [...ancestors, gqlType], fieldPath)];
133
141
  }
134
142
  } else if (listItemMatchesScalar(type, GraphQLID)) {
135
143
  schemaArg[fieldEntryName] = [mongoose.Schema.Types.ObjectId];
@@ -1,10 +1,9 @@
1
1
  import {
2
2
  GraphQLEnumType,
3
3
  GraphQLID,
4
- GraphQLList,
5
- GraphQLNonNull,
6
4
  GraphQLObjectType,
7
5
  GraphQLScalarType,
6
+ getNamedType,
8
7
  } from 'graphql';
9
8
  import mongoose from 'mongoose';
10
9
 
@@ -16,18 +15,6 @@ import {
16
15
  import { castObjectId } from './ids.js';
17
16
  import { resolveStorageScalar } from './models.js';
18
17
 
19
- const isNonNullOfType = (fieldEntryType, graphQLType) => (
20
- fieldEntryType instanceof GraphQLNonNull && fieldEntryType.ofType instanceof graphQLType
21
- );
22
-
23
- const unwrapListAndNonNull = (type) => {
24
- let unwrapped = type;
25
- while (unwrapped instanceof GraphQLList || unwrapped instanceof GraphQLNonNull) {
26
- unwrapped = unwrapped.ofType;
27
- }
28
- return unwrapped;
29
- };
30
-
31
18
  const getEffectiveTypeName = (type) => (
32
19
  type instanceof GraphQLScalarType ? resolveStorageScalar(type).name : type.name
33
20
  );
@@ -127,12 +114,6 @@ const AGG_OP_TO_MONGO = {
127
114
 
128
115
  const filterError = (message, code = 'INVALID_FILTER_VALUE') => new SimfinityError(message, code, 400);
129
116
 
130
- const queryNamedType = (type) => {
131
- let result = type;
132
- while (result instanceof GraphQLList || result instanceof GraphQLNonNull) result = result.ofType;
133
- return result;
134
- };
135
-
136
117
  // `id` is the record key `_id` at an entity boundary: the root or a joined document. Inside an
137
118
  // embedded object it follows what reads return. Entity types read the stored subdocument `_id`
138
119
  // through the generated id resolver; that resolver lives on the type object, so any runtime that
@@ -159,7 +140,7 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
159
140
  for (const [index, part] of parts.entries()) {
160
141
  const field = Object.hasOwn(currentType.getFields(), part) ? currentType.getFields()[part] : null;
161
142
  if (!field) throw filterError(`Unknown query field: ${path}`, 'INVALID_FILTER_FIELD');
162
- const fieldType = queryNamedType(field.type);
143
+ const fieldType = getNamedType(field.type);
163
144
  const relation = field.extensions?.relation;
164
145
  if (fieldType instanceof GraphQLObjectType) {
165
146
  if (index === parts.length - 1) throw filterError(`Query path must end in a scalar field: ${path}`, 'INVALID_FILTER_PATH');
@@ -267,7 +248,7 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
267
248
  if (!qlField) throw filterError(`Unknown filter field: ${fieldName}`, 'INVALID_FILTER_FIELD');
268
249
  if (filterField == null) return { aggregateClauses, matchesClauses };
269
250
  if (typeof filterField !== 'object' || Array.isArray(filterField)) throw filterError(`Invalid filter for ${fieldName}`);
270
- const isObject = queryNamedType(qlField.type) instanceof GraphQLObjectType;
251
+ const isObject = getNamedType(qlField.type) instanceof GraphQLObjectType;
271
252
  if (isObject && (!Array.isArray(filterField.terms) || filterField.terms.length === 0)) {
272
253
  throw filterError(`Filter on ${fieldName} requires non-empty terms`, 'MISSING_FILTER_PATH');
273
254
  }
@@ -309,8 +290,7 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
309
290
  throw new SimfinityError(`Unknown filter field: ${condition.field}`, 'INVALID_FILTER_FIELD', 400);
310
291
  }
311
292
 
312
- const fieldType = unwrapListAndNonNull(qlField.type);
313
- const isObject = fieldType instanceof GraphQLObjectType || isNonNullOfType(fieldType, GraphQLObjectType);
293
+ const isObject = getNamedType(qlField.type) instanceof GraphQLObjectType;
314
294
 
315
295
  let filterInput;
316
296
  if (isObject) {
@@ -326,8 +306,6 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
326
306
  }
327
307
 
328
308
  const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype, aggregationsIncluded);
329
- if (!result) continue;
330
-
331
309
  appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
332
310
  for (const matchClause of Object.values(result.matchesClauses)) {
333
311
  for (const [matchKey, match] of Object.entries(matchClause)) {
@@ -379,8 +357,6 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
379
357
  if (controlKeys.has(key)) continue;
380
358
  const qlField = fields[key];
381
359
  const result = await buildQueryTerms(filterField, qlField, key, gqltype, aggregationsIncluded);
382
- if (!result) continue;
383
-
384
360
  appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
385
361
  for (const matchClause of Object.values(result.matchesClauses)) {
386
362
  for (const [matchKey, match] of Object.entries(matchClause)) {
@@ -0,0 +1,2 @@
1
+ export * from '@simtlix/simfinity-core/plugins';
2
+ export { default } from '@simtlix/simfinity-core/plugins';
@@ -0,0 +1,2 @@
1
+ export * from '@simtlix/simfinity-core/scalars';
2
+ export { default } from '@simtlix/simfinity-core/scalars';
@@ -0,0 +1,2 @@
1
+ export * from '@simtlix/simfinity-core/validators';
2
+ export { default } from '@simtlix/simfinity-core/validators';
package/types/index.d.ts CHANGED
@@ -52,6 +52,14 @@ export interface MongoAdapterOptions {
52
52
  /** Fixed at adapter creation. Defaults to off; transactional mode requires initialize(). */
53
53
  referentialIntegrity?: 'off' | 'transactional';
54
54
  }
55
+ /**
56
+ * In the default 'off' mode, withTransaction(null, callback) without a model, as custom mutations
57
+ * call it, opens its session on the default mongoose.connection, unless that connection has no
58
+ * client, no model is compiled on it or on its useDb() descendants, and every model of the bound
59
+ * runtime's registrations uses one MongoDB client: then it opens it on that client. A supplied
60
+ * session or model is used as is. In transactional mode it uses the first protected model's
61
+ * connection.
62
+ */
55
63
  export interface MongoAdapter extends DatabaseAdapter {
56
64
  readonly referentialIntegrity: 'off' | 'transactional';
57
65
  /**
@@ -62,6 +70,14 @@ export interface MongoAdapter extends DatabaseAdapter {
62
70
  castId(value: any): any;
63
71
  /** Returns null for a hydrated nested path whose stored value is an explicit null; otherwise the value. */
64
72
  readEmbeddedValue(value: any): any;
73
+ /**
74
+ * Returns the raw stored data of a hydrated nested path or subdocument, or a Mongoose map's
75
+ * entries as a plain object, without running schema getters or virtuals. Returns the value
76
+ * unchanged when its schema declares a getter, a virtual other than Mongoose's automatic `id`, an
77
+ * alias or a subdocument method on a member at any depth inside it, so Simfinity keeps the object
78
+ * as stored, and for any other value.
79
+ */
80
+ rawEmbeddedValue(value: any): any;
65
81
  /** After connecting MongoDB and createSchema(), await the transaction capability and existing-reference audit. */
66
82
  initialize(): Promise<void>;
67
83
  }
@@ -120,6 +136,20 @@ export function addNoEndpointType(gqltype: GraphQLObjectType): void;
120
136
  * connectionField, whose resolver Simfinity would generate, throws INVALID_MODEL (400). In default
121
137
  * mode, one with its own resolver or readOnly only logs a warning and rejects nested writes
122
138
  * (INVALID_MODEL, 500); referentialIntegrity 'transactional' rejects every such collection.
139
+ * Embedded types that contain each other throw INVALID_MODEL (400, `Embedded cycle at …`) when a
140
+ * generated model's embedded fields reach them, and always in transactional mode; in default mode a
141
+ * cycle that only supplied models reach is not checked.
142
+ * A generated input that would have no fields is left out with a warning, together with the
143
+ * collection operation, embedded field or add/update/state-action mutation that would take it; a
144
+ * non-null embedded field whose embedded type has no writable fields throws INVALID_MODEL (400)
145
+ * when the generated mutations reach its owner. An application type named FieldExtensionsType or
146
+ * RelationType renames Simfinity's introspection metadata types (SimfinityFieldExtensionsType,
147
+ * SimfinityRelationType) with a warning when it is in the process's first Simfinity schema. That
148
+ * schema fixes the names: in a later createSchema(), an application type with one of the current
149
+ * names (the original ones, or the fallback names after a rename) throws RESERVED_TYPE_NAME (500),
150
+ * before models are created when it is an included endpoint type, a registered mutation's input or
151
+ * output type, an output type they reach or a field argument's type, and when the schema is
152
+ * constructed otherwise.
123
153
  */
124
154
  export function createSchema(
125
155
  includedQueryTypes?: GraphQLObjectType[] | null,
@@ -187,7 +217,8 @@ export function configureMutationLimits(options?: MutationLimitsOptions): void;
187
217
  * Get the generated create input type, preserving field and list-item non-null
188
218
  * wrappers. Update inputs remove only the outer wrapper (except entity id).
189
219
  * References use IdInputType; embedded lists use nested inputs; referenced
190
- * collections use added/updated/deleted operation inputs.
220
+ * collections use added/updated/deleted operation inputs. It has no fields for a type without
221
+ * writable fields; such inputs are not part of generated schemas.
191
222
  */
192
223
  export function getInputType(type: GraphQLObjectType | { name: string }): GraphQLInputObjectType;
193
224