@classytic/mongokit 3.39.0 → 3.40.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.
Files changed (35) hide show
  1. package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-Dvl8jcRY.mjs} +33 -17
  2. package/dist/{Repository-D7fDpyFx.mjs → Repository-BMgPSkqZ.mjs} +279 -625
  3. package/dist/{Repository-IxI983_m.d.mts → Repository-CR8Y9g2E.d.mts} +57 -32
  4. package/dist/actions/index.mjs +1 -1
  5. package/dist/ai/index.d.mts +1 -1
  6. package/dist/better-auth/index.mjs +1 -1
  7. package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BzBUMvd1.mjs} +2 -2
  8. package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-CAle1MSU.d.mts} +4 -4
  9. package/dist/idempotency/index.d.mts +23 -0
  10. package/dist/idempotency/index.mjs +134 -0
  11. package/dist/index.d.mts +58 -32
  12. package/dist/index.mjs +285 -675
  13. package/dist/indexes/verify-indexes.d.mts +14 -1
  14. package/dist/indexes/verify-indexes.mjs +54 -7
  15. package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-CBgCo9im.d.mts} +18 -2
  16. package/dist/outbox/index.d.mts +1 -1
  17. package/dist/outbox/index.mjs +6 -4
  18. package/dist/pagination/PaginationEngine.mjs +1 -1
  19. package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-Zk46lD6F.d.mts} +15 -1
  20. package/dist/plugins/index.d.mts +8 -3
  21. package/dist/plugins/index.mjs +98 -25
  22. package/dist/{repository-ZbLtL7Nk.d.mts → repository-odl03582.d.mts} +20 -2
  23. package/dist/sync/index.d.mts +40 -0
  24. package/dist/sync/index.mjs +334 -0
  25. package/dist/tenant/index.mjs +2 -0
  26. package/dist/testkit/index.d.mts +2 -2
  27. package/dist/testkit/index.mjs +1 -1
  28. package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-DF0gJB6p.mjs} +86 -2
  29. package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-k7SjNlSS.d.mts} +2 -2
  30. package/dist/transaction-B5L9glO0.mjs +533 -0
  31. package/dist/{update-c_01A0if.mjs → update-DG0fCwRJ.mjs} +168 -11
  32. package/dist/utils/index.d.mts +1 -1
  33. package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-BLY2gLEk.mjs} +192 -279
  34. package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-DYp9E7K2.d.mts} +25 -3
  35. package/package.json +13 -4
package/dist/index.mjs CHANGED
@@ -1,23 +1,24 @@
1
1
  import { n as idVariants } from "./id-resolution-BqDrk0S2.mjs";
2
- import { _ as resetCapabilityProbeCache, a as supportsTransactions, b as transactionResolutionOf, c as ALL_OPERATIONS, d as READ_OPERATIONS, f as operationsByPolicyKey, g as probeMongoCapabilities, h as classifyHelloReply, i as isTransactionUnsupported, l as MUTATING_OPERATIONS, m as capabilitiesForSupport, n as Repository, o as withTransaction, p as MONGOKIT_CAPABILITIES, r as batchTransaction, s as AggregationBuilder, t as HOOK_PRIORITY, u as OP_REGISTRY, v as resolveMongoCapabilities, x as createMongoPurgePort, y as resolveTransactionSupport } from "./Repository-D7fDpyFx.mjs";
3
- import { _ as compileFilterToMongo, g as LookupBuilder, x as isCreateManyPartialError } from "./update-c_01A0if.mjs";
2
+ import { a as MUTATING_OPERATIONS, c as operationsByPolicyKey, i as ALL_OPERATIONS, l as createMongoPurgePort, n as Repository, o as OP_REGISTRY, r as AggregationBuilder, s as READ_OPERATIONS, t as HOOK_PRIORITY } from "./Repository-BMgPSkqZ.mjs";
3
+ import { C as isCreateManyPartialError, g as LookupBuilder, v as compileFilterToMongo } from "./update-DG0fCwRJ.mjs";
4
4
  import { n as warn, t as configureLogger } from "./logger-K7dPmu2U.mjs";
5
5
  import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-BuByZwKU.mjs";
6
6
  import { t as actions_exports } from "./actions/index.mjs";
7
- import { a as customIdPlugin, c as prefixedId, d as AuditTrailQuery, f as auditTrailPlugin, i as multiTenantPlugin, l as sequentialId, m as auditLogPlugin, n as softDeletePlugin, o as dateSequentialId, p as ensureAuditTrailReady, r as adminBypass, s as getNextSequence, t as timestampPlugin, u as cachePlugin } from "./timestamp.plugin-B5eiaItQ.mjs";
7
+ import { c as classifyHelloReply, d as resolveMongoCapabilities, f as resolveTransactionSupport, i as withTransaction, l as probeMongoCapabilities, n as isTransactionUnsupported, o as MONGOKIT_CAPABILITIES, p as transactionResolutionOf, r as supportsTransactions, s as capabilitiesForSupport, t as batchTransaction, u as resetCapabilityProbeCache } from "./transaction-B5L9glO0.mjs";
8
+ import { _ as AuditTrailQuery, a as declareTenantContextKey, b as auditLogPlugin, c as tenantContextKeysOf, d as ensureSequenceAtLeast, f as getNextSequence, g as cachePlugin, h as sequentialId, i as multiTenantPlugin, l as customIdPlugin, m as readSequence, n as softDeletePlugin, o as forwardScope, p as prefixedId, r as adminBypass, s as isScopeKey, t as timestampPlugin, u as dateSequentialId, v as auditTrailPlugin, y as ensureAuditTrailReady } from "./timestamp.plugin-DF0gJB6p.mjs";
8
9
  import { n as batchOperationsPlugin, t as methodRegistryPlugin } from "./method-registry.plugin-C63pGn1d.mjs";
9
- import { a as getPaginationDefaults, i as configurePaginationDefaults, o as resetPaginationDefaults, t as PaginationEngine } from "./PaginationEngine-DQ7beTCR.mjs";
10
- import { isGeoOperator, parseGeoFilter } from "./query/primitives/geo.mjs";
11
- import { t as createRepository } from "./create-repository-DOe9E_5F.mjs";
10
+ import { a as getPaginationDefaults, i as configurePaginationDefaults, o as resetPaginationDefaults, t as PaginationEngine } from "./PaginationEngine-Dvl8jcRY.mjs";
11
+ import { GEO_OPERATORS, parseGeoFilter } from "./query/primitives/geo.mjs";
12
+ import { t as createRepository } from "./create-repository-BzBUMvd1.mjs";
12
13
  import { n as defineModel, r as isModelRegistered, t as ModelCollisionError } from "./model-registry-ClKtWiHa.mjs";
13
- import { _ as cascadePlugin, a as uniqueField, c as subdocumentPlugin, d as mongoOperationsPlugin, f as leasePlugin, g as elasticSearchPlugin, h as fieldFilterPlugin, i as requireField, l as observabilityPlugin, m as idempotencyStorePlugin, n as blockIf, o as validationChainPlugin, p as immutableStatesPlugin, r as immutableField, s as createTenantContext, t as autoInject, u as MONGOKIT_PLUGIN_NAMES, v as appendOnlyPlugin, y as aggregateHelpersPlugin } from "./validation-chain.plugin-Bju1D1Sn.mjs";
14
+ import { _ as cascadePlugin, a as uniqueField, c as subdocumentPlugin, d as mongoOperationsPlugin, f as leasePlugin, g as elasticSearchPlugin, h as fieldFilterPlugin, i as requireField, l as observabilityPlugin, m as idempotencyStorePlugin, n as blockIf, o as validationChainPlugin, p as immutableStatesPlugin, r as immutableField, s as createTenantContext, t as autoInject, u as MONGOKIT_PLUGIN_NAMES, v as appendOnlyPlugin, y as aggregateHelpersPlugin } from "./validation-chain.plugin-BLY2gLEk.mjs";
14
15
  import { i as getMongooseProjection, n as filterResponseData, r as getFieldsForUser, t as createFieldPreset } from "./field-selection-FSQFLpsU.mjs";
15
- import { buildFieldTypeMap, coerceFieldValue, coerceHeuristic } from "./query/primitives/coercion.mjs";
16
+ import { buildFieldTypeMap, coerceHeuristic } from "./query/primitives/coercion.mjs";
16
17
  import { extractSchemaIndexes } from "./query/primitives/indexes.mjs";
17
18
  import { n as createMemoryCache, t as toPlain } from "./to-plain-vksRrLtc.mjs";
18
19
  import { n as buildCrudSchemasFromMongooseSchema, t as buildCrudSchemasFromModel } from "./mongooseToJsonSchema-waUVQwK4.mjs";
19
20
  import { runChunkedPurge } from "@classytic/repo-core/repository";
