@simtlix/simfinity-js 3.11.0 → 3.13.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 +11 -1
- package/package.json +3 -3
- package/src/index.js +5 -0
- package/src/mongo/adapter.js +134 -8
- package/src/mongo/integrity.js +56 -6
- package/src/mongo/models.js +4 -1
- package/types/index.d.ts +28 -0
package/README.md
CHANGED
|
@@ -22,6 +22,16 @@ Use a MongoDB replica set or sharded cluster for transactional mutations. The li
|
|
|
22
22
|
|
|
23
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
|
+
Declare `extensions: { concurrency: { mode: 'optimistic' } }` on an entity before its first `createSchema()` to enable optimistic concurrency. Simfinity adds a managed `version: RecordVersion!` field, starts new records at 1, and requires the version read by the client on updates, state actions and deletes. A successful update advances it once; a stale write fails with `VERSION_CONFLICT` (409), and an update at `MAX_RECORD_VERSION` (9007199254740991) fails with `VERSION_EXHAUSTED` (409). Missing or null versions in existing MongoDB documents count as 1 and advance to 2 on their first protected update. Other invalid stored versions produce `DATABASE_ERROR`.
|
|
26
|
+
|
|
27
|
+
Generated models declare a plain `version: Number` path without an index. A supplied model must already declare `version: Number`, without `immutable`, `select: false`, getters, setters or a default other than the literal 1; an alias is allowed, but `schema.options.versionKey` cannot be `'version'`. Simfinity rejects incompatible models with `INVALID_MODEL` and does not add paths to compiled models. Protection cannot be enabled after a type's first schema was built, or after a failed `createSchema()` already built its inputs or generated model without the managed field. Native pre/post save middleware must preserve initialized version 1 and the returned managed version: core checks controller `onSaving` before native save middleware and adds no post-save guard.
|
|
28
|
+
|
|
29
|
+
Protected updates use `findOneAndUpdate` with an atomic ID/version filter and set the next version explicitly; `findByIdAndUpdate` overrides no longer run. Protected deletes in default mode use `findOneAndDelete`, bypassing `findByIdAndDelete` overrides; transactional integrity still calls `findByIdAndDelete` after its conditional lock write. `findOneAndUpdate` and `findOneAndDelete` middleware and statics still run. Middleware must preserve the version condition and increment. The no-match existence read uses the raw collection and bypasses query middleware, so middleware-hidden records can still produce `VERSION_CONFLICT`; express record visibility in `get_by_id` scope instead of relying on middleware hiding. Core checks the returned versions as defensive diagnostics; those checks do not prove that arbitrary middleware or adapter wrappers preserved the atomic precondition. Wrappers must forward every argument, including `options.expectedVersion`. Update pipelines with `expectedVersion` retain their stages and append the managed next-version assignment; calls without that option preserve the existing write paths, including update pipelines.
|
|
30
|
+
|
|
31
|
+
The protected pre-read is a plain transaction read, with no extra claim lock. MongoDB write conflicts can retry an owned mutation; validators and hooks may run again, so keep external side effects out of them. Adapter-detected version errors abort a supplied session in transactional integrity mode; in default mode the caller owns the abort. Core-detected errors retain caller ownership of supplied sessions. A type's `get_by_id` scope decides whether stale versions are reported before hooks; hidden callers reach the conditional write after their hooks authorize it.
|
|
32
|
+
|
|
33
|
+
Native writers must follow the same protocol: match `{ _id, version: expected }` (use `version: { $in: [1, null] }` for expected 1), then set `{ $set: { ...changes, version: expected + 1 } }`, or delete with that filter. Check the matched result and never update at the maximum version. Use the model's `Model.base.trusted({ $in: [1, null] })` for this operator when calling Mongoose with `sanitizeFilter`. Do not use `$inc`: missing and null legacy values need an explicit next value. Native writes that skip this protocol are outside the guarantee; derived-value maintenance within the same transaction may use an ordinary `updateOne` without advancing the version.
|
|
34
|
+
|
|
25
35
|
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
36
|
|
|
27
37
|
Virtual fields (`extensions: { virtual: true }`, with the field's own `resolve`) get no path, no `[]` default and no index in generated models, on entities and embedded types, and no entry in the transactional integrity registry, so they need no `relation` in either mode and documents look the same with or without them. Generated writes never set or unset them, and Mongoose's strict mode drops a value that a controller assigns to a generated document for one. An embedded type whose members are all virtual becomes a strict subdocument without paths rather than a Mixed path, so nothing is stored for it but the `_id` of list items. With a generated model, values stored before a field became virtual stay in the documents: Mongoose drops an `$unset` of a path the model does not declare, so remove them with a driver-level update, such as `getModel(Type).collection.updateMany({}, { $unset: { busyRanges: '' } })`, or with `updateMany(filter, update, { strict: false })`; a generated update that sets an embedded parent rewrites it, which drops the stored keys of its virtual members. A supplied model keeps the paths it declares: documents stored earlier keep their values for the field, which nothing reads or writes until `$unset` removes them, and an index on such a path stays until you drop it. `buildQuery()` rejects a path through a virtual field with `INVALID_FILTER_FIELD` (400).
|
|
@@ -32,7 +42,7 @@ Text filters compile to escaped regular expressions, so a value never becomes re
|
|
|
32
42
|
|
|
33
43
|
The adapter also declares `queryFeatures.referenceEmptiness`, so these operators cross single references and end at references and collections, such as `director IS_EMPTY`, `director.name IS_EMPTY` or `seasons IS_EMPTY`, and an embedded object with a reference or collection member follows the member rule. A crossing joins the target with `$lookup` and `$unwind`, and the target's `id` right after it is its record identity, which is empty exactly when the reference is. A reference or collection is a frame: one `$lookup` that keeps at most one record id, so it never repeats a document, matched with `"alias.0": { $exists: false }` and its `$nor`; a collection frame reads the child's connection field and also tests its local key for null, because `$lookup` matches a missing local key with every child whose connection field is null. The joins and frames run before the `$match`, so they also filter counts, aggregates and batched collection reads, and they probe the `_id` index or the index on the connection field, which generated models create and a supplied model needs to declare. A client's condition reads each target through its `find` scope, inside the `$lookup` pipeline, so a record the scope hides reads as missing, also on the way to a later hop. Conditions that middleware and scopes add, and `buildQuery()` called directly, read stored data, so a dangling reference, to a record that no longer exists, is empty. A related type without a model fails with `INVALID_FILTER_PATH` (400), as for the other operators, and supplied models on another database or connection and discriminator targets are not supported, as for every relation path. A collection inside an embedded type, which only a supplied model can declare, is read through the embedded object's `_id`, as relation paths read it. The [query scope guide](https://simtlix.github.io/simfinity.js/guide/query-scope.html) describes client and trusted conditions, and the [middleware guide](https://simtlix.github.io/simfinity.js/guide/middleware.html) how to edit client filters without detaching them.
|
|
34
44
|
|
|
35
|
-
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. Custom queries registered with `registerCustomQuery()` receive no session; a callback that needs a transaction opens it with the `transaction()` of the Mongoose connection whose models it reads. `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).
|
|
45
|
+
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. Named views need no session: they run the generated list path. Custom queries registered with `registerCustomQuery()` receive no session; a callback that needs a transaction opens it with the `transaction()` of the Mongoose connection whose models it reads. `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).
|
|
36
46
|
|
|
37
47
|
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.
|
|
38
48
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.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.13.0",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.13.0"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"src",
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
InternalServerError,
|
|
3
|
+
MAX_RECORD_VERSION,
|
|
4
|
+
RecordVersion,
|
|
3
5
|
SimfinityError,
|
|
4
6
|
buildErrorFormatter,
|
|
5
7
|
createRuntime,
|
|
@@ -23,6 +25,7 @@ export const getRegistrations = runtime.getRegistrations;
|
|
|
23
25
|
export const use = runtime.use;
|
|
24
26
|
export const registerMutation = runtime.registerMutation;
|
|
25
27
|
export const registerCustomQuery = runtime.registerCustomQuery;
|
|
28
|
+
export const registerView = runtime.registerView;
|
|
26
29
|
export const saveObject = runtime.saveObject;
|
|
27
30
|
export const preventCreatingCollection = runtime.preventCreatingCollection;
|
|
28
31
|
|
|
@@ -35,6 +38,8 @@ export {
|
|
|
35
38
|
createRuntime,
|
|
36
39
|
createValidatedScalar,
|
|
37
40
|
InternalServerError,
|
|
41
|
+
MAX_RECORD_VERSION,
|
|
42
|
+
RecordVersion,
|
|
38
43
|
SimfinityError,
|
|
39
44
|
};
|
|
40
45
|
|
package/src/mongo/adapter.js
CHANGED
|
@@ -1,21 +1,114 @@
|
|
|
1
1
|
import mongoose from 'mongoose';
|
|
2
2
|
import { SimfinityError } from '@simtlix/simfinity-core';
|
|
3
|
+
import { MAX_RECORD_VERSION, isProtectedType, isVersionField, readStoredVersion, retainStoredVersion } from '@simtlix/simfinity-core/internal/record-version';
|
|
3
4
|
import { getListShape, normalizeConnectionField } from '@simtlix/simfinity-core/internal/relation-storage';
|
|
4
5
|
|
|
5
6
|
import { createMongoModel } from './models.js';
|
|
6
7
|
import { MONGO_QUERY_OPERATORS, createMongoQueries } from './queries.js';
|
|
7
8
|
import { withMongoTransaction } from './transactions.js';
|
|
8
|
-
import { createMongoIntegrity } from './integrity.js';
|
|
9
|
+
import { createMongoIntegrity, readVersionMiss, versionFilter, versionTypeName, versionedUpdate } from './integrity.js';
|
|
9
10
|
import {
|
|
10
11
|
castModelId, castObjectId, castPathValue, invalidId, isQueryKeyCastFailure, mapIdCastError,
|
|
11
12
|
} from './ids.js';
|
|
12
13
|
|
|
13
14
|
const withSession = (query, session) => (session ? query.session(session) : query);
|
|
14
15
|
|
|
16
|
+
const WRITE_FEATURES = Object.freeze({ optimisticConcurrency: true });
|
|
15
17
|
// Every filter operator the query compiler supports, and emptiness through references and collections (#108 part
|
|
16
18
|
// 2b); the runtime rejects any other operator.
|
|
17
19
|
const QUERY_FEATURES = Object.freeze({ operators: MONGO_QUERY_OPERATORS, referenceEmptiness: true });
|
|
18
20
|
|
|
21
|
+
const assertVersionModel = ({ gqltype, model }) => {
|
|
22
|
+
if (!model || !isProtectedType(gqltype)) return;
|
|
23
|
+
const path = model.schema?.path('version');
|
|
24
|
+
if (path?.instance !== 'Number' || path.options.immutable || path.options.select === false
|
|
25
|
+
|| path.getters.length || path.setters.length
|
|
26
|
+
|| (path.defaultValue !== undefined && path.defaultValue !== 1) || model.schema.options.versionKey === 'version') {
|
|
27
|
+
throw new SimfinityError(
|
|
28
|
+
`${gqltype.name} declares optimistic concurrency, so its Mongoose model must declare version: Number without immutable, select: false, getters, setters or a default other than 1`,
|
|
29
|
+
'INVALID_MODEL', 400,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Mongoose 8 casts Number paths before query middleware/transforms. Capture invalid raw versions
|
|
35
|
+
// at its private completion seam, then carry them to final same-record results after exec. Stored
|
|
36
|
+
// identity, never final array position, associates toObject/toJSON copies and reordered batches.
|
|
37
|
+
// Replacements must retain _id or an equivalent id; fabricated same-ID records cannot be distinguished
|
|
38
|
+
// from conversions. No document/schema mutation or extra read is needed, including for frozen results.
|
|
39
|
+
// Recheck these query-local completion seams when upgrading the Mongoose peer.
|
|
40
|
+
const preserveStoredVersion = (query, many) => {
|
|
41
|
+
const method = many ? '_completeMany' : '_completeOne';
|
|
42
|
+
const complete = query?.[method];
|
|
43
|
+
const exec = query?.exec;
|
|
44
|
+
if (typeof complete !== 'function' || typeof exec !== 'function') {
|
|
45
|
+
throw new SimfinityError('Protected MongoDB reads require a Mongoose query with hydration completion support', 'DATABASE_ERROR', 500);
|
|
46
|
+
}
|
|
47
|
+
const captured = new Map();
|
|
48
|
+
const originals = new Map();
|
|
49
|
+
const keyOf = (record) => {
|
|
50
|
+
const key = record?._id ?? record?.id;
|
|
51
|
+
return key == null ? null : String(key);
|
|
52
|
+
};
|
|
53
|
+
const capture = (document) => {
|
|
54
|
+
const value = document?.version;
|
|
55
|
+
try { readStoredVersion(value); return null; }
|
|
56
|
+
catch {
|
|
57
|
+
const key = keyOf(document);
|
|
58
|
+
if (key !== null) captured.set(key, value);
|
|
59
|
+
return { key, value };
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const associate = (record) => {
|
|
63
|
+
if (record === null || typeof record !== 'object') return;
|
|
64
|
+
const key = keyOf(record);
|
|
65
|
+
if (captured.has(key)) retainStoredVersion(record, captured.get(key));
|
|
66
|
+
else if (key === null && originals.has(record)) retainStoredVersion(record, originals.get(record));
|
|
67
|
+
// Middleware can reuse a result object across queries. Its prior raw value is no longer
|
|
68
|
+
// evidence about this result when the current query has no matching invalid storage value.
|
|
69
|
+
else retainStoredVersion(record, undefined);
|
|
70
|
+
};
|
|
71
|
+
if (many) {
|
|
72
|
+
query[method] = async function completeVersions(documents, ...args) {
|
|
73
|
+
const invalid = documents.map(capture);
|
|
74
|
+
const results = await complete.call(this, documents, ...args);
|
|
75
|
+
// Mongoose 8 _completeMany uses Promise.all over the raw documents in order. This
|
|
76
|
+
// establishes provenance only for its original hydrated objects, before transforms;
|
|
77
|
+
// final replacements still require an identifying key and never match by array position.
|
|
78
|
+
results.forEach((record, index) => {
|
|
79
|
+
const stored = invalid[index];
|
|
80
|
+
if (stored && record != null && keyOf(record) === stored.key) originals.set(record, stored.value);
|
|
81
|
+
});
|
|
82
|
+
return results;
|
|
83
|
+
};
|
|
84
|
+
} else {
|
|
85
|
+
query[method] = function completeVersion(document, metadata, projection, callback) {
|
|
86
|
+
const invalid = capture(document);
|
|
87
|
+
return complete.call(this, document, metadata, projection, (error, result) => {
|
|
88
|
+
const record = this.options?.includeResultMetadata ? result?.value : result;
|
|
89
|
+
// This completion callback establishes the original record's provenance even if a
|
|
90
|
+
// projection omits its identity. A later replacement still needs an identifying key.
|
|
91
|
+
if (!error && invalid && record != null && keyOf(record) === invalid.key) {
|
|
92
|
+
originals.set(record, invalid.value);
|
|
93
|
+
}
|
|
94
|
+
callback(error, result);
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
query.exec = async function execPreservingVersions(...args) {
|
|
99
|
+
try {
|
|
100
|
+
const result = await exec.apply(this, args);
|
|
101
|
+
if (many && Array.isArray(result)) result.forEach(associate);
|
|
102
|
+
else associate(this.options?.includeResultMetadata ? result?.value : result);
|
|
103
|
+
return result;
|
|
104
|
+
} finally {
|
|
105
|
+
captured.clear();
|
|
106
|
+
originals.clear();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
return query;
|
|
110
|
+
};
|
|
111
|
+
|
|
19
112
|
// A write that fails only on malformed ObjectId values reports NOT_VALID_ID, not a driver error.
|
|
20
113
|
const mapIdCastErrors = async (write) => {
|
|
21
114
|
try {
|
|
@@ -214,6 +307,10 @@ export const createMongoAdapter = (options) => {
|
|
|
214
307
|
session, body, (Model || session) ? Model : sessionModel(), ...rest,
|
|
215
308
|
);
|
|
216
309
|
|
|
310
|
+
const versionedRead = (Model, query, many = false) => getRegistrations().some(
|
|
311
|
+
(registration) => registration.model === Model && isVersionField(registration.gqltype.getFields().version),
|
|
312
|
+
) ? preserveStoredVersion(query, many) : query;
|
|
313
|
+
|
|
217
314
|
const addPrivateConnectionField = (typeName, fieldName) => {
|
|
218
315
|
if (!privateConnectionFields.has(typeName)) privateConnectionFields.set(typeName, new Set());
|
|
219
316
|
privateConnectionFields.get(typeName).add(fieldName);
|
|
@@ -221,6 +318,7 @@ export const createMongoAdapter = (options) => {
|
|
|
221
318
|
|
|
222
319
|
const adapter = {
|
|
223
320
|
queryFeatures: QUERY_FEATURES,
|
|
321
|
+
writeFeatures: WRITE_FEATURES,
|
|
224
322
|
bind(binding) {
|
|
225
323
|
integrity.bind(binding);
|
|
226
324
|
getRegistrations = binding.getRegistrations;
|
|
@@ -229,6 +327,7 @@ export const createMongoAdapter = (options) => {
|
|
|
229
327
|
adapter.buildFilterGroupMatch = queries.buildFilterGroupMatch;
|
|
230
328
|
},
|
|
231
329
|
prepare(registrations, preparationOptions) {
|
|
330
|
+
for (const registration of registrations) assertVersionModel(registration);
|
|
232
331
|
integrity.prepare(registrations, preparationOptions);
|
|
233
332
|
for (const registration of registrations) {
|
|
234
333
|
for (const field of Object.values(registration.gqltype.getFields())) {
|
|
@@ -317,7 +416,7 @@ export const createMongoAdapter = (options) => {
|
|
|
317
416
|
let query = withSession(requiredId == null ? Model.findById(id, projection)
|
|
318
417
|
: Model.findOne({ $and: [{ _id: requiredId }, { _id: id }] }, projection), session);
|
|
319
418
|
if (plain) query = query.lean();
|
|
320
|
-
return mapQueryIdCastErrors(query);
|
|
419
|
+
return mapQueryIdCastErrors(versionedRead(Model, query));
|
|
321
420
|
},
|
|
322
421
|
getByIds(Model, ids) {
|
|
323
422
|
integrity.assertReady();
|
|
@@ -325,7 +424,7 @@ export const createMongoAdapter = (options) => {
|
|
|
325
424
|
// reads each ID once with getById.
|
|
326
425
|
if (!batchesFindOne(Model)) throw new Error(`${Model.modelName} query middleware requires reads by ID`);
|
|
327
426
|
// Trusted, so that the `sanitizeFilter` option does not wrap the operator in `$eq`.
|
|
328
|
-
return Model.find({ _id: mongoose.trusted({ $in: ids }) });
|
|
427
|
+
return versionedRead(Model, Model.find({ _id: mongoose.trusted({ $in: ids }) }), true);
|
|
329
428
|
},
|
|
330
429
|
// Without middleware or overrides of the by-ID statics (see readsByIdAreInert), a read by ID has
|
|
331
430
|
// no side effects, and a malformed ID fails the update or delete before application code runs,
|
|
@@ -336,16 +435,43 @@ export const createMongoAdapter = (options) => {
|
|
|
336
435
|
prepareUpdate(set, unset) {
|
|
337
436
|
return Object.keys(unset).length > 0 ? { ...set, $unset: unset } : set;
|
|
338
437
|
},
|
|
339
|
-
update(Model, id, update, session) {
|
|
340
|
-
if (integrity.enabled) return integrity.updateRecord(Model, id, update, session);
|
|
438
|
+
update(Model, id, update, session, writeOptions) {
|
|
439
|
+
if (integrity.enabled) return integrity.updateRecord(Model, id, update, session, writeOptions);
|
|
440
|
+
const expected = writeOptions?.expectedVersion;
|
|
441
|
+
if (expected !== undefined) {
|
|
442
|
+
return mapIdCastErrors(async () => {
|
|
443
|
+
const query = mapQueryIdCastErrors(withSession(Model.findOneAndUpdate(
|
|
444
|
+
{ _id: id, ...versionFilter(Model, expected) }, versionedUpdate(update, expected), { new: true },
|
|
445
|
+
), session), mapIdCastError);
|
|
446
|
+
if (expected >= MAX_RECORD_VERSION) {
|
|
447
|
+
try {
|
|
448
|
+
query.cast(Model);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
// The skipped query has no exec/error flag; this cast only processes its filter.
|
|
451
|
+
throw error?.name === 'CastError' && error.path === '_id' ? invalidId() : error;
|
|
452
|
+
}
|
|
453
|
+
return readVersionMiss(Model, query.getFilter()._id, session, expected, true, versionTypeName(Model, getRegistrations()));
|
|
454
|
+
}
|
|
455
|
+
const updated = await query;
|
|
456
|
+
return updated ?? readVersionMiss(Model, query.getFilter()._id, session, expected, true, versionTypeName(Model, getRegistrations()));
|
|
457
|
+
});
|
|
458
|
+
}
|
|
341
459
|
return mapQueryIdCastErrors(withSession(
|
|
342
460
|
Model.findByIdAndUpdate(id, update, { new: true }), session,
|
|
343
461
|
), mapIdCastError);
|
|
344
462
|
},
|
|
345
|
-
delete(Model, id, session) {
|
|
463
|
+
delete(Model, id, session, writeOptions) {
|
|
346
464
|
if (integrity.enabled) {
|
|
347
465
|
integrity.assertWrite(Model, session);
|
|
348
|
-
return integrity.deleteRecord(Model, id, session);
|
|
466
|
+
return integrity.deleteRecord(Model, id, session, writeOptions);
|
|
467
|
+
}
|
|
468
|
+
const expected = writeOptions?.expectedVersion;
|
|
469
|
+
if (expected !== undefined) {
|
|
470
|
+
const query = mapQueryIdCastErrors(withSession(Model.findOneAndDelete(
|
|
471
|
+
{ _id: id, ...versionFilter(Model, expected) },
|
|
472
|
+
), session));
|
|
473
|
+
return query.then((deleted) => deleted ?? readVersionMiss(Model, query.getFilter()._id, session, expected, false,
|
|
474
|
+
versionTypeName(Model, getRegistrations())));
|
|
349
475
|
}
|
|
350
476
|
return mapQueryIdCastErrors(withSession(Model.findByIdAndDelete(id), session));
|
|
351
477
|
},
|
|
@@ -354,7 +480,7 @@ export const createMongoAdapter = (options) => {
|
|
|
354
480
|
const pipeline = await queries.buildQuery(args, gqltype);
|
|
355
481
|
// Mongoose does not cast aggregation stages, so cast the key type here, without setters.
|
|
356
482
|
if (requiredId != null) pipeline.unshift({ $match: { _id: castModelId(Model, requiredId) } });
|
|
357
|
-
if (pipeline.length === 0) return withSession(Model.find({}), session);
|
|
483
|
+
if (pipeline.length === 0) return versionedRead(Model, withSession(Model.find({}), session), true);
|
|
358
484
|
return withSession(Model.aggregate(pipeline), session);
|
|
359
485
|
},
|
|
360
486
|
async count(Model, gqltype, args, session) {
|
package/src/mongo/integrity.js
CHANGED
|
@@ -1,8 +1,38 @@
|
|
|
1
1
|
import mongoose from 'mongoose';
|
|
2
2
|
import { describeModels, SimfinityError } from '@simtlix/simfinity-core';
|
|
3
|
+
import {
|
|
4
|
+
MAX_RECORD_VERSION, readStoredVersion, unadvancedVersionError, versionConflictError, versionExhaustedError,
|
|
5
|
+
} from '@simtlix/simfinity-core/internal/record-version';
|
|
3
6
|
import { withMongoTransaction } from './transactions.js';
|
|
4
7
|
import { castModelId, mapIdCastError } from './ids.js';
|
|
5
8
|
|
|
9
|
+
// Shared by default and integrity writes. Trust this library-owned operator using the model's own
|
|
10
|
+
// Mongoose instance, so sanitizeFilter also works for models compiled by another copy.
|
|
11
|
+
export const versionFilter = (Model, expected) => ({
|
|
12
|
+
version: expected === 1 ? Model.base.trusted({ $in: [1, null] }) : expected,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const versionedUpdate = (update, expected) => Array.isArray(update)
|
|
16
|
+
? [...update, { $set: { version: expected + 1 } }]
|
|
17
|
+
: { ...update, version: expected + 1 };
|
|
18
|
+
|
|
19
|
+
// A supplied model need not share its GraphQL type's name. Do not choose arbitrarily when one
|
|
20
|
+
// model serves several registrations, or when a direct adapter caller has no runtime binding.
|
|
21
|
+
export const versionTypeName = (Model, registrations = []) => {
|
|
22
|
+
const matches = registrations.filter(({ model }) => model === Model);
|
|
23
|
+
return matches.length === 1 ? matches[0].gqltype.name : Model.modelName;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Bypass query middleware and use the write's cast key, in its still-active transaction.
|
|
27
|
+
export const readVersionMiss = async (Model, id, session, expected, updating = false, typeName = Model.modelName) => {
|
|
28
|
+
const stored = await Model.collection.findOne({ _id: id }, { session, projection: { version: 1 } });
|
|
29
|
+
if (!stored) return null;
|
|
30
|
+
const version = readStoredVersion(stored.version, { typeName, id });
|
|
31
|
+
if (version !== expected) throw versionConflictError(typeName, id);
|
|
32
|
+
if (updating && version === MAX_RECORD_VERSION) throw versionExhaustedError(typeName, id);
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
|
|
6
36
|
const LOCK_FIELD = '_simfinityReferenceLock';
|
|
7
37
|
// Distinct target ids per startup-audit query and per lock write, and ids the audit remembers per target.
|
|
8
38
|
const AUDIT_BATCH_SIZE = 1000;
|
|
@@ -99,9 +129,10 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
99
129
|
return [...references.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value);
|
|
100
130
|
};
|
|
101
131
|
|
|
102
|
-
const lock = async (Model, id, session) => {
|
|
132
|
+
const lock = async (Model, id, session, expectedVersion) => {
|
|
103
133
|
const result = await Model.collection.updateOne(
|
|
104
|
-
{ _id: id
|
|
134
|
+
{ _id: id, ...(expectedVersion === undefined ? {} : versionFilter(Model, expectedVersion)) },
|
|
135
|
+
{ $set: { [LOCK_FIELD]: new mongoose.Types.ObjectId() } }, { session },
|
|
105
136
|
);
|
|
106
137
|
return result.matchedCount > 0;
|
|
107
138
|
};
|
|
@@ -297,26 +328,45 @@ export const createMongoIntegrity = (options = {}) => {
|
|
|
297
328
|
},
|
|
298
329
|
// Protected writes cast the key before querying, without setters, as 3.5.3 did: the lock, the
|
|
299
330
|
// reference checks and the identity checks must all use the key requested before middleware runs.
|
|
300
|
-
async updateRecord(Model, id, update, session) {
|
|
331
|
+
async updateRecord(Model, id, update, session, { expectedVersion } = {}) {
|
|
301
332
|
assertWrite(Model, session);
|
|
302
333
|
return abortOnWriteError(session, async () => {
|
|
303
334
|
const objectId = castModelId(Model, id);
|
|
304
|
-
const
|
|
335
|
+
const versioned = expectedVersion !== undefined;
|
|
336
|
+
const query = (versioned
|
|
337
|
+
? Model.findOneAndUpdate({ _id: objectId, ...versionFilter(Model, expectedVersion) },
|
|
338
|
+
versionedUpdate(update, expectedVersion), { new: true })
|
|
339
|
+
: Model.findByIdAndUpdate(objectId, update, { new: true })).session(session);
|
|
340
|
+
// No write at the ceiling, but still cast the raw existence lookup's key once.
|
|
341
|
+
const exhausted = versioned && expectedVersion >= MAX_RECORD_VERSION;
|
|
342
|
+
if (exhausted) query.cast(Model);
|
|
343
|
+
const updated = exhausted ? null : await query;
|
|
305
344
|
if (!updated) {
|
|
345
|
+
if (versioned) await readVersionMiss(Model, query.getFilter()._id, session, expectedVersion, true,
|
|
346
|
+
versionTypeName(Model, binding.getRegistrations()));
|
|
306
347
|
// A middleware can hide a completed write with a null result. Abort
|
|
307
348
|
// even on a genuine no-match; core still reports its normal NOT_VALID_ID.
|
|
308
349
|
await session.abortTransaction();
|
|
309
350
|
return null;
|
|
310
351
|
}
|
|
311
352
|
if (!updated._id?.equals(objectId)) throw violation();
|
|
353
|
+
if (versioned) {
|
|
354
|
+
const typeName = versionTypeName(Model, binding.getRegistrations());
|
|
355
|
+
if (readStoredVersion(updated.version, { typeName, id }) !== expectedVersion + 1) {
|
|
356
|
+
throw unadvancedVersionError(typeName, id);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
312
359
|
await validateRecord(Model, updated, session);
|
|
313
360
|
return updated;
|
|
314
361
|
});
|
|
315
362
|
},
|
|
316
|
-
async deleteRecord(Model, id, session) {
|
|
363
|
+
async deleteRecord(Model, id, session, { expectedVersion } = {}) {
|
|
317
364
|
return abortOnWriteError(session, async () => {
|
|
318
365
|
const objectId = castModelId(Model, id);
|
|
319
|
-
if (!await lock(Model, objectId, session))
|
|
366
|
+
if (!await lock(Model, objectId, session, expectedVersion)) {
|
|
367
|
+
return expectedVersion === undefined ? null : readVersionMiss(Model, objectId, session, expectedVersion, false,
|
|
368
|
+
versionTypeName(Model, binding.getRegistrations()));
|
|
369
|
+
}
|
|
320
370
|
// One query per referencing collection: the $or of its per-path queries, each branch served
|
|
321
371
|
// by its own path index. A self-reference ignores the deleted document itself. The $ne stays
|
|
322
372
|
// inside each branch: hoisted, the planner can pick a scan of the _id index instead.
|
package/src/mongo/models.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
GraphQLString,
|
|
12
12
|
} from 'graphql';
|
|
13
13
|
import mongoose from 'mongoose';
|
|
14
|
+
import { isVersionField } from '@simtlix/simfinity-core/internal/record-version';
|
|
14
15
|
import { SimfinityError } from '@simtlix/simfinity-core';
|
|
15
16
|
import { getFieldStorageName, isVirtualField } from '@simtlix/simfinity-core/internal/relation-storage';
|
|
16
17
|
|
|
@@ -117,7 +118,9 @@ const generateSchemaDefinition = (gqlType, nested = false, ancestors = [], path
|
|
|
117
118
|
if (isVirtualField(fieldEntry)) continue;
|
|
118
119
|
const { type } = fieldEntry;
|
|
119
120
|
|
|
120
|
-
if (
|
|
121
|
+
if (isVersionField(fieldEntry)) {
|
|
122
|
+
schemaArg[fieldEntryName] = Number;
|
|
123
|
+
} else if (matchesScalar(type, GraphQLID)) {
|
|
121
124
|
schemaArg[fieldEntryName] = mongoose.Schema.Types.ObjectId;
|
|
122
125
|
} else if (matchesScalar(type, GraphQLString)) {
|
|
123
126
|
schemaArg[fieldEntryName] = withUnique(fieldEntry, String);
|
package/types/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
QueryFeatures,
|
|
20
20
|
RuntimeRegistration,
|
|
21
21
|
SimfinityError,
|
|
22
|
+
ViewOptions,
|
|
22
23
|
} from '@simtlix/simfinity-core';
|
|
23
24
|
export {
|
|
24
25
|
auth,
|
|
@@ -26,7 +27,9 @@ export {
|
|
|
26
27
|
createRuntime,
|
|
27
28
|
createValidatedScalar,
|
|
28
29
|
InternalServerError,
|
|
30
|
+
MAX_RECORD_VERSION,
|
|
29
31
|
plugins,
|
|
32
|
+
RecordVersion,
|
|
30
33
|
scalars,
|
|
31
34
|
SimfinityError,
|
|
32
35
|
validators,
|
|
@@ -36,10 +39,13 @@ export type {
|
|
|
36
39
|
AuthRule,
|
|
37
40
|
AuthRuleFunction,
|
|
38
41
|
AuthSchemaPlugin,
|
|
42
|
+
ConcurrencyDeclaration,
|
|
39
43
|
CreateSchemaOptions,
|
|
40
44
|
EnvelopSchemaPlugin,
|
|
41
45
|
FieldValidations,
|
|
42
46
|
FieldValidator,
|
|
47
|
+
FilterCondition,
|
|
48
|
+
FilterGroup,
|
|
43
49
|
ItemValidators,
|
|
44
50
|
MutationLimitsOptions,
|
|
45
51
|
PermissionSchema,
|
|
@@ -49,6 +55,9 @@ export type {
|
|
|
49
55
|
ScopeParams,
|
|
50
56
|
TypePermissions,
|
|
51
57
|
TypeScopes,
|
|
58
|
+
ViewOptions,
|
|
59
|
+
ViewParam,
|
|
60
|
+
ViewParamType,
|
|
52
61
|
VirtualFieldDeclaration,
|
|
53
62
|
VirtualFieldOptions,
|
|
54
63
|
} from '@simtlix/simfinity-core';
|
|
@@ -91,6 +100,8 @@ export interface MongoAdapterOptions {
|
|
|
91
100
|
*/
|
|
92
101
|
export interface MongoAdapter extends DatabaseAdapter {
|
|
93
102
|
readonly referentialIntegrity: 'off' | 'transactional';
|
|
103
|
+
/** Frozen declaration of atomic expectedVersion writes; wrappers must forward every argument. */
|
|
104
|
+
readonly writeFeatures: Readonly<{ optimisticConcurrency: true }>;
|
|
94
105
|
/**
|
|
95
106
|
* Frozen; lists every `QLOperator` value and declares `referenceEmptiness: true`. The literal text
|
|
96
107
|
* operators compile to escaped regular expressions: `ILIKE` unanchored, `STARTS_WITH` and
|
|
@@ -121,6 +132,15 @@ export interface MongoAdapter extends DatabaseAdapter {
|
|
|
121
132
|
/** After connecting MongoDB and createSchema(), await the transaction capability and existing-reference audit. */
|
|
122
133
|
initialize(): Promise<void>;
|
|
123
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Supports extensions.concurrency: { mode: 'optimistic' }. Generated models store version as Number.
|
|
137
|
+
* Supplied models must declare version: Number without immutable, select: false, getters, setters
|
|
138
|
+
* or a default other than 1; versionKey must not be 'version'. Missing/null stored versions mean 1.
|
|
139
|
+
* Protected writes use findOneAndUpdate/findOneAndDelete statics (integrity deletes still call
|
|
140
|
+
* findByIdAndDelete). Query middleware must preserve the version condition and increment.
|
|
141
|
+
* Adapter-detected conflicts abort supplied transactions only in transactional integrity mode;
|
|
142
|
+
* in default mode, the caller owns the abort. Core-detected conflicts keep that caller ownership.
|
|
143
|
+
*/
|
|
124
144
|
export function createMongoAdapter(options?: MongoAdapterOptions): MongoAdapter;
|
|
125
145
|
export function getRegistrations(): RuntimeRegistration[];
|
|
126
146
|
|
|
@@ -227,6 +247,14 @@ export function registerCustomQuery(
|
|
|
227
247
|
callback: (input: any, context: any) => any,
|
|
228
248
|
): void;
|
|
229
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Register a named view: a root query field that returns the type's list with the generated list
|
|
252
|
+
* arguments plus the view's params, and appends the groups of `where` after middleware. Throws
|
|
253
|
+
* `INVALID_VIEW` (500) for an invalid declaration and `QUERY_NAME_CONFLICT` (500) for a name already
|
|
254
|
+
* registered as a view (see the core `Runtime.registerView`).
|
|
255
|
+
*/
|
|
256
|
+
export function registerView(name: string, options: ViewOptions): void;
|
|
257
|
+
|
|
230
258
|
/** Operation context for root operations, generated non-embedded relation reads, and nested collection writes. */
|
|
231
259
|
export interface SimfinityMiddlewareContext {
|
|
232
260
|
args: any;
|