@iskra-bun/cache-kit 0.1.0 → 0.2.1
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 +57 -0
- package/README.md +1 -1
- package/dist/index.d.ts +32 -10
- package/dist/index.js +218 -86
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/cache.ts +211 -95
- package/src/memory-adapter.ts +71 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# @iskra-bun/cache-kit
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [e86ed55]
|
|
8
|
+
- Updated dependencies [ae7c798]
|
|
9
|
+
- Updated dependencies [ef10372]
|
|
10
|
+
- @iskra-bun/core@0.3.0
|
|
11
|
+
- @iskra-bun/kv-kit@0.3.1
|
|
12
|
+
|
|
13
|
+
## 0.2.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 938dd41: **Security** fixes from the data-kits audit (round 2).
|
|
18
|
+
|
|
19
|
+
- `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.
|
|
20
|
+
- `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`.
|
|
21
|
+
- `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.
|
|
22
|
+
- `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.
|
|
23
|
+
- `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.
|
|
24
|
+
- `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.
|
|
25
|
+
- `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.
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
- 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`.
|
|
32
|
+
|
|
33
|
+
Every package is published with an npm provenance attestation (`publishConfig.provenance`), linking each version to the commit and CI run that built it.
|
|
34
|
+
|
|
35
|
+
- Updated dependencies [620da18]
|
|
36
|
+
- Updated dependencies [b635a2c]
|
|
37
|
+
- Updated dependencies [5b2b0fd]
|
|
38
|
+
- Updated dependencies [58d4a8f]
|
|
39
|
+
- Updated dependencies [5c70c5b]
|
|
40
|
+
- Updated dependencies [ec198d4]
|
|
41
|
+
- Updated dependencies [cb3ec43]
|
|
42
|
+
- Updated dependencies [ef2009b]
|
|
43
|
+
- Updated dependencies [840439a]
|
|
44
|
+
- Updated dependencies [cb3ec43]
|
|
45
|
+
- Updated dependencies [620da18]
|
|
46
|
+
- Updated dependencies [87f6de2]
|
|
47
|
+
- Updated dependencies [58d4a8f]
|
|
48
|
+
- Updated dependencies [dbf8817]
|
|
49
|
+
- Updated dependencies [c4ff1e3]
|
|
50
|
+
- Updated dependencies [7e89103]
|
|
51
|
+
- Updated dependencies [3dc5581]
|
|
52
|
+
- Updated dependencies [9872d30]
|
|
53
|
+
- Updated dependencies [9bb254d]
|
|
54
|
+
- Updated dependencies [f2346f5]
|
|
55
|
+
- Updated dependencies [938dd41]
|
|
56
|
+
- Updated dependencies [3579944]
|
|
57
|
+
- @iskra-bun/core@0.2.0
|
|
58
|
+
- @iskra-bun/kv-kit@0.3.0
|
|
59
|
+
|
|
3
60
|
## 0.1.0
|
|
4
61
|
|
|
5
62
|
### 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: [
|
|
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
|
|
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
|
-
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
return `${this.prefix}${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
136
|
-
*
|
|
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
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
|
257
|
+
* @throws Error when the adapter has no `clear()`.
|
|
145
258
|
*/
|
|
146
259
|
async clear() {
|
|
147
|
-
if (this.
|
|
148
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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(
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
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
|
|
3
|
+
"version": "0.2.1",
|
|
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.
|
|
50
|
-
"@iskra-bun/kv-kit": "0.
|
|
52
|
+
"@iskra-bun/core": "^0.3.0",
|
|
53
|
+
"@iskra-bun/kv-kit": "^0.3.1"
|
|
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
|
-
/**
|
|
5
|
-
const
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* the
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
`
|
|
24
|
-
|
|
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
|
-
/**
|
|
32
|
-
function
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
return `${this.prefix}${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
150
|
-
*
|
|
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
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
|
252
|
+
* @throws Error when the adapter has no `clear()`.
|
|
159
253
|
*/
|
|
160
254
|
async clear(): Promise<void> {
|
|
161
|
-
if (this.
|
|
162
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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(
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
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
|
}
|
package/src/memory-adapter.ts
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
}
|