@classytic/repo-core 0.1.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 (84) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -0
  4. package/dist/cache/index.d.mts +4 -0
  5. package/dist/cache/index.mjs +3 -0
  6. package/dist/cache/memory-adapter.d.mts +7 -0
  7. package/dist/cache/memory-adapter.mjs +37 -0
  8. package/dist/cache/stable-stringify.d.mts +15 -0
  9. package/dist/cache/stable-stringify.mjs +19 -0
  10. package/dist/cache/types.d.mts +59 -0
  11. package/dist/context/index.d.mts +2 -0
  12. package/dist/context/index.mjs +0 -0
  13. package/dist/context/types.d.mts +24 -0
  14. package/dist/errors/create-error.d.mts +19 -0
  15. package/dist/errors/create-error.mjs +23 -0
  16. package/dist/errors/duplicate-key.d.mts +38 -0
  17. package/dist/errors/duplicate-key.mjs +57 -0
  18. package/dist/errors/index.d.mts +4 -0
  19. package/dist/errors/index.mjs +3 -0
  20. package/dist/errors/types.d.mts +37 -0
  21. package/dist/filter/builders.d.mts +60 -0
  22. package/dist/filter/builders.mjs +172 -0
  23. package/dist/filter/guard.d.mts +13 -0
  24. package/dist/filter/guard.mjs +34 -0
  25. package/dist/filter/index.d.mts +7 -0
  26. package/dist/filter/index.mjs +6 -0
  27. package/dist/filter/match.d.mts +12 -0
  28. package/dist/filter/match.mjs +91 -0
  29. package/dist/filter/scope.d.mts +31 -0
  30. package/dist/filter/scope.mjs +54 -0
  31. package/dist/filter/types.d.mts +143 -0
  32. package/dist/filter/walk.d.mts +24 -0
  33. package/dist/filter/walk.mjs +77 -0
  34. package/dist/hooks/engine.d.mts +48 -0
  35. package/dist/hooks/engine.mjs +101 -0
  36. package/dist/hooks/events.d.mts +95 -0
  37. package/dist/hooks/events.mjs +93 -0
  38. package/dist/hooks/index.d.mts +5 -0
  39. package/dist/hooks/index.mjs +4 -0
  40. package/dist/hooks/priority.d.mts +23 -0
  41. package/dist/hooks/priority.mjs +21 -0
  42. package/dist/hooks/types.d.mts +37 -0
  43. package/dist/lookup/index.d.mts +2 -0
  44. package/dist/lookup/index.mjs +0 -0
  45. package/dist/lookup/types.d.mts +170 -0
  46. package/dist/operations/index.d.mts +3 -0
  47. package/dist/operations/index.mjs +2 -0
  48. package/dist/operations/registry.d.mts +41 -0
  49. package/dist/operations/registry.mjs +140 -0
  50. package/dist/operations/types.d.mts +49 -0
  51. package/dist/pagination/cursor.d.mts +44 -0
  52. package/dist/pagination/cursor.mjs +150 -0
  53. package/dist/pagination/index.d.mts +5 -0
  54. package/dist/pagination/index.mjs +4 -0
  55. package/dist/pagination/keyset.d.mts +25 -0
  56. package/dist/pagination/keyset.mjs +61 -0
  57. package/dist/pagination/offset.d.mts +26 -0
  58. package/dist/pagination/offset.mjs +47 -0
  59. package/dist/pagination/types.d.mts +136 -0
  60. package/dist/query-parser/coerce.d.mts +16 -0
  61. package/dist/query-parser/coerce.mjs +73 -0
  62. package/dist/query-parser/index.d.mts +4 -0
  63. package/dist/query-parser/index.mjs +3 -0
  64. package/dist/query-parser/parse-url.d.mts +7 -0
  65. package/dist/query-parser/parse-url.mjs +224 -0
  66. package/dist/query-parser/types.d.mts +104 -0
  67. package/dist/repository/base.d.mts +90 -0
  68. package/dist/repository/base.mjs +111 -0
  69. package/dist/repository/index.d.mts +5 -0
  70. package/dist/repository/index.mjs +3 -0
  71. package/dist/repository/plugin-types.d.mts +27 -0
  72. package/dist/repository/plugin-types.mjs +45 -0
  73. package/dist/repository/types.d.mts +470 -0
  74. package/dist/schema/field-rules.d.mts +62 -0
  75. package/dist/schema/field-rules.mjs +110 -0
  76. package/dist/schema/index.d.mts +3 -0
  77. package/dist/schema/index.mjs +2 -0
  78. package/dist/schema/types.d.mts +138 -0
  79. package/dist/testing/conformance.d.mts +6 -0
  80. package/dist/testing/conformance.mjs +481 -0
  81. package/dist/testing/index.d.mts +3 -0
  82. package/dist/testing/index.mjs +2 -0
  83. package/dist/testing/types.d.mts +113 -0
  84. package/package.json +130 -0
@@ -0,0 +1,61 @@
1
+ //#region src/pagination/keyset.ts
2
+ /**
3
+ * Normalize a sort object so non-`_id` fields come first, `_id` last.
4
+ * Stable ordering is required for cursor comparability across requests.
5
+ */
6
+ function normalizeSort(sort) {
7
+ const normalized = {};
8
+ for (const key of Object.keys(sort)) if (key !== "_id") {
9
+ const direction = sort[key];
10
+ if (direction !== void 0) normalized[key] = direction;
11
+ }
12
+ const idDirection = sort["_id"];
13
+ if (idDirection !== void 0) normalized["_id"] = idDirection;
14
+ return normalized;
15
+ }
16
+ /**
17
+ * Validate a sort spec for keyset pagination and return the normalized form.
18
+ *
19
+ * - Rejects empty sorts (keyset needs at least one field).
20
+ * - Rejects non-`±1` directions.
21
+ * - Rejects mixed directions across fields (keyset can't straddle directions).
22
+ * - Auto-adds `_id` as tie-breaker (matching the primary direction) when absent.
23
+ * - When `allowedPrimaryFields` is non-empty, rejects primary fields outside
24
+ * the allowlist (protects against lossy null-boundary keyset).
25
+ */
26
+ function validateKeysetSort(sort, allowedPrimaryFields) {
27
+ const keys = Object.keys(sort);
28
+ if (keys.length === 0) throw new Error("Keyset pagination requires at least one sort field");
29
+ if (keys.length === 1 && keys[0] === "_id") return normalizeSort(sort);
30
+ for (const key of keys) {
31
+ const direction = sort[key];
32
+ if (direction !== 1 && direction !== -1) throw new Error(`Invalid sort direction for "${key}": must be 1 or -1, got ${String(direction)}`);
33
+ }
34
+ const nonIdKeys = keys.filter((k) => k !== "_id");
35
+ const firstNonId = nonIdKeys[0];
36
+ if (firstNonId === void 0) return normalizeSort(sort);
37
+ const primaryDirection = sort[firstNonId];
38
+ if (allowedPrimaryFields && allowedPrimaryFields.length > 0) {
39
+ for (const key of nonIdKeys) if (!allowedPrimaryFields.includes(key)) throw new Error(`Keyset sort field "${key}" is not in the strictKeysetSortFields allowlist. Allowed: ${allowedPrimaryFields.join(", ")}. (Protects against lossy null/non-null keyset boundaries.)`);
40
+ }
41
+ for (const key of nonIdKeys) if (sort[key] !== primaryDirection) throw new Error("All sort fields must share the same direction for keyset pagination");
42
+ if (keys.includes("_id") && sort["_id"] !== primaryDirection) throw new Error("_id direction must match primary field direction");
43
+ if (!keys.includes("_id")) return normalizeSort({
44
+ ...sort,
45
+ _id: primaryDirection
46
+ });
47
+ return normalizeSort(sort);
48
+ }
49
+ /** Invert every direction in a sort (ascending ↔ descending). */
50
+ function invertSort(sort) {
51
+ const inverted = {};
52
+ for (const key of Object.keys(sort)) inverted[key] = sort[key] === 1 ? -1 : 1;
53
+ return inverted;
54
+ }
55
+ /** Primary (first non-`_id`) sort field; falls back to `_id`. */
56
+ function getPrimaryField(sort) {
57
+ for (const key of Object.keys(sort)) if (key !== "_id") return key;
58
+ return "_id";
59
+ }
60
+ //#endregion
61
+ export { getPrimaryField, invertSort, normalizeSort, validateKeysetSort };
@@ -0,0 +1,26 @@
1
+ import { PaginationConfig } from "./types.mjs";
2
+
3
+ //#region src/pagination/offset.d.ts
4
+ /**
5
+ * Parse, clamp, and sanitize a `limit` value. Accepts string or number
6
+ * input (URL params arrive as strings). Returns the configured default
7
+ * when input is not a finite positive number.
8
+ *
9
+ * `config.maxLimit === 0` disables the upper cap (advanced usage only —
10
+ * unbounded page size is a footgun).
11
+ */
12
+ declare function validateLimit(limit: number | string, config: PaginationConfig): number;
13
+ /**
14
+ * Parse, clamp, and sanitize a 1-indexed `page` value.
15
+ * Throws when page exceeds `config.maxPage` — deep offset pagination is
16
+ * pathological and should be caught at the boundary.
17
+ */
18
+ declare function validatePage(page: number | string, config: PaginationConfig): number;
19
+ /** True when `page` is past the deep-pagination warning threshold. */
20
+ declare function shouldWarnDeepPagination(page: number, threshold: number): boolean;
21
+ /** Documents to skip for a given 1-indexed page + limit. */
22
+ declare function calculateSkip(page: number, limit: number): number;
23
+ /** Total page count from total rows + per-page limit. Zero-safe. */
24
+ declare function calculateTotalPages(total: number, limit: number): number;
25
+ //#endregion
26
+ export { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage };
@@ -0,0 +1,47 @@
1
+ //#region src/pagination/offset.ts
2
+ const DEFAULT_LIMIT = 10;
3
+ const DEFAULT_MAX_LIMIT = 100;
4
+ const DEFAULT_MAX_PAGE = 1e4;
5
+ /**
6
+ * Parse, clamp, and sanitize a `limit` value. Accepts string or number
7
+ * input (URL params arrive as strings). Returns the configured default
8
+ * when input is not a finite positive number.
9
+ *
10
+ * `config.maxLimit === 0` disables the upper cap (advanced usage only —
11
+ * unbounded page size is a footgun).
12
+ */
13
+ function validateLimit(limit, config) {
14
+ const parsed = Number(limit);
15
+ if (!Number.isFinite(parsed) || parsed < 1) return config.defaultLimit ?? DEFAULT_LIMIT;
16
+ const max = config.maxLimit ?? DEFAULT_MAX_LIMIT;
17
+ if (max === 0) return Math.floor(parsed);
18
+ return Math.min(Math.floor(parsed), max);
19
+ }
20
+ /**
21
+ * Parse, clamp, and sanitize a 1-indexed `page` value.
22
+ * Throws when page exceeds `config.maxPage` — deep offset pagination is
23
+ * pathological and should be caught at the boundary.
24
+ */
25
+ function validatePage(page, config) {
26
+ const parsed = Number(page);
27
+ if (!Number.isFinite(parsed) || parsed < 1) return 1;
28
+ const sanitized = Math.floor(parsed);
29
+ const maxPage = config.maxPage ?? DEFAULT_MAX_PAGE;
30
+ if (sanitized > maxPage) throw new Error(`Page ${String(sanitized)} exceeds maximum ${String(maxPage)}`);
31
+ return sanitized;
32
+ }
33
+ /** True when `page` is past the deep-pagination warning threshold. */
34
+ function shouldWarnDeepPagination(page, threshold) {
35
+ return page > threshold;
36
+ }
37
+ /** Documents to skip for a given 1-indexed page + limit. */
38
+ function calculateSkip(page, limit) {
39
+ return (page - 1) * limit;
40
+ }
41
+ /** Total page count from total rows + per-page limit. Zero-safe. */
42
+ function calculateTotalPages(total, limit) {
43
+ if (limit <= 0) return 0;
44
+ return Math.ceil(total / limit);
45
+ }
46
+ //#endregion
47
+ export { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage };
@@ -0,0 +1,136 @@
1
+ //#region src/pagination/types.d.ts
2
+ /**
3
+ * Pagination primitives — driver-agnostic type surface.
4
+ *
5
+ * These types are the vocabulary shared between arc, the driver kits,
6
+ * and any consumer that talks to a repository. Kits extend with their
7
+ * own option types (e.g. mongokit adds `populate`, `collation`); those
8
+ * extensions never change the shape of the result envelope.
9
+ */
10
+ /** Ascending (1) or descending (-1). */
11
+ type SortDirection = 1 | -1;
12
+ /** Sort specification keyed by field path. */
13
+ type SortSpec = Record<string, SortDirection>;
14
+ /** Global pagination configuration, configured once per repository. */
15
+ interface PaginationConfig {
16
+ /** Default documents per page when caller omits `limit`. Default: 10. */
17
+ defaultLimit?: number;
18
+ /** Hard ceiling for `limit`. `0` means unlimited. Default: 100. */
19
+ maxLimit?: number;
20
+ /** Hard ceiling for `page`. Throws above this. Default: 10_000. */
21
+ maxPage?: number;
22
+ /** Page index that triggers a deep-pagination warning. Default: 100. */
23
+ deepPageThreshold?: number;
24
+ /** Cursor version — bump when the payload format changes. Default: 1. */
25
+ cursorVersion?: number;
26
+ /**
27
+ * Minimum cursor version accepted. Bump alongside `cursorVersion` when a
28
+ * breaking format change ships so stale client cursors are rejected with
29
+ * a clear error rather than silently resuming from the wrong position.
30
+ */
31
+ minCursorVersion?: number;
32
+ /**
33
+ * Allowlist of primary sort fields for keyset pagination. When set, any
34
+ * keyset request whose primary (non-`_id`) sort field isn't listed throws
35
+ * at validation time. Use this to lock keyset sorts to fields your schema
36
+ * guarantees non-null — keyset across null/non-null boundaries is lossy.
37
+ *
38
+ * `_id` is always allowed regardless of this list.
39
+ */
40
+ strictKeysetSortFields?: string[];
41
+ }
42
+ /**
43
+ * Known value types that round-trip through a cursor.
44
+ *
45
+ * `objectid` / `uuid` are NOT in this core union — repo-core is driver-free,
46
+ * so it treats any non-primitive string id as a plain `string`. Kits that
47
+ * need typed id rehydration (mongokit wants real `ObjectId` instances) can
48
+ * tag their own type in the payload and post-process on decode. Unknown
49
+ * tags round-trip unchanged as strings.
50
+ */
51
+ type ValueType = 'date' | 'boolean' | 'number' | 'string' | 'null' | 'unknown';
52
+ /** Raw cursor payload — the base64url JSON blob behind a cursor token. */
53
+ interface CursorPayload {
54
+ /** Primary sort field value (legacy single-field compatibility). */
55
+ v: string | number | boolean | null;
56
+ /** Primary sort field value type tag. Open string so kits can extend. */
57
+ t: string;
58
+ /** Document id, serialized as string. */
59
+ id: string;
60
+ /** Document id type tag. Open string so kits can extend (e.g. `objectid`). */
61
+ idType: string;
62
+ /** Sort specification this cursor was built against. */
63
+ sort: SortSpec;
64
+ /** Cursor format version. */
65
+ ver: number;
66
+ /** Compound sort field values (multi-field keyset). */
67
+ vals?: Record<string, string | number | boolean | null>;
68
+ /** Compound sort value type tags. */
69
+ types?: Record<string, string>;
70
+ }
71
+ /** Decoded cursor with values rehydrated to their declared types. */
72
+ interface DecodedCursor {
73
+ /** Primary sort field value (rehydrated). */
74
+ value: unknown;
75
+ /** Document id (rehydrated — string for unknown id types). */
76
+ id: unknown;
77
+ /** Sort specification. */
78
+ sort: SortSpec;
79
+ /** Cursor format version. */
80
+ version: number;
81
+ /** Compound sort field values (rehydrated). Present when the cursor was built from a multi-field sort. */
82
+ values?: Record<string, unknown>;
83
+ }
84
+ /**
85
+ * Core fields of an offset-paginated result. Don't consume this directly —
86
+ * use `OffsetPaginationResult<TDoc>` or `OffsetPaginationResult<TDoc, TExtra>`.
87
+ */
88
+ interface OffsetPaginationResultCore<TDoc> {
89
+ method: 'offset';
90
+ docs: TDoc[];
91
+ page: number;
92
+ limit: number;
93
+ total: number;
94
+ pages: number;
95
+ hasNext: boolean;
96
+ hasPrev: boolean;
97
+ }
98
+ /**
99
+ * Offset-paginated result envelope.
100
+ *
101
+ * `TExtra` lets kits surface typed extras alongside the core envelope —
102
+ * mongokit emits `warning?: string` on deep-page reads, pgkit could surface
103
+ * `queryPlan`, sqlitekit could surface vacuum hints. Defaults to `{}` so
104
+ * consumers that don't care see zero change (`OffsetPaginationResult<User>`
105
+ * behaves exactly as before).
106
+ *
107
+ * The `method: 'offset'` discriminant carries through the intersection so
108
+ * `if (result.method === 'offset')` narrowing keeps working.
109
+ *
110
+ * @example Kit extends with typed extras
111
+ * ```ts
112
+ * type MongokitPage<T> = OffsetPaginationResult<T, { warning?: string }>;
113
+ * ```
114
+ */
115
+ type OffsetPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResultCore<TDoc> & TExtra;
116
+ /**
117
+ * Core fields of a keyset-paginated result. Don't consume this directly —
118
+ * use `KeysetPaginationResult<TDoc>` or `KeysetPaginationResult<TDoc, TExtra>`.
119
+ */
120
+ interface KeysetPaginationResultCore<TDoc> {
121
+ method: 'keyset';
122
+ docs: TDoc[];
123
+ limit: number;
124
+ hasMore: boolean;
125
+ /** Cursor token for the next page, or `null` when there is none. */
126
+ next: string | null;
127
+ }
128
+ /**
129
+ * Keyset-paginated result envelope.
130
+ *
131
+ * `TExtra` parallels `OffsetPaginationResult` — see that type's docstring
132
+ * for the rationale. Defaults to `{}`.
133
+ */
134
+ type KeysetPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = KeysetPaginationResultCore<TDoc> & TExtra;
135
+ //#endregion
136
+ export { CursorPayload, DecodedCursor, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResult, OffsetPaginationResultCore, PaginationConfig, SortDirection, SortSpec, ValueType };
@@ -0,0 +1,16 @@
1
+ import { QueryParserOptions } from "./types.mjs";
2
+
3
+ //#region src/query-parser/coerce.d.ts
4
+ /**
5
+ * Coerce a single URL value to its field-declared type, or to a best-guess
6
+ * scalar when no hint exists. Always returns `string`, `number`, `boolean`,
7
+ * `Date`, or `null` — never `undefined`.
8
+ */
9
+ declare function coerceValue(rawValue: string, fieldType: QueryParserOptions['fieldTypes'] extends infer T ? T extends Record<string, infer V> ? V | undefined : undefined : undefined): unknown;
10
+ /**
11
+ * Split a comma-separated URL value into an array of coerced scalars.
12
+ * Used by `in`/`nin`/`between` which accept `field[in]=a,b,c`.
13
+ */
14
+ declare function coerceList(rawValue: string, fieldType: Parameters<typeof coerceValue>[1]): unknown[];
15
+ //#endregion
16
+ export { coerceList, coerceValue };
@@ -0,0 +1,73 @@
1
+ //#region src/query-parser/coerce.ts
2
+ const BOOLEAN_STRINGS = new Set([
3
+ "true",
4
+ "1",
5
+ "yes",
6
+ "on"
7
+ ]);
8
+ const FALSEY_STRINGS = new Set([
9
+ "false",
10
+ "0",
11
+ "no",
12
+ "off"
13
+ ]);
14
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
15
+ /**
16
+ * Coerce a single URL value to its field-declared type, or to a best-guess
17
+ * scalar when no hint exists. Always returns `string`, `number`, `boolean`,
18
+ * `Date`, or `null` — never `undefined`.
19
+ */
20
+ function coerceValue(rawValue, fieldType) {
21
+ if (rawValue === "null") return null;
22
+ switch (fieldType) {
23
+ case "number": {
24
+ const n = Number(rawValue);
25
+ return Number.isFinite(n) ? n : rawValue;
26
+ }
27
+ case "boolean": return BOOLEAN_STRINGS.has(rawValue.toLowerCase());
28
+ case "date": {
29
+ const d = new Date(rawValue);
30
+ return Number.isNaN(d.getTime()) ? rawValue : d;
31
+ }
32
+ case "string": return rawValue;
33
+ default: return heuristicCoerce(rawValue);
34
+ }
35
+ }
36
+ /**
37
+ * Heuristic used when no field-type hint applies. Deliberately conservative:
38
+ *
39
+ * - Pure boolean strings ("true", "false") → boolean.
40
+ * - Unambiguous ISO-8601 dates → Date.
41
+ * - Integers that don't start with 0 (unless literal "0") → number.
42
+ * - Floats → number.
43
+ * - Anything else stays a string.
44
+ *
45
+ * We do NOT coerce arbitrary-looking numeric strings ("12345") because
46
+ * they're routinely SKUs, order IDs, or phone numbers. Callers needing
47
+ * reliable numeric coercion pass `fieldTypes: { age: 'number' }`.
48
+ */
49
+ function heuristicCoerce(value) {
50
+ if (BOOLEAN_STRINGS.has(value) && value.length <= 5) {
51
+ if (value === "true" || value === "false") return value === "true";
52
+ }
53
+ if (FALSEY_STRINGS.has(value) && (value === "false" || value === "true")) return value === "true";
54
+ if (ISO_DATE_RE.test(value)) {
55
+ const d = new Date(value);
56
+ if (!Number.isNaN(d.getTime())) return d;
57
+ }
58
+ if (/^-?\d+\.\d+$/.test(value)) {
59
+ const n = Number(value);
60
+ if (Number.isFinite(n)) return n;
61
+ }
62
+ return value;
63
+ }
64
+ /**
65
+ * Split a comma-separated URL value into an array of coerced scalars.
66
+ * Used by `in`/`nin`/`between` which accept `field[in]=a,b,c`.
67
+ */
68
+ function coerceList(rawValue, fieldType) {
69
+ if (rawValue.length === 0) return [];
70
+ return rawValue.split(",").map((v) => coerceValue(v.trim(), fieldType));
71
+ }
72
+ //#endregion
73
+ export { coerceList, coerceValue };
@@ -0,0 +1,4 @@
1
+ import { BracketOperator, ParsedPopulate, ParsedQuery, ParsedSelect, ParsedSort, ParsedSortDirection, QueryParserInput, QueryParserOptions } from "./types.mjs";
2
+ import { coerceList, coerceValue } from "./coerce.mjs";
3
+ import { parseUrl } from "./parse-url.mjs";
4
+ export { type BracketOperator, type ParsedPopulate, type ParsedQuery, type ParsedSelect, type ParsedSort, type ParsedSortDirection, type QueryParserInput, type QueryParserOptions, coerceList, coerceValue, parseUrl };
@@ -0,0 +1,3 @@
1
+ import { coerceList, coerceValue } from "./coerce.mjs";
2
+ import { parseUrl } from "./parse-url.mjs";
3
+ export { coerceList, coerceValue, parseUrl };
@@ -0,0 +1,7 @@
1
+ import { ParsedQuery, QueryParserInput, QueryParserOptions } from "./types.mjs";
2
+
3
+ //#region src/query-parser/parse-url.d.ts
4
+ /** Parse URL search params into a driver-agnostic ParsedQuery. */
5
+ declare function parseUrl(input: QueryParserInput, options?: QueryParserOptions): ParsedQuery;
6
+ //#endregion
7
+ export { parseUrl };
@@ -0,0 +1,224 @@
1
+ import { TRUE, and, between, contains, endsWith, eq, gt, gte, iEq, in_, isNotNull, isNull, like, lt, lte, ne, nin, regex, startsWith } from "../filter/builders.mjs";
2
+ import { coerceList, coerceValue } from "./coerce.mjs";
3
+ //#region src/query-parser/parse-url.ts
4
+ const DEFAULT_LIMIT = 20;
5
+ const DEFAULT_MAX_LIMIT = 200;
6
+ const DEFAULT_MAX_DEPTH = 10;
7
+ const DEFAULT_MAX_REGEX = 500;
8
+ const DEFAULT_MAX_SEARCH = 200;
9
+ /** Reserved top-level URL keys the parser handles specially. */
10
+ const RESERVED_KEYS = new Set([
11
+ "page",
12
+ "limit",
13
+ "after",
14
+ "sort",
15
+ "select",
16
+ "populate",
17
+ "search"
18
+ ]);
19
+ const ALL_OPERATORS = new Set([
20
+ "eq",
21
+ "ne",
22
+ "gt",
23
+ "gte",
24
+ "lt",
25
+ "lte",
26
+ "in",
27
+ "nin",
28
+ "like",
29
+ "contains",
30
+ "startsWith",
31
+ "endsWith",
32
+ "ieq",
33
+ "regex",
34
+ "between",
35
+ "exists"
36
+ ]);
37
+ /** Parse URL search params into a driver-agnostic ParsedQuery. */
38
+ function parseUrl(input, options = {}) {
39
+ const params = normalize(input);
40
+ const maxLimit = options.maxLimit ?? DEFAULT_MAX_LIMIT;
41
+ const allowedOps = options.allowedOperators ? new Set(options.allowedOperators) : ALL_OPERATORS;
42
+ const rawPage = params.get("page");
43
+ const rawLimit = params.get("limit");
44
+ const after = params.get("after") ?? void 0;
45
+ const limit = clampLimit(rawLimit, options.defaultLimit ?? DEFAULT_LIMIT, maxLimit);
46
+ const page = rawPage !== null && rawPage !== void 0 ? toPositiveInt(rawPage) : void 0;
47
+ const sort = parseSort(params.get("sort"), options.allowedSortFields);
48
+ const select = parseSelect(params.get("select"));
49
+ const populate = parsePopulate(params);
50
+ const rawSearch = params.get("search");
51
+ const searchCap = options.maxSearchLength ?? DEFAULT_MAX_SEARCH;
52
+ const search = rawSearch !== null && rawSearch !== void 0 && rawSearch.length > 0 ? rawSearch.slice(0, searchCap) : void 0;
53
+ const result = {
54
+ filter: parseFilters(params, {
55
+ allowedFields: options.allowedFilterFields,
56
+ allowedOps,
57
+ fieldTypes: options.fieldTypes,
58
+ maxDepth: options.maxFilterDepth ?? DEFAULT_MAX_DEPTH,
59
+ maxRegex: options.maxRegexLength ?? DEFAULT_MAX_REGEX
60
+ }),
61
+ limit
62
+ };
63
+ if (sort) result.sort = sort;
64
+ if (select) result.select = select;
65
+ if (populate.length > 0) result.populate = populate;
66
+ if (page !== void 0) result.page = page;
67
+ if (after !== void 0) result.after = after;
68
+ if (search !== void 0) result.search = search;
69
+ return result;
70
+ }
71
+ function normalize(input) {
72
+ if (input instanceof URLSearchParams) return {
73
+ get: (k) => input.get(k),
74
+ entries: () => input.entries(),
75
+ has: (k) => input.has(k)
76
+ };
77
+ if (Symbol.iterator in input) {
78
+ const usp = new URLSearchParams();
79
+ for (const [k, v] of input) usp.append(k, v);
80
+ return {
81
+ get: (k) => usp.get(k),
82
+ entries: () => usp.entries(),
83
+ has: (k) => usp.has(k)
84
+ };
85
+ }
86
+ const record = input;
87
+ const usp = new URLSearchParams();
88
+ for (const [k, v] of Object.entries(record)) {
89
+ if (v === void 0) continue;
90
+ if (Array.isArray(v)) for (const item of v) usp.append(k, item);
91
+ else usp.append(k, v);
92
+ }
93
+ return {
94
+ get: (k) => usp.get(k),
95
+ entries: () => usp.entries(),
96
+ has: (k) => usp.has(k)
97
+ };
98
+ }
99
+ function clampLimit(raw, fallback, max) {
100
+ if (raw === null || raw === void 0) return fallback;
101
+ const n = Number(raw);
102
+ if (!Number.isFinite(n) || n < 1) return fallback;
103
+ return Math.min(Math.floor(n), max);
104
+ }
105
+ function toPositiveInt(raw) {
106
+ const n = Number(raw);
107
+ if (!Number.isFinite(n) || n < 1) return void 0;
108
+ return Math.floor(n);
109
+ }
110
+ function parseSort(raw, allowed) {
111
+ if (!raw) return void 0;
112
+ const spec = {};
113
+ for (const piece of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
114
+ const desc = piece.startsWith("-");
115
+ const field = desc ? piece.slice(1) : piece.startsWith("+") ? piece.slice(1) : piece;
116
+ if (allowed && !allowed.includes(field)) continue;
117
+ spec[field] = desc ? -1 : 1;
118
+ }
119
+ return Object.keys(spec).length > 0 ? spec : void 0;
120
+ }
121
+ function parseSelect(raw) {
122
+ if (!raw) return void 0;
123
+ const spec = {};
124
+ for (const piece of raw.split(",").map((s) => s.trim()).filter(Boolean)) if (piece.startsWith("-")) spec[piece.slice(1)] = 0;
125
+ else spec[piece] = 1;
126
+ return Object.keys(spec).length > 0 ? spec : void 0;
127
+ }
128
+ /**
129
+ * Parse `populate[field][select]=...&populate[field][match][other]=...` into
130
+ * an array of ParsedPopulate specs. Flat iteration keeps the parser simple;
131
+ * nested populate (`populate[author][populate][org][select]=...`) is supported
132
+ * one level deep.
133
+ */
134
+ function parsePopulate(params) {
135
+ const byField = /* @__PURE__ */ new Map();
136
+ for (const [key, value] of params.entries()) {
137
+ if (!key.startsWith("populate[")) continue;
138
+ const match = /^populate\[([^\]]+)\](?:\[([^\]]+)\](?:\[([^\]]+)\])?)?$/.exec(key);
139
+ if (!match) continue;
140
+ const [, field, sub, subKey] = match;
141
+ if (!field) continue;
142
+ const existing = byField.get(field) ?? {};
143
+ if (!sub) byField.set(field, existing);
144
+ else if (sub === "select") {
145
+ existing.select = value;
146
+ byField.set(field, existing);
147
+ } else if (sub === "match" && subKey) {
148
+ existing.match = existing.match ?? {};
149
+ existing.match[subKey] = value;
150
+ byField.set(field, existing);
151
+ }
152
+ }
153
+ const out = [];
154
+ for (const [path, spec] of byField) {
155
+ const entry = { path };
156
+ if (spec.select !== void 0) entry.select = spec.select;
157
+ if (spec.match) entry.match = spec.match;
158
+ out.push(entry);
159
+ }
160
+ return out;
161
+ }
162
+ function parseFilters(params, ctx) {
163
+ const leaves = [];
164
+ const fieldGroups = /* @__PURE__ */ new Map();
165
+ for (const [key, rawValue] of params.entries()) {
166
+ if (RESERVED_KEYS.has(key) || key.startsWith("populate[")) continue;
167
+ const bracket = /^([^[\]]+)\[([^\]]+)\]$/.exec(key);
168
+ let field;
169
+ let op;
170
+ if (bracket) {
171
+ const [, f, o] = bracket;
172
+ if (!f || !o) continue;
173
+ field = f;
174
+ op = o;
175
+ } else {
176
+ field = key;
177
+ op = "eq";
178
+ }
179
+ if (ctx.allowedFields && !ctx.allowedFields.includes(field)) continue;
180
+ if (!ctx.allowedOps.has(op)) continue;
181
+ const fieldType = ctx.fieldTypes?.[field];
182
+ const leaf = buildLeaf(field, op, rawValue, fieldType, ctx);
183
+ if (!leaf) continue;
184
+ const bucket = fieldGroups.get(field) ?? [];
185
+ bucket.push(leaf);
186
+ fieldGroups.set(field, bucket);
187
+ }
188
+ for (const [, nodes] of fieldGroups) if (nodes.length === 1) leaves.push(nodes[0]);
189
+ else leaves.push(and(...nodes));
190
+ if (leaves.length === 0) return TRUE;
191
+ if (leaves.length === 1) return leaves[0];
192
+ return and(...leaves);
193
+ }
194
+ function buildLeaf(field, op, rawValue, fieldType, ctx) {
195
+ switch (op) {
196
+ case "eq": return eq(field, coerceValue(rawValue, fieldType));
197
+ case "ne": return ne(field, coerceValue(rawValue, fieldType));
198
+ case "gt": return gt(field, coerceValue(rawValue, fieldType));
199
+ case "gte": return gte(field, coerceValue(rawValue, fieldType));
200
+ case "lt": return lt(field, coerceValue(rawValue, fieldType));
201
+ case "lte": return lte(field, coerceValue(rawValue, fieldType));
202
+ case "in": return in_(field, coerceList(rawValue, fieldType));
203
+ case "nin": return nin(field, coerceList(rawValue, fieldType));
204
+ case "like": return like(field, rawValue);
205
+ case "contains": return contains(field, rawValue);
206
+ case "startsWith": return startsWith(field, rawValue);
207
+ case "endsWith": return endsWith(field, rawValue);
208
+ case "ieq": return iEq(field, rawValue);
209
+ case "regex":
210
+ if (rawValue.length > ctx.maxRegex) return void 0;
211
+ return regex(field, rawValue);
212
+ case "between": {
213
+ const parts = coerceList(rawValue, fieldType);
214
+ if (parts.length < 2) return void 0;
215
+ return between(field, parts[0], parts[1]);
216
+ }
217
+ case "exists": {
218
+ const val = rawValue.toLowerCase();
219
+ return val === "true" || val === "1" ? isNotNull(field) : isNull(field);
220
+ }
221
+ }
222
+ }
223
+ //#endregion
224
+ export { parseUrl };