Overview

Decorator driven caching for Vercube applications, backed by the Storage module

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

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.

src/container.ts
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.

src/services/UsersService.ts
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.

src/services/UsersService.ts
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

OptionWhat it does
maxAgeSeconds an entry stays fresh. Defaults to 1.
swrServe a stale entry immediately and refresh it in the background.
staleMaxAgeHow long a stale entry may still be served while revalidating.
getMaxAgeDerive 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:

src/controllers/ProductsController.ts
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);
  }
}
Only cache actions whose response depends purely on the arguments the method receives. An action that reads the authenticated user, a header or a cookie without taking it as an argument would serve one user's response to another - pass those values in as parameters so they become part of the key, or leave the action uncached.

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 with new by 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 / transform for values that do not, such as streams or class instances.
  • Expiration is enforced by the cache itself, so a driver that ignores the ttl hint (the built-in MemoryStorage does) 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 name so they do not read each other's entries.
  • Minified builds mangle class names. Since the default name is ClassName.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 explicit name for anything shipped minified.
  • A key built by hand will not match. The group and name segments are sanitized on the way into the key, so the stored key is not the one you would assemble from your own options. Ask CacheManager.resolveKeys() (or resolveKeys() on a cached function) for the real key.
Previous

API

Complete API reference for Cache module

Next