@simtlix/simfinity-js 3.15.0 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,7 @@ Generated models store enum internal values with their own type (numbers, boolea
36
36
 
37
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).
38
38
 
39
- Text filters compile to escaped regular expressions, so a value never becomes regex syntax: `LIKE` and `ILIKE` match anywhere, `STARTS_WITH` and `ISTARTS_WITH` are anchored with `^`, `ENDS_WITH` and `IENDS_WITH` with `\z` (so a trailing newline is part of the value), and `IEQ` with both. The `I` operators add the `i` option, which applies the Unicode simple case folding of the server's PCRE2 build (`É`/`é`, `ẞ`/`ß`, `σ`/`ς`, `µ`/`μ`, `ſ`/`s`), never a length-changing or Turkic folding (`ß`/`ss`, `İ`/`i`), and does not fold characters newer than that build, such as U+A7DC; the [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html) lists where PostgreSQL differs. Only `STARTS_WITH` reads a prefix range of an index on the field, a String list or an embedded path; the other text operators test every key of such an index, or every document without one, and `$regex` ignores collations, so a case-insensitive collation index does not serve them. A value whose escaped pattern is longer than MongoDB's 32,764-byte pattern limit fails with `INVALID_FILTER_VALUE` (400) before the query runs, also for `LIKE`. `createMongoAdapter()` declares every operator it compiles in `queryFeatures`; a copy of the adapter without it supports only the operators of 3.7.0.
39
+ Text filters compile to escaped regular expressions, so a value never becomes regex syntax: `LIKE` and `ILIKE` match anywhere, `STARTS_WITH` and `ISTARTS_WITH` are anchored with `^`, `ENDS_WITH` and `IENDS_WITH` with `\z` (so a trailing newline is part of the value), and `IEQ` with both. The `I` operators add the `i` option, which applies the Unicode simple case folding of the server's PCRE2 build (`É`/`é`, `ẞ`/`ß`, `σ`/`ς`, `µ`/`μ`, `ſ`/`s`), never a length-changing or Turkic folding (`ß`/`ss`, `İ`/`i`), and does not fold characters newer than that build, such as U+A7DC; the [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html) lists where PostgreSQL differs. Only `STARTS_WITH` reads a prefix range of an index on the field, a String list or an embedded path; the other text operators test every key of such an index, or every document without one, and `$regex` ignores collations, so a case-insensitive collation index does not serve them. A value whose escaped pattern is longer than MongoDB's 32,764-byte pattern limit fails with `INVALID_FILTER_VALUE` (400) before the query runs, also for `LIKE`. `IIN`, `IN` ignoring case, compiles to `$in` of escaped regular expressions anchored as for `IEQ`, with the `i` option, one BSON regular expression per value, each within that limit; like `IEQ`, it tests every key of an index on the field, or every document without one. [`CONTAINS_ALL`](https://simtlix.github.io/simfinity.js/guide/queries.html#filters-on-lists) compiles to `$all` with each value cast as `IN` casts it, so an index on the list bounds one of the values. `createMongoAdapter()` declares every operator it compiles in `queryFeatures`; a copy of the adapter without it supports only the operators of 3.7.0.
40
40
 
41
41
  `IS_EMPTY` and `IS_NOT_EMPTY` take no value and compile to plain query operators inside the same `$match`, so they also filter counts, aggregates and batched collection reads (`$unionWith`): `$in: [null, ""]` and `$nin` on `String` fields, which an index on the field serves; `null` and `$ne: null` on other scalars, enums and the record identity `_id`; on an application scalar without a supported base, such as a `JSON` scalar, which MongoDB stores as written, `null` without arrays, `{ $and: [{ p: null }, { p: { $not: { $type: "array" } } }] }`, and its `$nor`, so that an array, also `[null]`, is a value, as reads return it; `"p.0": { $exists: false }` and `$exists: true` on scalar lists and embedded lists, which never look inside the items; and, on an embedded object, an `$or` of `null` and the `IS_EMPTY` form of every stored member, with its `$nor` for `IS_NOT_EMPTY`, so an object whose stored members are all empty, such as `{ w: null, label: "" }`, is empty. A term or group condition without a `path` tests the object or list field itself. Embedded `id` members are read where other filters read them, so an object of a supplied subdocument schema with an automatic `_id` is never empty, unlike on PostgreSQL. A path that continues inside an embedded list or a collection, such as `lines.qty` or `seasons.year`, fails with the permanent `INVALID_FILTER_PATH` (400), since emptiness of a value inside a list is not defined; the [queries guide](https://simtlix.github.io/simfinity.js/guide/queries.html#empty-values) has the rules and the other codes.
42
42
 
@@ -46,7 +46,7 @@ The adapter declares `queryFeatures.quantifiers: true`. Ordinary filter paths th
46
46
 
47
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
48
 
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).
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. Since 3.16.0, `updateObject()`, `deleteObject()` and `transitionObject()` are exported next to `saveObject()` and use the model's connection the same way, and `createSchema(…, { transaction })` adds a mutation that runs several generated mutations in one transaction, which requires the models it may write to share one `MongoClient` (`useDb()` siblings count as one) in both `referentialIntegrity` modes; models of different clients fail with `TRANSACTION_UNSUPPORTED` before a session opens. 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).
50
50
 
51
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.
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-js",
3
- "version": "3.15.0",
3
+ "version": "3.16.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.15.0",
33
- "@simtlix/simfinity-mcp": "3.15.0"
32
+ "@simtlix/simfinity-core": "3.16.0",
33
+ "@simtlix/simfinity-mcp": "3.16.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
package/src/index.js CHANGED
@@ -28,6 +28,9 @@ export const registerMutation = runtime.registerMutation;
28
28
  export const registerCustomQuery = runtime.registerCustomQuery;
29
29
  export const registerView = runtime.registerView;
30
30
  export const saveObject = runtime.saveObject;
31
+ export const updateObject = runtime.updateObject;
32
+ export const deleteObject = runtime.deleteObject;
33
+ export const transitionObject = runtime.transitionObject;
31
34
  export const preventCreatingCollection = runtime.preventCreatingCollection;
32
35
 
33
36
  export const buildQuery = (...args) => mongoAdapter.buildQuery(...args);
@@ -135,6 +135,41 @@ const mapQueryIdCastErrors = (query, mapError = (error) => error) => {
135
135
  return query;
136
136
  };
137
137
 
138
+ // MongoDB fails a command whose regular expression it cannot compile with Location51091. The query
139
+ // compiler escapes every value and bounds every pattern by the 32,764 bytes the server accepts, but
140
+ // ignoring case the server compiles each letter that has other case forms (k, K and the Kelvin sign)
141
+ // to a class, so only a case-insensitive pattern can still be too large; then the client's filter
142
+ // value is the cause. IIN (#108 part 4) sends its patterns as BSON regular expressions, the other
143
+ // text operators as $regex with $options.
144
+ const REGEX_INVALID = 51091;
145
+ const ignoresCase = (options) => typeof options === 'string' && options.includes('i');
146
+ const hasCaseInsensitiveRegex = (node) => {
147
+ if (Array.isArray(node)) return node.some(hasCaseInsensitiveRegex);
148
+ if (node === null || typeof node !== 'object') return false;
149
+ if (node._bsontype !== undefined) return node._bsontype === 'BSONRegExp' && ignoresCase(node.options);
150
+ if (typeof node.$regex === 'string' && ignoresCase(node.$options)) return true;
151
+ return Object.values(node).some(hasCaseInsensitiveRegex);
152
+ };
153
+ // The driver error stays available to logging, as on TRANSACTION_RETRY_EXCEEDED.
154
+ const mapRegexError = (error, pipeline) => {
155
+ if (error?.code !== REGEX_INVALID || !hasCaseInsensitiveRegex(pipeline)) return error;
156
+ const tooLarge = new SimfinityError(
157
+ 'Case-insensitive text filter value is too long: MongoDB cannot compile its regular expression when ignoring case, because letters such as k and s match more than one character',
158
+ 'INVALID_FILTER_VALUE', 400,
159
+ );
160
+ tooLarge.cause = error;
161
+ tooLarge.getCause = () => tooLarge.cause;
162
+ return tooLarge;
163
+ };
164
+ // Runs an aggregate of a pipeline that the query compiler built from filters.
165
+ const mapRegexErrors = async (pipeline, aggregate) => {
166
+ try {
167
+ return await aggregate;
168
+ } catch (error) {
169
+ throw mapRegexError(error, pipeline);
170
+ }
171
+ };
172
+
138
173
  // Even a shared hook can branch on `this.op` or the filter shape. Preserve findOne semantics
139
174
  // whenever either query operation has hooks instead of inferring equivalence from function identity.
140
175
  const hasNoHooks = (hooks, operations) => {
@@ -280,14 +315,20 @@ export const createMongoAdapter = (options) => {
280
315
  // - registered models on several clients have no single client to choose.
281
316
  // Once the default connection has a client, nothing changes.
282
317
  const hasModels = (connection) => Object.keys(connection.models).length > 0;
283
- // A child's otherDbs also lists its parent, so the walk visits each connection once, cycles
318
+ // The connection and everything its otherDbs reach: its useDb() parent, children and their siblings, at
319
+ // any depth. A child's otherDbs also lists its parent, so the walk visits each connection once, cycles
284
320
  // included: a Set iteration also visits the entries added while it runs.
285
- const defaultClientHasModels = () => {
286
- const seen = new Set([mongoose.connection]);
321
+ const connectionFamily = (start) => {
322
+ const seen = new Set([start]);
287
323
  for (const connection of seen) {
288
- if (hasModels(connection)) return true;
289
324
  for (const other of connection.otherDbs) seen.add(other);
290
325
  }
326
+ return seen;
327
+ };
328
+ const defaultClientHasModels = () => {
329
+ for (const connection of connectionFamily(mongoose.connection)) {
330
+ if (hasModels(connection)) return true;
331
+ }
291
332
  return false;
292
333
  };
293
334
  const sessionModel = () => {
@@ -303,6 +344,21 @@ export const createMongoAdapter = (options) => {
303
344
  return found;
304
345
  };
305
346
 
347
+ // Whether two connections share one MongoDB client, hence one session. Two resolved clients decide by
348
+ // identity. While either has no client yet (before connect(), or a useDb() child created earlier), the
349
+ // connections of one useDb() family share the client the family will have, so connections whose
350
+ // families meet count as one. A family is found through otherDbs, never Mongoose's private _parent;
351
+ // a useDb(name, { noListener: true }) child lists its parent, which does not list it, so the families
352
+ // meet at the parent.
353
+ const sharesClient = (first, second) => {
354
+ const firstClient = first.getClient?.();
355
+ const secondClient = second.getClient?.();
356
+ if (firstClient && secondClient) return firstClient === secondClient;
357
+ const family = connectionFamily(first);
358
+ for (const connection of connectionFamily(second)) if (family.has(connection)) return true;
359
+ return false;
360
+ };
361
+
306
362
  // A supplied session or model is used as is; only an owned session without a model looks up
307
363
  // the registrations.
308
364
  const withOwnedTransaction = (session, body, Model, transactionOptions) => withMongoTransaction(
@@ -362,6 +418,20 @@ export const createMongoAdapter = (options) => {
362
418
  if (onModelCreated) onModelCreated(model);
363
419
  }, integrity.enabled ? { ...modelOptions, createCollection: false } : modelOptions);
364
420
  },
421
+ // The affinity check of the generated transaction field (#151), before its session opens. Every
422
+ // model must share the first one's client, in both referentialIntegrity modes, which run every write
423
+ // in a transaction. It never writes, and a connection that is still connecting is no error: its
424
+ // session waits for it. The error names the first model's type and the first type that cannot share
425
+ // its session, as the types of the registrations (a model that several types share, or has none, by
426
+ // its modelName), never the connections' addresses.
427
+ assertTransactionModels(models) {
428
+ const [first, ...others] = models.filter((model) => model?.db);
429
+ const conflict = others.find((model) => !sharesClient(first.db, model.db));
430
+ if (!conflict) return;
431
+ const registrations = getRegistrations();
432
+ const names = [first, conflict].map((model) => versionTypeName(model, registrations)).sort();
433
+ throw new SimfinityError(`Types ${names.join(', ')} cannot share one database transaction`, 'TRANSACTION_UNSUPPORTED', 500);
434
+ },
365
435
  // Relation inputs and batch keys: only an ObjectId or its hex form, never a newly minted one.
366
436
  castId: castObjectId,
367
437
  initialize: integrity.initialize,
@@ -484,18 +554,18 @@ export const createMongoAdapter = (options) => {
484
554
  // Mongoose does not cast aggregation stages, so cast the key type here, without setters.
485
555
  if (requiredId != null) pipeline.unshift({ $match: { _id: castModelId(Model, requiredId) } });
486
556
  if (pipeline.length === 0) return versionedRead(Model, withSession(Model.find({}), session), true);
487
- return withSession(Model.aggregate(pipeline), session);
557
+ return mapRegexErrors(pipeline, withSession(Model.aggregate(pipeline), session));
488
558
  },
489
559
  async count(Model, gqltype, args, session) {
490
560
  integrity.assertReady();
491
561
  const pipeline = await queries.buildQuery(args, gqltype, true);
492
- const result = await withSession(Model.aggregate(pipeline), session);
562
+ const result = await mapRegexErrors(pipeline, withSession(Model.aggregate(pipeline), session));
493
563
  return result[0] ? result[0].size : 0;
494
564
  },
495
565
  async aggregate(Model, gqltype, args, session) {
496
566
  integrity.assertReady();
497
567
  const pipeline = await queries.buildAggregationQuery(args, gqltype, args.aggregation);
498
- return withSession(Model.aggregate(pipeline), session);
568
+ return mapRegexErrors(pipeline, withSession(Model.aggregate(pipeline), session));
499
569
  },
500
570
  async findChildren(Model, gqltype, connectionField, parentId, args, session) {
501
571
  integrity.assertReady();
@@ -509,7 +579,7 @@ export const createMongoAdapter = (options) => {
509
579
  throw error;
510
580
  }
511
581
  pipeline.unshift({ $match: { [connectionField]: parentKey } });
512
- return withSession(Model.aggregate(pipeline), session);
582
+ return mapRegexErrors(pipeline, withSession(Model.aggregate(pipeline), session));
513
583
  },
514
584
  };
515
585
 
@@ -570,7 +640,7 @@ export const createMongoAdapter = (options) => {
570
640
  // overlap the remaining commands.
571
641
  const outcomes = await Promise.allSettled(commands);
572
642
  const failure = outcomes.find(({ status }) => status === 'rejected');
573
- if (failure) throw failure.reason;
643
+ if (failure) throw mapRegexError(failure.reason, pipeline);
574
644
  for (const { value: rows } of outcomes) {
575
645
  for (const { i, d } of rows) lists[i].push(d);
576
646
  }
@@ -12,7 +12,7 @@ import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity
12
12
  import {
13
13
  TEXT_QUERY_OPERATORS, aggregateJoinPrefixes, crossingKeys, describeEmptiness, effectiveScalarName, emptinessScopePaths,
14
14
  frameCollectionPaths, isEmptinessOperator, validateQuantifiedGroups,
15
- textQueryOperator, validateTextFilterValue,
15
+ textQueryOperator, validateTextFilterValue, validateTextListFilterValue,
16
16
  } from '@simtlix/simfinity-core/internal/query-operators';
17
17
  import {
18
18
  getFieldStorageName, getListShape, hasStoredIdentity, isVirtualField, normalizeConnectionField,
@@ -39,6 +39,7 @@ export const MONGO_QUERY_OPERATORS = Object.freeze([
39
39
  'EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE',
40
40
  'ILIKE', 'STARTS_WITH', 'ISTARTS_WITH', 'ENDS_WITH', 'IENDS_WITH', 'IEQ',
41
41
  'IS_EMPTY', 'IS_NOT_EMPTY',
42
+ 'CONTAINS_ALL', 'IIN',
42
43
  ]);
43
44
 
44
45
  export const createMongoQueries = ({ getModel, getRegistrations }) => {
@@ -108,14 +109,18 @@ const escapeRegex = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
108
109
  // which would report a client's long value as an internal error. Escaping adds a byte to each
109
110
  // metacharacter and the operators add their anchors, so the limit applies to the compiled pattern.
110
111
  const MAX_REGEX_PATTERN_BYTES = 32764;
111
- // A $regex match, rejected with INVALID_FILTER_VALUE before the query runs when MongoDB would reject
112
- // its pattern. `path` is the condition's GraphQL path, for the message.
113
- const regexMatch = (operator, pattern, ignoreCase, path) => {
112
+ // MongoDB rejects a regular expression longer than 32,764 UTF-8 bytes on the server (Location51091). `path` is the
113
+ // condition's GraphQL path, for the message. Returns the pattern.
114
+ const assertPatternBytes = (operator, pattern, path) => {
114
115
  const bytes = Buffer.byteLength(pattern, 'utf8');
115
116
  if (bytes > MAX_REGEX_PATTERN_BYTES) {
116
117
  const where = path == null ? '' : ` on ${path}`;
117
118
  throw filterError(`${operator} value${where} is too long: MongoDB accepts regular expressions of at most ${MAX_REGEX_PATTERN_BYTES} bytes, and this one has ${bytes}`);
118
119
  }
120
+ return pattern;
121
+ };
122
+ const regexMatch = (operator, pattern, ignoreCase, path) => {
123
+ assertPatternBytes(operator, pattern, path);
119
124
  return ignoreCase ? { $regex: pattern, $options: 'i' } : { $regex: pattern };
120
125
  };
121
126
 
@@ -509,7 +514,8 @@ const rewriteContext = (gqltype, operation = 'find', aggregation) => {
509
514
  };
510
515
 
511
516
  // 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.
517
+ // anchor, or inside the prefix witness of a hand-built implicit group that crosses a collection
518
+ // before its end: all ordinary collection crossings have already become frames in the shared rewrite.
513
519
  const crossFramePath = async (gqltype, segments, scopes, build, lookups, included) => {
514
520
  let type = gqltype;
515
521
  let parent = { key: '', schemaPath: '', path: '' };
@@ -585,7 +591,9 @@ const compileFrame = async (ctx, quantifier, group) => {
585
591
  for (const [index, name] of segments.entries()) {
586
592
  const field = type.getFields()[name];
587
593
  if (field.extensions?.relation && !field.extensions.relation.embedded) {
588
- if (index < segments.length - 1 && vector) multiplyingPrefix = true;
594
+ // A collection crossed before the end multiplies the prefix as an embedded-list vector does (#108 part 4): only a
595
+ // hand-built implicit group crosses one outside an anchor, and anchored groups skip the witness below.
596
+ if (index < segments.length - 1 && (vector || getListShape(field.type))) multiplyingPrefix = true;
589
597
  vector = false;
590
598
  if (!getModel(getNamedType(field.type))) throw filterError(`Related model is not available for ${joinPath(ctx.base, segments.join('.'))}`, 'INVALID_FILTER_PATH');
591
599
  if (!anchor.length || index >= anchor.length || getListShape(field.type)) paths.push(segments.slice(0, index + 1).join('.'));
@@ -601,7 +609,7 @@ const compileFrame = async (ctx, quantifier, group) => {
601
609
  }
602
610
  }
603
611
  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
612
+ // A vector or collection prefix belongs to this predicate, not to the containing result set. A
605
613
  // self-correlated witness keeps its joins local without discarding any target. Aggregate
606
614
  // anchors retain their intentional expansion. NONE tests the positive ANY witness; ALL
607
615
  // looks for a prefix whose complete ALL test fails, including vacuous empty prefixes.
@@ -719,7 +727,14 @@ const buildMatchesClause = (resolved, operator, value, path) => {
719
727
  const text = validateTextFilterValue(op, value, effectiveScalarName(resolved.fieldType), path);
720
728
  return { [resolved.mongoPath]: OP_TO_MONGO[op](text, path) };
721
729
  }
722
- const collectionOperator = op === 'IN' || op === 'NIN' || op === 'BTW';
730
+ // IIN, IN ignoring case (#108 part 4): one anchored, escaped, case-insensitive regular expression per value inside
731
+ // $in, as a BSON regular expression, so the server receives exactly the pattern checked against its limit. Values
732
+ // are never cast; core's rule has accepted them.
733
+ if (op === 'IIN') {
734
+ const values = validateTextListFilterValue(op, value, effectiveScalarName(resolved.fieldType), path);
735
+ return { [resolved.mongoPath]: { $in: values.map((item) => new mongoose.mongo.BSONRegExp(assertPatternBytes(op, TEXT_PATTERNS.exact(escapeRegex(item)), path), 'i')) } };
736
+ }
737
+ const collectionOperator = op === 'IN' || op === 'NIN' || op === 'BTW' || op === 'CONTAINS_ALL';
723
738
  if (collectionOperator && (!Array.isArray(value) || (op === 'BTW' && value.length !== 2))) {
724
739
  throw filterError(`${op} requires ${op === 'BTW' ? 'exactly two values' : 'an array value'}`);
725
740
  }
@@ -734,6 +749,7 @@ const buildMatchesClause = (resolved, operator, value, path) => {
734
749
  const coerced = collectionOperator ? value.map(cast) : op === 'LIKE' ? value : cast(value);
735
750
  if (op === 'EQ') return { [resolved.mongoPath]: coerced };
736
751
  if (op === 'IN' || op === 'NIN') return { [resolved.mongoPath]: { [op === 'IN' ? '$in' : '$nin']: coerced } };
752
+ if (op === 'CONTAINS_ALL') return { [resolved.mongoPath]: { $all: coerced } };
737
753
  return { [resolved.mongoPath]: OP_TO_MONGO[op](coerced, path) };
738
754
  };
739
755
 
package/types/index.d.ts CHANGED
@@ -118,7 +118,9 @@ export interface MongoAdapter extends DatabaseAdapter {
118
118
  * Frozen; lists every `QLOperator` value and declares `referenceEmptiness: true` and `quantifiers: true`. The literal text
119
119
  * operators compile to escaped regular expressions: `ILIKE` unanchored, `STARTS_WITH` and
120
120
  * `ISTARTS_WITH` anchored with `^`, `ENDS_WITH` and `IENDS_WITH` with `\z`, and `IEQ` with both, with
121
- * the `i` option for the case-insensitive ones. `IS_EMPTY` and `IS_NOT_EMPTY` compile to plain query
121
+ * the `i` option for the case-insensitive ones. `CONTAINS_ALL` compiles to `$all` with cast values; `IIN` to `$in`
122
+ * of anchored, escaped, case-insensitive regular expressions, one per value, each within MongoDB's 32,764-byte
123
+ * limit. `IS_EMPTY` and `IS_NOT_EMPTY` compile to plain query
122
124
  * operators on the record's own fields, and through single references and at references and
123
125
  * collections to joins and frames (see {@link buildQuery}). A copy of the adapter without this member
124
126
  * supports only the operators of 3.7.0, and one whose declaration leaves out `referenceEmptiness`
@@ -131,6 +133,18 @@ export interface MongoAdapter extends DatabaseAdapter {
131
133
  * creates an identifier; use `new mongoose.Types.ObjectId()` for that.
132
134
  */
133
135
  castId(value: any): any;
136
+ /**
137
+ * The affinity check of the generated `transaction` field (see `CreateSchemaOptions.transaction`), run
138
+ * before each transaction opens. Synchronous: it never writes and opens no session. The models must
139
+ * share one MongoDB client: when every model's connection has a client they must be the same
140
+ * `MongoClient`, and while a connection has none yet (before `connect()`, or a `useDb()` child created
141
+ * earlier) the connections of one `useDb()` family count as one client. Otherwise it throws
142
+ * `TRANSACTION_UNSUPPORTED` (500), `Types <A>, <B> cannot share one database transaction`, naming the
143
+ * first model's type and the first type that cannot share its client, sorted, by their registrations
144
+ * (by `modelName` for a model that several types share). It never names a connection string. It applies
145
+ * in both `referentialIntegrity` modes, and an empty list passes.
146
+ */
147
+ assertTransactionModels(models: any[]): void;
134
148
  /** Returns null for a hydrated nested path whose stored value is an explicit null; otherwise the value. */
135
149
  readEmbeddedValue(value: any): any;
136
150
  /**
@@ -231,7 +245,11 @@ export function addNoEndpointType(gqltype: GraphQLObjectType): void;
231
245
  * constructed otherwise.
232
246
  * options.includedCustomQueries selects custom queries by name (see CreateSchemaOptions); an
233
247
  * invalid options argument throws INVALID_SCHEMA_OPTIONS (500), and an included custom query named
234
- * like an included generated query throws QUERY_NAME_CONFLICT (500), before models are created. A
248
+ * like an included generated query throws QUERY_NAME_CONFLICT (500), before models are created.
249
+ * options.transaction adds the generated transaction field (see CreateSchemaOptions): an invalid
250
+ * option throws INVALID_SCHEMA_OPTIONS (500) before models are created, except that a field name or
251
+ * derived type name taken in the built schema, or no operation left, is found once the models are
252
+ * prepared; an adapter without assertTransactionModels throws TRANSACTION_UNSUPPORTED (500). A
235
253
  * schema without mutation fields has no Mutation root.
236
254
  */
237
255
  export function createSchema(
@@ -307,11 +325,15 @@ export function configureQueryLimits(options?: QueryLimitsOptions): void;
307
325
  export function configureAfterCommit(options?: AfterCommitOptions): void;
308
326
 
309
327
  /**
310
- * Process-wide limit on nested added/updated/deleted entries per generated add, update or
311
- * state-action mutation; unlimited by default, restored by calling it without options.
312
- * Invalid configuration, including an unknown or misspelled option key, throws
313
- * INVALID_MUTATION_LIMITS (400) and keeps the current limit; a mutation over the limit
314
- * fails with NESTED_OPERATIONS_EXCEEDED (400) before its transaction starts.
328
+ * Process-wide mutation limits. `maxNestedOperations` caps the nested added/updated/deleted entries
329
+ * of one generated add, update or state-action mutation (unlimited by default; `null` = no limit),
330
+ * and `updateObject` and `transitionObject` apply it to their `input` in the same way.
331
+ * `maxTransactionOperations` caps the operations of one generated transaction mutation (a positive
332
+ * safe integer; 100 by default). Each call replaces both values, so an omitted option takes its
333
+ * default again, and calling it without options restores both defaults. Invalid configuration,
334
+ * including an unknown or misspelled option key, throws INVALID_MUTATION_LIMITS (400) and keeps the
335
+ * current limits. A mutation over the nested limit fails with NESTED_OPERATIONS_EXCEEDED (400)
336
+ * before its transaction starts.
315
337
  */
316
338
  export function configureMutationLimits(options?: MutationLimitsOptions): void;
317
339
 
@@ -344,6 +366,56 @@ export function saveObject(
344
366
  context?: any,
345
367
  ): Promise<any>;
346
368
 
369
+ /**
370
+ * Run the lifecycle of the generated update mutation from code, in a transaction of its own or in the
371
+ * active session supplied (see the core `Runtime.updateObject`). `input` has the shape of the type's
372
+ * update input and is checked structurally before anything runs: enum fields take their internal
373
+ * values, scalars are not coerced and a reference is its record key as a string. A scalar field takes a
374
+ * primitive, a `Date` or plain data (objects and arrays, at most 256 levels deep); class instances such
375
+ * as `ObjectId`, `Buffer` or `Map` are rejected with `INVALID_INPUT`, an own `undefined` counts as
376
+ * omitted and a bare value is never wrapped into a list. It skips the root record's global middleware,
377
+ * as `saveObject` does. More nested entries than `maxNestedOperations` from `configureMutationLimits()`
378
+ * (`null` = no limit) reject with `NESTED_OPERATIONS_EXCEEDED` (400). The helper itself never commits,
379
+ * retries, aborts or cleans up a supplied session; an adapter may still abort it on a write error (for
380
+ * example MongoDB with transactional reference integrity). Rejects with `INVALID_OPERATION_TARGET` (500)
381
+ * for a type that is not a stored record type of this runtime, or that has no prepared update input with
382
+ * an id field (before `createSchema()`, with a `readOnly` id, or with an empty update input),
383
+ * `REQUIRED_VALUE` or `INVALID_INPUT` (400) for an invalid input, and `NOT_VALID_ID` (404) when no
384
+ * record has the ID. Resolves to the updated record.
385
+ */
386
+ export function updateObject(
387
+ typeName: string,
388
+ input: Record<string, any>,
389
+ session?: any,
390
+ context?: any,
391
+ ): Promise<any>;
392
+
393
+ /**
394
+ * Run the lifecycle of the generated delete mutation from code: `target` is `{ id }`, plus `version` on
395
+ * a type with optimistic concurrency (see the core `Runtime.deleteObject`). Its type is checked as
396
+ * `updateObject` checks it, without needing an update input; its target is checked before anything runs.
397
+ * Resolves to the deleted record, or `null` when no record has the ID.
398
+ */
399
+ export function deleteObject(
400
+ typeName: string,
401
+ target: { id: string; version?: number },
402
+ session?: any,
403
+ context?: any,
404
+ ): Promise<any>;
405
+
406
+ /**
407
+ * Run a state machine action from code with the lifecycle of its generated mutation (see the core
408
+ * `Runtime.transitionObject`). It checks its type and `input` as `updateObject` does, rejects with
409
+ * `UNKNOWN_ACTION` (500) for an action the type does not have, and resolves to the updated record.
410
+ */
411
+ export function transitionObject(
412
+ typeName: string,
413
+ actionName: string,
414
+ input: Record<string, any>,
415
+ session?: any,
416
+ context?: any,
417
+ ): Promise<any>;
418
+
347
419
  /** Get the Mongoose model registered for a connected GraphQL type. */
348
420
  export function getModel(gqltype: GraphQLObjectType | { name: string }): any;
349
421