Overview
The Cache module turns any method into a cached one with a single decorator. It handles time-based expiration, stale-while-revalidate refreshes, concurrent call deduplication and automatic invalidation when your code changes - while keeping every entry inside a regular Storage instance, so memory, S3 or any other driver backs your cache without a line of glue code.
Installation
$ pnpm add @vercube/cache @vercube/storage
$ npm install @vercube/cache @vercube/storage
$ bun install @vercube/cache @vercube/storage
Quick Start
Register the cache in your container
The Cache module stores everything through the Storage module, so bind both. Mounting a storage named cache keeps cached entries away from the rest of your data.
import { Container } from '@vercube/di';
import { CacheManager } from '@vercube/cache';
import { StorageManager } from '@vercube/storage';
import { MemoryStorage } from '@vercube/storage/drivers/MemoryStorage';
export async function setupContainer(container: Container): Promise<void> {
container.bind(StorageManager);
container.bind(CacheManager);
const storageManager = container.get(StorageManager);
await storageManager.mount({ name: 'cache', storage: MemoryStorage });
// application wide defaults, every cached function can override them
container.get(CacheManager).configure({
storage: 'cache',
maxAge: 60,
});
}
Cache a method
Decorate any method of a class registered in the container. The cache key is derived from the method's arguments, so every distinct set of arguments gets its own entry.
import { Cache } from '@vercube/cache';
export class UsersService {
@Cache({ maxAge: 300 })
public async getUser(id: string): Promise<User> {
return this.database.findUser(id);
}
}
That is the whole setup. The first call hits the database, every call within the next 5 minutes is served from the storage, and simultaneous calls for the same id share a single database round trip.
Invalidate when data changes
Every decorated method gains invalidate(), expire() and resolveKeys() helpers, keyed exactly like the cached calls - you never rebuild a cache key by hand.
import { Cache } from '@vercube/cache';
import type { CacheTypes } from '@vercube/cache';
export class UsersService {
@Cache({ maxAge: 300 })
public async getUser(id: string): Promise<User> {
return this.database.findUser(id);
}
public async updateUser(id: string, patch: Partial<User>): Promise<User> {
const user = await this.database.updateUser(id, patch);
// drop the cached entry for this user only
await (this.getUser as CacheTypes.CachedMethod<[string], User>).invalidate(id);
return user;
}
}
How it works
Cached entries are plain objects written into a mounted storage. Each cache key carries the storage it belongs to:
/cache/<storage>:<group>:<name>:<argument-hash>.json # a named storage
/cache:<group>:<name>:<argument-hash>.json # the default storage
The <group> and <name> segments are sanitized: every character outside [A-Za-z0-9_] is dropped and a hash of the original is appended when that changes the value, so UsersService.getUser is stored as UsersServicegetUser.<hash>.
The Cache module resolves that prefix back to a mounted storage on every read and write, which means a single application can cache different things in different places - hot data in memory, shared data in a distributed storage - and inspect any of it through the regular StorageManager API.
If a cached function points at a storage nobody mounted, an in-memory one is mounted under that name on first use and a warning is logged. Caching therefore works with zero configuration, and mounting the name yourself is what swaps the backend for a real one.
Freshness
| Option | What it does |
|---|---|
maxAge | Seconds an entry stays fresh. Defaults to 1. |
swr | Serve a stale entry immediately and refresh it in the background. |
staleMaxAge | How long a stale entry may still be served while revalidating. |
getMaxAge | Derive the lifetime from the resolved value, e.g. an OAuth token's expires_in. |
export class RatesService {
// serve instantly for a minute, then keep serving the old value for up to
// five more minutes while a fresh one is fetched in the background
@Cache({ maxAge: 60, swr: true, staleMaxAge: 300 })
public async getRates(currency: string): Promise<Rates> {
return this.http.get(`/rates/${currency}`);
}
}
Cache keys
By default the key is a hash of the method's arguments. That is the right behaviour for plain values (strings, numbers, POJOs), but arguments that are not stable to hash - a Request, a stream, a class instance carrying a connection - should be projected into an explicit key:
export class ReportsController {
@Cache({
maxAge: 300,
getKey: (range: DateRange) => `${range.from}:${range.to}`,
})
public async report(range: DateRange, trace: TraceContext): Promise<Report> {
return this.reports.build(range);
}
}
Entries also carry an integrity hash derived from the method body and its cache options. Changing either drops the old entries automatically, so a deploy never serves results produced by code that no longer exists.
Caching controller responses
A controller action is just a method, and by the time it runs its arguments are already the resolved route parameters. Decorating it caches the response keyed by those parameters:
import { Cache } from '@vercube/cache';
import { Controller, Get, Param } from '@vercube/core';
@Controller('/products')
export class ProductsController {
@Get('/:id')
@Cache({ maxAge: 120, swr: true, staleMaxAge: 600 })
public async getProduct(@Param('id') id: string): Promise<Product> {
return this.products.find(id);
}
}
Multi-tier caching
Passing several storages reads them in order and writes to all of them, which gives a fast local tier in front of a shared one:
export class CatalogService {
@Cache({ maxAge: 300, storage: ['memory', 'shared'] })
public async getCatalog(): Promise<Catalog> {
return this.database.loadCatalog();
}
}
A read tries memory first and falls through to shared. A resolved value is written to every tier.
Caveats
@Cache()works on classes registered in the container. Instances created withnewby hand are not decorated.- Cached entries are written to storage as-is, so the returned value must survive whatever serialization the driver applies. Use
serialize/transformfor values that do not, such as streams or class instances. - Expiration is enforced by the cache itself, so a driver that ignores the
ttlhint (the built-inMemoryStoragedoes) still serves correct results - it just never evicts expired entries on its own. - Entries are shared between instances of the same class. The key is built from the class name, the method name and the arguments - never from instance identity. If you register the same class twice with different constructor state (say two API clients pointing at different hosts), give each one an explicit
nameso they do not read each other's entries. - Minified builds mangle class names. Since the default
nameisClassName.methodName, two different classes can collapse onto the same name after minification and fight over one key. The integrity hash keeps you from being served the wrong data, but the hit rate collapses. Pass an explicitnamefor anything shipped minified. - A key built by hand will not match. The
groupandnamesegments are sanitized on the way into the key, so the stored key is not the one you would assemble from your own options. AskCacheManager.resolveKeys()(orresolveKeys()on a cached function) for the real key.