layercache 1.2.1 → 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 +66 -11
- package/dist/{chunk-ZMDB5KOK.js → chunk-7V7XAB74.js} +24 -1
- package/dist/{chunk-46UH7LNM.js → chunk-KOYGHLVP.js} +142 -18
- package/dist/{chunk-GF47Y3XR.js → chunk-QHWG7QS5.js} +56 -25
- package/dist/cli.cjs +92 -27
- package/dist/cli.js +15 -4
- package/dist/{edge-C1sBhTfv.d.ts → edge-B_rUqDy6.d.cts} +39 -1
- package/dist/{edge-C1sBhTfv.d.cts → edge-B_rUqDy6.d.ts} +39 -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 +798 -127
- package/dist/index.d.cts +18 -3
- package/dist/index.d.ts +18 -3
- package/dist/index.js +582 -90
- package/package.json +5 -5
- package/packages/nestjs/dist/index.cjs +582 -61
- package/packages/nestjs/dist/index.d.cts +30 -0
- package/packages/nestjs/dist/index.d.ts +30 -0
- package/packages/nestjs/dist/index.js +582 -61
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# layercache
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Production-ready multi-layer caching for Node.js — memory, Redis, persistence, invalidation, and resilience in one API.**
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/layercache)
|
|
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
|
|
@@ -26,6 +26,8 @@ Most Node.js services end up with the same problem:
|
|
|
26
26
|
|
|
27
27
|
layercache solves all three. You declare your layers once and call `get`. Everything else is handled.
|
|
28
28
|
|
|
29
|
+
It is designed for production services that need predictable cache behavior under load: stampede prevention, cross-instance invalidation, layered TTL control, operational metrics, and safer persistence defaults.
|
|
30
|
+
|
|
29
31
|
```ts
|
|
30
32
|
const user = await cache.get('user:123', () => db.findUser(123))
|
|
31
33
|
// ↑ only called on a full miss
|
|
@@ -43,7 +45,7 @@ On a hit, the value is returned from the fastest layer that has it, and automati
|
|
|
43
45
|
- **Batch tag invalidation** — `invalidateByTags(['tenant:a', 'users'], 'all')` for OR/AND invalidation in one call
|
|
44
46
|
- **Pattern invalidation** — `invalidateByPattern('user:*')`
|
|
45
47
|
- **Prefix invalidation** — efficient `invalidateByPrefix('user:123:')` for hierarchical keys
|
|
46
|
-
- **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
|
|
47
49
|
- **Per-layer TTL overrides** — different TTLs for memory vs. Redis in one call
|
|
48
50
|
- **TTL policies** — align TTLs to time boundaries (`until-midnight`, `next-hour`, `{ alignTo }`, or a function)
|
|
49
51
|
- **Negative caching** — cache known misses for a short TTL to protect the database
|
|
@@ -82,7 +84,7 @@ On a hit, the value is returned from the fastest layer that has it, and automati
|
|
|
82
84
|
- **Nested namespaces** — `namespace('a').namespace('b')` for composable key prefixes with namespace-scoped metrics
|
|
83
85
|
- **Custom layers** — implement the 5-method `CacheLayer` interface to plug in Memcached, DynamoDB, or anything else
|
|
84
86
|
- **Edge-safe entry point** — `layercache/edge` exports the non-Node helpers for Worker-style runtimes
|
|
85
|
-
- **ESM + CJS** — works with both module systems, Node.js ≥
|
|
87
|
+
- **ESM + CJS** — works with both module systems, Node.js ≥ 20
|
|
86
88
|
|
|
87
89
|
---
|
|
88
90
|
|
|
@@ -171,7 +173,7 @@ await cache.set('user:123', user, {
|
|
|
171
173
|
|
|
172
174
|
### `cache.invalidateByTag(tag): Promise<void>`
|
|
173
175
|
|
|
174
|
-
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`).
|
|
175
177
|
|
|
176
178
|
```ts
|
|
177
179
|
await cache.set('user:123', user, { tags: ['user:123'] })
|
|
@@ -191,12 +193,14 @@ await cache.invalidateByTags(['users', 'posts'], 'any') // keys tagged with e
|
|
|
191
193
|
|
|
192
194
|
### `cache.invalidateByPattern(pattern): Promise<void>`
|
|
193
195
|
|
|
194
|
-
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`).
|
|
195
197
|
|
|
196
198
|
```ts
|
|
197
199
|
await cache.invalidateByPattern('user:*') // deletes user:1, user:2, …
|
|
198
200
|
```
|
|
199
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
|
+
|
|
200
204
|
### `cache.invalidateByPrefix(prefix): Promise<void>`
|
|
201
205
|
|
|
202
206
|
Prefer this over glob invalidation when your keys are hierarchical.
|
|
@@ -278,6 +282,17 @@ await cache.set('user:123', user)
|
|
|
278
282
|
cache.bumpGeneration() // now reads use v2:user:123
|
|
279
283
|
```
|
|
280
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
|
+
|
|
281
296
|
### OpenTelemetry note
|
|
282
297
|
|
|
283
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.
|
|
@@ -452,13 +467,14 @@ const cache = new CacheStack(
|
|
|
452
467
|
{
|
|
453
468
|
singleFlightCoordinator: coordinator,
|
|
454
469
|
singleFlightLeaseMs: 30_000,
|
|
470
|
+
singleFlightRenewIntervalMs: 10_000,
|
|
455
471
|
singleFlightTimeoutMs: 5_000,
|
|
456
472
|
singleFlightPollMs: 50
|
|
457
473
|
}
|
|
458
474
|
)
|
|
459
475
|
```
|
|
460
476
|
|
|
461
|
-
When another instance already owns the miss, the current process waits for the value to appear in the shared layer instead of running the fetcher again.
|
|
477
|
+
When another instance already owns the miss, the current process waits for the value to appear in the shared layer instead of running the fetcher again. `RedisSingleFlightCoordinator` also renews its Redis lease while the worker is still running, so long fetches are less likely to expire their lock mid-flight. Keep `singleFlightLeaseMs` comfortably above your expected fetch latency, and use `singleFlightRenewIntervalMs` if you need tighter control over renewal cadence.
|
|
462
478
|
|
|
463
479
|
### Cross-server L1 invalidation
|
|
464
480
|
|
|
@@ -479,10 +495,10 @@ const cache = new CacheStack(
|
|
|
479
495
|
await cache.disconnect() // unsubscribes cleanly on shutdown
|
|
480
496
|
```
|
|
481
497
|
|
|
482
|
-
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:
|
|
483
499
|
|
|
484
500
|
```ts
|
|
485
|
-
new CacheStack([...], { invalidationBus: bus,
|
|
501
|
+
new CacheStack([...], { invalidationBus: bus, broadcastL1Invalidation: true })
|
|
486
502
|
```
|
|
487
503
|
|
|
488
504
|
### Distributed tag invalidation
|
|
@@ -494,7 +510,8 @@ import { RedisTagIndex } from 'layercache'
|
|
|
494
510
|
|
|
495
511
|
const sharedTagIndex = new RedisTagIndex({
|
|
496
512
|
client: redis,
|
|
497
|
-
prefix: 'myapp:tag-index' // namespaced so it doesn't collide with other data
|
|
513
|
+
prefix: 'myapp:tag-index', // namespaced so it doesn't collide with other data
|
|
514
|
+
knownKeysShards: 8
|
|
498
515
|
})
|
|
499
516
|
|
|
500
517
|
// Every CacheStack instance should use the same Redis-backed tag index config
|
|
@@ -506,6 +523,8 @@ const cache = new CacheStack(
|
|
|
506
523
|
|
|
507
524
|
Now `invalidateByTag('user:123')` on any server deletes every tagged key, regardless of which server originally wrote it.
|
|
508
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
|
+
|
|
509
528
|
### Safe Redis clearing
|
|
510
529
|
|
|
511
530
|
`RedisLayer.clear()` is intentionally conservative. Without a `prefix`, it throws instead of deleting the whole Redis database.
|
|
@@ -528,6 +547,38 @@ new RedisLayer({
|
|
|
528
547
|
})
|
|
529
548
|
```
|
|
530
549
|
|
|
550
|
+
For production Redis, also set an explicit `prefix`, enforce Redis authentication/network isolation, and configure Redis `maxmemory` / eviction policy so cache growth cannot starve unrelated workloads.
|
|
551
|
+
|
|
552
|
+
### DiskLayer safety
|
|
553
|
+
|
|
554
|
+
`DiskLayer` is best used with an application-controlled directory and an explicit `maxFiles` bound.
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
import { resolve } from 'node:path'
|
|
558
|
+
|
|
559
|
+
const disk = new DiskLayer({
|
|
560
|
+
directory: resolve('./var/cache/layercache'),
|
|
561
|
+
maxFiles: 10_000
|
|
562
|
+
})
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
The library hashes cache keys before turning them into filenames, validates the configured directory, uses atomic temp-file writes, and removes malformed on-disk entries. You should still keep the directory outside any user-controlled path and set filesystem permissions so only your app can read or write it.
|
|
566
|
+
|
|
567
|
+
### Scoped fetcher rate limiting
|
|
568
|
+
|
|
569
|
+
Rate limits are global by default, but you can scope them per cache key or per fetcher function when different backends should not throttle each other.
|
|
570
|
+
|
|
571
|
+
```ts
|
|
572
|
+
await cache.get('user:123', fetchUser, {
|
|
573
|
+
fetcherRateLimit: {
|
|
574
|
+
maxConcurrent: 1,
|
|
575
|
+
scope: 'key'
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Use `scope: 'fetcher'` to share a bucket across calls using the same fetcher function reference, or `bucketKey: 'billing-api'` for a custom named bucket.
|
|
581
|
+
|
|
531
582
|
---
|
|
532
583
|
|
|
533
584
|
## Per-layer TTL overrides
|
|
@@ -586,6 +637,8 @@ await cache.get('leaderboard', fetchLeaderboard, {
|
|
|
586
637
|
})
|
|
587
638
|
```
|
|
588
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
|
+
|
|
589
642
|
---
|
|
590
643
|
|
|
591
644
|
## Graceful degradation & circuit breaker
|
|
@@ -682,6 +735,8 @@ await cache.persistToFile('./cache-snapshot.json')
|
|
|
682
735
|
await cache.restoreFromFile('./cache-snapshot.json')
|
|
683
736
|
```
|
|
684
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
|
+
|
|
685
740
|
---
|
|
686
741
|
|
|
687
742
|
## Event hooks
|
|
@@ -983,7 +1038,7 @@ new CacheStack([...], {
|
|
|
983
1038
|
|
|
984
1039
|
## Requirements
|
|
985
1040
|
|
|
986
|
-
- Node.js ≥
|
|
1041
|
+
- Node.js ≥ 20
|
|
987
1042
|
- TypeScript ≥ 5.0 (optional — fully typed, ships `.d.ts`)
|
|
988
1043
|
- ioredis ≥ 5 (optional peer dependency — only needed for `RedisLayer` / `RedisTagIndex`)
|
|
989
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,
|
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PatternMatcher
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-7V7XAB74.js";
|
|
4
4
|
|
|
5
5
|
// src/invalidation/RedisTagIndex.ts
|
|
6
6
|
var RedisTagIndex = class {
|
|
7
7
|
client;
|
|
8
8
|
prefix;
|
|
9
9
|
scanCount;
|
|
10
|
+
knownKeysShards;
|
|
10
11
|
constructor(options) {
|
|
11
12
|
this.client = options.client;
|
|
12
13
|
this.prefix = options.prefix ?? "layercache:tag-index";
|
|
13
14
|
this.scanCount = options.scanCount ?? 100;
|
|
15
|
+
this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
|
|
14
16
|
}
|
|
15
17
|
async touch(key) {
|
|
16
|
-
await this.client.sadd(this.
|
|
18
|
+
await this.client.sadd(this.knownKeysKeyFor(key), key);
|
|
17
19
|
}
|
|
18
20
|
async track(key, tags) {
|
|
19
21
|
const keyTagsKey = this.keyTagsKey(key);
|
|
20
22
|
const existingTags = await this.client.smembers(keyTagsKey);
|
|
21
23
|
const pipeline = this.client.pipeline();
|
|
22
|
-
pipeline.sadd(this.
|
|
24
|
+
pipeline.sadd(this.knownKeysKeyFor(key), key);
|
|
23
25
|
for (const tag of existingTags) {
|
|
24
26
|
pipeline.srem(this.tagKeysKey(tag), key);
|
|
25
27
|
}
|
|
@@ -36,7 +38,7 @@ var RedisTagIndex = class {
|
|
|
36
38
|
const keyTagsKey = this.keyTagsKey(key);
|
|
37
39
|
const existingTags = await this.client.smembers(keyTagsKey);
|
|
38
40
|
const pipeline = this.client.pipeline();
|
|
39
|
-
pipeline.srem(this.
|
|
41
|
+
pipeline.srem(this.knownKeysKeyFor(key), key);
|
|
40
42
|
pipeline.del(keyTagsKey);
|
|
41
43
|
for (const tag of existingTags) {
|
|
42
44
|
pipeline.srem(this.tagKeysKey(tag), key);
|
|
@@ -48,12 +50,14 @@ var RedisTagIndex = class {
|
|
|
48
50
|
}
|
|
49
51
|
async keysForPrefix(prefix) {
|
|
50
52
|
const matches = [];
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
for (const knownKeysKey of this.knownKeysKeys()) {
|
|
54
|
+
let cursor = "0";
|
|
55
|
+
do {
|
|
56
|
+
const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
|
|
57
|
+
cursor = nextCursor;
|
|
58
|
+
matches.push(...keys.filter((key) => key.startsWith(prefix)));
|
|
59
|
+
} while (cursor !== "0");
|
|
60
|
+
}
|
|
57
61
|
return matches;
|
|
58
62
|
}
|
|
59
63
|
async tagsForKey(key) {
|
|
@@ -61,19 +65,21 @@ var RedisTagIndex = class {
|
|
|
61
65
|
}
|
|
62
66
|
async matchPattern(pattern) {
|
|
63
67
|
const matches = [];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
this.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
for (const knownKeysKey of this.knownKeysKeys()) {
|
|
69
|
+
let cursor = "0";
|
|
70
|
+
do {
|
|
71
|
+
const [nextCursor, keys] = await this.client.sscan(
|
|
72
|
+
knownKeysKey,
|
|
73
|
+
cursor,
|
|
74
|
+
"MATCH",
|
|
75
|
+
pattern,
|
|
76
|
+
"COUNT",
|
|
77
|
+
this.scanCount
|
|
78
|
+
);
|
|
79
|
+
cursor = nextCursor;
|
|
80
|
+
matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
|
|
81
|
+
} while (cursor !== "0");
|
|
82
|
+
}
|
|
77
83
|
return matches;
|
|
78
84
|
}
|
|
79
85
|
async clear() {
|
|
@@ -94,8 +100,17 @@ var RedisTagIndex = class {
|
|
|
94
100
|
} while (cursor !== "0");
|
|
95
101
|
return matches;
|
|
96
102
|
}
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
knownKeysKeyFor(key) {
|
|
104
|
+
if (this.knownKeysShards === 1) {
|
|
105
|
+
return `${this.prefix}:keys`;
|
|
106
|
+
}
|
|
107
|
+
return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
|
|
108
|
+
}
|
|
109
|
+
knownKeysKeys() {
|
|
110
|
+
if (this.knownKeysShards === 1) {
|
|
111
|
+
return [`${this.prefix}:keys`];
|
|
112
|
+
}
|
|
113
|
+
return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
|
|
99
114
|
}
|
|
100
115
|
keyTagsKey(key) {
|
|
101
116
|
return `${this.prefix}:key:${encodeURIComponent(key)}`;
|
|
@@ -104,6 +119,22 @@ var RedisTagIndex = class {
|
|
|
104
119
|
return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
|
|
105
120
|
}
|
|
106
121
|
};
|
|
122
|
+
function normalizeKnownKeysShards(value) {
|
|
123
|
+
if (value === void 0) {
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
127
|
+
throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
function simpleHash(value) {
|
|
132
|
+
let hash = 0;
|
|
133
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
134
|
+
hash = hash * 31 + value.charCodeAt(index) >>> 0;
|
|
135
|
+
}
|
|
136
|
+
return hash;
|
|
137
|
+
}
|
|
107
138
|
|
|
108
139
|
export {
|
|
109
140
|
RedisTagIndex
|