@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.
- package/CHANGELOG.md +1290 -1098
- 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/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/types.d.mts +6 -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/types.d.mts +6 -5
- package/dist/schema/field-rules.mjs +49 -6
- package/dist/schema/standard-schema.mjs +1 -1
- package/dist/sync/index.d.mts +90 -55
- package/dist/sync/index.mjs +0 -0
- package/dist/testing/change-log-conformance.d.mts +21 -0
- package/dist/testing/change-log-conformance.mjs +204 -0
- package/dist/testing/command-stream-conformance.d.mts +15 -0
- package/dist/testing/command-stream-conformance.mjs +163 -0
- package/dist/testing/idempotency-conformance.d.mts +15 -0
- package/dist/testing/idempotency-conformance.mjs +315 -0
- package/dist/testing/index.d.mts +5 -1
- package/dist/testing/index.mjs +5 -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
|
|
@@ -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` | `
|
|
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
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
*
|