Quick Start
Install
# framework-agnostic core
npm install @stalefree/core
# NestJS apps: the adapter + the core (buses, stores, and types live in the core)
npm install @nest-native/cache @stalefree/core
The Drizzle L2 stores are opt-in — install drizzle-orm and your driver only
if you use one (pg is also the driver for the Postgres bus):
npm install drizzle-orm pg # or better-sqlite3 / mysql2
The framework-agnostic core
Create one cache per process, then wrap your expensive reads and
invalidateTags after mutations:
import {StalefreeCache} from '@stalefree/core';
const cache = new StalefreeCache({defaultTtlMs: 30_000});
// read-through with single-flight: concurrent misses share ONE loader run
const project = await cache.wrap(
`project:${id}`,
() => db.select()..., // your expensive read
{tags: [`org:${orgId}`, `project:${id}`]},
);
// after a mutation: evict every entry carrying the tag — here and, with a
// bus configured, on every other instance
await cache.invalidateTags([`project:${id}`]);
That's the whole primary API. TTL is required — per call via ttlMs or
via the cache-wide defaultTtlMs fallback; a set/wrap without either
throws. get, set, and delete exist for the cases wrap doesn't cover
(see the API Reference).
:::tip Put the tenant dimension in the key
Keys like org:7:project:42 keep tenants apart by construction. Keys and tags
are allow-listed ([A-Za-z0-9_:.-], length-capped) because they travel through
SQL, NOTIFY payloads, and socket frames — and they must never contain
secrets. See Semantics.
:::
Going multi-instance
A single process needs no bus — its own L1 is coherent by definition. As soon as two processes serve the same data, give each cache a bus so an invalidation in one evicts in all:
// Postgres, cross-machine — the flagship
import pg from 'pg';
import {StalefreeCache} from '@stalefree/core';
import {PostgresInvalidationBus} from '@stalefree/core/postgres';
const bus = new PostgresInvalidationBus({
connect: () => new pg.Client({connectionString, keepAlive: true}),
db, // your base drizzle handle (fire-and-forget publishes)
});
bus.start();
const cache = new StalefreeCache({bus, defaultTtlMs: 30_000});
Coherence covers all three tiers — in-process, unix-socket
(same machine), and Postgres LISTEN/NOTIFY — including transactional
invalidation with publishInTx. Stores adds the optional
shared L2 so fresh instances start warm.
NestJS
Register the module once (global by default) and inject CacheService
anywhere:
import {Module} from '@nestjs/common';
import {CacheModule} from '@nest-native/cache';
@Module({
imports: [
CacheModule.forRoot({defaultTtlMs: 30_000}),
],
})
export class AppModule {}
Then use it (inject by explicit token if your toolchain is esbuild/tsx — they
emit no design:paramtypes):
import {Inject, Injectable} from '@nestjs/common';
import {CacheService} from '@nest-native/cache';
@Injectable()
export class ProjectsService {
constructor(@Inject(CacheService) private readonly cache: CacheService) {}
getProject(orgId: number, id: number) {
return this.cache.wrap(
`org:${orgId}:project:${id}`,
() => this.repo.findById(id),
{tags: [`org:${orgId}`, `project:${id}`]},
);
}
async renameProject(orgId: number, id: number, name: string) {
await this.repo.rename(id, name);
await this.cache.invalidateTags([`project:${id}`]);
}
}
For a store and a bus, build them at bootstrap and hand them over via
forRootAsync:
CacheModule.forRootAsync({
inject: [getDrizzleClientToken()],
useFactory: (db: AppDatabase) => ({
defaultTtlMs: 30_000,
store: new PostgresCacheStore(db, cacheTable), // optional shared L2
bus, // built at bootstrap
}),
}),
useFactory is typed (...args: any[]) — a factory with typed injected
parameters assigns directly (no widening dance), matching NestJS's own
FactoryProvider. The module detaches the cache from the bus on application
shutdown; the bus and store belong to your app — close the bus where you built
it.
A runnable sample
The repository's
sample/00-express-two-instances
is a two-process bare-Express app (no @nestjs/* anywhere): instance A
caches, instance B mutates and invalidates over the socket bus, instance A
serves fresh. It doubles as the neutrality acceptance test that keeps the core
framework-agnostic.