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.2.1

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.2.1 — adds a complete multi-page docs site under docs/. (v0.2.0 accidentally shipped annotify's docs by mistake; 0.2.1 fixes that. Source is identical between 0.2.0 and 0.2.1.)

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).

Decorators

@Cacheable(cacheName, opts?)

Read-through cache. On every call:

  1. Compute a key (default ${methodName}:${hash(args)}).
  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 could grow with future metadata; right now it just carries methodName

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.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.2)

  • 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.
  • Cache stampede is not handled — under high concurrency on a missing key, the handler may run multiple times before the cache populates. Add a Lua-script fallback if you need single-flight.

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 your job. The manager does no request coalescing. If two requests miss simultaneously, both run the handler. Use Redis SET NX + a tiny Lua script if you need locking.
  • Eviction is best-effort. @CacheEvict after a failed write does not run. If you need stricter consistency, evict BEFORE invocation instead.

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