layercache 1.2.0 → 1.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/README.md +71 -5
- package/dist/chunk-46UH7LNM.js +312 -0
- package/dist/{chunk-BWM4MU2X.js → chunk-GF47Y3XR.js} +13 -38
- package/dist/chunk-ZMDB5KOK.js +159 -0
- package/dist/cli.cjs +121 -21
- package/dist/cli.js +57 -2
- package/dist/edge-C1sBhTfv.d.cts +667 -0
- package/dist/edge-C1sBhTfv.d.ts +667 -0
- package/dist/edge.cjs +399 -0
- package/dist/edge.d.cts +2 -0
- package/dist/edge.d.ts +2 -0
- package/dist/edge.js +14 -0
- package/dist/index.cjs +969 -195
- package/dist/index.d.cts +43 -567
- package/dist/index.d.ts +43 -567
- package/dist/index.js +849 -496
- package/package.json +7 -2
- package/packages/nestjs/dist/index.cjs +913 -375
- package/packages/nestjs/dist/index.d.cts +75 -0
- package/packages/nestjs/dist/index.d.ts +75 -0
- package/packages/nestjs/dist/index.js +901 -373
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
|
|
@@ -40,15 +40,22 @@ On a hit, the value is returned from the fastest layer that has it, and automati
|
|
|
40
40
|
- **Layered reads & automatic backfill** — hits in slower layers propagate up
|
|
41
41
|
- **Cache stampede prevention** — mutex-based deduplication per key
|
|
42
42
|
- **Tag-based invalidation** — `set('user:123:posts', posts, { tags: ['user:123'] })` then `invalidateByTag('user:123')`
|
|
43
|
+
- **Batch tag invalidation** — `invalidateByTags(['tenant:a', 'users'], 'all')` for OR/AND invalidation in one call
|
|
43
44
|
- **Pattern invalidation** — `invalidateByPattern('user:*')`
|
|
45
|
+
- **Prefix invalidation** — efficient `invalidateByPrefix('user:123:')` for hierarchical keys
|
|
46
|
+
- **Generation-based invalidation** — `generation` prefixes keys with `vN:` and `bumpGeneration()` rotates the namespace instantly
|
|
44
47
|
- **Per-layer TTL overrides** — different TTLs for memory vs. Redis in one call
|
|
48
|
+
- **TTL policies** — align TTLs to time boundaries (`until-midnight`, `next-hour`, `{ alignTo }`, or a function)
|
|
45
49
|
- **Negative caching** — cache known misses for a short TTL to protect the database
|
|
46
50
|
- **Stale strategies** — `staleWhileRevalidate` and `staleIfError` as opt-in read behavior
|
|
47
51
|
- **TTL jitter** — spread expirations to avoid synchronized stampedes
|
|
48
52
|
- **Sliding & adaptive TTL** — extend TTL on every read or ramp it up for hot keys
|
|
49
53
|
- **Refresh-ahead** — trigger background refresh when TTL drops below a threshold
|
|
54
|
+
- **Fetcher rate limiting** — cap concurrent fetchers or requests per interval
|
|
50
55
|
- **Best-effort writes** — tolerate partial layer write failures when desired
|
|
56
|
+
- **Write-behind mode** — write local layers immediately and flush slower remote layers asynchronously
|
|
51
57
|
- **Bulk reads** — `mget` uses layer-level `getMany()` when available
|
|
58
|
+
- **Bulk writes** — `mset` uses layer-level `setMany()` when available
|
|
52
59
|
- **Distributed tag index** — `RedisTagIndex` keeps tag state consistent across multiple servers
|
|
53
60
|
- **Optional distributed single-flight** — plug in a coordinator to dedupe misses across instances
|
|
54
61
|
- **Cross-server L1 invalidation** — Redis pub/sub bus flushes stale memory on other instances when you write or delete
|
|
@@ -59,17 +66,22 @@ On a hit, the value is returned from the fastest layer that has it, and automati
|
|
|
59
66
|
- **Graceful degradation** — skip a failing layer for a configurable retry window
|
|
60
67
|
- **Circuit breaker** — per-key or global; opens after N failures, recovers after cooldown
|
|
61
68
|
- **Compression** — transparent async gzip/brotli in `RedisLayer` (non-blocking) with a byte threshold
|
|
69
|
+
- **Serializer fallback chains** — transparently read legacy payloads (for example JSON) and rewrite them with the primary serializer
|
|
62
70
|
- **Metrics & stats** — per-layer hit/miss counters, **per-layer latency tracking**, circuit-breaker trips, degraded operations; HTTP stats handler
|
|
71
|
+
- **Health checks** — `cache.healthCheck()` returns per-layer health and latency
|
|
63
72
|
- **Persistence** — `exportState` / `importState` for in-process snapshots; `persistToFile` / `restoreFromFile` for disk
|
|
64
|
-
- **Admin CLI** — `layercache stats | keys | invalidate` against any Redis URL
|
|
65
|
-
- **Framework integrations** — Express middleware, Fastify plugin, tRPC middleware, GraphQL resolver wrapper
|
|
73
|
+
- **Admin CLI** — `layercache stats | keys | inspect | invalidate` against any Redis URL
|
|
74
|
+
- **Framework integrations** — Express middleware, Fastify plugin, Hono middleware, tRPC middleware, GraphQL resolver wrapper
|
|
75
|
+
- **OpenTelemetry plugin** — instrument `get` / `set` / invalidation flows with spans
|
|
66
76
|
- **MessagePack serializer** — drop-in replacement for lower Redis memory usage
|
|
67
77
|
- **NestJS module** — `CacheStackModule.forRoot(...)` and `forRootAsync(...)` with `@InjectCacheStack()`
|
|
68
78
|
- **`getOrThrow()`** — throws `CacheMissError` instead of returning `null`, for strict use cases
|
|
69
79
|
- **`inspect()`** — debug a key: see which layers hold it, remaining TTLs, tags, and staleness state
|
|
80
|
+
- **MemoryLayer cleanup hooks** — periodic TTL cleanup and `onEvict` callbacks
|
|
70
81
|
- **Conditional caching** — `shouldCache` predicate to skip caching specific fetcher results
|
|
71
|
-
- **Nested namespaces** — `namespace('a').namespace('b')` for composable key prefixes
|
|
82
|
+
- **Nested namespaces** — `namespace('a').namespace('b')` for composable key prefixes with namespace-scoped metrics
|
|
72
83
|
- **Custom layers** — implement the 5-method `CacheLayer` interface to plug in Memcached, DynamoDB, or anything else
|
|
84
|
+
- **Edge-safe entry point** — `layercache/edge` exports the non-Node helpers for Worker-style runtimes
|
|
73
85
|
- **ESM + CJS** — works with both module systems, Node.js ≥ 18
|
|
74
86
|
|
|
75
87
|
---
|
|
@@ -168,6 +180,15 @@ await cache.set('user:123:posts', posts, { tags: ['user:123'] })
|
|
|
168
180
|
await cache.invalidateByTag('user:123') // both keys gone
|
|
169
181
|
```
|
|
170
182
|
|
|
183
|
+
### `cache.invalidateByTags(tags, mode?): Promise<void>`
|
|
184
|
+
|
|
185
|
+
Delete keys that match any or all of a set of tags.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
await cache.invalidateByTags(['tenant:a', 'users'], 'all') // keys tagged with both
|
|
189
|
+
await cache.invalidateByTags(['users', 'posts'], 'any') // keys tagged with either
|
|
190
|
+
```
|
|
191
|
+
|
|
171
192
|
### `cache.invalidateByPattern(pattern): Promise<void>`
|
|
172
193
|
|
|
173
194
|
Glob-style deletion against the tracked key set.
|
|
@@ -176,6 +197,16 @@ Glob-style deletion against the tracked key set.
|
|
|
176
197
|
await cache.invalidateByPattern('user:*') // deletes user:1, user:2, …
|
|
177
198
|
```
|
|
178
199
|
|
|
200
|
+
### `cache.invalidateByPrefix(prefix): Promise<void>`
|
|
201
|
+
|
|
202
|
+
Prefer this over glob invalidation when your keys are hierarchical.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
await cache.invalidateByPrefix('user:123:') // deletes user:123:profile, user:123:posts, ...
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The prefix is matched as-is. You do not need to append `*`, and namespace helpers pass their namespace prefix directly.
|
|
209
|
+
|
|
179
210
|
### `cache.mget<T>(entries): Promise<Array<T | null>>`
|
|
180
211
|
|
|
181
212
|
Concurrent multi-key fetch, each with its own optional fetcher.
|
|
@@ -195,6 +226,13 @@ const [user1, user2] = await cache.mget([
|
|
|
195
226
|
const { hits, misses, fetches, staleHits, refreshes, writeFailures } = cache.getMetrics()
|
|
196
227
|
```
|
|
197
228
|
|
|
229
|
+
### `cache.healthCheck(): Promise<CacheHealthCheckResult[]>`
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
const health = await cache.healthCheck()
|
|
233
|
+
// [{ layer: 'memory', healthy: true, latencyMs: 0.03 }, ...]
|
|
234
|
+
```
|
|
235
|
+
|
|
198
236
|
### `cache.resetMetrics(): void`
|
|
199
237
|
|
|
200
238
|
Resets all counters to zero — useful for per-interval reporting.
|
|
@@ -229,6 +267,21 @@ const getUser = cache.wrap(
|
|
|
229
267
|
)
|
|
230
268
|
```
|
|
231
269
|
|
|
270
|
+
### Generation-based invalidation
|
|
271
|
+
|
|
272
|
+
Add a generation prefix to every key and rotate it when you want to invalidate the whole cache namespace without scanning:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
const cache = new CacheStack([...], { generation: 1 })
|
|
276
|
+
|
|
277
|
+
await cache.set('user:123', user)
|
|
278
|
+
cache.bumpGeneration() // now reads use v2:user:123
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### OpenTelemetry note
|
|
282
|
+
|
|
283
|
+
`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.
|
|
284
|
+
|
|
232
285
|
### `cache.warm(entries, options?)`
|
|
233
286
|
|
|
234
287
|
Pre-populate layers at startup from a prioritised list. Higher `priority` values run first.
|
|
@@ -246,7 +299,7 @@ await cache.warm(
|
|
|
246
299
|
|
|
247
300
|
### `cache.namespace(prefix): CacheNamespace`
|
|
248
301
|
|
|
249
|
-
Returns a scoped view with the same full API (`get`, `set`, `delete`, `clear`, `mget`, `wrap`, `warm`, `invalidateByTag`, `invalidateByPattern`, `getMetrics`). `clear()` only touches `prefix:*` keys.
|
|
302
|
+
Returns a scoped view with the same full API (`get`, `set`, `delete`, `clear`, `mget`, `wrap`, `warm`, `invalidateByTag`, `invalidateByPattern`, `getMetrics`). `clear()` only touches `prefix:*` keys, and namespace metrics are serialized per `CacheStack` instance so unrelated caches do not block each other while metrics are collected.
|
|
250
303
|
|
|
251
304
|
```ts
|
|
252
305
|
const users = cache.namespace('users')
|
|
@@ -305,6 +358,19 @@ const data = await cache.get('api:response', fetchFromApi, {
|
|
|
305
358
|
// If fetchFromApi returns { status: 500 }, the value is returned but NOT cached
|
|
306
359
|
```
|
|
307
360
|
|
|
361
|
+
### TTL policies
|
|
362
|
+
|
|
363
|
+
Align expirations to calendar or boundary-based schedules:
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
await cache.set('daily-report', report, { ttlPolicy: 'until-midnight' })
|
|
367
|
+
await cache.set('hourly-rollup', rollup, { ttlPolicy: 'next-hour' })
|
|
368
|
+
await cache.set('aligned', value, { ttlPolicy: { alignTo: 300 } }) // next 5-minute boundary
|
|
369
|
+
await cache.set('custom', value, {
|
|
370
|
+
ttlPolicy: ({ key, value }) => key.startsWith('hot:') ? 30 : 300
|
|
371
|
+
})
|
|
372
|
+
```
|
|
373
|
+
|
|
308
374
|
---
|
|
309
375
|
|
|
310
376
|
## Negative + stale caching
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PatternMatcher,
|
|
3
|
+
unwrapStoredValue
|
|
4
|
+
} from "./chunk-ZMDB5KOK.js";
|
|
5
|
+
|
|
6
|
+
// src/layers/MemoryLayer.ts
|
|
7
|
+
var MemoryLayer = class {
|
|
8
|
+
name;
|
|
9
|
+
defaultTtl;
|
|
10
|
+
isLocal = true;
|
|
11
|
+
maxSize;
|
|
12
|
+
evictionPolicy;
|
|
13
|
+
onEvict;
|
|
14
|
+
entries = /* @__PURE__ */ new Map();
|
|
15
|
+
cleanupTimer;
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.name = options.name ?? "memory";
|
|
18
|
+
this.defaultTtl = options.ttl;
|
|
19
|
+
this.maxSize = options.maxSize ?? 1e3;
|
|
20
|
+
this.evictionPolicy = options.evictionPolicy ?? "lru";
|
|
21
|
+
this.onEvict = options.onEvict;
|
|
22
|
+
if (options.cleanupIntervalMs && options.cleanupIntervalMs > 0) {
|
|
23
|
+
this.cleanupTimer = setInterval(() => {
|
|
24
|
+
this.pruneExpired();
|
|
25
|
+
}, options.cleanupIntervalMs);
|
|
26
|
+
this.cleanupTimer.unref?.();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async get(key) {
|
|
30
|
+
const value = await this.getEntry(key);
|
|
31
|
+
return unwrapStoredValue(value);
|
|
32
|
+
}
|
|
33
|
+
async getEntry(key) {
|
|
34
|
+
const entry = this.entries.get(key);
|
|
35
|
+
if (!entry) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (this.isExpired(entry)) {
|
|
39
|
+
this.entries.delete(key);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (this.evictionPolicy === "lru") {
|
|
43
|
+
this.entries.delete(key);
|
|
44
|
+
entry.accessCount += 1;
|
|
45
|
+
this.entries.set(key, entry);
|
|
46
|
+
} else if (this.evictionPolicy === "lfu") {
|
|
47
|
+
entry.accessCount += 1;
|
|
48
|
+
}
|
|
49
|
+
return entry.value;
|
|
50
|
+
}
|
|
51
|
+
async getMany(keys) {
|
|
52
|
+
const values = [];
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
values.push(await this.getEntry(key));
|
|
55
|
+
}
|
|
56
|
+
return values;
|
|
57
|
+
}
|
|
58
|
+
async setMany(entries) {
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
await this.set(entry.key, entry.value, entry.ttl);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async set(key, value, ttl = this.defaultTtl) {
|
|
64
|
+
this.entries.delete(key);
|
|
65
|
+
this.entries.set(key, {
|
|
66
|
+
value,
|
|
67
|
+
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
|
|
68
|
+
accessCount: 0,
|
|
69
|
+
insertedAt: Date.now()
|
|
70
|
+
});
|
|
71
|
+
while (this.entries.size > this.maxSize) {
|
|
72
|
+
this.evict();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async has(key) {
|
|
76
|
+
const entry = this.entries.get(key);
|
|
77
|
+
if (!entry) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (this.isExpired(entry)) {
|
|
81
|
+
this.entries.delete(key);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
async ttl(key) {
|
|
87
|
+
const entry = this.entries.get(key);
|
|
88
|
+
if (!entry) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (this.isExpired(entry)) {
|
|
92
|
+
this.entries.delete(key);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (entry.expiresAt === null) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1e3));
|
|
99
|
+
}
|
|
100
|
+
async size() {
|
|
101
|
+
this.pruneExpired();
|
|
102
|
+
return this.entries.size;
|
|
103
|
+
}
|
|
104
|
+
async delete(key) {
|
|
105
|
+
this.entries.delete(key);
|
|
106
|
+
}
|
|
107
|
+
async deleteMany(keys) {
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
this.entries.delete(key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async clear() {
|
|
113
|
+
this.entries.clear();
|
|
114
|
+
}
|
|
115
|
+
async ping() {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
async dispose() {
|
|
119
|
+
if (this.cleanupTimer) {
|
|
120
|
+
clearInterval(this.cleanupTimer);
|
|
121
|
+
this.cleanupTimer = void 0;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async keys() {
|
|
125
|
+
this.pruneExpired();
|
|
126
|
+
return [...this.entries.keys()];
|
|
127
|
+
}
|
|
128
|
+
exportState() {
|
|
129
|
+
this.pruneExpired();
|
|
130
|
+
return [...this.entries.entries()].map(([key, entry]) => ({
|
|
131
|
+
key,
|
|
132
|
+
value: entry.value,
|
|
133
|
+
expiresAt: entry.expiresAt
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
importState(entries) {
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
this.entries.set(entry.key, {
|
|
142
|
+
value: entry.value,
|
|
143
|
+
expiresAt: entry.expiresAt,
|
|
144
|
+
accessCount: 0,
|
|
145
|
+
insertedAt: Date.now()
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
while (this.entries.size > this.maxSize) {
|
|
149
|
+
this.evict();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
evict() {
|
|
153
|
+
if (this.evictionPolicy === "lru" || this.evictionPolicy === "fifo") {
|
|
154
|
+
const oldestKey = this.entries.keys().next().value;
|
|
155
|
+
if (oldestKey !== void 0) {
|
|
156
|
+
const entry = this.entries.get(oldestKey);
|
|
157
|
+
this.entries.delete(oldestKey);
|
|
158
|
+
if (entry) {
|
|
159
|
+
this.onEvict?.(oldestKey, unwrapStoredValue(entry.value));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
let victimKey;
|
|
165
|
+
let minCount = Number.POSITIVE_INFINITY;
|
|
166
|
+
let minInsertedAt = Number.POSITIVE_INFINITY;
|
|
167
|
+
for (const [key, entry] of this.entries.entries()) {
|
|
168
|
+
if (entry.accessCount < minCount || entry.accessCount === minCount && entry.insertedAt < minInsertedAt) {
|
|
169
|
+
minCount = entry.accessCount;
|
|
170
|
+
minInsertedAt = entry.insertedAt;
|
|
171
|
+
victimKey = key;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (victimKey !== void 0) {
|
|
175
|
+
const victim = this.entries.get(victimKey);
|
|
176
|
+
this.entries.delete(victimKey);
|
|
177
|
+
if (victim) {
|
|
178
|
+
this.onEvict?.(victimKey, unwrapStoredValue(victim.value));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
pruneExpired() {
|
|
183
|
+
for (const [key, entry] of this.entries.entries()) {
|
|
184
|
+
if (this.isExpired(entry)) {
|
|
185
|
+
this.entries.delete(key);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
isExpired(entry) {
|
|
190
|
+
return entry.expiresAt !== null && entry.expiresAt <= Date.now();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// src/invalidation/TagIndex.ts
|
|
195
|
+
var TagIndex = class {
|
|
196
|
+
tagToKeys = /* @__PURE__ */ new Map();
|
|
197
|
+
keyToTags = /* @__PURE__ */ new Map();
|
|
198
|
+
knownKeys = /* @__PURE__ */ new Set();
|
|
199
|
+
maxKnownKeys;
|
|
200
|
+
constructor(options = {}) {
|
|
201
|
+
this.maxKnownKeys = options.maxKnownKeys;
|
|
202
|
+
}
|
|
203
|
+
async touch(key) {
|
|
204
|
+
this.knownKeys.add(key);
|
|
205
|
+
this.pruneKnownKeysIfNeeded();
|
|
206
|
+
}
|
|
207
|
+
async track(key, tags) {
|
|
208
|
+
this.knownKeys.add(key);
|
|
209
|
+
this.pruneKnownKeysIfNeeded();
|
|
210
|
+
if (tags.length === 0) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const existingTags = this.keyToTags.get(key);
|
|
214
|
+
if (existingTags) {
|
|
215
|
+
for (const tag of existingTags) {
|
|
216
|
+
this.tagToKeys.get(tag)?.delete(key);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const tagSet = new Set(tags);
|
|
220
|
+
this.keyToTags.set(key, tagSet);
|
|
221
|
+
for (const tag of tagSet) {
|
|
222
|
+
const keys = this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set();
|
|
223
|
+
keys.add(key);
|
|
224
|
+
this.tagToKeys.set(tag, keys);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async remove(key) {
|
|
228
|
+
this.removeKey(key);
|
|
229
|
+
}
|
|
230
|
+
async keysForTag(tag) {
|
|
231
|
+
return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
|
|
232
|
+
}
|
|
233
|
+
async keysForPrefix(prefix) {
|
|
234
|
+
return [...this.knownKeys].filter((key) => key.startsWith(prefix));
|
|
235
|
+
}
|
|
236
|
+
async tagsForKey(key) {
|
|
237
|
+
return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
|
|
238
|
+
}
|
|
239
|
+
async matchPattern(pattern) {
|
|
240
|
+
return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
|
|
241
|
+
}
|
|
242
|
+
async clear() {
|
|
243
|
+
this.tagToKeys.clear();
|
|
244
|
+
this.keyToTags.clear();
|
|
245
|
+
this.knownKeys.clear();
|
|
246
|
+
}
|
|
247
|
+
pruneKnownKeysIfNeeded() {
|
|
248
|
+
if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
|
|
252
|
+
let removed = 0;
|
|
253
|
+
for (const key of this.knownKeys) {
|
|
254
|
+
if (removed >= toRemove) {
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
this.removeKey(key);
|
|
258
|
+
removed += 1;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
removeKey(key) {
|
|
262
|
+
this.knownKeys.delete(key);
|
|
263
|
+
const tags = this.keyToTags.get(key);
|
|
264
|
+
if (!tags) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
for (const tag of tags) {
|
|
268
|
+
const keys = this.tagToKeys.get(tag);
|
|
269
|
+
if (!keys) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
keys.delete(key);
|
|
273
|
+
if (keys.size === 0) {
|
|
274
|
+
this.tagToKeys.delete(tag);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
this.keyToTags.delete(key);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// src/integrations/hono.ts
|
|
282
|
+
function createHonoCacheMiddleware(cache, options = {}) {
|
|
283
|
+
const allowedMethods = new Set((options.methods ?? ["GET"]).map((method) => method.toUpperCase()));
|
|
284
|
+
return async (context, next) => {
|
|
285
|
+
const method = (context.req.method ?? "GET").toUpperCase();
|
|
286
|
+
if (!allowedMethods.has(method)) {
|
|
287
|
+
await next();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${context.req.path ?? context.req.url ?? "/"}`;
|
|
291
|
+
const cached = await cache.get(key, void 0, options);
|
|
292
|
+
if (cached !== null) {
|
|
293
|
+
context.header?.("x-cache", "HIT");
|
|
294
|
+
context.header?.("content-type", "application/json; charset=utf-8");
|
|
295
|
+
context.json(cached);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const originalJson = context.json.bind(context);
|
|
299
|
+
context.json = (body, status) => {
|
|
300
|
+
context.header?.("x-cache", "MISS");
|
|
301
|
+
void cache.set(key, body, options);
|
|
302
|
+
return originalJson(body, status);
|
|
303
|
+
};
|
|
304
|
+
await next();
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export {
|
|
309
|
+
MemoryLayer,
|
|
310
|
+
TagIndex,
|
|
311
|
+
createHonoCacheMiddleware
|
|
312
|
+
};
|
|
@@ -1,40 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* Tests whether a glob-style pattern matches a value.
|
|
5
|
-
* Supports `*` (any sequence of characters) and `?` (any single character).
|
|
6
|
-
* Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
|
|
7
|
-
*/
|
|
8
|
-
static matches(pattern, value) {
|
|
9
|
-
return _PatternMatcher.matchLinear(pattern, value);
|
|
10
|
-
}
|
|
11
|
-
/**
|
|
12
|
-
* Linear-time glob matching using dynamic programming.
|
|
13
|
-
* Avoids catastrophic backtracking that RegExp-based glob matching can cause.
|
|
14
|
-
*/
|
|
15
|
-
static matchLinear(pattern, value) {
|
|
16
|
-
const m = pattern.length;
|
|
17
|
-
const n = value.length;
|
|
18
|
-
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
|
|
19
|
-
dp[0][0] = true;
|
|
20
|
-
for (let i = 1; i <= m; i++) {
|
|
21
|
-
if (pattern[i - 1] === "*") {
|
|
22
|
-
dp[i][0] = dp[i - 1]?.[0];
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
for (let i = 1; i <= m; i++) {
|
|
26
|
-
for (let j = 1; j <= n; j++) {
|
|
27
|
-
const pc = pattern[i - 1];
|
|
28
|
-
if (pc === "*") {
|
|
29
|
-
dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
|
|
30
|
-
} else if (pc === "?" || pc === value[j - 1]) {
|
|
31
|
-
dp[i][j] = dp[i - 1]?.[j - 1];
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return dp[m]?.[n];
|
|
36
|
-
}
|
|
37
|
-
};
|
|
1
|
+
import {
|
|
2
|
+
PatternMatcher
|
|
3
|
+
} from "./chunk-ZMDB5KOK.js";
|
|
38
4
|
|
|
39
5
|
// src/invalidation/RedisTagIndex.ts
|
|
40
6
|
var RedisTagIndex = class {
|
|
@@ -80,6 +46,16 @@ var RedisTagIndex = class {
|
|
|
80
46
|
async keysForTag(tag) {
|
|
81
47
|
return this.client.smembers(this.tagKeysKey(tag));
|
|
82
48
|
}
|
|
49
|
+
async keysForPrefix(prefix) {
|
|
50
|
+
const matches = [];
|
|
51
|
+
let cursor = "0";
|
|
52
|
+
do {
|
|
53
|
+
const [nextCursor, keys] = await this.client.sscan(this.knownKeysKey(), cursor, "COUNT", this.scanCount);
|
|
54
|
+
cursor = nextCursor;
|
|
55
|
+
matches.push(...keys.filter((key) => key.startsWith(prefix)));
|
|
56
|
+
} while (cursor !== "0");
|
|
57
|
+
return matches;
|
|
58
|
+
}
|
|
83
59
|
async tagsForKey(key) {
|
|
84
60
|
return this.client.smembers(this.keyTagsKey(key));
|
|
85
61
|
}
|
|
@@ -130,6 +106,5 @@ var RedisTagIndex = class {
|
|
|
130
106
|
};
|
|
131
107
|
|
|
132
108
|
export {
|
|
133
|
-
PatternMatcher,
|
|
134
109
|
RedisTagIndex
|
|
135
110
|
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// src/internal/StoredValue.ts
|
|
2
|
+
function isStoredValueEnvelope(value) {
|
|
3
|
+
return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
|
|
4
|
+
}
|
|
5
|
+
function createStoredValueEnvelope(options) {
|
|
6
|
+
const now = options.now ?? Date.now();
|
|
7
|
+
const freshTtlSeconds = normalizePositiveSeconds(options.freshTtlSeconds);
|
|
8
|
+
const staleWhileRevalidateSeconds = normalizePositiveSeconds(options.staleWhileRevalidateSeconds);
|
|
9
|
+
const staleIfErrorSeconds = normalizePositiveSeconds(options.staleIfErrorSeconds);
|
|
10
|
+
const freshUntil = freshTtlSeconds ? now + freshTtlSeconds * 1e3 : null;
|
|
11
|
+
const staleUntil = freshUntil && staleWhileRevalidateSeconds ? freshUntil + staleWhileRevalidateSeconds * 1e3 : null;
|
|
12
|
+
const errorUntil = freshUntil && staleIfErrorSeconds ? freshUntil + staleIfErrorSeconds * 1e3 : null;
|
|
13
|
+
return {
|
|
14
|
+
__layercache: 1,
|
|
15
|
+
kind: options.kind,
|
|
16
|
+
value: options.value,
|
|
17
|
+
freshUntil,
|
|
18
|
+
staleUntil,
|
|
19
|
+
errorUntil,
|
|
20
|
+
freshTtlSeconds: freshTtlSeconds ?? null,
|
|
21
|
+
staleWhileRevalidateSeconds: staleWhileRevalidateSeconds ?? null,
|
|
22
|
+
staleIfErrorSeconds: staleIfErrorSeconds ?? null
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function resolveStoredValue(stored, now = Date.now()) {
|
|
26
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
27
|
+
return { state: "fresh", value: stored, stored };
|
|
28
|
+
}
|
|
29
|
+
if (stored.freshUntil === null || stored.freshUntil > now) {
|
|
30
|
+
return { state: "fresh", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
31
|
+
}
|
|
32
|
+
if (stored.staleUntil !== null && stored.staleUntil > now) {
|
|
33
|
+
return { state: "stale-while-revalidate", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
34
|
+
}
|
|
35
|
+
if (stored.errorUntil !== null && stored.errorUntil > now) {
|
|
36
|
+
return { state: "stale-if-error", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
37
|
+
}
|
|
38
|
+
return { state: "expired", value: null, stored, envelope: stored };
|
|
39
|
+
}
|
|
40
|
+
function unwrapStoredValue(stored) {
|
|
41
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
42
|
+
return stored;
|
|
43
|
+
}
|
|
44
|
+
if (stored.kind === "empty") {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return stored.value ?? null;
|
|
48
|
+
}
|
|
49
|
+
function remainingStoredTtlSeconds(stored, now = Date.now()) {
|
|
50
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
51
|
+
return void 0;
|
|
52
|
+
}
|
|
53
|
+
const expiry = maxExpiry(stored);
|
|
54
|
+
if (expiry === null) {
|
|
55
|
+
return void 0;
|
|
56
|
+
}
|
|
57
|
+
const remainingMs = expiry - now;
|
|
58
|
+
if (remainingMs <= 0) {
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
return Math.max(1, Math.ceil(remainingMs / 1e3));
|
|
62
|
+
}
|
|
63
|
+
function remainingFreshTtlSeconds(stored, now = Date.now()) {
|
|
64
|
+
if (!isStoredValueEnvelope(stored) || stored.freshUntil === null) {
|
|
65
|
+
return void 0;
|
|
66
|
+
}
|
|
67
|
+
const remainingMs = stored.freshUntil - now;
|
|
68
|
+
if (remainingMs <= 0) {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
return Math.max(1, Math.ceil(remainingMs / 1e3));
|
|
72
|
+
}
|
|
73
|
+
function refreshStoredEnvelope(stored, now = Date.now()) {
|
|
74
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
75
|
+
return stored;
|
|
76
|
+
}
|
|
77
|
+
return createStoredValueEnvelope({
|
|
78
|
+
kind: stored.kind,
|
|
79
|
+
value: stored.value,
|
|
80
|
+
freshTtlSeconds: stored.freshTtlSeconds ?? void 0,
|
|
81
|
+
staleWhileRevalidateSeconds: stored.staleWhileRevalidateSeconds ?? void 0,
|
|
82
|
+
staleIfErrorSeconds: stored.staleIfErrorSeconds ?? void 0,
|
|
83
|
+
now
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function maxExpiry(stored) {
|
|
87
|
+
const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
|
|
88
|
+
(value) => value !== null
|
|
89
|
+
);
|
|
90
|
+
if (values.length === 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return Math.max(...values);
|
|
94
|
+
}
|
|
95
|
+
function normalizePositiveSeconds(value) {
|
|
96
|
+
if (!value || value <= 0) {
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/invalidation/PatternMatcher.ts
|
|
103
|
+
var PatternMatcher = class _PatternMatcher {
|
|
104
|
+
/**
|
|
105
|
+
* Tests whether a glob-style pattern matches a value.
|
|
106
|
+
* Supports `*` (any sequence of characters) and `?` (any single character).
|
|
107
|
+
* Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
|
|
108
|
+
* quadratic memory usage on long patterns/keys.
|
|
109
|
+
*/
|
|
110
|
+
static matches(pattern, value) {
|
|
111
|
+
return _PatternMatcher.matchLinear(pattern, value);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Linear-time glob matching with O(1) extra memory.
|
|
115
|
+
*/
|
|
116
|
+
static matchLinear(pattern, value) {
|
|
117
|
+
let patternIndex = 0;
|
|
118
|
+
let valueIndex = 0;
|
|
119
|
+
let starIndex = -1;
|
|
120
|
+
let backtrackValueIndex = 0;
|
|
121
|
+
while (valueIndex < value.length) {
|
|
122
|
+
const patternChar = pattern[patternIndex];
|
|
123
|
+
const valueChar = value[valueIndex];
|
|
124
|
+
if (patternChar === "*" && patternIndex < pattern.length) {
|
|
125
|
+
starIndex = patternIndex;
|
|
126
|
+
patternIndex += 1;
|
|
127
|
+
backtrackValueIndex = valueIndex;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (patternChar === "?" || patternChar === valueChar) {
|
|
131
|
+
patternIndex += 1;
|
|
132
|
+
valueIndex += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (starIndex !== -1) {
|
|
136
|
+
patternIndex = starIndex + 1;
|
|
137
|
+
backtrackValueIndex += 1;
|
|
138
|
+
valueIndex = backtrackValueIndex;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
|
|
144
|
+
patternIndex += 1;
|
|
145
|
+
}
|
|
146
|
+
return patternIndex === pattern.length;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export {
|
|
151
|
+
isStoredValueEnvelope,
|
|
152
|
+
createStoredValueEnvelope,
|
|
153
|
+
resolveStoredValue,
|
|
154
|
+
unwrapStoredValue,
|
|
155
|
+
remainingStoredTtlSeconds,
|
|
156
|
+
remainingFreshTtlSeconds,
|
|
157
|
+
refreshStoredEnvelope,
|
|
158
|
+
PatternMatcher
|
|
159
|
+
};
|