@classytic/repo-core 0.28.0 → 0.30.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 (55) hide show
  1. package/CHANGELOG.md +1290 -1098
  2. package/dist/cache/engine.d.mts +7 -5
  3. package/dist/cache/engine.mjs +34 -16
  4. package/dist/cache/memo.d.mts +4 -0
  5. package/dist/cache/memo.mjs +17 -4
  6. package/dist/cache/memory-adapter.mjs +6 -4
  7. package/dist/cache/plugin/read-hooks.mjs +1 -1
  8. package/dist/cache/stable-stringify.d.mts +7 -8
  9. package/dist/cache/stable-stringify.mjs +14 -11
  10. package/dist/cache/tag-index.mjs +1 -2
  11. package/dist/cache/types.d.mts +4 -3
  12. package/dist/errors/codes.d.mts +117 -0
  13. package/dist/errors/codes.mjs +114 -0
  14. package/dist/errors/contract.d.mts +2 -1
  15. package/dist/errors/contract.mjs +9 -3
  16. package/dist/errors/index.d.mts +3 -2
  17. package/dist/errors/index.mjs +2 -2
  18. package/dist/errors/types.d.mts +3 -30
  19. package/dist/filter/builders.mjs +1 -1
  20. package/dist/filter/match-record.mjs +9 -3
  21. package/dist/filter/match.mjs +25 -9
  22. package/dist/idempotency/index.d.mts +462 -0
  23. package/dist/idempotency/index.mjs +419 -0
  24. package/dist/pagination/types.d.mts +6 -0
  25. package/dist/query-parser/coerce.d.mts +18 -11
  26. package/dist/query-parser/coerce.mjs +37 -69
  27. package/dist/query-parser/errors.d.mts +30 -0
  28. package/dist/query-parser/errors.mjs +32 -0
  29. package/dist/query-parser/grammar.d.mts +107 -0
  30. package/dist/query-parser/grammar.mjs +462 -0
  31. package/dist/query-parser/index.d.mts +5 -2
  32. package/dist/query-parser/index.mjs +5 -2
  33. package/dist/query-parser/parse-url.d.mts +1 -1
  34. package/dist/query-parser/parse-url.mjs +72 -205
  35. package/dist/query-parser/regex-safety.d.mts +31 -0
  36. package/dist/query-parser/regex-safety.mjs +151 -0
  37. package/dist/query-parser/reserved.mjs +1 -0
  38. package/dist/query-parser/types.d.mts +15 -25
  39. package/dist/repository/types.d.mts +6 -5
  40. package/dist/schema/field-rules.mjs +49 -6
  41. package/dist/schema/standard-schema.mjs +1 -1
  42. package/dist/sync/index.d.mts +90 -55
  43. package/dist/sync/index.mjs +0 -0
  44. package/dist/testing/change-log-conformance.d.mts +21 -0
  45. package/dist/testing/change-log-conformance.mjs +204 -0
  46. package/dist/testing/command-stream-conformance.d.mts +15 -0
  47. package/dist/testing/command-stream-conformance.mjs +163 -0
  48. package/dist/testing/idempotency-conformance.d.mts +15 -0
  49. package/dist/testing/idempotency-conformance.mjs +315 -0
  50. package/dist/testing/index.d.mts +5 -1
  51. package/dist/testing/index.mjs +5 -1
  52. package/dist/testing/query-grammar-conformance.d.mts +60 -0
  53. package/dist/testing/query-grammar-conformance.mjs +380 -0
  54. package/package.json +6 -2
  55. package/dist/errors/types.mjs +0 -29
@@ -1,226 +1,93 @@
1
- import { TRUE, and, anyOf as in_, between, contains, endsWith, eq, gt, gte, iEq, isNotNull, isNull, like, lt, lte, ne, nin, regex, startsWith } from "../filter/builders.mjs";
2
- import { coerceList, coerceValue } from "./coerce.mjs";
3
- import { isControlParam } from "./reserved.mjs";
1
+ import { QueryGrammarError } from "./errors.mjs";
2
+ import { clausesToFilter, readFilterClauses, readPageRequest, readSearch, readSelect, readSort, toRecord } from "./grammar.mjs";
4
3
  //#region src/query-parser/parse-url.ts
