@simtlix/simfinity-js 3.12.0 → 3.14.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/AGGREGATION_EXAMPLE.md +3 -1
- package/README.md +14 -0
- package/package.json +3 -3
- package/src/index.js +4 -0
- package/src/mongo/adapter.js +135 -9
- package/src/mongo/integrity.js +56 -6
- package/src/mongo/models.js +4 -1
- package/src/mongo/queries.js +226 -21
- package/types/index.d.ts +46 -19
package/AGGREGATION_EXAMPLE.md
CHANGED
|
@@ -510,6 +510,8 @@ query {
|
|
|
510
510
|
}
|
|
511
511
|
```
|
|
512
512
|
|
|
513
|
+
In Example 9, `episodes` is in J because `totalRevenue` crosses it. Its join preserves one row per episode, including one null row when none exists, so `seriesCount` counts those rows rather than distinct series. A collection used only by a filter becomes an `ANY` frame and does not multiply facts; beyond a joined prefix it tests the joined alias through an anchor. Explicit `ANY`/`ALL`/`NONE` and relation-filter `quantifier` always stay frames. See [aggregate collection semantics](../../docs/guide/queries.md#aggregate-records).
|
|
514
|
+
|
|
513
515
|
## Field Path Resolution
|
|
514
516
|
|
|
515
517
|
The `groupId` and `path` parameters support:
|
|
@@ -528,7 +530,7 @@ The `groupId` and `path` parameters support:
|
|
|
528
530
|
The aggregation queries are translated to MongoDB aggregation pipelines:
|
|
529
531
|
|
|
530
532
|
1. **$lookup**: Used for non-embedded related entities
|
|
531
|
-
2. **$unwind**:
|
|
533
|
+
2. **$unwind**: Expands collection prefixes used by `groupId` or facts (J), preserving their joined-row cardinality; filter-only collection frames use correlated `$lookup` existence probes without unwinding root rows
|
|
532
534
|
3. **$match**: Applied for filtering (before grouping)
|
|
533
535
|
4. **$group**: Groups by the specified field with aggregation operations
|
|
534
536
|
5. **$project**: Formats the final output with groupId and facts fields
|
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,6 +42,10 @@ 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
|
|
|
45
|
+
The adapter declares `queryFeatures.quantifiers: true`. Ordinary filter paths through collections compile as correlated `ANY` frames: `reviews.rating GTE 4` keeps each parent once, so pagination counts roots and filters no longer multiply aggregate rows. Conditions in the same group that share a collection prefix must match the same child; separate groups have independent frames. Explicit `ANY`, `ALL` and `NONE` groups and `quantifier` field filters accept paths relative to the target element, including nested frames and references. `ALL` holds when every visible element matches and is true for an empty set; `ALL` without conditions is always true, including inside `OR`. Client target `find` scopes run inside each frame before its conditions, and `ALL` negates only the conditions; trusted conditions from middleware, scopes and direct `buildQuery()` calls read stored data. A reference inside a client frame reads its target through the scoped join in that sub-pipeline.
|
|
46
|
+
|
|
47
|
+
A quantified frame uses one `$lookup` with the child scope, inner joins and the condition, followed by `$limit: 1` and `$project: { _id: 1 }`, tested by the presence or absence of `"__sf_qN.0"`. Collection frames correlated through a reference or embedded `_id` start with a non-null check on the child's connection field; legacy aggregate collection joins whose local key is not the root `_id` (a collection reached through a reference or an embedded object) use the same guard, avoiding orphan matches through missing parents. Root-anchored aggregate collection joins keep their form. Reference frames need no guard. `groupId` and fact paths preserve their collection joins and row cardinality, while other aggregate filters use frames anchored on the relevant joined row. Sort paths through a collection reject with `INVALID_FILTER_PATH` (400), also for counted pages; sorts through references remain available. Existing own-field and reference predicates, embedded-list predicates and 2b emptiness forms retain their pipelines. Generated and batched collection reads use the same frame pipelines unchanged inside `$unionWith`. These concise lookups require MongoDB 5.0 or later; see the [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html) for correlation, scopes, depth and compatibility rules.
|
|
48
|
+
|
|
35
49
|
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
50
|
|
|
37
51
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.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.14.0",
|
|
33
|
+
"@simtlix/simfinity-mcp": "3.14.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,
|
|
@@ -36,6 +38,8 @@ export {
|
|
|
36
38
|
createRuntime,
|
|
37
39
|
createValidatedScalar,
|
|
38
40
|
InternalServerError,
|
|
41
|
+
MAX_RECORD_VERSION,
|
|
42
|
+
RecordVersion,
|
|
39
43
|
SimfinityError,
|
|
40
44
|
};
|
|
41
45
|
|
package/src/mongo/adapter.js
CHANGED
|
@@ -1,20 +1,113 @@
|
|
|
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
|
-
const QUERY_FEATURES = Object.freeze({ operators: MONGO_QUERY_OPERATORS, referenceEmptiness: true });
|
|
19
|
+
const QUERY_FEATURES = Object.freeze({ operators: MONGO_QUERY_OPERATORS, referenceEmptiness: true, quantifiers: true });
|
|
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
|
+
};
|
|
18
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) => {
|
|
@@ -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/src/mongo/queries.js
CHANGED
|
@@ -10,7 +10,8 @@ import mongoose from 'mongoose';
|
|
|
10
10
|
|
|
11
11
|
import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity-core';
|
|
12
12
|
import {
|
|
13
|
-
TEXT_QUERY_OPERATORS, describeEmptiness, effectiveScalarName, emptinessScopePaths,
|
|
13
|
+
TEXT_QUERY_OPERATORS, aggregateJoinPrefixes, crossingKeys, describeEmptiness, effectiveScalarName, emptinessScopePaths,
|
|
14
|
+
frameCollectionPaths, isEmptinessOperator, validateQuantifiedGroups,
|
|
14
15
|
textQueryOperator, validateTextFilterValue,
|
|
15
16
|
} from '@simtlix/simfinity-core/internal/query-operators';
|
|
16
17
|
import {
|
|
@@ -43,6 +44,8 @@ export const MONGO_QUERY_OPERATORS = Object.freeze([
|
|
|
43
44
|
export const createMongoQueries = ({ getModel, getRegistrations }) => {
|
|
44
45
|
// One alias per relation path for each query build, keyed by the build's `included` accumulator.
|
|
45
46
|
const lookupAliases = new WeakMap();
|
|
47
|
+
// Only actual reference joins reached through embedded lists multiply a body's candidates.
|
|
48
|
+
const multiplyingBuilds = new WeakSet();
|
|
46
49
|
|
|
47
50
|
const allocateLookupAlias = (aggregationsIncluded, relationPath) => {
|
|
48
51
|
let aliases = lookupAliases.get(aggregationsIncluded);
|
|
@@ -62,7 +65,7 @@ const relationBuild = (aggregationsIncluded) => {
|
|
|
62
65
|
let build = relationBuilds.get(aggregationsIncluded);
|
|
63
66
|
if (!build) {
|
|
64
67
|
build = {
|
|
65
|
-
aliases: new Map(), counts: { join: 0, frame: 0 }, scopes: new WeakMap(),
|
|
68
|
+
aliases: new Map(), counts: { join: 0, frame: 0, scope: 0, serial: 0 }, scopes: new WeakMap(), serials: new WeakMap(),
|
|
66
69
|
};
|
|
67
70
|
relationBuilds.set(aggregationsIncluded, build);
|
|
68
71
|
}
|
|
@@ -92,6 +95,7 @@ const buildRelationLookup = ({
|
|
|
92
95
|
{
|
|
93
96
|
$lookup: {
|
|
94
97
|
from: collectionName, foreignField, localField, as: alias,
|
|
98
|
+
...(foreignField !== '_id' && localField !== '_id' ? { pipeline: [{ $match: { [foreignField]: { $ne: null } } }] } : {}),
|
|
95
99
|
},
|
|
96
100
|
},
|
|
97
101
|
{ $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
|
|
@@ -195,13 +199,14 @@ const identifierLeaf = (model, schemaPath, gqltype) => {
|
|
|
195
199
|
};
|
|
196
200
|
|
|
197
201
|
// Resolve GraphQL paths and storage paths together; supplied models own ID casting.
|
|
198
|
-
const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
202
|
+
const resolveQueryPath = (gqltype, path, aggregationsIncluded, origin) => {
|
|
199
203
|
assertValidFilterPath(path);
|
|
200
204
|
const parts = path.split('.');
|
|
201
205
|
let currentType = gqltype;
|
|
202
|
-
let currentModel = getModel(gqltype);
|
|
203
|
-
let mongoPath = '';
|
|
204
|
-
let schemaPath = '';
|
|
206
|
+
let currentModel = origin?.model ?? getModel(gqltype);
|
|
207
|
+
let mongoPath = origin?.parent.key ?? '';
|
|
208
|
+
let schemaPath = origin?.parent.schemaPath ?? '';
|
|
209
|
+
let vector = false;
|
|
205
210
|
const aggregateClauses = {};
|
|
206
211
|
|
|
207
212
|
for (const [index, part] of parts.entries()) {
|
|
@@ -218,6 +223,7 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
|
218
223
|
if (fieldType instanceof GraphQLObjectType) {
|
|
219
224
|
if (index === parts.length - 1) throw filterError(`Query path must end in a scalar field: ${path}`, 'INVALID_FILTER_PATH');
|
|
220
225
|
if (!relation || relation.embedded) {
|
|
226
|
+
vector ||= !!getListShape(field.type);
|
|
221
227
|
mongoPath = mongoPath ? `${mongoPath}.${part}` : part;
|
|
222
228
|
schemaPath = schemaPath ? `${schemaPath}.${part}` : part;
|
|
223
229
|
} else {
|
|
@@ -229,6 +235,8 @@ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
|
|
|
229
235
|
? normalizeConnectionField(fieldType, relation.connectionField || part).storageFieldName
|
|
230
236
|
: getFieldStorageName(part, field);
|
|
231
237
|
const localLeaf = isList ? '_id' : connField;
|
|
238
|
+
if (vector) multiplyingBuilds.add(aggregationsIncluded);
|
|
239
|
+
vector = false;
|
|
232
240
|
const alias = allocateLookupAlias(aggregationsIncluded, parts.slice(0, index + 1).join('.'));
|
|
233
241
|
aggregateClauses[alias] = buildRelationLookup({
|
|
234
242
|
collectionName: relatedModel.collection.collectionName,
|
|
@@ -394,7 +402,7 @@ const compileTargetScope = async (build, scope, targetType) => {
|
|
|
394
402
|
const stages = [];
|
|
395
403
|
const match = await collectFiltersAndLookups(scope, targetType, stages, {}, 'find');
|
|
396
404
|
if (match) stages.push(match);
|
|
397
|
-
const result = stages.length ? { token: `s${build.
|
|
405
|
+
const result = stages.length ? { token: `s${build.counts.scope++}`, stages } : null;
|
|
398
406
|
if (!build.scopes.has(scope)) build.scopes.set(scope, new Map());
|
|
399
407
|
build.scopes.get(scope).set(targetType, result);
|
|
400
408
|
return result;
|
|
@@ -491,6 +499,179 @@ const buildEmptinessClause = async (gqltype, path, operator, value, construct, l
|
|
|
491
499
|
return form(ctx, shape, parent, type, segments.at(-1));
|
|
492
500
|
};
|
|
493
501
|
|
|
502
|
+
// A build keeps one rewrite context; aggregate J paths must survive every logical-group pass.
|
|
503
|
+
const rewriteContext = (gqltype, operation = 'find', aggregation) => {
|
|
504
|
+
const ctx = { gqltype, operation, quantifiers: true, referenceEmptiness: true, operators: new Set(MONGO_QUERY_OPERATORS), base: '', inFrame: false };
|
|
505
|
+
// Ordinary root markers reject at the existing post-value guards, preserving native order.
|
|
506
|
+
ctx.deferOrdinaryRootMarkers = true;
|
|
507
|
+
ctx.J = operation === 'aggregate' ? aggregateJoinPrefixes(aggregation, ctx) : new Set();
|
|
508
|
+
return ctx;
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// Cross references in the containing pipeline. Collection steps occur here only for an aggregate
|
|
512
|
+
// anchor: all ordinary collection crossings have already become frames in the shared rewrite.
|
|
513
|
+
const crossFramePath = async (gqltype, segments, scopes, build, lookups, included) => {
|
|
514
|
+
let type = gqltype;
|
|
515
|
+
let parent = { key: '', schemaPath: '', path: '' };
|
|
516
|
+
let model = getModel(type);
|
|
517
|
+
const chain = [];
|
|
518
|
+
let scoped = false;
|
|
519
|
+
let vector = false;
|
|
520
|
+
for (const name of segments) {
|
|
521
|
+
const field = type.getFields()[name];
|
|
522
|
+
const targetType = getNamedType(field.type);
|
|
523
|
+
const path = joinPath(parent.path, name);
|
|
524
|
+
const relation = field.extensions?.relation;
|
|
525
|
+
if (!relation || relation.embedded) {
|
|
526
|
+
vector ||= !!getListShape(field.type);
|
|
527
|
+
parent = { key: joinPath(parent.key, name), schemaPath: joinPath(parent.schemaPath, name), path };
|
|
528
|
+
} else {
|
|
529
|
+
const target = getModel(targetType);
|
|
530
|
+
if (!target) throw filterError(`Related model is not available for ${path}`, 'INVALID_FILTER_PATH');
|
|
531
|
+
const collection = !!getListShape(field.type);
|
|
532
|
+
const foreignField = collection ? normalizeConnectionField(targetType, relation.connectionField || name).storageFieldName : '_id';
|
|
533
|
+
const localField = joinPath(parent.key, collection ? '_id' : getFieldStorageName(name, field));
|
|
534
|
+
const scope = scopes && Object.hasOwn(scopes, path) ? await compileTargetScope(build, scopes[path], targetType) : null;
|
|
535
|
+
if (vector && scope) throw new SimfinityError('A reference inside an embedded list cannot apply a target scope', 'FORBIDDEN_FILTER_PATH', 403);
|
|
536
|
+
chain.push(scope ? scope.token : '-');
|
|
537
|
+
scoped ||= !!scope;
|
|
538
|
+
let alias;
|
|
539
|
+
if (!scoped) {
|
|
540
|
+
alias = allocateLookupAlias(included, path);
|
|
541
|
+
lookups[alias] = buildRelationLookup({ collectionName: target.collection.collectionName, localField, foreignField, alias });
|
|
542
|
+
} else {
|
|
543
|
+
alias = allocateRelationAlias(build, 'join', `${path} ${chain.join(',')}`);
|
|
544
|
+
// A vector with a null target scope keeps every target. The chain alias isolates it
|
|
545
|
+
// from a trusted join while its local key preserves the earlier restricted reference.
|
|
546
|
+
lookups[alias] = vector ? buildRelationLookup({ collectionName: target.collection.collectionName, localField, foreignField, alias }) : { stages: [
|
|
547
|
+
{ $lookup: { from: target.collection.collectionName, localField, foreignField, pipeline: [...(scope?.stages ?? []), { $limit: 1 }], as: alias } },
|
|
548
|
+
{ $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
|
|
549
|
+
] };
|
|
550
|
+
}
|
|
551
|
+
if (vector) multiplyingBuilds.add(included);
|
|
552
|
+
vector = false;
|
|
553
|
+
parent = { key: alias, schemaPath: '', path };
|
|
554
|
+
model = target;
|
|
555
|
+
}
|
|
556
|
+
type = targetType;
|
|
557
|
+
}
|
|
558
|
+
return { type, parent, model, chain };
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// A non-emptiness marker is legal only inside a frame, on a condition crossing references. Its
|
|
562
|
+
// keys are relative to the element, just like 2b emptiness keys in the same sub-build.
|
|
563
|
+
const resolveConditionPath = async (gqltype, path, construct, included, ctx) => {
|
|
564
|
+
if (!hasScopeMarker(construct)) return resolveQueryPath(gqltype, path, included);
|
|
565
|
+
// buildQueryTerms rejects the marker immediately after buildMatchesClause; group conditions
|
|
566
|
+
// also retain their raw-condition guard. No accepted path parses or casts a value twice.
|
|
567
|
+
if (!ctx?.inFrame && ctx?.deferOrdinaryRootMarkers === true) return resolveQueryPath(gqltype, path, included);
|
|
568
|
+
if (!ctx?.inFrame) throw detachedFilter(`${construct.operator ?? 'EQ'} on ${joinPath(ctx?.base, path)} carries a client scope marker, but is not an emptiness condition`);
|
|
569
|
+
const paths = crossingKeys(path.split('.'), { gqltype });
|
|
570
|
+
const scopes = clientScopes(construct, `${construct.operator ?? 'EQ'} on ${joinPath(ctx.base, path)}`, paths);
|
|
571
|
+
const aggregateClauses = {};
|
|
572
|
+
const crossed = await crossFramePath(gqltype, path.split('.').slice(0, -1), scopes, relationBuild(included), aggregateClauses, included);
|
|
573
|
+
const resolved = resolveQueryPath(crossed.type, path.split('.').at(-1), included, crossed);
|
|
574
|
+
return { ...resolved, aggregateClauses: { ...aggregateClauses, ...resolved.aggregateClauses } };
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const compileFrame = async (ctx, quantifier, group) => {
|
|
578
|
+
const build = relationBuild(ctx.included);
|
|
579
|
+
const anchor = group.__implicit === true ? group.__anchor ?? [] : [];
|
|
580
|
+
const segments = [...anchor, ...group.path.split('.')];
|
|
581
|
+
const paths = [];
|
|
582
|
+
let type = ctx.gqltype;
|
|
583
|
+
let vector = false;
|
|
584
|
+
let multiplyingPrefix = false;
|
|
585
|
+
for (const [index, name] of segments.entries()) {
|
|
586
|
+
const field = type.getFields()[name];
|
|
587
|
+
if (field.extensions?.relation && !field.extensions.relation.embedded) {
|
|
588
|
+
if (index < segments.length - 1 && vector) multiplyingPrefix = true;
|
|
589
|
+
vector = false;
|
|
590
|
+
if (!getModel(getNamedType(field.type))) throw filterError(`Related model is not available for ${joinPath(ctx.base, segments.join('.'))}`, 'INVALID_FILTER_PATH');
|
|
591
|
+
if (!anchor.length || index >= anchor.length || getListShape(field.type)) paths.push(segments.slice(0, index + 1).join('.'));
|
|
592
|
+
} else {
|
|
593
|
+
vector ||= !!getListShape(field.type);
|
|
594
|
+
}
|
|
595
|
+
type = getNamedType(field.type);
|
|
596
|
+
}
|
|
597
|
+
const scopes = clientScopes(group, `${quantifier} on ${joinPath(ctx.base, segments.join('.'))}`, paths);
|
|
598
|
+
if (anchor.length && scopes) {
|
|
599
|
+
for (const path of paths.filter((key) => anchor.join('.').startsWith(key))) {
|
|
600
|
+
if (scopes[path] !== null) throw detachedFilter('Aggregate anchors require null J scopes and legacy reference joins');
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const emptyAll = quantifier === 'ALL' && !['conditions', 'ANY', 'ALL', 'NONE', 'AND', 'OR'].some((key) => group[key]?.length);
|
|
604
|
+
// A vector prefix belongs to this predicate, not to the containing result set. A
|
|
605
|
+
// self-correlated witness keeps its joins local without discarding any target. Aggregate
|
|
606
|
+
// anchors retain their intentional expansion. NONE tests the positive ANY witness; ALL
|
|
607
|
+
// looks for a prefix whose complete ALL test fails, including vacuous empty prefixes.
|
|
608
|
+
if (multiplyingPrefix && !anchor.length && !ctx.prefixIsolated && !emptyAll) {
|
|
609
|
+
const model = getModel(ctx.gqltype);
|
|
610
|
+
if (!model) throw filterError(`Related model is not available for ${ctx.gqltype.name}`, 'INVALID_FILTER_PATH');
|
|
611
|
+
if (!build.serials.has(group)) build.serials.set(group, build.counts.serial++);
|
|
612
|
+
const alias = allocateRelationAlias(build, 'frame', `prefix:${build.serials.get(group)}`);
|
|
613
|
+
const stages = [];
|
|
614
|
+
const included = {};
|
|
615
|
+
relationBuilds.set(included, { ...relationBuild(included), counts: build.counts, scopes: build.scopes });
|
|
616
|
+
const positive = await compileFrame({ ...ctx, stages, included, prefixIsolated: true }, quantifier === 'NONE' ? 'ANY' : quantifier, group);
|
|
617
|
+
if (quantifier === 'ALL' && !Object.keys(positive).length) return {};
|
|
618
|
+
ctx.stages.push({ $lookup: {
|
|
619
|
+
from: model.collection.collectionName, localField: '_id', foreignField: '_id',
|
|
620
|
+
pipeline: [...stages, { $match: quantifier === 'ALL' ? { $nor: [positive] } : positive }, { $limit: 1 }, { $project: { _id: 1 } }],
|
|
621
|
+
as: alias,
|
|
622
|
+
} });
|
|
623
|
+
ctx.included[alias] = true;
|
|
624
|
+
return { [`${alias}.0`]: { $exists: quantifier === 'ANY' } };
|
|
625
|
+
}
|
|
626
|
+
const lookups = {};
|
|
627
|
+
const crossed = await crossFramePath(ctx.gqltype, segments.slice(0, -1), anchor.length ? null : scopes, build, lookups, ctx.included);
|
|
628
|
+
const name = segments.at(-1);
|
|
629
|
+
const field = crossed.type.getFields()[name];
|
|
630
|
+
const targetType = getNamedType(field.type);
|
|
631
|
+
const path = segments.join('.');
|
|
632
|
+
const scope = scopes ? await compileTargetScope(build, scopes[path], targetType) : null;
|
|
633
|
+
if (emptyAll) return {};
|
|
634
|
+
// Allocate before compiling the body: descendants follow their parent's serial even though
|
|
635
|
+
// their lookups live inside its pipeline. No quantified frame shares a 2b emptiness frame.
|
|
636
|
+
if (!build.serials.has(group)) build.serials.set(group, build.counts.serial++);
|
|
637
|
+
const alias = allocateRelationAlias(build, 'frame', `group:${build.serials.get(group)} ${path} ${[...crossed.chain, scope ? scope.token : '-'].join(',')}`);
|
|
638
|
+
const stages = [];
|
|
639
|
+
const included = {};
|
|
640
|
+
relationBuilds.set(included, { ...relationBuild(included), counts: build.counts, scopes: build.scopes });
|
|
641
|
+
const childCtx = { ...rewriteContext(targetType), base: joinPath(ctx.base, path), inFrame: true };
|
|
642
|
+
const body = await buildFilterGroupMatch(group, targetType, stages, included, 0, childCtx);
|
|
643
|
+
if (quantifier === 'ALL' && body === null) return {};
|
|
644
|
+
// ALL negates one truth value for the actual child. With vector joins, negating
|
|
645
|
+
// individual expanded rows would incorrectly turn a bad sibling target into a failing
|
|
646
|
+
// child. Keep the complete positive body together, with its scope admission outside.
|
|
647
|
+
let bodyStages = stages;
|
|
648
|
+
let bodyMatch = quantifier === 'ALL' ? { $nor: [body] } : body;
|
|
649
|
+
if (quantifier === 'ALL' && multiplyingBuilds.has(included)) {
|
|
650
|
+
const witness = allocateRelationAlias(build, 'frame', `body:${build.serials.get(group)}`);
|
|
651
|
+
bodyStages = [{ $lookup: {
|
|
652
|
+
from: getModel(targetType).collection.collectionName, localField: '_id', foreignField: '_id',
|
|
653
|
+
pipeline: [...stages, { $match: body }, { $limit: 1 }, { $project: { _id: 1 } }],
|
|
654
|
+
as: witness,
|
|
655
|
+
} }];
|
|
656
|
+
bodyMatch = { [`${witness}.0`]: { $exists: false } };
|
|
657
|
+
}
|
|
658
|
+
const collection = !!getListShape(field.type);
|
|
659
|
+
const localField = joinPath(crossed.parent.key, collection ? '_id' : getFieldStorageName(name, field));
|
|
660
|
+
const foreignField = collection ? normalizeConnectionField(targetType, field.extensions.relation.connectionField || name).storageFieldName : '_id';
|
|
661
|
+
lookups[alias] = { stages: [{ $lookup: {
|
|
662
|
+
from: getModel(targetType).collection.collectionName, localField, foreignField,
|
|
663
|
+
pipeline: [
|
|
664
|
+
...(collection && localField !== '_id' ? [{ $match: { [foreignField]: { $ne: null } } }] : []),
|
|
665
|
+
...(scope?.stages ?? []), ...bodyStages,
|
|
666
|
+
...(body === null ? [] : [{ $match: bodyMatch }]),
|
|
667
|
+
{ $limit: 1 }, { $project: { _id: 1 } },
|
|
668
|
+
],
|
|
669
|
+
as: alias,
|
|
670
|
+
} }] };
|
|
671
|
+
appendLookups(ctx.stages, ctx.included, lookups);
|
|
672
|
+
return { [`${alias}.0`]: { $exists: quantifier === 'ANY' } };
|
|
673
|
+
};
|
|
674
|
+
|
|
494
675
|
const castFilterValue = (value, resolved) => {
|
|
495
676
|
if (value === null) return null;
|
|
496
677
|
if (value === undefined || Array.isArray(value)
|
|
@@ -556,7 +737,7 @@ const buildMatchesClause = (resolved, operator, value, path) => {
|
|
|
556
737
|
return { [resolved.mongoPath]: OP_TO_MONGO[op](coerced, path) };
|
|
557
738
|
};
|
|
558
739
|
|
|
559
|
-
const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggregationsIncluded) => {
|
|
740
|
+
const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggregationsIncluded, ctx) => {
|
|
560
741
|
const aggregateClauses = {};
|
|
561
742
|
const matchesClauses = [];
|
|
562
743
|
if (!qlField) throw filterError(`Unknown filter field: ${fieldName}`, 'INVALID_FILTER_FIELD');
|
|
@@ -606,10 +787,10 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
|
|
|
606
787
|
}
|
|
607
788
|
if (isObject) assertValidFilterPath(term.path);
|
|
608
789
|
const path = isObject ? `${fieldName}.${term.path}` : fieldName;
|
|
609
|
-
const resolved =
|
|
790
|
+
const resolved = await resolveConditionPath(gqltype, path, isObject ? term : null, aggregationsIncluded, ctx);
|
|
610
791
|
Object.assign(aggregateClauses, resolved.aggregateClauses);
|
|
611
792
|
matchesClauses.push(buildMatchesClause(resolved, term.operator, term.value, path));
|
|
612
|
-
if (isObject && hasScopeMarker(term)) {
|
|
793
|
+
if (isObject && hasScopeMarker(term) && !ctx?.inFrame) {
|
|
613
794
|
throw detachedFilter(`${term.operator ?? 'EQ'} on ${path} carries a client scope marker, but is not an emptiness condition`);
|
|
614
795
|
}
|
|
615
796
|
}
|
|
@@ -625,7 +806,15 @@ const validateLogicalLists = (group, keys = ['AND', 'OR', 'conditions']) => {
|
|
|
625
806
|
}
|
|
626
807
|
};
|
|
627
808
|
|
|
628
|
-
const buildFilterGroupMatch = async (
|
|
809
|
+
const buildFilterGroupMatch = async (rawFilterGroup, gqltype, aggregateClauses, aggregationsIncluded, depth = 0, rewriteCtx) => {
|
|
810
|
+
let filterGroup = rawFilterGroup;
|
|
811
|
+
let ctx = rewriteCtx;
|
|
812
|
+
if (!ctx) {
|
|
813
|
+
ctx = rewriteContext(gqltype);
|
|
814
|
+
const input = { AND: [filterGroup] };
|
|
815
|
+
validateQuantifiedGroups(input, ctx);
|
|
816
|
+
filterGroup = frameCollectionPaths(input, ctx).AND[0];
|
|
817
|
+
}
|
|
629
818
|
if (depth > MAX_FILTER_GROUP_DEPTH) {
|
|
630
819
|
throw new SimfinityError('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED', 400);
|
|
631
820
|
}
|
|
@@ -661,15 +850,15 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
661
850
|
throw new SimfinityError(`Filter on object field "${condition.field}" requires a path`, 'MISSING_FILTER_PATH', 400);
|
|
662
851
|
}
|
|
663
852
|
filterInput = {
|
|
664
|
-
terms: [{ path: condition.path, operator: condition.operator, value: condition.value }],
|
|
853
|
+
terms: [{ ...rawCondition, field: undefined, path: condition.path, operator: condition.operator, value: condition.value }],
|
|
665
854
|
};
|
|
666
855
|
} else {
|
|
667
856
|
if (condition.path != null && condition.path !== '') throw filterError(`Cannot traverse scalar field ${condition.field}`, 'INVALID_FILTER_PATH');
|
|
668
857
|
filterInput = { operator: condition.operator, value: condition.value };
|
|
669
858
|
}
|
|
670
859
|
|
|
671
|
-
const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype, aggregationsIncluded);
|
|
672
|
-
if (hasScopeMarker(rawCondition)) {
|
|
860
|
+
const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype, aggregationsIncluded, ctx);
|
|
861
|
+
if (hasScopeMarker(rawCondition) && !ctx.inFrame) {
|
|
673
862
|
throw detachedFilter(`${condition.operator ?? 'EQ'} on ${path} carries a client scope marker, but is not an emptiness condition`);
|
|
674
863
|
}
|
|
675
864
|
appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
|
|
@@ -681,9 +870,13 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
681
870
|
}
|
|
682
871
|
}
|
|
683
872
|
|
|
873
|
+
for (const quantifier of ['ANY', 'ALL', 'NONE']) {
|
|
874
|
+
for (const group of filterGroup[quantifier] ?? []) parts.push(await compileFrame({ ...ctx, stages: aggregateClauses, included: aggregationsIncluded }, quantifier, group));
|
|
875
|
+
}
|
|
876
|
+
|
|
684
877
|
if (filterGroup.AND?.length > 0) {
|
|
685
878
|
for (const subGroup of filterGroup.AND) {
|
|
686
|
-
const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1);
|
|
879
|
+
const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + (subGroup.__implicit === true ? 0 : 1), ctx);
|
|
687
880
|
if (subMatch) parts.push(subMatch);
|
|
688
881
|
}
|
|
689
882
|
}
|
|
@@ -691,7 +884,7 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
691
884
|
if (filterGroup.OR?.length > 0) {
|
|
692
885
|
const orParts = [];
|
|
693
886
|
for (const subGroup of filterGroup.OR) {
|
|
694
|
-
const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1);
|
|
887
|
+
const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + (subGroup.__implicit === true ? 0 : 1), ctx);
|
|
695
888
|
if (subMatch) orParts.push(subMatch);
|
|
696
889
|
}
|
|
697
890
|
if (orParts.length === 1) {
|
|
@@ -710,7 +903,10 @@ const FIND_CONTROL_KEYS = new Set(['pagination', 'sort', 'AND', 'OR']);
|
|
|
710
903
|
// `aggregation` is the expression argument only on aggregate queries; elsewhere it names a field.
|
|
711
904
|
const AGGREGATE_CONTROL_KEYS = new Set([...FIND_CONTROL_KEYS, 'aggregation']);
|
|
712
905
|
|
|
713
|
-
const collectFiltersAndLookups = async (
|
|
906
|
+
const collectFiltersAndLookups = async (rawInput, gqltype, aggregateClauses, aggregationsIncluded, operation = 'find', aggregation) => {
|
|
907
|
+
const ctx = rewriteContext(gqltype, operation, aggregation);
|
|
908
|
+
validateQuantifiedGroups(rawInput, ctx);
|
|
909
|
+
const input = frameCollectionPaths(rawInput, ctx);
|
|
714
910
|
// Top-level arguments are field filters plus AND/OR groups; `conditions` is a key only inside groups.
|
|
715
911
|
validateLogicalLists(input, ['AND', 'OR']);
|
|
716
912
|
const controlKeys = operation === 'aggregate' ? AGGREGATE_CONTROL_KEYS : FIND_CONTROL_KEYS;
|
|
@@ -722,7 +918,7 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
|
|
|
722
918
|
for (const [key, filterField] of Object.entries(input)) {
|
|
723
919
|
if (controlKeys.has(key)) continue;
|
|
724
920
|
const qlField = fields[key];
|
|
725
|
-
const result = await buildQueryTerms(filterField, qlField, key, gqltype, aggregationsIncluded);
|
|
921
|
+
const result = await buildQueryTerms(filterField, qlField, key, gqltype, aggregationsIncluded, ctx);
|
|
726
922
|
appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
|
|
727
923
|
for (const matchClause of Object.values(result.matchesClauses)) {
|
|
728
924
|
for (const [matchKey, match] of Object.entries(matchClause)) {
|
|
@@ -742,7 +938,7 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
|
|
|
742
938
|
|
|
743
939
|
if (input.AND?.length > 0) {
|
|
744
940
|
for (const group of input.AND) {
|
|
745
|
-
const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded);
|
|
941
|
+
const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded, 0, ctx);
|
|
746
942
|
if (groupMatch) topLevelAndParts.push(groupMatch);
|
|
747
943
|
}
|
|
748
944
|
}
|
|
@@ -750,7 +946,7 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
|
|
|
750
946
|
if (input.OR?.length > 0) {
|
|
751
947
|
const orParts = [];
|
|
752
948
|
for (const group of input.OR) {
|
|
753
|
-
const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded);
|
|
949
|
+
const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded, 0, ctx);
|
|
754
950
|
if (groupMatch) orParts.push(groupMatch);
|
|
755
951
|
}
|
|
756
952
|
if (orParts.length === 1) {
|
|
@@ -777,6 +973,15 @@ const validateSortTerms = (sort) => {
|
|
|
777
973
|
const buildSortClause = (sortTerms, gqltype, aggregateClauses, aggregationsIncluded) => {
|
|
778
974
|
const sortExpressions = {};
|
|
779
975
|
for (const sort of sortTerms) {
|
|
976
|
+
let type = gqltype;
|
|
977
|
+
for (const name of sort.field.split('.')) {
|
|
978
|
+
const field = type.getFields?.()[name];
|
|
979
|
+
if (!field) break;
|
|
980
|
+
if (field.extensions?.relation && !field.extensions.relation.embedded && getListShape(field.type)) {
|
|
981
|
+
throw filterError(`Sort path ${sort.field} crosses the collection ${type.name}.${name}; a record has no single value there`, 'INVALID_FILTER_PATH');
|
|
982
|
+
}
|
|
983
|
+
type = getNamedType(field.type);
|
|
984
|
+
}
|
|
780
985
|
const resolved = resolveQueryPath(gqltype, sort.field, aggregationsIncluded);
|
|
781
986
|
appendLookups(aggregateClauses, aggregationsIncluded, resolved.aggregateClauses);
|
|
782
987
|
sortExpressions[resolved.mongoPath] = sort.order === 'ASC' ? 1 : -1;
|
|
@@ -817,7 +1022,7 @@ const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
|
|
|
817
1022
|
const aggregationsIncluded = {};
|
|
818
1023
|
const paging = paginationStages(input.pagination, false);
|
|
819
1024
|
|
|
820
|
-
const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded, 'aggregate');
|
|
1025
|
+
const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded, 'aggregate', aggregationExpression);
|
|
821
1026
|
if (matchStage) aggregateClauses.push(matchStage);
|
|
822
1027
|
|
|
823
1028
|
const { groupId, facts } = aggregationExpression ?? {};
|
package/types/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
DatabaseAdapter,
|
|
18
18
|
MutationLimitsOptions,
|
|
19
19
|
QueryFeatures,
|
|
20
|
+
QueryLimitsOptions,
|
|
20
21
|
RuntimeRegistration,
|
|
21
22
|
SimfinityError,
|
|
22
23
|
ViewOptions,
|
|
@@ -27,7 +28,9 @@ export {
|
|
|
27
28
|
createRuntime,
|
|
28
29
|
createValidatedScalar,
|
|
29
30
|
InternalServerError,
|
|
31
|
+
MAX_RECORD_VERSION,
|
|
30
32
|
plugins,
|
|
33
|
+
RecordVersion,
|
|
31
34
|
scalars,
|
|
32
35
|
SimfinityError,
|
|
33
36
|
validators,
|
|
@@ -37,6 +40,7 @@ export type {
|
|
|
37
40
|
AuthRule,
|
|
38
41
|
AuthRuleFunction,
|
|
39
42
|
AuthSchemaPlugin,
|
|
43
|
+
ConcurrencyDeclaration,
|
|
40
44
|
CreateSchemaOptions,
|
|
41
45
|
EnvelopSchemaPlugin,
|
|
42
46
|
FieldValidations,
|
|
@@ -47,6 +51,12 @@ export type {
|
|
|
47
51
|
MutationLimitsOptions,
|
|
48
52
|
PermissionSchema,
|
|
49
53
|
PolicyExpression,
|
|
54
|
+
QLFilterCondition,
|
|
55
|
+
QLFilterGroup,
|
|
56
|
+
QLQuantifiedGroup,
|
|
57
|
+
QLTypeFilterExpression,
|
|
58
|
+
QueryLimitsOptions,
|
|
59
|
+
QueryQuantifier,
|
|
50
60
|
ScopeFunction,
|
|
51
61
|
ScopeOperation,
|
|
52
62
|
ScopeParams,
|
|
@@ -97,8 +107,10 @@ export interface MongoAdapterOptions {
|
|
|
97
107
|
*/
|
|
98
108
|
export interface MongoAdapter extends DatabaseAdapter {
|
|
99
109
|
readonly referentialIntegrity: 'off' | 'transactional';
|
|
110
|
+
/** Frozen declaration of atomic expectedVersion writes; wrappers must forward every argument. */
|
|
111
|
+
readonly writeFeatures: Readonly<{ optimisticConcurrency: true }>;
|
|
100
112
|
/**
|
|
101
|
-
* Frozen; lists every `QLOperator` value and declares `referenceEmptiness: true`. The literal text
|
|
113
|
+
* Frozen; lists every `QLOperator` value and declares `referenceEmptiness: true` and `quantifiers: true`. The literal text
|
|
102
114
|
* operators compile to escaped regular expressions: `ILIKE` unanchored, `STARTS_WITH` and
|
|
103
115
|
* `ISTARTS_WITH` anchored with `^`, `ENDS_WITH` and `IENDS_WITH` with `\z`, and `IEQ` with both, with
|
|
104
116
|
* the `i` option for the case-insensitive ones. `IS_EMPTY` and `IS_NOT_EMPTY` compile to plain query
|
|
@@ -127,6 +139,15 @@ export interface MongoAdapter extends DatabaseAdapter {
|
|
|
127
139
|
/** After connecting MongoDB and createSchema(), await the transaction capability and existing-reference audit. */
|
|
128
140
|
initialize(): Promise<void>;
|
|
129
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Supports extensions.concurrency: { mode: 'optimistic' }. Generated models store version as Number.
|
|
144
|
+
* Supplied models must declare version: Number without immutable, select: false, getters, setters
|
|
145
|
+
* or a default other than 1; versionKey must not be 'version'. Missing/null stored versions mean 1.
|
|
146
|
+
* Protected writes use findOneAndUpdate/findOneAndDelete statics (integrity deletes still call
|
|
147
|
+
* findByIdAndDelete). Query middleware must preserve the version condition and increment.
|
|
148
|
+
* Adapter-detected conflicts abort supplied transactions only in transactional integrity mode;
|
|
149
|
+
* in default mode, the caller owns the abort. Core-detected conflicts keep that caller ownership.
|
|
150
|
+
*/
|
|
130
151
|
export function createMongoAdapter(options?: MongoAdapterOptions): MongoAdapter;
|
|
131
152
|
export function getRegistrations(): RuntimeRegistration[];
|
|
132
153
|
|
|
@@ -270,21 +291,10 @@ export function use(
|
|
|
270
291
|
/** Globally prevent Mongoose collection creation for generated models. */
|
|
271
292
|
export function preventCreatingCollection(prevent: boolean): void;
|
|
272
293
|
|
|
273
|
-
/** Process-wide limits for generated list and paginated aggregate queries. */
|
|
274
|
-
export interface QueryLimitsOptions {
|
|
275
|
-
/** Positive safe integer; defaults to 1000. Unpaged lists use min(100, maxPageSize). */
|
|
276
|
-
maxPageSize?: number;
|
|
277
|
-
/**
|
|
278
|
-
* Positive safe integer; defaults to 10. The most distinct scoped joins and frames that the
|
|
279
|
-
* client's reference and collection emptiness may need in one list, aggregate or collection-field
|
|
280
|
-
* call; more fail with `FILTER_DEPTH_EXCEEDED` (400).
|
|
281
|
-
*/
|
|
282
|
-
maxReferenceFrames?: number;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
294
|
/**
|
|
286
|
-
*
|
|
287
|
-
* limits; an omitted option
|
|
295
|
+
* Process-wide limits for generated list and paginated aggregate queries. Configure at startup.
|
|
296
|
+
* Invalid configuration throws INVALID_QUERY_LIMITS (400) and keeps all limits; an omitted option
|
|
297
|
+
* takes its default again.
|
|
288
298
|
*/
|
|
289
299
|
export function configureQueryLimits(options?: QueryLimitsOptions): void;
|
|
290
300
|
|
|
@@ -338,10 +348,13 @@ export function getType(
|
|
|
338
348
|
* Query building (src/index.js)
|
|
339
349
|
* ========================================================================== */
|
|
340
350
|
|
|
341
|
-
/** A QLFilterGroup input value
|
|
351
|
+
/** A QLFilterGroup input value: conditions, logical groups and element-relative quantified groups. */
|
|
342
352
|
export interface FilterGroupInput {
|
|
343
353
|
AND?: FilterGroupInput[];
|
|
344
354
|
OR?: FilterGroupInput[];
|
|
355
|
+
ANY?: QuantifiedGroupInput[];
|
|
356
|
+
ALL?: QuantifiedGroupInput[];
|
|
357
|
+
NONE?: QuantifiedGroupInput[];
|
|
345
358
|
conditions?: Array<{
|
|
346
359
|
field: string;
|
|
347
360
|
operator?: string;
|
|
@@ -353,6 +366,11 @@ export interface FilterGroupInput {
|
|
|
353
366
|
[key: string]: any;
|
|
354
367
|
}
|
|
355
368
|
|
|
369
|
+
/** A reference or collection frame; its body uses paths relative to the target element. */
|
|
370
|
+
export interface QuantifiedGroupInput extends FilterGroupInput {
|
|
371
|
+
path: string;
|
|
372
|
+
}
|
|
373
|
+
|
|
356
374
|
/**
|
|
357
375
|
* Translate list-query arguments (filters, AND/OR, sort, pagination) into a MongoDB aggregation
|
|
358
376
|
* pipeline. `input.aggregation` filters a field named `aggregation`; the aggregate stage is never
|
|
@@ -389,16 +407,25 @@ export interface FilterGroupInput {
|
|
|
389
407
|
* that a scope hides reads as missing. A crossing or frame after a scoped crossing stays in that chain,
|
|
390
408
|
* also when its own scope is null. The aliases are unset at the end of find pipelines. A target type
|
|
391
409
|
* without a model fails with `INVALID_FILTER_PATH` (400), and a marker that is not filled for every
|
|
392
|
-
* path the construct reads, or that sits on a construct that reads
|
|
410
|
+
* path the construct reads, or that sits on a construct that reads no relation or on an ordinary
|
|
411
|
+
* condition outside a quantified frame, with
|
|
393
412
|
* `DETACHED_CLIENT_FILTER` (500), after the 400 checks. Supplied models on another database or
|
|
394
413
|
* connection and discriminator targets are not supported here, as for the other relation paths.
|
|
395
414
|
*
|
|
415
|
+
* Ordinary paths through collections compile as correlated `ANY` frames. Conditions in one group
|
|
416
|
+
* sharing a collection must hold on the same child, and each parent appears once. Explicit `ANY`,
|
|
417
|
+
* `ALL` and `NONE` groups and `quantifier` field filters accept element-relative conditions; `ALL`
|
|
418
|
+
* alone is true, including an OR branch. Target find scopes precede frame conditions; trusted
|
|
419
|
+
* constructs read stored data. References inside a client frame use its element-relative scope
|
|
420
|
+
* keys. Off-root collection correlation keys get an inner non-null connection guard. Aggregate
|
|
421
|
+
* groupId/fact collection prefixes keep legacy joins, and other filters can anchor frames on them.
|
|
422
|
+
* Collection sorts reject with `INVALID_FILTER_PATH`, including count mode.
|
|
423
|
+
*
|
|
396
424
|
* The checks, and their codes, are those of `createQueryPlan` in the same order: `MISSING_FILTER_PATH`
|
|
397
425
|
* for an emptiness operator on an object filter without terms or beside terms, `INVALID_FILTER_PATH`
|
|
398
426
|
* for a value inside an embedded list or a collection and for an interface or union field or member,
|
|
399
427
|
* `INVALID_FILTER_OPERATOR` for an embedded type that reaches itself through single embedded members,
|
|
400
|
-
* and `INVALID_FILTER_VALUE` for a value that is not omitted or null.
|
|
401
|
-
* pipelines.
|
|
428
|
+
* and `INVALID_FILTER_VALUE` for a value that is not omitted or null.
|
|
402
429
|
*/
|
|
403
430
|
export function buildQuery(
|
|
404
431
|
input: Record<string, any>,
|