@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.
- package/CHANGELOG.md +363 -0
- package/README.md +28 -7
- package/dist/adapter/index.d.mts +3 -0
- package/dist/adapter/index.mjs +2 -0
- package/dist/adapter/types.d.mts +222 -0
- package/dist/adapter/widen.d.mts +22 -0
- package/dist/adapter/widen.mjs +26 -0
- package/dist/aggregate/index.d.mts +3 -0
- package/dist/aggregate/index.mjs +3 -0
- package/dist/aggregate/keyset.d.mts +57 -0
- package/dist/aggregate/keyset.mjs +45 -0
- package/dist/aggregate/normalize.d.mts +24 -0
- package/dist/aggregate/normalize.mjs +28 -0
- package/dist/better-auth/index.d.mts +110 -0
- package/dist/better-auth/index.mjs +71 -0
- package/dist/cache/engine.d.mts +127 -0
- package/dist/cache/engine.mjs +235 -0
- package/dist/cache/envelope.mjs +32 -0
- package/dist/cache/index.d.mts +7 -2
- package/dist/cache/index.mjs +6 -2
- package/dist/cache/keys.mjs +131 -0
- package/dist/cache/memory-adapter.mjs +41 -7
- package/dist/cache/options.d.mts +112 -0
- package/dist/cache/options.mjs +25 -0
- package/dist/cache/plugin/context.d.mts +18 -0
- package/dist/cache/plugin/context.mjs +121 -0
- package/dist/cache/plugin/index.d.mts +86 -0
- package/dist/cache/plugin/index.mjs +78 -0
- package/dist/cache/plugin/invalidation-hooks.mjs +35 -0
- package/dist/cache/plugin/read-hooks.mjs +96 -0
- package/dist/cache/plugin/swr.mjs +20 -0
- package/dist/cache/runtime.d.mts +43 -0
- package/dist/cache/runtime.mjs +14 -0
- package/dist/cache/tag-index.mjs +84 -0
- package/dist/cache/timeout-adapter.d.mts +30 -0
- package/dist/cache/timeout-adapter.mjs +58 -0
- package/dist/cache/types.d.mts +45 -0
- package/dist/cache/version-store.mjs +57 -0
- package/dist/errors/contract.d.mts +37 -0
- package/dist/errors/contract.mjs +75 -0
- package/dist/errors/index.d.mts +4 -2
- package/dist/errors/index.mjs +4 -1
- package/dist/errors/schema.d.mts +101 -0
- package/dist/errors/schema.mjs +78 -0
- package/dist/errors/types.d.mts +113 -8
- package/dist/errors/types.mjs +29 -0
- package/dist/filter/match.mjs +38 -2
- package/dist/pagination/canonical.d.mts +35 -0
- package/dist/pagination/canonical.mjs +26 -0
- package/dist/pagination/cursor.mjs +4 -1
- package/dist/pagination/index.d.mts +3 -2
- package/dist/pagination/index.mjs +2 -1
- package/dist/pagination/types.d.mts +57 -3
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +2 -0
- package/dist/plugins/tenant-helpers.d.mts +63 -0
- package/dist/plugins/tenant-helpers.mjs +84 -0
- package/dist/query-parser/index.d.mts +2 -1
- package/dist/query-parser/index.mjs +2 -1
- package/dist/query-parser/parse-url.mjs +13 -11
- package/dist/query-parser/reserved.d.mts +43 -0
- package/dist/query-parser/reserved.mjs +56 -0
- package/dist/repository/agg-output.d.mts +63 -0
- package/dist/repository/agg-output.mjs +89 -0
- package/dist/repository/base.mjs +21 -0
- package/dist/repository/index.d.mts +4 -2
- package/dist/repository/index.mjs +3 -1
- package/dist/repository/options.d.mts +62 -0
- package/dist/repository/options.mjs +57 -0
- package/dist/repository/types.d.mts +935 -48
- package/dist/schema/field-rules.d.mts +60 -9
- package/dist/schema/field-rules.mjs +121 -10
- package/dist/schema/generator.d.mts +72 -0
- package/dist/schema/generator.mjs +16 -0
- package/dist/schema/index.d.mts +3 -2
- package/dist/schema/index.mjs +3 -2
- package/dist/schema/types.d.mts +77 -3
- package/dist/tenant/index.d.mts +3 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/tenant/resolve.d.mts +27 -0
- package/dist/tenant/resolve.mjs +69 -0
- package/dist/tenant/types.d.mts +142 -0
- package/dist/testing/conformance.mjs +666 -17
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/types.d.mts +99 -2
- package/package.json +27 -1
- package/dist/cache/stable-stringify.d.mts +0 -15
package/dist/errors/types.d.mts
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
//#region src/errors/types.d.ts
|
|
2
2
|
/**
|
|
3
|
-
* HTTP-shaped error
|
|
3
|
+
* HTTP-shaped error contracts used across every driver kit, integrator,
|
|
4
|
+
* and HTTP-emitting service in the org.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
/**
|
|
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 };
|
package/dist/filter/match.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 (
|
|
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;
|