layercache 1.0.2 → 1.2.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 +97 -5
- package/benchmarks/latency.ts +1 -1
- package/benchmarks/stampede.ts +1 -4
- package/dist/{chunk-IILH5XTS.js → chunk-BWM4MU2X.js} +36 -4
- package/dist/cli.cjs +87 -9
- package/dist/cli.js +52 -6
- package/dist/index.cjs +1219 -272
- package/dist/index.d.cts +469 -13
- package/dist/index.d.ts +469 -13
- package/dist/index.js +1181 -271
- package/examples/express-api/index.ts +12 -8
- package/examples/nestjs-module/app.module.ts +2 -5
- package/examples/nextjs-api-routes/route.ts +1 -4
- package/package.json +6 -1
- package/packages/nestjs/dist/index.cjs +712 -220
- package/packages/nestjs/dist/index.d.cts +243 -11
- package/packages/nestjs/dist/index.d.ts +243 -11
- package/packages/nestjs/dist/index.js +712 -220
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
|
|
@@ -58,13 +58,17 @@ On a hit, the value is returned from the fastest layer that has it, and automati
|
|
|
58
58
|
- **Event hooks** — `EventEmitter`-based events for hits, misses, stale serves, errors, and more
|
|
59
59
|
- **Graceful degradation** — skip a failing layer for a configurable retry window
|
|
60
60
|
- **Circuit breaker** — per-key or global; opens after N failures, recovers after cooldown
|
|
61
|
-
- **Compression** — transparent gzip/brotli in `RedisLayer` with a byte threshold
|
|
62
|
-
- **Metrics & stats** — per-layer hit/miss counters, circuit-breaker trips, degraded operations; HTTP stats handler
|
|
61
|
+
- **Compression** — transparent async gzip/brotli in `RedisLayer` (non-blocking) with a byte threshold
|
|
62
|
+
- **Metrics & stats** — per-layer hit/miss counters, **per-layer latency tracking**, circuit-breaker trips, degraded operations; HTTP stats handler
|
|
63
63
|
- **Persistence** — `exportState` / `importState` for in-process snapshots; `persistToFile` / `restoreFromFile` for disk
|
|
64
64
|
- **Admin CLI** — `layercache stats | keys | invalidate` against any Redis URL
|
|
65
|
-
- **Framework integrations** — Fastify plugin, tRPC middleware, GraphQL resolver wrapper
|
|
65
|
+
- **Framework integrations** — Express middleware, Fastify plugin, tRPC middleware, GraphQL resolver wrapper
|
|
66
66
|
- **MessagePack serializer** — drop-in replacement for lower Redis memory usage
|
|
67
|
-
- **NestJS module** — `CacheStackModule.forRoot(...)` with `@InjectCacheStack()`
|
|
67
|
+
- **NestJS module** — `CacheStackModule.forRoot(...)` and `forRootAsync(...)` with `@InjectCacheStack()`
|
|
68
|
+
- **`getOrThrow()`** — throws `CacheMissError` instead of returning `null`, for strict use cases
|
|
69
|
+
- **`inspect()`** — debug a key: see which layers hold it, remaining TTLs, tags, and staleness state
|
|
70
|
+
- **Conditional caching** — `shouldCache` predicate to skip caching specific fetcher results
|
|
71
|
+
- **Nested namespaces** — `namespace('a').namespace('b')` for composable key prefixes
|
|
68
72
|
- **Custom layers** — implement the 5-method `CacheLayer` interface to plug in Memcached, DynamoDB, or anything else
|
|
69
73
|
- **ESM + CJS** — works with both module systems, Node.js ≥ 18
|
|
70
74
|
|
|
@@ -250,6 +254,55 @@ const posts = cache.namespace('posts')
|
|
|
250
254
|
|
|
251
255
|
await users.set('123', userData) // stored as "users:123"
|
|
252
256
|
await users.clear() // only deletes "users:*"
|
|
257
|
+
|
|
258
|
+
// Nested namespaces
|
|
259
|
+
const tenant = cache.namespace('tenant:abc')
|
|
260
|
+
const posts = tenant.namespace('posts')
|
|
261
|
+
await posts.set('1', postData) // stored as "tenant:abc:posts:1"
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### `cache.getOrThrow<T>(key, fetcher?, options?): Promise<T>`
|
|
265
|
+
|
|
266
|
+
Like `get()`, but throws `CacheMissError` instead of returning `null`. Useful when you know the value must exist (e.g. after a warm-up).
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
import { CacheMissError } from 'layercache'
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const config = await cache.getOrThrow<Config>('app:config')
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (err instanceof CacheMissError) {
|
|
275
|
+
console.error(`Missing key: ${err.key}`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### `cache.inspect(key): Promise<CacheInspectResult | null>`
|
|
281
|
+
|
|
282
|
+
Returns detailed metadata about a cache key for debugging. Returns `null` if the key is not in any layer.
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
const info = await cache.inspect('user:123')
|
|
286
|
+
// {
|
|
287
|
+
// key: 'user:123',
|
|
288
|
+
// foundInLayers: ['memory', 'redis'],
|
|
289
|
+
// freshTtlSeconds: 45,
|
|
290
|
+
// staleTtlSeconds: 75,
|
|
291
|
+
// errorTtlSeconds: 345,
|
|
292
|
+
// isStale: false,
|
|
293
|
+
// tags: ['user', 'user:123']
|
|
294
|
+
// }
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Conditional caching with `shouldCache`
|
|
298
|
+
|
|
299
|
+
Skip caching specific results without affecting the return value:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
const data = await cache.get('api:response', fetchFromApi, {
|
|
303
|
+
shouldCache: (value) => (value as any).status === 200
|
|
304
|
+
})
|
|
305
|
+
// If fetchFromApi returns { status: 500 }, the value is returned but NOT cached
|
|
253
306
|
```
|
|
254
307
|
|
|
255
308
|
---
|
|
@@ -591,6 +644,26 @@ cache.on('error', ({ event, context }) => logger.error(event, context))
|
|
|
591
644
|
|
|
592
645
|
## Framework integrations
|
|
593
646
|
|
|
647
|
+
### Express
|
|
648
|
+
|
|
649
|
+
```ts
|
|
650
|
+
import { CacheStack, MemoryLayer, createExpressCacheMiddleware } from 'layercache'
|
|
651
|
+
|
|
652
|
+
const cache = new CacheStack([new MemoryLayer({ ttl: 60 })])
|
|
653
|
+
|
|
654
|
+
// Automatically caches GET responses as JSON
|
|
655
|
+
app.get('/api/users', createExpressCacheMiddleware(cache, { ttl: 30 }), (req, res) => {
|
|
656
|
+
res.json(await db.getUsers())
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
// Custom key resolver + tag support
|
|
660
|
+
app.get('/api/user/:id', createExpressCacheMiddleware(cache, {
|
|
661
|
+
keyResolver: (req) => `user:${req.url}`,
|
|
662
|
+
tags: ['users'],
|
|
663
|
+
ttl: 60
|
|
664
|
+
}), handler)
|
|
665
|
+
```
|
|
666
|
+
|
|
594
667
|
### tRPC
|
|
595
668
|
|
|
596
669
|
```ts
|
|
@@ -699,6 +772,25 @@ import { CacheStackModule } from '@cachestack/nestjs'
|
|
|
699
772
|
export class AppModule {}
|
|
700
773
|
```
|
|
701
774
|
|
|
775
|
+
Async configuration (resolve dependencies from DI):
|
|
776
|
+
|
|
777
|
+
```ts
|
|
778
|
+
@Module({
|
|
779
|
+
imports: [
|
|
780
|
+
CacheStackModule.forRootAsync({
|
|
781
|
+
inject: [ConfigService],
|
|
782
|
+
useFactory: (config: ConfigService) => ({
|
|
783
|
+
layers: [
|
|
784
|
+
new MemoryLayer({ ttl: 20 }),
|
|
785
|
+
new RedisLayer({ client: new Redis(config.get('REDIS_URL')), ttl: 300 })
|
|
786
|
+
]
|
|
787
|
+
})
|
|
788
|
+
})
|
|
789
|
+
]
|
|
790
|
+
})
|
|
791
|
+
export class AppModule {}
|
|
792
|
+
```
|
|
793
|
+
|
|
702
794
|
```ts
|
|
703
795
|
// your.service.ts
|
|
704
796
|
import { InjectCacheStack } from '@cachestack/nestjs'
|
package/benchmarks/latency.ts
CHANGED
package/benchmarks/stampede.ts
CHANGED
|
@@ -3,10 +3,7 @@ import { CacheStack, MemoryLayer, RedisLayer } from '../src'
|
|
|
3
3
|
|
|
4
4
|
async function main(): Promise<void> {
|
|
5
5
|
const redis = new Redis()
|
|
6
|
-
const cache = new CacheStack([
|
|
7
|
-
new MemoryLayer({ ttl: 60 }),
|
|
8
|
-
new RedisLayer({ client: redis, ttl: 300 })
|
|
9
|
-
])
|
|
6
|
+
const cache = new CacheStack([new MemoryLayer({ ttl: 60 }), new RedisLayer({ client: redis, ttl: 300 })])
|
|
10
7
|
|
|
11
8
|
let executions = 0
|
|
12
9
|
|
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
// src/invalidation/PatternMatcher.ts
|
|
2
|
-
var PatternMatcher = class {
|
|
2
|
+
var PatternMatcher = class _PatternMatcher {
|
|
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
|
+
*/
|
|
3
8
|
static matches(pattern, value) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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];
|
|
7
36
|
}
|
|
8
37
|
};
|
|
9
38
|
|
|
@@ -51,6 +80,9 @@ var RedisTagIndex = class {
|
|
|
51
80
|
async keysForTag(tag) {
|
|
52
81
|
return this.client.smembers(this.tagKeysKey(tag));
|
|
53
82
|
}
|
|
83
|
+
async tagsForKey(key) {
|
|
84
|
+
return this.client.smembers(this.keyTagsKey(key));
|
|
85
|
+
}
|
|
54
86
|
async matchPattern(pattern) {
|
|
55
87
|
const matches = [];
|
|
56
88
|
let cursor = "0";
|
package/dist/cli.cjs
CHANGED
|
@@ -37,11 +37,40 @@ module.exports = __toCommonJS(cli_exports);
|
|
|
37
37
|
var import_ioredis = __toESM(require("ioredis"), 1);
|
|
38
38
|
|
|
39
39
|
// src/invalidation/PatternMatcher.ts
|
|
40
|
-
var PatternMatcher = class {
|
|
40
|
+
var PatternMatcher = class _PatternMatcher {
|
|
41
|
+
/**
|
|
42
|
+
* Tests whether a glob-style pattern matches a value.
|
|
43
|
+
* Supports `*` (any sequence of characters) and `?` (any single character).
|
|
44
|
+
* Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
|
|
45
|
+
*/
|
|
41
46
|
static matches(pattern, value) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
return _PatternMatcher.matchLinear(pattern, value);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Linear-time glob matching using dynamic programming.
|
|
51
|
+
* Avoids catastrophic backtracking that RegExp-based glob matching can cause.
|
|
52
|
+
*/
|
|
53
|
+
static matchLinear(pattern, value) {
|
|
54
|
+
const m = pattern.length;
|
|
55
|
+
const n = value.length;
|
|
56
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
|
|
57
|
+
dp[0][0] = true;
|
|
58
|
+
for (let i = 1; i <= m; i++) {
|
|
59
|
+
if (pattern[i - 1] === "*") {
|
|
60
|
+
dp[i][0] = dp[i - 1]?.[0];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (let i = 1; i <= m; i++) {
|
|
64
|
+
for (let j = 1; j <= n; j++) {
|
|
65
|
+
const pc = pattern[i - 1];
|
|
66
|
+
if (pc === "*") {
|
|
67
|
+
dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
|
|
68
|
+
} else if (pc === "?" || pc === value[j - 1]) {
|
|
69
|
+
dp[i][j] = dp[i - 1]?.[j - 1];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return dp[m]?.[n];
|
|
45
74
|
}
|
|
46
75
|
};
|
|
47
76
|
|
|
@@ -89,6 +118,9 @@ var RedisTagIndex = class {
|
|
|
89
118
|
async keysForTag(tag) {
|
|
90
119
|
return this.client.smembers(this.tagKeysKey(tag));
|
|
91
120
|
}
|
|
121
|
+
async tagsForKey(key) {
|
|
122
|
+
return this.client.smembers(this.keyTagsKey(key));
|
|
123
|
+
}
|
|
92
124
|
async matchPattern(pattern) {
|
|
93
125
|
const matches = [];
|
|
94
126
|
let cursor = "0";
|
|
@@ -136,6 +168,7 @@ var RedisTagIndex = class {
|
|
|
136
168
|
};
|
|
137
169
|
|
|
138
170
|
// src/cli.ts
|
|
171
|
+
var CONNECT_TIMEOUT_MS = 5e3;
|
|
139
172
|
async function main(argv = process.argv.slice(2)) {
|
|
140
173
|
const args = parseArgs(argv);
|
|
141
174
|
if (!args.command || !args.redisUrl) {
|
|
@@ -143,8 +176,25 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
143
176
|
process.exitCode = 1;
|
|
144
177
|
return;
|
|
145
178
|
}
|
|
146
|
-
const
|
|
179
|
+
const redisUrl = validateRedisUrl(args.redisUrl);
|
|
180
|
+
if (!redisUrl) {
|
|
181
|
+
process.stderr.write(
|
|
182
|
+
`Error: invalid Redis URL "${args.redisUrl}". Expected format: redis://[user:password@]host[:port][/db]
|
|
183
|
+
`
|
|
184
|
+
);
|
|
185
|
+
process.exitCode = 1;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const redis = new import_ioredis.default(redisUrl, {
|
|
189
|
+
connectTimeout: CONNECT_TIMEOUT_MS,
|
|
190
|
+
lazyConnect: true,
|
|
191
|
+
enableReadyCheck: false
|
|
192
|
+
});
|
|
147
193
|
try {
|
|
194
|
+
await redis.connect().catch((error) => {
|
|
195
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
196
|
+
throw new Error(`Failed to connect to Redis at "${redisUrl}": ${message}`);
|
|
197
|
+
});
|
|
148
198
|
if (args.command === "stats") {
|
|
149
199
|
const keys = await scanKeys(redis, args.pattern ?? "*");
|
|
150
200
|
process.stdout.write(`${JSON.stringify({ totalKeys: keys.length, pattern: args.pattern ?? "*" }, null, 2)}
|
|
@@ -153,8 +203,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
153
203
|
}
|
|
154
204
|
if (args.command === "keys") {
|
|
155
205
|
const keys = await scanKeys(redis, args.pattern ?? "*");
|
|
156
|
-
|
|
206
|
+
if (keys.length > 0) {
|
|
207
|
+
process.stdout.write(`${keys.join("\n")}
|
|
157
208
|
`);
|
|
209
|
+
}
|
|
158
210
|
return;
|
|
159
211
|
}
|
|
160
212
|
if (args.command === "invalidate") {
|
|
@@ -162,7 +214,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
162
214
|
const tagIndex = new RedisTagIndex({ client: redis, prefix: args.tagIndexPrefix ?? "layercache:tag-index" });
|
|
163
215
|
const keys2 = await tagIndex.keysForTag(args.tag);
|
|
164
216
|
if (keys2.length > 0) {
|
|
165
|
-
await redis
|
|
217
|
+
await batchDelete(redis, keys2);
|
|
166
218
|
}
|
|
167
219
|
process.stdout.write(`${JSON.stringify({ deletedKeys: keys2.length, tag: args.tag }, null, 2)}
|
|
168
220
|
`);
|
|
@@ -170,7 +222,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
170
222
|
}
|
|
171
223
|
const keys = await scanKeys(redis, args.pattern ?? "*");
|
|
172
224
|
if (keys.length > 0) {
|
|
173
|
-
await redis
|
|
225
|
+
await batchDelete(redis, keys);
|
|
174
226
|
}
|
|
175
227
|
process.stdout.write(`${JSON.stringify({ deletedKeys: keys.length, pattern: args.pattern ?? "*" }, null, 2)}
|
|
176
228
|
`);
|
|
@@ -178,10 +230,29 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
178
230
|
}
|
|
179
231
|
printUsage();
|
|
180
232
|
process.exitCode = 1;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
235
|
+
process.stderr.write(`Error: ${message}
|
|
236
|
+
`);
|
|
237
|
+
process.exitCode = 1;
|
|
181
238
|
} finally {
|
|
182
239
|
redis.disconnect();
|
|
183
240
|
}
|
|
184
241
|
}
|
|
242
|
+
function validateRedisUrl(url) {
|
|
243
|
+
try {
|
|
244
|
+
const parsed = new URL(url);
|
|
245
|
+
if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return url;
|
|
249
|
+
} catch {
|
|
250
|
+
if (/^[A-Za-z0-9._-]+(:\d+)?$/.test(url)) {
|
|
251
|
+
return url;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
185
256
|
function parseArgs(argv) {
|
|
186
257
|
const [command, ...rest] = argv;
|
|
187
258
|
const parsed = { command };
|
|
@@ -204,6 +275,13 @@ function parseArgs(argv) {
|
|
|
204
275
|
}
|
|
205
276
|
return parsed;
|
|
206
277
|
}
|
|
278
|
+
var BATCH_DELETE_SIZE = 500;
|
|
279
|
+
async function batchDelete(redis, keys) {
|
|
280
|
+
for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
|
|
281
|
+
const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
|
|
282
|
+
await redis.del(...batch);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
207
285
|
async function scanKeys(redis, pattern) {
|
|
208
286
|
const keys = [];
|
|
209
287
|
let cursor = "0";
|
|
@@ -216,7 +294,7 @@ async function scanKeys(redis, pattern) {
|
|
|
216
294
|
}
|
|
217
295
|
function printUsage() {
|
|
218
296
|
process.stdout.write(
|
|
219
|
-
"Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n"
|
|
297
|
+
"Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n"
|
|
220
298
|
);
|
|
221
299
|
}
|
|
222
300
|
if (process.argv[1]?.includes("cli.")) {
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
RedisTagIndex
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-BWM4MU2X.js";
|
|
5
5
|
|
|
6
6
|
// src/cli.ts
|
|
7
7
|
import Redis from "ioredis";
|
|
8
|
+
var CONNECT_TIMEOUT_MS = 5e3;
|
|
8
9
|
async function main(argv = process.argv.slice(2)) {
|
|
9
10
|
const args = parseArgs(argv);
|
|
10
11
|
if (!args.command || !args.redisUrl) {
|
|
@@ -12,8 +13,25 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
12
13
|
process.exitCode = 1;
|
|
13
14
|
return;
|
|
14
15
|
}
|
|
15
|
-
const
|
|
16
|
+
const redisUrl = validateRedisUrl(args.redisUrl);
|
|
17
|
+
if (!redisUrl) {
|
|
18
|
+
process.stderr.write(
|
|
19
|
+
`Error: invalid Redis URL "${args.redisUrl}". Expected format: redis://[user:password@]host[:port][/db]
|
|
20
|
+
`
|
|
21
|
+
);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const redis = new Redis(redisUrl, {
|
|
26
|
+
connectTimeout: CONNECT_TIMEOUT_MS,
|
|
27
|
+
lazyConnect: true,
|
|
28
|
+
enableReadyCheck: false
|
|
29
|
+
});
|
|
16
30
|
try {
|
|
31
|
+
await redis.connect().catch((error) => {
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
+
throw new Error(`Failed to connect to Redis at "${redisUrl}": ${message}`);
|
|
34
|
+
});
|
|
17
35
|
if (args.command === "stats") {
|
|
18
36
|
const keys = await scanKeys(redis, args.pattern ?? "*");
|
|
19
37
|
process.stdout.write(`${JSON.stringify({ totalKeys: keys.length, pattern: args.pattern ?? "*" }, null, 2)}
|
|
@@ -22,8 +40,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
22
40
|
}
|
|
23
41
|
if (args.command === "keys") {
|
|
24
42
|
const keys = await scanKeys(redis, args.pattern ?? "*");
|
|
25
|
-
|
|
43
|
+
if (keys.length > 0) {
|
|
44
|
+
process.stdout.write(`${keys.join("\n")}
|
|
26
45
|
`);
|
|
46
|
+
}
|
|
27
47
|
return;
|
|
28
48
|
}
|
|
29
49
|
if (args.command === "invalidate") {
|
|
@@ -31,7 +51,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
31
51
|
const tagIndex = new RedisTagIndex({ client: redis, prefix: args.tagIndexPrefix ?? "layercache:tag-index" });
|
|
32
52
|
const keys2 = await tagIndex.keysForTag(args.tag);
|
|
33
53
|
if (keys2.length > 0) {
|
|
34
|
-
await redis
|
|
54
|
+
await batchDelete(redis, keys2);
|
|
35
55
|
}
|
|
36
56
|
process.stdout.write(`${JSON.stringify({ deletedKeys: keys2.length, tag: args.tag }, null, 2)}
|
|
37
57
|
`);
|
|
@@ -39,7 +59,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
39
59
|
}
|
|
40
60
|
const keys = await scanKeys(redis, args.pattern ?? "*");
|
|
41
61
|
if (keys.length > 0) {
|
|
42
|
-
await redis
|
|
62
|
+
await batchDelete(redis, keys);
|
|
43
63
|
}
|
|
44
64
|
process.stdout.write(`${JSON.stringify({ deletedKeys: keys.length, pattern: args.pattern ?? "*" }, null, 2)}
|
|
45
65
|
`);
|
|
@@ -47,10 +67,29 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
47
67
|
}
|
|
48
68
|
printUsage();
|
|
49
69
|
process.exitCode = 1;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
72
|
+
process.stderr.write(`Error: ${message}
|
|
73
|
+
`);
|
|
74
|
+
process.exitCode = 1;
|
|
50
75
|
} finally {
|
|
51
76
|
redis.disconnect();
|
|
52
77
|
}
|
|
53
78
|
}
|
|
79
|
+
function validateRedisUrl(url) {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = new URL(url);
|
|
82
|
+
if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return url;
|
|
86
|
+
} catch {
|
|
87
|
+
if (/^[A-Za-z0-9._-]+(:\d+)?$/.test(url)) {
|
|
88
|
+
return url;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
54
93
|
function parseArgs(argv) {
|
|
55
94
|
const [command, ...rest] = argv;
|
|
56
95
|
const parsed = { command };
|
|
@@ -73,6 +112,13 @@ function parseArgs(argv) {
|
|
|
73
112
|
}
|
|
74
113
|
return parsed;
|
|
75
114
|
}
|
|
115
|
+
var BATCH_DELETE_SIZE = 500;
|
|
116
|
+
async function batchDelete(redis, keys) {
|
|
117
|
+
for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
|
|
118
|
+
const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
|
|
119
|
+
await redis.del(...batch);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
76
122
|
async function scanKeys(redis, pattern) {
|
|
77
123
|
const keys = [];
|
|
78
124
|
let cursor = "0";
|
|
@@ -85,7 +131,7 @@ async function scanKeys(redis, pattern) {
|
|
|
85
131
|
}
|
|
86
132
|
function printUsage() {
|
|
87
133
|
process.stdout.write(
|
|
88
|
-
"Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n"
|
|
134
|
+
"Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n"
|
|
89
135
|
);
|
|
90
136
|
}
|
|
91
137
|
if (process.argv[1]?.includes("cli.")) {
|