@tekir/cache 0.1.6 → 0.1.8

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/dist/cache.d.ts CHANGED
@@ -15,6 +15,7 @@ export declare class Cache {
15
15
  private stores;
16
16
  private defaultStore;
17
17
  private defaultTtl;
18
+ private inFlight;
18
19
  /**
19
20
  * Create a new Cache instance.
20
21
  *
@@ -41,7 +42,7 @@ export declare class Cache {
41
42
  * @param key - The cache key.
42
43
  * @returns The cached value, or `null` if not found or expired.
43
44
  */
44
- get<T = unknown>(key: string): Promise<T | null>;
45
+ get<T = any>(key: string): Promise<T | null>;
45
46
  /**
46
47
  * Set a value in the default store.
47
48
  *
@@ -76,6 +77,10 @@ export declare class Cache {
76
77
  * @param factory - An async function that produces the value when not cached.
77
78
  * @returns The cached or freshly-computed value.
78
79
  *
80
+ * Concurrent misses for the same key share a single `factory()` execution
81
+ * (single-flight) so a popular key expiring does not trigger a stampede
82
+ * (thundering herd) against the backend.
83
+ *
79
84
  * @example
80
85
  * ```ts
81
86
  * const users = await cache.getOrSet('users', 300, () => db.query('SELECT * FROM users'))
package/dist/cache.js CHANGED
@@ -15,6 +15,9 @@ export class Cache {
15
15
  stores;
16
16
  defaultStore;
17
17
  defaultTtl;
18
+ // In-flight factory promises keyed by `<store>:<key>` for single-flight
19
+ // stampede protection in getOrSet.
20
+ inFlight = new Map();
18
21
  /**
19
22
  * Create a new Cache instance.
20
23
  *
@@ -86,6 +89,10 @@ export class Cache {
86
89
  * @param factory - An async function that produces the value when not cached.
87
90
  * @returns The cached or freshly-computed value.
88
91
  *
92
+ * Concurrent misses for the same key share a single `factory()` execution
93
+ * (single-flight) so a popular key expiring does not trigger a stampede
94
+ * (thundering herd) against the backend.
95
+ *
89
96
  * @example
90
97
  * ```ts
91
98
  * const users = await cache.getOrSet('users', 300, () => db.query('SELECT * FROM users'))
@@ -95,9 +102,17 @@ export class Cache {
95
102
  const cached = await this.get(key);
96
103
  if (cached !== null)
97
104
  return cached;
98
- const value = await factory();
99
- await this.set(key, value, ttl);
100
- return value;
105
+ const flightKey = `${this.defaultStore}:${key}`;
106
+ const existing = this.inFlight.get(flightKey);
107
+ if (existing)
108
+ return existing;
109
+ const promise = (async () => {
110
+ const value = await factory();
111
+ await this.set(key, value, ttl);
112
+ return value;
113
+ })().finally(() => this.inFlight.delete(flightKey));
114
+ this.inFlight.set(flightKey, promise);
115
+ return promise;
101
116
  }
102
117
  /**
103
118
  * Get a value and immediately delete it from the cache (atomic get-and-remove).
@@ -112,7 +127,10 @@ export class Cache {
112
127
  */
113
128
  async pull(key) {
114
129
  const value = await this.get(key);
115
- if (value !== null)
130
+ // Use has() rather than a null-check so that an explicitly cached `null`
131
+ // value is still evicted (get() alone cannot distinguish "absent" from
132
+ // "stored null").
133
+ if (value !== null || (await this.has(key)))
116
134
  await this.delete(key);
117
135
  return value;
118
136
  }
@@ -59,6 +59,25 @@ export type HttpCacheOptions = {
59
59
  * responses. Default: true.
60
60
  */
61
61
  setCacheControl?: boolean;
62
+ /**
63
+ * How to handle requests that carry credentials (`Authorization` or `Cookie`).
64
+ *
65
+ * Caching a per-user response under a shared key leaks one user's response to
66
+ * another. To prevent that, the default behaviour is `'bypass'`: authenticated
67
+ * requests skip the cache entirely unless you opt in.
68
+ *
69
+ * - `'bypass'` (default): never read or write the cache for credentialed
70
+ * requests.
71
+ * - `'vary'`: include the credential headers in the cache key so each
72
+ * identity gets its own entry. Use this only when you understand the cache
73
+ * size implications.
74
+ * - `'allow'`: cache credentialed requests under the same key as anonymous
75
+ * ones. DANGEROUS: only safe when the response is identical for every user.
76
+ *
77
+ * Note: providing a custom `key` builder that already incorporates identity
78
+ * overrides this and is always honoured.
79
+ */
80
+ authenticated?: "bypass" | "vary" | "allow";
62
81
  };
63
82
  export type HttpCacheCtx = {
64
83
  request: {
@@ -23,6 +23,8 @@
23
23
  * ```
24
24
  */
25
25
  import { Cache } from "./cache";
26
+ /** Request headers that indicate a credentialed/per-user request. */
27
+ const CREDENTIAL_HEADERS = ["authorization", "cookie"];
26
28
  const SAFE_METHODS = ["GET", "HEAD"];
27
29
  const isStore = (s) => !!s &&
28
30
  typeof s.get === "function" &&
@@ -101,12 +103,25 @@ const entryToResponse = (e, opts) => {
101
103
  headers["x-tekir-cache"] = "HIT";
102
104
  return new Response(e.body, { status: e.status, headers });
103
105
  };
106
+ /** True if the request carries an Authorization or Cookie header. */
107
+ const hasCredentials = (req) => {
108
+ for (const h of CREDENTIAL_HEADERS) {
109
+ if (req.headers?.get?.(h))
110
+ return true;
111
+ }
112
+ return false;
113
+ };
104
114
  export function cache(opts = {}) {
105
115
  const ttl = opts.ttl ?? 60;
106
116
  const methods = new Set((opts.methods ?? SAFE_METHODS).map((m) => m.toUpperCase()));
107
117
  const vary = opts.vary ?? [];
108
118
  const prefix = opts.prefix ?? "http:";
109
- const buildKey = opts.key ?? ((ctx) => defaultKey(ctx, vary));
119
+ const hasCustomKey = typeof opts.key === "function";
120
+ const authMode = opts.authenticated ?? "bypass";
121
+ // When varying by credentials, fold the credential headers into the key so
122
+ // each identity gets a private entry.
123
+ const effectiveVary = authMode === "vary" ? [...vary, ...CREDENTIAL_HEADERS] : vary;
124
+ const buildKey = opts.key ?? ((ctx) => defaultKey(ctx, effectiveVary));
110
125
  const directStore = resolveStore(opts.store);
111
126
  return async function cacheMiddleware(ctx, next) {
112
127
  const req = ctx.request;
@@ -123,6 +138,14 @@ export function cache(opts = {}) {
123
138
  await next();
124
139
  return;
125
140
  }
141
+ // Secure-by-default: a credentialed (Authorization/Cookie) request usually
142
+ // produces a per-user response. Caching it under a shared key would leak it
143
+ // to other users. Unless the caller opted into 'vary'/'allow' or supplied a
144
+ // custom identity-aware key, bypass the cache for such requests.
145
+ if (authMode === "bypass" && !hasCustomKey && hasCredentials(req)) {
146
+ await next();
147
+ return;
148
+ }
126
149
  // Resolve store: option > module-level default (set by CacheProvider).
127
150
  // No store anywhere → middleware acts as a transparent no-op so the
128
151
  // route still works without a registered cache backend.
@@ -28,7 +28,7 @@ export declare class DatabaseCacheStore implements CacheStore {
28
28
  * @param key - The cache key.
29
29
  * @returns The stored value parsed from JSON, or `null`.
30
30
  */
31
- get<T = unknown>(key: string): Promise<T | null>;
31
+ get<T = any>(key: string): Promise<T | null>;
32
32
  /**
33
33
  * Store a value under the given key, reptekirg any existing entry.
34
34
  *
@@ -51,6 +51,13 @@ export declare class DatabaseCacheStore implements CacheStore {
51
51
  * @returns Always returns `true`.
52
52
  */
53
53
  delete(key: string): Promise<boolean>;
54
+ /**
55
+ * Delete all expired entries. Entries are otherwise only removed when read,
56
+ * so call this periodically to stop never-read expired rows from accumulating.
57
+ *
58
+ * @returns A promise that resolves once expired rows have been removed.
59
+ */
60
+ prune(): Promise<void>;
54
61
  /**
55
62
  * Remove all entries from the cache table.
56
63
  *
@@ -32,7 +32,13 @@ export class DatabaseCacheStore {
32
32
  await this.db.exec(`CREATE TABLE IF NOT EXISTS "${this.table}" (key TEXT PRIMARY KEY, value TEXT, expires_at INTEGER)`);
33
33
  this._ready = true;
34
34
  }
35
- catch { }
35
+ catch (e) {
36
+ // Don't silently swallow: an unset _ready means every later get/set blows
37
+ // up with a confusing SQL error. Surface the real cause and rethrow so the
38
+ // misconfiguration is visible at the point of failure.
39
+ console.error(`[@tekir/cache] Failed to create cache table "${this.table}": ${e.message}`);
40
+ throw e;
41
+ }
36
42
  }
37
43
  /**
38
44
  * Retrieve a cached value by key. Expired entries are deleted and `null` is returned.
@@ -76,7 +82,16 @@ export class DatabaseCacheStore {
76
82
  * @returns `true` if the key exists and has not expired.
77
83
  */
78
84
  async has(key) {
79
- return (await this.get(key)) !== null;
85
+ await this._ensureTable();
86
+ const row = await this.db.queryOne(`SELECT expires_at FROM "${this.table}" WHERE key = ?`, [key]);
87
+ if (!row)
88
+ return false;
89
+ if (row.expires_at && Date.now() > row.expires_at) {
90
+ await this.db.run(`DELETE FROM "${this.table}" WHERE key = ?`, [key]);
91
+ return false;
92
+ }
93
+ // Present even if the stored value is `null` (negative caching).
94
+ return true;
80
95
  }
81
96
  /**
82
97
  * Delete a key from the database.
@@ -89,6 +104,16 @@ export class DatabaseCacheStore {
89
104
  await this.db.run(`DELETE FROM "${this.table}" WHERE key = ?`, [key]);
90
105
  return true;
91
106
  }
107
+ /**
108
+ * Delete all expired entries. Entries are otherwise only removed when read,
109
+ * so call this periodically to stop never-read expired rows from accumulating.
110
+ *
111
+ * @returns A promise that resolves once expired rows have been removed.
112
+ */
113
+ async prune() {
114
+ await this._ensureTable();
115
+ await this.db.run(`DELETE FROM "${this.table}" WHERE expires_at IS NOT NULL AND expires_at < ?`, [Date.now()]);
116
+ }
92
117
  /**
93
118
  * Remove all entries from the cache table.
94
119
  *
@@ -12,6 +12,19 @@ import type { CacheStore } from '../types';
12
12
  */
13
13
  export declare class MemoryCacheStore implements CacheStore {
14
14
  private data;
15
+ private maxEntries;
16
+ private writes;
17
+ /**
18
+ * @param options.maxEntries - Hard cap on stored entries. When exceeded, the
19
+ * oldest insertion-order entry is evicted (after pruning expired ones).
20
+ * Defaults to 10000 to bound memory growth from never-read keys. Set to 0
21
+ * to disable the cap.
22
+ */
23
+ constructor(options?: {
24
+ maxEntries?: number;
25
+ });
26
+ /** Remove every entry whose TTL has elapsed. */
27
+ prune(): void;
15
28
  /**
16
29
  * Retrieve a cached value by key. Returns `null` if the key does not exist
17
30
  * or has expired.
@@ -19,7 +32,7 @@ export declare class MemoryCacheStore implements CacheStore {
19
32
  * @param key - The cache key.
20
33
  * @returns The stored value cast to `T`, or `null`.
21
34
  */
22
- get<T = unknown>(key: string): Promise<T | null>;
35
+ get<T = any>(key: string): Promise<T | null>;
23
36
  /**
24
37
  * Store a value under the given key with an optional TTL.
25
38
  *
@@ -11,6 +11,25 @@
11
11
  */
12
12
  export class MemoryCacheStore {
13
13
  data = new Map();
14
+ maxEntries;
15
+ writes = 0;
16
+ /**
17
+ * @param options.maxEntries - Hard cap on stored entries. When exceeded, the
18
+ * oldest insertion-order entry is evicted (after pruning expired ones).
19
+ * Defaults to 10000 to bound memory growth from never-read keys. Set to 0
20
+ * to disable the cap.
21
+ */
22
+ constructor(options = {}) {
23
+ this.maxEntries = options.maxEntries ?? 10000;
24
+ }
25
+ /** Remove every entry whose TTL has elapsed. */
26
+ prune() {
27
+ const now = Date.now();
28
+ for (const [k, entry] of this.data) {
29
+ if (entry.expiresAt && now > entry.expiresAt)
30
+ this.data.delete(k);
31
+ }
32
+ }
14
33
  /**
15
34
  * Retrieve a cached value by key. Returns `null` if the key does not exist
16
35
  * or has expired.
@@ -40,6 +59,18 @@ export class MemoryCacheStore {
40
59
  value,
41
60
  expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
42
61
  });
62
+ // Periodically sweep expired entries so keys that are never read again don't
63
+ // accumulate unbounded, then enforce the size cap.
64
+ if (this.maxEntries > 0) {
65
+ if (++this.writes % 256 === 0)
66
+ this.prune();
67
+ while (this.data.size > this.maxEntries) {
68
+ const oldest = this.data.keys().next().value;
69
+ if (oldest === undefined)
70
+ break;
71
+ this.data.delete(oldest);
72
+ }
73
+ }
43
74
  }
44
75
  /**
45
76
  * Check whether a key exists and is not expired.
@@ -48,7 +79,16 @@ export class MemoryCacheStore {
48
79
  * @returns `true` if the key exists and has not expired.
49
80
  */
50
81
  async has(key) {
51
- return (await this.get(key)) !== null;
82
+ // Check the map directly so an explicitly stored `null` value still counts
83
+ // as present (get() alone can't distinguish stored-null from absent).
84
+ const entry = this.data.get(key);
85
+ if (!entry)
86
+ return false;
87
+ if (entry.expiresAt && Date.now() > entry.expiresAt) {
88
+ this.data.delete(key);
89
+ return false;
90
+ }
91
+ return true;
52
92
  }
53
93
  /**
54
94
  * Delete a key from the store.
@@ -39,7 +39,7 @@ export declare class RedisCacheStore implements CacheStore {
39
39
  * const value = await store.get<string>('greeting') // 'hello' or null
40
40
  * ```
41
41
  */
42
- get<T = unknown>(key: string): Promise<T | null>;
42
+ get<T = any>(key: string): Promise<T | null>;
43
43
  /**
44
44
  * Store a value under the given key with an optional TTL.
45
45
  * The value is serialized to JSON before being sent to Redis.
@@ -79,8 +79,13 @@ export declare class RedisCacheStore implements CacheStore {
79
79
  */
80
80
  delete(key: string): Promise<boolean>;
81
81
  /**
82
- * Flush the entire Redis database. Use with caution as this removes all keys,
83
- * not just those managed by this store.
82
+ * Remove only this store's keys (those under its prefix) using a non-blocking
83
+ * SCAN + DEL. This no longer flushes the entire Redis database, so data owned
84
+ * by other stores sharing the same database (sessions, queues, ...) is left
85
+ * intact.
86
+ *
87
+ * If the prefix is empty (which would match every key) this throws rather than
88
+ * risk wiping unrelated data; configure a non-empty prefix to use flush.
84
89
  *
85
90
  * @example
86
91
  * ```ts
@@ -60,12 +60,22 @@ export class RedisCacheStore {
60
60
  */
61
61
  async set(key, value, ttlSeconds) {
62
62
  const val = JSON.stringify(value);
63
+ const fullKey = this.prefix + key;
63
64
  if (ttlSeconds) {
64
- await this.redis.set(this.prefix + key, val);
65
- await this.redis.expire(this.prefix + key, ttlSeconds);
65
+ const sendable = this.redis;
66
+ if (typeof sendable.send === "function") {
67
+ // Atomic SET ... EX so a crash between SET and EXPIRE can never leave a
68
+ // permanent (TTL-less) key behind.
69
+ await sendable.send("SET", [fullKey, val, "EX", String(Math.floor(ttlSeconds))]);
70
+ }
71
+ else {
72
+ // Fallback for clients without a raw `send`: best-effort two-step.
73
+ await this.redis.set(fullKey, val);
74
+ await this.redis.expire(fullKey, ttlSeconds);
75
+ }
66
76
  }
67
77
  else {
68
- await this.redis.set(this.prefix + key, val);
78
+ await this.redis.set(fullKey, val);
69
79
  }
70
80
  }
71
81
  /**
@@ -100,8 +110,13 @@ export class RedisCacheStore {
100
110
  return true;
101
111
  }
102
112
  /**
103
- * Flush the entire Redis database. Use with caution as this removes all keys,
104
- * not just those managed by this store.
113
+ * Remove only this store's keys (those under its prefix) using a non-blocking
114
+ * SCAN + DEL. This no longer flushes the entire Redis database, so data owned
115
+ * by other stores sharing the same database (sessions, queues, ...) is left
116
+ * intact.
117
+ *
118
+ * If the prefix is empty (which would match every key) this throws rather than
119
+ * risk wiping unrelated data; configure a non-empty prefix to use flush.
105
120
  *
106
121
  * @example
107
122
  * ```ts
@@ -109,8 +124,20 @@ export class RedisCacheStore {
109
124
  * ```
110
125
  */
111
126
  async flush() {
127
+ if (!this.prefix) {
128
+ throw new Error('[@tekir/cache] RedisCacheStore.flush() refused: an empty prefix would delete every key in the database. Configure a non-empty prefix.');
129
+ }
112
130
  // Structural cast: callers may pass clients with varying `send` arg
113
131
  // types. We only need it to accept a string command and an array.
114
- await this.redis.send('FLUSHDB', []);
132
+ const client = this.redis;
133
+ const pattern = `${this.prefix}*`;
134
+ let cursor = '0';
135
+ do {
136
+ const reply = (await client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100']));
137
+ const [next, batch] = reply;
138
+ cursor = next;
139
+ if (batch && batch.length)
140
+ await client.send('DEL', batch);
141
+ } while (cursor !== '0');
115
142
  }
116
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekir/cache",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "In-memory, Redis, and database caching abstraction",
5
5
  "author": "dev@tekir.io",
6
6
  "license": "MIT",
@@ -39,10 +39,10 @@
39
39
  }
40
40
  },
41
41
  "dependencies": {
42
- "@tekir/core": "^0.1.3"
42
+ "@tekir/core": "^0.1.34"
43
43
  },
44
44
  "peerDependencies": {
45
- "@tekir/redis": "^0.1.0"
45
+ "@tekir/redis": "^0.1.1"
46
46
  },
47
47
  "peerDependenciesMeta": {
48
48
  "@tekir/redis": {
package/src/cache.ts CHANGED
@@ -18,6 +18,9 @@ export class Cache {
18
18
  private stores: Record<string, CacheStore>
19
19
  private defaultStore: string
20
20
  private defaultTtl: number
21
+ // In-flight factory promises keyed by `<store>:<key>` for single-flight
22
+ // stampede protection in getOrSet.
23
+ private inFlight = new Map<string, Promise<unknown>>()
21
24
 
22
25
  /**
23
26
  * Create a new Cache instance.
@@ -56,7 +59,7 @@ export class Cache {
56
59
  * @param key - The cache key.
57
60
  * @returns The cached value, or `null` if not found or expired.
58
61
  */
59
- async get<T = unknown>(key: string): Promise<T | null> { return this.store().get<T>(key) }
62
+ async get<T = any>(key: string): Promise<T | null> { return this.store().get<T>(key) }
60
63
 
61
64
  /**
62
65
  * Set a value in the default store.
@@ -96,6 +99,10 @@ export class Cache {
96
99
  * @param factory - An async function that produces the value when not cached.
97
100
  * @returns The cached or freshly-computed value.
98
101
  *
102
+ * Concurrent misses for the same key share a single `factory()` execution
103
+ * (single-flight) so a popular key expiring does not trigger a stampede
104
+ * (thundering herd) against the backend.
105
+ *
99
106
  * @example
100
107
  * ```ts
101
108
  * const users = await cache.getOrSet('users', 300, () => db.query('SELECT * FROM users'))
@@ -104,9 +111,19 @@ export class Cache {
104
111
  async getOrSet<T>(key: string, ttl: number, factory: () => Promise<T>): Promise<T> {
105
112
  const cached = await this.get<T>(key)
106
113
  if (cached !== null) return cached
107
- const value = await factory()
108
- await this.set(key, value, ttl)
109
- return value
114
+
115
+ const flightKey = `${this.defaultStore}:${key}`
116
+ const existing = this.inFlight.get(flightKey)
117
+ if (existing) return existing as Promise<T>
118
+
119
+ const promise = (async () => {
120
+ const value = await factory()
121
+ await this.set(key, value, ttl)
122
+ return value
123
+ })().finally(() => this.inFlight.delete(flightKey))
124
+
125
+ this.inFlight.set(flightKey, promise)
126
+ return promise
110
127
  }
111
128
 
112
129
  /**
@@ -122,7 +139,10 @@ export class Cache {
122
139
  */
123
140
  async pull<T = unknown>(key: string): Promise<T | null> {
124
141
  const value = await this.get<T>(key)
125
- if (value !== null) await this.delete(key)
142
+ // Use has() rather than a null-check so that an explicitly cached `null`
143
+ // value is still evicted (get() alone cannot distinguish "absent" from
144
+ // "stored null").
145
+ if (value !== null || (await this.has(key))) await this.delete(key)
126
146
  return value
127
147
  }
128
148
  }
package/src/http-cache.ts CHANGED
@@ -60,8 +60,30 @@ export type HttpCacheOptions = {
60
60
  * responses. Default: true.
61
61
  */
62
62
  setCacheControl?: boolean
63
+ /**
64
+ * How to handle requests that carry credentials (`Authorization` or `Cookie`).
65
+ *
66
+ * Caching a per-user response under a shared key leaks one user's response to
67
+ * another. To prevent that, the default behaviour is `'bypass'`: authenticated
68
+ * requests skip the cache entirely unless you opt in.
69
+ *
70
+ * - `'bypass'` (default): never read or write the cache for credentialed
71
+ * requests.
72
+ * - `'vary'`: include the credential headers in the cache key so each
73
+ * identity gets its own entry. Use this only when you understand the cache
74
+ * size implications.
75
+ * - `'allow'`: cache credentialed requests under the same key as anonymous
76
+ * ones. DANGEROUS: only safe when the response is identical for every user.
77
+ *
78
+ * Note: providing a custom `key` builder that already incorporates identity
79
+ * overrides this and is always honoured.
80
+ */
81
+ authenticated?: "bypass" | "vary" | "allow"
63
82
  }
64
83
 
84
+ /** Request headers that indicate a credentialed/per-user request. */
85
+ const CREDENTIAL_HEADERS = ["authorization", "cookie"]
86
+
65
87
  export type HttpCacheCtx = {
66
88
  request: { url: string; method: string; headers: Headers; raw?: Request }
67
89
  params?: Record<string, string>
@@ -162,12 +184,26 @@ const entryToResponse = (e: CachedEntry, opts: HttpCacheOptions): Response => {
162
184
  return new Response(e.body, { status: e.status, headers })
163
185
  }
164
186
 
187
+ /** True if the request carries an Authorization or Cookie header. */
188
+ const hasCredentials = (req: HttpCacheCtx["request"]): boolean => {
189
+ for (const h of CREDENTIAL_HEADERS) {
190
+ if (req.headers?.get?.(h)) return true
191
+ }
192
+ return false
193
+ }
194
+
165
195
  export function cache(opts: HttpCacheOptions = {}) {
166
196
  const ttl = opts.ttl ?? 60
167
197
  const methods = new Set((opts.methods ?? SAFE_METHODS).map((m) => m.toUpperCase()))
168
198
  const vary = opts.vary ?? []
169
199
  const prefix = opts.prefix ?? "http:"
170
- const buildKey = opts.key ?? ((ctx: HttpCacheCtx) => defaultKey(ctx, vary))
200
+ const hasCustomKey = typeof opts.key === "function"
201
+ const authMode = opts.authenticated ?? "bypass"
202
+ // When varying by credentials, fold the credential headers into the key so
203
+ // each identity gets a private entry.
204
+ const effectiveVary =
205
+ authMode === "vary" ? [...vary, ...CREDENTIAL_HEADERS] : vary
206
+ const buildKey = opts.key ?? ((ctx: HttpCacheCtx) => defaultKey(ctx, effectiveVary))
171
207
  const directStore = resolveStore(opts.store)
172
208
 
173
209
  return async function cacheMiddleware(ctx: any, next: () => Promise<void>) {
@@ -188,6 +224,15 @@ export function cache(opts: HttpCacheOptions = {}) {
188
224
  return
189
225
  }
190
226
 
227
+ // Secure-by-default: a credentialed (Authorization/Cookie) request usually
228
+ // produces a per-user response. Caching it under a shared key would leak it
229
+ // to other users. Unless the caller opted into 'vary'/'allow' or supplied a
230
+ // custom identity-aware key, bypass the cache for such requests.
231
+ if (authMode === "bypass" && !hasCustomKey && hasCredentials(req)) {
232
+ await next()
233
+ return
234
+ }
235
+
191
236
  // Resolve store: option > module-level default (set by CacheProvider).
192
237
  // No store anywhere → middleware acts as a transparent no-op so the
193
238
  // route still works without a registered cache backend.
@@ -35,7 +35,13 @@ export class DatabaseCacheStore implements CacheStore {
35
35
  try {
36
36
  await this.db.exec(`CREATE TABLE IF NOT EXISTS "${this.table}" (key TEXT PRIMARY KEY, value TEXT, expires_at INTEGER)`)
37
37
  this._ready = true
38
- } catch {}
38
+ } catch (e) {
39
+ // Don't silently swallow: an unset _ready means every later get/set blows
40
+ // up with a confusing SQL error. Surface the real cause and rethrow so the
41
+ // misconfiguration is visible at the point of failure.
42
+ console.error(`[@tekir/cache] Failed to create cache table "${this.table}": ${(e as Error).message}`)
43
+ throw e
44
+ }
39
45
  }
40
46
 
41
47
  /**
@@ -44,7 +50,7 @@ export class DatabaseCacheStore implements CacheStore {
44
50
  * @param key - The cache key.
45
51
  * @returns The stored value parsed from JSON, or `null`.
46
52
  */
47
- async get<T = unknown>(key: string): Promise<T | null> {
53
+ async get<T = any>(key: string): Promise<T | null> {
48
54
  await this._ensureTable()
49
55
  const row = await this.db.queryOne(`SELECT value, expires_at FROM "${this.table}" WHERE key = ?`, [key]) as CacheDbRow | null
50
56
  if (!row) return null
@@ -79,7 +85,15 @@ export class DatabaseCacheStore implements CacheStore {
79
85
  * @returns `true` if the key exists and has not expired.
80
86
  */
81
87
  async has(key: string): Promise<boolean> {
82
- return (await this.get(key)) !== null
88
+ await this._ensureTable()
89
+ const row = await this.db.queryOne(`SELECT expires_at FROM "${this.table}" WHERE key = ?`, [key]) as { expires_at: number | null } | null
90
+ if (!row) return false
91
+ if (row.expires_at && Date.now() > row.expires_at) {
92
+ await this.db.run(`DELETE FROM "${this.table}" WHERE key = ?`, [key])
93
+ return false
94
+ }
95
+ // Present even if the stored value is `null` (negative caching).
96
+ return true
83
97
  }
84
98
 
85
99
  /**
@@ -94,6 +108,17 @@ export class DatabaseCacheStore implements CacheStore {
94
108
  return true
95
109
  }
96
110
 
111
+ /**
112
+ * Delete all expired entries. Entries are otherwise only removed when read,
113
+ * so call this periodically to stop never-read expired rows from accumulating.
114
+ *
115
+ * @returns A promise that resolves once expired rows have been removed.
116
+ */
117
+ async prune(): Promise<void> {
118
+ await this._ensureTable()
119
+ await this.db.run(`DELETE FROM "${this.table}" WHERE expires_at IS NOT NULL AND expires_at < ?`, [Date.now()])
120
+ }
121
+
97
122
  /**
98
123
  * Remove all entries from the cache table.
99
124
  *
@@ -13,6 +13,26 @@ import type { CacheStore } from '../types'
13
13
  */
14
14
  export class MemoryCacheStore implements CacheStore {
15
15
  private data = new Map<string, { value: unknown; expiresAt: number | null }>()
16
+ private maxEntries: number
17
+ private writes = 0
18
+
19
+ /**
20
+ * @param options.maxEntries - Hard cap on stored entries. When exceeded, the
21
+ * oldest insertion-order entry is evicted (after pruning expired ones).
22
+ * Defaults to 10000 to bound memory growth from never-read keys. Set to 0
23
+ * to disable the cap.
24
+ */
25
+ constructor(options: { maxEntries?: number } = {}) {
26
+ this.maxEntries = options.maxEntries ?? 10000
27
+ }
28
+
29
+ /** Remove every entry whose TTL has elapsed. */
30
+ prune(): void {
31
+ const now = Date.now()
32
+ for (const [k, entry] of this.data) {
33
+ if (entry.expiresAt && now > entry.expiresAt) this.data.delete(k)
34
+ }
35
+ }
16
36
 
17
37
  /**
18
38
  * Retrieve a cached value by key. Returns `null` if the key does not exist
@@ -21,7 +41,7 @@ export class MemoryCacheStore implements CacheStore {
21
41
  * @param key - The cache key.
22
42
  * @returns The stored value cast to `T`, or `null`.
23
43
  */
24
- async get<T = unknown>(key: string): Promise<T | null> {
44
+ async get<T = any>(key: string): Promise<T | null> {
25
45
  const entry = this.data.get(key)
26
46
  if (!entry) return null
27
47
  if (entry.expiresAt && Date.now() > entry.expiresAt) {
@@ -43,6 +63,16 @@ export class MemoryCacheStore implements CacheStore {
43
63
  value,
44
64
  expiresAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
45
65
  })
66
+ // Periodically sweep expired entries so keys that are never read again don't
67
+ // accumulate unbounded, then enforce the size cap.
68
+ if (this.maxEntries > 0) {
69
+ if (++this.writes % 256 === 0) this.prune()
70
+ while (this.data.size > this.maxEntries) {
71
+ const oldest = this.data.keys().next().value
72
+ if (oldest === undefined) break
73
+ this.data.delete(oldest)
74
+ }
75
+ }
46
76
  }
47
77
 
48
78
  /**
@@ -52,7 +82,15 @@ export class MemoryCacheStore implements CacheStore {
52
82
  * @returns `true` if the key exists and has not expired.
53
83
  */
54
84
  async has(key: string): Promise<boolean> {
55
- return (await this.get(key)) !== null
85
+ // Check the map directly so an explicitly stored `null` value still counts
86
+ // as present (get() alone can't distinguish stored-null from absent).
87
+ const entry = this.data.get(key)
88
+ if (!entry) return false
89
+ if (entry.expiresAt && Date.now() > entry.expiresAt) {
90
+ this.data.delete(key)
91
+ return false
92
+ }
93
+ return true
56
94
  }
57
95
 
58
96
  /**
@@ -56,7 +56,7 @@ export class RedisCacheStore implements CacheStore {
56
56
  * const value = await store.get<string>('greeting') // 'hello' or null
57
57
  * ```
58
58
  */
59
- async get<T = unknown>(key: string): Promise<T | null> {
59
+ async get<T = any>(key: string): Promise<T | null> {
60
60
  const val = await this.redis.get(this.prefix + key)
61
61
  if (val === null) return null
62
62
  try { return JSON.parse(val) as T } catch { return val as unknown as T }
@@ -77,11 +77,20 @@ export class RedisCacheStore implements CacheStore {
77
77
  */
78
78
  async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
79
79
  const val = JSON.stringify(value)
80
+ const fullKey = this.prefix + key
80
81
  if (ttlSeconds) {
81
- await this.redis.set(this.prefix + key, val)
82
- await this.redis.expire(this.prefix + key, ttlSeconds)
82
+ const sendable = this.redis as unknown as Partial<RawSendable>
83
+ if (typeof sendable.send === "function") {
84
+ // Atomic SET ... EX so a crash between SET and EXPIRE can never leave a
85
+ // permanent (TTL-less) key behind.
86
+ await sendable.send("SET", [fullKey, val, "EX", String(Math.floor(ttlSeconds))])
87
+ } else {
88
+ // Fallback for clients without a raw `send`: best-effort two-step.
89
+ await this.redis.set(fullKey, val)
90
+ await this.redis.expire(fullKey, ttlSeconds)
91
+ }
83
92
  } else {
84
- await this.redis.set(this.prefix + key, val)
93
+ await this.redis.set(fullKey, val)
85
94
  }
86
95
  }
87
96
 
@@ -119,8 +128,13 @@ export class RedisCacheStore implements CacheStore {
119
128
  }
120
129
 
121
130
  /**
122
- * Flush the entire Redis database. Use with caution as this removes all keys,
123
- * not just those managed by this store.
131
+ * Remove only this store's keys (those under its prefix) using a non-blocking
132
+ * SCAN + DEL. This no longer flushes the entire Redis database, so data owned
133
+ * by other stores sharing the same database (sessions, queues, ...) is left
134
+ * intact.
135
+ *
136
+ * If the prefix is empty (which would match every key) this throws rather than
137
+ * risk wiping unrelated data; configure a non-empty prefix to use flush.
124
138
  *
125
139
  * @example
126
140
  * ```ts
@@ -128,8 +142,21 @@ export class RedisCacheStore implements CacheStore {
128
142
  * ```
129
143
  */
130
144
  async flush(): Promise<void> {
145
+ if (!this.prefix) {
146
+ throw new Error(
147
+ '[@tekir/cache] RedisCacheStore.flush() refused: an empty prefix would delete every key in the database. Configure a non-empty prefix.'
148
+ )
149
+ }
131
150
  // Structural cast: callers may pass clients with varying `send` arg
132
151
  // types. We only need it to accept a string command and an array.
133
- await (this.redis as unknown as RawSendable).send('FLUSHDB', [])
152
+ const client = this.redis as unknown as RawSendable
153
+ const pattern = `${this.prefix}*`
154
+ let cursor = '0'
155
+ do {
156
+ const reply = (await client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100'])) as [string, string[]]
157
+ const [next, batch] = reply
158
+ cursor = next
159
+ if (batch && batch.length) await client.send('DEL', batch)
160
+ } while (cursor !== '0')
134
161
  }
135
162
  }