@simtlix/simfinity-js 3.5.9 → 3.7.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 +14 -1
- package/package.json +3 -3
- package/src/mongo/adapter.js +127 -6
- package/src/mongo/integrity.js +61 -18
- package/types/index.d.ts +25 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ 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
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
|
|
|
@@ -28,6 +28,19 @@ The facade registers generated models on your application's default Mongoose ins
|
|
|
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
|
|
|
31
|
+
## Optional batched collection reads
|
|
32
|
+
|
|
33
|
+
Generated collection fields read one parent at a time by default. With `batchedCollections: true`, the adapter reads the collection fields of many parents that share their arguments with aggregate commands of up to `collectionBatchSize` parents each (an integer from 1 to 100, default 5), sent concurrently:
|
|
34
|
+
|
|
35
|
+
```javascript
|
|
36
|
+
import { createMongoAdapter, createRuntime } from '@simtlix/simfinity-js';
|
|
37
|
+
|
|
38
|
+
const adapter = createMongoAdapter({ batchedCollections: true }); // collectionBatchSize: 5
|
|
39
|
+
const simfinity = createRuntime(adapter);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Each parent's own pipeline (filters, sort and page) runs as one sub-pipeline: the first is the command's pipeline and the others are `$unionWith` stages. Like `referentialIntegrity`, the option needs your own adapter and runtime; the package-root runtime (`simfinity.connect()` and the rest) keeps one read per parent. Children of models with `aggregate` middleware, their own `aggregate` static, a discriminator child model or a wrapped `Model.collection.aggregate`, reads inside a `transactionAsyncLocalStorage` transaction, and adapter copies or overrides of `findChildren` keep one read per parent. A command runs its sub-pipelines one after another, so fewer commands can come with a slower single request, and command monitoring, query settings and `$queryStats` see the new command shape: read the [upgrade notes](https://simtlix.github.io/simfinity.js/resources/compatibility.html#upgrade-to-3-7-0) before enabling it. Invalid values throw `INVALID_MONGO_ADAPTER_OPTIONS` (400).
|
|
43
|
+
|
|
31
44
|
## Optional transactional reference integrity
|
|
32
45
|
|
|
33
46
|
Choose the mode once when creating an adapter; the default remains `'off'`:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.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.
|
|
33
|
-
"@simtlix/simfinity-mcp": "3.
|
|
32
|
+
"@simtlix/simfinity-core": "3.7.0",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.7.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
package/src/mongo/adapter.js
CHANGED
|
@@ -39,16 +39,65 @@ 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
|
|
42
|
+
const hasNoHooks = (hooks, operations) => {
|
|
43
43
|
if (!(hooks?._pres instanceof Map) || !(hooks._posts instanceof Map)) return false;
|
|
44
|
-
return
|
|
44
|
+
return operations.every((operation) => !hooks._pres.get(operation)?.length
|
|
45
45
|
&& !hooks._posts.get(operation)?.length);
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
const
|
|
51
|
-
&& (Model.Query?.prototype?._queryMiddleware == null ||
|
|
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
|
+
// Batched collection reads (findChildrenBatch), opt in with `batchedCollections`. Each parent's own
|
|
57
|
+
// findChildren pipeline runs as one sub-pipeline of an aggregate command, the first as the command's
|
|
58
|
+
// pipeline and the others in $unionWith stages, at most `collectionBatchSize` parents per command;
|
|
59
|
+
// the commands of one call run concurrently. A command runs its sub-pipelines one after another.
|
|
60
|
+
const DEFAULT_COLLECTION_BATCH_SIZE = 5;
|
|
61
|
+
const MAX_COLLECTION_BATCH_SIZE = 100;
|
|
62
|
+
// Sub-pipeline bytes per command, well below MongoDB's 16 MiB command limit.
|
|
63
|
+
const COLLECTION_COMMAND_BYTES = 8 * 1024 * 1024;
|
|
64
|
+
const invalidAdapterOption = (message) => new SimfinityError(message, 'INVALID_MONGO_ADAPTER_OPTIONS', 400);
|
|
65
|
+
const readCollectionBatching = (options) => {
|
|
66
|
+
const { batchedCollections = false, collectionBatchSize = DEFAULT_COLLECTION_BATCH_SIZE } = options ?? {};
|
|
67
|
+
if (typeof batchedCollections !== 'boolean') throw invalidAdapterOption('batchedCollections must be true or false');
|
|
68
|
+
if (!Number.isSafeInteger(collectionBatchSize) || collectionBatchSize < 1 || collectionBatchSize > MAX_COLLECTION_BATCH_SIZE) {
|
|
69
|
+
throw invalidAdapterOption(`collectionBatchSize must be an integer from 1 to ${MAX_COLLECTION_BATCH_SIZE}`);
|
|
70
|
+
}
|
|
71
|
+
return { enabled: batchedCollections, parentsPerCommand: collectionBatchSize };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Mongoose applies aggregate middleware, a model's own aggregate static (schema.static, loadClass, or
|
|
75
|
+
// a model from another Mongoose copy), a discriminator's key and a wrapper of the model's
|
|
76
|
+
// collection.aggregate to the command's pipeline only, never to $unionWith sub-pipelines, so such
|
|
77
|
+
// models read their children one parent at a time.
|
|
78
|
+
const batchesAggregate = (Model) => Model?.aggregate === mongoose.Model.aggregate
|
|
79
|
+
&& hasNoHooks(Model.hooks, ['aggregate']) && hasNoHooks(Model.schema?.s?.hooks, ['aggregate'])
|
|
80
|
+
&& !(Model.schema?.discriminatorMapping && !Model.schema.discriminatorMapping.isRoot)
|
|
81
|
+
&& Model.collection != null && !Object.hasOwn(Model.collection, 'aggregate');
|
|
82
|
+
// MongoDB rejects $unionWith in a multi-document transaction, and the rejection aborts the
|
|
83
|
+
// transaction. Inside connection.transaction() with Mongoose's transactionAsyncLocalStorage, an
|
|
84
|
+
// aggregate without a session joins the transaction.
|
|
85
|
+
const inTransactionStore = (Model) => Model?.db?.base?.transactionAsyncLocalStorage?.getStore()?.session != null;
|
|
86
|
+
|
|
87
|
+
// The statics that getById, update and delete call, directly or through each other.
|
|
88
|
+
const BY_ID_STATICS = ['findById', 'findOne', 'findByIdAndUpdate', 'findOneAndUpdate', 'findByIdAndDelete', 'findOneAndDelete'];
|
|
89
|
+
// Mongoose's automatic ObjectId setter only changes document assignments, never query filters.
|
|
90
|
+
const autoIdSetter = new mongoose.Schema.Types.ObjectId('_id').auto(true).setters.at(-1);
|
|
91
|
+
|
|
92
|
+
// update and delete write through findByIdAndUpdate and findByIdAndDelete, whose findOneAndUpdate and
|
|
93
|
+
// findOneAndDelete middleware runs before Mongoose casts the ID. Such middleware, or an application
|
|
94
|
+
// override of one of these statics (schema.static, loadClass), could guard the read or act on a
|
|
95
|
+
// malformed ID that the read by ID rejects first, so the runtime keeps that read. ID setters also
|
|
96
|
+
// run before casting and can branch on the query operation. A model from another Mongoose copy
|
|
97
|
+
// has other statics and keeps the read too.
|
|
98
|
+
const readsByIdAreInert = (Model) => BY_ID_STATICS.every((name) => Model[name] === mongoose.Model[name])
|
|
99
|
+
&& Model.schema?.path('_id')?.setters?.every((setter) => setter === autoIdSetter)
|
|
100
|
+
&& hasNoQueryHooks(Model, ['find', 'findOne', 'findOneAndUpdate', 'findOneAndDelete']);
|
|
52
101
|
|
|
53
102
|
// `useDb()` connections share their parent's MongoClient, and a session belongs to a client.
|
|
54
103
|
const clientOf = (connection) => connection.getClient?.() || connection;
|
|
@@ -110,6 +159,7 @@ const nestedPathOf = (nested) => {
|
|
|
110
159
|
|
|
111
160
|
export const createMongoAdapter = (options) => {
|
|
112
161
|
const integrity = createMongoIntegrity(options);
|
|
162
|
+
const collectionBatching = readCollectionBatching(options);
|
|
113
163
|
let queries;
|
|
114
164
|
let getRegistrations = () => [];
|
|
115
165
|
const privateConnectionFields = new Map();
|
|
@@ -272,6 +322,12 @@ export const createMongoAdapter = (options) => {
|
|
|
272
322
|
// Trusted, so that the `sanitizeFilter` option does not wrap the operator in `$eq`.
|
|
273
323
|
return Model.find({ _id: mongoose.trusted({ $in: ids }) });
|
|
274
324
|
},
|
|
325
|
+
// Without middleware or overrides of the by-ID statics (see readsByIdAreInert), a read by ID has
|
|
326
|
+
// no side effects, and a malformed ID fails the update or delete before application code runs,
|
|
327
|
+
// as it fails the read.
|
|
328
|
+
readByIdIsInert(Model) {
|
|
329
|
+
return readsByIdAreInert(Model);
|
|
330
|
+
},
|
|
275
331
|
prepareUpdate(set, unset) {
|
|
276
332
|
return Object.keys(unset).length > 0 ? { ...set, $unset: unset } : set;
|
|
277
333
|
},
|
|
@@ -323,6 +379,71 @@ export const createMongoAdapter = (options) => {
|
|
|
323
379
|
},
|
|
324
380
|
};
|
|
325
381
|
|
|
382
|
+
// Without the option the adapter has neither batching member, so the runtime reads each
|
|
383
|
+
// collection field with findChildren, as 3.6.0 did.
|
|
384
|
+
if (collectionBatching.enabled) {
|
|
385
|
+
const ownFindChildren = adapter.findChildren;
|
|
386
|
+
// Why `self` (the adapter the runtime called) cannot batch the children of `Model`, or null. A
|
|
387
|
+
// batch reproduces only this adapter's own findChildren, so a copy or an override of findChildren
|
|
388
|
+
// reads one parent at a time with that findChildren.
|
|
389
|
+
const declineReason = (self, Model, session) => {
|
|
390
|
+
if (self?.findChildren !== ownFindChildren) return 'its findChildren is not the adapter\'s own';
|
|
391
|
+
if (!batchesAggregate(Model)) {
|
|
392
|
+
return 'its aggregate reads have middleware, an own aggregate static, a discriminator or a wrapped collection.aggregate';
|
|
393
|
+
}
|
|
394
|
+
if (session != null || inTransactionStore(Model)) return 'it reads in a session or a transaction';
|
|
395
|
+
return null;
|
|
396
|
+
};
|
|
397
|
+
adapter.batchesChildren = function batchesChildren(Model) {
|
|
398
|
+
return declineReason(this, Model, null) === null;
|
|
399
|
+
};
|
|
400
|
+
// One array per parent ID, in order, each equal to findChildren's result for that parent. It
|
|
401
|
+
// declines by throwing before any command, and fails as a whole, never with partial results.
|
|
402
|
+
adapter.findChildrenBatch = async function findChildrenBatch(Model, gqltype, connectionField, parentIds, args, batchOptions) {
|
|
403
|
+
integrity.assertReady();
|
|
404
|
+
const reason = declineReason(this, Model, batchOptions?.session);
|
|
405
|
+
if (reason) throw new Error(`${Model?.modelName} reads children one parent at a time: ${reason}`);
|
|
406
|
+
const pipeline = await queries.buildQuery(args, gqltype);
|
|
407
|
+
const schemaType = Model.schema.path(connectionField);
|
|
408
|
+
const lists = parentIds.map(() => []);
|
|
409
|
+
const subPipelines = [];
|
|
410
|
+
parentIds.forEach((parentId, index) => {
|
|
411
|
+
let parentKey;
|
|
412
|
+
try {
|
|
413
|
+
parentKey = castPathValue(schemaType, parentId);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
// As in findChildren: a parent key that the connection path cannot cast has no children.
|
|
416
|
+
if (error?.extensions?.code === 'NOT_VALID_ID') return;
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
// Wrapping each row, instead of adding a field, cannot collide with a stored field.
|
|
420
|
+
subPipelines.push([{ $match: { [connectionField]: parentKey } }, ...pipeline, { $replaceWith: { i: index, d: '$$ROOT' } }]);
|
|
421
|
+
});
|
|
422
|
+
if (subPipelines.length === 0) return lists;
|
|
423
|
+
const parentsPerCommand = Math.max(1, Math.min(collectionBatching.parentsPerCommand,
|
|
424
|
+
Math.floor(COLLECTION_COMMAND_BYTES / mongoose.mongo.BSON.calculateObjectSize({ pipeline: subPipelines[0] }))));
|
|
425
|
+
const limit = pipeline.findLast((stage) => Object.hasOwn(stage, '$limit'))?.$limit;
|
|
426
|
+
const coll = Model.collection.collectionName;
|
|
427
|
+
const commands = [];
|
|
428
|
+
for (let start = 0; start < subPipelines.length; start += parentsPerCommand) {
|
|
429
|
+
const [first, ...others] = subPipelines.slice(start, start + parentsPerCommand);
|
|
430
|
+
const command = Model.aggregate([...first, ...others.map((subPipeline) => ({ $unionWith: { coll, pipeline: subPipeline } }))]);
|
|
431
|
+
// One more than the rows the command can return, so that its first batch closes the cursor.
|
|
432
|
+
if (Number.isSafeInteger(limit)) command.option({ batchSize: limit * (others.length + 1) + 1 });
|
|
433
|
+
commands.push(command);
|
|
434
|
+
}
|
|
435
|
+
// Every command settles before a failure is reported, so per-parent reads that follow it do not
|
|
436
|
+
// overlap the remaining commands.
|
|
437
|
+
const outcomes = await Promise.allSettled(commands);
|
|
438
|
+
const failure = outcomes.find(({ status }) => status === 'rejected');
|
|
439
|
+
if (failure) throw failure.reason;
|
|
440
|
+
for (const { value: rows } of outcomes) {
|
|
441
|
+
for (const { i, d } of rows) lists[i].push(d);
|
|
442
|
+
}
|
|
443
|
+
return lists;
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
326
447
|
Object.defineProperty(adapter, 'referentialIntegrity', { value: integrity.mode, enumerable: true });
|
|
327
448
|
return adapter;
|
|
328
449
|
};
|
package/src/mongo/integrity.js
CHANGED
|
@@ -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
|
-
//
|
|
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' } };
|
|
@@ -26,10 +26,13 @@ const valuesAt = (value, path) => {
|
|
|
26
26
|
return valuesAt(value[path[0]], path.slice(1));
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
// createMongoAdapter's options: the adapter reads the collection batching ones.
|
|
30
|
+
const ADAPTER_OPTIONS = ['referentialIntegrity', 'batchedCollections', 'collectionBatchSize'];
|
|
31
|
+
|
|
29
32
|
export const createMongoIntegrity = (options = {}) => {
|
|
30
33
|
if (!options || typeof options !== 'object' || Array.isArray(options)
|
|
31
|
-
|| Object.keys(options).some((key) => key
|
|
32
|
-
throw invalid('Expected Mongo adapter options
|
|
34
|
+
|| Object.keys(options).some((key) => !ADAPTER_OPTIONS.includes(key))) {
|
|
35
|
+
throw invalid('Expected Mongo adapter options among referentialIntegrity, batchedCollections and collectionBatchSize');
|
|
33
36
|
}
|
|
34
37
|
const mode = options.referentialIntegrity === undefined ? 'off' : options.referentialIntegrity;
|
|
35
38
|
if (!['off', 'transactional'].includes(mode)) {
|
|
@@ -44,7 +47,10 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
44
47
|
let ready = false;
|
|
45
48
|
const models = new Map();
|
|
46
49
|
const outgoing = new Map();
|
|
50
|
+
// Per target model: each referencing model once, with its paths to the target in declaration order.
|
|
47
51
|
const incoming = new Map();
|
|
52
|
+
// Per model: _id and its reference paths, the fields modelReferences reads.
|
|
53
|
+
const projections = new Map();
|
|
48
54
|
|
|
49
55
|
const assertRegistry = () => {
|
|
50
56
|
const current = binding.getRegistrations();
|
|
@@ -100,6 +106,37 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
100
106
|
return result.matchedCount > 0;
|
|
101
107
|
};
|
|
102
108
|
|
|
109
|
+
// Lock the distinct targets of one record with one write per target collection (up to
|
|
110
|
+
// AUDIT_BATCH_SIZE ids each). A batch of several ids is read first in the transaction snapshot,
|
|
111
|
+
// so a missing target is a violation before the batch's write, whatever its position, and never
|
|
112
|
+
// a retried conflict with a concurrently locked target of the same batch. A single id keeps its
|
|
113
|
+
// single updateOne.
|
|
114
|
+
const lockTargets = async (references, session) => {
|
|
115
|
+
const byTarget = new Map();
|
|
116
|
+
for (const { target, id } of references) {
|
|
117
|
+
if (!byTarget.has(target)) byTarget.set(target, []);
|
|
118
|
+
byTarget.get(target).push(id);
|
|
119
|
+
}
|
|
120
|
+
for (const [target, ids] of byTarget) {
|
|
121
|
+
const Target = models.get(target);
|
|
122
|
+
for (let start = 0; start < ids.length; start += AUDIT_BATCH_SIZE) {
|
|
123
|
+
const batch = ids.slice(start, start + AUDIT_BATCH_SIZE);
|
|
124
|
+
if (batch.length === 1) {
|
|
125
|
+
if (!await lock(Target, batch[0], session)) throw violation();
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const found = await Target.collection.find(
|
|
129
|
+
{ _id: { $in: batch } }, { session, projection: { _id: 1 }, batchSize: batch.length + 1 },
|
|
130
|
+
).toArray();
|
|
131
|
+
if (found.length !== batch.length) throw violation();
|
|
132
|
+
const result = await Target.collection.updateMany(
|
|
133
|
+
{ _id: { $in: batch } }, { $set: { [LOCK_FIELD]: new mongoose.Types.ObjectId() } }, { session },
|
|
134
|
+
);
|
|
135
|
+
if (result.matchedCount !== batch.length) throw violation();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
103
140
|
const abortOnWriteError = async (session, body) => {
|
|
104
141
|
try {
|
|
105
142
|
return await body();
|
|
@@ -115,13 +152,12 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
115
152
|
};
|
|
116
153
|
|
|
117
154
|
const validateRecord = async (Model, record, session) => abortOnWriteError(session, async () => {
|
|
118
|
-
// Read the stored post-image, including setters/defaults and save middleware changes
|
|
155
|
+
// Read the stored post-image, including setters/defaults and save middleware changes:
|
|
156
|
+
// only _id and the reference paths.
|
|
119
157
|
if (!record?._id) throw violation();
|
|
120
|
-
const stored = await Model.collection.findOne({ _id: record._id }, { session });
|
|
158
|
+
const stored = await Model.collection.findOne({ _id: record._id }, { session, projection: projections.get(Model) });
|
|
121
159
|
if (!stored) throw violation();
|
|
122
|
-
|
|
123
|
-
if (!await lock(models.get(target), id, session)) throw violation();
|
|
124
|
-
}
|
|
160
|
+
await lockTargets(modelReferences(Model, stored), session);
|
|
125
161
|
});
|
|
126
162
|
|
|
127
163
|
const initialize = async () => {
|
|
@@ -154,11 +190,16 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
154
190
|
}
|
|
155
191
|
if (!connection || connection.readyState !== 1) throw invalid('Connect MongoDB before initializing reference integrity');
|
|
156
192
|
for (const [Model, paths] of outgoing) {
|
|
193
|
+
const projection = { _id: 1 };
|
|
157
194
|
for (const reference of paths) {
|
|
195
|
+
const path = reference.path.join('.');
|
|
196
|
+
projection[path] = 1;
|
|
158
197
|
const target = models.get(reference.target);
|
|
159
|
-
if (!incoming.has(target)) incoming.set(target,
|
|
160
|
-
incoming.get(target).
|
|
198
|
+
if (!incoming.has(target)) incoming.set(target, new Map());
|
|
199
|
+
if (!incoming.get(target).has(Model)) incoming.get(target).set(Model, []);
|
|
200
|
+
if (!incoming.get(target).get(Model).includes(path)) incoming.get(target).get(Model).push(path);
|
|
161
201
|
}
|
|
202
|
+
projections.set(Model, projection);
|
|
162
203
|
}
|
|
163
204
|
for (const Model of models.values()) {
|
|
164
205
|
if (createCollection) await Model.createCollection();
|
|
@@ -188,9 +229,7 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
188
229
|
try {
|
|
189
230
|
session.startTransaction({ readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } });
|
|
190
231
|
for (const Model of models.values()) {
|
|
191
|
-
const
|
|
192
|
-
for (const { path } of outgoing.get(Model)) projection[path.join('.')] = 1;
|
|
193
|
-
const cursor = Model.collection.find({}, { session, projection });
|
|
232
|
+
const cursor = Model.collection.find({}, { session, projection: { ...projections.get(Model), [LOCK_FIELD]: 1 } });
|
|
194
233
|
try {
|
|
195
234
|
for await (const record of cursor) {
|
|
196
235
|
if (record[LOCK_FIELD] != null && record[LOCK_FIELD]?._bsontype !== 'ObjectId') {
|
|
@@ -278,11 +317,15 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
278
317
|
return abortOnWriteError(session, async () => {
|
|
279
318
|
const objectId = castModelId(Model, id);
|
|
280
319
|
if (!await lock(Model, objectId, session)) return null;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
320
|
+
// One query per referencing collection: the $or of its per-path queries, each branch served
|
|
321
|
+
// by its own path index. A self-reference ignores the deleted document itself. The $ne stays
|
|
322
|
+
// inside each branch: hoisted, the planner can pick a scan of the _id index instead.
|
|
323
|
+
for (const [Source, paths] of incoming.get(Model) || []) {
|
|
324
|
+
const matches = paths.map((path) => (Source === Model
|
|
325
|
+
? { $and: [{ [path]: objectId }, { _id: { $ne: objectId } }] }
|
|
326
|
+
: { [path]: objectId }));
|
|
327
|
+
const query = matches.length === 1 ? matches[0] : { $or: matches };
|
|
328
|
+
if (await Source.collection.findOne(query, { session, projection: { _id: 1 } })) throw violation();
|
|
286
329
|
}
|
|
287
330
|
const deleted = await Model.findByIdAndDelete(objectId).session(session);
|
|
288
331
|
if (!deleted?._id?.equals(objectId)
|
package/types/index.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export type {
|
|
|
33
33
|
AuthPluginOptions,
|
|
34
34
|
AuthRule,
|
|
35
35
|
AuthRuleFunction,
|
|
36
|
+
AuthSchemaPlugin,
|
|
36
37
|
EnvelopSchemaPlugin,
|
|
37
38
|
FieldValidations,
|
|
38
39
|
FieldValidator,
|
|
@@ -51,6 +52,28 @@ export { default as mcp } from '@simtlix/simfinity-mcp';
|
|
|
51
52
|
export interface MongoAdapterOptions {
|
|
52
53
|
/** Fixed at adapter creation. Defaults to off; transactional mode requires initialize(). */
|
|
53
54
|
referentialIntegrity?: 'off' | 'transactional';
|
|
55
|
+
/**
|
|
56
|
+
* Opt in to batched collection reads; defaults to false. When true, the adapter defines
|
|
57
|
+
* `findChildrenBatch` and `batchesChildren`, so the runtime reads the generated collection fields
|
|
58
|
+
* of one request that share a related type, a connection field and their arguments with aggregate
|
|
59
|
+
* commands of up to `collectionBatchSize` parents each, run concurrently. Each parent's own
|
|
60
|
+
* pipeline (filters, sort and page) runs as one sub-pipeline of a command, in a `$unionWith` stage
|
|
61
|
+
* after the first. Children of models with aggregate middleware, their own `aggregate` static, a
|
|
62
|
+
* discriminator key or a wrapped `collection.aggregate`, reads inside a Mongoose
|
|
63
|
+
* `transactionAsyncLocalStorage` transaction, and adapter copies or overrides of `findChildren`
|
|
64
|
+
* keep one read per parent. When false, the adapter has neither member and reads one parent at a
|
|
65
|
+
* time, as 3.6.0 did. Fixed at adapter creation; a value other than a boolean throws
|
|
66
|
+
* `INVALID_MONGO_ADAPTER_OPTIONS` (400).
|
|
67
|
+
*/
|
|
68
|
+
batchedCollections?: boolean | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Parents per batched aggregate command, an integer from 1 to 100; defaults to 5. A command runs
|
|
71
|
+
* its parents' sub-pipelines one after another, and the commands of one batch run concurrently, so
|
|
72
|
+
* a larger value sends fewer commands at the cost of a longer command. Only used with
|
|
73
|
+
* `batchedCollections: true`. Fixed at adapter creation; another value throws
|
|
74
|
+
* `INVALID_MONGO_ADAPTER_OPTIONS` (400).
|
|
75
|
+
*/
|
|
76
|
+
collectionBatchSize?: number | undefined;
|
|
54
77
|
}
|
|
55
78
|
/**
|
|
56
79
|
* In the default 'off' mode, withTransaction(null, callback) without a model, as custom mutations
|
|
@@ -58,7 +81,8 @@ export interface MongoAdapterOptions {
|
|
|
58
81
|
* client, no model is compiled on it or on its useDb() descendants, and every model of the bound
|
|
59
82
|
* runtime's registrations uses one MongoDB client: then it opens it on that client. A supplied
|
|
60
83
|
* session or model is used as is. In transactional mode it uses the first protected model's
|
|
61
|
-
* connection.
|
|
84
|
+
* connection. With `batchedCollections: true` the adapter also has the optional `DatabaseAdapter`
|
|
85
|
+
* members `findChildrenBatch` and `batchesChildren`; without it, it has neither.
|
|
62
86
|
*/
|
|
63
87
|
export interface MongoAdapter extends DatabaseAdapter {
|
|
64
88
|
readonly referentialIntegrity: 'off' | 'transactional';
|