@classytic/repo-core 0.27.1 → 0.29.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 +1263 -1014
  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/context.mjs +3 -1
  8. package/dist/cache/plugin/read-hooks.mjs +1 -1
  9. package/dist/cache/stable-stringify.d.mts +7 -8
  10. package/dist/cache/stable-stringify.mjs +14 -11
  11. package/dist/cache/tag-index.mjs +1 -2
  12. package/dist/cache/types.d.mts +4 -3
  13. package/dist/errors/codes.d.mts +117 -0
  14. package/dist/errors/codes.mjs +114 -0
  15. package/dist/errors/contract.d.mts +2 -1
  16. package/dist/errors/contract.mjs +9 -3
  17. package/dist/errors/index.d.mts +3 -2
  18. package/dist/errors/index.mjs +2 -2
  19. package/dist/errors/types.d.mts +3 -30
  20. package/dist/filter/builders.mjs +1 -1
  21. package/dist/filter/match-record.mjs +9 -3
  22. package/dist/filter/match.mjs +25 -9
  23. package/dist/idempotency/index.d.mts +462 -0
  24. package/dist/idempotency/index.mjs +419 -0
  25. package/dist/pagination/keyset.mjs +0 -2
  26. package/dist/pagination/types.d.mts +18 -0
  27. package/dist/query-parser/coerce.d.mts +18 -11
  28. package/dist/query-parser/coerce.mjs +37 -69
  29. package/dist/query-parser/errors.d.mts +30 -0
  30. package/dist/query-parser/errors.mjs +32 -0
  31. package/dist/query-parser/grammar.d.mts +107 -0
  32. package/dist/query-parser/grammar.mjs +462 -0
  33. package/dist/query-parser/index.d.mts +5 -2
  34. package/dist/query-parser/index.mjs +5 -2
  35. package/dist/query-parser/parse-url.d.mts +1 -1
  36. package/dist/query-parser/parse-url.mjs +72 -205
  37. package/dist/query-parser/regex-safety.d.mts +31 -0
  38. package/dist/query-parser/regex-safety.mjs +151 -0
  39. package/dist/query-parser/reserved.mjs +1 -0
  40. package/dist/query-parser/types.d.mts +15 -25
  41. package/dist/repository/index.d.mts +2 -2
  42. package/dist/repository/index.mjs +2 -2
  43. package/dist/repository/resilience.d.mts +25 -20
  44. package/dist/repository/resilience.mjs +66 -2
  45. package/dist/repository/types.d.mts +105 -6
  46. package/dist/schema/field-rules.mjs +49 -6
  47. package/dist/schema/standard-schema.mjs +1 -1
  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 +3 -1
  51. package/dist/testing/index.mjs +3 -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
@@ -1,7 +1,7 @@
1
1
  import { LookupPopulateOptions, LookupPopulateResult, LookupRow, LookupSpec } from "../lookup/types.mjs";
2
2
  import { UpdateInput } from "../update/types.mjs";
3
3
  import { nestDottedKeys, nestDottedKeysAll } from "./agg-output.mjs";
4
- import { RetryPolicy, throwIfAborted, withRetry } from "./resilience.mjs";
4
+ import { RetryPolicy, isTransientError, throwIfAborted, withRetry } from "./resilience.mjs";
5
5
  import { ArchiveOptions, ArchivePort, ArchiveProgress, ArchiveResult, ArchiveSink, runChunkedArchive } from "./archive.mjs";
6
6
  import { PLUGIN_ORDER_CONSTRAINTS, Plugin, PluginFunction, PluginType, validatePluginOrder } from "./plugin-types.mjs";
7
7
  import { RepositoryBase, RepositoryBaseOptions } from "./base.mjs";
@@ -12,4 +12,4 @@ import { STANDARD_REPO_OPTION_KEYS, StandardRepoOptionKey } from "./options.mjs"
12
12
  import { PurgePort, WritingPurgeStrategy, runChunkedPurge } from "./purge.mjs";
13
13
  import { ReadOnlyRepoOptions, ReadOnlyRepositoryError, asReadOnlyRepo, isReadOnlyRepo } from "./read-only.mjs";
14
14
  import { RetryingTransactionOptions, retryingTransaction } from "./retrying-transaction.mjs";
