@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.
- package/CHANGELOG.md +1263 -1014
- package/dist/cache/engine.d.mts +7 -5
- package/dist/cache/engine.mjs +34 -16
- package/dist/cache/memo.d.mts +4 -0
- package/dist/cache/memo.mjs +17 -4
- package/dist/cache/memory-adapter.mjs +6 -4
- package/dist/cache/plugin/context.mjs +3 -1
- package/dist/cache/plugin/read-hooks.mjs +1 -1
- package/dist/cache/stable-stringify.d.mts +7 -8
- package/dist/cache/stable-stringify.mjs +14 -11
- package/dist/cache/tag-index.mjs +1 -2
- package/dist/cache/types.d.mts +4 -3
- package/dist/errors/codes.d.mts +117 -0
- package/dist/errors/codes.mjs +114 -0
- package/dist/errors/contract.d.mts +2 -1
- package/dist/errors/contract.mjs +9 -3
- package/dist/errors/index.d.mts +3 -2
- package/dist/errors/index.mjs +2 -2
- package/dist/errors/types.d.mts +3 -30
- package/dist/filter/builders.mjs +1 -1
- package/dist/filter/match-record.mjs +9 -3
- package/dist/filter/match.mjs +25 -9
- package/dist/idempotency/index.d.mts +462 -0
- package/dist/idempotency/index.mjs +419 -0
- package/dist/pagination/keyset.mjs +0 -2
- package/dist/pagination/types.d.mts +18 -0
- package/dist/query-parser/coerce.d.mts +18 -11
- package/dist/query-parser/coerce.mjs +37 -69
- package/dist/query-parser/errors.d.mts +30 -0
- package/dist/query-parser/errors.mjs +32 -0
- package/dist/query-parser/grammar.d.mts +107 -0
- package/dist/query-parser/grammar.mjs +462 -0
- package/dist/query-parser/index.d.mts +5 -2
- package/dist/query-parser/index.mjs +5 -2
- package/dist/query-parser/parse-url.d.mts +1 -1
- package/dist/query-parser/parse-url.mjs +72 -205
- package/dist/query-parser/regex-safety.d.mts +31 -0
- package/dist/query-parser/regex-safety.mjs +151 -0
- package/dist/query-parser/reserved.mjs +1 -0
- package/dist/query-parser/types.d.mts +15 -25
- package/dist/repository/index.d.mts +2 -2
- package/dist/repository/index.mjs +2 -2
- package/dist/repository/resilience.d.mts +25 -20
- package/dist/repository/resilience.mjs +66 -2
- package/dist/repository/types.d.mts +105 -6
- package/dist/schema/field-rules.mjs +49 -6
- package/dist/schema/standard-schema.mjs +1 -1
- package/dist/testing/idempotency-conformance.d.mts +15 -0
- package/dist/testing/idempotency-conformance.mjs +315 -0
- package/dist/testing/index.d.mts +3 -1
- package/dist/testing/index.mjs +3 -1
- package/dist/testing/query-grammar-conformance.d.mts +60 -0
- package/dist/testing/query-grammar-conformance.mjs +380 -0
- package/package.json +6 -2
- package/dist/errors/types.mjs +0 -29
|
@@ -1,226 +1,93 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
19
|
-
|
|
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
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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:
|
|
55
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
*
|
|
130
|
-
*
|
|
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
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
182
|
-
if (!
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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 };
|
|
@@ -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
|
-
/**
|
|
57
|
+
/** Per-page count when the URL omits `limit`. Default: 20. */
|
|
60
58
|
defaultLimit?: number;
|
|
61
|
-
/**
|
|
59
|
+
/** Cap on `limit`; a larger request is clamped to it. Default: 1000. */
|
|
62
60
|
maxLimit?: number;
|
|
63
|
-
/**
|
|
61
|
+
/** Filterable fields. */
|
|
64
62
|
allowedFilterFields?: readonly string[];
|
|
65
|
-
/**
|
|
63
|
+
/** Sortable fields. */
|
|
66
64
|
allowedSortFields?: readonly string[];
|
|
67
|
-
/**
|
|
65
|
+
/** URL operator names permitted in `field[op]`. */
|
|
68
66
|
allowedOperators?: readonly BracketOperator[];
|
|
69
|
-
/**
|
|
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
|
-
/**
|
|
69
|
+
/** Longest `search`. Default: 200. */
|
|
74
70
|
maxSearchLength?: number;
|
|
75
71
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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,
|
|
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 };
|