@iskra-bun/kv-kit 0.2.0 → 0.3.1

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/src/manager.ts CHANGED
@@ -1,4 +1,5 @@
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';
@@ -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
- } 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
+ }
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
  }