@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.
@@ -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**: Used to flatten joined collections
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.12.0",
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.12.0",
33
- "@simtlix/simfinity-mcp": "3.12.0"
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
 
@@ -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) {
@@ -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 }, { $set: { [LOCK_FIELD]: new mongoose.Types.ObjectId() } }, { session },
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 updated = await Model.findByIdAndUpdate(objectId, update, { new: true }).session(session);
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)) return null;
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.
@@ -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 (matchesScalar(type, GraphQLID)) {
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);
@@ -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, isEmptinessOperator,
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(), scopeCount: 0,
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.scopeCount++}`, stages } : null;
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 = resolveQueryPath(gqltype, path, aggregationsIncluded);
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 (filterGroup, gqltype, aggregateClauses, aggregationsIncluded, depth = 0) => {
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 (input, gqltype, aggregateClauses, aggregationsIncluded, operation = 'find') => {
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
- * Configure at startup. Invalid configuration throws INVALID_QUERY_LIMITS (400) and keeps both
287
- * limits; an omitted option takes its default again.
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 (recursive AND/OR groups plus flat conditions). */
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 none or on another operator, with
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. Other operators keep their
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>,