@iskra-bun/cache-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # @iskra-bun/cache-kit
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 938dd41: **Security** fixes from the data-kits audit (round 2).
8
+
9
+ - `storage-kit` (**breaking**): files are stored and served with a type from their extension (`contentTypeFor`), and anything but a raster image as a download. The S3 adapter stores a `Content-Disposition` with each object (`attachment` unless it is a PNG, JPEG, GIF, WebP, AVIF, BMP or ICO image; `put(..., { contentDisposition })` to choose), and `url()` signs `response-content-type` and `response-content-disposition` into presigned URLs whatever the object was stored with (`url(path, expiresIn, { contentType, contentDisposition })` to choose): an upload named `logo.svg` or `invoice.html` was stored as `image/svg+xml`/`text/html` and ran its scripts on the bucket's origin. HTML, SVG, XML and JavaScript are now `application/octet-stream`. `put(..., { overwrite: false })` throws the new `FileExistsError` instead of replacing a stored file (S3 `If-None-Match: *`, an exclusive create locally); `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` now need 3.635 or later, the first releases that send `If-None-Match` on a put (earlier ones dropped it, and the file was replaced). The plaintext-endpoint guard parses the endpoint as a URL, as the SDK does: `http:/minio:9000`, `http:minio:9000` and `http:\\minio:9000` were accepted without `useSSL: false`; an endpoint that is not an `http(s)` URL is rejected.
10
+ - `web-kit` uploads (**breaking**): the upload route stores a file with the type of its extension, never `File.type` (which Bun derives from the name), and `uploadFromRequest()` too. Downloads are streamed with `getStream()` (each one was buffered twice), typed by extension, `attachment` unless a raster image, and sandboxed (`Content-Security-Policy: sandbox`). Without `allowedExtensions`, active web content (`.html`, `.svg`, `.xml`, `.js`...) is refused (400) unless listed. `authorize(c, action, target)` receives what the action touches (`{ key, subfolder, filename, size, type }`), and `upload` is asked again with it before the file is written. An upload no longer replaces a stored file: **409** unless `overwrite: true`.
11
+ - `mailer-kit` (**breaking**): every `to`, `cc`, `bcc` and `replyTo` entry must be one bare address, or a new `{ name, address }` object for a display name, in every adapter (the mock too); only `from` was checked. One value such as `"bob@example.com <attacker@evil.test>, x@example.com"`, a group (`"undisclosed: a@evil.test; b@x.com"`, `"a@evil.test:b@x.com"`) or `{ address: "bob@example.com\r\nBcc: …" }` mailed other recipients than the ones an allowlist checked. Addresses may not contain whitespace, control characters or `<>()[]\,;:"` and need exactly one `@`; `replyTo` takes one recipient. `checkRecipients()` is exported. Mailgun cuts the `subject` at a CR/LF, as it does header values.
12
+ - `web-kit` email: `EmailFeature`'s adapter checks recipients with mailer-kit's rules (object recipients were tested as `"[object Object]"`), and rejects through the returned promise instead of throwing synchronously.
13
+ - `kv-kit`: the `KVAdapter` contract gains an optional `clear(prefix?)` and expiring sets (`sadd(key, member, ttl?)`, `sdrain(key)`), implemented by both adapters and `KVManager`. `KVManager.clear()` deletes its namespace's keys; with Redis it uses `SCAN` + `DEL` (within ioredis' `keyPrefix` too) and, without a namespace, refuses to empty the whole database unless `new KVManager({ flushDb: true })`. Expiring sets are sorted sets scored by expiry, updated by one atomic script: cache-kit's tag index. The memory adapter stores and returns copies (`structuredClone`), as Redis does (**breaking** for values that cannot be cloned, such as functions): it returned the stored object itself, so one request's mutation showed up in every other.
14
+ - `cache-kit` (**breaking**): `clear()` runs the adapter's `clear()` with the cache's namespace instead of `disconnect()`/`connect()` of the shared adapter, which on Redis deleted nothing (cached permissions stayed), failed concurrent operations meanwhile, and left the adapter dead when the reconnect failed during a Redis blip. A namespaced cache now clears its own entries (it used to throw); an adapter without `clear()` makes it throw. The tag index is kv-kit's expiring set when the adapter has one (one atomic `sadd` per tagged `set()`; each one read and rewrote the whole index, and the last 10k of 40k tagged sets took 30 s), and otherwise a JSON list that drops expired keys, expires with its last entry and keeps at most 10,000 (the oldest are deleted with their data); indexes written before are still drained by `invalidateTag()`. A value with a `__proto__`/`constructor`/`prototype` key is a miss (deleted when read; not stored by `set()`), so `remember()` refetches instead of every read throwing until the TTL ran out. Data keys and namespaces containing `__cache_tag__:`/`__cache_tags__:` (at the start or after a `:`) are rejected: a caller-chosen key could rewrite a tag index, and `invalidateTag()` deleted whatever it listed.
15
+ - `db-kit` (**breaking**): `MigrationHelper` and the CLI run only the drizzle-kit installed in the project (`node_modules/.bin` of the working directory or a parent), with `bunx --no-install drizzle-kit`, and fail with a `MigrationError` where it is not installed. drizzle-kit is a devDependency, so in a production install `bunx drizzle-kit` downloaded its latest release from npm and ran it with `DATABASE_URL` in its environment.
16
+
17
+ ### Patch Changes
18
+
19
+ - 7e89103: Concurrent `set()` calls with the same tag no longer lose keys from the tag index (within a process), so `invalidateTag()` deletes them all. The prototype-pollution guard also catches an escaped `"__proto__"` key. `remember()` rethrows the fallback's own error instead of wrapping it in a plain `Error`. The memory adapter keeps entries whose TTL exceeds `setTimeout`'s limit, and a negative TTL is rejected.
20
+ - 98e05ef: When indexing a tagged entry's tags fails, `set()` deletes the entry it just wrote before rejecting. The entry used to stay cached without being listed under its tags, so `invalidateTag()` never removed it.
21
+ - 840439a: Packages declare the runtime they are tested on: `engines.bun` `>=1.3.0` (the monorepo now builds and tests on Bun 1.3). `create-iskra`, a CLI that also runs under `npm create iskra`, declares `engines.node` `>=18`.
22
+
23
+ Every package is published with an npm provenance attestation (`publishConfig.provenance`), linking each version to the commit and CI run that built it.
24
+
25
+ - Updated dependencies [620da18]
26
+ - Updated dependencies [b635a2c]
27
+ - Updated dependencies [5b2b0fd]
28
+ - Updated dependencies [58d4a8f]
29
+ - Updated dependencies [5c70c5b]
30
+ - Updated dependencies [ec198d4]
31
+ - Updated dependencies [cb3ec43]
32
+ - Updated dependencies [ef2009b]
33
+ - Updated dependencies [840439a]
34
+ - Updated dependencies [cb3ec43]
35
+ - Updated dependencies [620da18]
36
+ - Updated dependencies [87f6de2]
37
+ - Updated dependencies [58d4a8f]
38
+ - Updated dependencies [dbf8817]
39
+ - Updated dependencies [c4ff1e3]
40
+ - Updated dependencies [7e89103]
41
+ - Updated dependencies [3dc5581]
42
+ - Updated dependencies [9872d30]
43
+ - Updated dependencies [9bb254d]
44
+ - Updated dependencies [f2346f5]
45
+ - Updated dependencies [938dd41]
46
+ - Updated dependencies [3579944]
47
+ - @iskra-bun/core@0.2.0
48
+ - @iskra-bun/kv-kit@0.3.0
49
+
3
50
  ## 0.1.0
4
51
 
5
52
  ### Minor Changes
package/README.md CHANGED
@@ -44,7 +44,7 @@ await cache.invalidateTag('items'); // elimina item:1 e item:2
44
44
 
45
45
  ## Documentacion
46
46
 
