@classytic/repo-core 0.2.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 (87) hide show
  1. package/CHANGELOG.md +363 -0
  2. package/README.md +28 -7
  3. package/dist/adapter/index.d.mts +3 -0
  4. package/dist/adapter/index.mjs +2 -0
  5. package/dist/adapter/types.d.mts +222 -0
  6. package/dist/adapter/widen.d.mts +22 -0
  7. package/dist/adapter/widen.mjs +26 -0
  8. package/dist/aggregate/index.d.mts +3 -0
  9. package/dist/aggregate/index.mjs +3 -0
  10. package/dist/aggregate/keyset.d.mts +57 -0
  11. package/dist/aggregate/keyset.mjs +45 -0
  12. package/dist/aggregate/normalize.d.mts +24 -0
  13. package/dist/aggregate/normalize.mjs +28 -0
  14. package/dist/better-auth/index.d.mts +110 -0
  15. package/dist/better-auth/index.mjs +71 -0
  16. package/dist/cache/engine.d.mts +127 -0
  17. package/dist/cache/engine.mjs +235 -0
  18. package/dist/cache/envelope.mjs +32 -0
  19. package/dist/cache/index.d.mts +7 -2
  20. package/dist/cache/index.mjs +6 -2
  21. package/dist/cache/keys.mjs +131 -0
  22. package/dist/cache/memory-adapter.mjs +41 -7
  23. package/dist/cache/options.d.mts +112 -0
  24. package/dist/cache/options.mjs +25 -0
  25. package/dist/cache/plugin/context.d.mts +18 -0
  26. package/dist/cache/plugin/context.mjs +121 -0
  27. package/dist/cache/plugin/index.d.mts +86 -0
  28. package/dist/cache/plugin/index.mjs +78 -0
  29. package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
  30. package/dist/cache/plugin/read-hooks.mjs +96 -0
  31. package/dist/cache/plugin/swr.mjs +20 -0
  32. package/dist/cache/runtime.d.mts +43 -0
  33. package/dist/cache/runtime.mjs +14 -0
  34. package/dist/cache/tag-index.mjs +84 -0
  35. package/dist/cache/timeout-adapter.d.mts +30 -0
  36. package/dist/cache/timeout-adapter.mjs +58 -0
  37. package/dist/cache/types.d.mts +45 -0
  38. package/dist/cache/version-store.mjs +57 -0
  39. package/dist/errors/contract.d.mts +37 -0
  40. package/dist/errors/contract.mjs +75 -0
  41. package/dist/errors/index.d.mts +4 -2
  42. package/dist/errors/index.mjs +4 -1
  43. package/dist/errors/schema.d.mts +101 -0
  44. package/dist/errors/schema.mjs +78 -0
  45. package/dist/errors/types.d.mts +113 -8
  46. package/dist/errors/types.mjs +29 -0
  47. package/dist/filter/match.mjs +38 -2
  48. package/dist/pagination/canonical.d.mts +35 -0
  49. package/dist/pagination/canonical.mjs +26 -0
  50. package/dist/pagination/cursor.mjs +4 -1
  51. package/dist/pagination/index.d.mts +3 -2
  52. package/dist/pagination/index.mjs +2 -1
  53. package/dist/pagination/types.d.mts +57 -3
  54. package/dist/plugins/index.d.mts +2 -0
  55. package/dist/plugins/index.mjs +2 -0
  56. package/dist/plugins/tenant-helpers.d.mts +63 -0
  57. package/dist/plugins/tenant-helpers.mjs +84 -0
  58. package/dist/query-parser/index.d.mts +2 -1
  59. package/dist/query-parser/index.mjs +2 -1
  60. package/dist/query-parser/parse-url.mjs +13 -11
  61. package/dist/query-parser/reserved.d.mts +43 -0
  62. package/dist/query-parser/reserved.mjs +56 -0
  63. package/dist/repository/agg-output.d.mts +63 -0
  64. package/dist/repository/agg-output.mjs +89 -0
  65. package/dist/repository/base.mjs +21 -0
  66. package/dist/repository/index.d.mts +4 -2
  67. package/dist/repository/index.mjs +3 -1
  68. package/dist/repository/options.d.mts +62 -0
  69. package/dist/repository/options.mjs +57 -0
  70. package/dist/repository/types.d.mts +935 -48
  71. package/dist/schema/field-rules.d.mts +60 -9
  72. package/dist/schema/field-rules.mjs +121 -10
  73. package/dist/schema/generator.d.mts +72 -0
  74. package/dist/schema/generator.mjs +16 -0
  75. package/dist/schema/index.d.mts +3 -2
  76. package/dist/schema/index.mjs +3 -2
  77. package/dist/schema/types.d.mts +77 -3
  78. package/dist/tenant/index.d.mts +3 -0
  79. package/dist/tenant/index.mjs +2 -0
  80. package/dist/tenant/resolve.d.mts +27 -0
  81. package/dist/tenant/resolve.mjs +69 -0
  82. package/dist/tenant/types.d.mts +142 -0
  83. package/dist/testing/conformance.mjs +666 -17
  84. package/dist/testing/index.d.mts +2 -2
  85. package/dist/testing/types.d.mts +99 -2
  86. package/package.json +27 -1
  87. package/dist/cache/stable-stringify.d.mts +0 -15