5
- const DEFAULT_LIMIT = 20;
6
- const DEFAULT_MAX_LIMIT = 200;
7
- const DEFAULT_MAX_DEPTH = 10;
8
- const DEFAULT_MAX_REGEX = 500;
9
- const DEFAULT_MAX_SEARCH = 200;
10
4
  /**
11
- * Hard cap on URL parameter KEY length. Param keys land in regex-based
12
- * bracket parsing (`/^([^[\]]+)\[([^\]]+)\]$/` and friends); without a
13
- * length bound, a hostile caller can submit a 1MB key and force the
14
- * parser to scan the entire string for every regex try. Keys that
15
- * exceed this cap are silently skipped — legitimate URL params don't
16
- * approach this bound.
5
+ * URL → {@link ParsedQuery} with a portable Filter IR. The grammar (keys, operators, coercion,
6
+ * paging) is `./grammar.ts`'s; this adds only the IR emission and the populate spec.
7
+ *
8
+ * ?status=active&age[gte]=18 → and(eq(status), gte(age, 18))
9
+ * ?role[in]=admin,editor → in_(role, [...])
10
+ * ?name[contains]=john&sort=-createdAt → contains(name, 'john') + sort
17
11
  */
18
- const MAX_PARAM_KEY_LENGTH = 256;
19
- const ALL_OPERATORS = /* @__PURE__ */ new Set([
20
- "eq",
21
- "ne",
22
- "gt",
23
- "gte",
24
- "lt",
25
- "lte",
26
- "in",
27
- "nin",
28
- "like",
29
- "contains",
30
- "startsWith",
31
- "endsWith",
32
- "ieq",
33
- "regex",
34
- "between",
35
- "exists"
36
- ]);
37
- /** Parse URL search params into a driver-agnostic ParsedQuery. */
12
+ const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*$/;
13
+ /** Parse URL search params into a driver-agnostic ParsedQuery. Invalid input throws a 400. */
38
14
  function parseUrl(input, options = {}) {
39
- const params = normalize(input);
40
- const maxLimit = options.maxLimit ?? DEFAULT_MAX_LIMIT;
41
- const allowedOps = options.allowedOperators ? new Set(options.allowedOperators) : ALL_OPERATORS;
42
- const rawPage = params.get("page");
43
- const rawLimit = params.get("limit");
44
- const after = params.get("after") ?? void 0;
45
- const limit = clampLimit(rawLimit, options.defaultLimit ?? DEFAULT_LIMIT, maxLimit);
46
- const page = rawPage !== null && rawPage !== void 0 ? toPositiveInt(rawPage) : void 0;
47
- const sort = parseSort(params.get("sort"), options.allowedSortFields);
48
- const select = parseSelect(params.get("select"));
49
- const populate = parsePopulate(params);
50
- const rawSearch = params.get("search");
51
- const searchCap = options.maxSearchLength ?? DEFAULT_MAX_SEARCH;
52
- const search = rawSearch !== null && rawSearch !== void 0 && rawSearch.length > 0 ? rawSearch.slice(0, searchCap) : void 0;
15
+ const record = toRecord(input);
16
+ const pageRequest = readPageRequest({
17
+ page: first(record["page"]),
18
+ limit: first(record["limit"]),
19
+ after: first(record["after"])
20
+ }, {
21
+ defaultLimit: options.defaultLimit,
22
+ maxLimit: options.maxLimit
23
+ });
24
+ const { clauses } = readFilterClauses(record, {
25
+ allowedFilterFields: options.allowedFilterFields,
26
+ allowedOperators: options.allowedOperators,
27
+ fieldTypes: options.fieldTypes,
28
+ maxTextLength: options.maxRegexLength ?? 500
29
+ });
53
30
  const result = {
54
- filter: parseFilters(params, {
55
- allowedFields: options.allowedFilterFields,
56
- allowedOps,
57
- fieldTypes: options.fieldTypes,
58
- maxDepth: options.maxFilterDepth ?? DEFAULT_MAX_DEPTH,
59
- maxRegex: options.maxRegexLength ?? DEFAULT_MAX_REGEX
60
- }),
61
- limit
31
+ filter: clausesToFilter(clauses),
32
+ limit: pageRequest.limit
62
33
  };
34
+ const sort = readSort(record["sort"], { allowedSortFields: options.allowedSortFields });
63
35
  if (sort) result.sort = sort;
36
+ const select = readSelect(record["select"]);
64
37
  if (select) result.select = select;
38
+ const populate = readPopulate(record);
65
39
  if (populate.length > 0) result.populate = populate;
66
- if (page !== void 0) result.page = page;
67
- if (after !== void 0) result.after = after;
40
+ if (pageRequest.page !== void 0) result.page = pageRequest.page;
41
+ if (pageRequest.after !== void 0) result.after = pageRequest.after;
42
+ const search = readSearch(first(record["search"]), { maxSearchLength: options.maxSearchLength });
68
43
  if (search !== void 0) result.search = search;
69
44
  return result;
70
45
  }