47
- Guia completa: [docs/cache-kit.md](../../docs/cache-kit.md)
47
+ Guia completa: [@iskra-bun/cache-kit](https://iskra-docs.fly.dev/es/packages/cache-kit/)
48
48
 
49
49
  ## Licencia
50
50
 
package/dist/index.d.ts CHANGED
@@ -48,10 +48,12 @@ declare class Cache {
48
48
  private readonly defaultTtl;
49
49
  constructor(adapter?: KVAdapter, options?: CacheOptions);
50
50
  private prefixKey;
51
- private tagIndexKey;
51
+ private tagSetKey;
52
+ private tagListKey;
52
53
  /**
53
54
  * Retrieve a cached value by key.
54
- * Returns `undefined` when the key is absent or has expired.
55
+ * Returns `undefined` when the key is absent or has expired, or when the
56
+ * stored value carries a prototype-pollution key (the entry is deleted).
55
57
  */
56
58
  get<T = unknown>(key: string): Promise<T | undefined>;
57
59
  /**
@@ -72,16 +74,16 @@ declare class Cache {
72
74
  */
73
75
  delete(key: string): Promise<void>;
74
76
  /**
75
- * Flush the **entire** backing store shared by this Cache and every other
76
- * Cache built on the same adapter.
77
+ * Delete every entry of this cache: its namespace (with the namespaces and
78
+ * tag indexes under it), or all the adapter's keys for a root cache. It
79
+ * runs the adapter's `clear()`: a KVManager clears its own namespace, and
80
+ * on Redis never the whole database unless `flushDb` allows it.
77
81
  *
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.
82
+ * It used to recycle the adapter (disconnect + connect): on Redis that
83
+ * deleted nothing, and a reconnect failing during a blip left the shared
84
+ * adapter dead after Redis recovered.
83
85
  *
84
- * @throws Error when called on a namespaced Cache (a prefix is set).
86
+ * @throws Error when the adapter has no `clear()`.
85
87
  */
86
88
  clear(): Promise<void>;
87
89
  /**
@@ -89,6 +91,8 @@ declare class Cache {
89
91
  * store the result with the given TTL, and return it.
90
92
  *
91
93
  * The fallback is called **exactly once** on a cache miss — never on a hit.
94
+ * An error it throws is rethrown as is (so its class, status or code still
95
+ * reach the caller's error handling) and nothing is cached.
92
96
  *
93
97
  * @param key Cache key.
94
98
  * @param ttl TTL in seconds for the stored value.
@@ -104,6 +108,9 @@ declare class Cache {
104
108
  * index entry is removed. Entries carrying other tags are unaffected.
105
109
  */
106
110
  invalidateTag(tag: string): Promise<void>;
111
+ private drainList;
112
+ /** Deletes the entries of `keys` (relative to this cache), in batches; never a tag index. */
113
+ private deleteKeys;
107
114
  /**
108
115
  * Return a new `Cache` sharing the same backing adapter but with an
109
116
  * additional namespace prefix, preventing key collisions between modules.
@@ -117,7 +124,22 @@ declare class Cache {
117
124
  */
118
125
  namespace(prefix: string): Cache;
119
126
  private resolveSetOptions;
127
+ /**
128
+ * Adds `key` to each tag's index, for as long as the entry lives. With an
129
+ * adapter that has `sadd` it is one atomic step per tag, whatever the size
130
+ * of the index. Otherwise a JSON list is rewritten, one set() at a time per
131
+ * index key (concurrent set() calls used to both read the old index and
132
+ * one key was lost); that lock is per-process, so instances sharing a store
133
+ * can still race (see the docs).
134
+ */
120
135
  private indexTags;
136
+ /**
137
+ * Rewriting the whole list on every set() made each one slower than the
138
+ * last, and expired keys were never dropped: they are now, the list expires
139
+ * with its last entry, and past MAX_TAG_LIST the oldest entries are deleted
140
+ * with their data (a deleted entry needs no invalidating).
141
+ */
142
+ private addToList;
121
143
  }
122
144
 
123
145
  export { Cache, type CacheOptions, type SetOptions };
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // src/memory-adapter.ts
2
+ var MAX_TIMEOUT_MS = 2 ** 31 - 1;
2
3
  var MemoryAdapter = class {
3
4
  id = "memory";
4
5
  store = /* @__PURE__ */ new Map();
6
+ sets = /* @__PURE__ */ new Map();
5
7
  timers = /* @__PURE__ */ new Map();
6
8
  connect() {
7
9
  }
@@ -9,25 +11,36 @@ var MemoryAdapter = class {
9
11
  for (const timer of this.timers.values()) clearTimeout(timer);
10
12
  this.timers = /* @__PURE__ */ new Map();
11
13
  this.store = /* @__PURE__ */ new Map();
14
+ this.sets = /* @__PURE__ */ new Map();
12
15
  }
13
16
  async get(key) {
14
17
  return this.store.get(key);
15
18
  }
16
19
  async set(key, value, ttl) {
17
20
  this.clearTimer(key);
21
+ this.sets.delete(key);
18
22
  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
- }
23
+ if (ttl && ttl > 0 && Number.isFinite(ttl)) this.expireIn(key, ttl * 1e3);
24
+ }
25
+ /** Arms the expiry timer, in steps when the delay exceeds setTimeout's limit. */
26
+ expireIn(key, ms) {
27
+ const step = Math.min(ms, MAX_TIMEOUT_MS);
28
+ const timer = setTimeout(() => {
29
+ if (ms > step) {
30
+ this.expireIn(key, ms - step);
31
+ return;
32
+ }
33
+ this.store.delete(key);
34
+ this.sets.delete(key);
35
+ this.timers.delete(key);
36
+ }, step);
37
+ timer.unref?.();
38
+ this.timers.set(key, timer);
27
39
  }
28
40
  async del(key) {
29
41
  this.clearTimer(key);
30
42
  this.store.delete(key);
43
+ this.sets.delete(key);
31
44
  }
32
45
  clearTimer(key) {
33
46
  const existing = this.timers.get(key);
@@ -37,30 +50,104 @@ var MemoryAdapter = class {
37
50
  }
38
51
  }
39
52
  async has(key) {
40
- return this.store.has(key);
53
+ return this.store.has(key) || this.sets.has(key);
54
+ }
55
+ async clear(prefix = "") {
56
+ for (const key of [...this.store.keys(), ...this.sets.keys()]) {
57
+ if (key.startsWith(prefix)) await this.del(key);
58
+ }
59
+ }
60
+ async sadd(key, member, ttl) {
61
+ const now = Date.now();
62
+ const expiresAt = ttl && ttl > 0 && Number.isFinite(ttl) ? now + ttl * 1e3 : Infinity;
63
+ let set = this.sets.get(key);
64
+ if (!set) {
65
+ this.store.delete(key);
66
+ set = { members: /* @__PURE__ */ new Map(), expiresAt: 0, pruneAt: 64 };
67
+ this.sets.set(key, set);
68
+ }
69
+ set.members.set(member, Math.max(set.members.get(member) ?? 0, expiresAt));
70
+ if (set.members.size >= set.pruneAt) {
71
+ for (const [name, at] of set.members) if (at <= now) set.members.delete(name);
72
+ set.pruneAt = Math.max(64, set.members.size * 2);
73
+ }
74
+ if (expiresAt > set.expiresAt) {
75
+ set.expiresAt = expiresAt;
76
+ this.clearTimer(key);
77
+ if (expiresAt !== Infinity) this.expireIn(key, expiresAt - now);
78
+ }
79
+ }
80
+ async sdrain(key) {
81
+ const set = this.sets.get(key);
82
+ if (!set) return [];
83
+ this.sets.delete(key);
84
+ this.clearTimer(key);
85
+ const now = Date.now();
86
+ return [...set.members].filter(([, at]) => at > now).map(([name]) => name);
41
87
  }
42
88
  };
43
89
 
44
90
  // src/cache.ts
45
- var TAG_INDEX_PREFIX = "__cache_tag__:";
91
+ var TAG_SET_PREFIX = "__cache_tags__:";
92
+ var TAG_LIST_PREFIX = "__cache_tag__:";
93
+ var RESERVED = /(?:^|:)__cache_tags?__:/;
94
+ var MAX_TAG_LIST = 1e4;
95
+ var DELETE_BATCH = 1e3;
46
96
  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
- }
97
+ var DANGEROUS_KEY_TEXT = /"(?:__proto__|constructor|prototype)":/;
98
+ var PollutionError = class extends Error {
99
+ };
100
+ var indexLocks = /* @__PURE__ */ new WeakMap();
101
+ function withLock(adapter, key, fn) {
102
+ let locks = indexLocks.get(adapter);
103
+ if (!locks) indexLocks.set(adapter, locks = /* @__PURE__ */ new Map());
104
+ const previous = locks.get(key) ?? Promise.resolve();
105
+ const result = previous.then(fn, fn);
106
+ const settled = result.then(
107
+ () => void 0,
108
+ () => void 0
109
+ );
110
+ locks.set(key, settled);
111
+ void settled.then(() => {
112
+ if (locks.get(key) === settled) locks.delete(key);
113
+ });
114
+ return result;
115
+ }
116
+ function parseSafely(raw) {
117
+ return JSON.parse(raw, function(key, value) {
118
+ if (DANGEROUS_KEYS.includes(key)) {
119
+ throw new PollutionError(
120
+ `cache-kit: refusing to deserialize value containing the unsafe key "${key}" (prototype-pollution risk).`
121
+ );
55
122
  }
123
+ return value;
124
+ });
125
+ }
126
+ function isPolluted(serialized) {
127
+ if (serialized === void 0 || !DANGEROUS_KEY_TEXT.test(serialized)) return false;
128
+ try {
129
+ parseSafely(serialized);
130
+ return false;
131
+ } catch (error) {
132
+ return error instanceof PollutionError;
56
133
  }
57
134
  }
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
- );
135
+ function parseTagList(raw) {
136
+ if (typeof raw !== "string") return [];
137
+ let list;
138
+ try {
139
+ list = JSON.parse(raw);
140
+ } catch {
141
+ return [];
142
+ }
143
+ if (!Array.isArray(list)) return [];
144
+ return list.flatMap((entry) => {
145
+ if (typeof entry === "string") return [[entry, 0]];
146
+ if (Array.isArray(entry) && typeof entry[0] === "string" && typeof entry[1] === "number") {
147
+ return [[entry[0], entry[1]]];
148
+ }
149
+ return [];
150
+ });
64
151
  }