@@ -1,12 +1,28 @@
1
1
  //#region src/errors/types.d.ts
2
2
  /**
3
- * HTTP-shaped error contract used across every driver kit.
3
+ * HTTP-shaped error contracts used across every driver kit, integrator,
4
+ * and HTTP-emitting service in the org.
4
5
  *
5
- * An `HttpError` is a plain `Error` with a `status` field and optional
6
- * structured fields for duplicate-key and validation conflicts. Kits
7
- * classify their driver-specific errors into this shape at the boundary,
8
- * so the framework layer (arc) never needs to know whether the error
9
- * originated from MongoDB `E11000`, Postgres `23505`, or Prisma `P2002`.
6
+ * **`@classytic/repo-core/errors` is the canonical home for the wire +
7
+ * throwable error contract.** Two complementary shapes live here:
8
+ *
9
+ * - {@link HttpError} the *throwable* shape. Plain `Error` with
10
+ * `status` and optional structured fields. Kits classify their
11
+ * driver-specific errors into this shape at the boundary; framework
12
+ * layers (arc) catch and serialize.
13
+ * - {@link ErrorContract} — the *wire* shape (RFC 7807 / Stripe-style).
14
+ * What gets serialized into JSON responses, queue dead-letter records,
15
+ * audit logs, and inter-service error envelopes.
16
+ *
17
+ * Throwable classes (arc's `ArcError` family, future `BillingError` etc.)
18
+ * `implements HttpError` and serialize to `ErrorContract` for the wire.
19
+ * One contract, one canonical home, every package follows the same shape.
20
+ *
21
+ * **Custom-domain escape hatch.** {@link ErrorCode} is a documented union
22
+ * of canonical codes; `code: string` accepts ANY string so domain packages
23
+ * can extend (`'order.validation.missing_line'`, `'payment.gateway.timeout'`).
24
+ * The canonical codes cover cross-cutting concerns; domain extensions
25
+ * hierarchically narrow.
10
26
  */
11
27
  /** Structured metadata for duplicate-key (unique-constraint) errors. */