15
- export { type AggCacheOptions, type AggDateBucket, type AggDateBucketInterval, type AggDateBucketUnit, type AggExecutionHints, type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type AggTopN, type AggTopNTies, type AggregateOpsSupport, type ArchiveOptions, type ArchivePort, type ArchiveProgress, type ArchiveResult, type ArchiveSink, type BulkCreateResult, type BulkWriteOperation, type BulkWriteResult, type ChangeEvent, type ClaimTransition, type ClaimVersionTransition, type CursorOptions, type DeleteManyResult, type DeleteOptions, type DeleteResult, type DistributionConfig, type DistributionMissHandler, type FilterInput, type FindAllOptions, type FindOneAndUpdateOptions, type InferDoc, type KeysetAggPaginationResult, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type PurgePort, type QueryOptions, type ReadOnlyRepoOptions, ReadOnlyRepositoryError, type RepoCapabilities, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, type RetryPolicy, type RetryingTransactionOptions, STANDARD_REPO_OPTION_KEYS, type StandardRepo, type StandardRepoOptionKey, type TenantPurgeOptions, type TenantPurgeProgress, type TenantPurgeResult, type TenantPurgeStrategy, type TransactionHandle, type TransitionArgs, type TransitionMachine, type UpdateInput, type UpdateManyResult, type WatchOptions, type WriteOptions, type WritingPurgeStrategy, asReadOnlyRepo, createDistributionGuard, filterReferencesKey, isReadOnlyRepo, nestDottedKeys, nestDottedKeysAll, retryingTransaction, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
15
+ export { type AggCacheOptions, type AggDateBucket, type AggDateBucketInterval, type AggDateBucketUnit, type AggExecutionHints, type AggMeasure, type AggPaginationRequest, type AggRequest, type AggResult, type AggRow, type AggTopN, type AggTopNTies, type AggregateOpsSupport, type ArchiveOptions, type ArchivePort, type ArchiveProgress, type ArchiveResult, type ArchiveSink, type BulkCreateResult, type BulkWriteOperation, type BulkWriteResult, type ChangeEvent, type ClaimTransition, type ClaimVersionTransition, type CursorOptions, type DeleteManyResult, type DeleteOptions, type DeleteResult, type DistributionConfig, type DistributionMissHandler, type FilterInput, type FindAllOptions, type FindOneAndUpdateOptions, type InferDoc, type KeysetAggPaginationResult, type LookupPopulateOptions, type LookupPopulateResult, type LookupRow, type LookupSpec, type MinimalRepo, PLUGIN_ORDER_CONSTRAINTS, type PaginationParams, type Plugin, type PluginFunction, type PluginType, type PurgePort, type QueryOptions, type ReadOnlyRepoOptions, ReadOnlyRepositoryError, type RepoCapabilities, RepositoryBase, type RepositoryBaseOptions, type RepositorySession, type RetryPolicy, type RetryingTransactionOptions, STANDARD_REPO_OPTION_KEYS, type StandardRepo, type StandardRepoOptionKey, type TenantPurgeOptions, type TenantPurgeProgress, type TenantPurgeResult, type TenantPurgeStrategy, type TransactionHandle, type TransitionArgs, type TransitionMachine, type UpdateInput, type UpdateManyResult, type WatchOptions, type WriteOptions, type WritingPurgeStrategy, asReadOnlyRepo, createDistributionGuard, filterReferencesKey, isReadOnlyRepo, isTransientError, nestDottedKeys, nestDottedKeysAll, retryingTransaction, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
@@ -1,5 +1,5 @@
1
1
  import { nestDottedKeys, nestDottedKeysAll } from "./agg-output.mjs";
2
- import { throwIfAborted, withRetry } from "./resilience.mjs";
2
+ import { isTransientError, throwIfAborted, withRetry } from "./resilience.mjs";
3
3
  import { runChunkedArchive } from "./archive.mjs";
4
4
  import { PLUGIN_ORDER_CONSTRAINTS, validatePluginOrder } from "./plugin-types.mjs";
5
5
  import { RepositoryBase } from "./base.mjs";
@@ -8,4 +8,4 @@ import { STANDARD_REPO_OPTION_KEYS } from "./options.mjs";
8
8
  import { runChunkedPurge } from "./purge.mjs";
9
9
  import { ReadOnlyRepositoryError, asReadOnlyRepo, isReadOnlyRepo } from "./read-only.mjs";
10
10
  import { retryingTransaction } from "./retrying-transaction.mjs";
11
- export { PLUGIN_ORDER_CONSTRAINTS, ReadOnlyRepositoryError, RepositoryBase, STANDARD_REPO_OPTION_KEYS, asReadOnlyRepo, createDistributionGuard, filterReferencesKey, isReadOnlyRepo, nestDottedKeys, nestDottedKeysAll, retryingTransaction, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };
11
+ export { PLUGIN_ORDER_CONSTRAINTS, ReadOnlyRepositoryError, RepositoryBase, STANDARD_REPO_OPTION_KEYS, asReadOnlyRepo, createDistributionGuard, filterReferencesKey, isReadOnlyRepo, isTransientError, nestDottedKeys, nestDottedKeysAll, retryingTransaction, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry };