@classytic/repo-core 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +1290 -1098
  2. package/dist/cache/engine.d.mts +7 -5
  3. package/dist/cache/engine.mjs +34 -16
  4. package/dist/cache/memo.d.mts +4 -0
  5. package/dist/cache/memo.mjs +17 -4
  6. package/dist/cache/memory-adapter.mjs +6 -4
  7. package/dist/cache/plugin/read-hooks.mjs +1 -1
  8. package/dist/cache/stable-stringify.d.mts +7 -8
  9. package/dist/cache/stable-stringify.mjs +14 -11
  10. package/dist/cache/tag-index.mjs +1 -2
  11. package/dist/cache/types.d.mts +4 -3
  12. package/dist/errors/codes.d.mts +117 -0
  13. package/dist/errors/codes.mjs +114 -0
  14. package/dist/errors/contract.d.mts +2 -1
  15. package/dist/errors/contract.mjs +9 -3
  16. package/dist/errors/index.d.mts +3 -2
  17. package/dist/errors/index.mjs +2 -2
  18. package/dist/errors/types.d.mts +3 -30
  19. package/dist/filter/builders.mjs +1 -1
  20. package/dist/filter/match-record.mjs +9 -3
  21. package/dist/filter/match.mjs +25 -9
  22. package/dist/idempotency/index.d.mts +462 -0
  23. package/dist/idempotency/index.mjs +419 -0
  24. package/dist/pagination/types.d.mts +6 -0
  25. package/dist/query-parser/coerce.d.mts +18 -11
  26. package/dist/query-parser/coerce.mjs +37 -69
  27. package/dist/query-parser/errors.d.mts +30 -0
  28. package/dist/query-parser/errors.mjs +32 -0
  29. package/dist/query-parser/grammar.d.mts +107 -0
  30. package/dist/query-parser/grammar.mjs +462 -0
  31. package/dist/query-parser/index.d.mts +5 -2
  32. package/dist/query-parser/index.mjs +5 -2
  33. package/dist/query-parser/parse-url.d.mts +1 -1
  34. package/dist/query-parser/parse-url.mjs +72 -205
  35. package/dist/query-parser/regex-safety.d.mts +31 -0
  36. package/dist/query-parser/regex-safety.mjs +151 -0
  37. package/dist/query-parser/reserved.mjs +1 -0
  38. package/dist/query-parser/types.d.mts +15 -25
  39. package/dist/repository/types.d.mts +6 -5
  40. package/dist/schema/field-rules.mjs +49 -6
  41. package/dist/schema/standard-schema.mjs +1 -1
  42. package/dist/sync/index.d.mts +90 -55
  43. package/dist/sync/index.mjs +0 -0
  44. package/dist/testing/change-log-conformance.d.mts +21 -0
  45. package/dist/testing/change-log-conformance.mjs +204 -0
  46. package/dist/testing/command-stream-conformance.d.mts +15 -0
  47. package/dist/testing/command-stream-conformance.mjs +163 -0
  48. package/dist/testing/idempotency-conformance.d.mts +15 -0
  49. package/dist/testing/idempotency-conformance.mjs +315 -0
  50. package/dist/testing/index.d.mts +5 -1
  51. package/dist/testing/index.mjs +5 -1
  52. package/dist/testing/query-grammar-conformance.d.mts +60 -0
  53. package/dist/testing/query-grammar-conformance.mjs +380 -0
  54. package/package.json +6 -2
  55. package/dist/errors/types.mjs +0 -29
@@ -0,0 +1,107 @@
1
+ import { Filter } from "../filter/types.mjs";
2
+ import { QueryFieldType, QueryScalar } from "./coerce.mjs";
3
+ import { QueryGrammarError } from "./errors.mjs";
4
+ import { BracketOperator, ParsedSelect, ParsedSort } from "./types.mjs";
5
+ //#region src/query-parser/grammar.d.ts
6
+ declare const DEFAULT_LIMIT = 20;
7
+ declare const DEFAULT_MAX_LIMIT = 1000;
8
+ declare const DEFAULT_MAX_TEXT_LENGTH = 500;
9
+ declare const DEFAULT_MAX_SEARCH_LENGTH = 200;
10
+ declare const CORE_OPERATORS: readonly ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'exists', 'contains', 'startsWith', 'endsWith', 'ieq', 'regex'];
11
+ type CoreOperator = (typeof CORE_OPERATORS)[number];
12
+ declare const URL_OPERATORS: readonly BracketOperator[];
13
+ /** One line per URL operator — the single source for every parser's OpenAPI description. */
14
+ declare const OPERATOR_DESCRIPTIONS: Readonly<Record<BracketOperator, string>>;
15
+ type FilterClause = {
16
+ readonly field: string;
17
+ readonly op: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
18
+ readonly value: QueryScalar;
19
+ } | {
20
+ readonly field: string;
21
+ readonly op: 'in' | 'nin';
22
+ readonly values: readonly QueryScalar[];
23
+ } | {
24
+ readonly field: string;
25
+ readonly op: 'exists';
26
+ readonly value: boolean;
27
+ } | {
28
+ readonly field: string;
29
+ readonly op: 'contains' | 'startsWith' | 'endsWith' | 'ieq';
30
+ readonly text: string;
31
+ } | {
32
+ readonly field: string;
33
+ readonly op: 'regex';
34
+ readonly pattern: string;
35
+ readonly flags?: string;
36
+ };
37
+ /** A kit-declared operator (geo, `size`, …), handed back raw for the kit to emit natively. */
38
+ interface ExtensionClause {
39
+ readonly field: string;
40
+ readonly op: string;
41
+ readonly raw: string;
42
+ }
43
+ interface QueryGrammarOptions {
44
+ /** Filterable fields; any other field is refused. */
45
+ allowedFilterFields?: readonly string[] | undefined;
46
+ /** URL operator names permitted (`options` follows `regex`); any other is refused. */
47
+ allowedOperators?: readonly string[] | undefined;
48
+ /** Declared field types — exact coercion, and a value that does not fit is refused. */
49
+ fieldTypes?: Readonly<Record<string, QueryFieldType>> | undefined;
50
+ /** Operators the kit emits itself; returned as {@link ExtensionClause}s. */
51
+ extensionOperators?: readonly string[] | undefined;
52
+ /** Kit-local control params (beyond `STANDARD_RESERVED_PARAMS`) that are not filters. */
53
+ reservedParams?: readonly string[] | undefined;
54
+ /** Longest text-operator value or regex pattern. Default {@link DEFAULT_MAX_TEXT_LENGTH}. */
55
+ maxTextLength?: number | undefined;
56
+ /**
57
+ * Drop mode: called with each refusal and the offending entry is skipped instead of thrown.
58
+ * Dropping a filter WIDENS the read — for trusted compatibility tooling only.
59
+ */
60
+ onInvalid?: ((error: QueryGrammarError) => void) | undefined;
61
+ }
62
+ interface FilterReadResult {
63
+ readonly clauses: FilterClause[];
64
+ readonly extensions: ExtensionClause[];
65
+ }
66
+ /** A list query as a parser receives it: URL params, qs-nested objects, or flat records. */
67
+ type QueryInput = URLSearchParams | Iterable<readonly [string, string]> | Readonly<Record<string, unknown>>;
68
+ /** Read every filter in `input` as canonical clauses. */
69
+ declare function readFilterClauses(input: QueryInput | null | undefined, options?: QueryGrammarOptions): FilterReadResult;
70
+ /** Canonical clauses → the portable Filter IR (repeated fields AND). */
71
+ declare function clausesToFilter(clauses: readonly FilterClause[]): Filter;
72
+ interface PageRequest {
73
+ readonly limit: number;
74
+ /** Offset page (1-based). Absent on a keyset request — `after` wins over `page`. */
75
+ readonly page?: number;
76
+ /** Keyset cursor (`after`, or its alias `cursor`). */
77
+ readonly after?: string;
78
+ }
79
+ interface PageOptions {
80
+ defaultLimit?: number | undefined;
81
+ maxLimit?: number | undefined;
82
+ }
83
+ /** `limit`/`page` must be positive integers; a `limit` above the cap is clamped to it. */
84
+ declare function readPageRequest(input: {
85
+ page?: unknown;
86
+ limit?: unknown;
87
+ after?: unknown;
88
+ cursor?: unknown;
89
+ }, options?: PageOptions): PageRequest;
90
+ /** `sort=-createdAt,name` or qs `sort[createdAt]=desc`. An invalid or disallowed field is refused. */
91
+ declare function readSort(raw: unknown, options?: {
92
+ allowedSortFields?: readonly string[] | undefined;
93
+ /** Drop mode: called for each refused token, which is skipped. */
94
+ onInvalid?: ((error: QueryGrammarError) => void) | undefined;
95
+ }): ParsedSort | undefined;
96
+ /** `select=name,-password`, or a `{ field: 0 | 1 }` projection. An invalid entry is refused. */
97
+ declare function readSelect(raw: unknown, options?: {
98
+ onInvalid?: ((error: QueryGrammarError) => void) | undefined;
99
+ }): ParsedSelect | undefined;
100
+ /** Free-text search; longer than the cap is refused, never silently truncated. */
101
+ declare function readSearch(raw: unknown, options?: {
102
+ maxSearchLength?: number | undefined;
103
+ }): string | undefined;
104
+ /** Every input shape → one record: repeated URL keys become arrays, qs objects stay nested. */
105
+ declare function toRecord(input: QueryInput | null | undefined): Record<string, unknown>;
106
+ //#endregion
107
+ export { CORE_OPERATORS, CoreOperator, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_MAX_SEARCH_LENGTH, DEFAULT_MAX_TEXT_LENGTH, ExtensionClause, FilterClause, FilterReadResult, OPERATOR_DESCRIPTIONS, PageOptions, PageRequest, QueryGrammarOptions, QueryInput, URL_OPERATORS, clausesToFilter, readFilterClauses, readPageRequest, readSearch, readSelect, readSort, toRecord };
@@ -0,0 +1,462 @@
1
+ import { TRUE, and, anyOf as in_, contains, endsWith, eq, exists, gt, gte, iEq, lt, lte, ne, nin, regex, startsWith } from "../filter/builders.mjs";
2
+ import { QueryGrammarError } from "./errors.mjs";
3
+ import { coerceQueryValue } from "./coerce.mjs";
4
+ import { assessRegex } from "./regex-safety.mjs";
5
+ import { STANDARD_RESERVED_PARAMS } from "./reserved.mjs";
6
+ //#region src/query-parser/grammar.ts
7
+ const DEFAULT_LIMIT = 20;
8
+ const DEFAULT_MAX_LIMIT = 1e3;
9
+ const DEFAULT_MAX_TEXT_LENGTH = 500;
10
+ const DEFAULT_MAX_SEARCH_LENGTH = 200;
11
+ const MAX_KEY_LENGTH = 256;
12
+ /** Deepest qs nesting a filter can have: `filter[field][op]` plus a repeated value. */
13
+ const MAX_NESTING = 4;
14
+ const CORE_OPERATORS = [
15
+ "eq",
16
+ "ne",
17
+ "gt",
18
+ "gte",
19
+ "lt",
20
+ "lte",
21
+ "in",
22
+ "nin",
23
+ "exists",
24
+ "contains",
25
+ "startsWith",
26
+ "endsWith",
27
+ "ieq",
28
+ "regex"
29
+ ];
30
+ /**
31
+ * URL operator names the grammar accepts: the core set plus its sugar. Keyed by `BracketOperator`
32
+ * so the public type and the runtime list cannot drift — a missing or extra name is a compile error.
33
+ */
34
+ const URL_OPERATOR_SET = {
35
+ ...Object.fromEntries(CORE_OPERATORS.map((op) => [op, true])),
36
+ like: true,
37
+ between: true,
38
+ options: true
39
+ };
40
+ const URL_OPERATORS = Object.keys(URL_OPERATOR_SET);
41
+ /** One line per URL operator — the single source for every parser's OpenAPI description. */
42
+ const OPERATOR_DESCRIPTIONS = {
43
+ eq: "Equal (the default for `field=value`; repeated keys mean `in`)",
44
+ ne: "Not equal — null and missing values never match",
45
+ gt: "Greater than",
46
+ gte: "Greater than or equal",
47
+ lt: "Less than",
48
+ lte: "Less than or equal",
49
+ in: "One of a comma-separated list",
50
+ nin: "None of a comma-separated list — null and missing values never match",
51
+ exists: "Has a non-null value (true) / is null or missing (false)",
52
+ contains: "Contains the text (literal, case-insensitive)",
53
+ like: "Alias of contains",
54
+ startsWith: "Starts with the text (literal, case-insensitive)",
55
+ endsWith: "Ends with the text (literal, case-insensitive)",
56
+ ieq: "Equals the text, ignoring case",
57
+ regex: "Regular expression (case-sensitive; checked for ReDoS)",
58
+ options: "Flags for the regex on the same field (i, m, s, x)",
59
+ between: "Inclusive range `from,to` (either side may be empty)"
60
+ };
61
+ const FIELD = "[A-Za-z_][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*";
62
+ const FIELD_RE = new RegExp(`^${FIELD}$`);
63
+ const FILTER_KEY_RE = new RegExp(`^(${FIELD})(?:\\[([A-Za-z]+)\\])?$`);
64
+ const SORT_TOKEN_RE = new RegExp(`^([+-]?)(${FIELD})$`);
65
+ const SELECT_TOKEN_RE = new RegExp(`^(-?)(${FIELD})$`);
66
+ const REGEX_FLAGS_RE = /^[imsx]+$/;
67
+ /** Read every filter in `input` as canonical clauses. */
68
+ function readFilterClauses(input, options = {}) {
69
+ const reserved = /* @__PURE__ */ new Set([...STANDARD_RESERVED_PARAMS, ...options.reservedParams ?? []]);
70
+ const extensions = new Set(options.extensionOperators ?? []);
71
+ const allowedOps = options.allowedOperators ? new Set(options.allowedOperators) : void 0;
72
+ const allowedFields = options.allowedFilterFields ? new Set(options.allowedFilterFields) : void 0;
73
+ const maxText = options.maxTextLength ?? 500;
74
+ const clauses = [];
75
+ const extensionClauses = [];
76
+ const regexFlags = /* @__PURE__ */ new Map();
77
+ const guard = (fn) => {
78
+ try {
79
+ fn();
80
+ } catch (error) {
81
+ if (!(error instanceof QueryGrammarError) || !options.onInvalid) throw error;
82
+ options.onInvalid(error);
83
+ }
84
+ };
85
+ for (const [key, value] of filterEntries(toRecord(input), reserved)) guard(() => {
86
+ const raw = toRaw(key, value);
87
+ if (raw === "") return;
88
+ const { field, op, explicit } = parseFilterKey(key, extensions);
89
+ if (allowedFields && !allowedFields.has(field)) throw new QueryGrammarError(key, `"${field}" is not a filterable field`, "policy");
90
+ const gate = op === "options" ? "regex" : op;
91
+ if (explicit && allowedOps && !allowedOps.has(gate)) throw new QueryGrammarError(key, `operator "${op}" is not allowed`, "policy");
92
+ if (extensions.has(op)) {
93
+ extensionClauses.push({
94
+ field,
95
+ op,
96
+ raw
97
+ });
98
+ return;
99
+ }
100
+ if (op === "options") {
101
+ if (!REGEX_FLAGS_RE.test(raw)) throw new QueryGrammarError(key, "flags must be from i, m, s, x");
102
+ regexFlags.set(field, raw);
103
+ return;
104
+ }
105
+ const fieldType = options.fieldTypes?.[field];
106
+ clauses.push(...toClauses(key, field, op, raw, fieldType, maxText));
107
+ });
108
+ for (const [field, flags] of regexFlags) guard(() => {
109
+ const index = clauses.findIndex((c) => c.field === field && c.op === "regex");
110
+ if (index < 0) throw new QueryGrammarError(`${field}[options]`, "needs a regex on the same field");
111
+ const clause = clauses[index];
112
+ clauses[index] = {
113
+ ...clause,
114
+ flags
115
+ };
116
+ });
117
+ return {
118
+ clauses: collapseRepeats(clauses),
119
+ extensions: extensionClauses
120
+ };
121
+ }
122
+ /** Canonical clauses → the portable Filter IR (repeated fields AND). */
123
+ function clausesToFilter(clauses) {
124
+ const nodes = clauses.map(clauseToFilter);
125
+ if (nodes.length === 0) return TRUE;
126
+ return nodes.length === 1 ? nodes[0] : and(...nodes);
127
+ }
128
+ function clauseToFilter(clause) {
129
+ switch (clause.op) {
130
+ case "eq": return eq(clause.field, clause.value);
131
+ case "ne": return ne(clause.field, clause.value);
132
+ case "gt": return gt(clause.field, clause.value);
133
+ case "gte": return gte(clause.field, clause.value);
134
+ case "lt": return lt(clause.field, clause.value);
135
+ case "lte": return lte(clause.field, clause.value);
136
+ case "in": return in_(clause.field, clause.values);
137
+ case "nin": return nin(clause.field, clause.values);
138
+ case "exists": return exists(clause.field, clause.value);
139
+ case "contains": return contains(clause.field, clause.text);
140
+ case "startsWith": return startsWith(clause.field, clause.text);
141
+ case "endsWith": return endsWith(clause.field, clause.text);
142
+ case "ieq": return iEq(clause.field, clause.text);
143
+ case "regex": return regex(clause.field, clause.pattern, clause.flags);
144
+ }
145
+ }
146
+ function parseFilterKey(key, extensions) {
147
+ if (key.length > MAX_KEY_LENGTH) throw new QueryGrammarError(key.slice(0, 64), `key longer than ${MAX_KEY_LENGTH} characters`, "syntax");
148
+ const match = FILTER_KEY_RE.exec(key);
149
+ if (!match) {
150
+ if (key.includes("$")) throw new QueryGrammarError(key, "`$` operators are not part of the query grammar", "syntax");
151
+ const nested = /^([^[\]]+)\[([^[\]]+)\]\[/.exec(key);
152
+ if (nested) throw new QueryGrammarError(key, `a nested field is a dot path: ${nested[1]}.${nested[2]}`, "syntax");
153
+ throw new QueryGrammarError(key, "not a valid filter key (`field` or `field[operator]`)", "syntax");
154
+ }
155
+ const field = match[1];
156
+ const op = match[2] ?? "eq";
157
+ if (!(op in URL_OPERATOR_SET) && !extensions.has(op)) throw new QueryGrammarError(key, `unknown operator "${op}" (supported: ${[...URL_OPERATORS, ...extensions].join(", ")}); a nested field is a dot path: ${field}.${op}`, "syntax");
158
+ return {
159
+ field,
160
+ op,
161
+ explicit: match[2] !== void 0
162
+ };
163
+ }
164
+ function toClauses(param, field, op, raw, fieldType, maxText) {
165
+ switch (op) {
166
+ case "eq":
167
+ case "ne": return [{
168
+ field,
169
+ op,
170
+ value: coerceQueryValue(raw, {
171
+ fieldType,
172
+ param
173
+ })
174
+ }];
175
+ case "gt":
176
+ case "gte":
177
+ case "lt":
178
+ case "lte": return [{
179
+ field,
180
+ op,
181
+ value: rangeValue(raw, fieldType, param)
182
+ }];
183
+ case "in":
184
+ case "nin": {
185
+ const values = splitList(raw).map((item) => coerceQueryValue(item, {
186
+ fieldType,
187
+ param
188
+ }));
189
+ return values.length > 0 ? [{
190
+ field,
191
+ op,
192
+ values
193
+ }] : [];
194
+ }
195
+ case "between": {
196
+ const parts = raw.split(",").map((part) => part.trim());
197
+ if (parts.length !== 2 || parts[0] === "" && parts[1] === "") throw new QueryGrammarError(param, "between takes `from,to` (one side may be empty)");
198
+ const [from, to] = parts;
199
+ const out = [];
200
+ if (from !== "") out.push({
201
+ field,
202
+ op: "gte",
203
+ value: rangeValue(from, fieldType, param)
204
+ });
205
+ if (to !== "") out.push({
206
+ field,
207
+ op: "lte",
208
+ value: rangeValue(to, fieldType, param)
209
+ });
210
+ return out;
211
+ }
212
+ case "exists": {
213
+ const lower = raw.toLowerCase();
214
+ if (lower === "true" || lower === "1") return [{
215
+ field,
216
+ op: "exists",
217
+ value: true
218
+ }];
219
+ if (lower === "false" || lower === "0") return [{
220
+ field,
221
+ op: "exists",
222
+ value: false
223
+ }];
224
+ throw new QueryGrammarError(param, "exists takes true or false");
225
+ }
226
+ case "like":
227
+ case "contains":
228
+ case "startsWith":
229
+ case "endsWith":
230
+ case "ieq":
231
+ if (raw.length > maxText) throw new QueryGrammarError(param, `text longer than ${maxText} characters`);
232
+ return [{
233
+ field,
234
+ op: op === "like" ? "contains" : op,
235
+ text: raw
236
+ }];
237
+ case "regex": {
238
+ const risk = assessRegex(raw, { maxLength: maxText });
239
+ if (!risk.safe) throw new QueryGrammarError(param, `regex refused (${risk.reason})`);
240
+ return [{
241
+ field,
242
+ op: "regex",
243
+ pattern: raw
244
+ }];
245
+ }
246
+ }
247
+ throw new QueryGrammarError(param, `unknown operator "${op}"`, "syntax");
248
+ }
249
+ function rangeValue(raw, fieldType, param) {
250
+ const value = coerceQueryValue(raw, {
251
+ fieldType,
252
+ range: true,
253
+ param
254
+ });
255
+ if (value === null) throw new QueryGrammarError(param, "a range bound cannot be null");
256
+ return value;
257
+ }
258
+ function splitList(raw) {
259
+ return raw.split(",").map((item) => item.trim()).filter((item) => item !== "");
260
+ }
261
+ /**
262
+ * Membership on one field is a UNION, never an AND of alternatives: `status=a&status=b`,
263
+ * `status[in]=a&status[in]=b` and `status=a&status[in]=b,c` are one `in`; the `ne`/`nin`
264
+ * family likewise becomes one `nin`. Ranges and text clauses on a field still AND.
265
+ */
266
+ function collapseRepeats(clauses) {
267
+ const family = (c) => c.op === "eq" || c.op === "in" ? "in" : c.op === "ne" || c.op === "nin" ? "nin" : void 0;
268
+ const valuesOf = (c) => c.op === "in" || c.op === "nin" ? c.values : [c.value];
269
+ const key = (c) => `${family(c)}:${c.field}`;
270
+ const groups = /* @__PURE__ */ new Map();
271
+ for (const clause of clauses) {
272
+ if (family(clause) === void 0) continue;
273
+ groups.set(key(clause), [...groups.get(key(clause)) ?? [], clause]);
274
+ }
275
+ const out = [];
276
+ const emitted = /* @__PURE__ */ new Set();
277
+ for (const clause of clauses) {
278
+ const group = family(clause) === void 0 ? void 0 : groups.get(key(clause));
279
+ if (group === void 0 || group.length === 1) out.push(clause);
280
+ else if (!emitted.has(key(clause))) {
281
+ emitted.add(key(clause));
282
+ const values = [...new Set(group.flatMap(valuesOf))];
283
+ out.push({
284
+ field: clause.field,
285
+ op: family(clause),
286
+ values
287
+ });
288
+ }
289
+ }
290
+ return out;
291
+ }
292
+ /** `limit`/`page` must be positive integers; a `limit` above the cap is clamped to it. */
293
+ function readPageRequest(input, options = {}) {
294
+ const maxLimit = options.maxLimit ?? 1e3;
295
+ const requested = positiveInt(input.limit, "limit");
296
+ const limit = Math.min(requested ?? options.defaultLimit ?? 20, maxLimit);
297
+ const after = cursorValue(input.after ?? input.cursor);
298
+ if (after !== void 0) return {
299
+ limit,
300
+ after
301
+ };
302
+ const page = positiveInt(input.page, "page");
303
+ return page === void 0 ? { limit } : {
304
+ limit,
305
+ page
306
+ };
307
+ }
308
+ /** `sort=-createdAt,name` or qs `sort[createdAt]=desc`. An invalid or disallowed field is refused. */
309
+ function readSort(raw, options = {}) {
310
+ if (raw === void 0 || raw === null || raw === "") return void 0;
311
+ const allowed = options.allowedSortFields ? new Set(options.allowedSortFields) : void 0;
312
+ const sort = {};
313
+ const refuse = (param, reason, kind = "syntax") => {
314
+ const error = new QueryGrammarError(param, reason, kind);
315
+ if (!options.onInvalid) throw error;
316
+ options.onInvalid(error);
317
+ };
318
+ const add = (field, direction) => {
319
+ if (allowed && !allowed.has(field)) refuse("sort", `"${field}" is not a sortable field`, "policy");
320
+ else sort[field] = direction;
321
+ };
322
+ if (isPlainObject(raw)) for (const [field, value] of Object.entries(raw)) {
323
+ const dir = String(value).toLowerCase();
324
+ if (!FIELD_RE.test(field)) refuse("sort", `"${field}" is not a field`);
325
+ else if (dir === "asc" || dir === "1") add(field, 1);
326
+ else if (dir === "desc" || dir === "-1") add(field, -1);
327
+ else refuse(`sort[${field}]`, "direction is asc or desc", "value");
328
+ }
329
+ else {
330
+ const tokens = (Array.isArray(raw) ? raw : [raw]).flatMap(splitTokens);
331
+ for (const token of tokens.map((t) => t.trim()).filter((t) => t !== "")) {
332
+ const match = SORT_TOKEN_RE.exec(token);
333
+ if (!match) refuse("sort", `"${token}" is not a field`);
334
+ else add(match[2], match[1] === "-" ? -1 : 1);
335
+ }
336
+ }
337
+ return Object.keys(sort).length > 0 ? sort : void 0;
338
+ }
339
+ /** `select=name,-password`, or a `{ field: 0 | 1 }` projection. An invalid entry is refused. */
340
+ function readSelect(raw, options = {}) {
341
+ if (raw === void 0 || raw === null || raw === "") return void 0;
342
+ const select = {};
343
+ const refuse = (param, reason, kind) => {
344
+ const error = new QueryGrammarError(param, reason, kind);
345
+ if (!options.onInvalid) throw error;
346
+ options.onInvalid(error);
347
+ };
348
+ if (isPlainObject(raw)) for (const [field, flag] of Object.entries(raw)) {
349
+ const value = String(flag);
350
+ if (!FIELD_RE.test(field)) refuse("select", `"${field}" is not a field`, "syntax");
351
+ else if (value !== "0" && value !== "1") refuse(`select[${field}]`, "a projection flag is 0 or 1", "value");
352
+ else select[field] = value === "1" ? 1 : 0;
353
+ }
354
+ else {
355
+ const tokens = (Array.isArray(raw) ? raw : [raw]).flatMap(splitTokens);
356
+ for (const token of tokens.map((t) => t.trim()).filter((t) => t !== "")) {
357
+ const match = SELECT_TOKEN_RE.exec(token);
358
+ if (!match) refuse("select", `"${token}" is not a field`, "syntax");
359
+ else select[match[2]] = match[1] === "-" ? 0 : 1;
360
+ }
361
+ }
362
+ return Object.keys(select).length > 0 ? select : void 0;
363
+ }
364
+ /** Free-text search; longer than the cap is refused, never silently truncated. */
365
+ function readSearch(raw, options = {}) {
366
+ if (raw === void 0 || raw === null) return void 0;
367
+ const search = String(raw).trim();
368
+ if (search === "") return void 0;
369
+ const max = options.maxSearchLength ?? 200;
370
+ if (search.length > max) throw new QueryGrammarError("search", `longer than ${max} characters`);
371
+ return search;
372
+ }
373
+ /** Every input shape → one record: repeated URL keys become arrays, qs objects stay nested. */
374
+ function toRecord(input) {
375
+ if (input === null || input === void 0) return {};
376
+ if (input instanceof URLSearchParams || isEntryIterable(input)) {
377
+ const record = {};
378
+ const entries = input instanceof URLSearchParams ? input.entries() : input;
379
+ for (const [key, value] of entries) {
380
+ const prev = record[key];
381
+ record[key] = prev === void 0 ? value : Array.isArray(prev) ? [...prev, value] : [prev, value];
382
+ }
383
+ return record;
384
+ }
385
+ return input;
386
+ }
387
+ function isEntryIterable(input) {
388
+ return typeof input === "object" && input !== null && !(input instanceof URLSearchParams) && Symbol.iterator in input;
389
+ }
390
+ /** Flat `[bracketKey, scalar]` pairs for every non-reserved param, unwrapping the `filter` envelope. */
391
+ function* filterEntries(record, reserved) {
392
+ for (const [key, value] of Object.entries(record)) {
393
+ const top = key.split("[", 1)[0];
394
+ if (top === "filter") {
395
+ if (key === "filter") {
396
+ if (!isPlainObject(value)) {
397
+ yield ["filter", new Refusal("the envelope is filter[field]=value")];
398
+ continue;
399
+ }
400
+ for (const [inner, innerValue] of Object.entries(value)) yield* flatten(inner, innerValue, 1);
401
+ } else yield* flatten(key.replace(/^filter\[([^\]]*)\]/, "$1"), value, 1);
402
+ continue;
403
+ }
404
+ if (reserved.has(top)) continue;
405
+ yield* flatten(key, value, 0);
406
+ }
407
+ }
408
+ /** Refuses input nested past {@link MAX_NESTING} BEFORE recursing into it. */
409
+ function* flatten(path, value, depth) {
410
+ if (value === void 0) return;
411
+ if (depth > MAX_NESTING) {
412
+ yield [path.slice(0, 64), new Refusal("nested too deeply")];
413
+ return;
414
+ }
415
+ if (Array.isArray(value)) {
416
+ for (const item of value) yield* flatten(path, item, depth + 1);
417
+ return;
418
+ }
419
+ if (isPlainObject(value)) {
420
+ for (const [key, inner] of Object.entries(value)) yield* flatten(`${path}[${key}]`, inner, depth + 1);
421
+ return;
422
+ }
423
+ yield [path, value];
424
+ }
425
+ /**
426
+ * A refusal found while flattening, yielded in place of a value so the caller's `onInvalid` policy
427
+ * applies to it like any other entry — a generator that threw would end the whole read.
428
+ */
429
+ var Refusal = class {
430
+ reason;
431
+ constructor(reason) {
432
+ this.reason = reason;
433
+ }
434
+ };
435
+ /** Sort/select tokens: comma- or whitespace-separated (`a,-b` or Mongoose's `a -b`). */
436
+ function splitTokens(part) {
437
+ return String(part).split(/[\s,]+/);
438
+ }
439
+ function toRaw(key, value) {
440
+ if (value instanceof Refusal) throw new QueryGrammarError(key, value.reason, "syntax");
441
+ if (value === null) return "null";
442
+ if (value instanceof Date) return value.toISOString();
443
+ if (typeof value === "string") return value;
444
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
445
+ throw new QueryGrammarError(key, "a filter value is a string", "syntax");
446
+ }
447
+ function isPlainObject(value) {
448
+ return typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
449
+ }
450
+ function positiveInt(raw, param) {
451
+ if (raw === void 0 || raw === null || raw === "") return void 0;
452
+ const value = typeof raw === "number" ? raw : /^\d+$/.test(String(raw).trim()) ? Number(String(raw).trim()) : NaN;
453
+ if (!Number.isSafeInteger(value) || value < 1) throw new QueryGrammarError(param, `"${String(raw)}" is not a positive integer`);
454
+ return value;
455
+ }
456
+ function cursorValue(raw) {
457
+ if (raw === void 0 || raw === null) return void 0;
458
+ const value = String(raw).trim();
459
+ return value === "" ? void 0 : value;
460
+ }
461
+ //#endregion
462
+ export { CORE_OPERATORS, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_MAX_SEARCH_LENGTH, DEFAULT_MAX_TEXT_LENGTH, OPERATOR_DESCRIPTIONS, URL_OPERATORS, clausesToFilter, readFilterClauses, readPageRequest, readSearch, readSelect, readSort, toRecord };
@@ -1,5 +1,8 @@
1
+ import { CoerceOptions, QueryFieldType, QueryScalar, coerceQueryValue } from "./coerce.mjs";
2
+ import { INVALID_QUERY_INPUT, QueryErrorKind, QueryGrammarError, isQueryGrammarError } from "./errors.mjs";
1
3
  import { BracketOperator, ParsedPopulate, ParsedQuery, ParsedSelect, ParsedSort, ParsedSortDirection, QueryParserInput, QueryParserOptions } from "./types.mjs";
2
- import { coerceList, coerceValue } from "./coerce.mjs";
4
+ import { CORE_OPERATORS, CoreOperator, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_MAX_SEARCH_LENGTH, DEFAULT_MAX_TEXT_LENGTH, ExtensionClause, FilterClause, FilterReadResult, OPERATOR_DESCRIPTIONS, PageOptions, PageRequest, QueryGrammarOptions, QueryInput, URL_OPERATORS, clausesToFilter, readFilterClauses, readPageRequest, readSearch, readSelect, readSort, toRecord } from "./grammar.mjs";
3
5
  import { parseUrl } from "./parse-url.mjs";
6
+ import { MAX_UNBOUNDED_QUANTIFIERS, RegexRisk, assessRegex, escapeRegex } from "./regex-safety.mjs";
4
7
  import { STANDARD_RESERVED_PARAMS, isControlParam } from "./reserved.mjs";
5
- export { type BracketOperator, type ParsedPopulate, type ParsedQuery, type ParsedSelect, type ParsedSort, type ParsedSortDirection, type QueryParserInput, type QueryParserOptions, STANDARD_RESERVED_PARAMS, coerceList, coerceValue, isControlParam, parseUrl };
8
+ export { type BracketOperator, CORE_OPERATORS, type CoerceOptions, type CoreOperator, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_MAX_SEARCH_LENGTH, DEFAULT_MAX_TEXT_LENGTH, type ExtensionClause, type FilterClause, type FilterReadResult, INVALID_QUERY_INPUT, MAX_UNBOUNDED_QUANTIFIERS, OPERATOR_DESCRIPTIONS, type PageOptions, type PageRequest, type ParsedPopulate, type ParsedQuery, type ParsedSelect, type ParsedSort, type ParsedSortDirection, type QueryErrorKind, type QueryFieldType, QueryGrammarError, type QueryGrammarOptions, type QueryInput, type QueryParserInput, type QueryParserOptions, type QueryScalar, type RegexRisk, STANDARD_RESERVED_PARAMS, URL_OPERATORS, assessRegex, clausesToFilter, coerceQueryValue, escapeRegex, isControlParam, isQueryGrammarError, parseUrl, readFilterClauses, readPageRequest, readSearch, readSelect, readSort, toRecord };
@@ -1,4 +1,7 @@
1
- import { coerceList, coerceValue } from "./coerce.mjs";
1
+ import { INVALID_QUERY_INPUT, QueryGrammarError, isQueryGrammarError } from "./errors.mjs";
2
+ import { coerceQueryValue } from "./coerce.mjs";
3
+ import { MAX_UNBOUNDED_QUANTIFIERS, assessRegex, escapeRegex } from "./regex-safety.mjs";
2
4
  import { STANDARD_RESERVED_PARAMS, isControlParam } from "./reserved.mjs";
