layercache 1.3.2 → 1.3.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 CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <strong>English</strong> | <a href="./docs/i18n/README.ko.md">한국어</a> | <a href="./docs/i18n/README.zh-CN.md">简体中文</a> | <a href="./docs/i18n/README.ja.md">日本語</a> | <a href="./docs/i18n/README.es.md">Español</a>
3
+ </p>
4
+
1
5
  <p align="center">
2
6
  <img src="./logo.png" width="520" alt="layercache logo">
3
7
  </p>
@@ -5,8 +9,8 @@
5
9
  <h1 align="center">layercache</h1>
6
10
 
7
11
  <p align="center">
8
- <strong>The multi-layer caching toolkit that Node.js deserves.</strong><br>
9
- <em>Stack memory + Redis + disk. One API. Zero stampedes.</em>
12
+ <strong>100 concurrent requests. 1 DB call. Always.</strong><br>
13
+ <em>Multi-layer cache (Memory Redis Disk) with stampede prevention built in.</em>
10
14
  </p>
11
15
 
12
16
  <p align="center">
@@ -22,7 +26,7 @@
22
26
  <p align="center">
23
27
  <a href="https://layercache.flyingsquirrel.me">Website</a>&nbsp;&nbsp;|&nbsp;&nbsp;
24
28
  <a href="#-quick-start">Quick Start</a>&nbsp;&nbsp;|&nbsp;&nbsp;
25
- <a href="#-features">Features</a>&nbsp;&nbsp;|&nbsp;&nbsp;
29
+ <a href="#-performance">Performance</a>&nbsp;&nbsp;|&nbsp;&nbsp;
26
30
  <a href="./docs/api.md">API Reference</a>&nbsp;&nbsp;|&nbsp;&nbsp;
27
31
  <a href="#-integrations">Integrations</a>&nbsp;&nbsp;|&nbsp;&nbsp;
28
32
  <a href="#-comparison">Comparison</a>&nbsp;&nbsp;|&nbsp;&nbsp;
@@ -32,37 +36,20 @@
32
36
 
33
37
  ---
34
38
 
35
- ## The Problem
36
-
37
- Every growing Node.js service hits the same caching wall:
39
+ ## Why layercache?
38
40
 
41
+ ```ts
42
+ // 100 concurrent requests hit an empty cache at the same time.
43
+ // Without stampede prevention, your DB gets 100 calls.
44
+ const results = await Promise.all(
45
+ Array.from({ length: 100 }, () =>
46
+ cache.get('user:1', () => db.findUser(1))
47
+ )
48
+ )
49
+ // fetcherExecutions: 1 ← your DB was called exactly once
39
50
  ```
