npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

annotify-redis

v0.3.0

Published

Redis-backed caching decorators for annotify. Spring Boot @Cacheable ergonomics, Node.js runtime.

Readme

annotify-redis

Redis-backed caching decorators for annotify — Spring Boot @Cacheable ergonomics, Node.js runtime.

Latest: v0.3.0 — fixes cache stampedes, makes cache failures non-fatal, tightens ioredis timeouts, and namespaces default keys per class. See What's new in v0.3.0 below.

annotify-redis adds three method decorators to annotify controllers:

  • @Cacheable('users', { ttl: 60 }) — read-through cache; serve from Redis on hit, populate on miss
  • @CachePut('users', { ttl: 300 }) — always run the handler, then refresh the cache
  • @CacheEvict('users', { key: (args) => findOne:${args[0]} }) — remove an entry after the handler runs

Plus a RedisCacheManager service backed by an injectable RedisAdapter, and a wrapController(UserController, cacheManager) helper that turns decorated methods into caching proxies.

Install

npm install annotify-redis
npm install --save-dev annotify ioredis

annotify-redis declares ioredis@^5 as a runtime dependency. annotify@^0.5.0 is recommended as a peer dependency because the recommended wiring uses AppBuilder.useInterceptor() (added in annotify 0.5.0). Older versions still work — you just have to call wrapController() manually per controller (see Advanced: manual wrap below).

Requires TypeScript with experimentalDecorators: true — same constraint as annotify itself.

Quick start

// src/controllers/UserController.ts
import {
  RestController, GetMapping, PostMapping, DeleteMapping,
  PathVariable, RequestBody,
} from 'annotify';
import { Cacheable, CachePut, CacheEvict } from 'annotify-redis';

interface User { id: number; name: string }
const users: User[] = [{ id: 1, name: 'Ada' }, { id: 2, name: 'Grace' }];

@RestController('/users')
export class UserController {

  // First call → handler runs, result cached. Subsequent calls → served from Redis.
  @GetMapping('/:id')
  @Cacheable('users', { ttl: 60 })
  findOne(@PathVariable('id') id: string): User {
    // pretend this hits a database
    return users.find(u => u.id === Number(id))!;
  }

  @PostMapping('/')
  @CachePut('users:list', { ttl: 300 })
  create(@RequestBody() body: User): User {
    users.push(body);
    return body;
  }

  @DeleteMapping('/:id')
  @CacheEvict('users', { allEntries: true })
  remove(@PathVariable('id') id: string): { ok: boolean } {
    const i = users.findIndex(u => u.id === Number(id));
    if (i < 0) return { ok: false };
    users.splice(i, 1);
    return { ok: true };
  }
}
// src/main.ts
import { AppBuilder } from 'annotify';
import {
  RedisCacheManager, IORedisAdapter, enableCaching,
} from 'annotify-redis';
import { UserController } from './controllers/UserController.js';

const cache = new RedisCacheManager(
  new IORedisAdapter({ host: 'localhost', port: 6379 })
);

// 1. ONE LINE: install the cache interceptor on the app. Every controller
//    you register from here on — past, present, or future — gets its
//    decorated methods wrapped automatically. No per-class wrap call.
const app = new AppBuilder();
enableCaching(app, cache);

// 2. Register controllers (cache wiring is automatic).
app.register(UserController);

// 3. Boot.
await app.listen(3000);

// 4. Clean shutdown.
process.on('SIGTERM', () => cache.close());

That's the whole shape. No wrapController(...) call. enableCaching(app, cache) does the job once, globally.

Advanced: manual wrap

If you're on annotify@<0.5.0 (which lacks useInterceptor()), you can still use annotify-redis — you just have to call wrapController for each controller manually:

import { wrapController } from 'annotify-redis';

wrapController(UserController, cache);
wrapController(ProductController, cache);
// ... for every controller

const app = new AppBuilder();
app.register(UserController);
app.register(ProductController);
await app.listen(3000);

