@iskra-bun/kv-kit 0.2.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 +56 -0
- package/README.md +9 -4
- package/dist/index.d.ts +42 -0
- package/dist/index.js +284 -39
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/adapters/memory.ts +86 -18
- package/src/adapters/redis.ts +214 -23
- package/src/manager.ts +86 -14
- package/src/ttl.ts +12 -0
- package/src/types.ts +16 -0
package/src/manager.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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';
|
|
@@ -9,6 +10,11 @@ export interface KVManagerOptions {
|
|
|
9
10
|
* Defaults to `""` (no prefix) to preserve existing behavior.
|
|
10
11
|
*/
|
|
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;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
export class KVManager implements Driver, KVAdapter {
|
|
@@ -17,23 +23,51 @@ export class KVManager implements Driver, KVAdapter {
|
|
|
17
23
|
private app: App | null = null;
|
|
18
24
|
private adapter: KVAdapter;
|
|
19
25
|
private readonly prefix: string;
|
|
26
|
+
private readonly flushDb: boolean;
|
|
20
27
|
|
|
21
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
|
+
}
|
|
22
39
|
// Default to memory until configured
|
|
23
40
|
this.adapter = new MemoryAdapter();
|
|
24
41
|
this.prefix = options.namespace ? `${options.namespace}:` : '';
|
|
42
|
+
this.flushDb = options.flushDb ?? false;
|
|
25
43
|
}
|
|
26
44
|
|
|
27
45
|
init(app: App) {
|
|
28
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);
|
|
29
49
|
const config = app.config.kv;
|
|
30
50
|
|
|
31
51
|
if (config?.driver === 'redis') {
|
|
32
52
|
app.logger.info('Initializing KV with Redis');
|
|
33
|
-
this.adapter = new RedisAdapter(config.connection
|
|
34
|
-
|
|
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
|
+
}
|
|
35
65
|
app.logger.info('Initializing KV with Memory');
|
|
36
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")`);
|
|
37
71
|
}
|
|
38
72
|
}
|
|
39
73
|
|
|
@@ -55,6 +89,16 @@ export class KVManager implements Driver, KVAdapter {
|
|
|
55
89
|
await this.disconnect();
|
|
56
90
|
}
|
|
57
91
|
|
|
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
|
+
|
|
58
102
|
private prefixed(key: string): string {
|
|
59
103
|
return `${this.prefix}${key}`;
|
|
60
104
|
}
|
|
@@ -83,26 +127,21 @@ export class KVManager implements Driver, KVAdapter {
|
|
|
83
127
|
// do not implement them while avoiding the N+1 fan-out for those that do.
|
|
84
128
|
|
|
85
129
|
async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {
|
|
86
|
-
const prefixed = keys.map(k => this.prefixed(k));
|
|
130
|
+
const prefixed = keys.map((k) => this.prefixed(k));
|
|
87
131
|
|
|
88
132
|
if (this.adapter.mget) {
|
|
89
133
|
return this.adapter.mget<T>(prefixed);
|
|
90
134
|
}
|
|
91
135
|
|
|
92
|
-
return Promise.all(prefixed.map(k => this.adapter.get<T>(k)));
|
|
136
|
+
return Promise.all(prefixed.map((k) => this.adapter.get<T>(k)));
|
|
93
137
|
}
|
|
94
138
|
|
|
95
|
-
async mset<T = unknown>(
|
|
96
|
-
entries: Array<[string, T]> | Record<string, T>,
|
|
97
|
-
ttl?: number
|
|
98
|
-
): Promise<void> {
|
|
139
|
+
async mset<T = unknown>(entries: Array<[string, T]> | Record<string, T>, ttl?: number): Promise<void> {
|
|
99
140
|
const pairs: Array<[string, T]> = Array.isArray(entries)
|
|
100
141
|
? entries
|
|
101
142
|
: (Object.entries(entries) as Array<[string, T]>);
|
|
102
143
|
|
|
103
|
-
const prefixed: Array<[string, T]> = pairs.map(
|
|
104
|
-
([k, v]) => [this.prefixed(k), v] as [string, T]
|
|
105
|
-
);
|
|
144
|
+
const prefixed: Array<[string, T]> = pairs.map(([k, v]) => [this.prefixed(k), v] as [string, T]);
|
|
106
145
|
|
|
107
146
|
if (this.adapter.mset) {
|
|
108
147
|
await this.adapter.mset<T>(prefixed, ttl);
|
|
@@ -113,13 +152,46 @@ export class KVManager implements Driver, KVAdapter {
|
|
|
113
152
|
}
|
|
114
153
|
|
|
115
154
|
async mdel(keys: string[]): Promise<void> {
|
|
116
|
-
const prefixed = keys.map(k => this.prefixed(k));
|
|
155
|
+
const prefixed = keys.map((k) => this.prefixed(k));
|
|
117
156
|
|
|
118
157
|
if (this.adapter.mdel) {
|
|
119
158
|
await this.adapter.mdel(prefixed);
|
|
120
159
|
return;
|
|
121
160
|
}
|
|
122
161
|
|
|
123
|
-
await Promise.all(prefixed.map(k => this.adapter.del(k)));
|
|
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;
|
|
124
196
|
}
|
|
125
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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -19,4 +19,20 @@ export interface KVAdapter {
|
|
|
19
19
|
mget?<T = unknown>(keys: string[]): Promise<(T | undefined)[]>;
|
|
20
20
|
mset?<T = unknown>(entries: Array<[string, T]>, ttl?: number): Promise<void>;
|
|
21
21
|
mdel?(keys: string[]): Promise<void>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Deletes every key that starts with `prefix` (every key the adapter holds
|
|
25
|
+
* without one), without touching the connection. An adapter that cannot
|
|
26
|
+
* leaves it out, and callers such as cache-kit's `clear()` then fail.
|
|
27
|
+
*/
|
|
28
|
+
clear?(prefix?: string): Promise<void>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Expiring sets, for cache-kit's tag index: `sadd` adds `member` to the set
|
|
32
|
+
* at `key` for `ttl` seconds (for good without one), and the set lives as
|
|
33
|
+
* long as its longest-lived member. `sdrain` deletes the set and returns
|
|
34
|
+
* its unexpired members in one step, so a member added meanwhile is never lost.
|
|
35
|
+
*/
|
|
36
|
+
sadd?(key: string, member: string, ttl?: number): Promise<void>;
|
|
37
|
+
sdrain?(key: string): Promise<string[]>;
|
|
22
38
|
}
|