40
- Memory-only cache --> Fast, but each instance has a different view of data
41
- Redis-only cache --> Shared, but every request pays a network round-trip
42
- Hand-rolled hybrid --> Works... until you need stampede prevention, invalidation,
43
- stale serving, observability, and distributed consistency
44
- ```
45
-
46
- ## The Solution
47
51
 
48
- **layercache** gives you a unified multi-layer cache with production-grade features built in:
49
-
50
- ```
51
- ┌───────────────────────────────────────┐
52
- your app ---->│ layercache │
53
- │ │
54
- │ L1 Memory ~0.01ms (per-process) │
55
- │ | │
56
- │ L2 Redis ~0.5ms (shared) │
57
- │ | │
58
- │ L3 Disk ~2ms (persistent) │
59
- │ | │
60
- │ Fetcher ~20ms (runs once) │
61
- └───────────────────────────────────────┘
62
-
63
- On a hit --> serves the fastest layer, backfills the rest
64
- On a miss --> fetcher runs ONCE (even under 100x concurrency)
65
- ```
52
+ layercache is a multi-layer cache (Memory Redis → Disk) for Node.js. Stampede prevention, tag invalidation, and distributed consistency are built in — no extra config required.
66
53
 
67
54
  ---
68
55
 
@@ -113,8 +100,144 @@ const cache = new CacheStack([
113
100
 
114
101
  ---
115
102
 
103
+ ## Performance
104
+
105
+ ```
106
+ Environment: Node.js v20.20.1, Redis 7-alpine, Linux x86_64
107
+ CPU: AMD EPYC 4584PX 16-Core | RAM: 1.9 GB
108
+ Layers: MemoryLayer(ttl=60, maxSize=2000) + RedisLayer(ttl=300)
109
+ ```
110
+
111
+ ```
112
+ ┌──────────────────────────────┬──────────┬──────────┬──────────┬──────────┐
113
+ │ Scenario │ avg ms │ p95 ms │ min ms │ max ms │
114
+ ├──────────────────────────────┼──────────┼──────────┼──────────┼──────────┤
115
+ │ L1 memory hit (warm) │ 0.011 │ 0.016 │ 0.004 │ 0.405 │
116
+ │ L1 hit in layered setup │ 0.006 │ 0.007 │ 0.004 │ 0.077 │
117
+ │ No cache / origin fetch │ 6.844 │ 11.196 │ 4.683 │ 11.196 │
118
+ └──────────────────────────────┴──────────┴──────────┴──────────┴──────────┘
119
+
120
+ ┌──────────────────────────────┬────────────────────┐
121
+ │ │ 75 concurrent req │
122
+ ├──────────────────────────────┼────────────────────┤
123
+ │ Without layercache │ 75 origin calls │
124
+ │ With layercache │ 1 origin call │ ← stampede prevention
125
+ └──────────────────────────────┴────────────────────┘
126
+ ```
127
+
128
+ Benchmark commands and full scenario notes: [docs/benchmarking.md](./docs/benchmarking.md)
129
+
130
+ ---
131
+
132
+ ## Migrating from node-cache-manager?
133
+
134
+ <table>
135
+ <tr>
136
+ <th>Before</th>
137
+ <th>After</th>
138
+ </tr>
139
+ <tr>
140
+ <td>
141
+
142
+ ```ts
143
+ import { caching, multiCaching }
144
+ from 'cache-manager'
145
+ import { redisStore }
146
+ from 'cache-manager-redis-yet'
147
+
148
+ const mem = await caching('memory', {
149
+ max: 100,
150
+ ttl: 60 * 1000 // ms
151
+ })
152
+ const red = await caching(redisStore, {
153
+ url: 'redis://localhost:6379',
154
+ ttl: 300 * 1000 // ms
155
+ })
156
+ const cache = multiCaching([mem, red])
157
+
158
+ // stampede prevention: ❌
159
+ // auto backfill: ❌
160
+ // tag invalidation: ❌
161
+ ```
162
+
163
+ </td>
164
+ <td>
165
+
166
+ ```ts
167
+ import {
168
+ CacheStack,
169
+ MemoryLayer,
170
+ RedisLayer
171
+ } from 'layercache'
172
+ import Redis from 'ioredis'
173
+
174
+ const cache = new CacheStack([
175
+ new MemoryLayer({ ttl: 60 }), // s
176
+ new RedisLayer({
177
+ client: new Redis(),
178
+ ttl: 300 // s
179
+ })
180
+ ])
181
+
182
+ // stampede prevention: ✅
183
+ // auto backfill: ✅
184
+ // tag invalidation: ✅
185
+ ```
186
+
187
+ </td>
188
+ </tr>
189
+ </table>
190
+
191
+ > Full migration guides for [keyv and cacheable](./docs/migration-guide.md).
192
+
193
+ ---
194
+
195
+ ## Comparison
196
+
197
+ | | node-cache-manager | keyv | cacheable | **layercache** |
198
+ |---|:---:|:---:|:---:|:---:|
199
+ | Multi-layer + auto backfill | Partial | Plugin | -- | **Yes** |
200
+ | Stampede prevention | -- | -- | -- | **Yes** |
201
+ | Tag invalidation | -- | Yes | Yes | **Yes** |
202
+ | TypeScript-first | Partial | Yes | Yes | **Yes** |
203
+ | Event hooks | Yes | Yes | Yes | **Yes** |
204
+
205
+ <details>
206
+ <summary>Full comparison (19 features)</summary>
207
+
208
+ | | node-cache-manager | keyv | cacheable | **layercache** |
209
+ |---|:---:|:---:|:---:|:---:|
210
+ | Multi-layer with auto backfill | Partial | Plugin | -- | **Yes** |
211
+ | Stampede prevention | -- | -- | -- | **Yes** |
212
+ | Distributed single-flight | -- | -- | -- | **Yes** |
213
+ | Tag invalidation | -- | Yes | Yes | **Yes** |
214
+ | Distributed tags | -- | -- | -- | **Yes** |
215
+ | Cross-server L1 flush | -- | -- | -- | **Yes** |
216
+ | Stale-while-revalidate | -- | -- | -- | **Yes** |
217
+ | Circuit breaker | -- | -- | -- | **Yes** |
218
+ | Graceful degradation | -- | -- | -- | **Yes** |
219
+ | Sliding / adaptive TTL | -- | -- | -- | **Yes** |
220
+ | Cache warming | -- | -- | -- | **Yes** |
221
+ | Persistence / snapshots | -- | -- | -- | **Yes** |
222
+ | Compression | -- | -- | Yes | **Yes** |
223
+ | Admin CLI | -- | -- | -- | **Yes** |
224
+ | TypeScript-first | Partial | Yes | Yes | **Yes** |
225
+ | Wrap / decorator API | Yes | -- | -- | **Yes** |
226
+ | Namespaces | -- | Yes | Yes | **Yes** |
227
+ | Event hooks | Yes | Yes | Yes | **Yes** |
228
+ | Custom layers | Partial | -- | -- | **Yes** |
229
+
230
+ </details>
231
+
232
+ > See the full [comparison guide](./docs/comparison.md) for detailed breakdowns.
233
+
234
+ ---
235
+
116
236
  ## Features
117
237
 
238
+ <details>
239
+ <summary><b>Core Caching, Invalidation, Resilience & Observability (click to expand)</b></summary>
240
+
118
241
  ### Core Caching
119
242
 
120
243
  | Feature | What it does |
@@ -169,6 +292,8 @@ const cache = new CacheStack([
169
292
  | **HTTP stats handler** | JSON endpoint for dashboards |
170
293
  | **Admin CLI** | `npx layercache stats\|keys\|invalidate` for Redis-backed caches |
171
294
 
295
+ </details>
296
+
172
297
  ---
173
298
 
174
299
  ## Integrations
@@ -272,55 +397,6 @@ const cache = new CacheStack(
272
397
 
273
398
  ---
274
399
 
275
- ## Performance
276
-
277
- ```
278
- ┌─────────────────────┬──────────────┐
279
- │ Scenario │ Avg Latency │
280
- ├─────────────────────┼──────────────┤
281
- │ L1 memory hit │ ~0.006 ms │
282
- │ L2 Redis hit │ ~0.020 ms │
283
- │ No cache (sim. DB) │ ~1.08 ms │
284
- └─────────────────────┴──────────────┘
285
-
286
- ┌─────────────────────┬────────┐
287
- │ concurrentRequests │ 100 │
288
- │ fetcherExecutions │ 1 │ <-- stampede prevention
289
- └─────────────────────┴────────┘
290
- ```
291
-
292
- Benchmark commands, fixtures, and scenario notes live in [docs/benchmarking.md](./docs/benchmarking.md).
293
-
294
- ---
295
-
296
- ## Comparison
297
-
298
- | | node-cache-manager | keyv | cacheable | **layercache** |
299
- |---|:---:|:---:|:---:|:---:|
300
- | Multi-layer with auto backfill | Partial | Plugin | -- | **Yes** |
301
- | Stampede prevention | -- | -- | -- | **Yes** |
302
- | Distributed single-flight | -- | -- | -- | **Yes** |
303
- | Tag invalidation | -- | -- | Yes | **Yes** |
304
- | Distributed tags | -- | -- | -- | **Yes** |
305
- | Cross-server L1 flush | -- | -- | -- | **Yes** |
306
- | Stale-while-revalidate | -- | -- | -- | **Yes** |
307
- | Circuit breaker | -- | -- | -- | **Yes** |
308
- | Graceful degradation | -- | -- | -- | **Yes** |
309
- | Sliding / adaptive TTL | -- | -- | -- | **Yes** |
310
- | Cache warming | -- | -- | -- | **Yes** |
311
- | Persistence / snapshots | -- | -- | -- | **Yes** |
312
- | Compression | -- | -- | Yes | **Yes** |
313
- | Admin CLI | -- | -- | -- | **Yes** |
314
- | TypeScript-first | Partial | Yes | Yes | **Yes** |
315
- | Wrap / decorator API | Yes | -- | -- | **Yes** |
316
- | Namespaces | -- | Yes | Yes | **Yes** |
317
- | Event hooks | Yes | Yes | Yes | **Yes** |
318
- | Custom layers | Partial | -- | -- | **Yes** |
319
-
320
- > See the full [comparison guide](./docs/comparison.md) for detailed breakdowns.
321
-
322
- ---
323
-
324
400
  ## Documentation
325
401
 
326
402
  | Document | Description |
@@ -195,7 +195,7 @@ var MAX_PATTERN_RECURSION_DEPTH = 500;
195
195
  var TagIndex = class {
196
196
  tagToKeys = /* @__PURE__ */ new Map();
197
197
  keyToTags = /* @__PURE__ */ new Map();
198
- knownKeys = /* @__PURE__ */ new Set();
198
+ knownKeys = /* @__PURE__ */ new Map();
199
199
  maxKnownKeys;
200
200
  nextNodeId = 1;
201
201
  root = this.createTrieNode();
@@ -283,10 +283,11 @@ var TagIndex = class {
283
283
  };
284
284
  }
285
285
  insertKnownKey(key) {
286
- if (this.knownKeys.has(key)) {
286
+ const isNew = !this.knownKeys.has(key);
287
+ this.knownKeys.set(key, Date.now());
288
+ if (!isNew) {
287
289
  return;
288
290
  }
289
- this.knownKeys.add(key);
290
291
  let node = this.root;
291
292
  for (const character of key) {
292
293
  let child = node.children.get(character);
@@ -381,14 +382,13 @@ var TagIndex = class {
381
382
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
382
383
  return;
383
384
  }
385
+ const sorted = [...this.knownKeys.entries()].sort((a, b) => a[1] - b[1]);
384
386
  const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
385
- let removed = 0;
386
- for (const key of this.knownKeys) {
387
- if (removed >= toRemove) {
388
- break;
387
+ for (let i = 0; i < toRemove && i < sorted.length; i += 1) {
388
+ const entry = sorted[i];
389
+ if (entry) {
390
+ this.removeKey(entry[0]);
389
391
  }
390
- this.removeKey(key);
391
- removed += 1;
392
392
  }
393
393
  }
394
394
  removeKey(key) {
@@ -2,6 +2,130 @@ import {
2
2
  PatternMatcher
3
3
  } from "./chunk-4PPBOOXT.js";
4
4
 
5
+ // src/internal/CacheStackValidation.ts
6
+ var MAX_CACHE_KEY_LENGTH = 1024;
7
+ var MAX_PATTERN_LENGTH = 1024;
8
+ var MAX_TAGS_PER_OPERATION = 128;
9
+ function validatePositiveNumber(name, value) {
10
+ if (value === void 0) {
11
+ return;
12
+ }
13
+ if (!Number.isFinite(value) || value <= 0) {
14
+ throw new Error(`${name} must be a positive finite number.`);
15
+ }
16
+ }
17
+ function validateNonNegativeNumber(name, value) {
18
+ if (!Number.isFinite(value) || value < 0) {
19
+ throw new Error(`${name} must be a non-negative finite number.`);
20
+ }
21
+ }
22
+ function validateLayerNumberOption(name, value) {
23
+ if (value === void 0) {
24
+ return;
25
+ }
26
+ if (typeof value === "number") {
27
+ validateNonNegativeNumber(name, value);
28
+ return;
29
+ }
30
+ for (const [layerName, layerValue] of Object.entries(value)) {
31
+ if (layerValue === void 0) {
32
+ continue;
33
+ }
34
+ validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
35
+ }
36
+ }
37
+ function validateRateLimitOptions(name, options) {
38
+ if (!options) {
39
+ return;
40
+ }
41
+ validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
42
+ validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
43
+ validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
44
+ if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
45
+ throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
46
+ }
47
+ if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
48
+ throw new Error(`${name}.bucketKey must not be empty.`);
49
+ }
50
+ }
51
+ function validateCacheKey(key) {
52
+ if (key.length === 0) {
53
+ throw new Error("Cache key must not be empty.");
54
+ }
55
+ if (key.length > MAX_CACHE_KEY_LENGTH) {
56
+ throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
57
+ }
58
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
59
+ throw new Error("Cache key contains unsupported control characters.");
60
+ }
61
+ if (/[\uD800-\uDFFF]/.test(key)) {
62
+ throw new Error("Cache key contains unsupported surrogate code points.");
63
+ }
64
+ return key;
65
+ }
66
+ function validateTag(tag) {
67
+ if (tag.length === 0) {
68
+ throw new Error("Cache tag must not be empty.");
69
+ }
70
+ if (tag.length > MAX_CACHE_KEY_LENGTH) {
71
+ throw new Error(`Cache tag length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
72
+ }
73
+ if (/[\u0000-\u001F\u007F]/.test(tag)) {
74
+ throw new Error("Cache tag contains unsupported control characters.");
75
+ }
76
+ if (/[\uD800-\uDFFF]/.test(tag)) {
77
+ throw new Error("Cache tag contains unsupported surrogate code points.");
78
+ }
79
+ return tag;
80
+ }
81
+ function validateTags(tags) {
82
+ if (!tags) {
83
+ return;
84
+ }
85
+ if (tags.length > MAX_TAGS_PER_OPERATION) {
86
+ throw new Error(`options.tags must contain at most ${MAX_TAGS_PER_OPERATION} tags.`);
87
+ }
88
+ for (const tag of tags) {
89
+ validateTag(tag);
90
+ }
91
+ }
92
+ function validatePattern(pattern) {
93
+ if (pattern.length === 0) {
94
+ throw new Error("Pattern must not be empty.");
95
+ }
96
+ if (pattern.length > MAX_PATTERN_LENGTH) {
97
+ throw new Error(`Pattern length must be at most ${MAX_PATTERN_LENGTH} characters.`);
98
+ }
99
+ if (/[\u0000-\u001F\u007F]/.test(pattern)) {
100
+ throw new Error("Pattern contains unsupported control characters.");
101
+ }
102
+ }
103
+ function validateTtlPolicy(name, policy) {
104
+ if (!policy || typeof policy === "function" || policy === "until-midnight" || policy === "next-hour") {
105
+ return;
106
+ }
107
+ if ("alignTo" in policy) {
108
+ validatePositiveNumber(`${name}.alignTo`, policy.alignTo);
109
+ return;
110
+ }
111
+ throw new Error(`${name} is invalid.`);
112
+ }
113
+ function validateAdaptiveTtlOptions(options) {
114
+ if (!options || options === true) {
115
+ return;
116
+ }
117
+ validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
118
+ validateLayerNumberOption("adaptiveTtl.step", options.step);
119
+ validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
120
+ }
121
+ function validateCircuitBreakerOptions(options) {
122
+ if (!options) {
123
+ return;
124
+ }
125
+ validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
126
+ validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
127
+ }
128
+
5
129
  // src/invalidation/RedisTagIndex.ts