wrapController is still exported and is the right tool when you need to wrap a controller before passing it somewhere outside the AppBuilder (e.g. into a test harness, or into a different framework). For typical use, prefer enableCaching(app, cache).

v0.3.0 note: if you supply a custom key builder, its second argument (ctx) now includes a className field — the controller's class name. This makes per-class key deconfliction trivial without resorting to string concatenation. See Custom key builders below.

Decorators

@Cacheable(cacheName, opts?)

Read-through cache. On every call:

  1. Compute a key (default ${className}:${methodName}:${hash(args)} — the class name was added in v0.3.0 to prevent collisions across controllers).
  2. Look it up in the named cache.
  3. Hit → return the cached value. The handler does NOT run.
  4. Miss → run the handler. If it returns a value, store it with the configured TTL (default 300s) and return it. Returning undefined skips the cache write — useful for "not found" responses.
@Cacheable('users', { ttl: 60, key: (args) => `user:${args[0]}` })

| Option | Type | Default | Meaning | |---|---|---|---| | cacheName | string | — | Required. Redis key prefix. | | ttl | number | 300 | Seconds before the entry expires. | | key | (args, ctx) => string | hash of args | Custom key builder. Returns the part AFTER the cache name (${cacheName}:${key}). | | condition | (args) => boolean \| Promise<boolean> | always true | Skip caching when this returns false (handler always runs). |

@CachePut(cacheName, opts?)

Always runs the handler. After it resolves, writes its return value to the cache (overwriting any existing entry at the same key). Use after writes to refresh related reads:

@PostMapping('/')
@CachePut('users', { key: (args) => `findOne:${args[0]}` })
create(@RequestBody() body: User): User { ... }

Same ttl / key options as @Cacheable.

@CacheEvict(cacheName, opts?)

After (or before) the handler runs, removes entries from the cache. By default the eviction happens after a successful invocation:

@DeleteMapping('/:id')
@CacheEvict('users', { key: (args) => `findOne:${args[0]}` })
remove(@PathVariable('id') id: string): { ok: boolean } { ... }

| Option | Type | Default | Meaning | |---|---|---|---| | cacheName | string | — | Required. | | key | (args, ctx) => string | hash of args | The single entry to evict. Required unless allEntries is true. | | allEntries | boolean | false | Wipe every entry in the cache (use sparingly). | | beforeInvocation | boolean | false | Evict BEFORE running the handler. Only set true if you want the cache cleared even when the handler might throw. |

Cache manager

The RedisCacheManager is a thin layer over a RedisAdapter. You can use it directly without decorators:

const cache = new RedisCacheManager(adapter);

await cache.set('users', '1', { id: 1, name: 'Ada' }, 300);
const user = await cache.get<User>('users', '1'); // { id: 1, name: 'Ada' }
await cache.del('users', '1');
await cache.clear('users');     // wipes every entry under the 'users' cache
await cache.close();             // closes the underlying Redis connection

Swap the adapter

The adapter is just an interface:

interface RedisAdapter {
  get(key: string): Promise<string | undefined>;
  set(key: string, value: string, ttlSeconds: number): Promise<void>;
  del(key: string): Promise<void>;
  clear(pattern: string): Promise<number>;
  close(): Promise<void>;
}

IORedisAdapter is the default. You can plug in any Redis-compatible client — node-redis, KeyDB, DragonflyDB, or even a fake/in-memory one for tests:

class InMemoryAdapter implements RedisAdapter {
  private store = new Map<string, string>();
  async get(key: string) { return this.store.get(key); }
  async set(key: string, value: string, ttl: number) { this.store.set(key, value); }
  async del(key: string) { this.store.delete(key); }
  async clear(pattern: string) {
    const re = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
    let n = 0;
    for (const k of [...this.store.keys()]) {
      if (re.test(k)) { this.store.delete(k); n++; }
    }
    return n;
  }
  async close() { this.store.clear(); }
}

Custom key builders

The default key (${methodName}:${hash(args)}) is opaque and stable. When you want predictable Redis keys, supply your own:

