@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.
- package/CHANGELOG.md +75 -0
- package/README.md +9 -4
- package/dist/index.d.ts +62 -5
- package/dist/index.js +368 -30
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/adapters/memory.ts +111 -10
- package/src/adapters/redis.ts +270 -20
- package/src/manager.ts +153 -9
- package/src/ttl.ts +12 -0
- package/src/types.ts +30 -2
package/src/adapters/memory.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
this.
|
|
20
|
-
if (
|
|
21
|
-
|
|
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
|
|
30
|
-
|
|
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
|
}
|
package/src/adapters/redis.ts
CHANGED
|
@@ -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:
|
|
115
|
+
private readonly options: RedisAdapterOptions;
|
|
116
|
+
private readonly hooks: RedisAdapterHooks;
|
|
8
117
|
|
|
9
|
-
constructor(options:
|
|
118
|
+
constructor(options: RedisAdapterOptions, hooks: RedisAdapterHooks = {}) {
|
|
10
119
|
this.options = options;
|
|
120
|
+
this.hooks = hooks;
|
|
11
121
|
}
|
|
12
122
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
153
|
+
/** Longest wait for QUIT before the connection is closed anyway. */
|
|
154
|
+
protected quitTimeoutMs = 2000;
|
|
20
155
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
210
|
+
await this.redis.set(key, val);
|
|
36
211
|
}
|
|
37
212
|
}
|
|
38
213
|
|
|
39
214
|
async del(key: string) {
|
|
40
|
-
await this.
|
|
215
|
+
await this.redis.del(key);
|
|
41
216
|
}
|
|
42
217
|
|
|
43
218
|
async has(key: string) {
|
|
44
|
-
const exists = await this.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|