65
152
  var Cache = class _Cache {
66
153
  adapter;
@@ -70,37 +157,47 @@ var Cache = class _Cache {
70
157
  this.adapter = adapter ?? new MemoryAdapter();
71
158
  this.prefix = options.namespace ? `${options.namespace}:` : "";
72
159
  this.defaultTtl = options.defaultTtl;
160
+ if (RESERVED.test(this.prefix)) {
161
+ throw new Error(`cache-kit: the namespace "${options.namespace}" is reserved for tag indexes`);
162
+ }
73
163
  }
74
164
  // -------------------------------------------------------------------------
75
165
  // Key helpers
76
166
  // -------------------------------------------------------------------------
77
167
  prefixKey(key) {
78
- return `${this.prefix}${key}`;
168
+ const full = `${this.prefix}${key}`;
169
+ if (RESERVED.test(full)) throw new Error(`cache-kit: the key "${key}" is reserved for tag indexes`);
170
+ return full;
79
171
  }
80
- tagIndexKey(tag) {
81
- return `${this.prefix}${TAG_INDEX_PREFIX}${tag}`;
172
+ tagSetKey(tag) {
173
+ return `${this.prefix}${TAG_SET_PREFIX}${tag}`;
174
+ }
175
+ tagListKey(tag) {
176
+ return `${this.prefix}${TAG_LIST_PREFIX}${tag}`;
82
177
  }
83
178
  // -------------------------------------------------------------------------
84
179
  // Core API
85
180
  // -------------------------------------------------------------------------
86
181
  /**
87
182
  * Retrieve a cached value by key.
88
- * Returns `undefined` when the key is absent or has expired.
183
+ * Returns `undefined` when the key is absent or has expired, or when the
184
+ * stored value carries a prototype-pollution key (the entry is deleted).
89
185
  */
90
186
  async get(key) {
91
- const raw = await this.adapter.get(this.prefixKey(key));
187
+ const dataKey = this.prefixKey(key);
188
+ const raw = await this.adapter.get(dataKey);
92
189
  if (raw === void 0 || raw === null) return void 0;
93
- const text = raw;
94
- let parsed;
95
190
  try {
96
- parsed = JSON.parse(text);
97
- } catch {
191
+ return parseSafely(raw);
192
+ } catch (error) {
193
+ if (error instanceof PollutionError) {
194
+ await this.adapter.del(dataKey);
195
+ return void 0;
196
+ }
98
197
  throw new Error(
99
198
  `cache-kit: failed to deserialize value for key "${key}". The stored value is not valid JSON.`
100
199
  );
101
200
  }
102
- assertNoPollution(text, parsed);
103
- return parsed;
104
201
  }
105
202
  /**
106
203
  * Store a value under key, optionally with a TTL and/or tags.
@@ -112,11 +209,27 @@ var Cache = class _Cache {
112
209
  */
113
210
  async set(key, value, options) {
114
211
  const { ttl, tags } = this.resolveSetOptions(options);
212
+ const dataKey = this.prefixKey(key);
115
213
  const serialized = JSON.stringify(value);
116
214
  const effectiveTtl = ttl ?? this.defaultTtl;
117
- await this.adapter.set(this.prefixKey(key), serialized, effectiveTtl);
215
+ if (effectiveTtl !== void 0 && effectiveTtl !== 0 && !(effectiveTtl > 0 && Number.isFinite(effectiveTtl))) {
216
+ throw new RangeError(
217
+ `cache-kit: invalid TTL ${String(effectiveTtl)}: expected a positive number of seconds`
218
+ );
219
+ }
220
+ if (isPolluted(serialized)) {
221
+ await this.adapter.del(dataKey);
222
+ return;
223
+ }
224
+ await this.adapter.set(dataKey, serialized, effectiveTtl);
118
225
  if (tags && tags.length > 0) {
119
- await this.indexTags(key, tags);
226
+ try {
227
+ await this.indexTags(key, tags, effectiveTtl);
228
+ } catch (err) {
229
+ await this.adapter.del(dataKey).catch(() => {
230
+ });
231
+ throw err;
232
+ }
120
233
  }
121
234
  }
122
235
  /**
@@ -132,26 +245,22 @@ var Cache = class _Cache {
132
245
  await this.adapter.del(this.prefixKey(key));
133
246
  }
134
247
  /**
135
- * Flush the **entire** backing store shared by this Cache and every other
136
- * Cache built on the same adapter.
248
+ * Delete every entry of this cache: its namespace (with the namespaces and
249
+ * tag indexes under it), or all the adapter's keys for a root cache. It
250
+ * runs the adapter's `clear()`: a KVManager clears its own namespace, and
251
+ * on Redis never the whole database unless `flushDb` allows it.
137
252
  *
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.
253
+ * It used to recycle the adapter (disconnect + connect): on Redis that
254
+ * deleted nothing, and a reconnect failing during a blip left the shared
255
+ * adapter dead after Redis recovered.
143
256
  *
144
- * @throws Error when called on a namespaced Cache (a prefix is set).
257
+ * @throws Error when the adapter has no `clear()`.
145
258
  */
146
259
  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
- );
260
+ if (!this.adapter.clear) {
261
+ throw new Error(`cache-kit: the "${this.adapter.id}" adapter cannot clear its keys (it has no clear())`);
152
262
  }
153
- await this.adapter.disconnect();
154
- await this.adapter.connect();
263
+ await this.adapter.clear(this.prefix);
155
264
  }
156
265
  // -------------------------------------------------------------------------
157
266
  // Cache-aside (remember / wrap)
@@ -161,6 +270,8 @@ var Cache = class _Cache {
161
270
  * store the result with the given TTL, and return it.
162
271
  *
163
272
  * The fallback is called **exactly once** on a cache miss — never on a hit.
273
+ * An error it throws is rethrown as is (so its class, status or code still
274
+ * reach the caller's error handling) and nothing is cached.
164
275
  *
165
276
  * @param key Cache key.
166
277
  * @param ttl TTL in seconds for the stored value.
@@ -169,14 +280,7 @@ var Cache = class _Cache {
169
280
  async remember(key, ttl, fallback) {
170
281
  const cached = await this.get(key);
171
282
  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
- }
283
+ const value = await fallback();
180
284
  await this.set(key, value, { ttl });
181
285
  return value;
182
286
  }
@@ -192,19 +296,27 @@ var Cache = class _Cache {
192
296
  * index entry is removed. Entries carrying other tags are unaffected.
193
297
  */
194
298
  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 = [];
299
+ const listKey = this.tagListKey(tag);
300
+ await withLock(this.adapter, listKey, async () => {
301
+ const keys = await this.drainList(listKey);
302
+ if (this.adapter.sdrain) keys.push(...await this.adapter.sdrain(this.tagSetKey(tag)));
303
+ await this.deleteKeys(keys);
304
+ });
305
+ }
306
+ async drainList(listKey) {
307
+ const raw = await this.adapter.get(listKey);
308
+ if (raw === null || raw === void 0) return [];
309
+ await this.adapter.del(listKey);
310
+ return parseTagList(raw).map(([key]) => key);
311
+ }
312
+ /** Deletes the entries of `keys` (relative to this cache), in batches; never a tag index. */
313
+ async deleteKeys(keys) {
314
+ const full = [...new Set(keys)].map((key) => `${this.prefix}${key}`).filter((key) => !RESERVED.test(key));
315
+ for (let i = 0; i < full.length; i += DELETE_BATCH) {
316
+ const batch = full.slice(i, i + DELETE_BATCH);
317
+ if (this.adapter.mdel) await this.adapter.mdel(batch);
318
+ else await Promise.all(batch.map((key) => this.adapter.del(key)));
203
319
  }
204
- await Promise.all([
205
- ...keys.map((k) => this.adapter.del(this.prefixKey(k))),
206
- this.adapter.del(indexKey)
207
- ]);
208
320
  }
209
321
  // -------------------------------------------------------------------------
210
322
  // Namespace helper
@@ -236,24 +348,44 @@ var Cache = class _Cache {
236
348
  if (typeof options === "number") return { ttl: options };
237
349
  return options;
238
350
  }
239
- async indexTags(key, tags) {
351
+ /**
352
+ * Adds `key` to each tag's index, for as long as the entry lives. With an
353
+ * adapter that has `sadd` it is one atomic step per tag, whatever the size
354
+ * of the index. Otherwise a JSON list is rewritten, one set() at a time per
355
+ * index key (concurrent set() calls used to both read the old index and
356
+ * one key was lost); that lock is per-process, so instances sharing a store
357
+ * can still race (see the docs).
358
+ */
359
+ async indexTags(key, tags, ttl) {
360
+ const adapter = this.adapter;
240
361
  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));
362
+ tags.map((tag) => {
363
+ if (adapter.sadd && adapter.sdrain) return adapter.sadd(this.tagSetKey(tag), key, ttl || void 0);
364
+ const listKey = this.tagListKey(tag);
365
+ return withLock(adapter, listKey, () => this.addToList(listKey, key, ttl));
254
366
  })
255
367
  );
256
368
  }
369
+ /**
370
+ * Rewriting the whole list on every set() made each one slower than the
371
+ * last, and expired keys were never dropped: they are now, the list expires
372
+ * with its last entry, and past MAX_TAG_LIST the oldest entries are deleted
373
+ * with their data (a deleted entry needs no invalidating).
374
+ */
375
+ async addToList(listKey, key, ttl) {
376
+ const now = Date.now();
377
+ const expiresAt = ttl ? now + ttl * 1e3 : 0;
378
+ const entries = parseTagList(await this.adapter.get(listKey)).filter(([, at]) => at === 0 || at > now);
379
+ const existing = entries.find(([listed]) => listed === key);
380
+ if (!existing) entries.push([key, expiresAt]);
381
+ else if (existing[1] !== 0) existing[1] = expiresAt === 0 ? 0 : Math.max(existing[1], expiresAt);
382
+ const evicted = entries.length > MAX_TAG_LIST ? entries.splice(0, entries.length - MAX_TAG_LIST) : [];
383
+ await this.deleteKeys(evicted.map(([listed]) => listed));
384
+ const forever = entries.some(([, at]) => at === 0);
385
+ const last = entries.reduce((max, [, at]) => Math.max(max, at), 0);
386
+ const listTtl = forever ? void 0 : (last - now) / 1e3;
387
+ await this.adapter.set(listKey, JSON.stringify(entries), listTtl);
388
+ }
257
389
  };
