@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
@@ -0,0 +1,127 @@
1
+ import { CacheReadResult, ResolvedCacheOptions } from "./options.mjs";
2
+ import { CacheAdapter } from "./types.mjs";
3
+
4
+ //#region src/cache/engine.d.ts
5
+ interface CacheEngineOptions {
6
+ /** Cache key namespace prefix. Default: `'rc'`. */
7
+ prefix?: string;
8
+ /**
9
+ * TTL jitter — randomizes the actual stored TTL so cache stampedes
10
+ * don't synchronize across many entries written together. Pass a
11
+ * number in `(0, 1]` for symmetric fractional jitter (`0.1` =
12
+ * uniform ±10%) or a function for custom logic. Default: `0` (off).
13
+ */
14
+ jitter?: number | ((ttl: number) => number);
15
+ }
16
+ /**
17
+ * In-flight claim outcome. `'claimed'` → caller owns the fetch;
18
+ * `'wait'` → caller awaits an already-in-flight fetch.
19
+ */
20
+ type SingleFlightClaim<T = unknown> = {
21
+ readonly status: 'claimed';
22
+ } | {
23
+ readonly status: 'wait';
24
+ readonly promise: Promise<T>;
25
+ };
26
+ declare class CacheEngine {
27
+ private readonly adapter;
28
+ private readonly prefix;
29
+ private readonly jitter;
30
+ /**
31
+ * In-flight fetches keyed by cache-key. Process-local (lives in this
32
+ * engine instance) — server restart clears it; cross-pod fanout is
33
+ * fine because each pod runs its own single-flight, and downstream
34
+ * load is bounded to N-pods worst case (a huge improvement over
35
+ * unbounded burst).
36
+ */
37
+ private readonly pending;
38
+ constructor(adapter: CacheAdapter, options?: CacheEngineOptions);
39
+ /**
40
+ * Read a cache entry under SWR + TTL semantics. Returns a
41
+ * structured `CacheReadResult` describing freshness state — the
42
+ * caller decides whether to serve, revalidate, or fetch fresh.
43
+ *
44
+ * **State table:**
45
+ * - `enabled: false` → `{ status: 'disabled' }` — caller fetches
46
+ * - `bypass: true` → `{ status: 'bypass' }` — caller fetches
47
+ * - missing / expired → `{ status: 'miss' }` — caller fetches
48
+ * - fresh → `{ status: 'fresh', data }`
49
+ * - stale + swr=true → `{ status: 'stale', data }` — caller serves + bg-refreshes
50
+ * - stale + swr=false → `{ status: 'miss' }` — caller fetches
51
+ */
52
+ get<TData>(key: string, opts: ResolvedCacheOptions): Promise<CacheReadResult<TData>>;
53
+ /**
54
+ * Write `value` under `key` with the resolved options. Skips silently
55
+ * when `enabled: false` (no cache pollution from disabled calls).
56
+ *
57
+ * Side effect: appends `key` to the tag side-index for every tag in
58
+ * `opts.tags` so future `invalidateByTags` calls find it.
59
+ */
60
+ set<TData>(key: string, value: TData, opts: ResolvedCacheOptions): Promise<void>;
61
+ /**
62
+ * Look up an in-flight fetch for `key`. Returns the promise the
63
+ * first miss-claimer registered, or `undefined` when no fetch is
64
+ * pending.
65
+ */
66
+ getPending<T = unknown>(key: string): Promise<T> | undefined;
67
+ /**
68
+ * Atomically claim `key` for a fetch. Returns `'claimed'` when this
69
+ * caller owns the fetch (it must call `resolvePending` or
70
+ * `rejectPending` when done) or `{ status: 'wait', promise }` when
71
+ * another caller already claimed — the returned promise resolves
72
+ * with the first claimer's result.
73
+ */
74
+ claimPending<T = unknown>(key: string): SingleFlightClaim<T>;
75
+ /** Resolve an in-flight claim with the fresh result + clear it. */
76
+ resolvePending<T>(key: string, value: T): void;
77
+ /**
78
+ * Reject an in-flight claim — waiters fail-fast (they DON'T retry
79
+ * inline; they get the same error as the claimer). Caller's choice
80
+ * whether to retry on a higher level.
81
+ */
82
+ rejectPending(key: string, error: unknown): void;
83
+ /** Internal — number of in-flight fetches; observability hook. */
84
+ get pendingCount(): number;
85
+ /**
86
+ * Invalidate every entry tagged with ANY of the provided tags. Reads
87
+ * each tag's index, deletes the listed cache entries, and clears
88
+ * the index. Returns the count of entries removed.
89
+ */
90
+ invalidateByTags(tags: readonly string[]): Promise<number>;
91
+ /**
92
+ * Read a model's current version (optionally per-scope). Used by
93
+ * the plugin to embed `v<version>` into every cache key so a single
94
+ * version bump orphans the model's cache space.
95
+ */
96
+ getVersion(model: string, scopeKey?: string): Promise<number>;
97
+ /**
98
+ * Bump the model's version (per-scope when `scopeKey` is supplied)
99
+ * to invalidate every cached read for it. Per-scope bumps don't
100
+ * affect other tenants' caches — TanStack-style targeted
101
+ * invalidation.
102
+ */
103
+ bumpVersion(model: string, scopeKey?: string): Promise<number>;
104
+ /** Wipe the entire cache namespace (when the adapter supports `clear`). */
105
+ clear(): Promise<void>;
106
+ /** Expose the prefix so plugins building keys downstream stay aligned. */
107
+ get keyPrefix(): string;
108
+ /**
109
+ * Warm the cache for `key` if it's not already populated. On hit
110
+ * (fresh OR stale) returns the cached value; on miss runs `fetcher`,
111
+ * stores the result, and returns it. Single-flight guarantees apply
112
+ * — concurrent `prefetch` calls for the same key share one fetcher
113
+ * invocation.
114
+ *
115
+ * **Use case:** preload dashboards before the user request lands
116
+ * (route-level `prefetch` after auth, scheduled-job warmup, server-
117
+ * push hints from a CDN edge).
118
+ *
119
+ * **Difference from `engine.get` + manual write:** this one method
120
+ * handles the miss-fetch-store sequence atomically, with single-
121
+ * flight dedup. Mirrors TanStack Query's
122
+ * `queryClient.prefetchQuery({ queryKey, queryFn })`.
123
+ */
124
+ prefetch<TData>(key: string, opts: ResolvedCacheOptions, fetcher: () => Promise<TData>): Promise<TData>;
125
+ }
126
+ //#endregion
127
+ export { CacheEngine, CacheEngineOptions, SingleFlightClaim };
@@ -0,0 +1,235 @@
1
+ import { buildEnvelope, inspectEnvelope } from "./envelope.mjs";
2
+ import { appendKeyToTags, invalidateByTags } from "./tag-index.mjs";
3
+ import { bumpModelVersion, getModelVersion } from "./version-store.mjs";
4
+ //#region src/cache/engine.ts
5
+ /**
6
+ * `CacheEngine` — the SWR + TTL + tag-invalidation behavior on top of
7
+ * a `CacheAdapter`. ONE implementation of the cache-flow primitives,
8
+ * shared across every kit + arc + Express/Nest hosts.
9
+ *
10
+ * Replaces three independent implementations:
11
+ * - mongokit's `withAggCache` (TTL/SWR/tag flow for aggregate)
12
+ * - mongokit's CRUD `cachePlugin` (TTL + version-bump for getById/getAll)
13
+ * - arc's `QueryCache` (TTL + SWR + version-bump + tag-version)
14
+ *
15
+ * **Production hardening (TanStack-inspired):**
16
+ * - **Single-flight on miss** — concurrent misses for the same key
17
+ * wait on the first fetch's promise (no cache stampede).
18
+ * - **Per-scope version-bump** — writes only invalidate the writing
19
+ * scope's cache, not other tenants' (targeted invalidation).
20
+ * - **Strictly-monotonic version** — same-millisecond writes never
21
+ * collide.
22
+ *
23
+ * Hosts compose this via `cachePlugin` (declarative, hook-driven) or
24
+ * call it directly when they need fine-grained control.
25
+ */
26
+ var CacheEngine = class {
27
+ adapter;
28
+ prefix;
29
+ jitter;
30
+ /**
31
+ * In-flight fetches keyed by cache-key. Process-local (lives in this
32
+ * engine instance) — server restart clears it; cross-pod fanout is
33
+ * fine because each pod runs its own single-flight, and downstream
34
+ * load is bounded to N-pods worst case (a huge improvement over
35
+ * unbounded burst).
36
+ */
37
+ pending = /* @__PURE__ */ new Map();
38
+ constructor(adapter, options = {}) {
39
+ this.adapter = adapter;
40
+ this.prefix = options.prefix ?? "rc";
41
+ this.jitter = resolveJitter(options.jitter);
42
+ }
43
+ /**
44
+ * Read a cache entry under SWR + TTL semantics. Returns a
45
+ * structured `CacheReadResult` describing freshness state — the
46
+ * caller decides whether to serve, revalidate, or fetch fresh.
47
+ *
48
+ * **State table:**
49
+ * - `enabled: false` → `{ status: 'disabled' }` — caller fetches
50
+ * - `bypass: true` → `{ status: 'bypass' }` — caller fetches
51
+ * - missing / expired → `{ status: 'miss' }` — caller fetches
52
+ * - fresh → `{ status: 'fresh', data }`
53
+ * - stale + swr=true → `{ status: 'stale', data }` — caller serves + bg-refreshes
54
+ * - stale + swr=false → `{ status: 'miss' }` — caller fetches
55
+ */
56
+ async get(key, opts) {
57
+ if (!opts.enabled) return {
58
+ status: "disabled",
59
+ data: void 0
60
+ };
61
+ if (opts.bypass) return {
62
+ status: "bypass",
63
+ data: void 0
64
+ };
65
+ const inspection = inspectEnvelope(await this.adapter.get(key));
66
+ if (inspection.state === "missing" || inspection.state === "expired") return {
67
+ status: "miss",
68
+ data: void 0
69
+ };
70
+ const env = inspection.envelope;
71
+ if (!env) return {
72
+ status: "miss",
73
+ data: void 0
74
+ };
75
+ const ageSeconds = Math.floor((Date.now() - env.createdAt) / 1e3);
76
+ if (inspection.state === "fresh") return {
77
+ status: "fresh",
78
+ data: env.data,
79
+ age: ageSeconds
80
+ };
81
+ if (opts.swr) return {
82
+ status: "stale",
83
+ data: env.data,
84
+ age: ageSeconds
85
+ };
86
+ return {
87
+ status: "miss",
88
+ data: void 0
89
+ };
90
+ }
91
+ /**
92
+ * Write `value` under `key` with the resolved options. Skips silently
93
+ * when `enabled: false` (no cache pollution from disabled calls).
94
+ *
95
+ * Side effect: appends `key` to the tag side-index for every tag in
96
+ * `opts.tags` so future `invalidateByTags` calls find it.
97
+ */
98
+ async set(key, value, opts) {
99
+ if (!opts.enabled) return;
100
+ const tags = opts.tags;
101
+ const envelope = buildEnvelope(value, opts.staleTime, opts.gcTime, tags);
102
+ const totalSeconds = opts.staleTime + opts.gcTime;
103
+ const ttl = this.jitter(totalSeconds);
104
+ await this.adapter.set(key, envelope, ttl);
105
+ if (tags.length > 0) await appendKeyToTags(this.adapter, this.prefix, key, tags, ttl);
106
+ }
107
+ /**
108
+ * Look up an in-flight fetch for `key`. Returns the promise the
109
+ * first miss-claimer registered, or `undefined` when no fetch is
110
+ * pending.
111
+ */
112
+ getPending(key) {
113
+ return this.pending.get(key)?.promise;
114
+ }
115
+ /**
116
+ * Atomically claim `key` for a fetch. Returns `'claimed'` when this
117
+ * caller owns the fetch (it must call `resolvePending` or
118
+ * `rejectPending` when done) or `{ status: 'wait', promise }` when
119
+ * another caller already claimed — the returned promise resolves
120
+ * with the first claimer's result.
121
+ */
122
+ claimPending(key) {
123
+ const existing = this.pending.get(key);
124
+ if (existing) return {
125
+ status: "wait",
126
+ promise: existing.promise
127
+ };
128
+ this.pending.set(key, Promise.withResolvers());
129
+ return { status: "claimed" };
130
+ }
131
+ /** Resolve an in-flight claim with the fresh result + clear it. */
132
+ resolvePending(key, value) {
133
+ const deferred = this.pending.get(key);
134
+ if (!deferred) return;
135
+ this.pending.delete(key);
136
+ deferred.resolve(value);
137
+ }
138
+ /**
139
+ * Reject an in-flight claim — waiters fail-fast (they DON'T retry
140
+ * inline; they get the same error as the claimer). Caller's choice
141
+ * whether to retry on a higher level.
142
+ */
143
+ rejectPending(key, error) {
144
+ const deferred = this.pending.get(key);
145
+ if (!deferred) return;
146
+ this.pending.delete(key);
147
+ deferred.reject(error);
148
+ }
149
+ /** Internal — number of in-flight fetches; observability hook. */
150
+ get pendingCount() {
151
+ return this.pending.size;
152
+ }
153
+ /**
154
+ * Invalidate every entry tagged with ANY of the provided tags. Reads
155
+ * each tag's index, deletes the listed cache entries, and clears
156
+ * the index. Returns the count of entries removed.
157
+ */
158
+ async invalidateByTags(tags) {
159
+ return invalidateByTags(this.adapter, this.prefix, tags);
160
+ }
161
+ /**
162
+ * Read a model's current version (optionally per-scope). Used by
163
+ * the plugin to embed `v<version>` into every cache key so a single
164
+ * version bump orphans the model's cache space.
165
+ */
166
+ async getVersion(model, scopeKey) {
167
+ return getModelVersion(this.adapter, this.prefix, model, scopeKey);
168
+ }
169
+ /**
170
+ * Bump the model's version (per-scope when `scopeKey` is supplied)
171
+ * to invalidate every cached read for it. Per-scope bumps don't
172
+ * affect other tenants' caches — TanStack-style targeted
173
+ * invalidation.
174
+ */
175
+ async bumpVersion(model, scopeKey) {
176
+ return bumpModelVersion(this.adapter, this.prefix, model, scopeKey);
177
+ }
178
+ /** Wipe the entire cache namespace (when the adapter supports `clear`). */
179
+ async clear() {
180
+ if (this.adapter.clear) await this.adapter.clear(`${this.prefix}:*`);
181
+ }
182
+ /** Expose the prefix so plugins building keys downstream stay aligned. */
183
+ get keyPrefix() {
184
+ return this.prefix;
185
+ }
186
+ /**
187
+ * Warm the cache for `key` if it's not already populated. On hit
188
+ * (fresh OR stale) returns the cached value; on miss runs `fetcher`,
189
+ * stores the result, and returns it. Single-flight guarantees apply
190
+ * — concurrent `prefetch` calls for the same key share one fetcher
191
+ * invocation.
192
+ *
193
+ * **Use case:** preload dashboards before the user request lands
194
+ * (route-level `prefetch` after auth, scheduled-job warmup, server-
195
+ * push hints from a CDN edge).
196
+ *
197
+ * **Difference from `engine.get` + manual write:** this one method
198
+ * handles the miss-fetch-store sequence atomically, with single-
199
+ * flight dedup. Mirrors TanStack Query's
200
+ * `queryClient.prefetchQuery({ queryKey, queryFn })`.
201
+ */
202
+ async prefetch(key, opts, fetcher) {
203
+ const result = await this.get(key, opts);
204
+ if (result.status === "fresh" || result.status === "stale") return result.data;
205
+ if (result.status === "miss") {
206
+ const claim = this.claimPending(key);
207
+ if (claim.status === "wait") return await claim.promise;
208
+ try {
209
+ const value = await fetcher();
210
+ await this.set(key, value, opts);
211
+ this.resolvePending(key, value);
212
+ return value;
213
+ } catch (err) {
214
+ this.rejectPending(key, err);
215
+ throw err;
216
+ }
217
+ }
218
+ const value = await fetcher();
219
+ if (opts.enabled) await this.set(key, value, opts);
220
+ return value;
221
+ }
222
+ };
223
+ function resolveJitter(jitter) {
224
+ if (!jitter) return (ttl) => ttl;
225
+ if (typeof jitter === "function") return (ttl) => Math.max(1, Math.round(jitter(ttl)));
226
+ const fraction = Math.min(1, Math.max(0, jitter));
227
+ if (fraction === 0) return (ttl) => ttl;
228
+ return (ttl) => {
229
+ const delta = ttl * fraction;
230
+ const jittered = ttl - delta + Math.random() * 2 * delta;
231
+ return Math.max(1, Math.round(jittered));
232
+ };
233
+ }
234
+ //#endregion
235
+ export { CacheEngine };
@@ -0,0 +1,32 @@
1
+ //#region src/cache/envelope.ts
2
+ /** Build an envelope from raw data + freshness windows (seconds). */
3
+ function buildEnvelope(data, staleTimeSeconds, gcTimeSeconds, tags, now = Date.now()) {
4
+ const staleAfter = now + Math.max(0, staleTimeSeconds) * 1e3;
5
+ return {
6
+ version: 1,
7
+ data,
8
+ createdAt: now,
9
+ staleAfter,
10
+ expiresAt: staleAfter + Math.max(0, gcTimeSeconds) * 1e3,
11
+ tags
12
+ };
13
+ }
14
+ /**
15
+ * Inspect an envelope against the current wall-clock time. Returns
16
+ * structured freshness state — caller chooses how to act on `'stale'`
17
+ * (serve + revalidate vs treat as miss) based on its SWR config.
18
+ */
19
+ function inspectEnvelope(envelope, now = Date.now()) {
20
+ if (!envelope || envelope.version !== 1) return { state: "missing" };
21
+ if (now >= envelope.expiresAt) return { state: "expired" };
22
+ if (now < envelope.staleAfter) return {
23
+ state: "fresh",
24
+ envelope
25
+ };
26
+ return {
27
+ state: "stale",
28
+ envelope
29
+ };
30
+ }
31
+ //#endregion
32
+ export { buildEnvelope, inspectEnvelope };
@@ -1,4 +1,9 @@
1
+ import { CacheOptions, CacheReadResult } from "./options.mjs";
1
2
  import { CacheAdapter } from "./types.mjs";