12
28
  interface DuplicateKeyMeta {
@@ -24,14 +40,103 @@ interface ValidationErrorMeta {
24
40
  validator: string;
25
41
  error: string;
26
42
  }
27
- /** HTTP-shaped error — the envelope every repository error resolves to. */
43
+ /**
44
+ * HTTP-shaped error — the throwable envelope every repository error and
45
+ * arc handler resolves to.
46
+ *
47
+ * Note: `code` and `meta` are optional but every long-lived production
48
+ * handler should populate them. `code` lets clients switch on
49
+ * machine-readable identifiers without grepping `message`; `meta` carries
50
+ * structured diagnostics safe for logs.
51
+ */
28
52
  interface HttpError extends Error {
29
53
  /** HTTP status code (400, 404, 409, 500, ...). */
30
54
  status: number;
55
+ /**
56
+ * Stable machine-readable error code. Use one of {@link ErrorCode} for
57
+ * cross-cutting cases or extend hierarchically with a domain prefix
58
+ * (`'order.validation.missing_line'`, `'payment.gateway.timeout'`).
59
+ * Hosts switch on this in catch blocks instead of regex-matching
60
+ * `message`.
61
+ */
62
+ code?: string;
63
+ /**
64
+ * Free-form structured metadata for diagnostics. Pairs with `code` so
65
+ * hosts can render a clearer message in their own UI without parsing
66
+ * `message`. Safe for logs (don't include PII).
67
+ */
68
+ meta?: Record<string, unknown>;
31
69
  /** Structured validation failures when `status` is 400. */
32
70
  validationErrors?: ValidationErrorMeta[];
33
71
  /** Structured duplicate-key metadata when `status` is 409. */
34
72
  duplicate?: DuplicateKeyMeta;
35
73
  }
74
+ /**
75
+ * Standard error contract — a framework-agnostic JSON shape that maps
76
+ * cleanly to HTTP responses, worker failure logs, and inter-service
77
+ * errors. Loosely matches RFC 7807 (`application/problem+json`); shape
78
+ * matches Stripe / Shopify / Slack API conventions.
79
+ *
80
+ * Packages throw `HttpError` instances. Hosts (HTTP adapters, workers)
81
+ * serialize those errors into this shape on the wire via
82
+ * {@link toErrorContract}. Wire shape is FLAT (top-level `code` /
83
+ * `message` / `status`), not nested under `{ error: { ... } }` — matches
84
+ * the existing org-wide envelope convention. Hosts that need a
85
+ * Stripe-style nested envelope wrap once at the edge.
86
+ */
87
+ interface ErrorContract {
88
+ /** Machine-readable, hierarchical code — e.g. `'order.validation.missing_line'`. */
89
+ code: string;
90
+ /** Human-readable, safe-for-client message. */
91
+ message: string;
92
+ /** Suggested HTTP status code — hosts may override. */
93
+ status?: number;
94
+ /**
95
+ * Field-scoped structured details. Populated for validation failures
96
+ * (one entry per offending field) or domain errors that map to multiple
97
+ * sub-codes. Distinct from `HttpError.validationErrors` (which is a
98
+ * mongokit-shaped throwable field) — the wire form is canonical.
99
+ */
100
+ details?: readonly ErrorDetail[];
101
+ /** Correlation / trace identifier for support lookups. */
102
+ correlationId?: string;
103
+ /** Non-PII metadata (safe to log, safe to return to clients). */
104
+ meta?: Readonly<Record<string, unknown>>;
105
+ }
106
+ /** A single field-scoped error detail. */
107
+ interface ErrorDetail {
108
+ /** Dot-path pointer to the offending field, e.g. `'lines.0.quantity'`. */
109
+ path?: string;
110
+ code: string;
111
+ message: string;
112
+ meta?: Readonly<Record<string, unknown>>;
113
+ }
114
+ /**
115
+ * Cross-cutting error codes used across the org. Every canonical code is
116
+ * lowercase + snake_case to match RFC 7807, Stripe, and Shopify
117
+ * conventions. Domain packages add their own hierarchical codes
118
+ * (`'order.validation.*'`, `'payment.gateway.*'`); these cover the
119
+ * universal cases every HTTP-emitting layer needs.
120
+ *
121
+ * **Arc compatibility note.** Arc's `ArcError` hierarchy historically
122
+ * uses UPPER_SNAKE codes (`'NOT_FOUND'`, `'VALIDATION_ERROR'`) for
123
+ * back-compat with hosts that switch on those values. New code should
124
+ * prefer the canonical lowercase codes; arc keeps emitting its UPPER_SNAKE
125
+ * codes on the wire so existing client switches keep working.
126
+ */
127
+ declare const ERROR_CODES: {
128
+ readonly VALIDATION: "validation_error";
129
+ readonly NOT_FOUND: "not_found";
130
+ readonly CONFLICT: "conflict";
131
+ readonly UNAUTHORIZED: "unauthorized";
132
+ readonly FORBIDDEN: "forbidden";
133
+ readonly RATE_LIMITED: "rate_limited";
134
+ readonly IDEMPOTENCY_CONFLICT: "idempotency_conflict";
135
+ readonly PRECONDITION_FAILED: "precondition_failed";
136
+ readonly INTERNAL: "internal_error";
137
+ readonly UNAVAILABLE: "service_unavailable";
138
+ readonly TIMEOUT: "timeout";
139
+ };
140
+ type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
36
141
  //#endregion
37
- export { DuplicateKeyMeta, HttpError, ValidationErrorMeta };
142
+ export { DuplicateKeyMeta, ERROR_CODES, ErrorCode, ErrorContract, ErrorDetail, HttpError, ValidationErrorMeta };
@@ -0,0 +1,29 @@
1
+ //#region src/errors/types.ts
2
+ /**
3
+ * Cross-cutting error codes used across the org. Every canonical code is
4
+ * lowercase + snake_case to match RFC 7807, Stripe, and Shopify
5
+ * conventions. Domain packages add their own hierarchical codes
6
+ * (`'order.validation.*'`, `'payment.gateway.*'`); these cover the
7
+ * universal cases every HTTP-emitting layer needs.
8
+ *
9
+ * **Arc compatibility note.** Arc's `ArcError` hierarchy historically
10
+ * uses UPPER_SNAKE codes (`'NOT_FOUND'`, `'VALIDATION_ERROR'`) for
11
+ * back-compat with hosts that switch on those values. New code should
12
+ * prefer the canonical lowercase codes; arc keeps emitting its UPPER_SNAKE
13
+ * codes on the wire so existing client switches keep working.
14
+ */
15
+ const ERROR_CODES = {
16
+ VALIDATION: "validation_error",
17
+ NOT_FOUND: "not_found",
18
+ CONFLICT: "conflict",
19
+ UNAUTHORIZED: "unauthorized",
20
+ FORBIDDEN: "forbidden",
21
+ RATE_LIMITED: "rate_limited",
22
+ IDEMPOTENCY_CONFLICT: "idempotency_conflict",
23
+ PRECONDITION_FAILED: "precondition_failed",
24
+ INTERNAL: "internal_error",
25
+ UNAVAILABLE: "service_unavailable",
26
+ TIMEOUT: "timeout"
27
+ };
28
+ //#endregion
29
+ export { ERROR_CODES };
@@ -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 = "";
@@ -0,0 +1,35 @@
1
+ import { AnyPaginationResult, BareListResult, PaginatedResult } from "./types.mjs";
2
+
3
+ //#region src/pagination/canonical.d.ts
4
+ /**
5
+ * Type guard: is this value a paginated result envelope (vs a bare array
6
+ * or some other shape)?
7
+ *
8
+ * Checks for the `method` discriminant rather than `Array.isArray` so a
9
+ * paginated result whose `docs` field happens to contain zero items still
10
+ * routes through the paginated branch.
11
+ *
12
+ * Accepts `unknown` (rather than `T[] | AnyPaginationResult<T>`) so wire-
13
+ * boundary callers can guard arbitrary inputs without pre-narrowing — the
14
+ * arc / arc-next response pipeline routinely sees `{ data: unknown[] }`
15
+ * shapes that are neither a bare array nor a paginated result, and forcing
16
+ * those callers to cast first defeats the guard's purpose.
17
+ */
18
+ declare function isPaginatedResult<TDoc>(input: unknown): input is AnyPaginationResult<TDoc>;
19
+ /**
20
+ * Normalise a list-shaped value into the canonical wire envelope.
21
+ *
22
+ * Overloads keep the return type tight:
23
+ * - bare array → {@link BareListResult}
24
+ * - paginated → {@link PaginatedResult} (preserves method discriminant)
25
+ *
26
+ * The mutable-array overload widens to `TDoc[]` because that's the most
27
+ * common server input (kit results return `TDoc[]` for `docs`); the
28
+ * readonly overload covers callers passing `readonly TDoc[]`.
29
+ */
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
+ //#endregion
35
+ export { isPaginatedResult, toCanonicalList };
@@ -0,0 +1,26 @@
1
+ //#region src/pagination/canonical.ts
2
+ /**
3
+ * Type guard: is this value a paginated result envelope (vs a bare array
4
+ * or some other shape)?
5
+ *
6
+ * Checks for the `method` discriminant rather than `Array.isArray` so a
7
+ * paginated result whose `docs` field happens to contain zero items still
8
+ * routes through the paginated branch.
9
+ *
10
+ * Accepts `unknown` (rather than `T[] | AnyPaginationResult<T>`) so wire-
11
+ * boundary callers can guard arbitrary inputs without pre-narrowing — the
12
+ * arc / arc-next response pipeline routinely sees `{ data: unknown[] }`
13
+ * shapes that are neither a bare array nor a paginated result, and forcing
14
+ * those callers to cast first defeats the guard's purpose.
15
+ */
16
+ function isPaginatedResult(input) {
17
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return false;
18
+ const method = input.method;
19
+ return method === "offset" || method === "keyset" || method === "aggregate";
20
+ }
21
+ function toCanonicalList(input) {
22
+ if (isPaginatedResult(input)) return { ...input };
23
+ return { data: [...input] };
24
+ }
25
+ //#endregion
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,5 +1,6 @@
1
- import { CursorPayload, DecodedCursor, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResult, OffsetPaginationResultCore, 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
+ import { isPaginatedResult, toCanonicalList } from "./canonical.mjs";
2
3
  import { decodeCursor, encodeCursor, validateCursorSort, validateCursorVersion } from "./cursor.mjs";
3
4
  import { getPrimaryField, invertSort, normalizeSort, validateKeysetSort } from "./keyset.mjs";
4
5
  import { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage } from "./offset.mjs";
5
- export { type CursorPayload, type DecodedCursor, type KeysetPaginationResult, type KeysetPaginationResultCore, type OffsetPaginationResult, type OffsetPaginationResultCore, type PaginationConfig, type SortDirection, type SortSpec, type ValueType, calculateSkip, calculateTotalPages, decodeCursor, encodeCursor, getPrimaryField, invertSort, normalizeSort, shouldWarnDeepPagination, 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 };
@@ -1,4 +1,5 @@
1
+ import { isPaginatedResult, toCanonicalList } from "./canonical.mjs";
1
2
  import { decodeCursor, encodeCursor, validateCursorSort, validateCursorVersion } from "./cursor.mjs";
2
3
  import { getPrimaryField, invertSort, normalizeSort, validateKeysetSort } from "./keyset.mjs";
3
4
  import { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage } from "./offset.mjs";
4
- export { calculateSkip, calculateTotalPages, decodeCursor, encodeCursor, getPrimaryField, invertSort, normalizeSort, shouldWarnDeepPagination, validateCursorSort, validateCursorVersion, validateKeysetSort, validateLimit, validatePage };
5
+ export { 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. */
@@ -132,5 +132,59 @@ interface KeysetPaginationResultCore<TDoc> {
132
132
  * for the rationale. Defaults to `{}`.
133
133
  */
134
134
  type KeysetPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = KeysetPaginationResultCore<TDoc> & TExtra;
135
+ /**
136
+ * Core fields of an aggregate-paginated result. Don't consume this directly —
137
+ * use `AggregatePaginationResult<TDoc>` or `AggregatePaginationResult<TDoc, TExtra>`.
138
+ *
139
+ * Aggregate pagination produces page-shaped envelopes from arbitrary aggregate
140
+ * pipelines (mongokit's `aggregatePaginate` / `aggregatePipelinePaginate`,
141
+ * pgkit's CTE-based windowed counts, etc). The shape mirrors offset because
142
+ * the math is the same — the discriminant exists so consumers can route
143
+ * "this came from an aggregate, not a plain find" without inspecting the
144
+ * pipeline.
145
+ */
146
+ interface AggregatePaginationResultCore<TDoc> {
147
+ method: 'aggregate';
148
+ data: TDoc[];
149
+ page: number;
150
+ limit: number;
151
+ total: number;
152
+ pages: number;
153
+ hasNext: boolean;
154
+ hasPrev: boolean;
155
+ }
156
+ /**
157
+ * Aggregate-paginated result envelope.
158
+ *
159
+ * `TExtra` parallels `OffsetPaginationResult` — kits surface deep-pagination
160
+ * warnings (`warning?: string`), aggregate-specific stats, etc.
161
+ */
162
+ type AggregatePaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = AggregatePaginationResultCore<TDoc> & TExtra;
163
+ /**
164
+ * Union of every pagination *result* shape (server-side, pre-wire).
165
+ *
166
+ * What kits return from `getAll` / `aggregatePaginate`. Use this as the
167
+ * input type to anything that converts repo results into HTTP envelopes —
168
+ * see {@link toCanonicalList}.
169
+ */
170
+ type AnyPaginationResult<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra> | AggregatePaginationResult<TDoc, TExtra>;
171
+ /**
172
+ * Bare list shape — an 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.
177
+ */
178
+ interface BareListResult<TDoc> {
179
+ data: TDoc[];
180
+ }
181
+ /**
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`).
187
+ */
188
+ type PaginatedResult<TDoc, TExtra extends Record<string, unknown> = {}> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra> | AggregatePaginationResult<TDoc, TExtra> | BareListResult<TDoc>;
135
189
  //#endregion
136
- export { CursorPayload, DecodedCursor, KeysetPaginationResult, KeysetPaginationResultCore, OffsetPaginationResult, OffsetPaginationResultCore, 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;