258
390
  export {
259
391
  Cache
package/dist/index.js.map CHANGED
@@ -1 +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":";AASO,IAAM,gBAAN,MAAyC;AAAA,EACnC,KAAK;AAAA,EACN,QAAQ,oBAAI,IAAqB;AAAA,EACjC,SAAS,oBAAI,IAA2C;AAAA,EAEhE,UAAgB;AAAA,EAEhB;AAAA,EAEA,aAAmB;AACf,eAAW,SAAS,KAAK,OAAO,OAAO,EAAG,cAAa,KAAK;AAC5D,SAAK,SAAS,oBAAI,IAAI;AACtB,SAAK,QAAQ,oBAAI,IAAI;AAAA,EACzB;AAAA,EAEA,MAAM,IAAiB,KAAqC;AACxD,WAAO,KAAK,MAAM,IAAI,GAAG;AAAA,EAC7B;AAAA,EAEA,MAAM,IAAiB,KAAa,OAAU,KAA6B;AACvE,SAAK,WAAW,GAAG;AACnB,SAAK,MAAM,IAAI,KAAK,KAAK;AACzB,QAAI,KAAK;AACL,YAAM,QAAQ,WAAW,MAAM;AAC3B,aAAK,MAAM,OAAO,GAAG;AACrB,aAAK,OAAO,OAAO,GAAG;AAAA,MAC1B,GAAG,MAAM,GAAI;AACb,YAAM,QAAQ;AACd,WAAK,OAAO,IAAI,KAAK,KAAK;AAAA,IAC9B;AAAA,EACJ;AAAA,EAEA,MAAM,IAAI,KAA4B;AAClC,SAAK,WAAW,GAAG;AACnB,SAAK,MAAM,OAAO,GAAG;AAAA,EACzB;AAAA,EAEQ,WAAW,KAAmB;AAClC,UAAM,WAAW,KAAK,OAAO,IAAI,GAAG;AACpC,QAAI,UAAU;AACV,mBAAa,QAAQ;AACrB,WAAK,OAAO,OAAO,GAAG;AAAA,IAC1B;AAAA,EACJ;AAAA,EAEA,MAAM,IAAI,KAA+B;AACrC,WAAO,KAAK,MAAM,IAAI,GAAG;AAAA,EAC7B;AACJ;;;ACrDA,IAAM,mBAAmB;AAGzB,IAAM,iBAAiB,CAAC,aAAa,eAAe,WAAW;AAU/D,SAAS,kBAAkB,KAAa,OAAsB;AAC1D,MAAI,eAAe,KAAK,CAAC,MAAM,IAAI,SAAS,IAAI,CAAC,GAAG,CAAC,GAAG;AACpD,eAAW,OAAO,gBAAgB;AAC9B,UAAI,YAAY,OAAO,GAAG,GAAG;AACzB,cAAM,IAAI;AAAA,UACN,uEACe,GAAG;AAAA,QACtB;AAAA,MACJ;AAAA,IACJ;AAAA,EACJ;AACJ;AAGA,SAAS,YAAY,OAAgB,KAAsB;AACvD,MAAI,UAAU,QAAQ,OAAO,UAAU,SAAU,QAAO;AACxD,MAAI,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,EAAG,QAAO;AAC7D,SAAO,OAAO,OAAO,KAAgC,EAAE;AAAA,IAAK,CAAC,UACzD,YAAY,OAAO,GAAG;AAAA,EAC1B;AACJ;AA0BO,IAAM,QAAN,MAAM,OAAM;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,SAAqB,UAAwB,CAAC,GAAG;AACzD,SAAK,UAAU,WAAW,IAAI,cAAc;AAC5C,SAAK,SAAS,QAAQ,YAAY,GAAG,QAAQ,SAAS,MAAM;AAC5D,SAAK,aAAa,QAAQ;AAAA,EAC9B;AAAA;AAAA;AAAA;AAAA,EAMQ,UAAU,KAAqB;AACnC,WAAO,GAAG,KAAK,MAAM,GAAG,GAAG;AAAA,EAC/B;AAAA,EAEQ,YAAY,KAAqB;AACrC,WAAO,GAAG,KAAK,MAAM,GAAG,gBAAgB,GAAG,GAAG;AAAA,EAClD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAM,IAAiB,KAAqC;AACxD,UAAM,MAAM,MAAM,KAAK,QAAQ,IAAI,KAAK,UAAU,GAAG,CAAC;AACtD,QAAI,QAAQ,UAAa,QAAQ,KAAM,QAAO;AAE9C,UAAM,OAAO;AACb,QAAI;AACJ,QAAI;AACA,eAAS,KAAK,MAAM,IAAI;AAAA,IAC5B,QAAQ;AACJ,YAAM,IAAI;AAAA,QACN,mDAAmD,GAAG;AAAA,MAE1D;AAAA,IACJ;AAEA,sBAAkB,MAAM,MAAM;AAC9B,WAAO;AAAA,EACX;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAM,IAAO,KAAa,OAAU,SAA8C;AAC9E,UAAM,EAAE,KAAK,KAAK,IAAI,KAAK,kBAAkB,OAAO;AACpD,UAAM,aAAa,KAAK,UAAU,KAAK;AACvC,UAAM,eAAe,OAAO,KAAK;AAEjC,UAAM,KAAK,QAAQ,IAAI,KAAK,UAAU,GAAG,GAAG,YAAY,YAAY;AAEpE,QAAI,QAAQ,KAAK,SAAS,GAAG;AACzB,YAAM,KAAK,UAAU,KAAK,IAAI;AAAA,IAClC;AAAA,EACJ;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,IAAI,KAA+B;AACrC,WAAO,KAAK,QAAQ,IAAI,KAAK,UAAU,GAAG,CAAC;AAAA,EAC/C;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,OAAO,KAA4B;AACrC,UAAM,KAAK,QAAQ,IAAI,KAAK,UAAU,GAAG,CAAC;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAcA,MAAM,QAAuB;AACzB,QAAI,KAAK,WAAW,IAAI;AACpB,YAAM,YAAY,KAAK,OAAO,QAAQ,MAAM,EAAE;AAC9C,YAAM,IAAI;AAAA,QACN,2GAC6C,SAAS;AAAA,MAG1D;AAAA,IACJ;AACA,UAAM,KAAK,QAAQ,WAAW;AAC9B,UAAM,KAAK,QAAQ,QAAQ;AAAA,EAC/B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBA,MAAM,SAAY,KAAa,KAAa,UAAwC;AAChF,UAAM,SAAS,MAAM,KAAK,IAAO,GAAG;AACpC,QAAI,WAAW,OAAW,QAAO;AAEjC,QAAI;AACJ,QAAI;AACA,cAAQ,MAAM,SAAS;AAAA,IAC3B,SAAS,OAAO;AACZ,YAAM,IAAI;AAAA,QACN,2CAA2C,GAAG,YAAY,OAAO,KAAK,CAAC;AAAA,MAC3E;AAAA,IACJ;AAEA,UAAM,KAAK,IAAI,KAAK,OAAO,EAAE,IAAI,CAAC;AAClC,WAAO;AAAA,EACX;AAAA;AAAA,EAGS,OAAO,KAAK,SAAS,KAAK,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYvC,MAAM,cAAc,KAA4B;AAC5C,UAAM,WAAW,KAAK,YAAY,GAAG;AACrC,UAAM,MAAM,MAAM,KAAK,QAAQ,IAAI,QAAQ;AAC3C,QAAI,QAAQ,QAAQ,QAAQ,OAAW;AAEvC,QAAI;AACJ,QAAI;AACA,aAAO,KAAK,MAAM,GAAa;AAAA,IACnC,QAAQ;AACJ,aAAO,CAAC;AAAA,IACZ;AAEA,UAAM,QAAQ,IAAI;AAAA,MACd,GAAG,KAAK,IAAI,CAAC,MAAM,KAAK,QAAQ,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC;AAAA,MACtD,KAAK,QAAQ,IAAI,QAAQ;AAAA,IAC7B,CAAC;AAAA,EACL;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBA,UAAU,QAAuB;AAC7B,UAAM,WAAW,KAAK,OAAO,QAAQ,MAAM,EAAE;AAC7C,UAAM,iBAAiB,WAAW,GAAG,QAAQ,IAAI,MAAM,KAAK;AAC5D,WAAO,IAAI,OAAM,KAAK,SAAS;AAAA,MAC3B,WAAW;AAAA,MACX,YAAY,KAAK;AAAA,IACrB,CAAC;AAAA,EACL;AAAA;AAAA;AAAA;AAAA,EAMQ,kBAAkB,SAA2C;AACjE,QAAI,YAAY,OAAW,QAAO,CAAC;AACnC,QAAI,OAAO,YAAY,SAAU,QAAO,EAAE,KAAK,QAAQ;AACvD,WAAO;AAAA,EACX;AAAA,EAEA,MAAc,UAAU,KAAa,MAA+B;AAChE,UAAM,QAAQ;AAAA,MACV,KAAK,IAAI,OAAO,QAAQ;AACpB,cAAM,WAAW,KAAK,YAAY,GAAG;AACrC,cAAM,MAAM,MAAM,KAAK,QAAQ,IAAI,QAAQ;AAC3C,YAAI,OAAiB,CAAC;AACtB,YAAI,QAAQ,QAAQ,QAAQ,QAAW;AACnC,cAAI;AACA,mBAAO,KAAK,MAAM,GAAa;AAAA,UACnC,QAAQ;AACJ,mBAAO,CAAC;AAAA,UACZ;AAAA,QACJ;AACA,cAAM,UAAU,KAAK,SAAS,GAAG,IAAI,OAAO,CAAC,GAAG,MAAM,GAAG;AACzD,cAAM,KAAK,QAAQ,IAAI,UAAU,KAAK,UAAU,OAAO,CAAC;AAAA,MAC5D,CAAC;AAAA,IACL;AAAA,EACJ;AACJ;","names":[]}
1
+ {"version":3,"sources":["../src/memory-adapter.ts","../src/cache.ts"],"sourcesContent":["import type { KVAdapter } from './types';\n\n/** setTimeout's limit: a longer delay (a TTL over ~24.8 days) fires at once. */\nconst MAX_TIMEOUT_MS = 2 ** 31 - 1;\n\n/** An expiring set: each member's expiry (ms since the epoch, Infinity for none). */\ninterface ExpiringSet {\n members: Map<string, number>;\n expiresAt: number;\n /** Size at which expired members are next dropped. */\n pruneAt: number;\n}\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 sets = new Map<string, ExpiringSet>();\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 this.sets = 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.sets.delete(key);\n this.store.set(key, value);\n if (ttl && ttl > 0 && Number.isFinite(ttl)) this.expireIn(key, ttl * 1000);\n }\n\n /** Arms the expiry timer, in steps when the delay exceeds setTimeout's limit. */\n private expireIn(key: string, ms: number): void {\n const step = Math.min(ms, MAX_TIMEOUT_MS);\n const timer = setTimeout(() => {\n if (ms > step) {\n this.expireIn(key, ms - step);\n return;\n }\n this.store.delete(key);\n this.sets.delete(key);\n this.timers.delete(key);\n }, step);\n timer.unref?.();\n this.timers.set(key, timer);\n }\n\n async del(key: string): Promise<void> {\n this.clearTimer(key);\n this.store.delete(key);\n this.sets.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) || this.sets.has(key);\n }\n\n async clear(prefix = ''): Promise<void> {\n for (const key of [...this.store.keys(), ...this.sets.keys()]) {\n if (key.startsWith(prefix)) await this.del(key);\n }\n }\n\n async sadd(key: string, member: string, ttl?: number): Promise<void> {\n const now = Date.now();\n const expiresAt = ttl && ttl > 0 && Number.isFinite(ttl) ? now + ttl * 1000 : Infinity;\n let set = this.sets.get(key);\n if (!set) {\n this.store.delete(key);\n set = { members: new Map(), expiresAt: 0, pruneAt: 64 };\n this.sets.set(key, set);\n }\n // A member keeps its latest expiry, and expired members go each time\n // the set doubles (O(1) per add on average), as in kv-kit.\n set.members.set(member, Math.max(set.members.get(member) ?? 0, expiresAt));\n if (set.members.size >= set.pruneAt) {\n for (const [name, at] of set.members) if (at <= now) set.members.delete(name);\n set.pruneAt = Math.max(64, set.members.size * 2);\n }\n if (expiresAt > set.expiresAt) {\n set.expiresAt = expiresAt;\n this.clearTimer(key);\n if (expiresAt !== Infinity) this.expireIn(key, expiresAt - now);\n }\n }\n\n async sdrain(key: string): Promise<string[]> {\n const set = this.sets.get(key);\n if (!set) return [];\n this.sets.delete(key);\n this.clearTimer(key);\n const now = Date.now();\n return [...set.members].filter(([, at]) => at > now).map(([name]) => name);\n }\n}\n","import { MemoryAdapter } from './memory-adapter';\nimport type { KVAdapter, CacheOptions, SetOptions } from './types';\n\n/** A tag's index as an expiring set, with adapters that have `sadd`/`sdrain`. */\nconst TAG_SET_PREFIX = '__cache_tags__:';\n\n/** A tag's index as a JSON list: with other adapters, and every index written before 0.x. */\nconst TAG_LIST_PREFIX = '__cache_tag__:';\n\n/**\n * Data keys and namespaces may not reach the tag indexes: a key such as\n * `__cache_tag__:perms` overwrote the index, and invalidateTag('perms') then\n * deleted whatever keys it listed.\n */\nconst RESERVED = /(?:^|:)__cache_tags?__:/;\n\n/** Longest JSON tag index: beyond it, the oldest entries are deleted along with their data. */\nconst MAX_TAG_LIST = 10_000;\n\n/** Keys deleted per call when a tag is invalidated. */\nconst DELETE_BATCH = 1000;\n\n/** Keys that enable prototype-pollution when an object is later deep-merged. */\nconst DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] as const;\n\n/** One of them as a key in JSON.stringify's output, which escapes none of their characters. */\nconst DANGEROUS_KEY_TEXT = /\"(?:__proto__|constructor|prototype)\":/;\n\nclass PollutionError extends Error {}\n\n/** A JSON tag index entry: the key and its expiry (ms since the epoch, 0 for none). */\ntype TagEntry = [key: string, expiresAt: number];\n\n/** Pending tag-index updates per adapter (shared by its namespaced caches) and key. */\nconst indexLocks = new WeakMap<object, Map<string, Promise<unknown>>>();\n\n/** Runs `fn` after the previous `fn` for the same adapter and key has settled. */\nfunction withLock<T>(adapter: object, key: string, fn: () => Promise<T>): Promise<T> {\n let locks = indexLocks.get(adapter);\n if (!locks) indexLocks.set(adapter, (locks = new Map()));\n const previous = locks.get(key) ?? Promise.resolve();\n const result = previous.then(fn, fn);\n const settled = result.then(\n () => undefined,\n () => undefined,\n );\n locks.set(key, settled);\n void settled.then(() => {\n if (locks.get(key) === settled) locks.delete(key);\n });\n return result;\n}\n\n/**\n * Parses cached JSON, throwing if any object in it carries a\n * prototype-pollution key. Cached payloads can originate from untrusted\n * writers; rejecting these keys at the parse boundary stops a malicious value\n * from reaching code that deep-merges it. The reviver sees every key as\n * parsed, so an escaped spelling such as `\"\\u005f_proto__\"` is caught too\n * (a check of the raw text for `\"__proto__\"` missed it).\n */\nfunction parseSafely(raw: string): unknown {\n return JSON.parse(raw, function (key, value) {\n if ((DANGEROUS_KEYS as readonly string[]).includes(key)) {\n throw new PollutionError(\n `cache-kit: refusing to deserialize value containing the ` +\n `unsafe key \"${key}\" (prototype-pollution risk).`,\n );\n }\n return value;\n });\n}\n\n/** Whether get() would refuse this serialized value (see parseSafely). */\nfunction isPolluted(serialized: string | undefined): boolean {\n if (serialized === undefined || !DANGEROUS_KEY_TEXT.test(serialized)) return false;\n try {\n parseSafely(serialized);\n return false;\n } catch (error) {\n return error instanceof PollutionError;\n }\n}\n\n/** A JSON tag index; before 0.x its entries were bare keys, with no expiry. */\nfunction parseTagList(raw: unknown): TagEntry[] {\n if (typeof raw !== 'string') return [];\n let list: unknown;\n try {\n list = JSON.parse(raw);\n } catch {\n return [];\n }\n if (!Array.isArray(list)) return [];\n return list.flatMap((entry): TagEntry[] => {\n if (typeof entry === 'string') return [[entry, 0]];\n if (Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'number') {\n return [[entry[0], entry[1]]];\n }\n return [];\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 if (RESERVED.test(this.prefix)) {\n throw new Error(`cache-kit: the namespace \"${options.namespace}\" is reserved for tag indexes`);\n }\n }\n\n // -------------------------------------------------------------------------\n // Key helpers\n // -------------------------------------------------------------------------\n\n private prefixKey(key: string): string {\n const full = `${this.prefix}${key}`;\n if (RESERVED.test(full)) throw new Error(`cache-kit: the key \"${key}\" is reserved for tag indexes`);\n return full;\n }\n\n private tagSetKey(tag: string): string {\n return `${this.prefix}${TAG_SET_PREFIX}${tag}`;\n }\n\n private tagListKey(tag: string): string {\n return `${this.prefix}${TAG_LIST_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, or when the\n * stored value carries a prototype-pollution key (the entry is deleted).\n */\n async get<T = unknown>(key: string): Promise<T | undefined> {\n const dataKey = this.prefixKey(key);\n const raw = await this.adapter.get(dataKey);\n if (raw === undefined || raw === null) return undefined;\n\n try {\n return parseSafely(raw as string) as T;\n } catch (error) {\n if (error instanceof PollutionError) {\n // A miss: thrown, it failed every read until the TTL ran out\n // (never without one), and remember() did not refetch.\n await this.adapter.del(dataKey);\n return undefined;\n }\n throw new Error(\n `cache-kit: failed to deserialize value for key \"${key}\". ` + 'The stored value is not valid JSON.',\n );\n }\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 dataKey = this.prefixKey(key);\n const serialized = JSON.stringify(value);\n const effectiveTtl = ttl ?? this.defaultTtl;\n if (effectiveTtl !== undefined && effectiveTtl !== 0 && !(effectiveTtl > 0 && Number.isFinite(effectiveTtl))) {\n throw new RangeError(\n `cache-kit: invalid TTL ${String(effectiveTtl)}: expected a positive number of seconds`,\n );\n }\n\n // get() could never return it: nothing is stored (and the old value goes).\n if (isPolluted(serialized)) {\n await this.adapter.del(dataKey);\n return;\n }\n\n await this.adapter.set(dataKey, serialized, effectiveTtl);\n\n if (tags && tags.length > 0) {\n try {\n await this.indexTags(key, tags, effectiveTtl);\n } catch (err) {\n // An entry its tags do not list would outlive invalidateTag():\n // drop it and report the failure.\n await this.adapter.del(dataKey).catch(() => {});\n throw err;\n }\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 * Delete every entry of this cache: its namespace (with the namespaces and\n * tag indexes under it), or all the adapter's keys for a root cache. It\n * runs the adapter's `clear()`: a KVManager clears its own namespace, and\n * on Redis never the whole database unless `flushDb` allows it.\n *\n * It used to recycle the adapter (disconnect + connect): on Redis that\n * deleted nothing, and a reconnect failing during a blip left the shared\n * adapter dead after Redis recovered.\n *\n * @throws Error when the adapter has no `clear()`.\n */\n async clear(): Promise<void> {\n if (!this.adapter.clear) {\n throw new Error(`cache-kit: the \"${this.adapter.id}\" adapter cannot clear its keys (it has no clear())`);\n }\n await this.adapter.clear(this.prefix);\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 * An error it throws is rethrown as is (so its class, status or code still\n * reach the caller's error handling) and nothing is cached.\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 const value = await fallback();\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 listKey = this.tagListKey(tag);\n await withLock(this.adapter, listKey, async () => {\n // The JSON list too with an adapter that has sets: entries tagged\n // before 0.x are listed there.\n const keys = await this.drainList(listKey);\n if (this.adapter.sdrain) keys.push(...(await this.adapter.sdrain(this.tagSetKey(tag))));\n await this.deleteKeys(keys);\n });\n }\n\n private async drainList(listKey: string): Promise<string[]> {\n const raw = await this.adapter.get(listKey);\n if (raw === null || raw === undefined) return [];\n await this.adapter.del(listKey);\n return parseTagList(raw).map(([key]) => key);\n }\n\n /** Deletes the entries of `keys` (relative to this cache), in batches; never a tag index. */\n private async deleteKeys(keys: string[]): Promise<void> {\n const full = [...new Set(keys)].map((key) => `${this.prefix}${key}`).filter((key) => !RESERVED.test(key));\n for (let i = 0; i < full.length; i += DELETE_BATCH) {\n const batch = full.slice(i, i + DELETE_BATCH);\n if (this.adapter.mdel) await this.adapter.mdel(batch);\n else await Promise.all(batch.map((key) => this.adapter.del(key)));\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 /**\n * Adds `key` to each tag's index, for as long as the entry lives. With an\n * adapter that has `sadd` it is one atomic step per tag, whatever the size\n * of the index. Otherwise a JSON list is rewritten, one set() at a time per\n * index key (concurrent set() calls used to both read the old index and\n * one key was lost); that lock is per-process, so instances sharing a store\n * can still race (see the docs).\n */\n private async indexTags(key: string, tags: string[], ttl: number | undefined): Promise<void> {\n const adapter = this.adapter;\n await Promise.all(\n tags.map((tag) => {\n if (adapter.sadd && adapter.sdrain) return adapter.sadd(this.tagSetKey(tag), key, ttl || undefined);\n const listKey = this.tagListKey(tag);\n return withLock(adapter, listKey, () => this.addToList(listKey, key, ttl));\n }),\n );\n }\n\n /**\n * Rewriting the whole list on every set() made each one slower than the\n * last, and expired keys were never dropped: they are now, the list expires\n * with its last entry, and past MAX_TAG_LIST the oldest entries are deleted\n * with their data (a deleted entry needs no invalidating).\n */\n private async addToList(listKey: string, key: string, ttl: number | undefined): Promise<void> {\n const now = Date.now();\n const expiresAt = ttl ? now + ttl * 1000 : 0;\n const entries = parseTagList(await this.adapter.get(listKey)).filter(([, at]) => at === 0 || at > now);\n const existing = entries.find(([listed]) => listed === key);\n if (!existing) entries.push([key, expiresAt]);\n // A key keeps its longest expiry: an earlier write may still be alive.\n else if (existing[1] !== 0) existing[1] = expiresAt === 0 ? 0 : Math.max(existing[1], expiresAt);\n\n const evicted = entries.length > MAX_TAG_LIST ? entries.splice(0, entries.length - MAX_TAG_LIST) : [];\n await this.deleteKeys(evicted.map(([listed]) => listed));\n\n // The list lives as long as its longest-lived entry (for good if one has no TTL).\n const forever = entries.some(([, at]) => at === 0);\n const last = entries.reduce((max, [, at]) => Math.max(max, at), 0);\n const listTtl = forever ? undefined : (last - now) / 1000;\n await this.adapter.set(listKey, JSON.stringify(entries), listTtl);\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iskra-bun/cache-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Cache de aplicación de alto nivel para Iskra, construido sobre @iskra-bun/kv-kit.",
5
5
  "keywords": [
6
6
  "iskra",
@@ -19,6 +19,9 @@
19
19
  },
20
20
  "homepage": "https://github.com/fearful/iskra/tree/main/packages/cache-kit#readme",
21
21
  "bugs": "https://github.com/fearful/iskra/issues",
22
+ "engines": {
23
+ "bun": ">=1.3.0"
24
+ },
22
25
  "type": "module",
23
26
  "main": "./dist/index.js",
24
27
  "module": "./dist/index.js",
@@ -46,8 +49,8 @@
46
49
  "build": "tsup --config ../../tsup.config.ts"
47
50
  },
48
51
  "dependencies": {
49
- "@iskra-bun/core": "0.1.1",
50
- "@iskra-bun/kv-kit": "0.2.0"
52
+ "@iskra-bun/core": "^0.2.0",
53
+ "@iskra-bun/kv-kit": "^0.3.0"
51
54
  },
52
55
  "devDependencies": {
53
56
  "@types/bun": "^1.3.5",
package/src/cache.ts CHANGED
@@ -1,40 +1,104 @@
1
1
  import { MemoryAdapter } from './memory-adapter';
2
2
  import type { KVAdapter, CacheOptions, SetOptions } from './types';
3
3
 
4
- /** Internal prefix used for the tag→keys index stored in the KV adapter. */
5
- const TAG_INDEX_PREFIX = '__cache_tag__:';
4
+ /** A tag's index as an expiring set, with adapters that have `sadd`/`sdrain`. */
5
+ const TAG_SET_PREFIX = '__cache_tags__:';
6
+
7
+ /** A tag's index as a JSON list: with other adapters, and every index written before 0.x. */
8
+ const TAG_LIST_PREFIX = '__cache_tag__:';
9
+
10
+ /**
11
+ * Data keys and namespaces may not reach the tag indexes: a key such as
12
+ * `__cache_tag__:perms` overwrote the index, and invalidateTag('perms') then
13
+ * deleted whatever keys it listed.
14
+ */
15
+ const RESERVED = /(?:^|:)__cache_tags?__:/;
16
+
17
+ /** Longest JSON tag index: beyond it, the oldest entries are deleted along with their data. */
18
+ const MAX_TAG_LIST = 10_000;
19
+
20
+ /** Keys deleted per call when a tag is invalidated. */
21
+ const DELETE_BATCH = 1000;
6
22
 
7
23
  /** Keys that enable prototype-pollution when an object is later deep-merged. */
8
24
  const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] as const;
9
25
 
26
+ /** One of them as a key in JSON.stringify's output, which escapes none of their characters. */
27
+ const DANGEROUS_KEY_TEXT = /"(?:__proto__|constructor|prototype)":/;
28
+
29
+ class PollutionError extends Error {}
30
+
31
+ /** A JSON tag index entry: the key and its expiry (ms since the epoch, 0 for none). */
32
+ type TagEntry = [key: string, expiresAt: number];
33
+
34
+ /** Pending tag-index updates per adapter (shared by its namespaced caches) and key. */
35
+ const indexLocks = new WeakMap<object, Map<string, Promise<unknown>>>();
36
+
37
+ /** Runs `fn` after the previous `fn` for the same adapter and key has settled. */
38
+ function withLock<T>(adapter: object, key: string, fn: () => Promise<T>): Promise<T> {
39
+ let locks = indexLocks.get(adapter);
40
+ if (!locks) indexLocks.set(adapter, (locks = new Map()));
41
+ const previous = locks.get(key) ?? Promise.resolve();
42
+ const result = previous.then(fn, fn);
43
+ const settled = result.then(
44
+ () => undefined,
45
+ () => undefined,
46
+ );
47
+ locks.set(key, settled);
48
+ void settled.then(() => {
49
+ if (locks.get(key) === settled) locks.delete(key);
50
+ });
51
+ return result;
52
+ }
53
+
10
54
  /**
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.
55
+ * Parses cached JSON, throwing if any object in it carries a
56
+ * prototype-pollution key. Cached payloads can originate from untrusted
57
+ * writers; rejecting these keys at the parse boundary stops a malicious value
58
+ * from reaching code that deep-merges it. The reviver sees every key as
59
+ * parsed, so an escaped spelling such as `"\u005f_proto__"` is caught too
60
+ * (a check of the raw text for `"__proto__"` missed it).
17
61
  */
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
- }
62
+ function parseSafely(raw: string): unknown {
63
+ return JSON.parse(raw, function (key, value) {
64
+ if ((DANGEROUS_KEYS as readonly string[]).includes(key)) {
65
+ throw new PollutionError(
66
+ `cache-kit: refusing to deserialize value containing the ` +
67
+ `unsafe key "${key}" (prototype-pollution risk).`,
68
+ );
27
69
  }
70
+ return value;
71
+ });
72
+ }
73
+
74
+ /** Whether get() would refuse this serialized value (see parseSafely). */
75
+ function isPolluted(serialized: string | undefined): boolean {
76
+ if (serialized === undefined || !DANGEROUS_KEY_TEXT.test(serialized)) return false;
77
+ try {
78
+ parseSafely(serialized);
79
+ return false;
80
+ } catch (error) {
81
+ return error instanceof PollutionError;
28
82
  }
29
83
  }
30
84
 
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
- );
85
+ /** A JSON tag index; before 0.x its entries were bare keys, with no expiry. */
86
+ function parseTagList(raw: unknown): TagEntry[] {
87
+ if (typeof raw !== 'string') return [];
88
+ let list: unknown;
89
+ try {
90
+ list = JSON.parse(raw);
91
+ } catch {
92
+ return [];
93
+ }
94
+ if (!Array.isArray(list)) return [];
95
+ return list.flatMap((entry): TagEntry[] => {
96
+ if (typeof entry === 'string') return [[entry, 0]];
97
+ if (Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'number') {
98
+ return [[entry[0], entry[1]]];
99
+ }
100
+ return [];
101
+ });
38
102
  }
