@classytic/repo-core 0.3.0 → 0.4.1

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 (79) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/dist/_virtual/_rolldown/runtime.mjs +7 -0
  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/index.d.mts +2 -1
  40. package/dist/errors/index.mjs +2 -1
  41. package/dist/errors/schema.d.mts +101 -0
  42. package/dist/errors/schema.mjs +78 -0
  43. package/dist/filter/match.mjs +38 -2
  44. package/dist/lock/index.d.mts +132 -0
  45. package/dist/lock/index.mjs +162 -0
  46. package/dist/pagination/canonical.d.mts +8 -8
  47. package/dist/pagination/canonical.mjs +3 -9
  48. package/dist/pagination/cursor.mjs +4 -1
  49. package/dist/pagination/index.d.mts +2 -2
  50. package/dist/pagination/types.d.mts +17 -27
  51. package/dist/plugins/index.d.mts +2 -0
  52. package/dist/plugins/index.mjs +2 -0
  53. package/dist/plugins/tenant-helpers.d.mts +63 -0
  54. package/dist/plugins/tenant-helpers.mjs +84 -0
  55. package/dist/query-parser/index.d.mts +2 -1
  56. package/dist/query-parser/index.mjs +2 -1
  57. package/dist/query-parser/parse-url.mjs +13 -11
  58. package/dist/query-parser/reserved.d.mts +43 -0
  59. package/dist/query-parser/reserved.mjs +56 -0
  60. package/dist/repository/agg-output.d.mts +63 -0
  61. package/dist/repository/agg-output.mjs +89 -0
  62. package/dist/repository/index.d.mts +4 -2
  63. package/dist/repository/index.mjs +3 -1
  64. package/dist/repository/options.d.mts +62 -0
  65. package/dist/repository/options.mjs +57 -0
  66. package/dist/repository/types.d.mts +936 -49
  67. package/dist/schema/field-rules.d.mts +41 -1
  68. package/dist/schema/field-rules.mjs +92 -1
  69. package/dist/schema/index.d.mts +2 -2
  70. package/dist/schema/index.mjs +2 -2
  71. package/dist/schema/types.d.mts +21 -0
  72. package/dist/testing/conformance.mjs +666 -17
  73. package/dist/testing/index.d.mts +3 -2
  74. package/dist/testing/index.mjs +2 -1
  75. package/dist/testing/lock-conformance.d.mts +25 -0
  76. package/dist/testing/lock-conformance.mjs +167 -0
  77. package/dist/testing/types.d.mts +99 -2
  78. package/package.json +23 -1
  79. 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 = "";