@Cacheable('users', {
  ttl: 60,
  key: (args) => `by-id:${args[0]}`,           // → 'users:by-id:42'
})
@Cacheable('shards', {
  key: (args, ctx) => `${ctx.methodName}:${(args[0] as User).tenant}:${args[0].id}`,
})

The function receives:

  • args — the handler's runtime arguments, in declaration order
  • ctx.methodName — the controller method name (e.g. 'findOne')
  • ctx.className — the controller's class name (e.g. 'UserController'); added in v0.3.0 for namespace-safe default keys
  • ctx could grow with future metadata; right now it carries methodName and className

How it works

        ┌───────────────────────┐
        │   Controller class    │
        │   @Cacheable('users') │
        │   findOne() { ... }   │
        └───────────┬───────────┘
                    │  decorator at module load time
                    ▼
        ┌───────────────────────┐
        │  WeakMap<proto, Meta> │  ←  getOrCreateCacheMeta(proto)
        └───────────┬───────────┘
                    │  wrapController() at runtime
                    ▼
        ┌─────────────────────────────────────────┐
        │  prototype.findOne = async (...args)    │
        │     cache.get  → hit? return             │
        │     miss?  → original(...args)           │
        │              → cache.set                │
        └───────────┬─────────────────────────────┘
                    │
                    ▼
        app.register(UserController)
        annotify →  user requests hit the wrapped prototype → cached

Each method is wrapped once. The original is stashed on __original_<methodName>, so calling wrapController() again on the same class is a no-op.

Caching patterns

Read-through for slow reads

@GetMapping('/:id')
@Cacheable('users', { ttl: 60 })
findOne(@PathVariable('id') id: string) {
  return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
}

Read-through + write-through

After a create / update, refresh the cache for reads by id:

@PostMapping('/')
@CachePut('users', { key: (args) => `findOne:${(args[0] as User).id}` })
create(@RequestBody() body: User) { ... }

Invalidation on delete

@DeleteMapping('/:id')
@CacheEvict('users', { key: (args) => `findOne:${args[0]}` })
remove(@PathVariable('id') id: string) { ... }

Whole-cache wipe (rare)

@DeleteMapping('/flush-cache')
@CacheEvict('users', { allEntries: true })
flushUsers() { ... }

Conditional caching

Skip the cache for premium user lookups when an Authorization header says the request is from an admin (admins must see fresh data):

@Cacheable('users', {
  ttl: 60,
  condition: async (args) => {
    // Always cache for non-admin requests.
    return !isAdminRequest();
  },
})

What's new in v0.3.0

  • Cache stampede protection@Cacheable now coalesces concurrent misses on the same key. If 20 requests arrive for an uncached key simultaneously, only ONE handler runs; the other 19 await the same Promise. No more thundering herd.
  • Cache failures no longer fail the request — if Redis is down during a @CachePut or @CacheEvict, the original handler has already run (DB write went through), so the request succeeds. Errors are logged but never propagated to the caller.
  • ioredis resilienceIORedisAdapter now sets connectTimeout: 1000, maxRetriesPerRequest: 1, enableOfflineQueue: false. The first request after Redis goes down takes ~1 second instead of 7+.
  • @Cacheable default key now includes the class name@Cacheable('users') findOne(1) on UserController and ProductController no longer collide (the keys become users:UserController:findOne:<hash> and users:ProductController:findOne:<hash>). See the callout below.
  • @Cacheable('') throws at decoration time — passing an empty cacheName used to silently write to the empty-string namespace, which collided with everything. Now it throws synchronously when the decorator runs.
  • enableCaching(app, cache) throws if called twice — previously it silently registered two interceptors and double-wrapped every method. Now it throws on the second call.
  • Default key builder handles BigInt / Date / Function / circular refs without producing collisions. Previously, BigInt arguments threw, Date/Function were JSON.stringify-ed as opaque strings that could collide, and circular refs threw outright.