39
103
 
40
104
  /**
@@ -70,6 +134,9 @@ export class Cache {
70
134
  this.adapter = adapter ?? new MemoryAdapter();
71
135
  this.prefix = options.namespace ? `${options.namespace}:` : '';
72
136
  this.defaultTtl = options.defaultTtl;
137
+ if (RESERVED.test(this.prefix)) {
138
+ throw new Error(`cache-kit: the namespace "${options.namespace}" is reserved for tag indexes`);
139
+ }
73
140
  }
74
141
 
75
142
  // -------------------------------------------------------------------------
@@ -77,11 +144,17 @@ export class Cache {
77
144
  // -------------------------------------------------------------------------
78
145
 
79
146
  private prefixKey(key: string): string {
80
- return `${this.prefix}${key}`;
147
+ const full = `${this.prefix}${key}`;
148
+ if (RESERVED.test(full)) throw new Error(`cache-kit: the key "${key}" is reserved for tag indexes`);
149
+ return full;
150
+ }
151
+
152
+ private tagSetKey(tag: string): string {
153
+ return `${this.prefix}${TAG_SET_PREFIX}${tag}`;
81
154
  }
82
155
 
83
- private tagIndexKey(tag: string): string {
84
- return `${this.prefix}${TAG_INDEX_PREFIX}${tag}`;
156
+ private tagListKey(tag: string): string {
157
+ return `${this.prefix}${TAG_LIST_PREFIX}${tag}`;
85
158
  }
86
159
 
87
160
  // -------------------------------------------------------------------------
@@ -90,25 +163,27 @@ export class Cache {
90
163
 
91
164
  /**
92
165
  * Retrieve a cached value by key.
93
- * Returns `undefined` when the key is absent or has expired.
166
+ * Returns `undefined` when the key is absent or has expired, or when the
167
+ * stored value carries a prototype-pollution key (the entry is deleted).
94
168
  */
