@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 +19 -0
- package/dist/index.d.ts +20 -5
- package/dist/index.js +106 -13
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/memory.ts +40 -7
- package/src/adapters/redis.ts +70 -11
- package/src/manager.ts +78 -6
- package/src/types.ts +14 -2
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<
|
|
8
|
-
set(key: string, value:
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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(() =>
|
|
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
|
-
|
|
45
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
50
|
+
"@iskra-bun/core": "0.1.1",
|
|
51
51
|
"ioredis": "^5.4.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
package/src/adapters/memory.ts
CHANGED
|
@@ -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,
|
|
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:
|
|
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(() =>
|
|
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
|
}
|
package/src/adapters/redis.ts
CHANGED
|
@@ -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:
|
|
32
|
+
private readonly options: RedisOptions | string;
|
|
8
33
|
|
|
9
|
-
constructor(options:
|
|
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
|
-
|
|
24
|
-
|
|
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:
|
|
31
|
-
const val =
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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<
|
|
7
|
-
set(key: string, value:
|
|
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
|
}
|