71
- function normalize(input) {
72
- if (input instanceof URLSearchParams) return {
73
- get: (k) => input.get(k),
74
- entries: () => input.entries(),
75
- has: (k) => input.has(k)
76
- };
77
- if (Symbol.iterator in input) {
78
- const usp = new URLSearchParams();
79
- for (const [k, v] of input) usp.append(k, v);
80
- return {
81
- get: (k) => usp.get(k),
82
- entries: () => usp.entries(),
83
- has: (k) => usp.has(k)
84
- };
85
- }
86
- const record = input;
87
- const usp = new URLSearchParams();
88
- for (const [k, v] of Object.entries(record)) {
89
- if (v === void 0) continue;
90
- if (Array.isArray(v)) for (const item of v) usp.append(k, item);
91
- else usp.append(k, v);
92
- }
93
- return {
94
- get: (k) => usp.get(k),
95
- entries: () => usp.entries(),
96
- has: (k) => usp.has(k)
97
- };
98
- }
99
- function clampLimit(raw, fallback, max) {
100
- if (raw === null || raw === void 0) return fallback;
101
- const n = Number(raw);
102
- if (!Number.isFinite(n) || n < 1) return fallback;
103
- return Math.min(Math.floor(n), max);
104
- }
105
- function toPositiveInt(raw) {
106
- const n = Number(raw);
107
- if (!Number.isFinite(n) || n < 1) return void 0;
108
- return Math.floor(n);
109
- }
110
- function parseSort(raw, allowed) {
111
- if (!raw) return void 0;
112
- const spec = {};
113
- for (const piece of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
114
- const desc = piece.startsWith("-");
115
- const field = desc ? piece.slice(1) : piece.startsWith("+") ? piece.slice(1) : piece;
116
- if (allowed && !allowed.includes(field)) continue;
117
- spec[field] = desc ? -1 : 1;
118
- }
119
- return Object.keys(spec).length > 0 ? spec : void 0;
120
- }
121
- function parseSelect(raw) {
122
- if (!raw) return void 0;
123
- const spec = {};
124
- for (const piece of raw.split(",").map((s) => s.trim()).filter(Boolean)) if (piece.startsWith("-")) spec[piece.slice(1)] = 0;
125
- else spec[piece] = 1;
126
- return Object.keys(spec).length > 0 ? spec : void 0;
46
+ /** A repeated control param (`?limit=5&limit=9`) is ambiguous — refused rather than guessed. */
47
+ function first(value) {
48
+ if (!Array.isArray(value)) return value;
49
+ if (value.length > 1) throw new QueryGrammarError("query", "a control parameter is repeated");
50
+ return value[0];
127
51
  }
128
52
  /**
129
- * Parse `populate[field][select]=...&populate[field][match][other]=...` into
130
- * an array of ParsedPopulate specs. Flat iteration keeps the parser simple;
131
- * nested populate (`populate[author][populate][org][select]=...`) is supported
132
- * one level deep.
53
+ * `populate[path][select]=a,b` and `populate[path][match][field]=value` (field equality only —
54
+ * a `$` key or a nested condition is refused, never passed to the kit).
133
55
  */
134
- function parsePopulate(params) {
135
- const byField = /* @__PURE__ */ new Map();
136
- for (const [key, value] of params.entries()) {
137
- if (key.length > MAX_PARAM_KEY_LENGTH) continue;
138
- if (!key.startsWith("populate[")) continue;
139
- const match = /^populate\[([^\]]+)\](?:\[([^\]]+)\](?:\[([^\]]+)\])?)?$/.exec(key);
140
- if (!match) continue;
141
- const [, field, sub, subKey] = match;
142
- if (!field) continue;
143
- const existing = byField.get(field) ?? {};
144
- if (!sub) byField.set(field, existing);
145
- else if (sub === "select") {
146
- existing.select = value;
147
- byField.set(field, existing);
148
- } else if (sub === "match" && subKey) {
149
- existing.match = existing.match ?? {};
150
- existing.match[subKey] = value;
151
- byField.set(field, existing);
152
- }
153
- }
154
- const out = [];
155
- for (const [path, spec] of byField) {
156
- const entry = { path };
157
- if (spec.select !== void 0) entry.select = spec.select;
158
- if (spec.match) entry.match = spec.match;
159
- out.push(entry);
160
- }
161
- return out;
162
- }
163
- function parseFilters(params, ctx) {
164
- const leaves = [];
165
- const fieldGroups = /* @__PURE__ */ new Map();
166
- for (const [key, rawValue] of params.entries()) {
167
- if (key.length > MAX_PARAM_KEY_LENGTH) continue;
168
- if (isControlParam(key) || key.startsWith("populate[")) continue;
169
- const bracket = /^([^[\]]+)\[([^\]]+)\]$/.exec(key);
170
- let field;
171
- let op;
172
- if (bracket) {
173
- const [, f, o] = bracket;
174
- if (!f || !o) continue;
175
- field = f;
176
- op = o;
177
- } else {
178
- field = key;
179
- op = "eq";
56
+ function readPopulate(record) {
57
+ const byPath = /* @__PURE__ */ new Map();
58
+ const entry = (path) => {
59
+ if (!FIELD_RE.test(path)) throw new QueryGrammarError(`populate[${path}]`, "not a field path", "syntax");
60
+ const existing = byPath.get(path) ?? { path };
61
+ byPath.set(path, existing);
62
+ return existing;
63
+ };
64
+ for (const [key, value] of Object.entries(record)) {
65
+ if (key === "populate" && value !== null && typeof value === "object" && !Array.isArray(value)) {
66
+ for (const [path, spec] of Object.entries(value)) {
67
+ const target = entry(path);
68
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec)) continue;
69
+ const { select, match } = spec;
70
+ if (typeof select === "string") target.select = select;
71
+ if (match !== void 0) target.match = readMatch(path, match);
72
+ }
73
+ continue;
180
74
  }
181
- if (ctx.allowedFields && !ctx.allowedFields.includes(field)) continue;
182
- if (!ctx.allowedOps.has(op)) continue;
183
- const fieldType = ctx.fieldTypes?.[field];
184
- const leaf = buildLeaf(field, op, rawValue, fieldType, ctx);
185
- if (!leaf) continue;
186
- const bucket = fieldGroups.get(field) ?? [];
187
- bucket.push(leaf);
188
- fieldGroups.set(field, bucket);
75
+ const flat = /^populate\[([^\]]+)\](?:\[(select|match)\](?:\[([^\]]+)\])?)?$/.exec(key);
76
+ if (!flat) continue;
77
+ const [, path, sub, matchField] = flat;
78
+ const target = entry(path);
79
+ if (sub === "select") target.select = String(value);
80
+ if (sub === "match" && matchField) target.match = {
81
+ ...target.match,
82
+ ...readMatch(path, { [matchField]: value })
83
+ };
189
84
  }
