layercache 1.2.2 → 1.2.3
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 +27 -8
- package/dist/{chunk-ZMDB5KOK.js → chunk-7V7XAB74.js} +24 -1
- package/dist/{chunk-46UH7LNM.js → chunk-KOYGHLVP.js} +142 -18
- package/dist/{chunk-IXCMHVHP.js → chunk-QHWG7QS5.js} +1 -1
- package/dist/cli.cjs +37 -3
- package/dist/cli.js +15 -4
- package/dist/{edge-DLpdQN0W.d.ts → edge-B_rUqDy6.d.cts} +34 -1
- package/dist/{edge-DLpdQN0W.d.cts → edge-B_rUqDy6.d.ts} +34 -1
- package/dist/edge.cjs +165 -17
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +2 -2
- package/dist/index.cjs +591 -98
- package/dist/index.d.cts +10 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.js +429 -84
- package/package.json +4 -4
- package/packages/nestjs/dist/index.cjs +492 -43
- package/packages/nestjs/dist/index.d.cts +25 -0
- package/packages/nestjs/dist/index.d.ts +25 -0
- package/packages/nestjs/dist/index.js +492 -43
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/layercache)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.typescriptlang.org/)
|
|
9
|
-
[](https://github.com/flyingsquirrel0419/layercache)
|
|
10
10
|
|
|
11
11
|
```
|
|
12
12
|
L1 hit ~0.01 ms ← served from memory, zero network
|
|
@@ -45,7 +45,7 @@ On a hit, the value is returned from the fastest layer that has it, and automati
|
|
|
45
45
|
- **Batch tag invalidation** — `invalidateByTags(['tenant:a', 'users'], 'all')` for OR/AND invalidation in one call
|
|
46
46
|
- **Pattern invalidation** — `invalidateByPattern('user:*')`
|
|
47
47
|
- **Prefix invalidation** — efficient `invalidateByPrefix('user:123:')` for hierarchical keys
|
|
48
|
-
- **Generation-based invalidation** — `generation` prefixes keys with `vN:` and `bumpGeneration()` rotates the namespace instantly
|
|
48
|
+
- **Generation-based invalidation** — `generation` prefixes keys with `vN:` and `bumpGeneration()` rotates the namespace instantly, with optional stale-generation cleanup
|
|
49
49
|
- **Per-layer TTL overrides** — different TTLs for memory vs. Redis in one call
|
|
50
50
|
- **TTL policies** — align TTLs to time boundaries (`until-midnight`, `next-hour`, `{ alignTo }`, or a function)
|
|
51
51
|
- **Negative caching** — cache known misses for a short TTL to protect the database
|
|
@@ -84,7 +84,7 @@ On a hit, the value is returned from the fastest layer that has it, and automati
|
|
|
84
84
|
- **Nested namespaces** — `namespace('a').namespace('b')` for composable key prefixes with namespace-scoped metrics
|
|
85
85
|
- **Custom layers** — implement the 5-method `CacheLayer` interface to plug in Memcached, DynamoDB, or anything else
|
|
86
86
|
- **Edge-safe entry point** — `layercache/edge` exports the non-Node helpers for Worker-style runtimes
|
|
87
|
-
- **ESM + CJS** — works with both module systems, Node.js ≥
|
|
87
|
+
- **ESM + CJS** — works with both module systems, Node.js ≥ 20
|
|
88
88
|
|
|
89
89
|
---
|
|
90
90
|
|
|
@@ -173,7 +173,7 @@ await cache.set('user:123', user, {
|
|
|
173
173
|
|
|
174
174
|
### `cache.invalidateByTag(tag): Promise<void>`
|
|
175
175
|
|
|
176
|
-
Deletes every key that was stored with this tag across all layers.
|
|
176
|
+
Deletes every key that was stored with this tag across all layers. In multi-instance deployments, this is only complete when every instance shares the same tag index implementation (for example `RedisTagIndex`).
|
|
177
177
|
|
|
178
178
|
```ts
|
|
179
179
|
await cache.set('user:123', user, { tags: ['user:123'] })
|
|
@@ -193,12 +193,14 @@ await cache.invalidateByTags(['users', 'posts'], 'any') // keys tagged with e
|
|
|
193
193
|
|
|
194
194
|
### `cache.invalidateByPattern(pattern): Promise<void>`
|
|
195
195
|
|
|
196
|
-
Glob-style deletion against the tracked key set.
|
|
196
|
+
Glob-style deletion against the tracked key set, plus any layer that can enumerate real keys (for example `MemoryLayer`, `RedisLayer`, or `DiskLayer`).
|
|
197
197
|
|
|
198
198
|
```ts
|
|
199
199
|
await cache.invalidateByPattern('user:*') // deletes user:1, user:2, …
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
+
For multi-instance deployments, prefer a shared `RedisTagIndex`. Without it, pattern invalidation still scans real layer keys when available, but that fallback only helps on layers that implement `keys()`, and tag tracking itself remains process-local.
|
|
203
|
+
|
|
202
204
|
### `cache.invalidateByPrefix(prefix): Promise<void>`
|
|
203
205
|
|
|
204
206
|
Prefer this over glob invalidation when your keys are hierarchical.
|
|
@@ -280,6 +282,17 @@ await cache.set('user:123', user)
|
|
|
280
282
|
cache.bumpGeneration() // now reads use v2:user:123
|
|
281
283
|
```
|
|
282
284
|
|
|
285
|
+
If you also want old generation keys cleaned up automatically instead of waiting for TTL expiry:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
const cache = new CacheStack([...], {
|
|
289
|
+
generation: 1,
|
|
290
|
+
generationCleanup: { batchSize: 500 }
|
|
291
|
+
})
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`bumpGeneration()` only rotates future reads and writes by default. Enable `generationCleanup` when you want previous generations to be pruned automatically instead of aging out by TTL.
|
|
295
|
+
|
|
283
296
|
### OpenTelemetry note
|
|
284
297
|
|
|
285
298
|
`createOpenTelemetryPlugin()` currently wraps a `CacheStack` instance's methods directly. Use one OpenTelemetry plugin per cache instance; if you need to compose multiple wrappers, install them in a fixed order and uninstall them in reverse order.
|
|
@@ -482,10 +495,10 @@ const cache = new CacheStack(
|
|
|
482
495
|
await cache.disconnect() // unsubscribes cleanly on shutdown
|
|
483
496
|
```
|
|
484
497
|
|
|
485
|
-
By default,
|
|
498
|
+
By default, write-triggered L1 invalidation is **off** even when an invalidation bus is configured. This avoids surprising Redis Pub/Sub traffic in write-heavy services. Enable it explicitly when you want every write to evict peer memory caches immediately:
|
|
486
499
|
|
|
487
500
|
```ts
|
|
488
|
-
new CacheStack([...], { invalidationBus: bus,
|
|
501
|
+
new CacheStack([...], { invalidationBus: bus, broadcastL1Invalidation: true })
|
|
489
502
|
```
|
|
490
503
|
|
|
491
504
|
### Distributed tag invalidation
|
|
@@ -510,6 +523,8 @@ const cache = new CacheStack(
|
|
|
510
523
|
|
|
511
524
|
Now `invalidateByTag('user:123')` on any server deletes every tagged key, regardless of which server originally wrote it.
|
|
512
525
|
|
|
526
|
+
The same recommendation applies to `invalidateByPattern()` and `invalidateByPrefix()` in distributed deployments: a shared tag index gives the most complete view of known keys, while layer key scans act as a fallback only when the shared layer exposes `keys()`.
|
|
527
|
+
|
|
513
528
|
### Safe Redis clearing
|
|
514
529
|
|
|
515
530
|
`RedisLayer.clear()` is intentionally conservative. Without a `prefix`, it throws instead of deleting the whole Redis database.
|
|
@@ -622,6 +637,8 @@ await cache.get('leaderboard', fetchLeaderboard, {
|
|
|
622
637
|
})
|
|
623
638
|
```
|
|
624
639
|
|
|
640
|
+
Background refreshes time out after 30 seconds by default so a hung upstream fetch cannot block future refresh attempts forever. Override that with `backgroundRefreshTimeoutMs`.
|
|
641
|
+
|
|
625
642
|
---
|
|
626
643
|
|
|
627
644
|
## Graceful degradation & circuit breaker
|
|
@@ -718,6 +735,8 @@ await cache.persistToFile('./cache-snapshot.json')
|
|
|
718
735
|
await cache.restoreFromFile('./cache-snapshot.json')
|
|
719
736
|
```
|
|
720
737
|
|
|
738
|
+
For safety, file snapshots are restricted to `process.cwd()` by default. Set `snapshotBaseDir` to an explicit directory for application-controlled snapshot storage, or `false` if you intentionally want to disable that restriction.
|
|
739
|
+
|
|
721
740
|
---
|
|
722
741
|
|
|
723
742
|
## Event hooks
|
|
@@ -1019,7 +1038,7 @@ new CacheStack([...], {
|
|
|
1019
1038
|
|
|
1020
1039
|
## Requirements
|
|
1021
1040
|
|
|
1022
|
-
- Node.js ≥
|
|
1041
|
+
- Node.js ≥ 20
|
|
1023
1042
|
- TypeScript ≥ 5.0 (optional — fully typed, ships `.d.ts`)
|
|
1024
1043
|
- ioredis ≥ 5 (optional peer dependency — only needed for `RedisLayer` / `RedisTagIndex`)
|
|
1025
1044
|
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
// src/internal/StoredValue.ts
|
|
2
2
|
function isStoredValueEnvelope(value) {
|
|
3
|
-
|
|
3
|
+
if (typeof value !== "object" || value === null) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
const v = value;
|
|
7
|
+
if (v.__layercache !== 1) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
if (v.kind !== "value" && v.kind !== "empty") {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
|
|
23
|
+
if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
4
27
|
}
|
|
5
28
|
function createStoredValueEnvelope(options) {
|
|
6
29
|
const now = options.now ?? Date.now();
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
PatternMatcher,
|
|
3
2
|
unwrapStoredValue
|
|
4
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-7V7XAB74.js";
|
|
5
4
|
|
|
6
5
|
// src/layers/MemoryLayer.ts
|
|
7
6
|
var MemoryLayer = class {
|
|
@@ -49,16 +48,10 @@ var MemoryLayer = class {
|
|
|
49
48
|
return entry.value;
|
|
50
49
|
}
|
|
51
50
|
async getMany(keys) {
|
|
52
|
-
|
|
53
|
-
for (const key of keys) {
|
|
54
|
-
values.push(await this.getEntry(key));
|
|
55
|
-
}
|
|
56
|
-
return values;
|
|
51
|
+
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
57
52
|
}
|
|
58
53
|
async setMany(entries) {
|
|
59
|
-
|
|
60
|
-
await this.set(entry.key, entry.value, entry.ttl);
|
|
61
|
-
}
|
|
54
|
+
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
|
|
62
55
|
}
|
|
63
56
|
async set(key, value, ttl = this.defaultTtl) {
|
|
64
57
|
this.entries.delete(key);
|
|
@@ -197,15 +190,17 @@ var TagIndex = class {
|
|
|
197
190
|
keyToTags = /* @__PURE__ */ new Map();
|
|
198
191
|
knownKeys = /* @__PURE__ */ new Set();
|
|
199
192
|
maxKnownKeys;
|
|
193
|
+
nextNodeId = 1;
|
|
194
|
+
root = this.createTrieNode();
|
|
200
195
|
constructor(options = {}) {
|
|
201
|
-
this.maxKnownKeys = options.maxKnownKeys;
|
|
196
|
+
this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
|
|
202
197
|
}
|
|
203
198
|
async touch(key) {
|
|
204
|
-
this.
|
|
199
|
+
this.insertKnownKey(key);
|
|
205
200
|
this.pruneKnownKeysIfNeeded();
|
|
206
201
|
}
|
|
207
202
|
async track(key, tags) {
|
|
208
|
-
this.
|
|
203
|
+
this.insertKnownKey(key);
|
|
209
204
|
this.pruneKnownKeysIfNeeded();
|
|
210
205
|
if (tags.length === 0) {
|
|
211
206
|
return;
|
|
@@ -231,18 +226,104 @@ var TagIndex = class {
|
|
|
231
226
|
return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
|
|
232
227
|
}
|
|
233
228
|
async keysForPrefix(prefix) {
|
|
234
|
-
|
|
229
|
+
const node = this.findNode(prefix);
|
|
230
|
+
if (!node) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
const matches = [];
|
|
234
|
+
this.collectFromNode(node, prefix, matches);
|
|
235
|
+
return matches;
|
|
235
236
|
}
|
|
236
237
|
async tagsForKey(key) {
|
|
237
238
|
return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
|
|
238
239
|
}
|
|
239
240
|
async matchPattern(pattern) {
|
|
240
|
-
|
|
241
|
+
const matches = /* @__PURE__ */ new Set();
|
|
242
|
+
this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
|
|
243
|
+
return [...matches];
|
|
241
244
|
}
|
|
242
245
|
async clear() {
|
|
243
246
|
this.tagToKeys.clear();
|
|
244
247
|
this.keyToTags.clear();
|
|
245
248
|
this.knownKeys.clear();
|
|
249
|
+
this.root.children.clear();
|
|
250
|
+
this.root.terminal = false;
|
|
251
|
+
this.nextNodeId = this.root.id + 1;
|
|
252
|
+
}
|
|
253
|
+
createTrieNode() {
|
|
254
|
+
return {
|
|
255
|
+
id: this.nextNodeId++,
|
|
256
|
+
terminal: false,
|
|
257
|
+
children: /* @__PURE__ */ new Map()
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
insertKnownKey(key) {
|
|
261
|
+
if (this.knownKeys.has(key)) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
this.knownKeys.add(key);
|
|
265
|
+
let node = this.root;
|
|
266
|
+
for (const character of key) {
|
|
267
|
+
let child = node.children.get(character);
|
|
268
|
+
if (!child) {
|
|
269
|
+
child = this.createTrieNode();
|
|
270
|
+
node.children.set(character, child);
|
|
271
|
+
}
|
|
272
|
+
node = child;
|
|
273
|
+
}
|
|
274
|
+
node.terminal = true;
|
|
275
|
+
}
|
|
276
|
+
findNode(prefix) {
|
|
277
|
+
let node = this.root;
|
|
278
|
+
for (const character of prefix) {
|
|
279
|
+
node = node.children.get(character);
|
|
280
|
+
if (!node) {
|
|
281
|
+
return void 0;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return node;
|
|
285
|
+
}
|
|
286
|
+
collectFromNode(node, prefix, matches) {
|
|
287
|
+
if (node.terminal) {
|
|
288
|
+
matches.push(prefix);
|
|
289
|
+
}
|
|
290
|
+
for (const [character, child] of node.children) {
|
|
291
|
+
this.collectFromNode(child, `${prefix}${character}`, matches);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
|
|
295
|
+
const stateKey = `${node.id}:${patternIndex}`;
|
|
296
|
+
if (visited.has(stateKey)) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
visited.add(stateKey);
|
|
300
|
+
if (patternIndex === pattern.length) {
|
|
301
|
+
if (node.terminal) {
|
|
302
|
+
matches.add(prefix);
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const patternChar = pattern[patternIndex];
|
|
307
|
+
if (patternChar === void 0) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (patternChar === "*") {
|
|
311
|
+
this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
|
|
312
|
+
for (const [character, child2] of node.children) {
|
|
313
|
+
this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (patternChar === "?") {
|
|
318
|
+
for (const [character, child2] of node.children) {
|
|
319
|
+
this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const child = node.children.get(patternChar);
|
|
324
|
+
if (child) {
|
|
325
|
+
this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
|
|
326
|
+
}
|
|
246
327
|
}
|
|
247
328
|
pruneKnownKeysIfNeeded() {
|
|
248
329
|
if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
|
|
@@ -259,7 +340,7 @@ var TagIndex = class {
|
|
|
259
340
|
}
|
|
260
341
|
}
|
|
261
342
|
removeKey(key) {
|
|
262
|
-
this.
|
|
343
|
+
this.removeKnownKey(key);
|
|
263
344
|
const tags = this.keyToTags.get(key);
|
|
264
345
|
if (!tags) {
|
|
265
346
|
return;
|
|
@@ -276,6 +357,34 @@ var TagIndex = class {
|
|
|
276
357
|
}
|
|
277
358
|
this.keyToTags.delete(key);
|
|
278
359
|
}
|
|
360
|
+
removeKnownKey(key) {
|
|
361
|
+
if (!this.knownKeys.delete(key)) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const path = [];
|
|
365
|
+
let node = this.root;
|
|
366
|
+
for (const character of key) {
|
|
367
|
+
const child = node.children.get(character);
|
|
368
|
+
if (!child) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
path.push([node, character]);
|
|
372
|
+
node = child;
|
|
373
|
+
}
|
|
374
|
+
node.terminal = false;
|
|
375
|
+
for (let index = path.length - 1; index >= 0; index -= 1) {
|
|
376
|
+
const entry = path[index];
|
|
377
|
+
if (!entry) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const [parent, character] = entry;
|
|
381
|
+
const child = parent.children.get(character);
|
|
382
|
+
if (!child || child.terminal || child.children.size > 0) {
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
parent.children.delete(character);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
279
388
|
};
|
|
280
389
|
|
|
281
390
|
// src/integrations/hono.ts
|
|
@@ -287,7 +396,8 @@ function createHonoCacheMiddleware(cache, options = {}) {
|
|
|
287
396
|
await next();
|
|
288
397
|
return;
|
|
289
398
|
}
|
|
290
|
-
const
|
|
399
|
+
const rawPath = context.req.path ?? context.req.url ?? "/";
|
|
400
|
+
const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeUrl(rawPath)}`;
|
|
291
401
|
const cached = await cache.get(key, void 0, options);
|
|
292
402
|
if (cached !== null) {
|
|
293
403
|
context.header?.("x-cache", "HIT");
|
|
@@ -298,12 +408,26 @@ function createHonoCacheMiddleware(cache, options = {}) {
|
|
|
298
408
|
const originalJson = context.json.bind(context);
|
|
299
409
|
context.json = (body, status) => {
|
|
300
410
|
context.header?.("x-cache", "MISS");
|
|
301
|
-
|
|
411
|
+
cache.set(key, body, options).catch((err) => {
|
|
412
|
+
cache.emit("error", {
|
|
413
|
+
operation: "set",
|
|
414
|
+
error: err instanceof Error ? err.message : String(err)
|
|
415
|
+
});
|
|
416
|
+
});
|
|
302
417
|
return originalJson(body, status);
|
|
303
418
|
};
|
|
304
419
|
await next();
|
|
305
420
|
};
|
|
306
421
|
}
|
|
422
|
+
function normalizeUrl(url) {
|
|
423
|
+
try {
|
|
424
|
+
const parsed = new URL(url, "http://localhost");
|
|
425
|
+
parsed.searchParams.sort();
|
|
426
|
+
return decodeURIComponent(parsed.pathname) + parsed.search;
|
|
427
|
+
} catch {
|
|
428
|
+
return url;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
307
431
|
|
|
308
432
|
export {
|
|
309
433
|
MemoryLayer,
|
package/dist/cli.cjs
CHANGED
|
@@ -38,7 +38,30 @@ var import_ioredis = __toESM(require("ioredis"), 1);
|
|
|
38
38
|
|
|
39
39
|
// src/internal/StoredValue.ts
|
|
40
40
|
function isStoredValueEnvelope(value) {
|
|
41
|
-
|
|
41
|
+
if (typeof value !== "object" || value === null) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const v = value;
|
|
45
|
+
if (v.__layercache !== 1) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (v.kind !== "value" && v.kind !== "empty") {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
|
|
61
|
+
if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
42
65
|
}
|
|
43
66
|
function resolveStoredValue(stored, now = Date.now()) {
|
|
44
67
|
if (!isStoredValueEnvelope(stored)) {
|
|
@@ -259,7 +282,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
259
282
|
const redisUrl = validateRedisUrl(args.redisUrl);
|
|
260
283
|
if (!redisUrl) {
|
|
261
284
|
process.stderr.write(
|
|
262
|
-
`Error: invalid Redis URL "${args.redisUrl}". Expected format: redis://[user:password@]host[:port][/db]
|
|
285
|
+
`Error: invalid Redis URL "${maskRedisUrl(args.redisUrl)}". Expected format: redis://[user:password@]host[:port][/db]
|
|
263
286
|
`
|
|
264
287
|
);
|
|
265
288
|
process.exitCode = 1;
|
|
@@ -273,7 +296,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
273
296
|
try {
|
|
274
297
|
await redis.connect().catch((error) => {
|
|
275
298
|
const message = error instanceof Error ? error.message : String(error);
|
|
276
|
-
throw new Error(`Failed to connect to Redis at "${redisUrl}": ${message}`);
|
|
299
|
+
throw new Error(`Failed to connect to Redis at "${maskRedisUrl(redisUrl)}": ${message}`);
|
|
277
300
|
});
|
|
278
301
|
if (args.command === "stats") {
|
|
279
302
|
const keys = await scanKeys(redis, args.pattern ?? "*");
|
|
@@ -428,6 +451,17 @@ function summarizeInspectableValue(value) {
|
|
|
428
451
|
}
|
|
429
452
|
return value;
|
|
430
453
|
}
|
|
454
|
+
function maskRedisUrl(url) {
|
|
455
|
+
try {
|
|
456
|
+
const parsed = new URL(url);
|
|
457
|
+
if (parsed.password) {
|
|
458
|
+
parsed.password = "***";
|
|
459
|
+
}
|
|
460
|
+
return parsed.toString();
|
|
461
|
+
} catch {
|
|
462
|
+
return url.replace(/:([^@/]+)@/, ":***@");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
431
465
|
if (process.argv[1]?.includes("cli.")) {
|
|
432
466
|
void main();
|
|
433
467
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
RedisTagIndex
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-QHWG7QS5.js";
|
|
5
5
|
import {
|
|
6
6
|
isStoredValueEnvelope,
|
|
7
7
|
resolveStoredValue
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-7V7XAB74.js";
|
|
9
9
|
|
|
10
10
|
// src/cli.ts
|
|
11
11
|
import Redis from "ioredis";
|
|
@@ -20,7 +20,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
20
20
|
const redisUrl = validateRedisUrl(args.redisUrl);
|
|
21
21
|
if (!redisUrl) {
|
|
22
22
|
process.stderr.write(
|
|
23
|
-
`Error: invalid Redis URL "${args.redisUrl}". Expected format: redis://[user:password@]host[:port][/db]
|
|
23
|
+
`Error: invalid Redis URL "${maskRedisUrl(args.redisUrl)}". Expected format: redis://[user:password@]host[:port][/db]
|
|
24
24
|
`
|
|
25
25
|
);
|
|
26
26
|
process.exitCode = 1;
|
|
@@ -34,7 +34,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
34
34
|
try {
|
|
35
35
|
await redis.connect().catch((error) => {
|
|
36
36
|
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
-
throw new Error(`Failed to connect to Redis at "${redisUrl}": ${message}`);
|
|
37
|
+
throw new Error(`Failed to connect to Redis at "${maskRedisUrl(redisUrl)}": ${message}`);
|
|
38
38
|
});
|
|
39
39
|
if (args.command === "stats") {
|
|
40
40
|
const keys = await scanKeys(redis, args.pattern ?? "*");
|
|
@@ -189,6 +189,17 @@ function summarizeInspectableValue(value) {
|
|
|
189
189
|
}
|
|
190
190
|
return value;
|
|
191
191
|
}
|
|
192
|
+
function maskRedisUrl(url) {
|
|
193
|
+
try {
|
|
194
|
+
const parsed = new URL(url);
|
|
195
|
+
if (parsed.password) {
|
|
196
|
+
parsed.password = "***";
|
|
197
|
+
}
|
|
198
|
+
return parsed.toString();
|
|
199
|
+
} catch {
|
|
200
|
+
return url.replace(/:([^@/]+)@/, ":***@");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
192
203
|
if (process.argv[1]?.includes("cli.")) {
|
|
193
204
|
void main();
|
|
194
205
|
}
|
|
@@ -177,6 +177,7 @@ interface CacheStackOptions {
|
|
|
177
177
|
invalidationBus?: InvalidationBus;
|
|
178
178
|
tagIndex?: CacheTagIndex;
|
|
179
179
|
generation?: number;
|
|
180
|
+
generationCleanup?: boolean | CacheGenerationCleanupOptions;
|
|
180
181
|
broadcastL1Invalidation?: boolean;
|
|
181
182
|
/**
|
|
182
183
|
* @deprecated Use `broadcastL1Invalidation` instead.
|
|
@@ -195,11 +196,13 @@ interface CacheStackOptions {
|
|
|
195
196
|
writeStrategy?: 'write-through' | 'write-behind';
|
|
196
197
|
writeBehind?: CacheWriteBehindOptions;
|
|
197
198
|
fetcherRateLimit?: CacheRateLimitOptions;
|
|
199
|
+
backgroundRefreshTimeoutMs?: number;
|
|
198
200
|
singleFlightCoordinator?: CacheSingleFlightCoordinator;
|
|
199
201
|
singleFlightLeaseMs?: number;
|
|
200
202
|
singleFlightTimeoutMs?: number;
|
|
201
203
|
singleFlightPollMs?: number;
|
|
202
204
|
singleFlightRenewIntervalMs?: number;
|
|
205
|
+
snapshotBaseDir?: string | false;
|
|
203
206
|
/**
|
|
204
207
|
* Maximum number of entries in `accessProfiles` and `circuitBreakers` maps
|
|
205
208
|
* before the oldest entries are pruned. Prevents unbounded memory growth.
|
|
@@ -212,6 +215,9 @@ interface CacheAdaptiveTtlOptions {
|
|
|
212
215
|
step?: number | LayerTtlMap;
|
|
213
216
|
maxTtl?: number | LayerTtlMap;
|
|
214
217
|
}
|
|
218
|
+
interface CacheGenerationCleanupOptions {
|
|
219
|
+
batchSize?: number;
|
|
220
|
+
}
|
|
215
221
|
type CacheTtlPolicy = 'until-midnight' | 'next-hour' | {
|
|
216
222
|
alignTo: number;
|
|
217
223
|
} | ((context: CacheTtlPolicyContext) => number | undefined);
|
|
@@ -415,7 +421,7 @@ interface TagIndexOptions {
|
|
|
415
421
|
/**
|
|
416
422
|
* Maximum number of keys tracked in `knownKeys`. When exceeded, the oldest
|
|
417
423
|
* 10 % of keys are pruned to keep memory bounded.
|
|
418
|
-
* Defaults to
|
|
424
|
+
* Defaults to 100,000.
|
|
419
425
|
*/
|
|
420
426
|
maxKnownKeys?: number;
|
|
421
427
|
}
|
|
@@ -424,6 +430,8 @@ declare class TagIndex implements CacheTagIndex {
|
|
|
424
430
|
private readonly keyToTags;
|
|
425
431
|
private readonly knownKeys;
|
|
426
432
|
private readonly maxKnownKeys;
|
|
433
|
+
private nextNodeId;
|
|
434
|
+
private readonly root;
|
|
427
435
|
constructor(options?: TagIndexOptions);
|
|
428
436
|
touch(key: string): Promise<void>;
|
|
429
437
|
track(key: string, tags: string[]): Promise<void>;
|
|
@@ -433,8 +441,14 @@ declare class TagIndex implements CacheTagIndex {
|
|
|
433
441
|
tagsForKey(key: string): Promise<string[]>;
|
|
434
442
|
matchPattern(pattern: string): Promise<string[]>;
|
|
435
443
|
clear(): Promise<void>;
|
|
444
|
+
private createTrieNode;
|
|
445
|
+
private insertKnownKey;
|
|
446
|
+
private findNode;
|
|
447
|
+
private collectFromNode;
|
|
448
|
+
private collectPatternMatches;
|
|
436
449
|
private pruneKnownKeysIfNeeded;
|
|
437
450
|
private removeKey;
|
|
451
|
+
private removeKnownKey;
|
|
438
452
|
}
|
|
439
453
|
|
|
440
454
|
declare class CacheNamespace {
|
|
@@ -505,6 +519,7 @@ declare class CacheStack extends EventEmitter {
|
|
|
505
519
|
private readonly logger;
|
|
506
520
|
private readonly tagIndex;
|
|
507
521
|
private readonly fetchRateLimiter;
|
|
522
|
+
private readonly snapshotSerializer;
|
|
508
523
|
private readonly backgroundRefreshes;
|
|
509
524
|
private readonly layerDegradedUntil;
|
|
510
525
|
private readonly ttlResolver;
|
|
@@ -513,6 +528,7 @@ declare class CacheStack extends EventEmitter {
|
|
|
513
528
|
private readonly writeBehindQueue;
|
|
514
529
|
private writeBehindTimer?;
|
|
515
530
|
private writeBehindFlushPromise?;
|
|
531
|
+
private generationCleanupPromise?;
|
|
516
532
|
private isDisconnecting;
|
|
517
533
|
private disconnectPromise?;
|
|
518
534
|
constructor(layers: CacheLayer[], options?: CacheStackOptions);
|
|
@@ -523,6 +539,7 @@ declare class CacheStack extends EventEmitter {
|
|
|
523
539
|
* and no `fetcher` is provided.
|
|
524
540
|
*/
|
|
525
541
|
get<T>(key: string, fetcher?: () => Promise<T>, options?: CacheGetOptions): Promise<T | null>;
|
|
542
|
+
private getPrepared;
|
|
526
543
|
/**
|
|
527
544
|
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
528
545
|
* Fetches and caches the value if not already present.
|
|
@@ -581,6 +598,11 @@ declare class CacheStack extends EventEmitter {
|
|
|
581
598
|
*/
|
|
582
599
|
getHitRate(): CacheHitRateSnapshot;
|
|
583
600
|
healthCheck(): Promise<CacheHealthCheckResult[]>;
|
|
601
|
+
/**
|
|
602
|
+
* Rotates the active generation prefix used for all future cache keys.
|
|
603
|
+
* Previous-generation keys remain in the underlying layers until they expire,
|
|
604
|
+
* unless `generationCleanup` is enabled to prune them in the background.
|
|
605
|
+
*/
|
|
584
606
|
bumpGeneration(nextGeneration?: number): number;
|
|
585
607
|
/**
|
|
586
608
|
* Returns detailed metadata about a single cache key: which layers contain it,
|
|
@@ -608,6 +630,7 @@ declare class CacheStack extends EventEmitter {
|
|
|
608
630
|
private resolveLayerSeconds;
|
|
609
631
|
private shouldNegativeCache;
|
|
610
632
|
private scheduleBackgroundRefresh;
|
|
633
|
+
private runBackgroundRefresh;
|
|
611
634
|
private resolveSingleFlightOptions;
|
|
612
635
|
private deleteKeys;
|
|
613
636
|
private publishInvalidation;
|
|
@@ -615,7 +638,14 @@ declare class CacheStack extends EventEmitter {
|
|
|
615
638
|
private getTagsForKey;
|
|
616
639
|
private formatError;
|
|
617
640
|
private sleep;
|
|
641
|
+
private withTimeout;
|
|
618
642
|
private shouldBroadcastL1Invalidation;
|
|
643
|
+
private collectKeysWithPrefix;
|
|
644
|
+
private collectKeysMatchingPattern;
|
|
645
|
+
private shouldCleanupGenerations;
|
|
646
|
+
private generationCleanupBatchSize;
|
|
647
|
+
private scheduleGenerationCleanup;
|
|
648
|
+
private cleanupGeneration;
|
|
619
649
|
private initializeWriteBehind;
|
|
620
650
|
private shouldWriteBehind;
|
|
621
651
|
private enqueueWriteBehind;
|
|
@@ -643,12 +673,15 @@ declare class CacheStack extends EventEmitter {
|
|
|
643
673
|
private applyFreshReadPolicies;
|
|
644
674
|
private shouldSkipLayer;
|
|
645
675
|
private handleLayerFailure;
|
|
676
|
+
private reportRecoverableLayerFailure;
|
|
646
677
|
private isGracefulDegradationEnabled;
|
|
647
678
|
private recordCircuitFailure;
|
|
648
679
|
private isNegativeStoredValue;
|
|
649
680
|
private emitError;
|
|
650
681
|
private serializeKeyPart;
|
|
651
682
|
private isCacheSnapshotEntries;
|
|
683
|
+
private sanitizeSnapshotValue;
|
|
684
|
+
private validateSnapshotFilePath;
|
|
652
685
|
private normalizeForSerialization;
|
|
653
686
|
}
|
|
654
687
|
|