layercache 1.0.1 → 1.0.2

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
@@ -6,6 +6,7 @@
6
6
  [![npm downloads](https://img.shields.io/npm/dw/layercache)](https://www.npmjs.com/package/layercache)
7
7
  [![license](https://img.shields.io/npm/l/layercache)](LICENSE)
8
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-blue)](https://www.typescriptlang.org/)
9
+ [![test coverage](https://img.shields.io/badge/tests-49%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
9
10
 
10
11
  ```
11
12
  L1 hit ~0.01 ms ← served from memory, zero network
@@ -44,12 +45,24 @@ On a hit, the value is returned from the fastest layer that has it, and automati
44
45
  - **Negative caching** — cache known misses for a short TTL to protect the database
45
46
  - **Stale strategies** — `staleWhileRevalidate` and `staleIfError` as opt-in read behavior
46
47
  - **TTL jitter** — spread expirations to avoid synchronized stampedes
48
+ - **Sliding & adaptive TTL** — extend TTL on every read or ramp it up for hot keys
49
+ - **Refresh-ahead** — trigger background refresh when TTL drops below a threshold
47
50
  - **Best-effort writes** — tolerate partial layer write failures when desired
48
51
  - **Bulk reads** — `mget` uses layer-level `getMany()` when available
49
52
  - **Distributed tag index** — `RedisTagIndex` keeps tag state consistent across multiple servers
50
53
  - **Optional distributed single-flight** — plug in a coordinator to dedupe misses across instances
51
54
  - **Cross-server L1 invalidation** — Redis pub/sub bus flushes stale memory on other instances when you write or delete
52
- - **Metrics**hit/miss/fetch/backfill counters built in
55
+ - **`wrap()` decorator API** — turn any async function into a cached version with auto-generated keys
56
+ - **Cache warming** — pre-populate layers with a prioritised list of entries at startup
57
+ - **Namespaces** — scope a `CacheStack` to a key prefix for multi-tenant or module isolation
58
+ - **Event hooks** — `EventEmitter`-based events for hits, misses, stale serves, errors, and more
59
+ - **Graceful degradation** — skip a failing layer for a configurable retry window
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
63
+ - **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** — Fastify plugin, tRPC middleware, GraphQL resolver wrapper
53
66
  - **MessagePack serializer** — drop-in replacement for lower Redis memory usage
54
67
  - **NestJS module** — `CacheStackModule.forRoot(...)` with `@InjectCacheStack()`
55
68
  - **Custom layers** — implement the 5-method `CacheLayer` interface to plug in Memcached, DynamoDB, or anything else
@@ -178,6 +191,67 @@ const [user1, user2] = await cache.mget([
178
191
  const { hits, misses, fetches, staleHits, refreshes, writeFailures } = cache.getMetrics()
179
192
  ```
180
193
 
194
+ ### `cache.resetMetrics(): void`
195
+
196
+ Resets all counters to zero — useful for per-interval reporting.
197
+
198
+ ```ts
199
+ cache.resetMetrics()
200
+ ```
201
+
202
+ ### `cache.getStats(): CacheStatsSnapshot`
203
+
204
+ Returns metrics, per-layer degradation state, and the number of in-flight background refreshes.
205
+
206
+ ```ts
207
+ const { metrics, layers, backgroundRefreshes } = cache.getStats()
208
+ // layers: [{ name, isLocal, degradedUntil }]
209
+ ```
210
+
211
+ ### `cache.wrap(prefix, fetcher, options?)`
212
+
213
+ Wraps an async function so every call is transparently cached. The key is derived from the function arguments unless you supply a `keyResolver`.
214
+
215
+ ```ts
216
+ const getUser = cache.wrap('user', (id: number) => db.findUser(id))
217
+
218
+ const user = await getUser(123) // key → "user:123"
219
+
220
+ // Custom key resolver
221
+ const getUser = cache.wrap(
222
+ 'user',
223
+ (id: number) => db.findUser(id),
224
+ { keyResolver: (id) => String(id), ttl: 300 }
225
+ )
226
+ ```
227
+
228
+ ### `cache.warm(entries, options?)`
229
+
230
+ Pre-populate layers at startup from a prioritised list. Higher `priority` values run first.
231
+
232
+ ```ts
233
+ await cache.warm(
234
+ [
235
+ { key: 'config', fetcher: () => db.getConfig(), priority: 10 },
236
+ { key: 'user:1', fetcher: () => db.findUser(1), priority: 5 },
237
+ { key: 'user:2', fetcher: () => db.findUser(2), priority: 5 },
238
+ ],
239
+ { concurrency: 4, continueOnError: true }
240
+ )
241
+ ```
242
+
243
+ ### `cache.namespace(prefix): CacheNamespace`
244
+
245
+ Returns a scoped view with the same full API (`get`, `set`, `delete`, `clear`, `mget`, `wrap`, `warm`, `invalidateByTag`, `invalidateByPattern`, `getMetrics`). `clear()` only touches `prefix:*` keys.
246
+
247
+ ```ts
248
+ const users = cache.namespace('users')
249
+ const posts = cache.namespace('posts')
250
+
251
+ await users.set('123', userData) // stored as "users:123"
252
+ await users.clear() // only deletes "users:*"
253
+ ```
254
+
181
255
  ---
182
256
 
183
257
  ## Negative + stale caching
@@ -364,6 +438,200 @@ await cache.set('key', value, { ttl: { local: 15, shared: 600 } })
364
438
 
365
439
  ---
366
440
 
441
+ ## Sliding & adaptive TTL
442
+
443
+ **Sliding TTL** resets the TTL on every read so frequently-accessed keys never expire.
444
+
445
+ ```ts
446
+ const value = await cache.get('session:abc', fetchSession, { slidingTtl: true })
447
+ ```
448
+
449
+ **Adaptive TTL** automatically increases the TTL of hot keys up to a ceiling.
450
+
451
+ ```ts
452
+ await cache.get('popular-post', fetchPost, {
453
+ adaptiveTtl: {
454
+ hotAfter: 5, // ramp up after 5 hits
455
+ step: 60, // add 60s per hit
456
+ maxTtl: 3600 // cap at 1h
457
+ }
458
+ })
459
+ ```
460
+
461
+ **Refresh-ahead** triggers a background refresh when the remaining TTL drops below a threshold, so callers never see a miss.
462
+
463
+ ```ts
464
+ await cache.get('leaderboard', fetchLeaderboard, {
465
+ ttl: 120,
466
+ refreshAhead: 30 // start refreshing when ≤30s remain
467
+ })
468
+ ```
469
+
470
+ ---
471
+
472
+ ## Graceful degradation & circuit breaker
473
+
474
+ **Graceful degradation** marks a layer as degraded on failure and skips it for a retry window, keeping the cache available even if Redis is briefly unreachable.
475
+
476
+ ```ts
477
+ new CacheStack([...], {
478
+ gracefulDegradation: { retryAfterMs: 10_000 }
479
+ })
480
+ ```
481
+
482
+ **Circuit breaker** opens after repeated fetcher failures for a key, returning `null` instead of hammering a broken downstream.
483
+
484
+ ```ts
485
+ new CacheStack([...], {
486
+ circuitBreaker: {
487
+ failureThreshold: 5, // open after 5 consecutive failures
488
+ cooldownMs: 30_000 // retry after 30s
489
+ }
490
+ })
491
+
492
+ // Or per-operation
493
+ await cache.get('fragile-key', fetch, {
494
+ circuitBreaker: { failureThreshold: 3, cooldownMs: 10_000 }
495
+ })
496
+ ```
497
+
498
+ ---
499
+
500
+ ## Compression
501
+
502
+ `RedisLayer` can transparently compress values before writing. Values smaller than `compressionThreshold` are stored as-is.
503
+
504
+ ```ts
505
+ new RedisLayer({
506
+ client: redis,
507
+ ttl: 300,
508
+ compression: 'gzip', // or 'brotli'
509
+ compressionThreshold: 1_024 // bytes — skip compression for small values
510
+ })
511
+ ```
512
+
513
+ ---
514
+
515
+ ## Stats & HTTP endpoint
516
+
517
+ `cache.getStats()` returns a full snapshot suitable for dashboards or health checks.
518
+
519
+ ```ts
520
+ const stats = cache.getStats()
521
+ // {
522
+ // metrics: { hits, misses, fetches, circuitBreakerTrips, ... },
523
+ // layers: [{ name, isLocal, degradedUntil }],
524
+ // backgroundRefreshes: 2
525
+ // }
526
+ ```
527
+
528
+ Mount a JSON endpoint with the built-in HTTP handler (works with Express, Fastify, Next.js):
529
+
530
+ ```ts
531
+ import { createCacheStatsHandler } from 'layercache'
532
+ import http from 'node:http'
533
+
534
+ const statsHandler = createCacheStatsHandler(cache)
535
+ http.createServer(statsHandler).listen(9090)
536
+ // GET / → JSON stats
537
+ ```
538
+
539
+ Or use the Fastify plugin:
540
+
541
+ ```ts
542
+ import { createFastifyLayercachePlugin } from 'layercache/integrations/fastify'
543
+
544
+ await fastify.register(createFastifyLayercachePlugin(cache, {
545
+ statsPath: '/cache/stats' // default; set exposeStatsRoute: false to disable
546
+ }))
547
+ // fastify.cache is now available in all handlers
548
+ ```
549
+
550
+ ---
551
+
552
+ ## Persistence & snapshots
553
+
554
+ Transfer cache state between `CacheStack` instances or survive a restart.
555
+
556
+ ```ts
557
+ // In-memory snapshot
558
+ const snapshot = await cache.exportState()
559
+ await anotherCache.importState(snapshot)
560
+
561
+ // Disk snapshot
562
+ await cache.persistToFile('./cache-snapshot.json')
563
+ await cache.restoreFromFile('./cache-snapshot.json')
564
+ ```
565
+
566
+ ---
567
+
568
+ ## Event hooks
569
+
570
+ `CacheStack` extends `EventEmitter`. Subscribe to events for monitoring or custom side-effects.
571
+
572
+ | Event | Payload |
573
+ |-------|---------|
574
+ | `hit` | `{ key, layer }` |
575
+ | `miss` | `{ key }` |
576
+ | `set` | `{ key }` |
577
+ | `delete` | `{ key }` |
578
+ | `stale-serve` | `{ key, state, layer }` |
579
+ | `stampede-dedupe` | `{ key }` |
580
+ | `backfill` | `{ key, fromLayer, toLayer }` |
581
+ | `warm` | `{ key }` |
582
+ | `error` | `{ event, context }` |
583
+
584
+ ```ts
585
+ cache.on('hit', ({ key, layer }) => metrics.inc('cache.hit', { layer }))
586
+ cache.on('miss', ({ key }) => metrics.inc('cache.miss'))
587
+ cache.on('error', ({ event, context }) => logger.error(event, context))
588
+ ```
589
+
590
+ ---
591
+
592
+ ## Framework integrations
593
+
594
+ ### tRPC
595
+
596
+ ```ts
597
+ import { createTrpcCacheMiddleware } from 'layercache/integrations/trpc'
598
+
599
+ const cacheMiddleware = createTrpcCacheMiddleware(cache, 'trpc', { ttl: 60 })
600
+
601
+ export const cachedProcedure = t.procedure.use(cacheMiddleware)
602
+ ```
603
+
604
+ ### GraphQL
605
+
606
+ ```ts
607
+ import { cacheGraphqlResolver } from 'layercache/integrations/graphql'
608
+
609
+ const resolvers = {
610
+ Query: {
611
+ user: cacheGraphqlResolver(cache, 'user', (_root, { id }) => db.findUser(id), {
612
+ keyResolver: (_root, { id }) => id,
613
+ ttl: 300
614
+ })
615
+ }
616
+ }
617
+ ```
618
+
619
+ ---
620
+
621
+ ## Admin CLI
622
+
623
+ Inspect and manage a Redis-backed cache without writing code.
624
+
625
+ ```bash
626
+ # Requires ioredis
627
+ npx layercache stats --redis redis://localhost:6379
628
+ npx layercache keys --redis redis://localhost:6379 --pattern "user:*"
629
+ npx layercache invalidate --redis redis://localhost:6379 --tag user:123
630
+ npx layercache invalidate --redis redis://localhost:6379 --pattern "session:*"
631
+ ```
632
+
633
+ ---
634
+
367
635
  ## MessagePack serialization
368
636
 
369
637
  Reduces Redis memory usage and speeds up serialization for large values:
@@ -512,17 +780,28 @@ Example output from a local run:
512
780
 
513
781
  ## Comparison
514
782
 
515
- | | node-cache | ioredis | cache-manager | **layercache** |
783
+ | | node-cache-manager | keyv | cacheable | **layercache** |
516
784
  |---|:---:|:---:|:---:|:---:|
517
- | Multi-layer | | | | ✅ |
785
+ | Multi-layer | | Plugin | | ✅ |
518
786
  | Auto backfill | ❌ | ❌ | ❌ | ✅ |
519
787
  | Stampede prevention | ❌ | ❌ | ❌ | ✅ |
520
- | Tag invalidation | ❌ | ❌ | | ✅ |
788
+ | Tag invalidation | ❌ | ❌ | | ✅ |
521
789
  | Distributed tags | ❌ | ❌ | ❌ | ✅ |
522
790
  | Cross-server L1 flush | ❌ | ❌ | ❌ | ✅ |
523
- | TypeScript-first | | ✅ | | ✅ |
524
- | NestJS module | | ❌ | | ✅ |
525
- | Custom layers | ❌ | | | ✅ |
791
+ | TypeScript-first | | ✅ | | ✅ |
792
+ | Wrap / decorator API | | ❌ | | ✅ |
793
+ | Cache warming | ❌ | | | ✅ |
794
+ | Namespaces | ❌ | ✅ | ✅ | ✅ |
795
+ | Sliding / adaptive TTL | ❌ | ❌ | ❌ | ✅ |
796
+ | Event hooks | ✅ | ✅ | ✅ | ✅ |
797
+ | Circuit breaker | ❌ | ❌ | ❌ | ✅ |
798
+ | Graceful degradation | ❌ | ❌ | ❌ | ✅ |
799
+ | Compression | ❌ | ❌ | ✅ | ✅ |
800
+ | Persistence / snapshots | ❌ | ❌ | ❌ | ✅ |
801
+ | Admin CLI | ❌ | ❌ | ❌ | ✅ |
802
+ | Pluggable logger | ❌ | ❌ | ✅ | ✅ |
803
+ | NestJS module | ❌ | ❌ | ❌ | ✅ |
804
+ | Custom layers | △ | ❌ | ❌ | ✅ |
526
805
 
527
806
  ---
528
807
 
@@ -0,0 +1,103 @@
1
+ // src/invalidation/PatternMatcher.ts
2
+ var PatternMatcher = class {
3
+ static matches(pattern, value) {
4
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
5
+ const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
6
+ return regex.test(value);
7
+ }
8
+ };
9
+
10
+ // src/invalidation/RedisTagIndex.ts
11
+ var RedisTagIndex = class {
12
+ client;
13
+ prefix;
14
+ scanCount;
15
+ constructor(options) {
16
+ this.client = options.client;
17
+ this.prefix = options.prefix ?? "layercache:tag-index";
18
+ this.scanCount = options.scanCount ?? 100;
19
+ }
20
+ async touch(key) {
21
+ await this.client.sadd(this.knownKeysKey(), key);
22
+ }
23
+ async track(key, tags) {
24
+ const keyTagsKey = this.keyTagsKey(key);
25
+ const existingTags = await this.client.smembers(keyTagsKey);
26
+ const pipeline = this.client.pipeline();
27
+ pipeline.sadd(this.knownKeysKey(), key);
28
+ for (const tag of existingTags) {
29
+ pipeline.srem(this.tagKeysKey(tag), key);
30
+ }
31
+ pipeline.del(keyTagsKey);
32
+ if (tags.length > 0) {
33
+ pipeline.sadd(keyTagsKey, ...tags);
34
+ for (const tag of new Set(tags)) {
35
+ pipeline.sadd(this.tagKeysKey(tag), key);
36
+ }
37
+ }
38
+ await pipeline.exec();
39
+ }
40
+ async remove(key) {
41
+ const keyTagsKey = this.keyTagsKey(key);
42
+ const existingTags = await this.client.smembers(keyTagsKey);
43
+ const pipeline = this.client.pipeline();
44
+ pipeline.srem(this.knownKeysKey(), key);
45
+ pipeline.del(keyTagsKey);
46
+ for (const tag of existingTags) {
47
+ pipeline.srem(this.tagKeysKey(tag), key);
48
+ }
49
+ await pipeline.exec();
50
+ }
51
+ async keysForTag(tag) {
52
+ return this.client.smembers(this.tagKeysKey(tag));
53
+ }
54
+ async matchPattern(pattern) {
55
+ const matches = [];
56
+ let cursor = "0";
57
+ do {
58
+ const [nextCursor, keys] = await this.client.sscan(
59
+ this.knownKeysKey(),
60
+ cursor,
61
+ "MATCH",
62
+ pattern,
63
+ "COUNT",
64
+ this.scanCount
65
+ );
66
+ cursor = nextCursor;
67
+ matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
68
+ } while (cursor !== "0");
69
+ return matches;
70
+ }
71
+ async clear() {
72
+ const indexKeys = await this.scanIndexKeys();
73
+ if (indexKeys.length === 0) {
74
+ return;
75
+ }
76
+ await this.client.del(...indexKeys);
77
+ }
78
+ async scanIndexKeys() {
79
+ const matches = [];
80
+ let cursor = "0";
81
+ const pattern = `${this.prefix}:*`;
82
+ do {
83
+ const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
84
+ cursor = nextCursor;
85
+ matches.push(...keys);
86
+ } while (cursor !== "0");
87
+ return matches;
88
+ }
89
+ knownKeysKey() {
90
+ return `${this.prefix}:keys`;
91
+ }
92
+ keyTagsKey(key) {
93
+ return `${this.prefix}:key:${encodeURIComponent(key)}`;
94
+ }
95
+ tagKeysKey(tag) {
96
+ return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
97
+ }
98
+ };
99
+
100
+ export {
101
+ PatternMatcher,
102
+ RedisTagIndex
103
+ };
package/dist/cli.cjs ADDED
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
22
+ // If the importer is in node compatibility mode or this is not an ESM
23
+ // file that has been converted to a CommonJS file using a Babel-
24
+ // compatible transform (i.e. "__esModule" has not been set), then set
25
+ // "default" to the CommonJS "module.exports" for node compatibility.
26
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
27
+ mod
28
+ ));
29
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
+
31
+ // src/cli.ts
32
+ var cli_exports = {};
33
+ __export(cli_exports, {
34
+ main: () => main
35
+ });
36
+ module.exports = __toCommonJS(cli_exports);
37
+ var import_ioredis = __toESM(require("ioredis"), 1);
38
+
39
+ // src/invalidation/PatternMatcher.ts
40
+ var PatternMatcher = class {
41
+ static matches(pattern, value) {
42
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
43
+ const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
44
+ return regex.test(value);
45
+ }
46
+ };
47
+
48
+ // src/invalidation/RedisTagIndex.ts
49
+ var RedisTagIndex = class {
50
+ client;
51
+ prefix;
52
+ scanCount;
53
+ constructor(options) {
54
+ this.client = options.client;
55
+ this.prefix = options.prefix ?? "layercache:tag-index";
56
+ this.scanCount = options.scanCount ?? 100;
57
+ }
58
+ async touch(key) {
59
+ await this.client.sadd(this.knownKeysKey(), key);
60
+ }
61
+ async track(key, tags) {
62
+ const keyTagsKey = this.keyTagsKey(key);
63
+ const existingTags = await this.client.smembers(keyTagsKey);
64
+ const pipeline = this.client.pipeline();
65
+ pipeline.sadd(this.knownKeysKey(), key);
66
+ for (const tag of existingTags) {
67
+ pipeline.srem(this.tagKeysKey(tag), key);
68
+ }
69
+ pipeline.del(keyTagsKey);
70
+ if (tags.length > 0) {
71
+ pipeline.sadd(keyTagsKey, ...tags);
72
+ for (const tag of new Set(tags)) {
73
+ pipeline.sadd(this.tagKeysKey(tag), key);
74
+ }
75
+ }
76
+ await pipeline.exec();
77
+ }
78
+ async remove(key) {
79
+ const keyTagsKey = this.keyTagsKey(key);
80
+ const existingTags = await this.client.smembers(keyTagsKey);
81
+ const pipeline = this.client.pipeline();
82
+ pipeline.srem(this.knownKeysKey(), key);
83
+ pipeline.del(keyTagsKey);
84
+ for (const tag of existingTags) {
85
+ pipeline.srem(this.tagKeysKey(tag), key);
86
+ }
87
+ await pipeline.exec();
88
+ }
89
+ async keysForTag(tag) {
90
+ return this.client.smembers(this.tagKeysKey(tag));
91
+ }
92
+ async matchPattern(pattern) {
93
+ const matches = [];
94
+ let cursor = "0";
95
+ do {
96
+ const [nextCursor, keys] = await this.client.sscan(
97
+ this.knownKeysKey(),
98
+ cursor,
99
+ "MATCH",
100
+ pattern,
101
+ "COUNT",
102
+ this.scanCount
103
+ );
104
+ cursor = nextCursor;
105
+ matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
106
+ } while (cursor !== "0");
107
+ return matches;
108
+ }
109
+ async clear() {
110
+ const indexKeys = await this.scanIndexKeys();
111
+ if (indexKeys.length === 0) {
112
+ return;
113
+ }
114
+ await this.client.del(...indexKeys);
115
+ }
116
+ async scanIndexKeys() {
117
+ const matches = [];
118
+ let cursor = "0";
119
+ const pattern = `${this.prefix}:*`;
120
+ do {
121
+ const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
122
+ cursor = nextCursor;
123
+ matches.push(...keys);
124
+ } while (cursor !== "0");
125
+ return matches;
126
+ }
127
+ knownKeysKey() {
128
+ return `${this.prefix}:keys`;
129
+ }
130
+ keyTagsKey(key) {
131
+ return `${this.prefix}:key:${encodeURIComponent(key)}`;
132
+ }
133
+ tagKeysKey(tag) {
134
+ return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
135
+ }
136
+ };
137
+
138
+ // src/cli.ts
139
+ async function main(argv = process.argv.slice(2)) {
140
+ const args = parseArgs(argv);
141
+ if (!args.command || !args.redisUrl) {
142
+ printUsage();
143
+ process.exitCode = 1;
144
+ return;
145
+ }
146
+ const redis = new import_ioredis.default(args.redisUrl);
147
+ try {
148
+ if (args.command === "stats") {
149
+ const keys = await scanKeys(redis, args.pattern ?? "*");
150
+ process.stdout.write(`${JSON.stringify({ totalKeys: keys.length, pattern: args.pattern ?? "*" }, null, 2)}
151
+ `);
152
+ return;
153
+ }
154
+ if (args.command === "keys") {
155
+ const keys = await scanKeys(redis, args.pattern ?? "*");
156
+ process.stdout.write(`${keys.join("\n")}
157
+ `);
158
+ return;
159
+ }
160
+ if (args.command === "invalidate") {
161
+ if (args.tag) {
162
+ const tagIndex = new RedisTagIndex({ client: redis, prefix: args.tagIndexPrefix ?? "layercache:tag-index" });
163
+ const keys2 = await tagIndex.keysForTag(args.tag);
164
+ if (keys2.length > 0) {
165
+ await redis.del(...keys2);
166
+ }
167
+ process.stdout.write(`${JSON.stringify({ deletedKeys: keys2.length, tag: args.tag }, null, 2)}
168
+ `);
169
+ return;
170
+ }
171
+ const keys = await scanKeys(redis, args.pattern ?? "*");
172
+ if (keys.length > 0) {
173
+ await redis.del(...keys);
174
+ }
175
+ process.stdout.write(`${JSON.stringify({ deletedKeys: keys.length, pattern: args.pattern ?? "*" }, null, 2)}
176
+ `);
177
+ return;
178
+ }
179
+ printUsage();
180
+ process.exitCode = 1;
181
+ } finally {
182
+ redis.disconnect();
183
+ }
184
+ }
185
+ function parseArgs(argv) {
186
+ const [command, ...rest] = argv;
187
+ const parsed = { command };
188
+ for (let index = 0; index < rest.length; index += 1) {
189
+ const token = rest[index];
190
+ const value = rest[index + 1];
191
+ if (token === "--redis") {
192
+ parsed.redisUrl = value;
193
+ index += 1;
194
+ } else if (token === "--pattern") {
195
+ parsed.pattern = value;
196
+ index += 1;
197
+ } else if (token === "--tag") {
198
+ parsed.tag = value;
199
+ index += 1;
200
+ } else if (token === "--tag-index-prefix") {
201
+ parsed.tagIndexPrefix = value;
202
+ index += 1;
203
+ }
204
+ }
205
+ return parsed;
206
+ }
207
+ async function scanKeys(redis, pattern) {
208
+ const keys = [];
209
+ let cursor = "0";
210
+ do {
211
+ const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
212
+ cursor = nextCursor;
213
+ keys.push(...batch);
214
+ } while (cursor !== "0");
215
+ return keys;
216
+ }
217
+ function printUsage() {
218
+ 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"
220
+ );
221
+ }
222
+ if (process.argv[1]?.includes("cli.")) {
223
+ void main();
224
+ }
225
+ // Annotate the CommonJS export names for ESM import in node:
226
+ 0 && (module.exports = {
227
+ main
228
+ });
package/dist/cli.d.cts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ declare function main(argv?: string[]): Promise<void>;
3
+
4
+ export { main };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ declare function main(argv?: string[]): Promise<void>;
3
+
4
+ export { main };