190
- for (const [, nodes] of fieldGroups) if (nodes.length === 1) leaves.push(nodes[0]);
191
- else leaves.push(and(...nodes));
192
- if (leaves.length === 0) return TRUE;
193
- if (leaves.length === 1) return leaves[0];
194
- return and(...leaves);
85
+ return [...byPath.values()];
195
86
  }
196
- function buildLeaf(field, op, rawValue, fieldType, ctx) {
197
- switch (op) {
198
- case "eq": return eq(field, coerceValue(rawValue, fieldType));
199
- case "ne": return ne(field, coerceValue(rawValue, fieldType));
200
- case "gt": return gt(field, coerceValue(rawValue, fieldType));
201
- case "gte": return gte(field, coerceValue(rawValue, fieldType));
202
- case "lt": return lt(field, coerceValue(rawValue, fieldType));
203
- case "lte": return lte(field, coerceValue(rawValue, fieldType));
204
- case "in": return in_(field, coerceList(rawValue, fieldType));
205
- case "nin": return nin(field, coerceList(rawValue, fieldType));
206
- case "like": return like(field, rawValue);
207
- case "contains": return contains(field, rawValue);
208
- case "startsWith": return startsWith(field, rawValue);
209
- case "endsWith": return endsWith(field, rawValue);
210
- case "ieq": return iEq(field, rawValue);
211
- case "regex":
212
- if (rawValue.length > ctx.maxRegex) return void 0;
213
- return regex(field, rawValue);
214
- case "between": {
215
- const parts = coerceList(rawValue, fieldType);
216
- if (parts.length < 2) return void 0;
217
- return between(field, parts[0], parts[1]);
218
- }
219
- case "exists": {
220
- const val = rawValue.toLowerCase();
221
- return val === "true" || val === "1" ? isNotNull(field) : isNull(field);
222
- }
223
- }
87
+ function readMatch(path, match) {
88
+ if (match === null || typeof match !== "object" || Array.isArray(match)) throw new QueryGrammarError(`populate[${path}][match]`, "use populate[path][match][field]=value", "syntax");
89
+ for (const [field, value] of Object.entries(match)) if (!FIELD_RE.test(field) || value !== null && typeof value === "object") throw new QueryGrammarError(`populate[${path}][match]`, `field equality only: "${field}"`, "syntax");
90
+ return match;
224
91
  }
225
92
  //#endregion
226
93
  export { parseUrl };
@@ -0,0 +1,31 @@
1
+ //#region src/query-parser/regex-safety.d.ts
2
+ /**
3
+ * ReDoS assessment for a user-supplied regex — the ONE analyser every parser uses.
4
+ *
5
+ * STRUCTURAL, not a regex over the regex: the pattern is walked token by token with a group stack,
6
+ * so nesting is seen however it is spelled. A pattern is unsafe when:
7
+ * - a group REPEATED by `*`, `+` or `{n,}` / `{n,m}` (m > 1) contains a repetition or an
8
+ * alternation — `(a+)+`, `(a*)*`, `(.*)*`, `(a|aa)+`, `((ab)*)+` (exponential backtracking);
9
+ * - it uses a backreference (`\1`, `\k<name>`) — matching becomes NP-hard;
10
+ * - it carries more than {@link MAX_UNBOUNDED_QUANTIFIERS} unbounded quantifiers
11
+ * (`.*.*.*…` — polynomial, but degree grows with each);
12
+ * - it exceeds the complexity budget: more than 8 groups, more than 10 alternations, or
13
+ * unbounded quantifiers × groups above 40 (`(a*)(b+)(c*)…` — dense, overlapping, polynomial);
14
+ * - it is not valid syntax.
15
+ * Conservative on purpose: `(foo|bar)+` is refused too. A user regex runs on a shared database;
16
+ * refusing a rare legitimate pattern costs a 400, accepting a hostile one costs the server.
17
+ */
18
+ declare const MAX_UNBOUNDED_QUANTIFIERS = 20;
19
+ type RegexRisk = {
20
+ readonly safe: true;
21
+ } | {
22
+ readonly safe: false;
23
+ readonly reason: 'nested-quantifier' | 'backreference' | 'too-many-quantifiers' | 'too-complex' | 'invalid' | 'too-long';
24
+ };
25
+ declare function assessRegex(pattern: string, options?: {
26
+ maxLength?: number;
27
+ }): RegexRisk;
28
+ /** Escape every regex metacharacter — the literal-match fallback for an unsafe pattern. */
29
+ declare function escapeRegex(value: string): string;
30
+ //#endregion
31
+ export { MAX_UNBOUNDED_QUANTIFIERS, RegexRisk, assessRegex, escapeRegex };
@@ -0,0 +1,151 @@
1
+ //#region src/query-parser/regex-safety.ts
2
+ /**
3
+ * ReDoS assessment for a user-supplied regex — the ONE analyser every parser uses.
4
+ *
5
+ * STRUCTURAL, not a regex over the regex: the pattern is walked token by token with a group stack,
6
+ * so nesting is seen however it is spelled. A pattern is unsafe when:
7
+ * - a group REPEATED by `*`, `+` or `{n,}` / `{n,m}` (m > 1) contains a repetition or an
8
+ * alternation — `(a+)+`, `(a*)*`, `(.*)*`, `(a|aa)+`, `((ab)*)+` (exponential backtracking);
9
+ * - it uses a backreference (`\1`, `\k<name>`) — matching becomes NP-hard;
10
+ * - it carries more than {@link MAX_UNBOUNDED_QUANTIFIERS} unbounded quantifiers
11
+ * (`.*.*.*…` — polynomial, but degree grows with each);
12
+ * - it exceeds the complexity budget: more than 8 groups, more than 10 alternations, or
13
+ * unbounded quantifiers × groups above 40 (`(a*)(b+)(c*)…` — dense, overlapping, polynomial);
14
+ * - it is not valid syntax.
15
+ * Conservative on purpose: `(foo|bar)+` is refused too. A user regex runs on a shared database;
16
+ * refusing a rare legitimate pattern costs a 400, accepting a hostile one costs the server.
17
+ */
18
+ const MAX_UNBOUNDED_QUANTIFIERS = 20;
19
+ /**
20
+ * The quantifier token at `i`: its length, whether it repeats (max > 1), whether the count is
21
+ * VARIABLE (min ≠ max — the source of backtracking ambiguity; `{2}` is not) and whether it is unbounded.
22
+ */
23
+ function readQuantifier(p, i) {
24
+ const c = p[i];
25
+ if (c === "*" || c === "+") return {
26
+ length: 1,
27
+ repeats: true,
28
+ variable: true,
29
+ unbounded: true
30
+ };
31
+ if (c === "?") return {
32
+ length: 1,
33
+ repeats: false,
34
+ variable: true,
35
+ unbounded: false
36
+ };
37
+ if (c !== "{") return null;
38
+ const m = /^\{(\d+)(,(\d*))?\}/.exec(p.slice(i));
39
+ if (!m) return null;
40
+ const min = Number(m[1]);
41
+ const max = m[2] !== void 0 ? m[3] === "" ? Number.POSITIVE_INFINITY : Number(m[3]) : min;
42
+ return {
43
+ length: m[0].length,
44
+ repeats: max > 1,
45
+ variable: min !== max,
46
+ unbounded: max === Number.POSITIVE_INFINITY
47
+ };
48
+ }
49
+ function assessRegex(pattern, options = {}) {
50
+ if (options.maxLength !== void 0 && pattern.length > options.maxLength) return {
51
+ safe: false,
52
+ reason: "too-long"
53
+ };
54
+ try {
55
+ new RegExp(pattern);
56
+ } catch {
57
+ return {
58
+ safe: false,
59
+ reason: "invalid"
60
+ };
61
+ }
62
+ const stack = [{
63
+ repeats: false,
64
+ alternates: false
65
+ }];
66
+ /** The group that just closed — the atom a following quantifier applies to. */
67
+ let closedGroup = null;
68
+ let unbounded = 0;
69
+ let groups = 0;
70
+ let alternations = 0;
71
+ for (let i = 0; i < pattern.length; i++) {
72
+ const c = pattern[i];
73
+ const top = stack[stack.length - 1];
74
+ if (c === "\\") {
75
+ const next = pattern[i + 1] ?? "";
76
+ if (/[1-9]/.test(next) || next === "k") return {
77
+ safe: false,
78
+ reason: "backreference"
79
+ };
80
+ i += 1;
81
+ closedGroup = null;
82
+ continue;
83
+ }
84
+ if (c === "[") {
85
+ let j = i + 1;
86
+ if (pattern[j] === "^") j++;
87
+ if (pattern[j] === "]") j++;
88
+ while (j < pattern.length && pattern[j] !== "]") j += pattern[j] === "\\" ? 2 : 1;
89
+ i = j;
90
+ closedGroup = null;
91
+ continue;
92
+ }
93
+ if (c === "(") {
94
+ groups += 1;
95
+ stack.push({
96
+ repeats: false,
97
+ alternates: false
98
+ });
99
+ if (pattern[i + 1] === "?") {
100
+ const kind = /^\?(?:[:=!]|<[=!]|<[A-Za-z_][A-Za-z0-9_]*>)/.exec(pattern.slice(i + 1));
101
+ if (kind) i += kind[0].length;
102
+ }
103
+ closedGroup = null;
104
+ continue;
105
+ }
106
+ if (c === ")") {
107
+ const frame = stack.pop();
108
+ const parent = stack[stack.length - 1];
109
+ if (frame.repeats) parent.repeats = true;
110
+ closedGroup = frame;
111
+ continue;
112
+ }
113
+ if (c === "|") {
114
+ alternations += 1;
115
+ top.alternates = true;
116
+ closedGroup = null;
117
+ continue;
118
+ }
119
+ const q = readQuantifier(pattern, i);
120
+ if (q) {
121
+ if (q.unbounded) unbounded += 1;
122
+ if (q.repeats) {
123
+ if (closedGroup && (closedGroup.repeats || closedGroup.alternates)) return {
124
+ safe: false,
125
+ reason: "nested-quantifier"
126
+ };
127
+ }
128
+ if (q.repeats && q.variable) top.repeats = true;
129
+ i += q.length - 1;
130
+ if (pattern[i + 1] === "?" || pattern[i + 1] === "+") i += 1;
131
+ closedGroup = null;
132
+ continue;
133
+ }
134
+ closedGroup = null;
135
+ }
136
+ if (unbounded > 20) return {
137
+ safe: false,
138
+ reason: "too-many-quantifiers"
139
+ };
140
+ if (groups > 8 || alternations > 10 || unbounded * groups > 40) return {
141
+ safe: false,
142
+ reason: "too-complex"
143
+ };
144
+ return { safe: true };
145
+ }
146
+ /** Escape every regex metacharacter — the literal-match fallback for an unsafe pattern. */
147
+ function escapeRegex(value) {
148
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ }
150
+ //#endregion
151
+ export { MAX_UNBOUNDED_QUANTIFIERS, assessRegex, escapeRegex };
@@ -31,6 +31,7 @@ const STANDARD_RESERVED_PARAMS = /* @__PURE__ */ new Set([
31
31
  "select",
32
32
  "populate",
33
33
  "search",
34
+ "filter",
34
35
  "_count",
35
36
  "_distinct",
36
37
  "_exists"
@@ -1,4 +1,5 @@
1
1
  import { Filter } from "../filter/types.mjs";
2
+ import { QueryFieldType } from "./coerce.mjs";
2
3
  //#region src/query-parser/types.d.ts
3
4
  /** Sort direction on a single field. */
4
5
  type ParsedSortDirection = 1 | -1;
@@ -51,47 +52,36 @@ interface ParsedQuery {
51
52
  /** Free-text search term (kits interpret per backend — $text, FTS, etc.). */
52
53
  search?: string;
53
54
  }
54
- /**
55
- * Configuration knobs for the parser. All optional — sane defaults cover
56
- * typical arc use cases.
57
- */
55
+ /** Parser knobs. Every allowlist REFUSES (400) what it does not name — never drops it. */
58
56
  interface QueryParserOptions {
59
- /** Default per-page count when the URL omits `limit`. Default: 20. */
57
+ /** Per-page count when the URL omits `limit`. Default: 20. */
60
58
  defaultLimit?: number;
61
- /** Hard cap on `limit` to prevent resource exhaustion. Default: 200. */
59
+ /** Cap on `limit`; a larger request is clamped to it. Default: 1000. */
62
60
  maxLimit?: number;
63
- /** Allowlist of filter field names. When set, unknown fields are dropped. */
61
+ /** Filterable fields. */
64
62
  allowedFilterFields?: readonly string[];
65
- /** Allowlist of sort field names. When set, unknown fields are dropped. */
63
+ /** Sortable fields. */
66
64
  allowedSortFields?: readonly string[];
67
- /** Allowlist of operator names accepted in bracket syntax. */
65
+ /** URL operator names permitted in `field[op]`. */
68
66
  allowedOperators?: readonly BracketOperator[];
69
- /** Max filter nesting depth (defends against filter-bomb URLs). Default: 10. */
70
- maxFilterDepth?: number;
71
- /** Regex pattern length cap (ReDoS defense). Default: 500. */
67
+ /** Longest text-operator value or regex pattern. Default: 500. */
72
68
  maxRegexLength?: number;
73
- /** Search query length cap. Default: 200. */
69
+ /** Longest `search`. Default: 200. */
74
70
  maxSearchLength?: number;
75
71
  /**
76
- * Field-type hints for value coercion. When a filter field is in this
77
- * map, the URL string value is coerced to the declared type:
78
- * - `'number'` → `Number(value)`
79
- * - `'boolean'` → `'true'`/`'1'` → true, else false
80
- * - `'date'` → `new Date(value)` (validated)
81
- * - `'string'` → left as-is (default for unlisted fields)
82
- *
83
- * Use this to avoid the heuristic coercion's footguns (e.g. `?sku=12345`
84
- * against a string SKU column — without a hint, heuristics turn it into
85
- * a number and the SQL comparison fails).
72
+ * Declared field types — exact coercion, and a value that does not fit is refused. Without one,
73
+ * the grammar's conservative heuristic applies (see `coerceQueryValue`).
86
74
  */
87
- fieldTypes?: Record<string, 'string' | 'number' | 'boolean' | 'date'>;
75
+ fieldTypes?: Record<string, QueryFieldType>;
88
76
  }
89
77
  /**
90
78
  * Bracket operators accepted in URL syntax. When you see `field[op]=value`
91
79
  * in a URL, `op` is one of these. Kept as a closed set — new operators
92
80
  * require a new version.
93
81
  */
94
- type BracketOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'contains' | 'startsWith' | 'endsWith' | 'ieq' | 'regex' | 'between' | 'exists';
82
+ type BracketOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'like' | 'contains' | 'startsWith' | 'endsWith' | 'ieq' | 'regex' | 'between' | 'exists' |
83
+ /** Flags for the `regex` on the same field (`i`, `m`, `s`, `x`). */
84
+ 'options';
95
85
  /**
96
86
  * Input shape for `parseUrl`. URL search params can be sourced from
97
87
  * `URLSearchParams.entries()`, Fastify's `request.query`, or
@@ -844,7 +844,7 @@ type AggCacheOptions = CacheOptions;
844
844
  * | --------------- | ------------------------------ | ------------------------ | ----------- |
845
845
  * | `allowDiskUse` | `aggregate({ allowDiskUse })` | ignored (planner spills) | per-driver |
846
846
  * | `maxTimeMs` | `aggregate({ maxTimeMS })` | ignored (sync driver) | per-driver |
847
- * | `indexHint` | `aggregate({ hint })` | ignored (planner-driven) | per-driver |
847
+ * | `indexHint` | `{ leadingKeys }` checked; else `hint` | ignored (planner-driven) | per-driver |
848
848
  *
849
849
  * **Hosts that need a hint to work** must pin the kit version that
850
850
  * supports it via peer-deps. Falling back to "ignored" keeps mixed-
@@ -874,10 +874,11 @@ interface AggExecutionHints {
874
874
  */
875
875
  maxTimeMs?: number;
876
876
  /**
877
- * Index hint passed to the planner. Kit-specific shape:
878
- * mongokit accepts `{ field: 1 }` or an index name string;
879
- * sqlitekit ignores (the SQLite planner picks indexes itself).
880
- * Use sparingly — most aggregations plan correctly without hints.
877
+ * Index hint. The PORTABLE form is `{ leadingKeys: string[] }` — an expectation that an index
878
+ * leading with those fields serves this read. It is never forced: mongokit checks a declared
879
+ * index matches (and throws a configuration error naming what is declared if none does); kits
880
+ * whose planner picks indexes itself (sqlitekit) ignore it. Any other shape is kit-specific and
881
+ * FORCED — mongokit sends a `{ field: 1 }` object or an index name as the driver `hint`.
881
882
  */
882
883
  indexHint?: unknown;
883
884
  }
@@ -158,17 +158,60 @@ function mergeFieldRuleConstraints(schemas, schemaOptions) {
158
158
  for (const [field, rule] of Object.entries(rules)) {
159
159
  const prop = properties[field];
160
160
  if (!prop || typeof prop !== "object") continue;
161
- if (rule.minLength != null && prop["minLength"] == null) prop["minLength"] = rule.minLength;
162
- if (rule.maxLength != null && prop["maxLength"] == null) prop["maxLength"] = rule.maxLength;
163
- if (rule.min != null && prop["minimum"] == null) prop["minimum"] = rule.min;
164
- if (rule.max != null && prop["maximum"] == null) prop["maximum"] = rule.max;
165
- if (rule.pattern != null && prop["pattern"] == null) prop["pattern"] = rule.pattern;
166
- if (rule.enum != null && prop["enum"] == null) prop["enum"] = rule.enum;
161
+ for (const target of elementSchemas(prop)) {
162
+ if (rule.minLength != null && target["minLength"] == null) target["minLength"] = rule.minLength;
163
+ if (rule.maxLength != null && target["maxLength"] == null) target["maxLength"] = rule.maxLength;
164
+ if (rule.min != null && target["minimum"] == null) target["minimum"] = rule.min;
165
+ if (rule.max != null && target["maximum"] == null) target["maximum"] = rule.max;
166
+ if (rule.pattern != null && target["pattern"] == null) target["pattern"] = rule.pattern;
167
+ if (rule.enum != null && target["enum"] == null) target["enum"] = rule.enum;
168
+ }
167
169
  if (rule.description != null && prop["description"] == null) prop["description"] = rule.description;
168
170
  if (rule.nullable === true) applyNullable(prop);
169
171
  }
170
172
  }
171
173
  }
174
+ /** Is this property typed as an array — `'array'` or a tuple that includes it? */
175
+ function isArrayTyped(prop) {
176
+ const type = prop["type"];
177
+ return type === "array" || Array.isArray(type) && type.includes("array");
178
+ }
179
+ /**
180
+ * Where a VALUE constraint (`enum`, `pattern`, `minLength`, …) belongs.
181
+ *
182
+ * On a scalar property: the property. On an array property: every ELEMENT
183
+ * schema, never the array.
184
+ *
185
+ * Writing them onto the array is not merely redundant, it is wrong in two
186
+ * different directions depending on the keyword, and neither one throws:
187
+ *
188
+ * - `enum` on an array compares the WHOLE array to each member, which a list
189
+ * of strings can never equal — so every write is refused, including one that
190
+ * omits the field, and the error names a field the caller did not send.
191
+ * - `pattern`, `minLength`, `maxLength`, `minimum`, `maximum` are string /
192
+ * number keywords, and JSON Schema ignores them on an array — so the rule
193
+ * validates NOTHING while reading as enforced.
194
+ *
195
+ * `min`/`max` therefore bound each element's value, not the item count. That
196
+ * keeps one meaning per keyword; reinterpreting them as `minItems` on arrays
197
+ * only would make a rule's meaning depend on a type the author never wrote.
198
+ *
199
+ * A property with no `items` gets one, so the constraint is never dropped. A
200
+ * tuple-form `items` (an array of schemas) constrains every position.
201
+ */
202
+ function elementSchemas(prop) {
203
+ if (Array.isArray(prop["anyOf"])) {
204
+ const branches = prop["anyOf"].filter((b) => b !== null && typeof b === "object" && isArrayTyped(b));
205
+ if (branches.length > 0) return branches.flatMap((b) => elementSchemas(b));
206
+ }
207
+ if (!isArrayTyped(prop)) return [prop];
208
+ const items = prop["items"];
209
+ if (Array.isArray(items)) return items.filter((s) => s !== null && typeof s === "object");
210
+ if (items && typeof items === "object") return [items];
211
+ const created = {};
212
+ prop["items"] = created;
213
+ return [created];
214
+ }
172
215
  /**
173
216
  * Widen a JSON Schema property to also accept `null`.
174
217
  *