3
+ import { CacheEngine, CacheEngineOptions, SingleFlightClaim } from "./engine.mjs";
2
4
  import { createMemoryCacheAdapter } from "./memory-adapter.mjs";
3
- import { stableStringify } from "./stable-stringify.mjs";
4
- export { type CacheAdapter, createMemoryCacheAdapter, stableStringify };
5
+ import { DEFAULT_SHAPE_KEYS_BY_OP } from "./plugin/context.mjs";
6
+ import { DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, LogCallbacks, RepositoryCacheHandle, RepositoryCachePluginOptions, cachePlugin } from "./plugin/index.mjs";
7
+ import { scheduleBackground } from "./runtime.mjs";
8
+ import { CacheTimeoutError, TimeoutAdapterOptions, withTimeout } from "./timeout-adapter.mjs";
9
+ export { type CacheAdapter, CacheEngine, type CacheEngineOptions, type CacheOptions, type CacheReadResult, CacheTimeoutError, DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, DEFAULT_SHAPE_KEYS_BY_OP, type LogCallbacks, type RepositoryCacheHandle, type RepositoryCachePluginOptions, type SingleFlightClaim, type TimeoutAdapterOptions, cachePlugin, createMemoryCacheAdapter, scheduleBackground, withTimeout };
@@ -1,3 +1,7 @@
1
+ import { CacheEngine } from "./engine.mjs";
1
2
  import { createMemoryCacheAdapter } from "./memory-adapter.mjs";
