@simtlix/simfinity-js 3.8.0 → 3.9.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
@@ -26,6 +26,8 @@ Generated models store enum internal values with their own type (numbers, boolea
26
26
 
27
27
  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).
28
28
 
29
+ 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.
30
+
29
31
  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. `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).
30
32
 
31
33
  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.8.0",
3
+ "version": "3.9.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.8.0",
33
- "@simtlix/simfinity-mcp": "3.8.0"
32
+ "@simtlix/simfinity-core": "3.9.0",
33
+ "@simtlix/simfinity-mcp": "3.9.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
@@ -3,7 +3,7 @@ import { SimfinityError } from '@simtlix/simfinity-core';
3
3
  import { getListShape, normalizeConnectionField } from '@simtlix/simfinity-core/internal/relation-storage';
4
4
 
5
5
  import { createMongoModel } from './models.js';
6
- import { createMongoQueries } from './queries.js';
6
+ import { MONGO_QUERY_OPERATORS, createMongoQueries } from './queries.js';
7
7
  import { withMongoTransaction } from './transactions.js';
8
8
  import { createMongoIntegrity } from './integrity.js';
9
9
  import {
@@ -12,6 +12,9 @@ import {
12
12
 
13
13
  const withSession = (query, session) => (session ? query.session(session) : query);
14
14
 
15
+ // Every filter operator the query compiler supports; the runtime rejects any other one.
16
+ const QUERY_FEATURES = Object.freeze({ operators: MONGO_QUERY_OPERATORS });
17
+
15
18
  // A write that fails only on malformed ObjectId values reports NOT_VALID_ID, not a driver error.
16
19
  const mapIdCastErrors = async (write) => {
17
20
  try {
@@ -216,6 +219,7 @@ export const createMongoAdapter = (options) => {
216
219
  };
217
220
 
218
221
  const adapter = {
222
+ queryFeatures: QUERY_FEATURES,
219
223
  bind(binding) {
220
224
  integrity.bind(binding);
221
225
  getRegistrations = binding.getRegistrations;
@@ -8,6 +8,9 @@ import {
8
8
  import mongoose from 'mongoose';
9
9
 
10
10
  import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity-core';
11
+ import {
12
+ TEXT_QUERY_OPERATORS, textQueryOperator, validateTextFilterValue,
13
+ } from '@simtlix/simfinity-core/internal/query-operators';
11
14
  import {
12
15
  getFieldStorageName, getListShape, hasStoredIdentity, isVirtualField, normalizeConnectionField,
13
16
  } from '@simtlix/simfinity-core/internal/relation-storage';
@@ -26,6 +29,15 @@ const isGraphQLisoDate = (typeName) => (
26
29
  // GraphQL reserves `__` names, so these aliases never replace a stored field.
27
30
  const LOOKUP_ALIAS_PREFIX = '__sf_l';
28
31
 
32
+ /**
33
+ * The filter operators this compiler supports, which the adapter declares as its queryFeatures. They
34
+ * are listed explicitly, so that a QLOperator value added later stays unsupported until it compiles.
35
+ */
36
+ export const MONGO_QUERY_OPERATORS = Object.freeze([
37
+ 'EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE',
38
+ 'ILIKE', 'STARTS_WITH', 'ISTARTS_WITH', 'ENDS_WITH', 'IENDS_WITH', 'IEQ',
39
+ ]);
40
+
29
41
  export const createMongoQueries = ({ getModel, getRegistrations }) => {
30
42
  // One alias per relation path for each query build, keyed by the build's `included` accumulator.
31
43
  const lookupAliases = new WeakMap();
@@ -62,6 +74,34 @@ const buildRelationLookup = ({
62
74
 
63
75
  const escapeRegex = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
64
76
 
77
+ // MongoDB rejects a $regex pattern longer than 32,764 UTF-8 bytes on the server (Location51091),
78
+ // which would report a client's long value as an internal error. Escaping adds a byte to each
79
+ // metacharacter and the operators add their anchors, so the limit applies to the compiled pattern.
80
+ const MAX_REGEX_PATTERN_BYTES = 32764;
81
+ // A $regex match, rejected with INVALID_FILTER_VALUE before the query runs when MongoDB would reject
82
+ // its pattern. `path` is the condition's GraphQL path, for the message.
83
+ const regexMatch = (operator, pattern, ignoreCase, path) => {
84
+ const bytes = Buffer.byteLength(pattern, 'utf8');
85
+ if (bytes > MAX_REGEX_PATTERN_BYTES) {
86
+ const where = path == null ? '' : ` on ${path}`;
87
+ 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}`);
88
+ }
89
+ return ignoreCase ? { $regex: pattern, $options: 'i' } : { $regex: pattern };
90
+ };
91
+
92
+ // The literal text operators: the escaped value, anchored with `^` at the start of the string and
93
+ // `\z` at its very end (`$` would also match before a final newline), with the `i` option to ignore
94
+ // case. Without the `m` option, `^` never matches after a newline.
95
+ const TEXT_PATTERNS = {
96
+ contains: (escaped) => escaped,
97
+ prefix: (escaped) => `^${escaped}`,
98
+ suffix: (escaped) => `${escaped}\\z`,
99
+ exact: (escaped) => `^${escaped}\\z`,
100
+ };
101
+ const textRegex = (operator, { match, ignoreCase }) => (value, path) => (
102
+ regexMatch(operator, TEXT_PATTERNS[match](escapeRegex(value)), ignoreCase, path)
103
+ );
104
+
65
105
  const PATH_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