95
169
  async get<T = unknown>(key: string): Promise<T | undefined> {
96
- const raw = await this.adapter.get(this.prefixKey(key));
170
+ const dataKey = this.prefixKey(key);
171
+ const raw = await this.adapter.get(dataKey);
97
172
  if (raw === undefined || raw === null) return undefined;
98
173
 
99
- const text = raw as string;
100
- let parsed: unknown;
101
174
  try {
102
- parsed = JSON.parse(text);
103
- } catch {
175
+ return parseSafely(raw as string) as T;
176
+ } catch (error) {
177
+ if (error instanceof PollutionError) {
178
+ // A miss: thrown, it failed every read until the TTL ran out
179
+ // (never without one), and remember() did not refetch.
180
+ await this.adapter.del(dataKey);
181
+ return undefined;
182
+ }
104
183
  throw new Error(
105
- `cache-kit: failed to deserialize value for key "${key}". ` +
106
- 'The stored value is not valid JSON.'
184
+ `cache-kit: failed to deserialize value for key "${key}". ` + 'The stored value is not valid JSON.',
107
185
  );
108
186
  }
109
-
110
- assertNoPollution(text, parsed);
111
- return parsed as T;
112
187
  }
113
188
 
114
189
  /**
@@ -121,13 +196,32 @@ export class Cache {
121
196
  */
