@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 +18 -0
- package/README.md +51 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +261 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/cache.ts +288 -0
- package/src/index.ts +2 -0
- package/src/memory-adapter.ts +58 -0
- package/src/types.ts +22 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
}
|