5
+ import { CORE_OPERATORS, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_MAX_SEARCH_LENGTH, DEFAULT_MAX_TEXT_LENGTH, OPERATOR_DESCRIPTIONS, URL_OPERATORS, clausesToFilter, readFilterClauses, readPageRequest, readSearch, readSelect, readSort, toRecord } from "./grammar.mjs";
3
6
  import { parseUrl } from "./parse-url.mjs";
4
- export { STANDARD_RESERVED_PARAMS, coerceList, coerceValue, isControlParam, parseUrl };
7
+ export { CORE_OPERATORS, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_MAX_SEARCH_LENGTH, DEFAULT_MAX_TEXT_LENGTH, INVALID_QUERY_INPUT, MAX_UNBOUNDED_QUANTIFIERS, OPERATOR_DESCRIPTIONS, QueryGrammarError, STANDARD_RESERVED_PARAMS, URL_OPERATORS, assessRegex, clausesToFilter, coerceQueryValue, escapeRegex, isControlParam, isQueryGrammarError, parseUrl, readFilterClauses, readPageRequest, readSearch, readSelect, readSort, toRecord };
@@ -1,6 +1,6 @@
1
1
  import { ParsedQuery, QueryParserInput, QueryParserOptions } from "./types.mjs";
2
2
  //#region src/query-parser/parse-url.d.ts
3
- /** Parse URL search params into a driver-agnostic ParsedQuery. */
3
+ /** Parse URL search params into a driver-agnostic ParsedQuery. Invalid input throws a 400. */
4
4
  declare function parseUrl(input: QueryParserInput, options?: QueryParserOptions): ParsedQuery;
5
5
  //#endregion
6
6
  export { parseUrl };