2
- import { stableStringify } from "./stable-stringify.mjs";
3
- export { createMemoryCacheAdapter, stableStringify };
3
+ import { DEFAULT_SHAPE_KEYS_BY_OP } from "./plugin/context.mjs";
4
+ import { scheduleBackground } from "./runtime.mjs";
5
+ import { DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, cachePlugin } from "./plugin/index.mjs";
6
+ import { CacheTimeoutError, withTimeout } from "./timeout-adapter.mjs";
7
+ export { CacheEngine, CacheTimeoutError, DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, DEFAULT_SHAPE_KEYS_BY_OP, cachePlugin, createMemoryCacheAdapter, scheduleBackground, withTimeout };
@@ -0,0 +1,131 @@
1
+ import { stableStringify } from "./stable-stringify.mjs";
2
+ //#region src/cache/keys.ts
3
+ /**
4
+ * Cache key derivation. Builds stable keys from operation context so
5
+ * any kit + arc + Express/Nest hosts compute the same key for the
6
+ * same logical request — letting one Redis serve a mixed-kit fleet.
7
+ *
8
+ * **Key shape:**
9
+ * `<prefix>:<op>:<model>:v<version>:<paramsHash>:<scopeHash>`
10
+ *
11
+ * - `prefix` — tenant of the cache namespace (`'rc'` default)
12
+ * - `op` — repository operation name (`getById`, `aggregate`, ...)
13
+ * - `model` — the entity model name
14
+ * - `version` — collection version (bumped on writes; orphans all
15
+ * keys for the model in O(1)). Per-scope when the
16
+ * plugin extracts a scopeKey.
17
+ * - `paramsHash` — fnv1a64 of stable-stringified call params
18
+ * (filter, id, sort, kit-specific options like
19
+ * `lean`). The plugin's per-op allowlist decides
20
+ * which fields participate.
21
+ * - `scopeHash` — short hash of the auto-extracted scope tags
22
+ * (`org:<id>` / `user:<id>`). Keeps the key short
23
+ * while preserving cross-tenant isolation.
24
+ */
25
+ /** Build the canonical cache key. */
26
+ function buildCacheKey(input) {
27
+ const paramsHash = fnv1a64(stableStringify(input.params));
28
+ const scopeHash = input.scopeTags.length > 0 ? fnv1a64(input.scopeTags.join("|")) : "0";
29
+ return `${input.prefix}:${input.operation}:${input.model}:v${input.version}:${paramsHash}:${scopeHash}`;
30
+ }
31
+ /** Tag-index key — maps a tag to the set of cache keys carrying it. */
32
+ function tagIndexKey(prefix, tag) {
33
+ return `${prefix}:tag:${tag}`;
34
+ }
35
+ /**
36
+ * Collection-version key — bumped on writes to orphan all reads.
37
+ *
38
+ * Per-scope sharding when `scopeKey` is supplied — e.g. `'org:abc'`
39
+ * keys to `<prefix>:ver:<model>:org:abc`, so writes inside `org:abc`
40
+ * don't invalidate other tenants' cached reads. Without a scopeKey
41
+ * the version is global (legacy semantic — invalidates all reads on
42
+ * any write).
43
+ */
44
+ function versionKey(prefix, model, scopeKey) {
45
+ return scopeKey ? `${prefix}:ver:${model}:${scopeKey}` : `${prefix}:ver:${model}`;
46
+ }
47
+ /**
48
+ * Build a deterministic scope-key string from extracted scope tags.
49
+ * Used to suffix `versionKey` so per-scope invalidation works.
50
+ *
51
+ * Returns `undefined` when no scope is present — caller falls back to
52
+ * global version semantics.
53
+ */
54
+ function scopeKeyFromTags(scopeTags) {
55
+ if (scopeTags.length === 0) return void 0;
56
+ return [...scopeTags].sort().join("|");
57
+ }
58
+ /**
59
+ * Merge two tag lists, preserving first-seen order and deduping. Used
60
+ * by the plugin to combine caller-supplied tags with auto-derived
61
+ * scope tags into a single index-able set.
62
+ */
63
+ function mergeTags(a, b) {
64
+ if (a.length === 0) return b;
65
+ if (b.length === 0) return a;
66
+ const seen = /* @__PURE__ */ new Set();
67
+ const out = [];
68
+ for (const t of a) if (!seen.has(t)) {
69
+ seen.add(t);
70
+ out.push(t);
71
+ }
72
+ for (const t of b) if (!seen.has(t)) {
73
+ seen.add(t);
74
+ out.push(t);
75
+ }
76
+ return out;
77
+ }
78
+ /**
79
+ * Extract scope tags from a hook context. Reads the canonical fields
80
+ * multi-tenant + auth plugins inject (`organizationId`, `userId`).
81
+ * Returns an empty array when no scope is present — public reads share
82
+ * one cache slot.
83
+ *
84
+ * Looks at THREE locations in priority order:
85
+ * 1. `context.filter.<field>` — multi-tenant injects here
86
+ * 2. `context.options.<field>` — kit options bag (arc audit attribution)
87
+ * 3. `context.<field>` — top-level fallback
88
+ */
89
+ function extractScopeTags(context) {
90
+ if (!context) return [];
91
+ const tags = [];
92
+ const orgId = pickScopeField(context, "organizationId");
93
+ if (orgId) tags.push(`org:${orgId}`);
94
+ const userId = pickScopeField(context, "userId");
95
+ if (userId) tags.push(`user:${userId}`);
96
+ return tags;
97
+ }
98
+ function pickScopeField(context, field) {
99
+ const filter = context["filter"];
100
+ if (filter && typeof filter[field] === "string") return filter[field];
101
+ const options = context["options"];
102
+ if (options && typeof options[field] === "string") return options[field];
103
+ if (typeof context[field] === "string") return context[field];
104
+ }
105
+ /**
106
+ * FNV-1a 64-bit — non-cryptographic hash for cache-key shortening.
107
+ * Emits stable base-36 strings (≤13 chars) for compact keys.
108
+ *
109
+ * **Why 64-bit, not 32-bit:** djb2 32-bit (~4B value space) hits ~50%
110
+ * birthday-paradox collision probability around √(2^32) ≈ 65k distinct
111
+ * keys — easily exceeded by a multi-tenant fleet. FNV-1a 64-bit pushes
112
+ * that threshold to ~4B keys, which no realistic cache approaches.
113
+ * Both are non-cryptographic; FNV-1a has better avalanche on short
114
+ * ASCII strings (cache keys).
115
+ *
116
+ * BigInt is required — JS numbers lose precision past 2^53. The cost
117
+ * is negligible at cache-key sizes (key strings are typically <1KB).
118
+ */
119
+ const FNV_OFFSET_64 = 14695981039346656037n;
120
+ const FNV_PRIME_64 = 1099511628211n;
121
+ const FNV_MASK_64 = 18446744073709551615n;
122
+ function fnv1a64(str) {
123
+ let hash = FNV_OFFSET_64;
124
+ for (let i = 0; i < str.length; i++) {
125
+ hash ^= BigInt(str.charCodeAt(i));
126
+ hash = hash * FNV_PRIME_64 & FNV_MASK_64;
127
+ }
128
+ return hash.toString(36);
129
+ }
130
+ //#endregion
131
+ export { buildCacheKey, extractScopeTags, mergeTags, scopeKeyFromTags, tagIndexKey, versionKey };
@@ -3,15 +3,20 @@
3
3
  function createMemoryCacheAdapter() {
4
4
  const store = /* @__PURE__ */ new Map();
5
5
  const now = () => Date.now();
6
+ function readUnexpired(key) {
7
+ const entry = store.get(key);
8
+ if (!entry) return void 0;
9
+ if (entry.expiresAt !== 0 && entry.expiresAt < now()) {
10
+ store.delete(key);
11
+ return;
12
+ }
13
+ return entry;
14
+ }
6
15
  return {
7
16
  get(key) {
8
- const entry = store.get(key);
9
- if (!entry) return void 0;
10
- if (entry.expiresAt !== 0 && entry.expiresAt < now()) {
11
- store.delete(key);
12
- return;
13
- }
14
- return entry.value;
17
+ const value = readUnexpired(key)?.value;
18
+ if (value instanceof Set) return Array.from(value);
19
+ return value;
15
20
  },
16
21
  set(key, value, ttlSeconds = 60) {
17
22
  const expiresAt = ttlSeconds === 0 ? 0 : now() + ttlSeconds * 1e3;
@@ -30,6 +35,35 @@ function createMemoryCacheAdapter() {
30
35
  }
31
36
  const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
32
37
  for (const key of store.keys()) if (key.startsWith(prefix)) store.delete(key);
38
+ },
39
+ addToSet(key, members, ttlSeconds = 60) {
40
+ const existing = readUnexpired(key);
41
+ let set;
42
+ if (existing && existing.value instanceof Set) set = existing.value;
43
+ else {
44
+ set = /* @__PURE__ */ new Set();
45
+ const expiresAt = existing?.expiresAt ?? (ttlSeconds === 0 ? 0 : now() + ttlSeconds * 1e3);
46
+ store.set(key, {
47
+ value: set,
48
+ expiresAt
49
+ });
50
+ }
51
+ let added = 0;
52
+ for (const m of members) if (!set.has(m)) {
53
+ set.add(m);
54
+ added++;
55
+ }
56
+ return added;
57
+ },
58
+ increment(key, by = 1, ttlSeconds = 60) {
59
+ const existing = readUnexpired(key);
60
+ const next = (existing && typeof existing.value === "number" && Number.isFinite(existing.value) ? existing.value : 0) + by;
61
+ const expiresAt = existing ? existing.expiresAt : ttlSeconds === 0 ? 0 : now() + ttlSeconds * 1e3;
62
+ store.set(key, {
63
+ value: next,
64
+ expiresAt
65
+ });
66
+ return next;
33
67
  }
34
68
  };
35
69
  }