@iskra-bun/cache-kit 0.1.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 ADDED
@@ -0,0 +1,18 @@
1
+ # @iskra-bun/cache-kit
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f9654df: Initial public release. Higher-level application cache built on `@iskra-bun/kv-kit`: cache-aside (`remember`/`wrap`), TTL, namespaces, and tag-based invalidation, with an in-memory default and Redis support via a kv adapter.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [f9654df]
12
+ - Updated dependencies
13
+ - Updated dependencies [f9654df]
14
+ - Updated dependencies [f9654df]
15
+ - Updated dependencies
16
+ - Updated dependencies [f9654df]
17
+ - @iskra-bun/core@0.1.1
18
+ - @iskra-bun/kv-kit@0.2.0
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @iskra-bun/cache-kit
2
+
3
+ Cache de aplicación de alto nivel para Iskra, construido sobre `@iskra-bun/kv-kit`.
4
+
5
+ ## Instalacion
6
+
7
+ ```bash
8
+ bun add @iskra-bun/cache-kit @iskra-bun/kv-kit @iskra-bun/core
9
+ ```
10
+
11
+ ## Uso rapido
12
+
13
+ ```typescript
14
+ import { Cache } from '@iskra-bun/cache-kit';
15
+
16
+ // Zero-config — usa memoria en desarrollo
17
+ const cache = new Cache();
18
+
19
+ await cache.set('user:1', { name: 'Alice' }, { ttl: 300 });
20
+ const user = await cache.get('user:1');
21
+
22
+ // Cache-aside: llama al fallback solo en miss
23
+ const data = await cache.remember('dashboard', 60, () => fetchFromDB());
24
+ ```
25
+
26
+ ## Namespacing
27
+
28
+ ```typescript
29
+ const users = cache.namespace('users');
30
+ const posts = cache.namespace('posts');
31
+
32
+ await users.set('profile:1', userPayload);
33
+ await posts.set('profile:1', postPayload); // clave distinta, sin colision
34
+ ```
35
+
36
+ ## Invalidacion por etiquetas
37
+
38
+ ```typescript
39
+ await cache.set('item:1', item, { ttl: 60, tags: ['items', 'featured'] });
40
+ await cache.set('item:2', item2, { ttl: 60, tags: ['items'] });
41
+
42
+ await cache.invalidateTag('items'); // elimina item:1 e item:2
43
+ ```
44
+
45
+ ## Documentacion
46
+
47
+ Guia completa: [docs/cache-kit.md](../../docs/cache-kit.md)
48
+
49
+ ## Licencia
50
+
51
+ AGPL-3.0-or-later
@@ -0,0 +1,123 @@
1
+ import { KVAdapter } from '@iskra-bun/kv-kit';
2
+
3
+ interface CacheOptions {
4
+ /** Prefix prepended to every key — prevents collisions between modules. */
5
+ namespace?: string;
6
+ /** Default TTL in seconds applied when set() is called without an explicit ttl. */
7
+ defaultTtl?: number;
8
+ }
9
+ interface SetOptions {
10
+ /** TTL in seconds. Overrides any defaultTtl configured on the Cache. */
11
+ ttl?: number;
12
+ /**
13
+ * Tag names associated with this entry.
14
+ *
15
+ * Pass one or more tags so the entry can later be invalidated as a group
16
+ * via `Cache.invalidateTag(tag)`.
17
+ */
18
+ tags?: string[];
19
+ }
20
+
21
+ /**
22
+ * Cache — higher-level application cache backed by any {@link KVAdapter}.
23
+ *
24
+ * Works out of the box with zero config (defaults to an in-memory adapter).
25
+ * Compose with any kv-kit adapter (MemoryAdapter, RedisAdapter) for production.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * const cache = new Cache();
30
+ * await cache.set('user:1', { name: 'Alice' }, { ttl: 300 });
31
+ * const user = await cache.get<User>('user:1');
32
+ *
33
+ * // Cache-aside pattern
34
+ * const data = await cache.remember('expensive', 60, () => fetchFromDB());
35
+ *
36
+ * // Namespace isolation
37
+ * const userCache = cache.namespace('users');
38
+ * const postCache = cache.namespace('posts');
39
+ *
40
+ * // Tag-based invalidation
41
+ * await cache.set('item:1', data, { ttl: 60, tags: ['items'] });
42
+ * await cache.invalidateTag('items'); // removes all tagged entries
43
+ * ```
44
+ */
45
+ declare class Cache {
46
+ private readonly adapter;
47
+ private readonly prefix;
48
+ private readonly defaultTtl;
49
+ constructor(adapter?: KVAdapter, options?: CacheOptions);
50
+ private prefixKey;
51
+ private tagIndexKey;
52
+ /**
53
+ * Retrieve a cached value by key.
54
+ * Returns `undefined` when the key is absent or has expired.
55
+ */
56
+ get<T = unknown>(key: string): Promise<T | undefined>;
57
+ /**
58
+ * Store a value under key, optionally with a TTL and/or tags.
59
+ *
60
+ * @param key Cache key (namespace prefix is applied automatically).
61
+ * @param value Any JSON-serialisable value.
62
+ * @param options `ttl` in seconds and/or `tags` for group invalidation.
63
+ * A bare number is treated as `ttl` seconds (shorthand).
64
+ */
65
+ set<T>(key: string, value: T, options?: number | SetOptions): Promise<void>;
66
+ /**
67
+ * Check whether a key exists in the cache (and has not expired).
68
+ */
69
+ has(key: string): Promise<boolean>;
70
+ /**
71
+ * Delete a single key from the cache.
72
+ */
73
+ delete(key: string): Promise<void>;
74
+ /**
75
+ * Flush the **entire** backing store shared by this Cache and every other
76
+ * Cache built on the same adapter.
77
+ *
78
+ * Implementation note: the {@link KVAdapter} interface does not expose key
79
+ * enumeration, so this recycles the adapter via `disconnect()`/`connect()`,
80
+ * which is a whole-store reset rather than a namespace-scoped one. To avoid
81
+ * a namespaced sub-cache silently nuking its siblings, this method refuses
82
+ * to run when a namespace prefix is set: it is only valid on a root Cache.
83
+ *
84
+ * @throws Error when called on a namespaced Cache (a prefix is set).
85
+ */
86
+ clear(): Promise<void>;
87
+ /**
88
+ * Return the cached value for `key` if present; otherwise call `fallback`,
89
+ * store the result with the given TTL, and return it.
90
+ *
91
+ * The fallback is called **exactly once** on a cache miss — never on a hit.
92
+ *
93
+ * @param key Cache key.
94
+ * @param ttl TTL in seconds for the stored value.
95
+ * @param fallback Async factory invoked on a cache miss.
96
+ */
97
+ remember<T>(key: string, ttl: number, fallback: () => Promise<T>): Promise<T>;
98
+ /** Alias for {@link remember}. */
99
+ readonly wrap: <T>(key: string, ttl: number, fallback: () => Promise<T>) => Promise<T>;
100
+ /**
101
+ * Invalidate every cached entry that was stored with the given tag.
102
+ *
103
+ * After this call all keys associated with `tag` are deleted and the tag
104
+ * index entry is removed. Entries carrying other tags are unaffected.
105
+ */
106
+ invalidateTag(tag: string): Promise<void>;
107
+ /**
108
+ * Return a new `Cache` sharing the same backing adapter but with an
109
+ * additional namespace prefix, preventing key collisions between modules.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * const users = cache.namespace('users');
114
+ * const posts = cache.namespace('posts');
115
+ * // 'users:profile:1' and 'posts:profile:1' are distinct keys
116
+ * ```
117
+ */
118
+ namespace(prefix: string): Cache;
119
+ private resolveSetOptions;
120
+ private indexTags;
121
+ }
122
+
123
+ export { Cache, type CacheOptions, type SetOptions };
package/dist/index.js ADDED
@@ -0,0 +1,261 @@
1
+ // src/memory-adapter.ts
2
+ var MemoryAdapter = class {
3
+ id = "memory";
4
+ store = /* @__PURE__ */ new Map();
5
+ timers = /* @__PURE__ */ new Map();
6
+ connect() {
7
+ }
8
+ disconnect() {
9
+ for (const timer of this.timers.values()) clearTimeout(timer);
10
+ this.timers = /* @__PURE__ */ new Map();
11
+ this.store = /* @__PURE__ */ new Map();
12
+ }
13
+ async get(key) {
14
+ return this.store.get(key);
15
+ }
16
+ async set(key, value, ttl) {
17
+ this.clearTimer(key);
18
+ this.store.set(key, value);
19
+ if (ttl) {
20
+ const timer = setTimeout(() => {
21
+ this.store.delete(key);
22
+ this.timers.delete(key);
23
+ }, ttl * 1e3);
24
+ timer.unref?.();
25
+ this.timers.set(key, timer);
26
+ }
27
+ }
28
+ async del(key) {
29
+ this.clearTimer(key);
30
+ this.store.delete(key);
31
+ }
32
+ clearTimer(key) {
33
+ const existing = this.timers.get(key);
34
+ if (existing) {
35
+ clearTimeout(existing);
36
+ this.timers.delete(key);
37
+ }
38
+ }
39
+ async has(key) {
40
+ return this.store.has(key);
41
+ }
42
+ };
43
+
44
+ // src/cache.ts
45
+ var TAG_INDEX_PREFIX = "__cache_tag__:";
46
+ var DANGEROUS_KEYS = ["__proto__", "constructor", "prototype"];
47
+ function assertNoPollution(raw, value) {
48
+ if (DANGEROUS_KEYS.some((k) => raw.includes(`"${k}"`))) {
49
+ for (const key of DANGEROUS_KEYS) {
50
+ if (containsKey(value, key)) {
51
+ throw new Error(
52
+ `cache-kit: refusing to deserialize value containing the unsafe key "${key}" (prototype-pollution risk).`
53
+ );
54
+ }
55
+ }
56
+ }
57
+ }
58
+ function containsKey(value, key) {
59
+ if (value === null || typeof value !== "object") return false;
60
+ if (Object.prototype.hasOwnProperty.call(value, key)) return true;
61
+ return Object.values(value).some(
62
+ (child) => containsKey(child, key)
63
+ );
64
+ }
65
+ var Cache = class _Cache {
66
+ adapter;
67
+ prefix;
68
+ defaultTtl;
69
+ constructor(adapter, options = {}) {
70
+ this.adapter = adapter ?? new MemoryAdapter();
71
+ this.prefix = options.namespace ? `${options.namespace}:` : "";
72
+ this.defaultTtl = options.defaultTtl;
73
+ }
74
+ // -------------------------------------------------------------------------
75
+ // Key helpers
76
+ // -------------------------------------------------------------------------
77
+ prefixKey(key) {
78
+ return `${this.prefix}${key}`;
79
+ }
80
+ tagIndexKey(tag) {
81
+ return `${this.prefix}${TAG_INDEX_PREFIX}${tag}`;
82
+ }
83
+ // -------------------------------------------------------------------------
84
+ // Core API
85
+ // -------------------------------------------------------------------------
86
+ /**
87
+ * Retrieve a cached value by key.
88
+ * Returns `undefined` when the key is absent or has expired.
89
+ */
90
+ async get(key) {
91
+ const raw = await this.adapter.get(this.prefixKey(key));
92
+ if (raw === void 0 || raw === null) return void 0;
93
+ const text = raw;
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(text);
97
+ } catch {
98
+ throw new Error(
99
+ `cache-kit: failed to deserialize value for key "${key}". The stored value is not valid JSON.`
100
+ );
101
+ }
102
+ assertNoPollution(text, parsed);
103
+ return parsed;
104
+ }
105
+ /**
106
+ * Store a value under key, optionally with a TTL and/or tags.
107
+ *
108
+ * @param key Cache key (namespace prefix is applied automatically).
109
+ * @param value Any JSON-serialisable value.
110
+ * @param options `ttl` in seconds and/or `tags` for group invalidation.
111
+ * A bare number is treated as `ttl` seconds (shorthand).
112
+ */
113
+ async set(key, value, options) {
114
+ const { ttl, tags } = this.resolveSetOptions(options);
115
+ const serialized = JSON.stringify(value);
116
+ const effectiveTtl = ttl ?? this.defaultTtl;
117
+ await this.adapter.set(this.prefixKey(key), serialized, effectiveTtl);
118
+ if (tags && tags.length > 0) {
119
+ await this.indexTags(key, tags);
120
+ }
121
+ }
122
+ /**
123
+ * Check whether a key exists in the cache (and has not expired).
124
+ */
125
+ async has(key) {
126
+ return this.adapter.has(this.prefixKey(key));
127
+ }
128
+ /**
129
+ * Delete a single key from the cache.
130
+ */
131
+ async delete(key) {
132
+ await this.adapter.del(this.prefixKey(key));
133
+ }
134
+ /**
135
+ * Flush the **entire** backing store shared by this Cache and every other
136
+ * Cache built on the same adapter.
137
+ *
138
+ * Implementation note: the {@link KVAdapter} interface does not expose key
139
+ * enumeration, so this recycles the adapter via `disconnect()`/`connect()`,
140
+ * which is a whole-store reset rather than a namespace-scoped one. To avoid
141
+ * a namespaced sub-cache silently nuking its siblings, this method refuses
142
+ * to run when a namespace prefix is set: it is only valid on a root Cache.
143
+ *
144
+ * @throws Error when called on a namespaced Cache (a prefix is set).
145
+ */
146
+ async clear() {
147
+ if (this.prefix !== "") {
148
+ const namespace = this.prefix.replace(/:$/, "");
149
+ throw new Error(
150
+ `cache-kit: clear() resets the entire shared backing store and cannot be called on the namespaced cache "${namespace}". Delete individual keys with delete(), invalidate a group with invalidateTag(), or call clear() on the root cache to reset everything.`
151
+ );
152
+ }
153
+ await this.adapter.disconnect();
154
+ await this.adapter.connect();
155
+ }
156
+ // -------------------------------------------------------------------------
157
+ // Cache-aside (remember / wrap)
158
+ // -------------------------------------------------------------------------
159
+ /**
160
+ * Return the cached value for `key` if present; otherwise call `fallback`,
161
+ * store the result with the given TTL, and return it.
162
+ *
163
+ * The fallback is called **exactly once** on a cache miss — never on a hit.
164
+ *
165
+ * @param key Cache key.
166
+ * @param ttl TTL in seconds for the stored value.
167
+ * @param fallback Async factory invoked on a cache miss.
168
+ */
169
+ async remember(key, ttl, fallback) {
170
+ const cached = await this.get(key);
171
+ if (cached !== void 0) return cached;
172
+ let value;
173
+ try {
174
+ value = await fallback();
175
+ } catch (error) {
176
+ throw new Error(
177
+ `cache-kit: remember() fallback for key "${key}" threw: ${String(error)}`
178
+ );
179
+ }
180
+ await this.set(key, value, { ttl });
181
+ return value;
182
+ }
183
+ /** Alias for {@link remember}. */
184
+ wrap = this.remember.bind(this);
185
+ // -------------------------------------------------------------------------
186
+ // Tag-based invalidation
187
+ // -------------------------------------------------------------------------
188
+ /**
189
+ * Invalidate every cached entry that was stored with the given tag.
190
+ *
191
+ * After this call all keys associated with `tag` are deleted and the tag
192
+ * index entry is removed. Entries carrying other tags are unaffected.
193
+ */
194
+ async invalidateTag(tag) {
195
+ const indexKey = this.tagIndexKey(tag);
196
+ const raw = await this.adapter.get(indexKey);
197
+ if (raw === null || raw === void 0) return;
198
+ let keys;
199
+ try {
200
+ keys = JSON.parse(raw);
201
+ } catch {
202
+ keys = [];
203
+ }
204
+ await Promise.all([
205
+ ...keys.map((k) => this.adapter.del(this.prefixKey(k))),
206
+ this.adapter.del(indexKey)
207
+ ]);
208
+ }
209
+ // -------------------------------------------------------------------------
210
+ // Namespace helper
211
+ // -------------------------------------------------------------------------
212
+ /**
213
+ * Return a new `Cache` sharing the same backing adapter but with an
214
+ * additional namespace prefix, preventing key collisions between modules.
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * const users = cache.namespace('users');
219
+ * const posts = cache.namespace('posts');
220
+ * // 'users:profile:1' and 'posts:profile:1' are distinct keys
221
+ * ```
222
+ */
223
+ namespace(prefix) {
224
+ const parentNs = this.prefix.replace(/:$/, "");
225
+ const combinedPrefix = parentNs ? `${parentNs}:${prefix}` : prefix;
226
+ return new _Cache(this.adapter, {
227
+ namespace: combinedPrefix,
228
+ defaultTtl: this.defaultTtl
229
+ });
230
+ }
231
+ // -------------------------------------------------------------------------
232
+ // Private helpers
233
+ // -------------------------------------------------------------------------
234
+ resolveSetOptions(options) {
235
+ if (options === void 0) return {};
236
+ if (typeof options === "number") return { ttl: options };
237
+ return options;
238
+ }
239
+ async indexTags(key, tags) {
240
+ await Promise.all(
241
+ tags.map(async (tag) => {
242
+ const indexKey = this.tagIndexKey(tag);
243
+ const raw = await this.adapter.get(indexKey);
244
+ let keys = [];
245
+ if (raw !== null && raw !== void 0) {
246
+ try {
247
+ keys = JSON.parse(raw);
248
+ } catch {
249
+ keys = [];
250
+ }
251
+ }
252
+ const updated = keys.includes(key) ? keys : [...keys, key];
253
+ await this.adapter.set(indexKey, JSON.stringify(updated));
254
+ })
255
+ );
256
+ }
257
+ };
258
+ export {
259
+ Cache
260
+ };
261
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/memory-adapter.ts","../src/cache.ts"],"sourcesContent":["import type { KVAdapter } from './types';\n\n/**\n * Lightweight in-memory {@link KVAdapter} used as the default backing store\n * when no adapter is supplied to the {@link Cache} constructor.\n *\n * This is intentionally a thin Map wrapper — it mirrors kv-kit's MemoryAdapter\n * without depending on an unexported internal symbol.\n */\nexport class MemoryAdapter implements KVAdapter {\n readonly id = 'memory';\n private store = new Map<string, unknown>();\n private timers = new Map<string, ReturnType<typeof setTimeout>>();\n\n connect(): void {\n // no-op\n }\n\n disconnect(): void {\n for (const timer of this.timers.values()) clearTimeout(timer);\n this.timers = new Map();\n this.store = new Map();\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 this.clearTimer(key);\n this.store.set(key, value);\n if (ttl) {\n const timer = setTimeout(() => {\n this.store.delete(key);\n this.timers.delete(key);\n }, ttl * 1000);\n timer.unref?.();\n this.timers.set(key, timer);\n }\n }\n\n async del(key: string): Promise<void> {\n this.clearTimer(key);\n this.store.delete(key);\n }\n\n private clearTimer(key: string): void {\n const existing = this.timers.get(key);\n if (existing) {\n clearTimeout(existing);\n this.timers.delete(key);\n }\n }\n\n async has(key: string): Promise<boolean> {\n return this.store.has(key);\n }\n}\n","import { MemoryAdapter } from './memory-adapter';\nimport type { KVAdapter, CacheOptions, SetOptions } from './types';\n\n/** Internal prefix used for the tag→keys index stored in the KV adapter. */\nconst TAG_INDEX_PREFIX = '__cache_tag__:';\n\n/** Keys that enable prototype-pollution when an object is later deep-merged. */\nconst DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] as const;\n\n/**\n * Throw if `value` (or any nested object) carries a prototype-pollution key.\n *\n * Cached payloads can originate from untrusted writers; rejecting these keys at\n * the parse boundary stops a malicious value from reaching code that deep-merges\n * it. Reads the raw JSON text first so `__proto__` (which `JSON.parse` hides on\n * the resulting object) is detected before traversal.\n */\nfunction assertNoPollution(raw: string, value: unknown): void {\n if (DANGEROUS_KEYS.some((k) => raw.includes(`\"${k}\"`))) {\n for (const key of DANGEROUS_KEYS) {\n if (containsKey(value, key)) {\n throw new Error(\n `cache-kit: refusing to deserialize value containing the ` +\n `unsafe key \"${key}\" (prototype-pollution risk).`\n );\n }\n }\n }\n}\n\n/** Recursively test whether `value` is/contains an object with own `key`. */\nfunction containsKey(value: unknown, key: string): boolean {\n if (value === null || typeof value !== 'object') return false;\n if (Object.prototype.hasOwnProperty.call(value, key)) return true;\n return Object.values(value as Record<string, unknown>).some((child) =>\n containsKey(child, key)\n );\n}\n\n/**\n * Cache — higher-level application cache backed by any {@link KVAdapter}.\n *\n * Works out of the box with zero config (defaults to an in-memory adapter).\n * Compose with any kv-kit adapter (MemoryAdapter, RedisAdapter) for production.\n *\n * @example\n * ```ts\n * const cache = new Cache();\n * await cache.set('user:1', { name: 'Alice' }, { ttl: 300 });\n * const user = await cache.get<User>('user:1');\n *\n * // Cache-aside pattern\n * const data = await cache.remember('expensive', 60, () => fetchFromDB());\n *\n * // Namespace isolation\n * const userCache = cache.namespace('users');\n * const postCache = cache.namespace('posts');\n *\n * // Tag-based invalidation\n * await cache.set('item:1', data, { ttl: 60, tags: ['items'] });\n * await cache.invalidateTag('items'); // removes all tagged entries\n * ```\n */\nexport class Cache {\n private readonly adapter: KVAdapter;\n private readonly prefix: string;\n private readonly defaultTtl: number | undefined;\n\n constructor(adapter?: KVAdapter, options: CacheOptions = {}) {\n this.adapter = adapter ?? new MemoryAdapter();\n this.prefix = options.namespace ? `${options.namespace}:` : '';\n this.defaultTtl = options.defaultTtl;\n }\n\n // -------------------------------------------------------------------------\n // Key helpers\n // -------------------------------------------------------------------------\n\n private prefixKey(key: string): string {\n return `${this.prefix}${key}`;\n }\n\n private tagIndexKey(tag: string): string {\n return `${this.prefix}${TAG_INDEX_PREFIX}${tag}`;\n }\n\n // -------------------------------------------------------------------------\n // Core API\n // -------------------------------------------------------------------------\n\n /**\n * Retrieve a cached value by key.\n * Returns `undefined` when the key is absent or has expired.\n */\n async get<T = unknown>(key: string): Promise<T | undefined> {\n const raw = await this.adapter.get(this.prefixKey(key));\n if (raw === undefined || raw === null) return undefined;\n\n const text = raw as string;\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n throw new Error(\n `cache-kit: failed to deserialize value for key \"${key}\". ` +\n 'The stored value is not valid JSON.'\n );\n }\n\n assertNoPollution(text, parsed);\n return parsed as T;\n }\n\n /**\n * Store a value under key, optionally with a TTL and/or tags.\n *\n * @param key Cache key (namespace prefix is applied automatically).\n * @param value Any JSON-serialisable value.\n * @param options `ttl` in seconds and/or `tags` for group invalidation.\n * A bare number is treated as `ttl` seconds (shorthand).\n */\n async set<T>(key: string, value: T, options?: number | SetOptions): Promise<void> {\n const { ttl, tags } = this.resolveSetOptions(options);\n const serialized = JSON.stringify(value);\n const effectiveTtl = ttl ?? this.defaultTtl;\n\n await this.adapter.set(this.prefixKey(key), serialized, effectiveTtl);\n\n if (tags && tags.length > 0) {\n await this.indexTags(key, tags);\n }\n }\n\n /**\n * Check whether a key exists in the cache (and has not expired).\n */\n async has(key: string): Promise<boolean> {\n return this.adapter.has(this.prefixKey(key));\n }\n\n /**\n * Delete a single key from the cache.\n */\n async delete(key: string): Promise<void> {\n await this.adapter.del(this.prefixKey(key));\n }\n\n /**\n * Flush the **entire** backing store shared by this Cache and every other\n * Cache built on the same adapter.\n *\n * Implementation note: the {@link KVAdapter} interface does not expose key\n * enumeration, so this recycles the adapter via `disconnect()`/`connect()`,\n * which is a whole-store reset rather than a namespace-scoped one. To avoid\n * a namespaced sub-cache silently nuking its siblings, this method refuses\n * to run when a namespace prefix is set: it is only valid on a root Cache.\n *\n * @throws Error when called on a namespaced Cache (a prefix is set).\n */\n async clear(): Promise<void> {\n if (this.prefix !== '') {\n const namespace = this.prefix.replace(/:$/, '');\n throw new Error(\n `cache-kit: clear() resets the entire shared backing store and ` +\n `cannot be called on the namespaced cache \"${namespace}\". ` +\n 'Delete individual keys with delete(), invalidate a group with ' +\n 'invalidateTag(), or call clear() on the root cache to reset everything.'\n );\n }\n await this.adapter.disconnect();\n await this.adapter.connect();\n }\n\n // -------------------------------------------------------------------------\n // Cache-aside (remember / wrap)\n // -------------------------------------------------------------------------\n\n /**\n * Return the cached value for `key` if present; otherwise call `fallback`,\n * store the result with the given TTL, and return it.\n *\n * The fallback is called **exactly once** on a cache miss — never on a hit.\n *\n * @param key Cache key.\n * @param ttl TTL in seconds for the stored value.\n * @param fallback Async factory invoked on a cache miss.\n */\n async remember<T>(key: string, ttl: number, fallback: () => Promise<T>): Promise<T> {\n const cached = await this.get<T>(key);\n if (cached !== undefined) return cached;\n\n let value: T;\n try {\n value = await fallback();\n } catch (error) {\n throw new Error(\n `cache-kit: remember() fallback for key \"${key}\" threw: ${String(error)}`\n );\n }\n\n await this.set(key, value, { ttl });\n return value;\n }\n\n /** Alias for {@link remember}. */\n readonly wrap = this.remember.bind(this);\n\n // -------------------------------------------------------------------------\n // Tag-based invalidation\n // -------------------------------------------------------------------------\n\n /**\n * Invalidate every cached entry that was stored with the given tag.\n *\n * After this call all keys associated with `tag` are deleted and the tag\n * index entry is removed. Entries carrying other tags are unaffected.\n */\n async invalidateTag(tag: string): Promise<void> {\n const indexKey = this.tagIndexKey(tag);\n const raw = await this.adapter.get(indexKey);\n if (raw === null || raw === undefined) return;\n\n let keys: string[];\n try {\n keys = JSON.parse(raw as string) as string[];\n } catch {\n keys = [];\n }\n\n await Promise.all([\n ...keys.map((k) => this.adapter.del(this.prefixKey(k))),\n this.adapter.del(indexKey),\n ]);\n }\n\n // -------------------------------------------------------------------------\n // Namespace helper\n // -------------------------------------------------------------------------\n\n /**\n * Return a new `Cache` sharing the same backing adapter but with an\n * additional namespace prefix, preventing key collisions between modules.\n *\n * @example\n * ```ts\n * const users = cache.namespace('users');\n * const posts = cache.namespace('posts');\n * // 'users:profile:1' and 'posts:profile:1' are distinct keys\n * ```\n */\n namespace(prefix: string): Cache {\n const parentNs = this.prefix.replace(/:$/, '');\n const combinedPrefix = parentNs ? `${parentNs}:${prefix}` : prefix;\n return new Cache(this.adapter, {\n namespace: combinedPrefix,\n defaultTtl: this.defaultTtl,\n });\n }\n\n // -------------------------------------------------------------------------\n // Private helpers\n // -------------------------------------------------------------------------\n\n private resolveSetOptions(options?: number | SetOptions): SetOptions {\n if (options === undefined) return {};\n if (typeof options === 'number') return { ttl: options };\n return options;\n }\n\n private async indexTags(key: string, tags: string[]): Promise<void> {\n await Promise.all(\n tags.map(async (tag) => {\n const indexKey = this.tagIndexKey(tag);\n const raw = await this.adapter.get(indexKey);\n let keys: string[] = [];\n if (raw !== null && raw !== undefined) {\n try {\n keys = JSON.parse(raw as string) as string[];\n } catch {\n keys = [];\n }\n }\n const updated = keys.includes(key) ? keys : [...keys, key];\n await this.adapter.set(indexKey, JSON.stringify(updated));\n })\n );\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@iskra-bun/cache-kit",
3
+ "version": "0.1.0",
4
+ "description": "Cache de aplicación de alto nivel para Iskra, construido sobre @iskra-bun/kv-kit.",
5
+ "keywords": [
6
+ "iskra",
7
+ "bun",
8
+ "cache",
9
+ "cache-aside",
10
+ "ttl",
11
+ "kv"
12
+ ],
13
+ "author": "Joan Lascano",
14
+ "license": "AGPL-3.0-or-later",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/fearful/iskra.git",
18
+ "directory": "packages/cache-kit"
19
+ },
20
+ "homepage": "https://github.com/fearful/iskra/tree/main/packages/cache-kit#readme",
21
+ "bugs": "https://github.com/fearful/iskra/issues",
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "module": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "source": "./src/index.ts",
29
+ "bun": "./src/index.ts",
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src",
38
+ "README.md",
39
+ "CHANGELOG.md"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "test": "bun test",
46
+ "build": "tsup --config ../../tsup.config.ts"
47
+ },
48
+ "dependencies": {
49
+ "@iskra-bun/core": "0.1.1",
50
+ "@iskra-bun/kv-kit": "0.2.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/bun": "^1.3.5",
54
+ "@types/node": "^22.10.2"
55
+ }
56
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,288 @@
1
+ import { MemoryAdapter } from './memory-adapter';
2
+ import type { KVAdapter, CacheOptions, SetOptions } from './types';
3
+
4
+ /** Internal prefix used for the tag→keys index stored in the KV adapter. */
5
+ const TAG_INDEX_PREFIX = '__cache_tag__:';
6
+
7
+ /** Keys that enable prototype-pollution when an object is later deep-merged. */
8
+ const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] as const;
9
+
10
+ /**
11
+ * Throw if `value` (or any nested object) carries a prototype-pollution key.
12
+ *
13
+ * Cached payloads can originate from untrusted writers; rejecting these keys at
14
+ * the parse boundary stops a malicious value from reaching code that deep-merges
15
+ * it. Reads the raw JSON text first so `__proto__` (which `JSON.parse` hides on
16
+ * the resulting object) is detected before traversal.
17
+ */
18
+ function assertNoPollution(raw: string, value: unknown): void {
19
+ if (DANGEROUS_KEYS.some((k) => raw.includes(`"${k}"`))) {
20
+ for (const key of DANGEROUS_KEYS) {
21
+ if (containsKey(value, key)) {
22
+ throw new Error(
23
+ `cache-kit: refusing to deserialize value containing the ` +
24
+ `unsafe key "${key}" (prototype-pollution risk).`
25
+ );
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ /** Recursively test whether `value` is/contains an object with own `key`. */
32
+ function containsKey(value: unknown, key: string): boolean {
33
+ if (value === null || typeof value !== 'object') return false;
34
+ if (Object.prototype.hasOwnProperty.call(value, key)) return true;
35
+ return Object.values(value as Record<string, unknown>).some((child) =>
36
+ containsKey(child, key)
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Cache — higher-level application cache backed by any {@link KVAdapter}.
42
+ *
43
+ * Works out of the box with zero config (defaults to an in-memory adapter).
44
+ * Compose with any kv-kit adapter (MemoryAdapter, RedisAdapter) for production.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * const cache = new Cache();
49
+ * await cache.set('user:1', { name: 'Alice' }, { ttl: 300 });
50
+ * const user = await cache.get<User>('user:1');
51
+ *
52
+ * // Cache-aside pattern
53
+ * const data = await cache.remember('expensive', 60, () => fetchFromDB());
54
+ *
55
+ * // Namespace isolation
56
+ * const userCache = cache.namespace('users');
57
+ * const postCache = cache.namespace('posts');
58
+ *
59
+ * // Tag-based invalidation
60
+ * await cache.set('item:1', data, { ttl: 60, tags: ['items'] });
61
+ * await cache.invalidateTag('items'); // removes all tagged entries
62
+ * ```
63
+ */
64
+ export class Cache {
65
+ private readonly adapter: KVAdapter;
66
+ private readonly prefix: string;
67
+ private readonly defaultTtl: number | undefined;
68
+
69
+ constructor(adapter?: KVAdapter, options: CacheOptions = {}) {
70
+ this.adapter = adapter ?? new MemoryAdapter();
71
+ this.prefix = options.namespace ? `${options.namespace}:` : '';
72
+ this.defaultTtl = options.defaultTtl;
73
+ }
74
+
75
+ // -------------------------------------------------------------------------
76
+ // Key helpers
77
+ // -------------------------------------------------------------------------
78
+
79
+ private prefixKey(key: string): string {
80
+ return `${this.prefix}${key}`;
81
+ }
82
+
83
+ private tagIndexKey(tag: string): string {
84
+ return `${this.prefix}${TAG_INDEX_PREFIX}${tag}`;
85
+ }
86
+
87
+ // -------------------------------------------------------------------------
88
+ // Core API
89
+ // -------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Retrieve a cached value by key.
93
+ * Returns `undefined` when the key is absent or has expired.
94
+ */
95
+ async get<T = unknown>(key: string): Promise<T | undefined> {
96
+ const raw = await this.adapter.get(this.prefixKey(key));
97
+ if (raw === undefined || raw === null) return undefined;
98
+
99
+ const text = raw as string;
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = JSON.parse(text);
103
+ } catch {
104
+ throw new Error(
105
+ `cache-kit: failed to deserialize value for key "${key}". ` +
106
+ 'The stored value is not valid JSON.'
107
+ );
108
+ }
109
+
110
+ assertNoPollution(text, parsed);
111
+ return parsed as T;
112
+ }
113
+
114
+ /**
115
+ * Store a value under key, optionally with a TTL and/or tags.
116
+ *
117
+ * @param key Cache key (namespace prefix is applied automatically).
118
+ * @param value Any JSON-serialisable value.
119
+ * @param options `ttl` in seconds and/or `tags` for group invalidation.
120
+ * A bare number is treated as `ttl` seconds (shorthand).
121
+ */
122
+ async set<T>(key: string, value: T, options?: number | SetOptions): Promise<void> {
123
+ const { ttl, tags } = this.resolveSetOptions(options);
124
+ const serialized = JSON.stringify(value);
125
+ const effectiveTtl = ttl ?? this.defaultTtl;
126
+
127
+ await this.adapter.set(this.prefixKey(key), serialized, effectiveTtl);
128
+
129
+ if (tags && tags.length > 0) {
130
+ await this.indexTags(key, tags);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Check whether a key exists in the cache (and has not expired).
136
+ */
137
+ async has(key: string): Promise<boolean> {
138
+ return this.adapter.has(this.prefixKey(key));
139
+ }
140
+
141
+ /**
142
+ * Delete a single key from the cache.
143
+ */
144
+ async delete(key: string): Promise<void> {
145
+ await this.adapter.del(this.prefixKey(key));
146
+ }
147
+
148
+ /**
149
+ * Flush the **entire** backing store shared by this Cache and every other
150
+ * Cache built on the same adapter.
151
+ *
152
+ * Implementation note: the {@link KVAdapter} interface does not expose key
153
+ * enumeration, so this recycles the adapter via `disconnect()`/`connect()`,
154
+ * which is a whole-store reset rather than a namespace-scoped one. To avoid
155
+ * a namespaced sub-cache silently nuking its siblings, this method refuses
156
+ * to run when a namespace prefix is set: it is only valid on a root Cache.
157
+ *
158
+ * @throws Error when called on a namespaced Cache (a prefix is set).
159
+ */
160
+ async clear(): Promise<void> {
161
+ if (this.prefix !== '') {
162
+ const namespace = this.prefix.replace(/:$/, '');
163
+ throw new Error(
164
+ `cache-kit: clear() resets the entire shared backing store and ` +
165
+ `cannot be called on the namespaced cache "${namespace}". ` +
166
+ 'Delete individual keys with delete(), invalidate a group with ' +
167
+ 'invalidateTag(), or call clear() on the root cache to reset everything.'
168
+ );
169
+ }
170
+ await this.adapter.disconnect();
171
+ await this.adapter.connect();
172
+ }
173
+
174
+ // -------------------------------------------------------------------------
175
+ // Cache-aside (remember / wrap)
176
+ // -------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Return the cached value for `key` if present; otherwise call `fallback`,
180
+ * store the result with the given TTL, and return it.
181
+ *
182
+ * The fallback is called **exactly once** on a cache miss — never on a hit.
183
+ *
184
+ * @param key Cache key.
185
+ * @param ttl TTL in seconds for the stored value.
186
+ * @param fallback Async factory invoked on a cache miss.
187
+ */
188
+ async remember<T>(key: string, ttl: number, fallback: () => Promise<T>): Promise<T> {
189
+ const cached = await this.get<T>(key);
190
+ if (cached !== undefined) return cached;
191
+
192
+ let value: T;
193
+ try {
194
+ value = await fallback();
195
+ } catch (error) {
196
+ throw new Error(
197
+ `cache-kit: remember() fallback for key "${key}" threw: ${String(error)}`
198
+ );
199
+ }
200
+
201
+ await this.set(key, value, { ttl });
202
+ return value;
203
+ }
204
+
205
+ /** Alias for {@link remember}. */
206
+ readonly wrap = this.remember.bind(this);
207
+
208
+ // -------------------------------------------------------------------------
209
+ // Tag-based invalidation
210
+ // -------------------------------------------------------------------------
211
+
212
+ /**
213
+ * Invalidate every cached entry that was stored with the given tag.
214
+ *
215
+ * After this call all keys associated with `tag` are deleted and the tag
216
+ * index entry is removed. Entries carrying other tags are unaffected.
217
+ */
218
+ async invalidateTag(tag: string): Promise<void> {
219
+ const indexKey = this.tagIndexKey(tag);
220
+ const raw = await this.adapter.get(indexKey);
221
+ if (raw === null || raw === undefined) return;
222
+
223
+ let keys: string[];
224
+ try {
225
+ keys = JSON.parse(raw as string) as string[];
226
+ } catch {
227
+ keys = [];
228
+ }
229
+
230
+ await Promise.all([
231
+ ...keys.map((k) => this.adapter.del(this.prefixKey(k))),
232
+ this.adapter.del(indexKey),
233
+ ]);
234
+ }
235
+
236
+ // -------------------------------------------------------------------------
237
+ // Namespace helper
238
+ // -------------------------------------------------------------------------
239
+
240
+ /**
241
+ * Return a new `Cache` sharing the same backing adapter but with an
242
+ * additional namespace prefix, preventing key collisions between modules.
243
+ *
244
+ * @example
245
+ * ```ts
246
+ * const users = cache.namespace('users');
247
+ * const posts = cache.namespace('posts');
248
+ * // 'users:profile:1' and 'posts:profile:1' are distinct keys
249
+ * ```
250
+ */
251
+ namespace(prefix: string): Cache {
252
+ const parentNs = this.prefix.replace(/:$/, '');
253
+ const combinedPrefix = parentNs ? `${parentNs}:${prefix}` : prefix;
254
+ return new Cache(this.adapter, {
255
+ namespace: combinedPrefix,
256
+ defaultTtl: this.defaultTtl,
257
+ });
258
+ }
259
+
260
+ // -------------------------------------------------------------------------
261
+ // Private helpers
262
+ // -------------------------------------------------------------------------
263
+
264
+ private resolveSetOptions(options?: number | SetOptions): SetOptions {
265
+ if (options === undefined) return {};
266
+ if (typeof options === 'number') return { ttl: options };
267
+ return options;
268
+ }
269
+
270
+ private async indexTags(key: string, tags: string[]): Promise<void> {
271
+ await Promise.all(
272
+ tags.map(async (tag) => {
273
+ const indexKey = this.tagIndexKey(tag);
274
+ const raw = await this.adapter.get(indexKey);
275
+ let keys: string[] = [];
276
+ if (raw !== null && raw !== undefined) {
277
+ try {
278
+ keys = JSON.parse(raw as string) as string[];
279
+ } catch {
280
+ keys = [];
281
+ }
282
+ }
283
+ const updated = keys.includes(key) ? keys : [...keys, key];
284
+ await this.adapter.set(indexKey, JSON.stringify(updated));
285
+ })
286
+ );
287
+ }
288
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { Cache } from './cache';
2
+ export type { CacheOptions, SetOptions } from './types';
@@ -0,0 +1,58 @@
1
+ import type { KVAdapter } from './types';
2
+
3
+ /**
4
+ * Lightweight in-memory {@link KVAdapter} used as the default backing store
5
+ * when no adapter is supplied to the {@link Cache} constructor.
6
+ *
7
+ * This is intentionally a thin Map wrapper — it mirrors kv-kit's MemoryAdapter
8
+ * without depending on an unexported internal symbol.
9
+ */
10
+ export class MemoryAdapter implements KVAdapter {
11
+ readonly id = 'memory';
12
+ private store = new Map<string, unknown>();
13
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
14
+
15
+ connect(): void {
16
+ // no-op
17
+ }
18
+
19
+ disconnect(): void {
20
+ for (const timer of this.timers.values()) clearTimeout(timer);
21
+ this.timers = new Map();
22
+ this.store = new Map();
23
+ }
24
+
25
+ async get<T = unknown>(key: string): Promise<T | undefined> {
26
+ return this.store.get(key) as T | undefined;
27
+ }
28
+
29
+ async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
30
+ this.clearTimer(key);
31
+ this.store.set(key, value);
32
+ if (ttl) {
33
+ const timer = setTimeout(() => {
34
+ this.store.delete(key);
35
+ this.timers.delete(key);
36
+ }, ttl * 1000);
37
+ timer.unref?.();
38
+ this.timers.set(key, timer);
39
+ }
40
+ }
41
+
42
+ async del(key: string): Promise<void> {
43
+ this.clearTimer(key);
44
+ this.store.delete(key);
45
+ }
46
+
47
+ private clearTimer(key: string): void {
48
+ const existing = this.timers.get(key);
49
+ if (existing) {
50
+ clearTimeout(existing);
51
+ this.timers.delete(key);
52
+ }
53
+ }
54
+
55
+ async has(key: string): Promise<boolean> {
56
+ return this.store.has(key);
57
+ }
58
+ }
package/src/types.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { KVAdapter } from '@iskra-bun/kv-kit';
2
+
3
+ export type { KVAdapter };
4
+
5
+ export interface CacheOptions {
6
+ /** Prefix prepended to every key — prevents collisions between modules. */
7
+ namespace?: string;
8
+ /** Default TTL in seconds applied when set() is called without an explicit ttl. */
9
+ defaultTtl?: number;
10
+ }
11
+
12
+ export interface SetOptions {
13
+ /** TTL in seconds. Overrides any defaultTtl configured on the Cache. */
14
+ ttl?: number;
15
+ /**
16
+ * Tag names associated with this entry.
17
+ *
18
+ * Pass one or more tags so the entry can later be invalidated as a group
19
+ * via `Cache.invalidateTag(tag)`.
20
+ */
21
+ tags?: string[];
22
+ }