@classytic/repo-core 0.3.0 → 0.4.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 (73) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/dist/adapter/index.d.mts +3 -0
  3. package/dist/adapter/index.mjs +2 -0
  4. package/dist/adapter/types.d.mts +222 -0
  5. package/dist/adapter/widen.d.mts +22 -0
  6. package/dist/adapter/widen.mjs +26 -0
  7. package/dist/aggregate/index.d.mts +3 -0
  8. package/dist/aggregate/index.mjs +3 -0
  9. package/dist/aggregate/keyset.d.mts +57 -0
  10. package/dist/aggregate/keyset.mjs +45 -0
  11. package/dist/aggregate/normalize.d.mts +24 -0
  12. package/dist/aggregate/normalize.mjs +28 -0
  13. package/dist/better-auth/index.d.mts +110 -0
  14. package/dist/better-auth/index.mjs +71 -0
  15. package/dist/cache/engine.d.mts +127 -0
  16. package/dist/cache/engine.mjs +235 -0
  17. package/dist/cache/envelope.mjs +32 -0
  18. package/dist/cache/index.d.mts +7 -2
  19. package/dist/cache/index.mjs +6 -2
  20. package/dist/cache/keys.mjs +131 -0
  21. package/dist/cache/memory-adapter.mjs +41 -7
  22. package/dist/cache/options.d.mts +112 -0
  23. package/dist/cache/options.mjs +25 -0
  24. package/dist/cache/plugin/context.d.mts +18 -0
  25. package/dist/cache/plugin/context.mjs +121 -0
  26. package/dist/cache/plugin/index.d.mts +86 -0
  27. package/dist/cache/plugin/index.mjs +78 -0
  28. package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
  29. package/dist/cache/plugin/read-hooks.mjs +96 -0
  30. package/dist/cache/plugin/swr.mjs +20 -0
  31. package/dist/cache/runtime.d.mts +43 -0
  32. package/dist/cache/runtime.mjs +14 -0
  33. package/dist/cache/tag-index.mjs +84 -0
  34. package/dist/cache/timeout-adapter.d.mts +30 -0
  35. package/dist/cache/timeout-adapter.mjs +58 -0
  36. package/dist/cache/types.d.mts +45 -0
  37. package/dist/cache/version-store.mjs +57 -0
  38. package/dist/errors/index.d.mts +2 -1
  39. package/dist/errors/index.mjs +2 -1
  40. package/dist/errors/schema.d.mts +101 -0
  41. package/dist/errors/schema.mjs +78 -0
  42. package/dist/filter/match.mjs +38 -2
  43. package/dist/pagination/canonical.d.mts +8 -8
  44. package/dist/pagination/canonical.mjs +3 -9
  45. package/dist/pagination/cursor.mjs +4 -1
  46. package/dist/pagination/index.d.mts +2 -2
  47. package/dist/pagination/types.d.mts +17 -27
  48. package/dist/plugins/index.d.mts +2 -0
  49. package/dist/plugins/index.mjs +2 -0
  50. package/dist/plugins/tenant-helpers.d.mts +63 -0
  51. package/dist/plugins/tenant-helpers.mjs +84 -0
  52. package/dist/query-parser/index.d.mts +2 -1
  53. package/dist/query-parser/index.mjs +2 -1
  54. package/dist/query-parser/parse-url.mjs +13 -11
  55. package/dist/query-parser/reserved.d.mts +43 -0
  56. package/dist/query-parser/reserved.mjs +56 -0
  57. package/dist/repository/agg-output.d.mts +63 -0
  58. package/dist/repository/agg-output.mjs +89 -0
  59. package/dist/repository/index.d.mts +4 -2
  60. package/dist/repository/index.mjs +3 -1
  61. package/dist/repository/options.d.mts +62 -0
  62. package/dist/repository/options.mjs +57 -0
  63. package/dist/repository/types.d.mts +935 -48
  64. package/dist/schema/field-rules.d.mts +41 -1
  65. package/dist/schema/field-rules.mjs +92 -1
  66. package/dist/schema/index.d.mts +2 -2
  67. package/dist/schema/index.mjs +2 -2
  68. package/dist/schema/types.d.mts +21 -0
  69. package/dist/testing/conformance.mjs +666 -17
  70. package/dist/testing/index.d.mts +2 -2
  71. package/dist/testing/types.d.mts +99 -2
  72. package/package.json +19 -1
  73. package/dist/cache/stable-stringify.d.mts +0 -15
@@ -30,12 +30,12 @@ function matchFilter(doc, filter) {
30
30
  const v = getField(doc, filter.field);
31
31
  if (typeof v !== "string") return false;
32
32
  const flags = filter.caseSensitivity === "sensitive" ? "" : "i";
33
- return new RegExp(`^${likeToRegex(filter.pattern)}$`, flags).test(v);
33
+ return getOrCompileLike(filter.pattern, flags).test(v);
34
34
  }
35
35
  case "regex": {
36
36
  const v = getField(doc, filter.field);
37
37
  if (typeof v !== "string") return false;
38
- return new RegExp(filter.pattern, filter.flags).test(v);
38
+ return getOrCompileRegex(filter.pattern, filter.flags).test(v);
39
39
  }
40
40
  case "raw": return false;
41
41
  }
@@ -78,6 +78,42 @@ function toComparable(value) {
78
78
  if (typeof value === "number" || typeof value === "string") return value;
79
79
  if (typeof value === "boolean") return value ? 1 : 0;
80
80
  }
81
+ /**
82
+ * Compiled-RegExp caches keyed by `pattern|flags`. Without these, a
83
+ * filter run via `asPredicate(filter)` over an N-doc array compiles a
84
+ * fresh `new RegExp(...)` on every doc — at 100k docs and a non-trivial
85
+ * pattern, that's measurable. Bounded LRU eviction keeps the cache
86
+ * from growing unboundedly when callers hand us thousands of distinct
87
+ * patterns (e.g. "name LIKE %" personalization at scale).
88
+ */
89
+ const REGEX_CACHE_LIMIT = 256;
90
+ const likeCache = /* @__PURE__ */ new Map();
91
+ const regexCache = /* @__PURE__ */ new Map();
92
+ function getOrCompileLike(pattern, flags) {
93
+ const key = `${flags}|${pattern}`;
94
+ let re = likeCache.get(key);
95
+ if (re) return re;
96
+ re = new RegExp(`^${likeToRegex(pattern)}$`, flags);
97
+ if (likeCache.size >= REGEX_CACHE_LIMIT) {
98
+ const oldest = likeCache.keys().next().value;
99
+ if (oldest !== void 0) likeCache.delete(oldest);
100
+ }
101
+ likeCache.set(key, re);
102
+ return re;
103
+ }
104
+ function getOrCompileRegex(pattern, flags) {
105
+ const f = flags ?? "";
106
+ const key = `${f}|${pattern}`;
107
+ let re = regexCache.get(key);
108
+ if (re) return re;
109
+ re = new RegExp(pattern, f);
110
+ if (regexCache.size >= REGEX_CACHE_LIMIT) {
111
+ const oldest = regexCache.keys().next().value;
112
+ if (oldest !== void 0) regexCache.delete(oldest);
113
+ }
114
+ regexCache.set(key, re);
115
+ return re;
116
+ }
81
117
  /** SQL `LIKE` pattern → JS regex body. Escapes regex metachars; `%` → `.*`, `_` → `.`. */
82
118
  function likeToRegex(pattern) {
83
119
  let out = "";
@@ -1,4 +1,4 @@
1
- import { AnyPaginationResult, BareListResponse, PaginatedResponse } from "./types.mjs";
1
+ import { AnyPaginationResult, BareListResult, PaginatedResult } from "./types.mjs";
2
2
 
3
3
  //#region src/pagination/canonical.d.ts
4
4
  /**
@@ -11,7 +11,7 @@ import { AnyPaginationResult, BareListResponse, PaginatedResponse } from "./type
11
11
  *
12
12
  * Accepts `unknown` (rather than `T[] | AnyPaginationResult<T>`) so wire-
13
13
  * boundary callers can guard arbitrary inputs without pre-narrowing — the
14
- * arc / arc-next response pipeline routinely sees `{ docs: unknown[] }`
14
+ * arc / arc-next response pipeline routinely sees `{ data: unknown[] }`
15
15
  * shapes that are neither a bare array nor a paginated result, and forcing
16
16
  * those callers to cast first defeats the guard's purpose.
17
17
  */
@@ -20,16 +20,16 @@ declare function isPaginatedResult<TDoc>(input: unknown): input is AnyPagination
20
20
  * Normalise a list-shaped value into the canonical wire envelope.
21
21
  *
22
22
  * Overloads keep the return type tight:
23
- * - bare array → {@link BareListResponse}
24
- * - paginated → {@link PaginatedResponse} (preserves method discriminant)
23
+ * - bare array → {@link BareListResult}
24
+ * - paginated → {@link PaginatedResult} (preserves method discriminant)
25
25
  *
26
26
  * The mutable-array overload widens to `TDoc[]` because that's the most
27
27
  * common server input (kit results return `TDoc[]` for `docs`); the
28
28
  * readonly overload covers callers passing `readonly TDoc[]`.
29
29
  */
30
- declare function toCanonicalList<TDoc>(input: TDoc[]): BareListResponse<TDoc>;
31
- declare function toCanonicalList<TDoc>(input: readonly TDoc[]): BareListResponse<TDoc>;
32
- declare function toCanonicalList<TDoc, TExtra extends Record<string, unknown>>(input: AnyPaginationResult<TDoc, TExtra>): PaginatedResponse<TDoc, TExtra>;
33
- declare function toCanonicalList<TDoc>(input: readonly TDoc[] | AnyPaginationResult<TDoc>): PaginatedResponse<TDoc>;
30
+ declare function toCanonicalList<TDoc>(input: TDoc[]): BareListResult<TDoc>;
31
+ declare function toCanonicalList<TDoc>(input: readonly TDoc[]): BareListResult<TDoc>;
32
+ declare function toCanonicalList<TDoc, TExtra extends Record<string, unknown>>(input: AnyPaginationResult<TDoc, TExtra>): PaginatedResult<TDoc, TExtra>;
33
+ declare function toCanonicalList<TDoc>(input: readonly TDoc[] | AnyPaginationResult<TDoc>): PaginatedResult<TDoc>;
34
34
  //#endregion
35
35
  export { isPaginatedResult, toCanonicalList };
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Accepts `unknown` (rather than `T[] | AnyPaginationResult<T>`) so wire-
11
11
  * boundary callers can guard arbitrary inputs without pre-narrowing — the
12
- * arc / arc-next response pipeline routinely sees `{ docs: unknown[] }`
12
+ * arc / arc-next response pipeline routinely sees `{ data: unknown[] }`
13
13
  * shapes that are neither a bare array nor a paginated result, and forcing
14
14
  * those callers to cast first defeats the guard's purpose.
15
15
  */
@@ -19,14 +19,8 @@ function isPaginatedResult(input) {
19
19
  return method === "offset" || method === "keyset" || method === "aggregate";
20
20
  }
21
21
  function toCanonicalList(input) {
22
- if (isPaginatedResult(input)) return {
23
- ...input,
24
- success: true
25
- };
26
- return {
27
- success: true,
28
- docs: [...input]
29
- };
22
+ if (isPaginatedResult(input)) return { ...input };
23
+ return { data: [...input] };
30
24
  }
31
25
  //#endregion
32
26
  export { isPaginatedResult, toCanonicalList };
@@ -104,7 +104,10 @@ function validateCursorVersion(cursorVersion, expectedVersion, minVersion = 1) {
104
104
  function isValidPayload(payload) {
105
105
  if (!payload || typeof payload !== "object") return false;
106
106
  const p = payload;
107
- return "v" in p && typeof p["t"] === "string" && typeof p["id"] === "string" && typeof p["idType"] === "string" && typeof p["sort"] === "object" && p["sort"] !== null && typeof p["ver"] === "number";
107
+ return isSerializedScalar(p["v"]) && typeof p["t"] === "string" && typeof p["id"] === "string" && typeof p["idType"] === "string" && typeof p["sort"] === "object" && p["sort"] !== null && !Array.isArray(p["sort"]) && typeof p["ver"] === "number" && Number.isFinite(p["ver"]);
108
+ }
109
+ function isSerializedScalar(v) {
110
+ return v === null || typeof v === "string" || typeof v === "number" && Number.isFinite(v) || typeof v === "boolean";
108
111
  }
109
112
  function serializeValue(value) {
110
113
  if (value === null || value === void 0) return null;
@@ -1,6 +1,6 @@
1
- import { AggregatePaginationResponse, AggregatePaginationResult, AggregatePaginationResultCore, AnyPaginationResult, BareListResponse, CursorPayload, DecodedCursor, KeysetPaginationResponse, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResponse, OffsetPaginationResult, OffsetPaginationResultCore, PaginatedResponse, PaginationConfig, SortDirection, SortSpec, ValueType } from "./types.mjs";
1
+ import { AggregatePaginationResult, AggregatePaginationResultCore, AnyPaginationResult, BareListResult, CursorPayload, DecodedCursor, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResult, OffsetPaginationResultCore, PaginatedResult, PaginationConfig, SortDirection, SortSpec, ValueType } from "./types.mjs";
2
2
  import { isPaginatedResult, toCanonicalList } from "./canonical.mjs";
3
3
  import { decodeCursor, encodeCursor, validateCursorSort, validateCursorVersion } from "./cursor.mjs";
4
4
  import { getPrimaryField, invertSort, normalizeSort, validateKeysetSort } from "./keyset.mjs";
5
5
  import { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage } from "./offset.mjs";
6
- export { type AggregatePaginationResponse, type AggregatePaginationResult, type AggregatePaginationResultCore, type AnyPaginationResult, type BareListResponse, type CursorPayload, type DecodedCursor, type KeysetPaginationResponse, type KeysetPaginationResult, type KeysetPaginationResultCore, type OffsetPaginationResponse, type OffsetPaginationResult, type OffsetPaginationResultCore, type PaginatedResponse, type PaginationConfig, type SortDirection, type SortSpec, type ValueType, calculateSkip, calculateTotalPages, decodeCursor, encodeCursor, getPrimaryField, invertSort, isPaginatedResult, normalizeSort, shouldWarnDeepPagination, toCanonicalList, validateCursorSort, validateCursorVersion, validateKeysetSort, validateLimit, validatePage };
6
+ export { type AggregatePaginationResult, type AggregatePaginationResultCore, type AnyPaginationResult, type BareListResult, type CursorPayload, type DecodedCursor, type KeysetPaginationResult, type KeysetPaginationResultCore, type OffsetPaginationResult, type OffsetPaginationResultCore, type PaginatedResult, type PaginationConfig, type SortDirection, type SortSpec, type ValueType, calculateSkip, calculateTotalPages, decodeCursor, encodeCursor, getPrimaryField, invertSort, isPaginatedResult, normalizeSort, shouldWarnDeepPagination, toCanonicalList, validateCursorSort, validateCursorVersion, validateKeysetSort, validateLimit, validatePage };
@@ -87,7 +87,7 @@ interface DecodedCursor {
87
87
  */
88
88
  interface OffsetPaginationResultCore<TDoc> {
89
89
  method: 'offset';
90
- docs: TDoc[];
90
+ data: TDoc[];
91
91
  page: number;
92
92
  limit: number;
93
93
  total: number;
@@ -119,7 +119,7 @@ type OffsetPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> =
119
119
  */
120
120
  interface KeysetPaginationResultCore<TDoc> {
121
121
  method: 'keyset';
122
- docs: TDoc[];
122
+ data: TDoc[];
123
123
  limit: number;
124
124
  hasMore: boolean;
125
125
  /** Cursor token for the next page, or `null` when there is none. */
@@ -145,7 +145,7 @@ type KeysetPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> =
145
145
  */
146
146
  interface AggregatePaginationResultCore<TDoc> {
147
147
  method: 'aggregate';
148
- docs: TDoc[];
148
+ data: TDoc[];
149
149
  page: number;
150
150
  limit: number;
151
151
  total: number;
@@ -168,33 +168,23 @@ type AggregatePaginationResult<TDoc, TExtra extends Record<string, unknown> = {}
168
168
  * see {@link toCanonicalList}.
169
169
  */
170
170
  type AnyPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra> | AggregatePaginationResult<TDoc, TExtra>;
171
- /** HTTP success envelope wrapping {@link OffsetPaginationResult}. */
172
- type OffsetPaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
173
- success: true;
174
- } & OffsetPaginationResult<TDoc, TExtra>;
175
- /** HTTP success envelope wrapping {@link KeysetPaginationResult}. */
176
- type KeysetPaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
177
- success: true;
178
- } & KeysetPaginationResult<TDoc, TExtra>;
179
- /** HTTP success envelope wrapping {@link AggregatePaginationResult}. */
180
- type AggregatePaginationResponse<TDoc, TExtra extends Record<string, unknown> = {}> = {
181
- success: true;
182
- } & AggregatePaginationResult<TDoc, TExtra>;
183
171
  /**
184
- * Bare list envelopea successful response that wasn't paginated (raw
185
- * array result). No `method` discriminant; consumers branch on the absence
186
- * of pagination fields. Most useful when an endpoint sometimes paginates
187
- * and sometimes returns a fixed-size list.
172
+ * Bare list shapean endpoint that doesn't paginate (raw array wrapped
173
+ * in `{data}` for consistency with paginated shapes). Consumers narrow on
174
+ * the absence of `method`. The `{data}` wrapper (vs returning the raw
175
+ * array) leaves room to add pagination metadata later without breaking
176
+ * the consumer contract.
188
177
  */
189
- interface BareListResponse<TDoc> {
190
- success: true;
191
- docs: TDoc[];
178
+ interface BareListResult<TDoc> {
179
+ data: TDoc[];
192
180
  }
193
181
  /**
194
- * Union of every wire envelope a paginated/list endpoint can emit. Locked
195
- * to `success: true` because errors take a separate envelope shape — a
196
- * client-side type guard checks `success` first, then `method`.
182
+ * Union of every list shape an endpoint can emit — paginated (offset,
183
+ * keyset, aggregate) OR bare (`{data}` only). Discriminate via
184
+ * `'method' in result` `method === 'offset' | 'keyset' | 'aggregate'`
185
+ * for paginated, absent for bare lists. Errors live on a separate path
186
+ * (HTTP status >= 400 → `ErrorContract`).
197
187
  */
198
- type PaginatedResponse<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResponse<TDoc, TExtra> | KeysetPaginationResponse<TDoc, TExtra> | AggregatePaginationResponse<TDoc, TExtra> | BareListResponse<TDoc>;
188
+ type PaginatedResult<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra> | AggregatePaginationResult<TDoc, TExtra> | BareListResult<TDoc>;
199
189
  //#endregion
200
- export { AggregatePaginationResponse, AggregatePaginationResult, AggregatePaginationResultCore, AnyPaginationResult, BareListResponse, CursorPayload, DecodedCursor, KeysetPaginationResponse, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResponse, OffsetPaginationResult, OffsetPaginationResultCore, PaginatedResponse, PaginationConfig, SortDirection, SortSpec, ValueType };
190
+ export { AggregatePaginationResult, AggregatePaginationResultCore, AnyPaginationResult, BareListResult, CursorPayload, DecodedCursor, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResult, OffsetPaginationResultCore, PaginatedResult, PaginationConfig, SortDirection, SortSpec, ValueType };
@@ -0,0 +1,2 @@
1
+ import { TenantPolicyContext, adminBypass, payloadHasTenantField } from "./tenant-helpers.mjs";
2
+ export { type TenantPolicyContext, adminBypass, payloadHasTenantField };
@@ -0,0 +1,2 @@
1
+ import { adminBypass, payloadHasTenantField } from "./tenant-helpers.mjs";
2
+ export { adminBypass, payloadHasTenantField };
@@ -0,0 +1,63 @@
1
+ import { PolicyKey } from "../operations/types.mjs";
2
+
3
+ //#region src/plugins/tenant-helpers.d.ts
4
+ /**
5
+ * Minimal context shape this module reads. Kits' richer
6
+ * `RepositoryContext` types extend this — by accepting only the slots
7
+ * we touch, we avoid coupling repo-core to any kit's typing.
8
+ */
9
+ interface TenantPolicyContext {
10
+ readonly data?: Record<string, unknown>;
11
+ readonly dataArray?: readonly Record<string, unknown>[];
12
+ readonly query?: unknown;
13
+ readonly filters?: unknown;
14
+ readonly operations?: unknown;
15
+ }
16
+ /**
17
+ * True when the op's policy target already has `tenantField` set by
18
+ * the caller. Used to decide whether the plugin can safely skip
19
+ * injecting a tenant scope rather than throwing on a missing context.
20
+ *
21
+ * - `data` — `context.data[tenantField]` is present
22
+ * - `dataArray` — every row in `context.dataArray` has `tenantField`
23
+ * - `query` — `context.query[tenantField]` is present
24
+ * - `filters` — `context.filters[tenantField]` is present
25
+ * - `operations` — every bulkWrite sub-op's filter/document has `tenantField`
26
+ * - `none` — unreachable (the hook isn't registered for these ops)
27
+ *
28
+ * For multi-row targets (`dataArray`, `operations`) we require EVERY
29
+ * row to be stamped. Partial stamping is ambiguous (we have no
30
+ * resolver value to fill in the gaps) and is safer to treat as "not
31
+ * stamped" so the caller either stamps all rows or supplies a
32
+ * context/resolver.
33
+ */
34
+ declare function payloadHasTenantField(context: TenantPolicyContext, policyKey: PolicyKey, tenantField: string): boolean;
35
+ /**
36
+ * Build a `skipWhen`-compatible callback that bypasses tenant scoping
37
+ * when the caller's role is in `adminRoles`. Composable with any
38
+ * kit's multi-tenant plugin shape.
39
+ *
40
+ * The factory does an exact-match `Set.has` check — case-sensitive,
41
+ * no fuzzy matching. Lowercase your role vocabulary upstream.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * multiTenantPlugin({
46
+ * resolveTenantId: ctx => ctx.organizationId,
47
+ * skipWhen: adminBypass({ adminRoles: ['superadmin', 'support'] }),
48
+ * });
49
+ * ```
50
+ *
51
+ * @param options.roleField Context key holding the role string (default: `'role'`)
52
+ * @param options.adminRoles Roles that bypass tenant scope. Frozen on
53
+ * factory construction so callers can't mutate the list afterward
54
+ * and silently change bypass semantics across plugin instances
55
+ * sharing the array reference.
56
+ * @returns A `skipWhen`-compatible callback `(ctx, op) → boolean`.
57
+ */
58
+ declare function adminBypass(options: {
59
+ roleField?: string;
60
+ adminRoles: readonly string[];
61
+ }): (context: Record<string, unknown>, operation: string) => boolean;
62
+ //#endregion
63
+ export { TenantPolicyContext, adminBypass, payloadHasTenantField };
@@ -0,0 +1,84 @@
1
+ //#region src/plugins/tenant-helpers.ts
2
+ /**
3
+ * True when the op's policy target already has `tenantField` set by
4
+ * the caller. Used to decide whether the plugin can safely skip
5
+ * injecting a tenant scope rather than throwing on a missing context.
6
+ *
7
+ * - `data` — `context.data[tenantField]` is present
8
+ * - `dataArray` — every row in `context.dataArray` has `tenantField`
9
+ * - `query` — `context.query[tenantField]` is present
10
+ * - `filters` — `context.filters[tenantField]` is present
11
+ * - `operations` — every bulkWrite sub-op's filter/document has `tenantField`
12
+ * - `none` — unreachable (the hook isn't registered for these ops)
13
+ *
14
+ * For multi-row targets (`dataArray`, `operations`) we require EVERY
15
+ * row to be stamped. Partial stamping is ambiguous (we have no
16
+ * resolver value to fill in the gaps) and is safer to treat as "not
17
+ * stamped" so the caller either stamps all rows or supplies a
18
+ * context/resolver.
19
+ */
20
+ function payloadHasTenantField(context, policyKey, tenantField) {
21
+ switch (policyKey) {
22
+ case "data": return context.data?.[tenantField] != null;
23
+ case "dataArray": {
24
+ const arr = context.dataArray;
25
+ if (!Array.isArray(arr) || arr.length === 0) return false;
26
+ return arr.every((row) => row && row[tenantField] != null);
27
+ }
28
+ case "query": return context.query?.[tenantField] != null;
29
+ case "filters": return context.filters?.[tenantField] != null;
30
+ case "operations": {
31
+ const ops = context.operations;
32
+ if (!Array.isArray(ops) || ops.length === 0) return false;
33
+ return ops.every((subOp) => {
34
+ for (const key of [
35
+ "updateOne",
36
+ "updateMany",
37
+ "deleteOne",
38
+ "deleteMany",
39
+ "replaceOne"
40
+ ]) {
41
+ const body = subOp[key];
42
+ if (body) return body["filter"]?.[tenantField] != null;
43
+ }
44
+ const ins = subOp["insertOne"];
45
+ if (ins) return ins["document"]?.[tenantField] != null;
46
+ return false;
47
+ });
48
+ }
49
+ default: return false;
50
+ }
51
+ }
52
+ /**
53
+ * Build a `skipWhen`-compatible callback that bypasses tenant scoping
54
+ * when the caller's role is in `adminRoles`. Composable with any
55
+ * kit's multi-tenant plugin shape.
56
+ *
57
+ * The factory does an exact-match `Set.has` check — case-sensitive,
58
+ * no fuzzy matching. Lowercase your role vocabulary upstream.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * multiTenantPlugin({
63
+ * resolveTenantId: ctx => ctx.organizationId,
64
+ * skipWhen: adminBypass({ adminRoles: ['superadmin', 'support'] }),
65
+ * });
66
+ * ```
67
+ *
68
+ * @param options.roleField Context key holding the role string (default: `'role'`)
69
+ * @param options.adminRoles Roles that bypass tenant scope. Frozen on
70
+ * factory construction so callers can't mutate the list afterward
71
+ * and silently change bypass semantics across plugin instances
72
+ * sharing the array reference.
73
+ * @returns A `skipWhen`-compatible callback `(ctx, op) → boolean`.
74
+ */
75
+ function adminBypass(options) {
76
+ const { roleField = "role", adminRoles } = options;
77
+ const allowed = new Set(adminRoles);
78
+ return function skipWhenAdmin(context) {
79
+ const role = context[roleField];
80
+ return typeof role === "string" && allowed.has(role);
81
+ };
82
+ }
83
+ //#endregion
84
+ export { adminBypass, payloadHasTenantField };
@@ -1,4 +1,5 @@
1
1
  import { BracketOperator, ParsedPopulate, ParsedQuery, ParsedSelect, ParsedSort, ParsedSortDirection, QueryParserInput, QueryParserOptions } from "./types.mjs";
2
2
  import { coerceList, coerceValue } from "./coerce.mjs";
3
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 };
4
+ 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 };
@@ -1,3 +1,4 @@
1
1
  import { coerceList, coerceValue } from "./coerce.mjs";
2
+ import { STANDARD_RESERVED_PARAMS, isControlParam } from "./reserved.mjs";
2
3
  import { parseUrl } from "./parse-url.mjs";
3
- export { coerceList, coerceValue, parseUrl };
4
+ export { STANDARD_RESERVED_PARAMS, coerceList, coerceValue, isControlParam, parseUrl };
@@ -1,21 +1,21 @@
1
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
2
  import { coerceList, coerceValue } from "./coerce.mjs";
3
+ import { isControlParam } from "./reserved.mjs";
3
4
  //#region src/query-parser/parse-url.ts
4
5
  const DEFAULT_LIMIT = 20;
5
6
  const DEFAULT_MAX_LIMIT = 200;
6
7
  const DEFAULT_MAX_DEPTH = 10;
7
8
  const DEFAULT_MAX_REGEX = 500;
8
9
  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
- ]);
10
+ /**
11
+ * Hard cap on URL parameter KEY length. Param keys land in regex-based
12
+ * bracket parsing (`/^([^[\]]+)\[([^\]]+)\]$/` and friends); without a
13
+ * length bound, a hostile caller can submit a 1MB key and force the
14
+ * parser to scan the entire string for every regex try. Keys that
15
+ * exceed this cap are silently skipped — legitimate URL params don't
16
+ * approach this bound.
17
+ */
18
+ const MAX_PARAM_KEY_LENGTH = 256;
19
19
  const ALL_OPERATORS = new Set([
20
20
  "eq",
21
21
  "ne",
@@ -134,6 +134,7 @@ function parseSelect(raw) {
134
134
  function parsePopulate(params) {
135
135
  const byField = /* @__PURE__ */ new Map();
136
136
  for (const [key, value] of params.entries()) {
137
+ if (key.length > MAX_PARAM_KEY_LENGTH) continue;
137
138
  if (!key.startsWith("populate[")) continue;
138
139
  const match = /^populate\[([^\]]+)\](?:\[([^\]]+)\](?:\[([^\]]+)\])?)?$/.exec(key);
139
140
  if (!match) continue;
@@ -163,7 +164,8 @@ function parseFilters(params, ctx) {
163
164
  const leaves = [];
164
165
  const fieldGroups = /* @__PURE__ */ new Map();
165
166
  for (const [key, rawValue] of params.entries()) {
166
- if (RESERVED_KEYS.has(key) || key.startsWith("populate[")) continue;
167
+ if (key.length > MAX_PARAM_KEY_LENGTH) continue;
168
+ if (isControlParam(key) || key.startsWith("populate[")) continue;
167
169
  const bracket = /^([^[\]]+)\[([^\]]+)\]$/.exec(key);
168
170
  let field;
169
171
  let op;
@@ -0,0 +1,43 @@
1
+ //#region src/query-parser/reserved.d.ts
2
+ /**
3
+ * URL parameter keys reserved by the framework — never parsed as
4
+ * filter predicates. Two categories:
5
+ *
6
+ * 1. **Pagination / list-control params** — `page`, `limit`, `after`,
7
+ * `sort`, `select`, `populate`, `search`. Universal across kits;
8
+ * every backend agrees on their meaning.
9
+ *
10
+ * 2. **Resource-dispatch verbs** — `_count`, `_distinct`, `_exists`.
11
+ * Arc-style frameworks pick the repo method from these URL keys
12
+ * (list vs count vs distinct vs exists).
13
+ *
14
+ * **Why an explicit allowlist, not a `_*` namespace.** MongoDB's `_id`
15
+ * (and every kit's analog) is a legitimate filter field; user-defined
16
+ * `_internal`, `_meta`, `_v` fields are common in real schemas. A
17
+ * blanket `key.startsWith('_')` rule silently drops filters on these.
18
+ * Adding a new dispatch verb here is a deliberate ecosystem-wide
19
+ * change — small, audited, and only needed every few major versions.
20
+ */
21
+ /**
22
+ * Reserved top-level URL keys handled outside the filter pipeline.
23
+ * Pagination + sort + select + populate + search + resource-dispatch
24
+ * verbs (`_count`, `_distinct`, `_exists`).
25
+ */
26
+ declare const STANDARD_RESERVED_PARAMS: ReadonlySet<string>;
27
+ /**
28
+ * True when `key` is a framework-reserved URL parameter and should be
29
+ * skipped during filter parsing.
30
+ *
31
+ * Use in any URL-parser implementation that decides "is this a filter
32
+ * predicate or a control flag?":
33
+ *
34
+ * ```ts
35
+ * for (const [key, value] of params.entries()) {
36
+ * if (isControlParam(key)) continue; // skip page, limit, _count, ...
37
+ * // … parse as filter predicate
38
+ * }
39
+ * ```
40
+ */
41
+ declare function isControlParam(key: string): boolean;
42
+ //#endregion
43
+ export { STANDARD_RESERVED_PARAMS, isControlParam };
@@ -0,0 +1,56 @@
1
+ //#region src/query-parser/reserved.ts
2
+ /**
3
+ * URL parameter keys reserved by the framework — never parsed as
4
+ * filter predicates. Two categories:
5
+ *
6
+ * 1. **Pagination / list-control params** — `page`, `limit`, `after`,
7
+ * `sort`, `select`, `populate`, `search`. Universal across kits;
8
+ * every backend agrees on their meaning.
9
+ *
10
+ * 2. **Resource-dispatch verbs** — `_count`, `_distinct`, `_exists`.
11
+ * Arc-style frameworks pick the repo method from these URL keys
12
+ * (list vs count vs distinct vs exists).
13
+ *
14
+ * **Why an explicit allowlist, not a `_*` namespace.** MongoDB's `_id`
15
+ * (and every kit's analog) is a legitimate filter field; user-defined
16
+ * `_internal`, `_meta`, `_v` fields are common in real schemas. A
17
+ * blanket `key.startsWith('_')` rule silently drops filters on these.
18
+ * Adding a new dispatch verb here is a deliberate ecosystem-wide
19
+ * change — small, audited, and only needed every few major versions.
20
+ */
21
+ /**
22
+ * Reserved top-level URL keys handled outside the filter pipeline.
23
+ * Pagination + sort + select + populate + search + resource-dispatch
24
+ * verbs (`_count`, `_distinct`, `_exists`).
25
+ */
26
+ const STANDARD_RESERVED_PARAMS = new Set([
27
+ "page",
28
+ "limit",
29
+ "after",
30
+ "sort",
31
+ "select",
32
+ "populate",
33
+ "search",
34
+ "_count",
35
+ "_distinct",
36
+ "_exists"
37
+ ]);
38
+ /**
39
+ * True when `key` is a framework-reserved URL parameter and should be
40
+ * skipped during filter parsing.
41
+ *
42
+ * Use in any URL-parser implementation that decides "is this a filter
43
+ * predicate or a control flag?":
44
+ *
45
+ * ```ts
46
+ * for (const [key, value] of params.entries()) {
47
+ * if (isControlParam(key)) continue; // skip page, limit, _count, ...
48
+ * // … parse as filter predicate
49
+ * }
50
+ * ```
51
+ */
52
+ function isControlParam(key) {
53
+ return STANDARD_RESERVED_PARAMS.has(key);
54
+ }
55
+ //#endregion
56
+ export { STANDARD_RESERVED_PARAMS, isControlParam };
@@ -0,0 +1,63 @@
1
+ //#region src/repository/agg-output.d.ts
2
+ /**
3
+ * Cross-kit AggResult row-shape normalization.
4
+ *
5
+ * When an `AggRequest` includes `lookups` and `groupBy` references a
6
+ * joined-alias path (e.g. `'department.code'`), the row that lands in
7
+ * `AggResult.rows` carries the joined data as a NESTED object:
8
+ *
9
+ * ```ts
10
+ * { status: 'pending', department: { code: 'ENG' }, count: 3 }
11
+ * ```
12
+ *
13
+ * Same convention `lookupPopulate` uses. Mongokit's `$project` with
14
+ * dotted-key output naturally nests; sqlitekit gets flat-dotted keys
15
+ * from Drizzle's SELECT alias map and runs results through
16
+ * `nestDottedKeys` before returning.
17
+ *
18
+ * **Why nested over flat-dotted?**
19
+ * - Matches `lookupPopulate` precedent (single convention across
20
+ * all read primitives).
21
+ * - JSON-clean: `{ department: { code: 'ENG' } }` round-trips
22
+ * identically through `JSON.stringify` / `parse`.
23
+ * - Cleaner consumer code: `row.department.code` vs
24
+ * `row['department.code']`.
25
+ * - BSON allows nested but disallows literal `.` in field names —
26
+ * the only shape that works in mongo without BSON workarounds.
27
+ *
28
+ * **Out of scope**:
29
+ * - Multi-level dotted paths (`'a.b.c'`) — kits don't emit these
30
+ * today (single-level joins only). The helper handles them
31
+ * correctly by recursive descent so future depth is supported.
32
+ * - Conflicting flat + nested keys on the same row (e.g. both
33
+ * `department` and `department.code`). The flat-dotted side wins;
34
+ * a top-level `department` value gets overwritten when a
35
+ * `department.<x>` partner key is processed. In practice this
36
+ * never happens — kits emit one or the other per groupBy key.
37
+ */
38
+ /**
39
+ * Walk a row's top-level keys, splitting any that contain `.` into
40
+ * nested objects. Keys without `.` pass through unchanged. Mutates a
41
+ * fresh output object — the input is not modified.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * nestDottedKeys({ status: 'pending', 'department.code': 'ENG', count: 3 })
46
+ * // → { status: 'pending', department: { code: 'ENG' }, count: 3 }
47
+ * ```
48
+ *
49
+ * Multi-level paths (`a.b.c`) recurse:
50
+ *
51
+ * ```ts
52
+ * nestDottedKeys({ 'a.b.c': 1 })
53
+ * // → { a: { b: { c: 1 } } }
54
+ * ```
55
+ */
56
+ declare function nestDottedKeys<T extends Record<string, unknown>>(row: T): Record<string, unknown>;
57
+ /**
58
+ * Convenience wrapper for an array of rows. Returns a new array of
59
+ * normalized rows; the input is not modified.
60
+ */
61
+ declare function nestDottedKeysAll<T extends Record<string, unknown>>(rows: readonly T[]): Record<string, unknown>[];
62
+ //#endregion
63
+ export { nestDottedKeys, nestDottedKeysAll };