@iskra-bun/kv-kit 0.1.0 → 0.2.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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # @iskra-bun/kv-kit
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f9654df: New KV features and a leak fix:
8
+
9
+ - **Fix:** the in-memory adapter no longer leaks TTL timers — expiry timers are tracked and cleared on overwrite/delete/disconnect, so overwriting a key can't have a stale timer delete the fresh value.
10
+ - `KVManager` accepts a `namespace` option that transparently prefixes every key, preventing cross-module collisions.
11
+ - Batch operations `mget`/`mset`/`mdel` on `KVManager`.
12
+
13
+ ### Patch Changes
14
+
15
+ - f9654df: `KVAdapter.get`/`set` are now generic (`get<T>()` returns `Promise<T | undefined>`, `set<T>(key, value: T)`) — values are typed instead of `any`. Reads of a missing key now resolve to `undefined` (previously `null` on the Redis adapter). Consumers relying on `any` may need an explicit type argument when reading.
16
+ - Fix value corruption in the Redis adapter's `set`/`get` codec. Values are now encoded with a single `JSON.stringify` and decoded with `JSON.parse`, so round-tripping is lossless: strings like `'123'` and `'{}'` stay strings, `undefined` is preserved instead of being stored as the literal text `"undefined"`, and `null` decodes back to `undefined`.
17
+ - Updated dependencies [f9654df]
18
+ - Updated dependencies
19
+ - Updated dependencies [f9654df]
20
+ - @iskra-bun/core@0.1.1
21
+
3
22
  ## 0.1.0
4
23
 
5
24
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -4,27 +4,42 @@ interface KVAdapter {
4
4
  id: string;
5
5
  connect(): Promise<void> | void;
6
6
  disconnect(): Promise<void> | void;
7
- get(key: string): Promise<any>;
8
- set(key: string, value: any, ttl?: number): Promise<void>;
7
+ get<T = unknown>(key: string): Promise<T | undefined>;
8
+ set<T = unknown>(key: string, value: T, ttl?: number): Promise<void>;
9
9
  del(key: string): Promise<void>;
10
10
  has(key: string): Promise<boolean>;
11
+ mget?<T = unknown>(keys: string[]): Promise<(T | undefined)[]>;
12
+ mset?<T = unknown>(entries: Array<[string, T]>, ttl?: number): Promise<void>;
13
+ mdel?(keys: string[]): Promise<void>;
11
14
  }
12
15
 
16
+ interface KVManagerOptions {
17
+ /**
18
+ * Optional namespace prepended to every key as `"<namespace>:<key>"`.
19
+ * Defaults to `""` (no prefix) to preserve existing behavior.
20
+ */
21
+ namespace?: string;
22
+ }
13
23
  declare class KVManager implements Driver, KVAdapter {
14
24
  name: string;
15
25
  id: string;
16
26
  private app;
17
27
  private adapter;
18
- constructor();
28
+ private readonly prefix;
29
+ constructor(options?: KVManagerOptions);
19
30
  init(app: App): void;
20
31
  connect(): Promise<void>;
21
32
  disconnect(): Promise<void>;
22
33
  start(): Promise<void>;
23
34
  stop(): Promise<void>;
24
- get(key: string): Promise<any>;
25
- set(key: string, value: any, ttl?: number): Promise<void>;
35
+ private prefixed;
36
+ get<T = unknown>(key: string): Promise<T | undefined>;
37
+ set<T = unknown>(key: string, value: T, ttl?: number): Promise<void>;
26
38
  del(key: string): Promise<void>;
27
39
  has(key: string): Promise<boolean>;
40
+ mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]>;
41
+ mset<T = unknown>(entries: Array<[string, T]> | Record<string, T>, ttl?: number): Promise<void>;
42
+ mdel(keys: string[]): Promise<void>;
28
43
  }
29
44
 
30
45
  export { type KVAdapter, KVManager };
