layercache 2.0.0 → 3.0.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/README.md +22 -6
- package/dist/{chunk-FFZCC7EQ.js → chunk-5CIBABDH.js} +149 -19
- package/dist/{chunk-7KMKQ6QZ.js → chunk-NBMG7DHT.js} +118 -13
- package/dist/cli.cjs +186 -25
- package/dist/cli.js +69 -13
- package/dist/{edge-D2FpRlyS.d.cts → edge-BDyuPmIq.d.cts} +509 -0
- package/dist/{edge-D2FpRlyS.d.ts → edge-BDyuPmIq.d.ts} +509 -0
- package/dist/edge.cjs +148 -19
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +1 -1
- package/dist/index.cjs +797 -82
- package/dist/index.d.cts +289 -3
- package/dist/index.d.ts +289 -3
- package/dist/index.js +528 -47
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<img src="./
|
|
6
|
+
<img src="./layercache-stampede.gif" width="930" alt="layercache stampede prevention demo">
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
9
|
<h1 align="center">layercache</h1>
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
26
|
<p align="center">
|
|
27
|
-
<a href="https://
|
|
27
|
+
<a href="https://flyingsquirrel0419.github.io/layercache">Website</a> |
|
|
28
28
|
<a href="#-quick-start">Quick Start</a> |
|
|
29
29
|
<a href="#-performance">Performance</a> |
|
|
30
30
|
<a href="./docs/api.md">API Reference</a> |
|
|
@@ -53,6 +53,18 @@ layercache is a multi-layer cache (Memory → Redis → Disk) for Node.js. Stamp
|
|
|
53
53
|
|
|
54
54
|
---
|
|
55
55
|
|
|
56
|
+
## What's New in 3.0
|
|
57
|
+
|
|
58
|
+
- `RedisTagIndex` uses 16 known-key shards by default. Existing Redis tag indexes that still use the legacy `<prefix>:keys` set should be migrated with `npx layercache migrate-tag-index`.
|
|
59
|
+
- Production CLI commands reject plaintext `redis://` URLs unless `--allow-plaintext` is passed. Prefer `rediss://` for production Redis endpoints.
|
|
60
|
+
- Express and Hono implicit URL cache keys now strip sensitive query parameters before caching, and non-2xx JSON responses are not cached by default.
|
|
61
|
+
- Redis-backed generation persistence is available through `RedisGenerationStore`, and `CacheStack.getGeneration()` exposes the active generation.
|
|
62
|
+
- The docs site now runs on Rspress and GitHub Pages.
|
|
63
|
+
|
|
64
|
+
See the [changelog](./CHANGELOG.md) and [migration guide](./docs/migration-guide.md) before upgrading an existing deployment.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
56
68
|
## Quick Start
|
|
57
69
|
|
|
58
70
|
```bash
|
|
@@ -363,7 +375,7 @@ layercache is built for multi-instance production environments:
|
|
|
363
375
|
|
|
364
376
|
- **Redis single-flight** - dedup misses across instances with distributed locks
|
|
365
377
|
- **Redis invalidation bus** - pub/sub-based L1 invalidation for memory consistency
|
|
366
|
-
- **Redis tag index** - shared tag tracking with
|
|
378
|
+
- **Redis tag index** - shared tag tracking with 16 known-key shards by default
|
|
367
379
|
- **Snapshot persistence** - export/import state between instances
|
|
368
380
|
|
|
369
381
|
<details>
|
|
@@ -375,9 +387,13 @@ import {
|
|
|
375
387
|
RedisInvalidationBus, RedisTagIndex, RedisSingleFlightCoordinator
|
|
376
388
|
} from 'layercache'
|
|
377
389
|
|
|
378
|
-
const redis = new Redis()
|
|
379
|
-
const bus = new RedisInvalidationBus({
|
|
380
|
-
|
|
390
|
+
const redis = new Redis(process.env.REDIS_URL)
|
|
391
|
+
const bus = new RedisInvalidationBus({
|
|
392
|
+
publisher: redis,
|
|
393
|
+
subscriber: new Redis(process.env.REDIS_URL),
|
|
394
|
+
signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET
|
|
395
|
+
})
|
|
396
|
+
const tagIndex = new RedisTagIndex({ client: redis, prefix: 'myapp:tags', knownKeysShards: 16 })
|
|
381
397
|
const coordinator = new RedisSingleFlightCoordinator({ client: redis })
|
|
382
398
|
|
|
383
399
|
const cache = new CacheStack(
|
|
@@ -12,6 +12,9 @@ var MemoryLayer = class {
|
|
|
12
12
|
onEvict;
|
|
13
13
|
entries = /* @__PURE__ */ new Map();
|
|
14
14
|
cleanupTimer;
|
|
15
|
+
/**
|
|
16
|
+
* Creates an in-memory cache layer.
|
|
17
|
+
*/
|
|
15
18
|
constructor(options = {}) {
|
|
16
19
|
this.name = options.name ?? "memory";
|
|
17
20
|
this.defaultTtl = options.ttl;
|
|
@@ -25,10 +28,16 @@ var MemoryLayer = class {
|
|
|
25
28
|
this.cleanupTimer.unref?.();
|
|
26
29
|
}
|
|
27
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Reads and unwraps a fresh value from memory.
|
|
33
|
+
*/
|
|
28
34
|
async get(key) {
|
|
29
35
|
const value = await this.getEntry(key);
|
|
30
36
|
return unwrapStoredValue(value);
|
|
31
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Reads the raw stored value or envelope from memory.
|
|
40
|
+
*/
|
|
32
41
|
async getEntry(key) {
|
|
33
42
|
const entry = this.entries.get(key);
|
|
34
43
|
if (!entry) {
|
|
@@ -47,12 +56,21 @@ var MemoryLayer = class {
|
|
|
47
56
|
}
|
|
48
57
|
return entry.value;
|
|
49
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Reads many raw entries from memory.
|
|
61
|
+
*/
|
|
50
62
|
async getMany(keys) {
|
|
51
63
|
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
52
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Writes many entries to memory.
|
|
67
|
+
*/
|
|
53
68
|
async setMany(entries) {
|
|
54
69
|
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
|
|
55
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Stores a value in memory using the provided TTL or layer default TTL.
|
|
73
|
+
*/
|
|
56
74
|
async set(key, value, ttl = this.defaultTtl) {
|
|
57
75
|
this.entries.delete(key);
|
|
58
76
|
this.entries.set(key, {
|
|
@@ -65,6 +83,9 @@ var MemoryLayer = class {
|
|
|
65
83
|
this.evict();
|
|
66
84
|
}
|
|
67
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Returns true when the key exists and has not expired.
|
|
88
|
+
*/
|
|
68
89
|
async has(key) {
|
|
69
90
|
const entry = this.entries.get(key);
|
|
70
91
|
if (!entry) {
|
|
@@ -76,6 +97,9 @@ var MemoryLayer = class {
|
|
|
76
97
|
}
|
|
77
98
|
return true;
|
|
78
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Returns remaining TTL in milliseconds, or null when absent or non-expiring.
|
|
102
|
+
*/
|
|
79
103
|
async ttl(key) {
|
|
80
104
|
const entry = this.entries.get(key);
|
|
81
105
|
if (!entry) {
|
|
@@ -90,40 +114,67 @@ var MemoryLayer = class {
|
|
|
90
114
|
}
|
|
91
115
|
return Math.max(0, Math.ceil(entry.expiresAt - Date.now()));
|
|
92
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Returns the number of currently retained, non-expired entries.
|
|
119
|
+
*/
|
|
93
120
|
async size() {
|
|
94
121
|
this.pruneExpired();
|
|
95
122
|
return this.entries.size;
|
|
96
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Deletes a key from memory.
|
|
126
|
+
*/
|
|
97
127
|
async delete(key) {
|
|
98
128
|
this.entries.delete(key);
|
|
99
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Deletes multiple keys from memory.
|
|
132
|
+
*/
|
|
100
133
|
async deleteMany(keys) {
|
|
101
134
|
for (const key of keys) {
|
|
102
135
|
this.entries.delete(key);
|
|
103
136
|
}
|
|
104
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Removes all entries from memory.
|
|
140
|
+
*/
|
|
105
141
|
async clear() {
|
|
106
142
|
this.entries.clear();
|
|
107
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Health check hook that always succeeds for the in-process layer.
|
|
146
|
+
*/
|
|
108
147
|
async ping() {
|
|
109
148
|
return true;
|
|
110
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Stops the cleanup timer, when one is active.
|
|
152
|
+
*/
|
|
111
153
|
async dispose() {
|
|
112
154
|
if (this.cleanupTimer) {
|
|
113
155
|
clearInterval(this.cleanupTimer);
|
|
114
156
|
this.cleanupTimer = void 0;
|
|
115
157
|
}
|
|
116
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Returns all currently retained, non-expired keys.
|
|
161
|
+
*/
|
|
117
162
|
async keys() {
|
|
118
163
|
this.pruneExpired();
|
|
119
164
|
return [...this.entries.keys()];
|
|
120
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Visits all currently retained, non-expired keys.
|
|
168
|
+
*/
|
|
121
169
|
async forEachKey(visitor) {
|
|
122
170
|
this.pruneExpired();
|
|
123
171
|
for (const key of this.entries.keys()) {
|
|
124
172
|
await visitor(key);
|
|
125
173
|
}
|
|
126
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Exports memory entries for process-local snapshots.
|
|
177
|
+
*/
|
|
127
178
|
exportState() {
|
|
128
179
|
this.pruneExpired();
|
|
129
180
|
return [...this.entries.entries()].map(([key, entry]) => ({
|
|
@@ -132,6 +183,9 @@ var MemoryLayer = class {
|
|
|
132
183
|
expiresAt: entry.expiresAt
|
|
133
184
|
}));
|
|
134
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Imports entries previously produced by `exportState()`.
|
|
188
|
+
*/
|
|
135
189
|
importState(entries) {
|
|
136
190
|
for (const entry of entries) {
|
|
137
191
|
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
@@ -202,10 +256,16 @@ var TagIndex = class {
|
|
|
202
256
|
constructor(options = {}) {
|
|
203
257
|
this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
|
|
204
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Records a key as known without changing tag assignments.
|
|
261
|
+
*/
|
|
205
262
|
async touch(key) {
|
|
206
263
|
this.insertKnownKey(key);
|
|
207
264
|
this.pruneKnownKeysIfNeeded();
|
|
208
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Replaces the tags associated with a key and records the key as known.
|
|
268
|
+
*/
|
|
209
269
|
async track(key, tags) {
|
|
210
270
|
this.insertKnownKey(key);
|
|
211
271
|
this.pruneKnownKeysIfNeeded();
|
|
@@ -226,17 +286,29 @@ var TagIndex = class {
|
|
|
226
286
|
this.tagToKeys.set(tag, keys);
|
|
227
287
|
}
|
|
228
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Removes a key from all tag mappings and known-key tracking.
|
|
291
|
+
*/
|
|
229
292
|
async remove(key) {
|
|
230
293
|
this.removeKey(key);
|
|
231
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Returns keys currently associated with a tag.
|
|
297
|
+
*/
|
|
232
298
|
async keysForTag(tag) {
|
|
233
299
|
return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
|
|
234
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Visits keys currently associated with a tag.
|
|
303
|
+
*/
|
|
235
304
|
async forEachKeyForTag(tag, visitor) {
|
|
236
305
|
for (const key of this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()) {
|
|
237
306
|
await visitor(key);
|
|
238
307
|
}
|
|
239
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Returns known keys that start with a prefix.
|
|
311
|
+
*/
|
|
240
312
|
async keysForPrefix(prefix) {
|
|
241
313
|
const node = this.findNode(prefix);
|
|
242
314
|
if (!node) {
|
|
@@ -246,6 +318,9 @@ var TagIndex = class {
|
|
|
246
318
|
this.collectFromNode(node, prefix, matches);
|
|
247
319
|
return matches;
|
|
248
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* Visits known keys that start with a prefix.
|
|
323
|
+
*/
|
|
249
324
|
async forEachKeyForPrefix(prefix, visitor) {
|
|
250
325
|
const node = this.findNode(prefix);
|
|
251
326
|
if (!node) {
|
|
@@ -253,20 +328,32 @@ var TagIndex = class {
|
|
|
253
328
|
}
|
|
254
329
|
await this.visitFromNode(node, prefix, visitor);
|
|
255
330
|
}
|
|
331
|
+
/**
|
|
332
|
+
* Returns the tags currently associated with a key.
|
|
333
|
+
*/
|
|
256
334
|
async tagsForKey(key) {
|
|
257
335
|
return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
|
|
258
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Returns known keys matching a wildcard pattern.
|
|
339
|
+
*/
|
|
259
340
|
async matchPattern(pattern) {
|
|
260
341
|
const matches = /* @__PURE__ */ new Set();
|
|
261
342
|
this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set(), 0);
|
|
262
343
|
return [...matches];
|
|
263
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Visits known keys matching a wildcard pattern.
|
|
347
|
+
*/
|
|
264
348
|
async forEachKeyMatchingPattern(pattern, visitor) {
|
|
265
349
|
const matches = await this.matchPattern(pattern);
|
|
266
350
|
for (const key of matches) {
|
|
267
351
|
await visitor(key);
|
|
268
352
|
}
|
|
269
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* Clears all tag and known-key index state.
|
|
356
|
+
*/
|
|
270
357
|
async clear() {
|
|
271
358
|
this.tagToKeys.clear();
|
|
272
359
|
this.keyToTags.clear();
|
|
@@ -284,6 +371,9 @@ var TagIndex = class {
|
|
|
284
371
|
}
|
|
285
372
|
insertKnownKey(key) {
|
|
286
373
|
const isNew = !this.knownKeys.has(key);
|
|
374
|
+
if (!isNew) {
|
|
375
|
+
this.knownKeys.delete(key);
|
|
376
|
+
}
|
|
287
377
|
this.knownKeys.set(key, Date.now());
|
|
288
378
|
if (!isNew) {
|
|
289
379
|
return;
|
|
@@ -382,13 +472,13 @@ var TagIndex = class {
|
|
|
382
472
|
if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
|
|
383
473
|
return;
|
|
384
474
|
}
|
|
385
|
-
const sorted = [...this.knownKeys.entries()].sort((a, b) => a[1] - b[1]);
|
|
386
475
|
const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
|
|
387
|
-
for (let i = 0; i < toRemove
|
|
388
|
-
const
|
|
389
|
-
if (
|
|
390
|
-
|
|
476
|
+
for (let i = 0; i < toRemove; i += 1) {
|
|
477
|
+
const oldestKey = this.knownKeys.keys().next().value;
|
|
478
|
+
if (oldestKey === void 0) {
|
|
479
|
+
break;
|
|
391
480
|
}
|
|
481
|
+
this.removeKnownKey(oldestKey);
|
|
392
482
|
}
|
|
393
483
|
}
|
|
394
484
|
removeKey(key) {
|
|
@@ -439,6 +529,41 @@ var TagIndex = class {
|
|
|
439
529
|
}
|
|
440
530
|
};
|
|
441
531
|
|
|
532
|
+
// src/integrations/httpCacheKeys.ts
|
|
533
|
+
var SENSITIVE_QUERY_PARAMETERS = /* @__PURE__ */ new Set([
|
|
534
|
+
"access_token",
|
|
535
|
+
"api_key",
|
|
536
|
+
"apikey",
|
|
537
|
+
"auth",
|
|
538
|
+
"authorization",
|
|
539
|
+
"code",
|
|
540
|
+
"credentials",
|
|
541
|
+
"id_token",
|
|
542
|
+
"jwt",
|
|
543
|
+
"password",
|
|
544
|
+
"private_key",
|
|
545
|
+
"refresh_token",
|
|
546
|
+
"secret",
|
|
547
|
+
"session",
|
|
548
|
+
"sessionid",
|
|
549
|
+
"session_id",
|
|
550
|
+
"token"
|
|
551
|
+
]);
|
|
552
|
+
function normalizeHttpCacheUrl(url) {
|
|
553
|
+
try {
|
|
554
|
+
const parsed = new URL(url, "http://localhost");
|
|
555
|
+
for (const name of [...parsed.searchParams.keys()]) {
|
|
556
|
+
if (SENSITIVE_QUERY_PARAMETERS.has(name.toLowerCase())) {
|
|
557
|
+
parsed.searchParams.delete(name);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
parsed.searchParams.sort();
|
|
561
|
+
return parsed.pathname + parsed.search;
|
|
562
|
+
} catch {
|
|
563
|
+
return url;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
442
567
|
// src/integrations/hono.ts
|
|
443
568
|
function createHonoCacheMiddleware(cache, options = {}) {
|
|
444
569
|
const allowedMethods = new Set((options.methods ?? ["GET"]).map((method) => method.toUpperCase()));
|
|
@@ -453,39 +578,44 @@ function createHonoCacheMiddleware(cache, options = {}) {
|
|
|
453
578
|
return;
|
|
454
579
|
}
|
|
455
580
|
const rawPath = context.req.path ?? context.req.url ?? "/";
|
|
456
|
-
const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${
|
|
581
|
+
const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeHttpCacheUrl(rawPath)}`;
|
|
457
582
|
const cached = await cache.get(key, void 0, options);
|
|
458
583
|
if (cached !== null) {
|
|
459
584
|
context.header?.("x-cache", "HIT");
|
|
460
585
|
context.header?.("content-type", "application/json; charset=utf-8");
|
|
461
586
|
return context.json(cached);
|
|
462
587
|
}
|
|
588
|
+
let currentStatus;
|
|
589
|
+
const originalStatus = context.status?.bind(context);
|
|
590
|
+
if (originalStatus) {
|
|
591
|
+
context.status = (status) => {
|
|
592
|
+
currentStatus = status;
|
|
593
|
+
return originalStatus(status);
|
|
594
|
+
};
|
|
595
|
+
}
|
|
463
596
|
const originalJson = context.json.bind(context);
|
|
464
597
|
context.json = (body, status) => {
|
|
465
598
|
context.header?.("x-cache", "MISS");
|
|
466
|
-
|
|
467
|
-
cache.
|
|
468
|
-
|
|
469
|
-
|
|
599
|
+
if (isSuccessfulStatus(status ?? currentStatus)) {
|
|
600
|
+
cache.set(key, body, options).catch((err) => {
|
|
601
|
+
cache.emit("error", {
|
|
602
|
+
operation: "set",
|
|
603
|
+
error: err instanceof Error ? err.message : String(err)
|
|
604
|
+
});
|
|
470
605
|
});
|
|
471
|
-
}
|
|
606
|
+
}
|
|
472
607
|
return originalJson(body, status);
|
|
473
608
|
};
|
|
474
609
|
await next();
|
|
475
610
|
};
|
|
476
611
|
}
|
|
477
|
-
function
|
|
478
|
-
|
|
479
|
-
const parsed = new URL(url, "http://localhost");
|
|
480
|
-
parsed.searchParams.sort();
|
|
481
|
-
return parsed.pathname + parsed.search;
|
|
482
|
-
} catch {
|
|
483
|
-
return url;
|
|
484
|
-
}
|
|
612
|
+
function isSuccessfulStatus(statusCode) {
|
|
613
|
+
return statusCode === void 0 || statusCode >= 200 && statusCode < 300;
|
|
485
614
|
}
|
|
486
615
|
|
|
487
616
|
export {
|
|
488
617
|
MemoryLayer,
|
|
489
618
|
TagIndex,
|
|
619
|
+
normalizeHttpCacheUrl,
|
|
490
620
|
createHonoCacheMiddleware
|
|
491
621
|
};
|
|
@@ -140,20 +140,30 @@ function validateContextEntryOptions(name, options) {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
// src/invalidation/RedisTagIndex.ts
|
|
143
|
+
var DEFAULT_KNOWN_KEYS_SHARDS = 16;
|
|
143
144
|
var RedisTagIndex = class {
|
|
144
145
|
client;
|
|
145
146
|
prefix;
|
|
146
147
|
scanCount;
|
|
147
148
|
knownKeysShards;
|
|
149
|
+
logger;
|
|
150
|
+
warnedLegacyKnownKeys = false;
|
|
148
151
|
constructor(options) {
|
|
149
152
|
this.client = options.client;
|
|
150
153
|
this.prefix = options.prefix ?? "layercache:tag-index";
|
|
151
154
|
this.scanCount = options.scanCount ?? 100;
|
|
152
155
|
this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
|
|
156
|
+
this.logger = options.logger;
|
|
153
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Records a key as known without changing tag assignments.
|
|
160
|
+
*/
|
|
154
161
|
async touch(key) {
|
|
155
162
|
await this.client.sadd(this.knownKeysKeyFor(key), key);
|
|
156
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Replaces the tags associated with a key and records the key as known.
|
|
166
|
+
*/
|
|
157
167
|
async track(key, tags) {
|
|
158
168
|
const keyTagsKey = this.keyTagsKey(key);
|
|
159
169
|
const existingTags = await this.client.smembers(keyTagsKey);
|
|
@@ -171,20 +181,32 @@ var RedisTagIndex = class {
|
|
|
171
181
|
}
|
|
172
182
|
await pipeline.exec();
|
|
173
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Removes a key from all tag mappings and known-key tracking.
|
|
186
|
+
*/
|
|
174
187
|
async remove(key) {
|
|
175
188
|
const keyTagsKey = this.keyTagsKey(key);
|
|
176
189
|
const existingTags = await this.client.smembers(keyTagsKey);
|
|
177
190
|
const pipeline = this.client.pipeline();
|
|
178
191
|
pipeline.srem(this.knownKeysKeyFor(key), key);
|
|
192
|
+
if (this.knownKeysShards > 1) {
|
|
193
|
+
pipeline.srem(this.legacyKnownKeysKey(), key);
|
|
194
|
+
}
|
|
179
195
|
pipeline.del(keyTagsKey);
|
|
180
196
|
for (const tag of existingTags) {
|
|
181
197
|
pipeline.srem(this.tagKeysKey(tag), key);
|
|
182
198
|
}
|
|
183
199
|
await pipeline.exec();
|
|
184
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Returns keys currently associated with a tag.
|
|
203
|
+
*/
|
|
185
204
|
async keysForTag(tag) {
|
|
186
205
|
return this.client.smembers(this.tagKeysKey(tag));
|
|
187
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Visits keys currently associated with a tag.
|
|
209
|
+
*/
|
|
188
210
|
async forEachKeyForTag(tag, visitor) {
|
|
189
211
|
let cursor = "0";
|
|
190
212
|
const tagKey = this.tagKeysKey(tag);
|
|
@@ -196,38 +218,56 @@ var RedisTagIndex = class {
|
|
|
196
218
|
}
|
|
197
219
|
} while (cursor !== "0");
|
|
198
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Returns known keys that start with a prefix.
|
|
223
|
+
*/
|
|
199
224
|
async keysForPrefix(prefix) {
|
|
200
|
-
const matches =
|
|
201
|
-
for (const knownKeysKey of this.
|
|
225
|
+
const matches = /* @__PURE__ */ new Set();
|
|
226
|
+
for (const knownKeysKey of await this.knownKeysKeysForRead()) {
|
|
202
227
|
let cursor = "0";
|
|
203
228
|
do {
|
|
204
229
|
const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
|
|
205
230
|
cursor = nextCursor;
|
|
206
|
-
|
|
231
|
+
for (const key of keys) {
|
|
232
|
+
if (key.startsWith(prefix)) {
|
|
233
|
+
matches.add(key);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
207
236
|
} while (cursor !== "0");
|
|
208
237
|
}
|
|
209
|
-
return matches;
|
|
238
|
+
return [...matches];
|
|
210
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Visits known keys that start with a prefix.
|
|
242
|
+
*/
|
|
211
243
|
async forEachKeyForPrefix(prefix, visitor) {
|
|
212
|
-
|
|
244
|
+
const visited = /* @__PURE__ */ new Set();
|
|
245
|
+
for (const knownKeysKey of await this.knownKeysKeysForRead()) {
|
|
213
246
|
let cursor = "0";
|
|
214
247
|
do {
|
|
215
248
|
const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
|
|
216
249
|
cursor = nextCursor;
|
|
217
250
|
for (const key of keys) {
|
|
218
|
-
if (key.startsWith(prefix)) {
|
|
251
|
+
if (key.startsWith(prefix) && !visited.has(key)) {
|
|
252
|
+
visited.add(key);
|
|
219
253
|
await visitor(key);
|
|
220
254
|
}
|
|
221
255
|
}
|
|
222
256
|
} while (cursor !== "0");
|
|
223
257
|
}
|
|
224
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Returns the tags currently associated with a key.
|
|
261
|
+
*/
|
|
225
262
|
async tagsForKey(key) {
|
|
226
263
|
return this.client.smembers(this.keyTagsKey(key));
|
|
227
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Returns known keys matching a wildcard pattern.
|
|
267
|
+
*/
|
|
228
268
|
async matchPattern(pattern) {
|
|
229
|
-
const matches =
|
|
230
|
-
for (const knownKeysKey of this.
|
|
269
|
+
const matches = /* @__PURE__ */ new Set();
|
|
270
|
+
for (const knownKeysKey of await this.knownKeysKeysForRead()) {
|
|
231
271
|
let cursor = "0";
|
|
232
272
|
do {
|
|
233
273
|
const [nextCursor, keys] = await this.client.sscan(
|
|
@@ -239,13 +279,21 @@ var RedisTagIndex = class {
|
|
|
239
279
|
this.scanCount
|
|
240
280
|
);
|
|
241
281
|
cursor = nextCursor;
|
|
242
|
-
|
|
282
|
+
for (const key of keys) {
|
|
283
|
+
if (PatternMatcher.matches(pattern, key)) {
|
|
284
|
+
matches.add(key);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
243
287
|
} while (cursor !== "0");
|
|
244
288
|
}
|
|
245
|
-
return matches;
|
|
289
|
+
return [...matches];
|
|
246
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Visits known keys matching a wildcard pattern.
|
|
293
|
+
*/
|
|
247
294
|
async forEachKeyMatchingPattern(pattern, visitor) {
|
|
248
|
-
|
|
295
|
+
const visited = /* @__PURE__ */ new Set();
|
|
296
|
+
for (const knownKeysKey of await this.knownKeysKeysForRead()) {
|
|
249
297
|
let cursor = "0";
|
|
250
298
|
do {
|
|
251
299
|
const [nextCursor, keys] = await this.client.sscan(
|
|
@@ -258,13 +306,17 @@ var RedisTagIndex = class {
|
|
|
258
306
|
);
|
|
259
307
|
cursor = nextCursor;
|
|
260
308
|
for (const key of keys) {
|
|
261
|
-
if (PatternMatcher.matches(pattern, key)) {
|
|
309
|
+
if (PatternMatcher.matches(pattern, key) && !visited.has(key)) {
|
|
310
|
+
visited.add(key);
|
|
262
311
|
await visitor(key);
|
|
263
312
|
}
|
|
264
313
|
}
|
|
265
314
|
} while (cursor !== "0");
|
|
266
315
|
}
|
|
267
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* Clears all Redis tag-index state under this prefix.
|
|
319
|
+
*/
|
|
268
320
|
async clear() {
|
|
269
321
|
const indexKeys = await this.scanIndexKeys();
|
|
270
322
|
if (indexKeys.length === 0) {
|
|
@@ -272,6 +324,31 @@ var RedisTagIndex = class {
|
|
|
272
324
|
}
|
|
273
325
|
await this.client.del(...indexKeys);
|
|
274
326
|
}
|
|
327
|
+
async migrateLegacyKnownKeys() {
|
|
328
|
+
if (this.knownKeysShards === 1) {
|
|
329
|
+
return { migratedKeys: 0 };
|
|
330
|
+
}
|
|
331
|
+
const legacyKey = this.legacyKnownKeysKey();
|
|
332
|
+
let cursor = "0";
|
|
333
|
+
let migratedKeys = 0;
|
|
334
|
+
do {
|
|
335
|
+
const [nextCursor, keys] = await this.client.sscan(legacyKey, cursor, "COUNT", this.scanCount);
|
|
336
|
+
cursor = nextCursor;
|
|
337
|
+
if (keys.length === 0) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const pipeline = this.client.pipeline();
|
|
341
|
+
for (const key of keys) {
|
|
342
|
+
pipeline.sadd(this.knownKeysKeyFor(key), key);
|
|
343
|
+
}
|
|
344
|
+
await pipeline.exec();
|
|
345
|
+
migratedKeys += keys.length;
|
|
346
|
+
} while (cursor !== "0");
|
|
347
|
+
if (migratedKeys > 0) {
|
|
348
|
+
await this.client.del(legacyKey);
|
|
349
|
+
}
|
|
350
|
+
return { migratedKeys };
|
|
351
|
+
}
|
|
275
352
|
async scanIndexKeys() {
|
|
276
353
|
const matches = [];
|
|
277
354
|
let cursor = "0";
|
|
@@ -289,12 +366,40 @@ var RedisTagIndex = class {
|
|
|
289
366
|
}
|
|
290
367
|
return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
|
|
291
368
|
}
|
|
369
|
+
async knownKeysKeysForRead() {
|
|
370
|
+
if (this.knownKeysShards === 1) {
|
|
371
|
+
return [this.legacyKnownKeysKey()];
|
|
372
|
+
}
|
|
373
|
+
const shardedKeys = this.knownKeysKeys();
|
|
374
|
+
const legacyKey = this.legacyKnownKeysKey();
|
|
375
|
+
const legacyExists = await this.client.exists(legacyKey) > 0;
|
|
376
|
+
if (!legacyExists) {
|
|
377
|
+
return shardedKeys;
|
|
378
|
+
}
|
|
379
|
+
this.warnLegacyKnownKeys(legacyKey);
|
|
380
|
+
return [legacyKey, ...shardedKeys];
|
|
381
|
+
}
|
|
292
382
|
knownKeysKeys() {
|
|
293
383
|
if (this.knownKeysShards === 1) {
|
|
294
384
|
return [`${this.prefix}:keys`];
|
|
295
385
|
}
|
|
296
386
|
return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
|
|
297
387
|
}
|
|
388
|
+
legacyKnownKeysKey() {
|
|
389
|
+
return `${this.prefix}:keys`;
|
|
390
|
+
}
|
|
391
|
+
warnLegacyKnownKeys(legacyKey) {
|
|
392
|
+
if (this.warnedLegacyKnownKeys) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
this.warnedLegacyKnownKeys = true;
|
|
396
|
+
const message = "RedisTagIndex detected a legacy RedisTagIndex known-key set. Run `layercache migrate-tag-index` to migrate keys into the sharded layout.";
|
|
397
|
+
if (this.logger?.warn) {
|
|
398
|
+
this.logger.warn(message, { legacyKey, knownKeysShards: this.knownKeysShards });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
console.warn(`[layercache] ${message}`, { legacyKey, knownKeysShards: this.knownKeysShards });
|
|
402
|
+
}
|
|
298
403
|
keyTagsKey(key) {
|
|
299
404
|
return `${this.prefix}:key:${encodeURIComponent(key)}`;
|
|
300
405
|
}
|
|
@@ -304,7 +409,7 @@ var RedisTagIndex = class {
|
|
|
304
409
|
};
|
|
305
410
|
function normalizeKnownKeysShards(value) {
|
|
306
411
|
if (value === void 0) {
|
|
307
|
-
return
|
|
412
|
+
return DEFAULT_KNOWN_KEYS_SHARDS;
|
|
308
413
|
}
|
|
309
414
|
if (!Number.isInteger(value) || value <= 0) {
|
|
310
415
|
throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
|