122
197
  async set<T>(key: string, value: T, options?: number | SetOptions): Promise<void> {
123
198
  const { ttl, tags } = this.resolveSetOptions(options);
199
+ const dataKey = this.prefixKey(key);
124
200
  const serialized = JSON.stringify(value);
125
201
  const effectiveTtl = ttl ?? this.defaultTtl;
202
+ if (effectiveTtl !== undefined && effectiveTtl !== 0 && !(effectiveTtl > 0 && Number.isFinite(effectiveTtl))) {
203
+ throw new RangeError(
204
+ `cache-kit: invalid TTL ${String(effectiveTtl)}: expected a positive number of seconds`,
205
+ );
206
+ }
126
207
 
127
- await this.adapter.set(this.prefixKey(key), serialized, effectiveTtl);
208
+ // get() could never return it: nothing is stored (and the old value goes).
209
+ if (isPolluted(serialized)) {
210
+ await this.adapter.del(dataKey);
211
+ return;
212
+ }
213
+
214
+ await this.adapter.set(dataKey, serialized, effectiveTtl);
128
215
 
129
216
  if (tags && tags.length > 0) {
130
- await this.indexTags(key, tags);
217
+ try {
218
+ await this.indexTags(key, tags, effectiveTtl);
219
+ } catch (err) {
220
+ // An entry its tags do not list would outlive invalidateTag():
221
+ // drop it and report the failure.
222
+ await this.adapter.del(dataKey).catch(() => {});
223
+ throw err;
224
+ }
131
225
  }
132
226
  }
133
227
 
@@ -146,29 +240,22 @@ export class Cache {
146
240
  }
147
241
 
148
242
  /**
149
- * Flush the **entire** backing store shared by this Cache and every other
150
- * Cache built on the same adapter.
243
+ * Delete every entry of this cache: its namespace (with the namespaces and
244
+ * tag indexes under it), or all the adapter's keys for a root cache. It
245
+ * runs the adapter's `clear()`: a KVManager clears its own namespace, and
246
+ * on Redis never the whole database unless `flushDb` allows it.
151
247
  *
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.
248
+ * It used to recycle the adapter (disconnect + connect): on Redis that
249
+ * deleted nothing, and a reconnect failing during a blip left the shared
250
+ * adapter dead after Redis recovered.
157
251
  *
158
- * @throws Error when called on a namespaced Cache (a prefix is set).
252
+ * @throws Error when the adapter has no `clear()`.
159
253
  */
160
254
  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
- );
255
+ if (!this.adapter.clear) {
256
+ throw new Error(`cache-kit: the "${this.adapter.id}" adapter cannot clear its keys (it has no clear())`);
169
257
  }
170
- await this.adapter.disconnect();
171
- await this.adapter.connect();
258
+ await this.adapter.clear(this.prefix);
172
259
  }
173
260
 
174
261
  // -------------------------------------------------------------------------