66
106
 
67
107
  const assertValidFilterPath = (path) => {
@@ -101,7 +141,8 @@ const OP_TO_MONGO = {
101
141
  GTE: (v) => ({ $gte: v }),
102
142
  NE: (v) => ({ $ne: v }),
103
143
  BTW: (v) => ({ $gte: v[0], $lte: v[1] }),
104
- LIKE: (v) => ({ $regex: `.*${escapeRegex(v)}.*` }),
144
+ LIKE: (v, path) => regexMatch('LIKE', `.*${escapeRegex(v)}.*`, false, path),
145
+ ...Object.fromEntries(Object.entries(TEXT_QUERY_OPERATORS).map(([op, text]) => [op, textRegex(op, text)])),
105
146
  };
106
147
 
107
148
  const AGG_OP_TO_MONGO = {
@@ -225,11 +266,18 @@ const castFilterValue = (value, resolved) => {
225
266
  }
226
267
  };
227
268
 
228
- const buildMatchesClause = (resolved, operator, value) => {
269
+ // `path` is the condition's GraphQL path, for messages.
270
+ const buildMatchesClause = (resolved, operator, value, path) => {
229
271
  const op = operator ?? 'EQ';
230
272
  if (!QLOperator.getValues().some((entry) => entry.value === op)) {
231
273
  throw filterError(`Unsupported filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
232
274
  }
275
+ // Text operators take the value as is, once core's shared rule accepts it: a String field (a
276
+ // validated scalar by its base scalar) and a string without U+0000.
277
+ if (textQueryOperator(op)) {
278
+ const text = validateTextFilterValue(op, value, getEffectiveTypeName(resolved.fieldType), path);
279
+ return { [resolved.mongoPath]: OP_TO_MONGO[op](text, path) };
280
+ }
233
281
  const collectionOperator = op === 'IN' || op === 'NIN' || op === 'BTW';
234
282
  if (collectionOperator && (!Array.isArray(value) || (op === 'BTW' && value.length !== 2))) {
235
283
  throw filterError(`${op} requires ${op === 'BTW' ? 'exactly two values' : 'an array value'}`);
@@ -245,7 +293,7 @@ const buildMatchesClause = (resolved, operator, value) => {
245
293
  const coerced = collectionOperator ? value.map(cast) : op === 'LIKE' ? value : cast(value);
246
294
  if (op === 'EQ') return { [resolved.mongoPath]: coerced };
247
295
  if (op === 'IN' || op === 'NIN') return { [resolved.mongoPath]: { [op === 'IN' ? '$in' : '$nin']: coerced } };
248
- return { [resolved.mongoPath]: OP_TO_MONGO[op](coerced) };
296
+ return { [resolved.mongoPath]: OP_TO_MONGO[op](coerced, path) };
249
297
  };
250
298
 
251
299
  const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggregationsIncluded) => {
@@ -262,9 +310,10 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggrega
262
310
  for (const term of terms) {
263
311
  if (!term || typeof term !== 'object' || Array.isArray(term)) throw filterError(`Invalid term for ${fieldName}`);
264
312
  if (isObject) assertValidFilterPath(term.path);
265
- const resolved = resolveQueryPath(gqltype, isObject ? `${fieldName}.${term.path}` : fieldName, aggregationsIncluded);
313
+ const path = isObject ? `${fieldName}.${term.path}` : fieldName;
314
+ const resolved = resolveQueryPath(gqltype, path, aggregationsIncluded);
266
315
  Object.assign(aggregateClauses, resolved.aggregateClauses);
267
- matchesClauses.push(buildMatchesClause(resolved, term.operator, term.value));
316
+ matchesClauses.push(buildMatchesClause(resolved, term.operator, term.value, path));
268
317
  }
269
318
  return { aggregateClauses, matchesClauses };
270
319
  };
package/types/index.d.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  import type {
16
16
  DatabaseAdapter,
17
17
  MutationLimitsOptions,
18
+ QueryFeatures,
18
19
  RuntimeRegistration,
19
20
  SimfinityError,
20
21
  } from '@simtlix/simfinity-core';
@@ -88,6 +89,13 @@ export interface MongoAdapterOptions {
88
89
  */
89
90
  export interface MongoAdapter extends DatabaseAdapter {
90
91
  readonly referentialIntegrity: 'off' | 'transactional';
92
+ /**
93
+ * Frozen; lists every `QLOperator` value. The literal text operators compile to escaped regular
94
+ * expressions: `ILIKE` unanchored, `STARTS_WITH` and `ISTARTS_WITH` anchored with `^`, `ENDS_WITH` and
95
+ * `IENDS_WITH` with `\z`, and `IEQ` with both, with the `i` option for the case-insensitive ones. A
96
+ * copy of the adapter without this member supports only the operators of 3.7.0.
97
+ */
98
+ readonly queryFeatures: QueryFeatures;
91
99
  /**
92
100
  * Accepts only an ObjectId or a 24-character hexadecimal string, normalized to lowercase, and
93
101
  * throws `NOT_VALID_ID` (400) for anything else, including undefined, null and numbers. It never