Breaking change (v0.3.0): The default cache key for @Cacheable now prefixes the controller class name. If you rely on the default key, your existing cache entries are NOT picked up by the new version — they will be re-computed on first hit. Users with a custom key function are unaffected. To opt into the new default keys explicitly while keeping your existing data, prefix your custom key with ${ctx.className}:.

What's new in v0.2

  • enableCaching(app, cache) — replaces per-controller wrapController() calls. Uses the new AppBuilder.useInterceptor() hook (annotify 0.5.0) so caching wires up automatically for every controller you register.
  • Helpful error when enableCaching is called with an annotify version that lacks useInterceptor — message tells the user to upgrade or fall back to wrapController().
  • wrapController still works — it's now an "advanced / manual" escape hatch, fully backward-compatible with v0.1.

Limitations (v0.3.0)

  • One cache decorator per method. Stacking is not supported; if you accidentally stack, the precedence is @CacheEvict > @CachePut > @Cacheable.
  • No SpEL-style placeholders in the key. Use a function key builder.
  • No @Caching (multi-cache annotation).
  • No class-level @CacheConfig shortcut.
  • TTL is seconds, integer. No sub-second precision, no jitter, no automatic refresh.

Architecture notes

  • Zero coupling to annotify at runtime. wrapController() only reads the controller's prototype — it doesn't import or call into annotify. You could use it with any framework that calls methods on a class instance.
  • JSON storage only. Values are JSON.stringify-ed before SET. Keep payloads small (< 100 KB) — Redis is fastest with small values.
  • Single-flight is built in (v0.3.0). @Cacheable coalesces concurrent misses on the same key — only one handler runs, the rest await the same Promise. No need for SET NX + Lua.
  • Eviction is best-effort by default. @CacheEvict after a failed write does not run. Set beforeInvocation: true if you want cache cleared even when the handler might throw.

Testing

The package ships with an offline smoke test that uses an in-memory adapter (no Redis required):

npm run smoke

Expected output:

[T1] @Cacheable: first call runs handler, subsequent hit cache
  PASS  1st call invokes handler
  PASS  subsequent calls hit cache
  PASS  first and later results are equal
[T2] @Cacheable: different args trigger new handler runs
  PASS  one handler call per unique arg
[T3] @CachePut: every call writes to cache
  PASS  @CachePut does not short-circuit on hit
[T4] @CacheEvict: removes entries after handler runs
  PASS  handler runs again after evict
[T5] wrapController handles repeated calls
  PASS  handler runs exactly once per call
[T6] defaultKeyBuilder is deterministic
  PASS  same args → same key
  PASS  different args → different key

=== ALL CHECKS PASSED ===

To verify against a real Redis:

# Terminal 1: start redis
docker run --rm -p 6379:6379 redis:7-alpine

# Terminal 2: start the example
npm run build
node examples/cached.controller.js   # see examples/cached.controller.ts
# ... send a request, watch Redis with `redis-cli MONITOR`

Project structure

annotify-redis/
├── src/
│   ├── index.ts                    ← public barrel
│   ├── decorators/
│   │   ├── cacheable.ts            ← @Cacheable
│   │   ├── cache-put.ts            ← @CachePut
│   │   ├── cache-evict.ts          ← @CacheEvict
│   │   ├── metadata.ts             ← WeakMap side-channel
│   │   └── method-decorator.ts     ← TS decorator type alias
│   ├── cache/
│   │   ├── adapter.ts              ← RedisAdapter interface
│   │   ├── manager.ts              ← RedisCacheManager
│   │   ├── key-builder.ts          ← default key builder
│   │   └── ioredis-adapter.ts      ← default adapter (ioredis)
│   └── wrap/
│       └── wrap-controller.ts      ← the wrapController helper
├── examples/
│   └── cached.controller.ts        ← sample using all 3 decorators
├── smoke.ts                        ← offline smoke (no Redis needed)
├── tsconfig.json                   ← build config
├── tsconfig.smoke.json             ← smoke-specific build
├── package.json
├── README.md
└── LICENSE

Author

Sanjay Sokalsanjaysokal.com

Links

License

MIT