package/dist/index.js CHANGED
@@ -2,21 +2,41 @@
2
2
  var MemoryAdapter = class {
3
3
  id = "memory";
4
4
  store = /* @__PURE__ */ new Map();
5
+ timers = /* @__PURE__ */ new Map();
5
6
  connect() {
6
7
  }
7
8
  disconnect() {
9
+ for (const timer of this.timers.values()) {
10
+ clearTimeout(timer);
11
+ }
12
+ this.timers.clear();
8
13
  this.store.clear();
9
14
  }
10
15
  async get(key) {
11
16
  return this.store.get(key);
12
17
  }
13
18
  async set(key, value, ttl) {
19
+ const existing = this.timers.get(key);
20
+ if (existing !== void 0) {
21
+ clearTimeout(existing);
22
+ this.timers.delete(key);
23
+ }
14
24
  this.store.set(key, value);
15
25
  if (ttl) {
16
- setTimeout(() => this.store.delete(key), ttl * 1e3);
26
+ const timer = setTimeout(() => {
27
+ this.store.delete(key);
28
+ this.timers.delete(key);
29
+ }, ttl * 1e3);
30
+ timer.unref?.();
31
+ this.timers.set(key, timer);
17
32
  }
18
33
  }
19
34
  async del(key) {
35
+ const timer = this.timers.get(key);
36
+ if (timer !== void 0) {
37
+ clearTimeout(timer);
38
+ this.timers.delete(key);
39
+ }
20
40
  this.store.delete(key);
21
41
  }
22
42
  async has(key) {
@@ -26,6 +46,18 @@ var MemoryAdapter = class {
26
46
 
27
47
  // src/adapters/redis.ts
28
48
  import Redis from "ioredis";
49
+ function encode(value) {
50
+ if (value === void 0) return "null";
51
+ return JSON.stringify(value);
52
+ }
53
+ function decode(raw) {
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ return parsed === null ? void 0 : parsed;
57
+ } catch {
58
+ return raw;
59
+ }
60
+ }
29
61
  var RedisAdapter = class {
30
62
  id = "redis";
31
63
  client = null;
@@ -41,14 +73,11 @@ var RedisAdapter = class {
41
73
  }
42
74
  async get(key) {
43
75
  const val = await this.client?.get(key);
44
- try {
45
- return val ? JSON.parse(val) : null;
46
- } catch {
47
- return val;
48
- }
76
+ if (val === null || val === void 0) return void 0;
77
+ return decode(val);
49
78
  }
50
79
  async set(key, value, ttl) {
51
- const val = typeof value === "object" ? JSON.stringify(value) : value;
80
+ const val = encode(value);
52
81
  if (ttl) {
53
82
  await this.client?.set(key, val, "EX", ttl);
54
83
  } else {
@@ -62,6 +91,34 @@ var RedisAdapter = class {
62
91
  const exists = await this.client?.exists(key);
63
92
  return exists === 1;
64
93
  }
94
+ // Native batch operations. A single MGET / pipelined MSET / variadic DEL
95
+ // replaces the manager's per-key fan-out (avoids the N+1 round-trips).
96
+ async mget(keys) {
97
+ if (keys.length === 0) return [];
98
+ const raws = await this.client?.mget(...keys) ?? [];
99
+ return keys.map((_, i) => {
100
+ const raw = raws[i];
101
+ return raw === null || raw === void 0 ? void 0 : decode(raw);
102
+ });
103
+ }
104
+ async mset(entries, ttl) {
105
+ if (entries.length === 0) return;
106
+ const pipeline = this.client?.pipeline();
107
+ if (!pipeline) return;
108
+ for (const [key, value] of entries) {
109
+ const val = encode(value);
110
+ if (ttl) {
111
+ pipeline.set(key, val, "EX", ttl);
112
+ } else {
113
+ pipeline.set(key, val);
114
+ }
115
+ }
116
+ await pipeline.exec();
117
+ }
118
+ async mdel(keys) {
119
+ if (keys.length === 0) return;
120
+ await this.client?.del(...keys);
121
+ }
65
122
  };
66
123
 
67
124
  // src/manager.ts
@@ -70,8 +127,10 @@ var KVManager = class {
70
127
  id = "manager";
71
128
  app = null;
72
129
  adapter;
73
- constructor() {
130
+ prefix;
131
+ constructor(options = {}) {
74
132
  this.adapter = new MemoryAdapter();
133
+ this.prefix = options.namespace ? `${options.namespace}:` : "";
75
134
  }
76
135
  init(app) {
77
136
  this.app = app;
@@ -98,18 +157,52 @@ var KVManager = class {
98
157
  async stop() {
99
158
  await this.disconnect();
100
159
  }
101
- // Proxy methods
160
+ prefixed(key) {
161
+ return `${this.prefix}${key}`;
162
+ }
163
+ // Proxy methods (namespace-aware)
102
164
  get(key) {
103
- return this.adapter.get(key);
165
+ return this.adapter.get(this.prefixed(key));
104
166
  }
105
167
  set(key, value, ttl) {
106
- return this.adapter.set(key, value, ttl);
168
+ return this.adapter.set(this.prefixed(key), value, ttl);
107
169
  }
108
170
  del(key) {
109
- return this.adapter.del(key);
171
+ return this.adapter.del(this.prefixed(key));
110
172
  }
111
173
  has(key) {
112
- return this.adapter.has(key);
174
+ return this.adapter.has(this.prefixed(key));
175
+ }
176
+ // Batch operations. When the underlying adapter exposes a native batch
177
+ // method, the manager delegates to it (a single round-trip) after applying
178
+ // the namespace prefix; otherwise it falls back to a per-key loop. This
179
+ // keeps the optional adapter methods out of the hot path for adapters that
180
+ // do not implement them while avoiding the N+1 fan-out for those that do.
181
+ async mget(keys) {
182
+ const prefixed = keys.map((k) => this.prefixed(k));
183
+ if (this.adapter.mget) {
184
+ return this.adapter.mget(prefixed);
185
+ }
186
+ return Promise.all(prefixed.map((k) => this.adapter.get(k)));
187
+ }
188
+ async mset(entries, ttl) {
189
+ const pairs = Array.isArray(entries) ? entries : Object.entries(entries);
190
+ const prefixed = pairs.map(
191
+ ([k, v]) => [this.prefixed(k), v]
192
+ );
193
+ if (this.adapter.mset) {
194
+ await this.adapter.mset(prefixed, ttl);
195
+ return;
196
+ }
197
+ await Promise.all(prefixed.map(([k, v]) => this.adapter.set(k, v, ttl)));
198
+ }
199
+ async mdel(keys) {
200
+ const prefixed = keys.map((k) => this.prefixed(k));
201
+ if (this.adapter.mdel) {
202
+ await this.adapter.mdel(prefixed);
203
+ return;
204
+ }
205
+ await Promise.all(prefixed.map((k) => this.adapter.del(k)));
113
206
  }
114
207
  };
115
208
  export {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/adapters/memory.ts","../src/adapters/redis.ts","../src/manager.ts"],"sourcesContent":["import type { KVAdapter } from '../types';\n\nexport class MemoryAdapter implements KVAdapter {\n id = 'memory';\n private store = new Map<string, any>();\n\n connect() {\n // No-op\n }\n disconnect() {\n this.store.clear();\n }\n\n async get(key: string) {\n return this.store.get(key);\n }\n\n async set(key: string, value: any, ttl?: number) {\n this.store.set(key, value);\n if (ttl) {\n setTimeout(() => this.store.delete(key), ttl * 1000);\n }\n }\n\n async del(key: string) {\n this.store.delete(key);\n }\n\n async has(key: string) {\n return this.store.has(key);\n }\n}\n","import type { KVAdapter } from '../types';\nimport Redis from 'ioredis';\n\nexport class RedisAdapter implements KVAdapter {\n id = 'redis';\n private client: Redis | null = null;\n private options: any;\n\n constructor(options: any) {\n this.options = options;\n }\n\n connect() {\n this.client = new Redis(this.options);\n }\n\n disconnect() {\n this.client?.disconnect();\n }\n\n async get(key: string) {\n const val = await this.client?.get(key);\n try {\n return val ? JSON.parse(val) : null;\n } catch {\n return val;\n }\n }\n\n async set(key: string, value: any, ttl?: number) {\n const val = typeof value === 'object' ? JSON.stringify(value) : value;\n if (ttl) {\n await this.client?.set(key, val, 'EX', ttl);\n } else {\n await this.client?.set(key, val);\n }\n }\n\n async del(key: string) {\n await this.client?.del(key);\n }\n\n async has(key: string) {\n const exists = await this.client?.exists(key);\n return exists === 1;\n }\n}\n","import type { App, Driver } from '@iskra-bun/core';\nimport type { KVAdapter } from './types';\nimport { MemoryAdapter } from './adapters/memory';\nimport { RedisAdapter } from './adapters/redis';\n\nexport class KVManager implements Driver, KVAdapter {\n name = 'KVManager';\n id = 'manager';\n private app: App | null = null;\n private adapter: KVAdapter;\n\n constructor() {\n // Default to memory until configured\n this.adapter = new MemoryAdapter();\n }\n\n init(app: App) {\n this.app = app;\n const config = app.config.kv;\n\n if (config?.driver === 'redis') {\n app.logger.info('Initializing KV with Redis');\n this.adapter = new RedisAdapter(config.connection);\n } else {\n app.logger.info('Initializing KV with Memory');\n this.adapter = new MemoryAdapter();\n }\n }\n\n async connect() {\n await this.adapter.connect();\n this.app?.logger.info('KV Store connected');\n }\n\n async disconnect() {\n await this.adapter.disconnect();\n }\n\n // Driver Interface\n async start() {\n await this.connect();\n }\n\n async stop() {\n await this.disconnect();\n }\n\n // Proxy methods\n get(key: string) { return this.adapter.get(key); }\n set(key: string, value: any, ttl?: number) { return this.adapter.set(key, value, ttl); }\n del(key: string) { return this.adapter.del(key); }\n has(key: string) { return this.adapter.has(key); }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/adapters/memory.ts","../src/adapters/redis.ts","../src/manager.ts"],"sourcesContent":["import type { KVAdapter } from '../types';\n\nexport class MemoryAdapter implements KVAdapter {\n id = 'memory';\n private store = new Map<string, unknown>();\n private timers = new Map<string, ReturnType<typeof setTimeout>>();\n\n connect() {\n // No-op\n }\n\n disconnect() {\n for (const timer of this.timers.values()) {\n clearTimeout(timer);\n }\n this.timers.clear();\n this.store.clear();\n }\n\n async get<T = unknown>(key: string): Promise<T | undefined> {\n return this.store.get(key) as T | undefined;\n }\n\n async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {\n // Note: storing `null`/`undefined` is undefined behavior across adapters.\n // This in-memory adapter stores the value verbatim (so `get` returns it\n // as-is), whereas the RedisAdapter normalizes both to \"absent\" because\n // they have no faithful JSON round-trip. Callers should not depend on\n // either form being preserved.\n //\n // Clear any existing expiry timer for this key before setting a new one\n const existing = this.timers.get(key);\n if (existing !== undefined) {\n clearTimeout(existing);\n this.timers.delete(key);\n }\n\n this.store.set(key, value);\n\n if (ttl) {\n const timer = setTimeout(() => {\n this.store.delete(key);\n this.timers.delete(key);\n }, ttl * 1000);\n\n // Avoid keeping the process alive just for expiry timers\n timer.unref?.();\n\n this.timers.set(key, timer);\n }\n }\n\n async del(key: string): Promise<void> {\n const timer = this.timers.get(key);\n if (timer !== undefined) {\n clearTimeout(timer);\n this.timers.delete(key);\n }\n this.store.delete(key);\n }\n\n async has(key: string): Promise<boolean> {\n return this.store.has(key);\n }\n}\n","import type { KVAdapter } from '../types';\nimport Redis from 'ioredis';\nimport type { RedisOptions } from 'ioredis';\n\n/**\n * Single consistent codec shared by every write/read path.\n *\n * `undefined` is guarded explicitly: there is no JSON representation for it,\n * so it is stored as the JSON `null` literal and decoded back to `undefined`\n * on read. Every other value is `JSON.stringify`'d on write and `JSON.parse`'d\n * on read, so the string \"123\" round-trips as the string \"123\" (not the number\n * 123) and \"{}\" round-trips as the string \"{}\" (not an empty object).\n */\nfunction encode<T>(value: T): string {\n if (value === undefined) return 'null';\n return JSON.stringify(value);\n}\n\nfunction decode<T>(raw: string): T | undefined {\n try {\n const parsed = JSON.parse(raw) as T | null;\n return parsed === null ? undefined : (parsed as T);\n } catch {\n // Tolerate values written outside the adapter that are not valid JSON.\n return raw as unknown as T;\n }\n}\n\nexport class RedisAdapter implements KVAdapter {\n id = 'redis';\n private client: Redis | null = null;\n private readonly options: RedisOptions | string;\n\n constructor(options: RedisOptions | string) {\n this.options = options;\n }\n\n connect() {\n this.client = new Redis(this.options as RedisOptions);\n }\n\n disconnect() {\n this.client?.disconnect();\n }\n\n async get<T = unknown>(key: string): Promise<T | undefined> {\n const val = await this.client?.get(key);\n if (val === null || val === undefined) return undefined;\n return decode<T>(val);\n }\n\n async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {\n const val = encode(value);\n if (ttl) {\n await this.client?.set(key, val, 'EX', ttl);\n } else {\n await this.client?.set(key, val);\n }\n }\n\n async del(key: string) {\n await this.client?.del(key);\n }\n\n async has(key: string) {\n const exists = await this.client?.exists(key);\n return exists === 1;\n }\n\n // Native batch operations. A single MGET / pipelined MSET / variadic DEL\n // replaces the manager's per-key fan-out (avoids the N+1 round-trips).\n\n async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {\n if (keys.length === 0) return [];\n const raws = (await this.client?.mget(...keys)) ?? [];\n return keys.map((_, i) => {\n const raw = raws[i];\n return raw === null || raw === undefined ? undefined : decode<T>(raw);\n });\n }\n\n async mset<T = unknown>(\n entries: Array<[string, T]>,\n ttl?: number\n ): Promise<void> {\n if (entries.length === 0) return;\n const pipeline = this.client?.pipeline();\n if (!pipeline) return;\n\n for (const [key, value] of entries) {\n const val = encode(value);\n if (ttl) {\n pipeline.set(key, val, 'EX', ttl);\n } else {\n pipeline.set(key, val);\n }\n }\n\n await pipeline.exec();\n }\n\n async mdel(keys: string[]): Promise<void> {\n if (keys.length === 0) return;\n await this.client?.del(...keys);\n }\n}\n","import type { App, Driver } from '@iskra-bun/core';\nimport type { KVAdapter } from './types';\nimport { MemoryAdapter } from './adapters/memory';\nimport { RedisAdapter } from './adapters/redis';\n\nexport interface KVManagerOptions {\n /**\n * Optional namespace prepended to every key as `\"<namespace>:<key>\"`.\n * Defaults to `\"\"` (no prefix) to preserve existing behavior.\n */\n namespace?: string;\n}\n\nexport class KVManager implements Driver, KVAdapter {\n name = 'KVManager';\n id = 'manager';\n private app: App | null = null;\n private adapter: KVAdapter;\n private readonly prefix: string;\n\n constructor(options: KVManagerOptions = {}) {\n // Default to memory until configured\n this.adapter = new MemoryAdapter();\n this.prefix = options.namespace ? `${options.namespace}:` : '';\n }\n\n init(app: App) {\n this.app = app;\n const config = app.config.kv;\n\n if (config?.driver === 'redis') {\n app.logger.info('Initializing KV with Redis');\n this.adapter = new RedisAdapter(config.connection);\n } else {\n app.logger.info('Initializing KV with Memory');\n this.adapter = new MemoryAdapter();\n }\n }\n\n async connect() {\n await this.adapter.connect();\n this.app?.logger.info('KV Store connected');\n }\n\n async disconnect() {\n await this.adapter.disconnect();\n }\n\n // Driver Interface\n async start() {\n await this.connect();\n }\n\n async stop() {\n await this.disconnect();\n }\n\n private prefixed(key: string): string {\n return `${this.prefix}${key}`;\n }\n\n // Proxy methods (namespace-aware)\n get<T = unknown>(key: string): Promise<T | undefined> {\n return this.adapter.get<T>(this.prefixed(key));\n }\n\n set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {\n return this.adapter.set<T>(this.prefixed(key), value, ttl);\n }\n\n del(key: string): Promise<void> {\n return this.adapter.del(this.prefixed(key));\n }\n\n has(key: string): Promise<boolean> {\n return this.adapter.has(this.prefixed(key));\n }\n\n // Batch operations. When the underlying adapter exposes a native batch\n // method, the manager delegates to it (a single round-trip) after applying\n // the namespace prefix; otherwise it falls back to a per-key loop. This\n // keeps the optional adapter methods out of the hot path for adapters that\n // do not implement them while avoiding the N+1 fan-out for those that do.\n\n async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {\n const prefixed = keys.map(k => this.prefixed(k));\n\n if (this.adapter.mget) {\n return this.adapter.mget<T>(prefixed);\n }\n\n return Promise.all(prefixed.map(k => this.adapter.get<T>(k)));\n }\n\n async mset<T = unknown>(\n entries: Array<[string, T]> | Record<string, T>,\n ttl?: number\n ): Promise<void> {\n const pairs: Array<[string, T]> = Array.isArray(entries)\n ? entries\n : (Object.entries(entries) as Array<[string, T]>);\n\n const prefixed: Array<[string, T]> = pairs.map(\n ([k, v]) => [this.prefixed(k), v] as [string, T]\n );\n\n if (this.adapter.mset) {\n await this.adapter.mset<T>(prefixed, ttl);\n return;\n }\n\n await Promise.all(prefixed.map(([k, v]) => this.adapter.set<T>(k, v, ttl)));\n }\n\n async mdel(keys: string[]): Promise<void> {\n const prefixed = keys.map(k => this.prefixed(k));\n\n if (this.adapter.mdel) {\n await this.adapter.mdel(prefixed);\n return;\n }\n\n await Promise.all(prefixed.map(k => this.adapter.del(k)));\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iskra-bun/kv-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Key-Value store de Iskra con adaptadores de Redis y memoria.",
5
5
  "keywords": [
6
6
  "iskra",
@@ -47,7 +47,7 @@
47
47
  "build": "tsup --config ../../tsup.config.ts"
48
48
  },
49
49
  "dependencies": {
50
- "@iskra-bun/core": "0.1.0",
50
+ "@iskra-bun/core": "0.1.1",
51
51
  "ioredis": "^5.4.1"
52
52
  },
53
53
  "devDependencies": {
@@ -2,31 +2,64 @@ import type { KVAdapter } from '../types';
2
2
 
3
3
  export class MemoryAdapter implements KVAdapter {
4
4
  id = 'memory';
5
- private store = new Map<string, any>();
5
+ private store = new Map<string, unknown>();
6
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
6
7
 
7
8
  connect() {
8
9
  // No-op
9
10
  }
11
+
10
12
  disconnect() {
13
+ for (const timer of this.timers.values()) {
14
+ clearTimeout(timer);
15
+ }
16
+ this.timers.clear();
11
17
  this.store.clear();
12
18
  }
13
19
 
14
- async get(key: string) {
15
- return this.store.get(key);
20
+ async get<T = unknown>(key: string): Promise<T | undefined> {
21
+ return this.store.get(key) as T | undefined;
16
22
  }
17
23
 
18
- async set(key: string, value: any, ttl?: number) {
24
+ async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
25
+ // Note: storing `null`/`undefined` is undefined behavior across adapters.
26
+ // This in-memory adapter stores the value verbatim (so `get` returns it
27
+ // as-is), whereas the RedisAdapter normalizes both to "absent" because
28
+ // they have no faithful JSON round-trip. Callers should not depend on
29
+ // either form being preserved.
30
+ //
31
+ // Clear any existing expiry timer for this key before setting a new one
32
+ const existing = this.timers.get(key);
33
+ if (existing !== undefined) {
34
+ clearTimeout(existing);
35
+ this.timers.delete(key);
36
+ }
37
+
19
38
  this.store.set(key, value);
39
+
20
40
  if (ttl) {
21
- setTimeout(() => this.store.delete(key), ttl * 1000);
41
+ const timer = setTimeout(() => {
42
+ this.store.delete(key);
43
+ this.timers.delete(key);
44
+ }, ttl * 1000);
45
+
46
+ // Avoid keeping the process alive just for expiry timers
47
+ timer.unref?.();
48
+
49
+ this.timers.set(key, timer);
22
50
  }
23
51
  }
24
52
 
25
- async del(key: string) {
53
+ async del(key: string): Promise<void> {
54
+ const timer = this.timers.get(key);
55
+ if (timer !== undefined) {
56
+ clearTimeout(timer);
57
+ this.timers.delete(key);
58
+ }
26
59
  this.store.delete(key);
27
60
  }
28
61
 
29
- async has(key: string) {
62
+ async has(key: string): Promise<boolean> {
30
63
  return this.store.has(key);
31
64
  }
32
65
  }
@@ -1,34 +1,56 @@
1
1
  import type { KVAdapter } from '../types';
2
2
  import Redis from 'ioredis';
3
+ import type { RedisOptions } from 'ioredis';
4
+
5
+ /**
6
+ * Single consistent codec shared by every write/read path.
7
+ *
8
+ * `undefined` is guarded explicitly: there is no JSON representation for it,
9
+ * so it is stored as the JSON `null` literal and decoded back to `undefined`
10
+ * on read. Every other value is `JSON.stringify`'d on write and `JSON.parse`'d
11
+ * on read, so the string "123" round-trips as the string "123" (not the number
12
+ * 123) and "{}" round-trips as the string "{}" (not an empty object).
13
+ */
14
+ function encode<T>(value: T): string {
15
+ if (value === undefined) return 'null';
16
+ return JSON.stringify(value);
17
+ }
18
+
19
+ function decode<T>(raw: string): T | undefined {
20
+ try {
21
+ const parsed = JSON.parse(raw) as T | null;
22
+ return parsed === null ? undefined : (parsed as T);
23
+ } catch {
24
+ // Tolerate values written outside the adapter that are not valid JSON.
25
+ return raw as unknown as T;
26
+ }
27
+ }
3
28
 
4
29
  export class RedisAdapter implements KVAdapter {
5
30
  id = 'redis';
6
31
  private client: Redis | null = null;
7
- private options: any;
32
+ private readonly options: RedisOptions | string;
8
33
 
9
- constructor(options: any) {
34
+ constructor(options: RedisOptions | string) {
10
35
  this.options = options;
11
36
  }
12
37
 
13
38
  connect() {
14
- this.client = new Redis(this.options);
39
+ this.client = new Redis(this.options as RedisOptions);
15
40
  }
16
41
 
17
42
  disconnect() {
18
43
  this.client?.disconnect();
19
44
  }
20
45
 
21
- async get(key: string) {
46
+ async get<T = unknown>(key: string): Promise<T | undefined> {
22
47
  const val = await this.client?.get(key);
23
- try {
24
- return val ? JSON.parse(val) : null;
25
- } catch {
26
- return val;
27
- }
48
+ if (val === null || val === undefined) return undefined;
49
+ return decode<T>(val);
28
50
  }
29
51
 
30
- async set(key: string, value: any, ttl?: number) {
31
- const val = typeof value === 'object' ? JSON.stringify(value) : value;
52
+ async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
53
+ const val = encode(value);
32
54
  if (ttl) {
33
55
  await this.client?.set(key, val, 'EX', ttl);
34
56
  } else {
@@ -44,4 +66,41 @@ export class RedisAdapter implements KVAdapter {
44
66
  const exists = await this.client?.exists(key);
45
67
  return exists === 1;
46
68
  }
69
+
70
+ // Native batch operations. A single MGET / pipelined MSET / variadic DEL
71
+ // replaces the manager's per-key fan-out (avoids the N+1 round-trips).
72
+
73
+ async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {
74
+ if (keys.length === 0) return [];
75
+ const raws = (await this.client?.mget(...keys)) ?? [];
76
+ return keys.map((_, i) => {
77
+ const raw = raws[i];
78
+ return raw === null || raw === undefined ? undefined : decode<T>(raw);
79
+ });
80
+ }
81
+
82
+ async mset<T = unknown>(
83
+ entries: Array<[string, T]>,
84
+ ttl?: number
85
+ ): Promise<void> {
86
+ if (entries.length === 0) return;
87
+ const pipeline = this.client?.pipeline();
88
+ if (!pipeline) return;
89
+
90
+ for (const [key, value] of entries) {
91
+ const val = encode(value);
92
+ if (ttl) {
93
+ pipeline.set(key, val, 'EX', ttl);
94
+ } else {
95
+ pipeline.set(key, val);
96
+ }
97
+ }
98
+
99
+ await pipeline.exec();
100
+ }
101
+
102
+ async mdel(keys: string[]): Promise<void> {
103
+ if (keys.length === 0) return;
104
+ await this.client?.del(...keys);
105
+ }
47
106
  }
package/src/manager.ts CHANGED
@@ -3,15 +3,25 @@ import type { KVAdapter } from './types';
3
3
  import { MemoryAdapter } from './adapters/memory';
4
4
  import { RedisAdapter } from './adapters/redis';
5
5
 
6
+ export interface KVManagerOptions {
7
+ /**
8
+ * Optional namespace prepended to every key as `"<namespace>:<key>"`.
9
+ * Defaults to `""` (no prefix) to preserve existing behavior.
10
+ */
11
+ namespace?: string;
12
+ }
13
+
6
14
  export class KVManager implements Driver, KVAdapter {
7
15
  name = 'KVManager';
8
16
  id = 'manager';
9
17
  private app: App | null = null;
10
18
  private adapter: KVAdapter;
19
+ private readonly prefix: string;
11
20
 
12
- constructor() {
21
+ constructor(options: KVManagerOptions = {}) {
13
22
  // Default to memory until configured
14
23
  this.adapter = new MemoryAdapter();
24
+ this.prefix = options.namespace ? `${options.namespace}:` : '';
15
25
  }
16
26
 
17
27
  init(app: App) {
@@ -45,9 +55,71 @@ export class KVManager implements Driver, KVAdapter {
45
55
  await this.disconnect();
46
56
  }
47
57
 
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); }
58
+ private prefixed(key: string): string {
59
+ return `${this.prefix}${key}`;
60
+ }
61
+
62
+ // Proxy methods (namespace-aware)
63
+ get<T = unknown>(key: string): Promise<T | undefined> {
64
+ return this.adapter.get<T>(this.prefixed(key));
65
+ }
66
+
67
+ set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
68
+ return this.adapter.set<T>(this.prefixed(key), value, ttl);
69
+ }
70
+
71
+ del(key: string): Promise<void> {
72
+ return this.adapter.del(this.prefixed(key));
73
+ }
74
+
75
+ has(key: string): Promise<boolean> {
76
+ return this.adapter.has(this.prefixed(key));
77
+ }
78
+
79
+ // Batch operations. When the underlying adapter exposes a native batch
80
+ // method, the manager delegates to it (a single round-trip) after applying
81
+ // the namespace prefix; otherwise it falls back to a per-key loop. This
82
+ // keeps the optional adapter methods out of the hot path for adapters that
83
+ // do not implement them while avoiding the N+1 fan-out for those that do.
84
+
85
+ async mget<T = unknown>(keys: string[]): Promise<(T | undefined)[]> {
86
+ const prefixed = keys.map(k => this.prefixed(k));
87
+
88
+ if (this.adapter.mget) {
89
+ return this.adapter.mget<T>(prefixed);
90
+ }
91
+
92
+ return Promise.all(prefixed.map(k => this.adapter.get<T>(k)));
93
+ }
94
+
95
+ async mset<T = unknown>(
96
+ entries: Array<[string, T]> | Record<string, T>,
97
+ ttl?: number
98
+ ): Promise<void> {
99
+ const pairs: Array<[string, T]> = Array.isArray(entries)
100
+ ? entries
101
+ : (Object.entries(entries) as Array<[string, T]>);
102
+
103
+ const prefixed: Array<[string, T]> = pairs.map(
104
+ ([k, v]) => [this.prefixed(k), v] as [string, T]
105
+ );
106
+
107
+ if (this.adapter.mset) {
108
+ await this.adapter.mset<T>(prefixed, ttl);
109
+ return;
110
+ }
111
+
112
+ await Promise.all(prefixed.map(([k, v]) => this.adapter.set<T>(k, v, ttl)));
113
+ }
114
+
115
+ async mdel(keys: string[]): Promise<void> {
116
+ const prefixed = keys.map(k => this.prefixed(k));
117
+
118
+ if (this.adapter.mdel) {
119
+ await this.adapter.mdel(prefixed);
120
+ return;
121
+ }
122
+
123
+ await Promise.all(prefixed.map(k => this.adapter.del(k)));
124
+ }
53
125
  }
package/src/types.ts CHANGED
@@ -3,8 +3,20 @@ export interface KVAdapter {
3
3
  connect(): Promise<void> | void;
4
4
  disconnect(): Promise<void> | void;
5
5
 
6
- get(key: string): Promise<any>;
7
- set(key: string, value: any, ttl?: number): Promise<void>;
6
+ get<T = unknown>(key: string): Promise<T | undefined>;
7
+ set<T = unknown>(key: string, value: T, ttl?: number): Promise<void>;
8
8
  del(key: string): Promise<void>;
9
9
  has(key: string): Promise<boolean>;
10
+
11
+ // Optional native batch operations. When an adapter implements these, the
12
+ // KVManager prefers them over a per-key loop to avoid N+1 round-trips.
13
+ // Adapters that omit them remain fully compatible.
14
+ //
15
+ // Storing `null`/`undefined` is normalized to "absent" by the adapters that
16
+ // round-trip through JSON (see RedisAdapter); the in-memory adapter stores
17
+ // them verbatim. Callers should treat `null`/`undefined` values as
18
+ // undefined behavior and avoid relying on either form being preserved.
19
+ mget?<T = unknown>(keys: string[]): Promise<(T | undefined)[]>;
20
+ mset?<T = unknown>(entries: Array<[string, T]>, ttl?: number): Promise<void>;
21
+ mdel?(keys: string[]): Promise<void>;
10
22
  }