@simtlix/simfinity-js 3.3.0 → 3.4.1
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 +20 -0
- package/package.json +3 -3
- package/src/mongo/adapter.js +23 -6
- package/src/mongo/integrity.js +268 -0
- package/src/mongo/transactions.js +3 -2
- package/types/index.d.ts +12 -1
package/README.md
CHANGED
|
@@ -18,6 +18,26 @@ const schema = simfinity.createSchema();
|
|
|
18
18
|
|
|
19
19
|
Use a MongoDB replica set or sharded cluster for transactional mutations. The library requires Node.js >=18.18.0 and GraphQL 16.
|
|
20
20
|
|
|
21
|
+
## Optional transactional reference integrity
|
|
22
|
+
|
|
23
|
+
Choose the mode once when creating an adapter; the default remains `'off'`:
|
|
24
|
+
|
|
25
|
+
```javascript
|
|
26
|
+
import { createMongoAdapter, createRuntime } from '@simtlix/simfinity-js';
|
|
27
|
+
|
|
28
|
+
const adapter = createMongoAdapter({ referentialIntegrity: 'transactional' });
|
|
29
|
+
const simfinity = createRuntime(adapter);
|
|
30
|
+
await mongoose.connect(process.env.MONGODB_URI);
|
|
31
|
+
// Register every application type on this runtime.
|
|
32
|
+
simfinity.connect(null, SerieType, 'serie', 'series');
|
|
33
|
+
const schema = simfinity.createSchema();
|
|
34
|
+
await adapter.initialize();
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Initialization checks transaction support and audits existing references. Protected mutations reject nonexistent references, including embedded paths, inferred/private inverse keys and explicit link entities. Deleting a referenced target is restricted. Target-document writes coordinate concurrent creation/deletion; a violation rolls back the whole mutation with `REFERENCE_CONSTRAINT_VIOLATION` (409).
|
|
38
|
+
|
|
39
|
+
Owned transactions use snapshot reads and majority commits. Supplied sessions need the same options; a reference violation or guarded-write error aborts even a supplied transaction. The reserved `_simfinityReferenceLock` storage field is excluded from GraphQL. All writers must use the same complete registry and mode. Direct Mongoose/driver writes bypass this protection; this is not a native MongoDB FK. Read the [full setup, concurrency and migration contract](https://simtlix.github.io/simfinity.js/guide/mongodb-integrity.html).
|
|
40
|
+
|
|
21
41
|
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.
|
|
22
42
|
|
|
23
43
|
- [MongoDB quick start](https://simtlix.github.io/simfinity.js/guide/getting-started.html)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.1",
|
|
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
|
"mongoose": "^8.16.2"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@simtlix/simfinity-core": "3.
|
|
33
|
-
"@simtlix/simfinity-mcp": "3.
|
|
32
|
+
"@simtlix/simfinity-core": "3.4.1",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.4.1"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
package/src/mongo/adapter.js
CHANGED
|
@@ -4,12 +4,14 @@ import { getNamedType, isListType, isNonNullType } from 'graphql';
|
|
|
4
4
|
import { createMongoModel } from './models.js';
|
|
5
5
|
import { createMongoQueries } from './queries.js';
|
|
6
6
|
import { withMongoTransaction } from './transactions.js';
|
|
7
|
+
import { createMongoIntegrity } from './integrity.js';
|
|
7
8
|
|
|
8
9
|
mongoose.set('strictQuery', false);
|
|
9
10
|
|
|
10
11
|
const withSession = (query, session) => (session ? query.session(session) : query);
|
|
11
12
|
|
|
12
|
-
export const createMongoAdapter = () => {
|
|
13
|
+
export const createMongoAdapter = (options) => {
|
|
14
|
+
const integrity = createMongoIntegrity(options);
|
|
13
15
|
let queries;
|
|
14
16
|
const privateConnectionFields = new Map();
|
|
15
17
|
|
|
@@ -20,11 +22,13 @@ export const createMongoAdapter = () => {
|
|
|
20
22
|
|
|
21
23
|
const adapter = {
|
|
22
24
|
bind(binding) {
|
|
25
|
+
integrity.bind(binding);
|
|
23
26
|
queries = createMongoQueries(binding);
|
|
24
27
|
adapter.buildQuery = queries.buildQuery;
|
|
25
28
|
adapter.buildFilterGroupMatch = queries.buildFilterGroupMatch;
|
|
26
29
|
},
|
|
27
|
-
prepare(registrations) {
|
|
30
|
+
prepare(registrations, preparationOptions) {
|
|
31
|
+
integrity.prepare(registrations, preparationOptions);
|
|
28
32
|
for (const registration of registrations) {
|
|
29
33
|
for (const field of Object.values(registration.gqltype.getFields())) {
|
|
30
34
|
const relation = field.extensions?.relation;
|
|
@@ -45,31 +49,34 @@ export const createMongoAdapter = () => {
|
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
},
|
|
48
|
-
createModel(gqltype, onModelCreated,
|
|
52
|
+
createModel(gqltype, onModelCreated, modelOptions) {
|
|
49
53
|
return createMongoModel(gqltype, (model) => {
|
|
50
54
|
for (const fieldName of privateConnectionFields.get(gqltype.name) || []) {
|
|
51
55
|
model.schema.add({ [fieldName]: mongoose.Schema.Types.ObjectId });
|
|
52
56
|
model.schema.index({ [fieldName]: 1 });
|
|
53
57
|
}
|
|
54
58
|
if (onModelCreated) onModelCreated(model);
|
|
55
|
-
},
|
|
59
|
+
}, integrity.enabled ? { ...modelOptions, createCollection: false } : modelOptions);
|
|
56
60
|
},
|
|
57
61
|
castId(value) {
|
|
58
62
|
return new mongoose.Types.ObjectId(value);
|
|
59
63
|
},
|
|
60
|
-
|
|
64
|
+
initialize: integrity.initialize,
|
|
65
|
+
withTransaction: integrity.enabled ? integrity.withTransaction : withMongoTransaction,
|
|
61
66
|
newRecord(Model, data, session) {
|
|
62
67
|
const record = new Model(data);
|
|
63
68
|
record.$session(session);
|
|
64
69
|
return record;
|
|
65
70
|
},
|
|
66
|
-
saveRecord(Model, record) {
|
|
71
|
+
saveRecord(Model, record, session) {
|
|
72
|
+
if (integrity.enabled) return integrity.saveRecord(Model, record, session === undefined ? record.$session() : session);
|
|
67
73
|
return record.save();
|
|
68
74
|
},
|
|
69
75
|
toObject(record) {
|
|
70
76
|
return typeof record?.toObject === 'function' ? record.toObject() : record;
|
|
71
77
|
},
|
|
72
78
|
getById(Model, id, session, { projection, plain, requiredId } = {}) {
|
|
79
|
+
integrity.assertReady();
|
|
73
80
|
let query = withSession(requiredId == null ? Model.findById(id, projection)
|
|
74
81
|
: Model.findOne({ $and: [{ _id: requiredId }, { _id: id }] }, projection), session);
|
|
75
82
|
if (plain) query = query.lean();
|
|
@@ -79,27 +86,36 @@ export const createMongoAdapter = () => {
|
|
|
79
86
|
return Object.keys(unset).length > 0 ? { ...set, $unset: unset } : set;
|
|
80
87
|
},
|
|
81
88
|
update(Model, id, update, session) {
|
|
89
|
+
if (integrity.enabled) return integrity.updateRecord(Model, id, update, session);
|
|
82
90
|
return withSession(Model.findByIdAndUpdate(id, update, { new: true }), session);
|
|
83
91
|
},
|
|
84
92
|
delete(Model, id, session) {
|
|
93
|
+
if (integrity.enabled) {
|
|
94
|
+
integrity.assertWrite(Model, session);
|
|
95
|
+
return integrity.deleteRecord(Model, id, session);
|
|
96
|
+
}
|
|
85
97
|
return withSession(Model.findByIdAndDelete(id), session);
|
|
86
98
|
},
|
|
87
99
|
async find(Model, gqltype, args, session, { requiredId } = {}) {
|
|
100
|
+
integrity.assertReady();
|
|
88
101
|
const pipeline = await queries.buildQuery(args, gqltype);
|
|
89
102
|
if (requiredId != null) pipeline.unshift({ $match: { _id: Model.schema.path('_id').cast(requiredId) } });
|
|
90
103
|
if (pipeline.length === 0) return withSession(Model.find({}), session);
|
|
91
104
|
return withSession(Model.aggregate(pipeline), session);
|
|
92
105
|
},
|
|
93
106
|
async count(Model, gqltype, args, session) {
|
|
107
|
+
integrity.assertReady();
|
|
94
108
|
const pipeline = await queries.buildQuery(args, gqltype, true);
|
|
95
109
|
const result = await withSession(Model.aggregate(pipeline), session);
|
|
96
110
|
return result[0] ? result[0].size : 0;
|
|
97
111
|
},
|
|
98
112
|
async aggregate(Model, gqltype, args, session) {
|
|
113
|
+
integrity.assertReady();
|
|
99
114
|
const pipeline = await queries.buildAggregationQuery(args, gqltype, args.aggregation);
|
|
100
115
|
return withSession(Model.aggregate(pipeline), session);
|
|
101
116
|
},
|
|
102
117
|
async findChildren(Model, gqltype, connectionField, parentId, args, session) {
|
|
118
|
+
integrity.assertReady();
|
|
103
119
|
const pipeline = await queries.buildQuery(args, gqltype);
|
|
104
120
|
const path = Model.schema.path(connectionField);
|
|
105
121
|
pipeline.unshift({ $match: { [connectionField]: path ? path.cast(parentId) : parentId } });
|
|
@@ -107,5 +123,6 @@ export const createMongoAdapter = () => {
|
|
|
107
123
|
},
|
|
108
124
|
};
|
|
109
125
|
|
|
126
|
+
Object.defineProperty(adapter, 'referentialIntegrity', { value: integrity.mode, enumerable: true });
|
|
110
127
|
return adapter;
|
|
111
128
|
};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import { describeModels, SimfinityError } from '@simtlix/simfinity-core';
|
|
3
|
+
import { withMongoTransaction } from './transactions.js';
|
|
4
|
+
|
|
5
|
+
const LOCK_FIELD = '_simfinityReferenceLock';
|
|
6
|
+
const transactionOptions = { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } };
|
|
7
|
+
const violation = () => new SimfinityError('Reference constraint violated', 'REFERENCE_CONSTRAINT_VIOLATION', 409);
|
|
8
|
+
const invalid = (message) => new SimfinityError(message, 'INVALID_MONGO_INTEGRITY_CONFIGURATION', 400);
|
|
9
|
+
|
|
10
|
+
const referencePaths = (fields, prefix = []) => fields.flatMap((field) => {
|
|
11
|
+
const path = [...prefix, ...field.storageName.split('.')];
|
|
12
|
+
if (path.includes(LOCK_FIELD)) throw invalid(`${LOCK_FIELD} is reserved for reference integrity`);
|
|
13
|
+
if (field.kind === 'reference') return [{ path, target: field.target }];
|
|
14
|
+
if (field.kind === 'embedded') return referencePaths(field.fields, path);
|
|
15
|
+
return [];
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const valuesAt = (value, path) => {
|
|
19
|
+
if (value == null) return [];
|
|
20
|
+
if (Array.isArray(value)) return value.flatMap((item) => valuesAt(item, path));
|
|
21
|
+
if (path.length === 0) return [value];
|
|
22
|
+
return valuesAt(value[path[0]], path.slice(1));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const createMongoIntegrity = (options = {}) => {
|
|
26
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)
|
|
27
|
+
|| Object.keys(options).some((key) => key !== 'referentialIntegrity')) {
|
|
28
|
+
throw invalid('Expected Mongo adapter options with referentialIntegrity');
|
|
29
|
+
}
|
|
30
|
+
const mode = options.referentialIntegrity === undefined ? 'off' : options.referentialIntegrity;
|
|
31
|
+
if (!['off', 'transactional'].includes(mode)) {
|
|
32
|
+
throw invalid('referentialIntegrity must be off or transactional');
|
|
33
|
+
}
|
|
34
|
+
const enabled = mode === 'transactional';
|
|
35
|
+
let binding;
|
|
36
|
+
let registrations;
|
|
37
|
+
let entities;
|
|
38
|
+
let createCollection;
|
|
39
|
+
let initialization;
|
|
40
|
+
let ready = false;
|
|
41
|
+
const models = new Map();
|
|
42
|
+
const outgoing = new Map();
|
|
43
|
+
const incoming = new Map();
|
|
44
|
+
|
|
45
|
+
const assertRegistry = () => {
|
|
46
|
+
const current = binding.getRegistrations();
|
|
47
|
+
if (current.length !== registrations.length
|
|
48
|
+
|| current.some((entry, index) => entry.gqltype !== registrations[index].gqltype)) {
|
|
49
|
+
throw invalid('Register all types before creating the protected schema');
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const assertReady = () => {
|
|
54
|
+
if (!enabled) return;
|
|
55
|
+
if (!ready) {
|
|
56
|
+
throw new SimfinityError('Await Mongo adapter.initialize() before serving operations', 'MONGO_INTEGRITY_NOT_INITIALIZED', 503);
|
|
57
|
+
}
|
|
58
|
+
assertRegistry();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const assertSession = (session) => {
|
|
62
|
+
if (!session?.inTransaction()) {
|
|
63
|
+
throw new SimfinityError('Reference integrity requires an active transaction', 'ACTIVE_TRANSACTION_REQUIRED', 400);
|
|
64
|
+
}
|
|
65
|
+
if (session.transaction.options.readConcern?.level !== 'snapshot'
|
|
66
|
+
|| session.transaction.options.writeConcern?.w !== 'majority') {
|
|
67
|
+
throw new SimfinityError('Reference integrity requires snapshot read concern and majority write concern', 'MONGO_INTEGRITY_TRANSACTION_OPTIONS', 400);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const assertWrite = (Model, session) => {
|
|
72
|
+
assertReady();
|
|
73
|
+
if (![...models.values()].includes(Model)) throw invalid('Model is outside the protected registry');
|
|
74
|
+
assertSession(session);
|
|
75
|
+
if (session.client !== Model.db.getClient()) {
|
|
76
|
+
throw invalid('The transaction and protected models must use the same MongoDB client');
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const modelReferences = (Model, record) => {
|
|
81
|
+
const references = new Map();
|
|
82
|
+
for (const { path, target } of outgoing.get(Model) || []) {
|
|
83
|
+
for (const id of valuesAt(record, path)) {
|
|
84
|
+
// Strings in native storage do not join an ObjectId key, even if castable.
|
|
85
|
+
if (id?._bsontype !== 'ObjectId') throw violation();
|
|
86
|
+
references.set(`${target}:${id.toHexString()}`, { target, id });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [...references.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const lock = async (Model, id, session) => {
|
|
93
|
+
const result = await Model.collection.updateOne(
|
|
94
|
+
{ _id: id }, { $set: { [LOCK_FIELD]: new mongoose.Types.ObjectId() } }, { session },
|
|
95
|
+
);
|
|
96
|
+
return result.matchedCount > 0;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const abortOnWriteError = async (session, body) => {
|
|
100
|
+
try {
|
|
101
|
+
return await body();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (session.inTransaction()) {
|
|
104
|
+
// Mongoose post hooks can reject after persisting an unchecked write.
|
|
105
|
+
// A caller must not catch that error and commit a borrowed transaction.
|
|
106
|
+
try { await session.abortTransaction(); } catch { /* Preserve the write error and retry labels. */ }
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const validateRecord = async (Model, record, session) => abortOnWriteError(session, async () => {
|
|
113
|
+
// Read the stored post-image, including setters/defaults and save middleware changes.
|
|
114
|
+
if (!record?._id) throw violation();
|
|
115
|
+
const stored = await Model.collection.findOne({ _id: record._id }, { session });
|
|
116
|
+
if (!stored) throw violation();
|
|
117
|
+
for (const { target, id } of modelReferences(Model, stored)) {
|
|
118
|
+
if (!await lock(models.get(target), id, session)) throw violation();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const initialize = async () => {
|
|
123
|
+
if (!enabled) return;
|
|
124
|
+
if (!entities) throw invalid('Create the GraphQL schema before initializing reference integrity');
|
|
125
|
+
assertRegistry();
|
|
126
|
+
const collections = new Set();
|
|
127
|
+
let connection;
|
|
128
|
+
for (const entity of entities) {
|
|
129
|
+
const Model = binding.getModel(entity.gqltype);
|
|
130
|
+
if (!Model?.collection || !Model?.db) throw invalid(`No MongoDB model for ${entity.name}`);
|
|
131
|
+
if (Model.schema.path('_id')?.instance !== 'ObjectId') throw invalid(`Protected model ${entity.name} must use ObjectId primary keys`);
|
|
132
|
+
if (connection && connection !== Model.db) throw invalid('All protected models must share one MongoDB connection');
|
|
133
|
+
connection = Model.db;
|
|
134
|
+
const collection = Model.collection.collectionName;
|
|
135
|
+
if (collections.has(collection)) throw invalid(`Multiple protected models share collection ${collection}`);
|
|
136
|
+
collections.add(collection);
|
|
137
|
+
const reserved = Model.schema.path(LOCK_FIELD);
|
|
138
|
+
if (reserved && !reserved.options.simfinityReferenceLock) throw invalid(`${LOCK_FIELD} is reserved for reference integrity`);
|
|
139
|
+
if (!reserved) Model.schema.add({
|
|
140
|
+
[LOCK_FIELD]: { type: mongoose.Schema.Types.ObjectId, select: false, simfinityReferenceLock: true },
|
|
141
|
+
});
|
|
142
|
+
for (const { path } of referencePaths(entity.fields)) {
|
|
143
|
+
if (Model.schema.path(path.join('.'))?.instance !== 'ObjectId') {
|
|
144
|
+
throw invalid(`Expected an ObjectId storage path for ${entity.name}.${path.join('.')}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
models.set(entity.name, Model);
|
|
148
|
+
outgoing.set(Model, referencePaths(entity.fields));
|
|
149
|
+
}
|
|
150
|
+
if (!connection || connection.readyState !== 1) throw invalid('Connect MongoDB before initializing reference integrity');
|
|
151
|
+
for (const [Model, paths] of outgoing) {
|
|
152
|
+
for (const reference of paths) {
|
|
153
|
+
const target = models.get(reference.target);
|
|
154
|
+
if (!incoming.has(target)) incoming.set(target, []);
|
|
155
|
+
incoming.get(target).push({ Model, path: reference.path.join('.') });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const Model of models.values()) {
|
|
159
|
+
if (createCollection) await Model.createCollection();
|
|
160
|
+
else if (!(await connection.db.listCollections({ name: Model.collection.collectionName }).toArray()).length) {
|
|
161
|
+
throw invalid(`Collection ${Model.collection.collectionName} must exist before initialization`);
|
|
162
|
+
}
|
|
163
|
+
// Model compilation may already have scheduled index builds. Finish them
|
|
164
|
+
// before the audit starts, so its transaction does not race our own DDL.
|
|
165
|
+
await Model.init();
|
|
166
|
+
}
|
|
167
|
+
const session = await connection.startSession();
|
|
168
|
+
try {
|
|
169
|
+
session.startTransaction({ readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } });
|
|
170
|
+
for (const Model of models.values()) {
|
|
171
|
+
const cursor = Model.collection.find({}, { session });
|
|
172
|
+
try {
|
|
173
|
+
for await (const record of cursor) {
|
|
174
|
+
if (record[LOCK_FIELD] != null && record[LOCK_FIELD]?._bsontype !== 'ObjectId') {
|
|
175
|
+
throw invalid(`Existing ${LOCK_FIELD} data is incompatible with reference integrity`);
|
|
176
|
+
}
|
|
177
|
+
for (const { target, id } of modelReferences(Model, record)) {
|
|
178
|
+
if (!await models.get(target).collection.findOne({ _id: id }, { session, projection: { _id: 1 } })) {
|
|
179
|
+
throw violation();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} finally { await cursor.close(); }
|
|
184
|
+
}
|
|
185
|
+
// Read-only startup audit. Mutation attempts use independently owned or supplied sessions.
|
|
186
|
+
await session.abortTransaction();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (session.inTransaction()) await session.abortTransaction().catch(() => {});
|
|
189
|
+
if (error.code === 20) {
|
|
190
|
+
throw new SimfinityError('Transactional reference integrity requires a MongoDB replica set or sharded cluster', 'MONGO_TRANSACTIONS_REQUIRED', 503);
|
|
191
|
+
}
|
|
192
|
+
throw error;
|
|
193
|
+
} finally { await session.endSession(); }
|
|
194
|
+
ready = true;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
mode,
|
|
199
|
+
enabled,
|
|
200
|
+
bind(value) {
|
|
201
|
+
if (!enabled) return;
|
|
202
|
+
if (binding) throw invalid('A protected Mongo adapter can only bind to one runtime');
|
|
203
|
+
binding = value;
|
|
204
|
+
},
|
|
205
|
+
prepare(values, options) {
|
|
206
|
+
if (!enabled) return;
|
|
207
|
+
if (entities) {
|
|
208
|
+
assertRegistry();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
registrations = values.map(({ gqltype }) => ({ gqltype }));
|
|
212
|
+
entities = describeModels(values).entities;
|
|
213
|
+
for (const entity of entities) referencePaths(entity.fields);
|
|
214
|
+
createCollection = options?.createCollection !== false;
|
|
215
|
+
},
|
|
216
|
+
assertReady,
|
|
217
|
+
assertWrite,
|
|
218
|
+
initialize() {
|
|
219
|
+
if (!initialization) initialization = initialize();
|
|
220
|
+
return initialization;
|
|
221
|
+
},
|
|
222
|
+
withTransaction(session, body, Model) {
|
|
223
|
+
assertReady();
|
|
224
|
+
if (session) assertSession(session);
|
|
225
|
+
return withMongoTransaction(session, body, Model || models.values().next().value, transactionOptions);
|
|
226
|
+
},
|
|
227
|
+
async saveRecord(Model, record, session) {
|
|
228
|
+
assertWrite(Model, session);
|
|
229
|
+
return abortOnWriteError(session, async () => {
|
|
230
|
+
const saved = await record.save({ session });
|
|
231
|
+
await validateRecord(Model, saved, session);
|
|
232
|
+
return saved;
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
async updateRecord(Model, id, update, session) {
|
|
236
|
+
assertWrite(Model, session);
|
|
237
|
+
return abortOnWriteError(session, async () => {
|
|
238
|
+
const objectId = Model.schema.path('_id').cast(id);
|
|
239
|
+
const updated = await Model.findByIdAndUpdate(objectId, update, { new: true }).session(session);
|
|
240
|
+
if (!updated) {
|
|
241
|
+
// A middleware can hide a completed write with a null result. Abort
|
|
242
|
+
// even on a genuine no-match; core still reports its normal NOT_VALID_ID.
|
|
243
|
+
await session.abortTransaction();
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
if (!updated._id?.equals(objectId)) throw violation();
|
|
247
|
+
await validateRecord(Model, updated, session);
|
|
248
|
+
return updated;
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
async deleteRecord(Model, id, session) {
|
|
252
|
+
return abortOnWriteError(session, async () => {
|
|
253
|
+
const objectId = Model.schema.path('_id').cast(id);
|
|
254
|
+
if (!await lock(Model, objectId, session)) return null;
|
|
255
|
+
for (const reference of incoming.get(Model) || []) {
|
|
256
|
+
const query = reference.Model === Model
|
|
257
|
+
? { $and: [{ [reference.path]: objectId }, { _id: { $ne: objectId } }] }
|
|
258
|
+
: { [reference.path]: objectId };
|
|
259
|
+
if (await reference.Model.collection.findOne(query, { session, projection: { _id: 1 } })) throw violation();
|
|
260
|
+
}
|
|
261
|
+
const deleted = await Model.findByIdAndDelete(objectId).session(session);
|
|
262
|
+
if (!deleted?._id?.equals(objectId)
|
|
263
|
+
|| await Model.collection.findOne({ _id: objectId }, { session, projection: { _id: 1 } })) throw violation();
|
|
264
|
+
return deleted;
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
};
|
|
@@ -29,7 +29,7 @@ const endOwnedSession = async (session, failed) => {
|
|
|
29
29
|
}
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
export const withMongoTransaction = async (session, body, model) => {
|
|
32
|
+
export const withMongoTransaction = async (session, body, model, transactionOptions) => {
|
|
33
33
|
const connection = model?.db || mongoose.connection;
|
|
34
34
|
if (session) {
|
|
35
35
|
if (!session.inTransaction()) {
|
|
@@ -46,7 +46,8 @@ export const withMongoTransaction = async (session, body, model) => {
|
|
|
46
46
|
let failed = false;
|
|
47
47
|
try {
|
|
48
48
|
for (let attempt = 0; attempt <= MAX_TRANSIENT_RETRIES; attempt++) {
|
|
49
|
-
await mySession.startTransaction();
|
|
49
|
+
if (transactionOptions) await mySession.startTransaction(transactionOptions);
|
|
50
|
+
else await mySession.startTransaction();
|
|
50
51
|
try {
|
|
51
52
|
const result = await body(mySession);
|
|
52
53
|
await commitWithRetry(mySession);
|
package/types/index.d.ts
CHANGED
|
@@ -41,7 +41,16 @@ export type {
|
|
|
41
41
|
} from '@simtlix/simfinity-core';
|
|
42
42
|
export * from '@simtlix/simfinity-mcp';
|
|
43
43
|
export { default as mcp } from '@simtlix/simfinity-mcp';
|
|
44
|
-
export
|
|
44
|
+
export interface MongoAdapterOptions {
|
|
45
|
+
/** Fixed at adapter creation. Defaults to off; transactional mode requires initialize(). */
|
|
46
|
+
referentialIntegrity?: 'off' | 'transactional';
|
|
47
|
+
}
|
|
48
|
+
export interface MongoAdapter extends DatabaseAdapter {
|
|
49
|
+
readonly referentialIntegrity: 'off' | 'transactional';
|
|
50
|
+
/** After connecting MongoDB and createSchema(), await the transaction capability and existing-reference audit. */
|
|
51
|
+
initialize(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export function createMongoAdapter(options?: MongoAdapterOptions): MongoAdapter;
|
|
45
54
|
export function getRegistrations(): RuntimeRegistration[];
|
|
46
55
|
|
|
47
56
|
/* ========================================================================== *
|
|
@@ -146,6 +155,8 @@ export function getInputType(type: GraphQLObjectType | { name: string }): GraphQ
|
|
|
146
155
|
* Without a session, owns a transaction on the model's connection and awaits cleanup.
|
|
147
156
|
* A supplied session must have an active transaction and belong to the model's MongoDB client.
|
|
148
157
|
* The caller then owns retries, commit, abort, and cleanup; inactive sessions are rejected.
|
|
158
|
+
* Exception: transactional reference integrity aborts on a violation, guarded-write error or ambiguous update result.
|
|
159
|
+
* That mode requires snapshot read concern and majority write concern on supplied transactions.
|
|
149
160
|
* Owned transactions retry transient failures and uncertain commits separately, up to five times each.
|
|
150
161
|
*/
|
|
151
162
|
|