6
130
  var RedisTagIndex = class {
7
131
  client;
@@ -183,5 +307,16 @@ function simpleHash(value) {
183
307
  }
184
308
 
185
309
  export {
310
+ validatePositiveNumber,
311
+ validateNonNegativeNumber,
312
+ validateLayerNumberOption,
313
+ validateRateLimitOptions,
314
+ validateCacheKey,
315
+ validateTag,
316
+ validateTags,
317
+ validatePattern,
318
+ validateTtlPolicy,
319
+ validateAdaptiveTtlOptions,
320
+ validateCircuitBreakerOptions,
186
321
  RedisTagIndex
187
322
  };
package/dist/cli.cjs CHANGED
@@ -36,6 +36,51 @@ __export(cli_exports, {
36
36
  module.exports = __toCommonJS(cli_exports);
37
37
  var import_ioredis = __toESM(require("ioredis"), 1);
38
38
 
39
+ // src/internal/CacheStackValidation.ts
40
+ var MAX_CACHE_KEY_LENGTH = 1024;
41
+ var MAX_PATTERN_LENGTH = 1024;
42
+ function validateCacheKey(key) {
43
+ if (key.length === 0) {
44
+ throw new Error("Cache key must not be empty.");
45
+ }
46
+ if (key.length > MAX_CACHE_KEY_LENGTH) {
47
+ throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
48
+ }
49
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
50
+ throw new Error("Cache key contains unsupported control characters.");
51
+ }
52
+ if (/[\uD800-\uDFFF]/.test(key)) {
53
+ throw new Error("Cache key contains unsupported surrogate code points.");
54
+ }
55
+ return key;
56
+ }
57
+ function validateTag(tag) {
58
+ if (tag.length === 0) {
59
+ throw new Error("Cache tag must not be empty.");
60
+ }
61
+ if (tag.length > MAX_CACHE_KEY_LENGTH) {
62
+ throw new Error(`Cache tag length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
63
+ }
64
+ if (/[\u0000-\u001F\u007F]/.test(tag)) {
65
+ throw new Error("Cache tag contains unsupported control characters.");
66
+ }
67
+ if (/[\uD800-\uDFFF]/.test(tag)) {
68
+ throw new Error("Cache tag contains unsupported surrogate code points.");
69
+ }
70
+ return tag;
71
+ }
72
+ function validatePattern(pattern) {
73
+ if (pattern.length === 0) {
74
+ throw new Error("Pattern must not be empty.");
75
+ }
76
+ if (pattern.length > MAX_PATTERN_LENGTH) {
77
+ throw new Error(`Pattern length must be at most ${MAX_PATTERN_LENGTH} characters.`);
78
+ }
79
+ if (/[\u0000-\u001F\u007F]/.test(pattern)) {
80
+ throw new Error("Pattern contains unsupported control characters.");
81
+ }
82
+ }
83
+
39
84
  // src/internal/StoredValue.ts
40
85
  function isStoredValueEnvelope(value) {
41
86
  if (typeof value !== "object" || value === null) {
@@ -388,13 +433,17 @@ async function main(argv = process.argv.slice(2)) {
388
433
  throw new Error(`Failed to connect to Redis at ${maskRedisUrl(redisUrl)}: ${message}`);
389
434
  });
390
435
  if (args.command === "stats") {
391
- const keys = await scanKeys(redis, args.pattern ?? "*");
392
- process.stdout.write(`${JSON.stringify({ totalKeys: keys.length, pattern: args.pattern ?? "*" }, null, 2)}
436
+ const pattern = args.pattern ?? "*";
437
+ if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
438
+ const keys = await scanKeys(redis, pattern);
439
+ process.stdout.write(`${JSON.stringify({ totalKeys: keys.length, pattern }, null, 2)}
393
440
  `);
394
441
  return;
395
442
  }
396
443
  if (args.command === "keys") {
397
- const keys = await scanKeys(redis, args.pattern ?? "*");
444
+ const pattern = args.pattern ?? "*";
445
+ if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
446
+ const keys = await scanKeys(redis, pattern);
398
447
  if (keys.length > 0) {
399
448
  process.stdout.write(`${keys.join("\n")}
400
449
  `);
@@ -403,6 +452,7 @@ async function main(argv = process.argv.slice(2)) {
403
452
  }
404
453
  if (args.command === "invalidate") {
405
454
  if (args.tag) {
455
+ if (!validateCliInput(args.tag, validateTag)) return;
406
456
  const tagIndex = new RedisTagIndex({ client: redis, prefix: args.tagIndexPrefix ?? "layercache:tag-index" });
407
457
  const keys2 = await tagIndex.keysForTag(args.tag);
408
458
  if (keys2.length > 0) {
@@ -412,11 +462,18 @@ async function main(argv = process.argv.slice(2)) {
412
462
  `);
413
463
  return;
414
464
  }
415
- const keys = await scanKeys(redis, args.pattern ?? "*");
465
+ const effectivePattern = args.pattern ?? "*";
466
+ if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
467
+ const keys = await scanKeys(redis, effectivePattern);
468
+ if (!args.pattern && !args.force && keys.length > 0) {
469
+ process.stderr.write(`Warning: this operation will invalidate ${keys.length} keys. Use --force to confirm.
470
+ `);
471
+ return;
472
+ }
416
473
  if (keys.length > 0) {
417
474
  await batchDelete(redis, keys);
418
475
  }
419
- process.stdout.write(`${JSON.stringify({ deletedKeys: keys.length, pattern: args.pattern ?? "*" }, null, 2)}
476
+ process.stdout.write(`${JSON.stringify({ deletedKeys: keys.length, pattern: effectivePattern }, null, 2)}
420
477
  `);
421
478
  return;
422
479
  }
@@ -424,6 +481,7 @@ async function main(argv = process.argv.slice(2)) {
424
481
  if (!args.key) {
425
482
  throw new Error("inspect requires --key <key>.");
426
483
  }
484
+ if (!validateCliInput(args.key, validateCacheKey)) return;
427
485
  const payload = await redis.getBuffer(args.key);
428
486
  const ttl = await redis.ttl(args.key);
429
487
  const decoded = decodeInspectablePayload(payload);
@@ -498,6 +556,8 @@ function parseArgs(argv) {
498
556
  index += 1;
499
557
  } else if (token === "--require-tls") {
500
558
  parsed.requireTls = true;
559
+ } else if (token === "--force") {
560
+ parsed.force = true;
501
561
  }
502
562
  }
503
563
  return parsed;
@@ -574,6 +634,18 @@ function maskRedisUrl(url) {
574
634
  return url.replace(/:([^@/]+)@/, ":***@");
575
635
  }
576
636
  }
637
+ function validateCliInput(value, validator) {
638
+ try {
639
+ validator(value);
640
+ return true;
641
+ } catch (error) {
642
+ const message = error instanceof Error ? error.message : String(error);
643
+ process.stderr.write(`Error: ${message}
644
+ `);
645
+ process.exitCode = 1;
646
+ return false;
647
+ }
648
+ }
577
649
  if (process.argv[1]?.endsWith("cli.cjs") || process.argv[1]?.endsWith("cli.js")) {
578
650
  void main();
579
651
  }