@simtlix/simfinity-js 3.5.9 → 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 +1 -1
- package/package.json +3 -3
- package/src/mongo/adapter.js +30 -6
- package/src/mongo/integrity.js +56 -16
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
33
|
-
"@simtlix/simfinity-mcp": "3.
|
|
32
|
+
"@simtlix/simfinity-core": "3.6.0",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.6.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
package/src/mongo/adapter.js
CHANGED
|
@@ -39,16 +39,34 @@ 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
|
+
// 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']);
|
|
52
70
|
|
|
53
71
|
// `useDb()` connections share their parent's MongoClient, and a session belongs to a client.
|
|
54
72
|
const clientOf = (connection) => connection.getClient?.() || connection;
|
|
@@ -272,6 +290,12 @@ export const createMongoAdapter = (options) => {
|
|
|
272
290
|
// Trusted, so that the `sanitizeFilter` option does not wrap the operator in `$eq`.
|
|
273
291
|
return Model.find({ _id: mongoose.trusted({ $in: ids }) });
|
|
274
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
|
+
},
|
|
275
299
|
prepareUpdate(set, unset) {
|
|
276
300
|
return Object.keys(unset).length > 0 ? { ...set, $unset: unset } : set;
|
|
277
301
|
},
|
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' } };
|
|
@@ -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
|
-
|
|
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).
|
|
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
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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)
|