@iskra-bun/kv-kit 0.1.0 → 0.3.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.
@@ -1,32 +1,133 @@
1
1
  import type { KVAdapter } from '../types';
2
+ import { checkTtl } from '../ttl';
3
+
4
+ /** setTimeout's limit: a longer delay (a TTL over ~24.8 days) fires at once. */
5
+ const MAX_TIMEOUT_MS = 2 ** 31 - 1;
6
+
7
+ /** An expiring set: each member's expiry (ms since the epoch, Infinity for none). */
8
+ interface ExpiringSet {
9
+ members: Map<string, number>;
10
+ expiresAt: number;
11
+ /** Size at which expired members are next dropped. */
12
+ pruneAt: number;
13
+ }
2
14
 
3
15
  export class MemoryAdapter implements KVAdapter {
4
16
  id = 'memory';
5
- private store = new Map<string, any>();
17
+ private store = new Map<string, unknown>();
18
+ private sets = new Map<string, ExpiringSet>();
19
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
6
20
 
7
21
  connect() {
8
22
  // No-op
9
23
  }
24
+
10
25
  disconnect() {
26
+ for (const timer of this.timers.values()) {
27
+ clearTimeout(timer);
28
+ }
29
+ this.timers.clear();
11
30
  this.store.clear();
31
+ this.sets.clear();
12
32
  }
13
33
 
14
- async get(key: string) {
15
- return this.store.get(key);
34
+ // Values are copied in and out, as Redis does: a stored object returned by
35
+ // reference let one request's mutation show up in every other one.
36
+ async get<T = unknown>(key: string): Promise<T | undefined> {
37
+ return structuredClone(this.store.get(key)) as T | undefined;
38
+ }
39
+
40
+ async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
41
+ const seconds = checkTtl(ttl);
42
+ const copy = structuredClone(value);
43
+ // Note: storing `null`/`undefined` is undefined behavior across adapters.
44
+ // This in-memory adapter stores the value verbatim (so `get` returns it
45
+ // as-is), whereas the RedisAdapter normalizes both to "absent" because
46
+ // they have no faithful JSON round-trip. Callers should not depend on
47
+ // either form being preserved.
48
+ //
49
+ // Clear any existing expiry timer for this key before setting a new one
50
+ this.clearTimer(key);
51
+ this.sets.delete(key);
52
+ this.store.set(key, copy);
53
+
54
+ if (seconds !== undefined) this.expireIn(key, seconds * 1000);
55
+ }
56
+
57
+ /** Arms the expiry timer, in steps when the delay exceeds setTimeout's limit. */
58
+ private expireIn(key: string, ms: number) {
59
+ const step = Math.min(ms, MAX_TIMEOUT_MS);
60
+ const timer = setTimeout(() => {
61
+ if (ms > step) {
62
+ this.expireIn(key, ms - step);
63
+ return;
64
+ }
65
+ this.store.delete(key);
66
+ this.sets.delete(key);
67
+ this.timers.delete(key);
68
+ }, step);
69
+
70
+ // Avoid keeping the process alive just for expiry timers
71
+ timer.unref?.();
72
+
73
+ this.timers.set(key, timer);
16
74
  }
17
75
 
18
- async set(key: string, value: any, ttl?: number) {
19
- this.store.set(key, value);
20
- if (ttl) {
21
- setTimeout(() => this.store.delete(key), ttl * 1000);
76
+ private clearTimer(key: string) {
77
+ const timer = this.timers.get(key);
78
+ if (timer !== undefined) {
79
+ clearTimeout(timer);
80
+ this.timers.delete(key);
22
81
  }
23
82
  }
24
83
 
25
- async del(key: string) {
84
+ async del(key: string): Promise<void> {
85
+ this.clearTimer(key);
26
86
  this.store.delete(key);
87
+ this.sets.delete(key);
88
+ }
89
+
90
+ async has(key: string): Promise<boolean> {
91
+ return this.store.has(key) || this.sets.has(key);
92
+ }
93
+
94
+ async clear(prefix = ''): Promise<void> {
95
+ for (const key of [...this.store.keys(), ...this.sets.keys()]) {
96
+ if (key.startsWith(prefix)) await this.del(key);
97
+ }
98
+ }
99
+
100
+ async sadd(key: string, member: string, ttl?: number): Promise<void> {
101
+ const seconds = checkTtl(ttl);
102
+ const now = Date.now();
103
+ const expiresAt = seconds === undefined ? Infinity : now + seconds * 1000;
104
+ let set = this.sets.get(key);
105
+ if (!set) {
106
+ this.store.delete(key);
107
+ set = { members: new Map(), expiresAt: 0, pruneAt: 64 };
108
+ this.sets.set(key, set);
109
+ }
110
+ // A member keeps its latest expiry: dropped earlier, an entry written
111
+ // with a longer TTL would survive an invalidation of its tag.
112
+ set.members.set(member, Math.max(set.members.get(member) ?? 0, expiresAt));
113
+ // Expired members go each time the set doubles: O(1) per add on average.
114
+ if (set.members.size >= set.pruneAt) {
115
+ for (const [name, at] of set.members) if (at <= now) set.members.delete(name);
116
+ set.pruneAt = Math.max(64, set.members.size * 2);
117
+ }
118
+ if (expiresAt > set.expiresAt) {
119
+ set.expiresAt = expiresAt;
120
+ this.clearTimer(key);
121
+ if (expiresAt !== Infinity) this.expireIn(key, expiresAt - now);
122
+ }
27
123
  }
28
124
 
29
- async has(key: string) {
30
- return this.store.has(key);
125
+ async sdrain(key: string): Promise<string[]> {
126
+ const set = this.sets.get(key);
127
+ if (!set) return [];
128
+ this.sets.delete(key);
129
+ this.clearTimer(key);
130
+ const now = Date.now();
131
+ return [...set.members].filter(([, at]) => at > now).map(([name]) => name);
31
132
  }
32
133
  }
@@ -1,47 +1,297 @@
1
1
  import type { KVAdapter } from '../types';
2
+ import { checkTtl } from '../ttl';
2
3
  import Redis from 'ioredis';
4
+ import type { RedisOptions } from 'ioredis';
5
+
6
+ /**
7
+ * Single consistent codec shared by every write/read path.
8
+ *
9
+ * `undefined` is guarded explicitly: there is no JSON representation for it,
10
+ * so it is stored as the JSON `null` literal and decoded back to `undefined`
11
+ * on read. Every other value is `JSON.stringify`'d on write and `JSON.parse`'d
12
+ * on read, so the string "123" round-trips as the string "123" (not the number
13
+ * 123) and "{}" round-trips as the string "{}" (not an empty object).
14
+ */
15
+ function encode<T>(value: T): string {
16
+ if (value === undefined) return 'null';
17
+ return JSON.stringify(value);
18
+ }
19
+
20
+ function decode<T>(raw: string): T | undefined {
21
+ try {
22
+ const parsed = JSON.parse(raw) as T | null;
23
+ return parsed === null ? undefined : (parsed as T);
24
+ } catch {
25
+ // Tolerate values written outside the adapter that are not valid JSON.
26
+ return raw as unknown as T;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Connection settings: a `redis://` / `rediss://` URL, ioredis options, or
32
+ * ioredis options with a `url` (the form used in app.config and the docs).
33
+ */
34
+ export type RedisAdapterOptions = string | (RedisOptions & { url?: string });
35
+
36
+ export interface RedisAdapterHooks {
37
+ /**
38
+ * Receives the client's `error` events (a dropped connection, each failed
39
+ * reconnect). Without it they are ignored; ioredis printed them to the
40
+ * console, outside the app's logger.
41
+ */
42
+ onError?: (error: Error) => void;
43
+ /**
44
+ * Lets `clear()` with no key prefix at all run FLUSHDB, which empties the
45
+ * whole Redis database, other apps' keys included.
46
+ */
47
+ flushDb?: boolean;
48
+ }
49
+
50
+ /** `EX` for whole seconds; `PX` for fractional TTLs, which Redis `EX` rejects. */
51
+ function ttlArgs(ttl: number): ['EX', number] | ['PX', number] {
52
+ return Number.isInteger(ttl) ? ['EX', ttl] : ['PX', Math.max(1, Math.round(ttl * 1000))];
53
+ }
54
+
55
+ /** `prefix` as a literal in a SCAN MATCH pattern. */
56
+ const escapeGlob = (prefix: string): string => prefix.replace(/[*?[\]\\]/g, '\\$&');
57
+
58
+ /** Keys deleted per DEL while clearing. */
59
+ const CLEAR_BATCH = 1000;
60
+
61
+ /**
62
+ * An expiring set is a sorted set scored by each member's expiry (Redis time,
63
+ * in ms; FOREVER for none). Adding a member drops the expired ones and keeps
64
+ * the key alive until its last member expires, in one atomic step.
65
+ * KEYS[1]: the set; ARGV[1]: the member; ARGV[2]: its TTL in ms, '' for none.
66
+ */
67
+ const SADD_SCRIPT = `
68
+ if redis.replicate_commands then redis.replicate_commands() end
69
+ local forever = 9007199254740991
70
+ local t = redis.call('TIME')
71
+ local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
72
+ local expires = forever
73
+ if ARGV[2] ~= '' then expires = now + tonumber(ARGV[2]) end
74
+ local current = tonumber(redis.call('ZSCORE', KEYS[1], ARGV[1]) or '0')
75
+ if expires > current then redis.call('ZADD', KEYS[1], expires, ARGV[1]) end
76
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', '(' .. now)
77
+ local last = redis.call('ZRANGE', KEYS[1], -1, -1, 'WITHSCORES')
78
+ if tonumber(last[2]) >= forever then
79
+ redis.call('PERSIST', KEYS[1])
80
+ else
81
+ redis.call('PEXPIREAT', KEYS[1], last[2])
82
+ end
83
+ `;
84
+
85
+ /**
86
+ * Returns the unexpired members of an expiring set (see SADD_SCRIPT) and
87
+ * deletes it, in one atomic step: members past their expiry are left out, as
88
+ * the memory adapter does. KEYS[1]: the set.
89
+ */
90
+ const SDRAIN_SCRIPT = `
91
+ if redis.replicate_commands then redis.replicate_commands() end
92
+ local t = redis.call('TIME')
93
+ local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
94
+ local members = redis.call('ZRANGEBYSCORE', KEYS[1], '(' .. now, '+inf')
95
+ redis.call('DEL', KEYS[1])
96
+ return members
97
+ `;
98
+
99
+ /**
100
+ * ioredis errors carry the command they answer with its arguments: AUTH's
101
+ * password on a refused login, which the app then logged. Only the command
102
+ * name is kept.
103
+ */
104
+ function withoutCommandArgs<E>(error: E): E {
105
+ const command = (error as { command?: { name?: unknown; args?: unknown } } | null)?.command;
106
+ if (command && typeof command === 'object' && 'args' in command) {
107
+ (error as { command: unknown }).command = { name: command.name };
108
+ }
109
+ return error;
110
+ }
3
111
 
4
112
  export class RedisAdapter implements KVAdapter {
5
113
  id = 'redis';
6
114
  private client: Redis | null = null;
7
- private options: any;
115
+ private readonly options: RedisAdapterOptions;
116
+ private readonly hooks: RedisAdapterHooks;
8
117
 
9
- constructor(options: any) {
118
+ constructor(options: RedisAdapterOptions, hooks: RedisAdapterHooks = {}) {
10
119
  this.options = options;
120
+ this.hooks = hooks;
11
121
  }
12
122
 
13
- connect() {
14
- this.client = new Redis(this.options);
123
+ /**
124
+ * Connects and waits for Redis to answer: an unreachable server or a wrong
125
+ * password fails here (and so the app's start) instead of on the first
126
+ * command. Once connected, a dropped connection is retried by ioredis.
127
+ */
128
+ async connect() {
129
+ // ioredis only parses a URL passed as its own argument: an object with
130
+ // a `url` key was ignored and it silently connected to localhost:6379.
131
+ let client: Redis;
132
+ if (typeof this.options === 'string') {
133
+ client = new Redis(this.options, { lazyConnect: true });
134
+ } else if (this.options.url) {
135
+ const { url, ...rest } = this.options;
136
+ client = new Redis(url, { ...rest, lazyConnect: true });
137
+ } else {
138
+ client = new Redis({ ...this.options, lazyConnect: true });
139
+ }
140
+ client.on('error', (error: Error) => this.hooks.onError?.(withoutCommandArgs(error)));
141
+ try {
142
+ await client.connect();
143
+ } catch (error) {
144
+ client.disconnect();
145
+ // The message, not the URL: it may carry the password.
146
+ throw new Error(`Could not connect to Redis: ${error instanceof Error ? error.message : String(error)}`, {
147
+ cause: withoutCommandArgs(error),
148
+ });
149
+ }
150
+ this.client = client;
15
151
  }
16
152
 
17
- disconnect() {
18
- this.client?.disconnect();
19
- }
153
+ /** Longest wait for QUIT before the connection is closed anyway. */
154
+ protected quitTimeoutMs = 2000;
20
155
 
21
- async get(key: string) {
22
- const val = await this.client?.get(key);
23
- try {
24
- return val ? JSON.parse(val) : null;
25
- } catch {
26
- return val;
156
+ /**
157
+ * Closes the connection. When connected, QUIT lets pending replies arrive
158
+ * (in-flight writes are not dropped), for at most `quitTimeoutMs`. When the
159
+ * connection is down, ioredis would queue QUIT behind the offline queue and
160
+ * resolve only after its reconnect attempts run out (about 10 s by default,
161
+ * never with `maxRetriesPerRequest: null`), eating the app's shutdown
162
+ * timeout: the client is closed at once instead.
163
+ */
164
+ async disconnect() {
165
+ const client = this.client;
166
+ this.client = null;
167
+ if (!client) return;
168
+ if (typeof client.quit !== 'function' || client.status !== 'ready') {
169
+ client.disconnect();
170
+ return;
27
171
  }
172
+ let timer: ReturnType<typeof setTimeout> | undefined;
173
+ const timedOut = new Promise<'timeout'>((resolve) => {
174
+ timer = setTimeout(() => resolve('timeout'), this.quitTimeoutMs);
175
+ });
176
+ const outcome = await Promise.race([
177
+ client.quit().then(
178
+ () => 'quit' as const,
179
+ () => 'error' as const,
180
+ ),
181
+ timedOut,
182
+ ]);
183
+ clearTimeout(timer);
184
+ if (outcome !== 'quit') client.disconnect();
28
185
  }
29
186
 
30
- async set(key: string, value: any, ttl?: number) {
31
- const val = typeof value === 'object' ? JSON.stringify(value) : value;
32
- if (ttl) {
33
- await this.client?.set(key, val, 'EX', ttl);
187
+ /** The ioredis client once connected (null before connect() and after disconnect()). */
188
+ get nativeClient(): Redis | null {
189
+ return this.client;
190
+ }
191
+
192
+ /** The live client; operations before connect() fail instead of silently doing nothing. */
193
+ private get redis(): Redis {
194
+ if (!this.client) throw new Error('RedisAdapter is not connected; call connect() first');
195
+ return this.client;
196
+ }
197
+
198
+ async get<T = unknown>(key: string): Promise<T | undefined> {
199
+ const val = await this.redis.get(key);
200
+ if (val === null || val === undefined) return undefined;
201
+ return decode<T>(val);
202
+ }
203
+
204
+ async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
205
+ const val = encode(value);
206
+ const seconds = checkTtl(ttl);
207
+ if (seconds !== undefined) {
208
+ await this.redis.set(key, val, ...(ttlArgs(seconds) as ['EX', number]));
34
209
  } else {
35
- await this.client?.set(key, val);
210
+ await this.redis.set(key, val);
36
211
  }
37
212
  }
38
213
 
39
214
  async del(key: string) {
40
- await this.client?.del(key);
215
+ await this.redis.del(key);
41
216
  }
42
217
 
43
218
  async has(key: string) {
44
- const exists = await this.client?.exists(key);
219
+ const exists = await this.redis.exists(key);
45
220
  return exists === 1;
46
221
  }
222
+
223
+ // Native batch operations. A single MGET / pipelined MSET / variadic DEL
224
+ // replaces the manager's per-key fan-out (avoids the N+1 round-trips).
225
+
226
+ async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {
227
+ if (keys.length === 0) return [];
228
+ const raws = (await this.redis.mget(...keys)) ?? [];
229
+ return keys.map((_, i) => {
230
+ const raw = raws[i];
231
+ return raw === null || raw === undefined ? undefined : decode<T>(raw);
232
+ });
233
+ }
234
+
235
+ async mset<T = unknown>(entries: Array<[string, T]>, ttl?: number): Promise<void> {
236
+ const seconds = checkTtl(ttl);
237
+ if (entries.length === 0) return;
238
+ const pipeline = this.redis.pipeline();
239
+
240
+ for (const [key, value] of entries) {
241
+ const val = encode(value);
242
+ if (seconds !== undefined) {
243
+ pipeline.set(key, val, ...(ttlArgs(seconds) as ['EX', number]));
244
+ } else {
245
+ pipeline.set(key, val);
246
+ }
247
+ }
248
+
249
+ // exec() resolves even when individual commands fail; surface them.
250
+ const results = await pipeline.exec();
251
+ const failed = results?.find(([err]) => err);
252
+ if (failed) throw failed[0];
253
+ }
254
+
255
+ async mdel(keys: string[]): Promise<void> {
256
+ if (keys.length === 0) return;
257
+ await this.redis.del(...keys);
258
+ }
259
+
260
+ /**
261
+ * Deletes the keys under `prefix` (after ioredis' own `keyPrefix`, if set)
262
+ * with SCAN and DEL. With no prefix at all this would be every key in the
263
+ * database, which only `flushDb: true` allows (as a FLUSHDB).
264
+ */
265
+ async clear(prefix = ''): Promise<void> {
266
+ const redis = this.redis;
267
+ const keyPrefix = redis.options.keyPrefix ?? '';
268
+ if (!keyPrefix && !prefix) {
269
+ if (!this.hooks.flushDb) {
270
+ throw new Error(
271
+ 'RedisAdapter.clear() without a key prefix would empty the whole Redis database: ' +
272
+ 'give the KVManager a namespace, or pass flushDb: true if the database belongs to this app alone',
273
+ );
274
+ }
275
+ await redis.flushdb();
276
+ return;
277
+ }
278
+ const match = `${escapeGlob(keyPrefix + prefix)}*`;
279
+ let cursor = '0';
280
+ do {
281
+ const [next, keys] = await redis.scan(cursor, 'MATCH', match, 'COUNT', CLEAR_BATCH);
282
+ cursor = next;
283
+ // SCAN returns whole keys, and ioredis prepends keyPrefix to DEL's.
284
+ if (keys.length > 0) await redis.del(...keys.map((key) => key.slice(keyPrefix.length)));
285
+ } while (cursor !== '0');
286
+ }
287
+
288
+ async sadd(key: string, member: string, ttl?: number): Promise<void> {
289
+ const seconds = checkTtl(ttl);
290
+ const ms = seconds === undefined ? '' : String(Math.max(1, Math.ceil(seconds * 1000)));
291
+ await this.redis.eval(SADD_SCRIPT, 1, key, member, ms);
292
+ }
293
+
294
+ async sdrain(key: string): Promise<string[]> {
295
+ return (await this.redis.eval(SDRAIN_SCRIPT, 1, key)) as string[];
296
+ }
47
297
  }
package/src/manager.ts CHANGED
@@ -1,29 +1,73 @@
1
- import type { App, Driver } from '@iskra-bun/core';
1
+ import { isProductionEnv, type App, type Driver } from '@iskra-bun/core';
2
+ import type { Redis } from 'ioredis';
2
3
  import type { KVAdapter } from './types';
3
4
  import { MemoryAdapter } from './adapters/memory';
4
5
  import { RedisAdapter } from './adapters/redis';
5
6
 
7
+ export interface KVManagerOptions {
8
+ /**
9
+ * Optional namespace prepended to every key as `"<namespace>:<key>"`.
10
+ * Defaults to `""` (no prefix) to preserve existing behavior.
11
+ */
12
+ namespace?: string;
13
+ /**
14
+ * Lets `clear()` without a `namespace` empty the whole Redis database
15
+ * (FLUSHDB). Only for a database no other app or service writes to.
16
+ */
17
+ flushDb?: boolean;
18
+ }
19
+
6
20
  export class KVManager implements Driver, KVAdapter {
7
21
  name = 'KVManager';
8
22
  id = 'manager';
9
23
  private app: App | null = null;
10
24
  private adapter: KVAdapter;
25
+ private readonly prefix: string;
26
+ private readonly flushDb: boolean;
11
27
 
12
- constructor() {
28
+ constructor(options: KVManagerOptions = {}) {
29
+ // The store is chosen by the App's config (`kv.driver`), not here: an
30
+ // `adapter: 'redis'` copied from an old README was ignored, and the app
31
+ // silently ran on per-process memory.
32
+ const misplaced = ['adapter', 'driver', 'connection'].filter((key) => key in options);
33
+ if (misplaced.length > 0) {
34
+ throw new Error(
35
+ `KVManager does not take ${misplaced.map((k) => `"${k}"`).join(', ')}: choose the store in the App config, ` +
36
+ "e.g. new App({ name, kv: { driver: 'redis', connection: process.env.REDIS_URL } })",
37
+ );
38
+ }
13
39
  // Default to memory until configured
14
40
  this.adapter = new MemoryAdapter();
41
+ this.prefix = options.namespace ? `${options.namespace}:` : '';
42
+ this.flushDb = options.flushDb ?? false;
15
43
  }
16
44
 
17
45
  init(app: App) {
18
46
  this.app = app;
47
+ // Like db-kit's 'db': services look the store up as app.context.get('kv').
48
+ app.context.set('kv', this);
19
49
  const config = app.config.kv;
20
50
 
21
51
  if (config?.driver === 'redis') {
22
52
  app.logger.info('Initializing KV with Redis');
23
- this.adapter = new RedisAdapter(config.connection);
24
- } else {
53
+ this.adapter = new RedisAdapter(config.connection ?? {}, {
54
+ onError: (err) => app.logger.warn({ err }, 'KV Redis connection error'),
55
+ flushDb: this.flushDb,
56
+ });
57
+ } else if (!config?.driver || config.driver === 'memory') {
58
+ if (!config?.driver && isProductionEnv()) {
59
+ app.logger.warn(
60
+ 'KV has no driver configured: using the in-memory store, which each process keeps on its own ' +
61
+ "and loses on restart. Set kv: { driver: 'redis', connection } in the App config, or " +
62
+ "kv: { driver: 'memory' } to keep it.",
63
+ );
64
+ }
25
65
  app.logger.info('Initializing KV with Memory');
26
66
  this.adapter = new MemoryAdapter();
67
+ } else {
68
+ // Unknown drivers (e.g. 'libsql') used to fall back to memory
69
+ // silently, losing every value on restart.
70
+ throw new Error(`Unsupported KV driver "${config.driver}" (supported: "memory", "redis")`);
27
71
  }
28
72
  }
29
73
 
@@ -45,9 +89,109 @@ export class KVManager implements Driver, KVAdapter {
45
89
  await this.disconnect();
46
90
  }
47
91
 
48
- // Proxy methods
49
- get(key: string) { return this.adapter.get(key); }
50
- set(key: string, value: any, ttl?: number) { return this.adapter.set(key, value, ttl); }
51
- del(key: string) { return this.adapter.del(key); }
52
- has(key: string) { return this.adapter.has(key); }
92
+ /**
93
+ * The underlying ioredis client with the "redis" driver, once started: for
94
+ * commands the KV API does not cover (sets, sorted sets, pipelines). It
95
+ * bypasses `namespace` and the JSON codec. `undefined` with the memory
96
+ * driver or before start().
97
+ */
98
+ get client(): Redis | undefined {
99
+ return this.adapter instanceof RedisAdapter ? (this.adapter.nativeClient ?? undefined) : undefined;
100
+ }
101
+
102
+ private prefixed(key: string): string {
103
+ return `${this.prefix}${key}`;
104
+ }
105
+
106
+ // Proxy methods (namespace-aware)
107
+ get<T = unknown>(key: string): Promise<T | undefined> {
108
+ return this.adapter.get<T>(this.prefixed(key));
109
+ }
110
+
111
+ set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
112
+ return this.adapter.set<T>(this.prefixed(key), value, ttl);
113
+ }
114
+
115
+ del(key: string): Promise<void> {
116
+ return this.adapter.del(this.prefixed(key));
117
+ }
118
+
119
+ has(key: string): Promise<boolean> {
120
+ return this.adapter.has(this.prefixed(key));
121
+ }
122
+
123
+ // Batch operations. When the underlying adapter exposes a native batch
124
+ // method, the manager delegates to it (a single round-trip) after applying
125
+ // the namespace prefix; otherwise it falls back to a per-key loop. This
126
+ // keeps the optional adapter methods out of the hot path for adapters that
127
+ // do not implement them while avoiding the N+1 fan-out for those that do.
128
+
129
+ async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {
130
+ const prefixed = keys.map((k) => this.prefixed(k));
131
+
132
+ if (this.adapter.mget) {
133
+ return this.adapter.mget<T>(prefixed);
134
+ }
135
+
136
+ return Promise.all(prefixed.map((k) => this.adapter.get<T>(k)));
137
+ }
138
+
139
+ async mset<T = unknown>(entries: Array<[string, T]> | Record<string, T>, ttl?: number): Promise<void> {
140
+ const pairs: Array<[string, T]> = Array.isArray(entries)
141
+ ? entries
142
+ : (Object.entries(entries) as Array<[string, T]>);
143
+
144
+ const prefixed: Array<[string, T]> = pairs.map(([k, v]) => [this.prefixed(k), v] as [string, T]);
145
+
146
+ if (this.adapter.mset) {
147
+ await this.adapter.mset<T>(prefixed, ttl);
148
+ return;
149
+ }
150
+
151
+ await Promise.all(prefixed.map(([k, v]) => this.adapter.set<T>(k, v, ttl)));
152
+ }
153
+
154
+ async mdel(keys: string[]): Promise<void> {
155
+ const prefixed = keys.map((k) => this.prefixed(k));
156
+
157
+ if (this.adapter.mdel) {
158
+ await this.adapter.mdel(prefixed);
159
+ return;
160
+ }
161
+
162
+ await Promise.all(prefixed.map((k) => this.adapter.del(k)));
163
+ }
164
+
165
+ /**
166
+ * Deletes every key under `prefix` in this manager's namespace (the whole
167
+ * namespace without one). With the redis driver and no namespace, only
168
+ * `flushDb: true` lets it empty the database.
169
+ */
170
+ async clear(prefix = ''): Promise<void> {
171
+ if (!this.adapter.clear) throw unsupported(this.adapter, 'clear');
172
+ await this.adapter.clear(this.prefixed(prefix));
173
+ }
174
+
175
+ /** See {@link KVAdapter.sadd}: the member is kept as is, only `key` is namespaced. */
176
+ async sadd(key: string, member: string, ttl?: number): Promise<void> {
177
+ if (!this.adapter.sadd) throw unsupported(this.adapter, 'sadd');
178
+ await this.adapter.sadd(this.prefixed(key), member, ttl);
179
+ }
180
+
181
+ async sdrain(key: string): Promise<string[]> {
182
+ if (!this.adapter.sdrain) throw unsupported(this.adapter, 'sdrain');
183
+ return this.adapter.sdrain(this.prefixed(key));
184
+ }
185
+ }
186
+
187
+ /** Both built-in adapters have the optional methods; an injected one may not. */
188
+ function unsupported(adapter: KVAdapter, method: string): Error {
189
+ return new Error(`The "${adapter.id}" KV adapter does not support ${method}()`);
190
+ }
191
+
192
+ // `app.context.get('kv')` is the KVManager registered on the app.
193
+ declare module '@iskra-bun/core' {
194
+ interface AppContextRegistry {
195
+ kv: KVManager;
196
+ }
53
197
  }
package/src/ttl.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A TTL in seconds: `undefined`, `null` or `0` mean "no expiry". Anything else
3
+ * must be a positive finite number: a negative TTL used to delete the key at
4
+ * once with the memory adapter and fail with Redis.
5
+ */
6
+ export function checkTtl(ttl: number | null | undefined): number | undefined {
7
+ if (ttl === undefined || ttl === null || ttl === 0) return undefined;
8
+ if (typeof ttl !== 'number' || !Number.isFinite(ttl) || ttl < 0) {
9
+ throw new RangeError(`Invalid TTL ${String(ttl)}: expected a positive number of seconds`);
10
+ }
11
+ return ttl;
12
+ }