20
- import { isControlParam } from "@classytic/repo-core/query-parser";
21
+ import { OPERATOR_DESCRIPTIONS, QueryGrammarError, URL_OPERATORS, clausesToFilter, escapeRegex, isQueryGrammarError, readFilterClauses, readPageRequest, readSearch, readSelect, readSort } from "@classytic/repo-core/query-parser";
21
22
  //#region src/actions/cascade-references.ts
22
23
  /**
23
24
  * cascadePurgeReferences — imperative, multi-relation "purge by reference".
@@ -357,410 +358,134 @@ function parseAggregation(rt, aggregate) {
357
358
  return pipeline.length > 0 ? pipeline : void 0;
358
359
  }
359
360
  //#endregion
360
- //#region src/query/parser/regex-safety.ts
361
- /**
362
- * Regex safety — ReDoS protection for user-supplied patterns.
363
- *
364
- * Two independent detectors feed the decision:
365
- * 1. A heuristic pattern for classic catastrophic-backtracking shapes
366
- * (nested quantifiers, possessive quantifiers, backreferences, …).
367
- * 2. A deterministic static complexity budget, so patterns that evade the
368
- * heuristic still fail over when their unbounded-quantifier density is
369
- * pathological.
370
- *
371
- * Unsafe input is never executed as a regex: depending on the caller's
372
- * intent it is either escaped to a literal match or rejected through the
373
- * `invalidInput` policy.
374
- */
375
- /**
376
- * Regex patterns that can cause catastrophic backtracking (ReDoS attacks).
377
- * Detects common footguns:
378
- * - Quantifiers: {n,m}
379
- * - Possessive quantifiers: *+, ++, ?+
380
- * - Nested quantifiers: (a+)+, (a*)*
381
- * - Backreferences: \1, \2, etc.
382
- * - Complex character classes: [...]...[...]
383
- */
384
- const DANGEROUS_REGEX_PATTERNS = /(\{[0-9,]+\}|\*\+|\+\+|\?\+|(\(.+\))\+|\(\?:|\\[0-9]|(\[.+\]).+(\[.+\]))/;
385
- /**
386
- * Static complexity budget for user-supplied regex patterns.
387
- * Returns `true` if the pattern crosses a budget that empirically correlates
388
- * with ReDoS risk — independent of the heuristic regex detector.
389
- *
390
- * Budget components (any single threshold crossed → over budget):
391
- * - unbounded quantifiers (`*`, `+`, `?`) — hard cap 20
392
- * - nested groups `(` — hard cap 8
393
- * - alternations `|` — hard cap 10
394
- * - combined: (quantifiers * groups) — hard cap 40
395
- */
396
- function regexComplexityExceedsBudget(patternStr) {
397
- const stripped = patternStr.replace(/\\[^\\]/g, "");
398
- const unboundedQuantifiers = (stripped.match(/[*+?]/g) ?? []).length;
399
- const groups = (stripped.match(/\(/g) ?? []).length;
400
- const alternations = (stripped.match(/\|/g) ?? []).length;
401
- if (unboundedQuantifiers > 20) return true;
402
- if (groups > 8) return true;
403
- if (alternations > 10) return true;
404
- if (unboundedQuantifiers * groups > 40) return true;
405
- return false;
406
- }
407
- /** Escape special regex characters (backslash first, then other metas). */
408
- function escapeRegex(str) {
409
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
410
- }
411
- /**
412
- * Build a safe RegExp from user input.
413
- *
414
- * `onUnsafe` selects what happens when the pattern is over-long,
415
- * pathological, or syntactically invalid:
416
- * - `'reject'`: route through the `invalidInput` policy — HTTP 400 in
417
- * `'throw'` mode, warn + escape-to-literal in `'drop'` mode. Used for
418
- * the explicit `regex` operator, where the caller claims to be sending
419
- * a real regex.
420
- * - `'escape'`: always warn + escape-to-literal, never throw. Used for
421
- * `search` terms and the `like`/`contains` operators, whose documented
422
- * semantic is a literal substring match — `?search=c++` must not 400.
423
- */
424
- function createSafeRegex(rt, pattern, flags = "i", onUnsafe = "reject") {
425
- if (pattern === null || pattern === void 0) return null;
426
- const flag = (message, meta) => {
427
- if (onUnsafe === "reject") rt.reject(message, meta);
428
- else warn(`[mongokit] ${message}`);
429
- };
430
- const patternStr = String(pattern);
431
- if (patternStr.length > rt.options.maxRegexLength) {
432
- flag(`Regex pattern too long, truncating`, {
433
- length: patternStr.length,
434
- maxRegexLength: rt.options.maxRegexLength
435
- });
436
- return new RegExp(escapeRegex(patternStr.substring(0, rt.options.maxRegexLength)), flags);
437
- }
438
- if (DANGEROUS_REGEX_PATTERNS.test(patternStr)) {
439
- flag("Potentially dangerous regex pattern, escaping");
440
- return new RegExp(escapeRegex(patternStr), flags);
441
- }
442
- if (regexComplexityExceedsBudget(patternStr)) {
443
- flag("Regex complexity budget exceeded, escaping");
444
- return new RegExp(escapeRegex(patternStr), flags);
445
- }
446
- try {
447
- return new RegExp(patternStr, flags);
448
- } catch {
449
- flag("Invalid regex pattern syntax, escaping to literal match");
450
- return new RegExp(escapeRegex(patternStr), flags);
451
- }
452
- }
453
- //#endregion
454
361
  //#region src/query/parser/filter-compiler.ts
455
362
  /**
456
- * Filter compiler — URL filter parameters → MongoDB filter query.
457
- *
458
- * Owns the three entry shapes (flat `field=value`, flat operator syntax
459
- * `field[gte]=10`, nested bracket objects from qs), the `$or` group router,
460
- * and the `between` range expander. Every invalid or disallowed fragment
461
- * routes through the runtime's `invalidInput` policy.
363
+ * URL filters → a MongoDB filter. The grammar (keys, operators, value coercion, refusals) is
364
+ * repo-core's `readFilterClauses`; its clauses compile through the Filter IR compiler every
365
+ * repository query uses, so URL and programmatic filters mean the same thing. Only mongokit's own
366
+ * operators (`size`, `type`, geo) are emitted here, natively.
462
367
  */
368
+ /** mongokit-native operators, beyond the shared grammar. */
369
+ const MONGOKIT_EXTENSION_OPERATORS = [
370
+ "size",
371
+ "type",
372
+ ...GEO_OPERATORS
373
+ ];
374
+ /** Group params — `or[0][status]=a&or[1][qty][gte]=5` — compiled branch by branch. */
375
+ const GROUP_PARAMS = [
376
+ "or",
377
+ "OR",
378
+ "$or",
379
+ "$and"
380
+ ];
381
+ /** mongokit control params that are not filters. */
382
+ const MONGOKIT_RESERVED_PARAMS = [
383
+ "lean",
384
+ "includeDeleted",
385
+ "lookup",
386
+ "aggregate",
387
+ "cursor",
388
+ ...GROUP_PARAMS
389
+ ];
390
+ const BSON_TYPE_RE = /^(?:[a-zA-Z]+|\d{1,3})$/;
391
+ /** Compile every filter in `query` (group params excluded — see {@link compileGroups}). */
392
+ function compileUrlFilters(rt, query) {
393
+ const { clauses, extensions } = readFilterClauses(query, grammarOptions(rt));
394
+ return conjoin([compileFilterToMongo(clausesToFilter(clauses)), ...extensions.map((clause) => compileExtension(rt, clause)).filter((part) => part !== void 0)]);
395
+ }
463
396
  /**
464
- * Top-level query parameters that mongokit handles outside the filter
465
- * pipeline. Used by `handleBracketSyntax` to detect a common typo where a
466
- * caller wrote `?filters[limit]=5` instead of `?limit=5` and the qs parser
467
- * nested the key under a filter object — the value is silently dropped
468
- * without this guard. Kept as a Set for O(1) lookup; values mirror the
469
- * reserved-key list at the top of `parseFilters`.
397
+ * `or` / `$or` and `$and` groups. Each branch is a full filter through the same grammar. An empty
398
+ * branch is refused — inside an OR it would match every document — and groups do not nest.
470
399
  */
471
- const RESERVED_PAGINATION_KEYS = /* @__PURE__ */ new Set([
472
- "page",
473
- "limit",
474
- "sort",
475
- "select"
476
- ]);
477
- function toMongoOperator(operator) {
478
- const op = operator.toLowerCase();
479
- return op.startsWith("$") ? op : `$${op}`;
400
+ function compileGroups(rt, query) {
401
+ const out = {};
402
+ const orRaw = query?.or ?? query?.OR ?? query?.$or;
403
+ const orBranches = orRaw === void 0 ? [] : compileBranches(rt, "or", orRaw);
404
+ if (orBranches.length > 0) out.$or = orBranches;
405
+ const andBranches = query?.$and === void 0 ? [] : compileBranches(rt, "$and", query.$and);
406
+ if (andBranches.length > 0) out.$and = andBranches;
407
+ return out;
480
408
  }
481
- /** Parse filter parameters into a MongoDB filter query. */
482
- function parseFilters(rt, filters, depth = 0) {
483
- if (depth > rt.options.maxFilterDepth) {
484
- rt.reject(`Filter depth ${depth} exceeds maximum ${rt.options.maxFilterDepth}, truncating`, {
485
- depth,
486
- maxFilterDepth: rt.options.maxFilterDepth
487
- });
488
- return {};
409
+ function compileBranches(rt, param, raw) {
410
+ const items = Array.isArray(raw) ? raw : isRecord(raw) ? Object.values(raw) : void 0;
411
+ if (!items) {
412
+ rt.reject(`${param} takes branches: ${param}[0][field]=value`, { param });
413
+ return [];
489
414
  }
490
- const parsedFilters = {};
491
- const regexFields = {};
492
- for (const [key, value] of Object.entries(filters)) {
493
- if (rt.dangerousOperators.includes(key) || key.startsWith("$") && !["$or", "$and"].includes(key)) {
494
- rt.reject(`Blocked dangerous operator: ${key}`, { key });
415
+ const branches = [];
416
+ for (const item of items) {
417
+ if (!isRecord(item)) {
418
+ rt.reject(`each ${param} branch is a set of filters`, { param });
495
419
  continue;
496
420
  }
497
- if (isControlParam(key) || [
498
- "lean",
499
- "includeDeleted",
500
- "lookup",
501
- "aggregate",
502
- "or",
503
- "OR",
504
- "$or"
505
- ].includes(key)) continue;
506
- const operatorMatch = key.match(/^(.+)\[(.+)\]$/);
507
- const baseField = operatorMatch ? operatorMatch[1] : key;
508
- if (rt.options.allowedFilterFields && !rt.options.allowedFilterFields.includes(baseField)) {
509
- rt.reject(`Blocked filter field not in allowlist: ${baseField}`, { field: baseField });
421
+ if (GROUP_PARAMS.some((group) => group in item)) {
422
+ rt.reject(`${param} groups do not nest`, { param });
510
423
  continue;
511
424
  }
512
- if (key === "$and") {
513
- const branches = parseCompoundBranches(rt, value, depth);
514
- if (branches.length > 0) parsedFilters.$and = branches;
425
+ const branch = compileUrlFilters(rt, item);
426
+ if (Object.keys(branch).length === 0) {
427
+ rt.reject(`an empty ${param} branch would match every document`, { param });
515
428
  continue;
516
429
  }
517
- if (operatorMatch) {
518
- const [, , operator] = operatorMatch;
519
- if (rt.dangerousOperators.includes(`$${operator}`)) {
520
- rt.reject(`Blocked dangerous operator: ${operator}`, { operator });
521
- continue;
522
- }
523
- handleOperatorSyntax(rt, parsedFilters, regexFields, operatorMatch, value);
524
- continue;
525
- }
526
- if (typeof value === "object" && value !== null && !Array.isArray(value)) handleBracketSyntax(rt, key, value, parsedFilters, depth + 1);
527
- else parsedFilters[key] = coerceFieldValue(key, value, rt.fieldTypes);
430
+ branches.push(branch);
528
431
  }
529
- return parsedFilters;
432
+ return branches;
530
433
  }
531
- /** Handle flat operator syntax: field[operator]=value */
532
- function handleOperatorSyntax(rt, filters, regexFields, operatorMatch, value) {
533
- const [, field, operator] = operatorMatch;
534
- if (value === "" || value === null || value === void 0) return;
535
- if (rt.options.allowedOperators && !rt.options.allowedOperators.includes(operator.toLowerCase())) {
536
- rt.reject(`Operator not in allowlist: ${operator}`, {
537
- field,
538
- operator
539
- });
540
- return;
541
- }
542
- if (operator.toLowerCase() === "options" && regexFields[field]) {
543
- const fieldValue = filters[field];
544
- if (typeof fieldValue === "object" && fieldValue !== null && "$regex" in fieldValue) {
545
- if (typeof value === "string" && /^[imsx]+$/.test(value)) fieldValue.$options = value;
546
- else rt.reject(`Blocked invalid regex $options value: ${String(value)}. Allowed flags: i, m, s, x`, {
547
- field,
548
- value: String(value)
549
- });
550
- }
551
- return;
552
- }
553
- if (operator.toLowerCase() === "between") {
554
- if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
555
- filters[field].between = value;
556
- return;
557
- }
558
- if (operator.toLowerCase() === "contains" || operator.toLowerCase() === "like") {
559
- const safeRegex = createSafeRegex(rt, value, "i", "escape");
560
- if (safeRegex) {
561
- filters[field] = { $regex: safeRegex };
562
- regexFields[field] = true;
563
- }
564
- return;
565
- }
566
- if (isGeoOperator(operator)) {
567
- const geoFilter = parseGeoFilter(operator, value);
568
- if (geoFilter) filters[field] = geoFilter;
569
- else rt.reject(`Invalid geo operator value for ${field}[${operator}]; dropping filter`, {
570
- field,
571
- operator
572
- });
573
- return;
574
- }
575
- const mongoOperator = toMongoOperator(operator);
576
- if (rt.dangerousOperators.includes(mongoOperator)) {
577
- rt.reject(`Blocked dangerous operator: ${mongoOperator}`, { operator: mongoOperator });
578
- return;
579
- }
580
- if (mongoOperator === "$eq") filters[field] = coerceFieldValue(field, value, rt.fieldTypes);
581
- else if (mongoOperator === "$regex") {
582
- const safeRegex = createSafeRegex(rt, value);
583
- if (safeRegex) {
584
- filters[field] = { $regex: safeRegex };
585
- regexFields[field] = true;
586
- }
587
- } else {
588
- let processedValue;
589
- const op = operator.toLowerCase();
590
- if (op === "size") {
591
- processedValue = parseFloat(String(value));
592
- if (Number.isNaN(processedValue)) {
593
- rt.reject(`Non-numeric value for ${field}[${op}]: ${String(value)}`, {
594
- field,
595
- op
596
- });
597
- return;
598
- }
599
- } else if ([
600
- "gt",
601
- "gte",
602
- "lt",
603
- "lte"
604
- ].includes(op)) {
605
- if (rt.fieldTypes.has(field)) {
606
- processedValue = coerceFieldValue(field, value, rt.fieldTypes);
607
- if (typeof processedValue === "number" && Number.isNaN(processedValue)) {
608
- rt.reject(`Invalid value for ${field}[${op}]: ${String(value)}`, {
609
- field,
610
- op
611
- });
612
- return;
613
- }
614
- } else {
615
- processedValue = parseFloat(String(value));
616
- if (Number.isNaN(processedValue)) {
617
- rt.reject(`Non-numeric value for ${field}[${op}]: ${String(value)}`, {
618
- field,
619
- op
620
- });
621
- return;
622
- }
623
- }
624
- } else if (op === "in" || op === "nin") processedValue = (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim())).map((elem) => coerceFieldValue(field, elem, rt.fieldTypes));
625
- else processedValue = coerceFieldValue(field, value, rt.fieldTypes);
626
- if (typeof filters[field] !== "object" || filters[field] === null || Array.isArray(filters[field])) filters[field] = {};
627
- filters[field][mongoOperator] = processedValue;
628
- }
434
+ function grammarOptions(rt) {
435
+ return {
436
+ allowedFilterFields: rt.options.allowedFilterFields,
437
+ allowedOperators: rt.options.allowedOperators,
438
+ fieldTypes: rt.grammarFieldTypes,
439
+ extensionOperators: MONGOKIT_EXTENSION_OPERATORS,
440
+ reservedParams: MONGOKIT_RESERVED_PARAMS,
441
+ maxTextLength: rt.options.maxRegexLength,
442
+ onInvalid: rt.options.invalidInput === "drop" ? (error) => rt.reject(error.message) : void 0
443
+ };
629
444
  }
630
- /** Handle bracket syntax with object value (qs-parsed nested operators). */
631
- function handleBracketSyntax(rt, field, operators, parsedFilters, depth = 0) {
632
- if (depth > rt.options.maxFilterDepth) {
633
- rt.reject(`Nested filter depth exceeds maximum, skipping field: ${field}`, {
634
- field,
635
- maxFilterDepth: rt.options.maxFilterDepth
636
- });
637
- return;
638
- }
639
- for (const op of Object.keys(operators)) if (RESERVED_PAGINATION_KEYS.has(op)) warn(`[mongokit] Nested filter contains reserved key '${op}' at '${field}.${op}' — did you mean ?${op}=... at the top level? The nested value is ignored.`);
640
- if (!parsedFilters[field]) parsedFilters[field] = {};
641
- for (const [operator, value] of Object.entries(operators)) {
642
- if (value === "" || value === null || value === void 0) continue;
643
- if (operator === "between") {
644
- parsedFilters[field].between = value;
645
- continue;
646
- }
647
- if (isGeoOperator(operator)) {
648
- const geoFilter = parseGeoFilter(operator, value);
649
- if (geoFilter) parsedFilters[field] = geoFilter;
650
- else {
651
- rt.reject(`Invalid geo operator value for ${field}[${operator}]; dropping filter`, {
652
- field,
653
- operator
654
- });
655
- delete parsedFilters[field];
445
+ function compileExtension(rt, clause) {
446
+ const param = `${clause.field}[${clause.op}]`;
447
+ try {
448
+ switch (clause.op) {
449
+ case "size":
450
+ if (!/^\d+$/.test(clause.raw)) throw new QueryGrammarError(param, "size takes a whole number");
451
+ return { [clause.field]: { $size: Number(clause.raw) } };
452
+ case "type": {
453
+ if (!BSON_TYPE_RE.test(clause.raw)) throw new QueryGrammarError(param, "not a BSON type");
454
+ const code = Number(clause.raw);
455
+ return { [clause.field]: { $type: Number.isInteger(code) ? code : clause.raw } };
456
+ }
457
+ default: {
458
+ const geo = parseGeoFilter(clause.op, clause.raw);
459
+ if (!geo) throw new QueryGrammarError(param, `invalid ${clause.op} coordinates`);
460
+ return { [clause.field]: geo };
656
461
  }
657
- continue;
658
- }
659
- if (rt.options.allowedOperators && !rt.options.allowedOperators.includes(operator)) {
660
- rt.reject(`Operator not in allowlist: ${operator}`, {
661
- field,
662
- operator
663
- });
664
- continue;
665
- }
666
- if (rt.operators[operator]) {
667
- const mongoOperator = rt.operators[operator];
668
- let processedValue;
669
- if (operator === "size") {
670
- processedValue = parseFloat(String(value));
671
- if (Number.isNaN(processedValue)) {
672
- rt.reject(`Non-numeric value for ${field}[${operator}]: ${String(value)}`, {
673
- field,
674
- operator
675
- });
676
- continue;
677
- }
678
- } else if ([
679
- "gt",
680
- "gte",
681
- "lt",
682
- "lte"
683
- ].includes(operator)) {
684
- if (rt.fieldTypes.has(field)) {
685
- processedValue = coerceFieldValue(field, value, rt.fieldTypes);
686
- if (typeof processedValue === "number" && Number.isNaN(processedValue)) {
687
- rt.reject(`Invalid value for ${field}[${operator}]: ${String(value)}`, {
688
- field,
689
- operator
690
- });
691
- continue;
692
- }
693
- } else {
694
- processedValue = parseFloat(String(value));
695
- if (Number.isNaN(processedValue)) {
696
- rt.reject(`Non-numeric value for ${field}[${operator}]: ${String(value)}`, {
697
- field,
698
- operator
699
- });
700
- continue;
701
- }
702
- }
703
- } else if (operator === "in" || operator === "nin") processedValue = (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim())).map((elem) => coerceFieldValue(field, elem, rt.fieldTypes));
704
- else if (operator === "like" || operator === "contains" || operator === "regex") {
705
- const safeRegex = createSafeRegex(rt, value, "i", operator === "regex" ? "reject" : "escape");
706
- if (!safeRegex) continue;
707
- processedValue = safeRegex;
708
- } else processedValue = coerceFieldValue(field, value, rt.fieldTypes);
709
- parsedFilters[field][mongoOperator] = processedValue;
710
462
  }
463
+ } catch (error) {
464
+ if (!(error instanceof QueryGrammarError) || rt.options.invalidInput !== "drop") throw error;
465
+ rt.reject(error.message);
466
+ return;
711
467
  }
712
- if (typeof parsedFilters[field] === "object" && Object.keys(parsedFilters[field]).length === 0) delete parsedFilters[field];
713
468
  }
714
- /** Parse `?or=[...]` / `?OR=[...]` / `?$or=[...]` groups into an $or array. */
715
- /**
716
- * Parse the branches of a compound operator (`$and`, and the body of `$or`).
717
- *
718
- * Every branch goes back through `parseFilters`, so the operator block, the allowlist and the
719
- * regex budget all apply inside it. Branches that parse to `{}` are DROPPED: a branch holding
720
- * only a blocked operator would otherwise become match-all, which widens the query instead of
721
- * failing it — the exact shape `parseOr` documents below.
722
- */
723
- function parseCompoundBranches(rt, raw, depth) {
724
- const items = Array.isArray(raw) ? raw : typeof raw === "object" && raw ? Object.values(raw) : [];
725
- const branches = [];
726
- for (const item of items) {
727
- if (typeof item !== "object" || !item) continue;
728
- const parsed = parseFilters(rt, item, depth + 1);
729
- if (Object.keys(parsed).length > 0) branches.push(parsed);
469
+ /** AND compiled parts: one object when fields don't collide, `$and` when they do. */
470
+ function conjoin(parts) {
471
+ const nonEmpty = parts.filter((part) => Object.keys(part).length > 0);
472
+ if (nonEmpty.length <= 1) return { ...nonEmpty[0] ?? {} };
473
+ const merged = {};
474
+ for (const part of nonEmpty) {
475
+ for (const key of Object.keys(part)) if (key in merged) return { $and: nonEmpty };
476
+ Object.assign(merged, part);
730
477
  }
731
- return branches;
478
+ return merged;
732
479
  }
733
- function parseOr(rt, query) {
734
- const orArray = [];
735
- const raw = query?.or || query?.OR || query?.$or;
736
- if (!raw) return void 0;
737
- const items = Array.isArray(raw) ? raw : typeof raw === "object" ? Object.values(raw) : [];
738
- for (const item of items) if (typeof item === "object" && item) {
739
- const parsedBranch = parseFilters(rt, item, 1);
740
- if (Object.keys(parsedBranch).length > 0) orArray.push(parsedBranch);
741
- }
742
- return orArray.length ? orArray : void 0;
480
+ /** mongokit's schema-derived field types, narrowed to the grammar's portable set. */
481
+ function toGrammarFieldTypes(types) {
482
+ const out = {};
483
+ for (const [path, type] of types) if (type === "string" || type === "objectid") out[path] = "string";
484
+ else if (type === "number" || type === "boolean" || type === "date") out[path] = type;
485
+ return out;
743
486
  }
744
- /** Expand `{ field: { between: 'a,b' } }` markers into `$gte`/`$lte` date ranges. */
745
- function enhanceWithBetween(rt, filters) {
746
- const output = { ...filters };
747
- for (const [key, value] of Object.entries(filters || {})) if (value && typeof value === "object" && "between" in value) {
748
- const between = value.between;
749
- const [from, to] = String(between).split(",").map((s) => s.trim());
750
- const fromDate = from ? new Date(from) : void 0;
751
- const toDate = to ? new Date(to) : void 0;
752
- const range = {};
753
- if (fromDate && !Number.isNaN(fromDate.getTime())) range.$gte = fromDate;
754
- if (toDate && !Number.isNaN(toDate.getTime())) range.$lte = toDate;
755
- if (Object.keys(range).length === 0) {
756
- rt.reject(`Invalid 'between' value for ${key}: ${String(between)}`, {
757
- field: key,
758
- value: String(between)
759
- });
760
- delete output[key];
761
- } else output[key] = range;
762
- }
763
- return output;
487
+ function isRecord(value) {
488
+ return typeof value === "object" && value !== null && !Array.isArray(value);
764
489
  }
765
490
  //#endregion
766
491
  //#region src/query/parser/lookup.ts
@@ -838,97 +563,70 @@ function parseSingleLookup(rt, collectionName, config) {
838
563
  return null;
839
564
  }
840
565
  //#endregion
841
- //#region src/query/parser/pagination-input.ts
566
+ //#region src/query/parser/runtime.ts
842
567
  /**
843
- * Parse a pagination input to a positive integer, or `undefined` when the
844
- * value is absent, empty, or rejected under `'drop'` mode. Rejects (per
845
- * policy) on non-integers, negatives, zero, decimals, and trailing garbage.
568
+ * Parser runtime — the shared context every parser module receives.
569
+ *
570
+ * The `QueryParser` facade resolves options once at construction and builds a
571
+ * single `ParserRuntime`; the extracted modules (filter compiler, sanitizers,
572
+ * populate/lookup/sort parsers, …) are pure functions over it. This keeps the
573
+ * modules independently testable and the facade thin, without threading six
574
+ * separate arguments through every call.
846
575
  */
847
- function parsePositiveInt(rt, raw, label) {
848
- if (raw === void 0 || raw === null || raw === "") return void 0;
849
- let n;
850
- if (typeof raw === "number") n = raw;
851
- else {
852
- const s = String(raw).trim();
853
- if (!/^\d+$/.test(s)) {
854
- rt.reject(`Invalid ${label} value: ${String(raw)} (expected a positive integer)`, { [label]: String(raw) });
855
- return;
856
- }
857
- n = Number(s);
858
- }
859
- if (!Number.isInteger(n) || n < 1) {
860
- rt.reject(`Invalid ${label} value: ${String(raw)} (expected a positive integer)`, { [label]: String(raw) });
861
- return;
576
+ /** Always-blocked MongoDB operators (extended via `additionalDangerousOperators`). */
577
+ const BASE_DANGEROUS_OPERATORS = [
578
+ "$where",
579
+ "$function",
580
+ "$accumulator",
581
+ "$expr"
582
+ ];
583
+ function createReject(mode) {
584
+ return (message, meta) => {
585
+ if (mode === "throw") throw createError(400, `[mongokit] ${message}`, {
586
+ code: "INVALID_QUERY_INPUT",
587
+ meta
588
+ });
589
+ warn(`[mongokit] ${message}`);
590
+ };
591
+ }
592
+ /**
593
+ * Run a grammar reader under the `invalidInput` policy: `'throw'` lets its 400 through untouched;
594
+ * `'drop'` warns and returns `fallback`.
595
+ */
596
+ function guarded(rt, read, fallback) {
597
+ try {
598
+ return read();
599
+ } catch (error) {
600
+ if (!isQueryGrammarError(error) || rt.options.invalidInput !== "drop") throw error;
601
+ rt.reject(error.message);
602
+ return fallback;
862
603
  }
863
- return n;
864
604
  }
865
605
  //#endregion
866
606
  //#region src/query/parser/sort-select.ts
867
607
  /**
868
- * The sort applied when a request names none. Mongo/mongokit convention.
869
- *
870
- * Exported so `parse()` can ask whether it is PERMITTED before applying it,
871
- * rather than substituting it and then validating it like caller input.
608
+ * `?sort=-createdAt,name` and `?select=name,-password` — read through the shared grammar
609
+ * (`readSort` / `readSelect`), so an invalid or disallowed field is refused like any filter.
872
610
  */
873
- const DEFAULT_PARSER_SORT = "-createdAt";
874
611
  /**
875
- * Is every field of `sort` inside `allowedSortFields`? True when no allowlist
876
- * is configured. Pure predicate — never rejects, never warns, so it is safe to
877
- * ask about a default the caller did not send.
612
+ * The sort applied when a request names none. Exported so `parse()` can ask whether it is
613
+ * PERMITTED before applying it, rather than validating it like caller input.
878
614
  */
615
+ const DEFAULT_PARSER_SORT = "-createdAt";
616
+ /** Is every field of `sort` inside `allowedSortFields`? Pure — never rejects. */
879
617
  function isSortFieldAllowed(rt, sort) {
880
618
  const allowed = rt.options.allowedSortFields;
881
619
  if (!allowed || allowed.length === 0) return true;
882
- return sort.split(",").map((s) => s.trim()).filter(Boolean).every((field) => allowed.includes(field.startsWith("-") ? field.substring(1) : field));
620
+ return sort.split(",").map((s) => s.trim()).filter(Boolean).every((field) => allowed.includes(field.replace(/^[+-]/, "")));
883
621
  }
884
- /** Parse a sort spec (string or object form), honoring `allowedSortFields`. */
885
622
  function parseSort(rt, sort) {
886
- if (!sort) return void 0;
887
- if (typeof sort === "object") {
888
- const sortObj = {};
889
- for (const [key, value] of Object.entries(sort)) {
890
- if (rt.options.allowedSortFields && !rt.options.allowedSortFields.includes(key)) {
891
- rt.reject(`Blocked sort field not in allowlist: ${key}`, { field: key });
892
- continue;
893
- }
894
- const strVal = String(value).toLowerCase();
895
- sortObj[key] = strVal === "desc" || strVal === "-1" || value === -1 ? -1 : 1;
896
- }
897
- return Object.keys(sortObj).length > 0 ? sortObj : void 0;
898
- }
899
- const sortObj = {};
900
- const fields = sort.split(",").map((s) => s.trim());
901
- for (const field of fields) {
902
- if (!field) continue;
903
- const cleanField = field.startsWith("-") ? field.substring(1) : field;
904
- if (rt.options.allowedSortFields && !rt.options.allowedSortFields.includes(cleanField)) {
905
- rt.reject(`Blocked sort field not in allowlist: ${cleanField}`, { field: cleanField });
906
- continue;
907
- }
908
- if (field.startsWith("-")) sortObj[field.substring(1)] = -1;
909
- else sortObj[field] = 1;
910
- }
911
- return Object.keys(sortObj).length > 0 ? sortObj : void 0;
623
+ return readSort(sort, {
624
+ allowedSortFields: rt.options.allowedSortFields,
625
+ onInvalid: rt.options.invalidInput === "drop" ? (error) => rt.reject(error.message) : void 0
626
+ });
912
627
  }
913
- /**
914
- * Parse select/project fields.
915
- *
916
- * @example
917
- * ```typescript
918
- * // URL: ?select=name,email,-password
919
- * // Returns: { name: 1, email: 1, password: 0 }
920
- * ```
921
- */
922
- function parseSelect(select) {
923
- if (!select) return void 0;
924
- if (typeof select === "string") {
925
- const projection = {};
926
- const fields = select.split(",").map((f) => f.trim());
927
- for (const field of fields) if (field.startsWith("-")) projection[field.substring(1)] = 0;
928
- else projection[field] = 1;
929
- return projection;
930
- }
931
- if (typeof select === "object" && select !== null) return select;
628
+ function parseSelect(rt, select) {
629
+ return guarded(rt, () => readSelect(select), void 0);
932
630
  }
933
631
  //#endregion
934
632
  //#region src/query/parser/populate.ts
@@ -992,7 +690,10 @@ function parseSinglePopulate(rt, path, config, depth = 0) {
992
690
  const opts = config;
993
691
  const option = { path };
994
692
  if (opts.select && typeof opts.select === "string") option.select = opts.select.split(",").map((s) => s.trim()).join(" ");
995
- if (opts.match && typeof opts.match === "object") option.match = convertPopulateMatch(opts.match);
693
+ if (opts.match && typeof opts.match === "object") {
694
+ const match = convertPopulateMatch(rt, path, opts.match);
695
+ if (match) option.match = match;
696
+ }
996
697
  if (opts.limit !== void 0) {
997
698
  const limit = parseInt(String(opts.limit), 10);
998
699
  if (!Number.isNaN(limit) && limit > 0) {
@@ -1027,112 +728,56 @@ function parseSinglePopulate(rt, path, config, depth = 0) {
1027
728
  }
1028
729
  return null;
1029
730
  }
1030
- /** Convert populate match values (handles boolean strings, etc.) */
1031
- function convertPopulateMatch(match) {
731
+ /**
732
+ * A populate `match` is plain field EQUALITY: a `$` key (`$where`) or a nested object — the same
733
+ * operator-smuggling route — is refused through the invalid-input policy, never passed to mongoose.
734
+ */
735
+ function convertPopulateMatch(rt, path, match) {
1032
736
  const converted = {};
1033
- for (const [key, value] of Object.entries(match)) converted[key] = coerceHeuristic(value);
737
+ for (const [key, value] of Object.entries(match)) {
738
+ if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(key) || value !== null && typeof value === "object") {
739
+ rt.reject(`Populate match on "${path}" accepts field equality only: "${key}"`, {
740
+ path,
741
+ key
742
+ });
743
+ return null;
744
+ }
745
+ converted[key] = coerceHeuristic(value);
746
+ }
1034
747
  return converted;
1035
748
  }
1036
749
  //#endregion
1037
- //#region src/query/parser/runtime.ts
750
+ //#region src/query/parser/schema-docs.ts
1038
751
  /**
1039
- * Parser runtime — the shared context every parser module receives.
1040
- *
1041
- * The `QueryParser` facade resolves options once at construction and builds a
1042
- * single `ParserRuntime`; the extracted modules (filter compiler, sanitizers,
1043
- * populate/lookup/sort parsers, …) are pure functions over it. This keeps the
1044
- * modules independently testable and the facade thin, without threading six
1045
- * separate arguments through every call.
752
+ * OpenAPI query-schema generation — Arc's `defineResource()` auto-detects
753
+ * `getQuerySchema()` on the parser and uses it to document list-endpoint
754
+ * query parameters in OpenAPI/Swagger.
1046
755
  */
1047
- /** URL operator key → MongoDB operator. */
1048
- const OPERATOR_MAP = {
1049
- eq: "$eq",
1050
- ne: "$ne",
1051
- gt: "$gt",
1052
- gte: "$gte",
1053
- lt: "$lt",
1054
- lte: "$lte",
1055
- in: "$in",
1056
- nin: "$nin",
1057
- like: "$regex",
1058
- contains: "$regex",
1059
- regex: "$regex",
1060
- exists: "$exists",
1061
- size: "$size",
1062
- type: "$type"
756
+ /** mongokit's own operators, described alongside the shared grammar's. */
757
+ const EXTENSION_DESCRIPTIONS = {
758
+ size: "Array has exactly N elements",
759
+ type: "Field is of a BSON type (name or number)",
760
+ near: "Nearest to lng,lat[,maxDistanceMeters]",
761
+ nearSphere: "Nearest on a sphere to lng,lat[,maxDistanceMeters]",
762
+ geoWithin: "Inside the box minLng,minLat,maxLng,maxLat",
763
+ withinRadius: "Within lng,lat,radiusMeters"
1063
764
  };
1064
- /** Always-blocked MongoDB operators (extended via `additionalDangerousOperators`). */
1065
- const BASE_DANGEROUS_OPERATORS = [
1066
- "$where",
1067
- "$function",
1068
- "$accumulator",
1069
- "$expr"
1070
- ];
1071
- function createReject(mode) {
1072
- return (message, meta) => {
1073
- if (mode === "throw") throw createError(400, `[mongokit] ${message}`, {
1074
- code: "INVALID_QUERY_INPUT",
1075
- meta
1076
- });
1077
- warn(`[mongokit] ${message}`);
1078
- };
765
+ function describeOperator(op) {
766
+ if (op in OPERATOR_DESCRIPTIONS) return OPERATOR_DESCRIPTIONS[op];
767
+ return EXTENSION_DESCRIPTIONS[op] ?? op;
1079
768
  }
1080
- //#endregion
1081
- //#region src/query/parser/schema-docs.ts
1082
769
  function availableOperators(rt) {
1083
- return rt.options.allowedOperators ? Object.entries(rt.operators).filter(([key]) => rt.options.allowedOperators?.includes(key)) : Object.entries(rt.operators);
770
+ const allowed = rt.options.allowedOperators;
771
+ return allowed ? rt.urlOperators.filter((op) => allowed.includes(op)) : rt.urlOperators;
1084
772
  }
1085
- /** Get the JSON Schema type for a filter operator. */
773
+ /** JSON Schema type of an operator's value. */
1086
774
  function operatorSchemaType(op) {
1087
- if ([
1088
- "gt",
1089
- "gte",
1090
- "lt",
1091
- "lte",
1092
- "size"
1093
- ].includes(op)) return "number";
1094
- if (["exists"].includes(op)) return "boolean";
775
+ if (op === "size") return "integer";
776
+ if (op === "exists") return "boolean";
1095
777
  return "string";
1096
778
  }
1097
- /** Get a human-readable description for a filter operator. */
1098
- function operatorDescription(op, field, mongoOp) {
1099
- return {
1100
- ne: `${field} not equal to value (${mongoOp})`,
1101
- gt: `${field} greater than value (${mongoOp})`,
1102
- gte: `${field} greater than or equal to value (${mongoOp})`,
1103
- lt: `${field} less than value (${mongoOp})`,
1104
- lte: `${field} less than or equal to value (${mongoOp})`,
1105
- in: `${field} in comma-separated list (${mongoOp}). Example: value1,value2`,
1106
- nin: `${field} not in comma-separated list (${mongoOp})`,
1107
- like: `${field} matches pattern (case-insensitive regex)`,
1108
- contains: `${field} contains substring (case-insensitive regex)`,
1109
- regex: `${field} matches regex pattern (${mongoOp})`,
1110
- exists: `Field ${field} exists (true/false)`,
1111
- size: `Array field ${field} has exactly N elements (${mongoOp})`,
1112
- type: `Field ${field} is of BSON type (${mongoOp})`
1113
- }[op] || `Filter ${field} with ${mongoOp}`;
1114
- }
1115
- /** Build a summary description of all available filter operators. */
1116
779
  function buildOperatorSummary(operators) {
1117
- const lines = ["Available filter operators (use as field[operator]=value):"];
1118
- const desc = {
1119
- eq: "Equal (default when no operator specified)",
1120
- ne: "Not equal",
1121
- gt: "Greater than",
1122
- gte: "Greater than or equal",
1123
- lt: "Less than",
1124
- lte: "Less than or equal",
1125
- in: "In list (comma-separated values)",
1126
- nin: "Not in list",
1127
- like: "Pattern match (case-insensitive)",
1128
- contains: "Contains substring (case-insensitive)",
1129
- regex: "Regex pattern",
1130
- exists: "Field exists (true/false)",
1131
- size: "Array size equals",
1132
- type: "BSON type check"
1133
- };
1134
- for (const [op, mongoOp] of operators) lines.push(` ${op} → ${mongoOp}: ${desc[op] || op}`);
1135
- return lines.join("\n");
780
+ return ["Available filter operators (use as field[operator]=value):", ...operators.map((op) => ` ${op}: ${describeOperator(op)}`)].join("\n");
1136
781
  }
1137
782
  /**
1138
783
  * Generate OpenAPI-compatible JSON Schema for query parameters.
@@ -1216,11 +861,11 @@ function buildQuerySchema(rt) {
1216
861
  type: "string",
1217
862
  description: `Filter by ${field} (exact match)`
1218
863
  };
1219
- for (const [op, mongoOp] of operators) {
864
+ for (const op of operators) {
1220
865
  if (op === "eq") continue;
1221
866
  properties[`${field}[${op}]`] = {
1222
867
  type: operatorSchemaType(op),
1223
- description: operatorDescription(op, field, mongoOp)
868
+ description: `${field}: ${describeOperator(op)}`
1224
869
  };
1225
870
  }
1226
871
  }
@@ -1246,39 +891,17 @@ function buildOpenAPIQuerySchema(rt) {
1246
891
  //#endregion
1247
892
  //#region src/query/parser/search.ts
1248
893
  /**
1249
- * Search-term handling — length-capped sanitization plus the regex-mode
1250
- * multi-field `$or` builder. Search terms are literal text, never regex
1251
- * patterns: special characters are escaped, not rejected.
894
+ * `?search=` — length-checked through the shared grammar. Regex mode matches the term as LITERAL
895
+ * text across `searchFields` (`c++` and `a.b` mean exactly those characters).
1252
896
  */
1253
- /** Trim + length-cap the raw search term (reject over-long input per policy). */
1254
897
  function sanitizeSearch(rt, search) {
1255
- if (search === null || search === void 0 || search === "") return void 0;
1256
- let searchStr = String(search).trim();
1257
- if (!searchStr) return void 0;
1258
- if (searchStr.length > rt.options.maxSearchLength) {
1259
- rt.reject(`Search query too long, truncating`, {
1260
- length: searchStr.length,
1261
- maxSearchLength: rt.options.maxSearchLength
1262
- });
1263
- searchStr = searchStr.substring(0, rt.options.maxSearchLength);
1264
- }
1265
- return searchStr;
898
+ return guarded(rt, () => readSearch(search, { maxSearchLength: rt.options.maxSearchLength }), void 0);
1266
899
  }
1267
- /**
1268
- * Build regex-based multi-field search filters: an $or with case-insensitive
1269
- * regex across all searchFields.
1270
- *
1271
- * @example
1272
- * // searchFields: ['name', 'description', 'sku'], search: 'azure'
1273
- * // Returns: [{ name: {$regex:/azure/i} }, { description: {...} }, { sku: {...} }]
1274
- */
1275
900
  function buildRegexSearch(rt, searchTerm) {
1276
- if (!rt.options.searchFields || rt.options.searchFields.length === 0) return null;
1277
- const safeRegex = createSafeRegex(rt, searchTerm, "i", "escape");
1278
- if (!safeRegex) return null;
1279
- const orConditions = [];
1280
- for (const field of rt.options.searchFields) orConditions.push({ [field]: { $regex: safeRegex } });
1281
- return orConditions.length > 0 ? orConditions : null;
901
+ const fields = rt.options.searchFields;
902
+ if (!fields || fields.length === 0) return null;
903
+ const pattern = new RegExp(escapeRegex(searchTerm), "i");
904
+ return fields.map((field) => ({ [field]: { $regex: pattern } }));
1282
905
  }
1283
906
  //#endregion
1284
907
  //#region src/query/QueryParser.ts
@@ -1293,8 +916,7 @@ function buildRegexSearch(rt, searchTerm) {
1293
916
  * orchestration, and the OpenAPI schema surface. The implementation lives in
1294
917
  * focused modules under `./parser/`:
1295
918
  *
1296
- * - `parser/filter-compiler` — filters, operator syntax, $or, between
1297
- * - `parser/regex-safety` — ReDoS protection (escape/reject)
919
+ * - `parser/filter-compiler` — filters (repo-core's shared grammar → Filter IR → Mongo), groups
1298
920
  * - `parser/pipeline-sanitizer` — $match / $lookup-pipeline / expression sanitizing
1299
921
  * - `parser/lookup` — ?lookup[...] parsing + collection allowlist
1300
922
  * - `parser/aggregation` — ?aggregate[...] parsing (opt-in)
@@ -1319,9 +941,9 @@ function buildRegexSearch(rt, searchTerm) {
1319
941
  * being silently dropped (which broadens the result set). Opt into
1320
942
  * `'drop'` only for trusted compat tooling.
1321
943
  * - Dangerous operators blocked everywhere ($where, $function, $accumulator, $expr).
1322
- * - Regex patterns validated (ReDoS protection); search/like/contains are
1323
- * literal text (escaped, never rejected).
1324
- * - Max filter depth + max limit enforced.
944
+ * - Filters, paging, sort and select follow repo-core's shared query grammar — the same
945
+ * contract as arc's parser and `parseUrl` (`runQueryGrammarConformance` holds all three).
946
+ * - Text operators and search are literal text; only `regex` is a pattern, checked for ReDoS.
1325
947
  * - Lookup pipelines sanitized; `enableAggregations` is opt-in — keep it off
1326
948
  * for public endpoints or pair with per-route allowlists.
1327
949
  *
@@ -1366,11 +988,13 @@ var QueryParser = class {
1366
988
  warn("[mongokit] searchMode \"regex\" requires searchFields to be specified. Falling back to \"text\" mode.");
1367
989
  resolved.searchMode = "text";
1368
990
  }
991
+ const fieldTypes = buildFieldTypeMap(options.schema, options.fieldTypes);
1369
992
  this.rt = {
1370
993
  options: resolved,
1371
- operators: OPERATOR_MAP,
994
+ urlOperators: [...URL_OPERATORS, ...MONGOKIT_EXTENSION_OPERATORS],
1372
995
  dangerousOperators: [...BASE_DANGEROUS_OPERATORS, ...resolved.additionalDangerousOperators],
1373
- fieldTypes: buildFieldTypeMap(options.schema, options.fieldTypes),
996
+ fieldTypes,
997
+ grammarFieldTypes: toGrammarFieldTypes(fieldTypes),
1374
998
  reject: createReject(resolved.invalidInput)
1375
999
  };
1376
1000
  this._schemaIndexes = extractSchemaIndexes(options.schema);
@@ -1434,67 +1058,41 @@ var QueryParser = class {
1434
1058
  */
1435
1059
  parse(query) {
1436
1060
  const rt = this.rt;
1437
- const { page, limit = 20, sort, populate, search, after, cursor, select, lookup, aggregate, ...filters } = query || {};
1438
- let parsedLimit = parsePositiveInt(rt, limit, "limit") ?? 20;
1439
- if (parsedLimit > rt.options.maxLimit) {
1440
- warn(`[mongokit] Limit ${parsedLimit} exceeds maximum ${rt.options.maxLimit}, capping to max`);
1441
- parsedLimit = rt.options.maxLimit;
1442
- }
1443
- /**
1444
- * The DEFAULT sort applies only when the allowlist permits it.
1445
- *
1446
- * `sort = '-createdAt'` was a DESTRUCTURING default, so an absent `?sort=`
1447
- * became `-createdAt` and was then validated exactly like caller input. With
1448
- * `allowedSortFields` set and `createdAt` not among them, the parser
1449
- * REJECTED ITS OWN DEFAULT — and since 3.25 rejection throws, so `parse({})`
1450
- * threw on an empty query. Every list call on such a resource answered 400
1451
- * (`Blocked sort field not in allowlist: createdAt`), REST and MCP alike,
1452
- * and no request the caller could make would avoid it.
1453
- *
1454
- * A default the resource forbade must simply not be applied: it is not a
1455
- * caller error, and there is nobody to report it to. A sort the caller DID
1456
- * send still rejects — that is the fail-closed behaviour 3.25 added, and it
1457
- * is deliberately unchanged.
1458
- */
1061
+ const q = query ?? {};
1062
+ const { page, limit, sort, populate, search, after, cursor, select, lookup, aggregate } = q;
1063
+ const pageRequest = guarded(rt, () => readPageRequest({
1064
+ page,
1065
+ limit,
1066
+ after,
1067
+ cursor
1068
+ }, { maxLimit: rt.options.maxLimit }), readPageRequest({}, { maxLimit: rt.options.maxLimit }));
1459
1069
  const effectiveSort = sort === void 0 && isSortFieldAllowed(rt, "-createdAt") ? DEFAULT_PARSER_SORT : sort;
1460
1070
  const sanitizedSearch = sanitizeSearch(rt, search);
1461
1071
  const { simplePopulate, populateOptions } = parsePopulate(rt, populate);
1462
1072
  const parsed = {
1463
- filters: parseFilters(rt, filters),
1464
- limit: parsedLimit,
1073
+ filters: compileUrlFilters(rt, q),
1074
+ limit: pageRequest.limit,
1465
1075
  sort: parseSort(rt, effectiveSort),
1466
1076
  populate: simplePopulate,
1467
1077
  populateOptions,
1468
1078
  search: sanitizedSearch
1469
1079
  };
1470
1080
  if (sanitizedSearch && rt.options.searchMode === "regex" && rt.options.searchFields) {
1471
- const regexSearchFilters = buildRegexSearch(rt, sanitizedSearch);
1472
- if (regexSearchFilters) {
1473
- if (parsed.filters.$or) {
1474
- parsed.filters = {
1475
- ...parsed.filters,
1476
- $and: [{ $or: parsed.filters.$or }, { $or: regexSearchFilters }]
1477
- };
1478
- delete parsed.filters.$or;
1479
- } else parsed.filters.$or = regexSearchFilters;
1081
+ const regexSearch = buildRegexSearch(rt, sanitizedSearch);
1082
+ if (regexSearch) {
1083
+ parsed.filters = attachCondition(parsed.filters, { $or: regexSearch });
1480
1084
  parsed.search = void 0;
1481
1085
  }
1482
1086
  }
1483
- if (select) parsed.select = parseSelect(select);
1087
+ const groups = compileGroups(rt, q);
1088
+ if (groups.$or) parsed.filters = attachCondition(parsed.filters, { $or: groups.$or });
1089
+ if (groups.$and) parsed.filters = attachCondition(parsed.filters, { $and: groups.$and });
1090
+ const projection = parseSelect(rt, select);
1091
+ if (projection) parsed.select = projection;
1484
1092
  if (rt.options.enableLookups && lookup) parsed.lookups = parseLookups(rt, lookup);
1485
1093
  if (rt.options.enableAggregations && aggregate) parsed.aggregation = parseAggregation(rt, aggregate);
1486
- if (after || cursor) parsed.after = String(after || cursor);
1487
- const parsedPage = parsePositiveInt(rt, page, "page");
1488
- if (parsedPage !== void 0) parsed.page = parsedPage;
1489
- const orGroup = parseOr(rt, query);
1490
- if (orGroup) {
1491
- if (parsed.filters.$or) {
1492
- const existingOr = parsed.filters.$or;
1493
- delete parsed.filters.$or;
1494
- parsed.filters.$and = [{ $or: existingOr }, { $or: orGroup }];
1495
- } else parsed.filters.$or = orGroup;
1496
- }
1497
- parsed.filters = enhanceWithBetween(rt, parsed.filters);
1094
+ if (pageRequest.after !== void 0) parsed.after = pageRequest.after;
1095
+ if (pageRequest.page !== void 0) parsed.page = pageRequest.page;
1498
1096
  return parsed;
1499
1097
  }
1500
1098
  /**
@@ -1515,6 +1113,29 @@ var QueryParser = class {
1515
1113
  return buildOpenAPIQuerySchema(this.rt);
1516
1114
  }
1517
1115
  };
1116
+ /**
1117
+ * AND a top-level condition (`{ $or }` / `{ $and }`) into `filters`: alongside when its key is
1118
+ * free, otherwise both go under one `$and` so neither overwrites the other.
1119
+ */
1120
+ function attachCondition(filters, condition) {
1121
+ const [key] = Object.keys(condition);
1122
+ if (!(key in filters)) return {
1123
+ ...filters,
1124
+ ...condition
1125
+ };
1126
+ const { [key]: existing, ...rest } = filters;
1127
+ const prior = key === "$and" ? existing : [{ [key]: existing }];
1128
+ const next = key === "$and" ? condition.$and : [condition];
1129
+ const and = rest.$and ? [
1130
+ ...rest.$and,
1131
+ ...prior,
1132
+ ...next
1133
+ ] : [...prior, ...next];
1134
+ return {
1135
+ ...rest,
1136
+ $and: and
1137
+ };
1138
+ }
1518
1139
  //#endregion
1519
1140
  //#region src/utils/repo-options.ts
1520
1141
  /**
@@ -1562,31 +1183,21 @@ var QueryParser = class {
1562
1183
  * });
1563
1184
  * ```
1564
1185
  *
1565
- * The fields read (all optional — extracted only when present and not
1566
- * `undefined`):
1186
+ * It is `forwardScope` (see `./scope.ts`), the same rule every internal
1187
+ * hop uses, so a request context and a cascade forward identically:
1567
1188
  *
1568
- * - `organizationId` — multi-tenant scope key. The
1569
- * `multiTenantPlugin` casts to `ObjectId` automatically when
1570
- * configured with `fieldType: 'objectId'`; do NOT pre-cast.
1571
- * - `userId` / `user` — audit attribution (audit-log + audit-trail
1572
- * plugins read these for the `who` column).
1573
- * - `session` — mongoose `ClientSession` for transaction threading.
1574
- * - `requestId` — observability correlation id.
1189
+ * - the tenant (`organizationId`, `tenantId`) — `multiTenantPlugin` casts
1190
+ * to `ObjectId` itself when configured with `fieldType: 'objectId'`;
1191
+ * do NOT pre-cast;
1192
+ * - `bypassTenant`, only when literally `true` AND no tenant is present
1193
+ * (the tenant plugin honours a bypass before a tenant, so carrying both
1194
+ * would widen a scoped call to every tenant);
1195
+ * - `session`, and `user` / `userId` / `requestId` for attribution.
1575
1196
  *
1576
- * Fields beyond this set should be added inline at the call site —
1577
- * `repoOptionsFromCtx` deliberately stays narrow so adding a new
1578
- * convention to the canonical set is a deliberate API decision, not
1579
- * a quiet drift.
1197
+ * Domain fields beyond this set belong to `createOptionsExtractor`.
1580
1198
  */
1581
1199
  function repoOptionsFromCtx(ctx) {
1582
- if (!ctx) return {};
1583
- const out = {};
1584
- if (ctx.organizationId !== void 0) out.organizationId = ctx.organizationId;
1585
- if (ctx.userId !== void 0) out.userId = ctx.userId;
1586
- if (ctx.user !== void 0) out.user = ctx.user;
1587
- if (ctx.session !== void 0) out.session = ctx.session;
1588
- if (ctx.requestId !== void 0) out.requestId = ctx.requestId;
1589
- return out;
1200
+ return forwardScope(ctx);
1590
1201
  }
1591
1202
  /**
1592
1203
  * Build a typed extractor for **consumer-defined** ctx → options
@@ -1608,12 +1219,11 @@ function repoOptionsFromCtx(ctx) {
1608
1219
  * output keys are inferred from the input field tuple — you can
1609
1220
  * destructure with full autocomplete.
1610
1221
  *
1611
- * **Difference from `repoOptionsFromCtx`.** That helper hardcodes the
1612
- * fields **mongokit's bundled plugins** read (`organizationId`,
1613
- * `userId`, `user`, `session`, `requestId`). Use it when those are
1614
- * sufficient. Reach for `createOptionsExtractor` when your domain has
1615
- * its own canonical fields (`actorRef`, `actorKind`, `correlationId`,
1616
- * `idempotencyKey`, `sagaRunId`, ...) that should also forward.
1222
+ * **Scope is always included, and never from `fields`.** The extractor
1223
+ * returns `forwardScope(ctx)` plus the declared DOMAIN fields. Listing a
1224
+ * scope key (`organizationId`, `bypassTenant`, `session`, …) in `fields`
1225
+ * changes nothing: those follow the one scope rule, so an extractor cannot
1226
+ * forget the bypass, or carry a bypass beside a tenant.
1617
1227
  *
1618
1228
  * @example Per-package canonical extractor
1619
1229
  * ```ts
@@ -1628,12 +1238,12 @@ function repoOptionsFromCtx(ctx) {
1628
1238
  * idempotencyKey?: string;
1629
1239
  * };
1630
1240
  *
1241
+ * // Domain fields only: the scope (organizationId, session, bypass) is
1242
+ * // always included by the one scope rule.
1631
1243
  * export const repoOptionsFromCtx = createOptionsExtractor<CommissionCtx>([
1632
- * 'organizationId',
1633
1244
  * 'actorRef',
1634
1245
  * 'actorKind',
1635
1246
  * 'correlationId',
1636
- * 'session',
1637
1247
  * 'idempotencyKey',
1638
1248
  * ]);
1639
1249
  *
@@ -1649,11 +1259,11 @@ function repoOptionsFromCtx(ctx) {
1649
1259
  * options bag never erases inherited values).
1650
1260
  */
1651
1261
  function createOptionsExtractor(fields) {
1652
- const frozen = Object.freeze([...fields]);
1262
+ const domainFields = Object.freeze(fields.filter((field) => !isScopeKey(field)));
1653
1263
  return function extractRepoOptions(ctx) {
1654
1264
  if (!ctx) return {};
1655
- const out = {};
1656
- for (const field of frozen) {
1265
+ const out = forwardScope(ctx);
1266
+ for (const field of domainFields) {
1657
1267
  const v = ctx[field];
1658
1268
  if (v !== void 0) out[field] = v;
1659
1269
  }
@@ -1781,4 +1391,4 @@ function systemContext(extra) {
1781
1391
  */
1782
1392
  var src_default = Repository;
1783
1393
  //#endregion
1784
- export { ALL_OPERATIONS, AggregationBuilder, AuditTrailQuery, HOOK_PRIORITY, LookupBuilder, MONGOKIT_CAPABILITIES, MONGOKIT_PLUGIN_NAMES, MUTATING_OPERATIONS, ModelCollisionError, OP_REGISTRY, PaginationEngine, QueryParser, READ_OPERATIONS, Repository, actions_exports as actions, adminBypass, aggregateHelpersPlugin, appendOnlyPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, capabilitiesForSupport, cascadePlugin, cascadePurgeReferences, classifyHelloReply, compileFilterToMongo, configureLogger, configurePaginationDefaults, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, src_default as default, defineModel, defineModels, elasticSearchPlugin, ensureAuditTrailReady, fieldFilterPlugin, filterResponseData, getFieldsForUser, getMongooseProjection, getNextSequence, getPaginationDefaults, idVariants, idempotencyStorePlugin, immutableField, immutableStatesPlugin, isCreateManyPartialError, isDuplicateKeyError, isModelRegistered, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, probeMongoCapabilities, repoOptionsFromCtx, requireField, resetCapabilityProbeCache, resetPaginationDefaults, resolveMongoCapabilities, resolveTransactionSupport, sequentialId, softDeletePlugin, subdocumentPlugin, supportsTransactions, systemContext, timestampPlugin, toPlain, transactionResolutionOf, uniqueField, validationChainPlugin, withTransaction };
1394
+ export { ALL_OPERATIONS, AggregationBuilder, AuditTrailQuery, HOOK_PRIORITY, LookupBuilder, MONGOKIT_CAPABILITIES, MONGOKIT_PLUGIN_NAMES, MUTATING_OPERATIONS, ModelCollisionError, OP_REGISTRY, PaginationEngine, QueryParser, READ_OPERATIONS, Repository, actions_exports as actions, adminBypass, aggregateHelpersPlugin, appendOnlyPlugin, auditLogPlugin, auditTrailPlugin, autoInject, batchOperationsPlugin, batchTransaction, blockIf, buildCrudSchemasFromModel, buildCrudSchemasFromMongooseSchema, cachePlugin, capabilitiesForSupport, cascadePlugin, cascadePurgeReferences, classifyHelloReply, compileFilterToMongo, configureLogger, configurePaginationDefaults, createError, createFieldPreset, createMemoryCache, createOptionsExtractor, createRepository, createTenantContext, customIdPlugin, dateSequentialId, declareTenantContextKey, src_default as default, defineModel, defineModels, elasticSearchPlugin, ensureAuditTrailReady, ensureSequenceAtLeast, fieldFilterPlugin, filterResponseData, forwardScope, getFieldsForUser, getMongooseProjection, getNextSequence, getPaginationDefaults, idVariants, idempotencyStorePlugin, immutableField, immutableStatesPlugin, isCreateManyPartialError, isDuplicateKeyError, isModelRegistered, isTransactionUnsupported, leasePlugin, methodRegistryPlugin, mongoOperationsPlugin, multiTenantPlugin, observabilityPlugin, operationsByPolicyKey, parseDuplicateKeyError, prefixedId, probeMongoCapabilities, readSequence, repoOptionsFromCtx, requireField, resetCapabilityProbeCache, resetPaginationDefaults, resolveMongoCapabilities, resolveTransactionSupport, sequentialId, softDeletePlugin, subdocumentPlugin, supportsTransactions, systemContext, tenantContextKeysOf, timestampPlugin, toPlain, transactionResolutionOf, uniqueField, validationChainPlugin, withTransaction };