@@ -180,6 +267,8 @@ export class Cache {
180
267
  * store the result with the given TTL, and return it.
181
268
  *
182
269
  * The fallback is called **exactly once** on a cache miss — never on a hit.
270
+ * An error it throws is rethrown as is (so its class, status or code still
271
+ * reach the caller's error handling) and nothing is cached.
183
272
  *
184
273
  * @param key Cache key.
185
274
  * @param ttl TTL in seconds for the stored value.
@@ -189,15 +278,7 @@ export class Cache {
189
278
  const cached = await this.get<T>(key);
190
279
  if (cached !== undefined) return cached;
191
280
 
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
-
281
+ const value = await fallback();
201
282
  await this.set(key, value, { ttl });
202
283
  return value;
203
284
  }
@@ -216,21 +297,31 @@ export class Cache {
216
297
  * index entry is removed. Entries carrying other tags are unaffected.
217
298
  */
218
299
  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;
300
+ const listKey = this.tagListKey(tag);
301
+ await withLock(this.adapter, listKey, async () => {
302
+ // The JSON list too with an adapter that has sets: entries tagged
303
+ // before 0.x are listed there.
304
+ const keys = await this.drainList(listKey);
305
+ if (this.adapter.sdrain) keys.push(...(await this.adapter.sdrain(this.tagSetKey(tag))));
306
+ await this.deleteKeys(keys);
307
+ });
308
+ }
222
309
 
223
- let keys: string[];
224
- try {
225
- keys = JSON.parse(raw as string) as string[];
226
- } catch {
227
- keys = [];
228
- }
310
+ private async drainList(listKey: string): Promise<string[]> {
311
+ const raw = await this.adapter.get(listKey);
312
+ if (raw === null || raw === undefined) return [];
313
+ await this.adapter.del(listKey);
314
+ return parseTagList(raw).map(([key]) => key);
315
+ }
229
316
 
230
- await Promise.all([
231
- ...keys.map((k) => this.adapter.del(this.prefixKey(k))),
232
- this.adapter.del(indexKey),
233
- ]);
317
+ /** Deletes the entries of `keys` (relative to this cache), in batches; never a tag index. */
318
+ private async deleteKeys(keys: string[]): Promise<void> {
319
+ const full = [...new Set(keys)].map((key) => `${this.prefix}${key}`).filter((key) => !RESERVED.test(key));
320
+ for (let i = 0; i < full.length; i += DELETE_BATCH) {
321
+ const batch = full.slice(i, i + DELETE_BATCH);
322
+ if (this.adapter.mdel) await this.adapter.mdel(batch);
323
+ else await Promise.all(batch.map((key) => this.adapter.del(key)));
324
+ }
234
325
  }
235
326
 
236
327
  // -------------------------------------------------------------------------
@@ -267,22 +358,47 @@ export class Cache {
267
358
  return options;
268
359
  }
269
360
 
270
- private async indexTags(key: string, tags: string[]): Promise<void> {
361
+ /**
362
+ * Adds `key` to each tag's index, for as long as the entry lives. With an
363
+ * adapter that has `sadd` it is one atomic step per tag, whatever the size
364
+ * of the index. Otherwise a JSON list is rewritten, one set() at a time per
365
+ * index key (concurrent set() calls used to both read the old index and
366
+ * one key was lost); that lock is per-process, so instances sharing a store
367
+ * can still race (see the docs).
368
+ */
369
+ private async indexTags(key: string, tags: string[], ttl: number | undefined): Promise<void> {
370
+ const adapter = this.adapter;
271
371
  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
- })
372
+ tags.map((tag) => {
373
+ if (adapter.sadd && adapter.sdrain) return adapter.sadd(this.tagSetKey(tag), key, ttl || undefined);
374
+ const listKey = this.tagListKey(tag);
375
+ return withLock(adapter, listKey, () => this.addToList(listKey, key, ttl));
376
+ }),
286
377
  );
287
378
  }
379
+
380
+ /**
381
+ * Rewriting the whole list on every set() made each one slower than the
382
+ * last, and expired keys were never dropped: they are now, the list expires
383
+ * with its last entry, and past MAX_TAG_LIST the oldest entries are deleted
384
+ * with their data (a deleted entry needs no invalidating).
385
+ */
386
+ private async addToList(listKey: string, key: string, ttl: number | undefined): Promise<void> {
387
+ const now = Date.now();
388
+ const expiresAt = ttl ? now + ttl * 1000 : 0;
389
+ const entries = parseTagList(await this.adapter.get(listKey)).filter(([, at]) => at === 0 || at > now);
390
+ const existing = entries.find(([listed]) => listed === key);
391
+ if (!existing) entries.push([key, expiresAt]);
392
+ // A key keeps its longest expiry: an earlier write may still be alive.
393
+ else if (existing[1] !== 0) existing[1] = expiresAt === 0 ? 0 : Math.max(existing[1], expiresAt);
394
+
395
+ const evicted = entries.length > MAX_TAG_LIST ? entries.splice(0, entries.length - MAX_TAG_LIST) : [];
396
+ await this.deleteKeys(evicted.map(([listed]) => listed));
397
+
398
+ // The list lives as long as its longest-lived entry (for good if one has no TTL).
399
+ const forever = entries.some(([, at]) => at === 0);
400
+ const last = entries.reduce((max, [, at]) => Math.max(max, at), 0);
401
+ const listTtl = forever ? undefined : (last - now) / 1000;
402
+ await this.adapter.set(listKey, JSON.stringify(entries), listTtl);
403
+ }
288
404
  }
@@ -1,5 +1,16 @@
1
1
  import type { KVAdapter } from './types';
2
2
 
3
+ /** setTimeout's limit: a longer delay (a TTL over ~24.8 days) fires at once. */
4
+ const MAX_TIMEOUT_MS = 2 ** 31 - 1;
5
+
6
+ /** An expiring set: each member's expiry (ms since the epoch, Infinity for none). */
7
+ interface ExpiringSet {
8
+ members: Map<string, number>;
9
+ expiresAt: number;
10
+ /** Size at which expired members are next dropped. */
11
+ pruneAt: number;
12
+ }
13
+
3
14
  /**
4
15
  * Lightweight in-memory {@link KVAdapter} used as the default backing store
5
16
  * when no adapter is supplied to the {@link Cache} constructor.
@@ -10,6 +21,7 @@ import type { KVAdapter } from './types';
10
21
  export class MemoryAdapter implements KVAdapter {
11
22
  readonly id = 'memory';
12
23
  private store = new Map<string, unknown>();
24
+ private sets = new Map<string, ExpiringSet>();
13
25
  private timers = new Map<string, ReturnType<typeof setTimeout>>();
14
26
 
15
27
  connect(): void {
@@ -20,6 +32,7 @@ export class MemoryAdapter implements KVAdapter {
20
32
  for (const timer of this.timers.values()) clearTimeout(timer);
21
33
  this.timers = new Map();
22
34
  this.store = new Map();
35
+ this.sets = new Map();
23
36
  }
24
37
 
25
38
  async get<T = unknown>(key: string): Promise<T | undefined> {
@@ -28,20 +41,31 @@ export class MemoryAdapter implements KVAdapter {
28
41
 
29
42
  async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
30
43
  this.clearTimer(key);
44
+ this.sets.delete(key);
31
45
  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
- }
46
+ if (ttl && ttl > 0 && Number.isFinite(ttl)) this.expireIn(key, ttl * 1000);
47
+ }
48
+
49
+ /** Arms the expiry timer, in steps when the delay exceeds setTimeout's limit. */
50
+ private expireIn(key: string, ms: number): void {
51
+ const step = Math.min(ms, MAX_TIMEOUT_MS);
52
+ const timer = setTimeout(() => {
53
+ if (ms > step) {
54
+ this.expireIn(key, ms - step);
55
+ return;
56
+ }
57
+ this.store.delete(key);
58
+ this.sets.delete(key);
59
+ this.timers.delete(key);
60
+ }, step);
61
+ timer.unref?.();
62
+ this.timers.set(key, timer);
40
63
  }
41
64
 
42
65
  async del(key: string): Promise<void> {
43
66
  this.clearTimer(key);
44
67
  this.store.delete(key);
68
+ this.sets.delete(key);
45
69
  }
46
70
 
47
71
  private clearTimer(key: string): void {
@@ -53,6 +77,44 @@ export class MemoryAdapter implements KVAdapter {
53
77
  }
54
78
 
55
79
  async has(key: string): Promise<boolean> {
56
- return this.store.has(key);
80
+ return this.store.has(key) || this.sets.has(key);
81
+ }
82
+
83
+ async clear(prefix = ''): Promise<void> {
84
+ for (const key of [...this.store.keys(), ...this.sets.keys()]) {
85
+ if (key.startsWith(prefix)) await this.del(key);
86
+ }
87
+ }
88
+
89
+ async sadd(key: string, member: string, ttl?: number): Promise<void> {
90
+ const now = Date.now();
91
+ const expiresAt = ttl && ttl > 0 && Number.isFinite(ttl) ? now + ttl * 1000 : Infinity;
92
+ let set = this.sets.get(key);
93
+ if (!set) {
94
+ this.store.delete(key);
95
+ set = { members: new Map(), expiresAt: 0, pruneAt: 64 };
96
+ this.sets.set(key, set);
97
+ }
98
+ // A member keeps its latest expiry, and expired members go each time
99
+ // the set doubles (O(1) per add on average), as in kv-kit.
100
+ set.members.set(member, Math.max(set.members.get(member) ?? 0, expiresAt));
101
+ if (set.members.size >= set.pruneAt) {
102
+ for (const [name, at] of set.members) if (at <= now) set.members.delete(name);
103
+ set.pruneAt = Math.max(64, set.members.size * 2);
104
+ }
105
+ if (expiresAt > set.expiresAt) {
106
+ set.expiresAt = expiresAt;
107
+ this.clearTimer(key);
108
+ if (expiresAt !== Infinity) this.expireIn(key, expiresAt - now);
109
+ }
110
+ }
111
+
112
+ async sdrain(key: string): Promise<string[]> {
113
+ const set = this.sets.get(key);
114
+ if (!set) return [];
115
+ this.sets.delete(key);
116
+ this.clearTimer(key);
117
+ const now = Date.now();
118
+ return [...set.members].filter(([, at]) => at > now).map(([name]) => name);
57
119
  }
58
120
  }