@@ -0,0 +1,132 @@
1
+ //#region src/lock/index.d.ts
2
+ /**
3
+ * Distributed lock contract for the @classytic ecosystem.
4
+ *
5
+ * Coordinates exclusive access to a *named resource* across multiple
6
+ * processes / replicas. The canonical use case: cron leader election.
7
+ * Multi-pod deployments fire every scheduled tick on every replica;
8
+ * without coordination the same sweep runs N times. A lock per cron
9
+ * name lets exactly one replica win each cycle.
10
+ *
11
+ * Distinct from `leasePlugin` (mongokit / sqlitekit) — that one
12
+ * leases existing **rows** (work-queue items) to workers. This
13
+ * adapter leases **names** (no underlying row required), so it
14
+ * doubles as singleton-flag, election-leader, and rate-limit
15
+ * coordination primitive.
16
+ *
17
+ * ## Why this lives in repo-core
18
+ *
19
+ * The contract is driver-free: any K-V or row store with conditional
20
+ * upsert can implement it. Mongokit ships a Mongo-backed adapter
21
+ * (`@classytic/mongokit/lock`); sqlitekit ships a SQLite-backed one
22
+ * (`@classytic/sqlitekit/lock`); future kits (pgkit, prismakit) wire
23
+ * their own. Hosts pick the adapter that matches their primary store
24
+ * and treat the lock as an implementation detail of "we already have
25
+ * a database, use it for coordination too."
26
+ *
27
+ * ## Lease semantics
28
+ *
29
+ * `tryAcquire(name, holderId, leaseMs)` returns `true` when `holderId`
30
+ * now holds the lock — either because it was free, the prior lease
31
+ * expired, or the same holder is extending. `false` means another
32
+ * holder owns an unexpired lease.
33
+ *
34
+ * `release(name, holderId)` releases the lock if held by this holder.
35
+ * Returns `true` on actual release, `false` when the holder didn't
36
+ * own it. Idempotent.
37
+ *
38
+ * Crashed leaders are reclaimed when their lease expires — adapters
39
+ * MUST treat `expiresAt < now` as "free for the taking" inside the
40
+ * atomic acquire path. Hosts size `leaseMs` based on cron interval
41
+ * (typically 80–95%); too long delays failover, too short risks the
42
+ * lease lapsing while the leader is still working.
43
+ *
44
+ * ## Sync-or-async
45
+ *
46
+ * Methods may return `Promise` or sync values; consumers `await`
47
+ * either way. Memory adapter is sync; SQL/Mongo adapters are async.
48
+ *
49
+ * ## Why one file, not a barrel
50
+ *
51
+ * Types + the in-memory reference adapter + the instance-id helper
52
+ * total under 200 LOC and have no internal seams worth a deep
53
+ * subpath. A barrel would re-export from siblings (memory-adapter,
54
+ * instance-id, types) and pull every sibling into the consumer
55
+ * graph — `sideEffects: false` lets modern bundlers tree-shake, but
56
+ * single-file is the cheaper guarantee.
57
+ */
58
+ interface LockAdapter {
59
+ /**
60
+ * Try to acquire (or extend) a named lock for `holderId`, valid
61
+ * for `leaseMs` milliseconds.
62
+ *
63
+ * Same `holderId` calling twice extends the lease — idempotent.
64
+ * Adapters MUST atomically check "free OR mine" and update in a
65
+ * single round-trip; a read-then-write split is racy.
66
+ */
67
+ tryAcquire(name: string, holderId: string, leaseMs: number): Promise<boolean> | boolean;
68
+ /**
69
+ * Release the lock if held by `holderId`. Returns `true` on actual
70
+ * release, `false` when the lock isn't held by this holder. Safe
71
+ * to call without ever having acquired (returns `false`).
72
+ */
73
+ release(name: string, holderId: string): Promise<boolean> | boolean;
74
+ /**
75
+ * Optional: introspect a lock without trying to acquire it. Useful
76
+ * for diagnostics ("which replica holds X?") and tests. Returns
77
+ * `null` when the lock is free or expired.
78
+ *
79
+ * Not in the hot path — adapters that can't implement cheaply may
80
+ * omit it. Consumers must check existence: `adapter.inspect?.(name)`.
81
+ */
82
+ inspect?(name: string): Promise<LockState | null> | LockState | null;
83
+ }
84
+ /** Snapshot of a lock's current holder. */
85
+ interface LockState {
86
+ /** The lock name (mirrored back for convenience). */
87
+ name: string;
88
+ /** Holder identifier. Free-form — typically `hostname.pid.uuid`. */
89
+ holder: string;
90
+ /** When the current lease expires. UTC. */
91
+ expiresAt: Date;
92
+ /** When the current holder first acquired (or last extended) the lock. */
93
+ acquiredAt: Date;
94
+ }
95
+ /** Adapter-construction options that every backend shares. */
96
+ interface BaseLockAdapterOptions {
97
+ /**
98
+ * Default lease length in milliseconds, applied when a caller passes
99
+ * `leaseMs <= 0` to `tryAcquire`. Most callers pass an explicit
100
+ * value sized to their cron interval; the default is a safety net.
101
+ */
102
+ defaultLeaseMs?: number;
103
+ }
104
+ /**
105
+ * Reference in-memory `LockAdapter` — single-process only.
106
+ *
107
+ * Useful for tests + single-pod deployments that want the same API
108
+ * as the production adapter without setting up a database. NOT a
109
+ * coordination primitive — there's no shared state across processes,
110
+ * so two processes each construct their own `Map` and both think
111
+ * they hold every lock. For real multi-replica safety use
112
+ * `@classytic/mongokit/lock`, `@classytic/sqlitekit/lock`, or a
113
+ * future kit-specific implementation.
114
+ *
115
+ * The atomic check-and-set inside `tryAcquire` is genuine — Node's
116
+ * single-threaded event loop guarantees a synchronous read-then-write
117
+ * is atomic relative to other JS, the same guarantee a real adapter
118
+ * gets from its database's atomic upsert.
119
+ */
120
+ declare function createMemoryLockAdapter(options?: BaseLockAdapterOptions): LockAdapter;
121
+ /**
122
+ * Returns a stable instance id for this process, generating it once
123
+ * on first call and caching for the process lifetime. Idempotent.
124
+ */
125
+ declare function getInstanceId(): string;
126
+ /**
127
+ * Test helper — overrides the cached id. Call between tests that
128
+ * simulate multiple replicas in one process. Pass `null` to reset.
129
+ */
130
+ declare function setInstanceIdForTesting(id: string | null): void;
131
+ //#endregion
132
+ export { BaseLockAdapterOptions, LockAdapter, LockState, createMemoryLockAdapter, getInstanceId, setInstanceIdForTesting };
@@ -0,0 +1,162 @@
1
+ import { __require } from "../_virtual/_rolldown/runtime.mjs";
2
+ import { randomUUID } from "node:crypto";
3
+ //#region src/lock/index.ts
4
+ /**
5
+ * Distributed lock contract for the @classytic ecosystem.
6
+ *
7
+ * Coordinates exclusive access to a *named resource* across multiple
8
+ * processes / replicas. The canonical use case: cron leader election.
9
+ * Multi-pod deployments fire every scheduled tick on every replica;
10
+ * without coordination the same sweep runs N times. A lock per cron
11
+ * name lets exactly one replica win each cycle.
12
+ *
13
+ * Distinct from `leasePlugin` (mongokit / sqlitekit) — that one
14
+ * leases existing **rows** (work-queue items) to workers. This
15
+ * adapter leases **names** (no underlying row required), so it
16
+ * doubles as singleton-flag, election-leader, and rate-limit
17
+ * coordination primitive.
18
+ *
19
+ * ## Why this lives in repo-core
20
+ *
21
+ * The contract is driver-free: any K-V or row store with conditional
22
+ * upsert can implement it. Mongokit ships a Mongo-backed adapter
23
+ * (`@classytic/mongokit/lock`); sqlitekit ships a SQLite-backed one
24
+ * (`@classytic/sqlitekit/lock`); future kits (pgkit, prismakit) wire
25
+ * their own. Hosts pick the adapter that matches their primary store
26
+ * and treat the lock as an implementation detail of "we already have
27
+ * a database, use it for coordination too."
28
+ *
29
+ * ## Lease semantics
30
+ *
31
+ * `tryAcquire(name, holderId, leaseMs)` returns `true` when `holderId`
32
+ * now holds the lock — either because it was free, the prior lease
33
+ * expired, or the same holder is extending. `false` means another
34
+ * holder owns an unexpired lease.
35
+ *
36
+ * `release(name, holderId)` releases the lock if held by this holder.
37
+ * Returns `true` on actual release, `false` when the holder didn't
38
+ * own it. Idempotent.
39
+ *
40
+ * Crashed leaders are reclaimed when their lease expires — adapters
41
+ * MUST treat `expiresAt < now` as "free for the taking" inside the
42
+ * atomic acquire path. Hosts size `leaseMs` based on cron interval
43
+ * (typically 80–95%); too long delays failover, too short risks the
44
+ * lease lapsing while the leader is still working.
45
+ *
46
+ * ## Sync-or-async
47
+ *
48
+ * Methods may return `Promise` or sync values; consumers `await`
49
+ * either way. Memory adapter is sync; SQL/Mongo adapters are async.
50
+ *
51
+ * ## Why one file, not a barrel
52
+ *
53
+ * Types + the in-memory reference adapter + the instance-id helper
54
+ * total under 200 LOC and have no internal seams worth a deep
55
+ * subpath. A barrel would re-export from siblings (memory-adapter,
56
+ * instance-id, types) and pull every sibling into the consumer
57
+ * graph — `sideEffects: false` lets modern bundlers tree-shake, but
58
+ * single-file is the cheaper guarantee.
59
+ */
60
+ /**
61
+ * Reference in-memory `LockAdapter` — single-process only.
62
+ *
63
+ * Useful for tests + single-pod deployments that want the same API
64
+ * as the production adapter without setting up a database. NOT a
65
+ * coordination primitive — there's no shared state across processes,
66
+ * so two processes each construct their own `Map` and both think
67
+ * they hold every lock. For real multi-replica safety use
68
+ * `@classytic/mongokit/lock`, `@classytic/sqlitekit/lock`, or a
69
+ * future kit-specific implementation.
70
+ *
71
+ * The atomic check-and-set inside `tryAcquire` is genuine — Node's
72
+ * single-threaded event loop guarantees a synchronous read-then-write
73
+ * is atomic relative to other JS, the same guarantee a real adapter
74
+ * gets from its database's atomic upsert.
75
+ */
76
+ function createMemoryLockAdapter(options = {}) {
77
+ const { defaultLeaseMs = 3e4 } = options;
78
+ const store = /* @__PURE__ */ new Map();
79
+ function readLive(name, now) {
80
+ const entry = store.get(name);
81
+ if (!entry) return void 0;
82
+ if (entry.expiresAt <= now) {
83
+ store.delete(name);
84
+ return;
85
+ }
86
+ return entry;
87
+ }
88
+ return {
89
+ tryAcquire(name, holderId, leaseMs) {
90
+ const ms = leaseMs > 0 ? leaseMs : defaultLeaseMs;
91
+ const now = Date.now();
92
+ const live = readLive(name, now);
93
+ if (live && live.holder !== holderId) return false;
94
+ store.set(name, {
95
+ holder: holderId,
96
+ expiresAt: now + ms,
97
+ acquiredAt: live ? live.acquiredAt : now
98
+ });
99
+ return true;
100
+ },
101
+ release(name, holderId) {
102
+ const live = readLive(name, Date.now());
103
+ if (!live || live.holder !== holderId) return false;
104
+ store.delete(name);
105
+ return true;
106
+ },
107
+ inspect(name) {
108
+ const live = readLive(name, Date.now());
109
+ if (!live) return null;
110
+ return {
111
+ name,
112
+ holder: live.holder,
113
+ expiresAt: new Date(live.expiresAt),
114
+ acquiredAt: new Date(live.acquiredAt)
115
+ };
116
+ }
117
+ };
118
+ }
119
+ /**
120
+ * Process-wide instance id helper.
121
+ *
122
+ * Lock holders need a stable identifier per process that's unique
123
+ * across replicas. The standard recipe is `hostname.pid.shortuuid`:
124
+ *
125
+ * - `hostname`: distinguishes containers on the same host.
126
+ * - `pid`: distinguishes worker processes on the same container.
127
+ * - short uuid: distinguishes restarts on the same host with
128
+ * pid-reuse (rare but possible after fast crash-loop).
129
+ *
130
+ * Edge runtimes (Cloudflare Workers, Vercel Edge) lack `os.hostname()`
131
+ * and `process.pid` — the helper falls back to a uuid-only id, which
132
+ * is still unique per worker isolate.
133
+ */
134
+ let cachedInstanceId = null;
135
+ /**
136
+ * Returns a stable instance id for this process, generating it once
137
+ * on first call and caching for the process lifetime. Idempotent.
138
+ */
139
+ function getInstanceId() {
140
+ if (cachedInstanceId) return cachedInstanceId;
141
+ cachedInstanceId = buildInstanceId();
142
+ return cachedInstanceId;
143
+ }
144
+ function buildInstanceId() {
145
+ const shortUuid = randomUUID().slice(0, 8);
146
+ let hostname = "unknown";
147
+ let pid = "edge";
148
+ try {
149
+ hostname = __require("node:os").hostname();
150
+ pid = typeof process !== "undefined" && process.pid ? process.pid : "edge";
151
+ } catch {}
152
+ return `${hostname}.${pid}.${shortUuid}`;
153
+ }
154
+ /**
155
+ * Test helper — overrides the cached id. Call between tests that
156
+ * simulate multiple replicas in one process. Pass `null` to reset.
157
+ */
158
+ function setInstanceIdForTesting(id) {
159
+ cachedInstanceId = id;
160
+ }
161
+ //#endregion
162
+ export { createMemoryLockAdapter, getInstanceId, setInstanceIdForTesting };
@@ -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 };