The main orchestrator that manages an ordered stack of cache adapters.
import { CacheManager } from "@ziggurat-cache/core";new CacheManager(options: CacheManagerOptions)CacheManagerOptions:
| Property | Type | Default | Description |
|---|---|---|---|
layers |
CacheAdapter[] |
(required) | Ordered array of cache layers. L1 is index 0 (fastest). Must contain at least one adapter — the constructor throws on an empty array. |
namespace |
string |
none | Prefix prepended to all keys as namespace:key. Useful for logical grouping. |
syncBackfill |
boolean |
false |
When true, waits for backfill to complete before returning. |
wrapWrites |
"await" | "background" |
"await" |
Whether wrap() waits for the computed value to reach every layer before resolving. "background" trades read-your-write for lower miss latency. |
strictWrites |
boolean |
false |
When true, set/mset/delete/mdel throw an AggregateError if every layer fails the write. wrap() is unaffected. |
stampede |
StampedeConfig |
{ coalesce: true } |
Stampede protection configuration. |
events |
TypedEventEmitter<CacheEventMap> |
(auto-created) | Optional shared event emitter for observability. If omitted, an internal one is created. |
StampedeConfig:
| Property | Type | Default | Description |
|---|---|---|---|
coalesce |
boolean |
true |
Combine concurrent cache misses into a single factory call. |
Queries layers sequentially from L1 to Ln. Returns the first hit and backfills higher layers. Returns null on a complete miss.
Each backfilled layer applies its own defaultTtlMs, capped by the source entry's remaining lifetime, so a layer keeps its own staleness budget and a copy never outlives its source. See Backfill.
const entry = await cache.get<User>("user:42");
if (entry) {
console.log(entry.value); // User
console.log(entry.expiresAt); // number | null
}Writes the value to all layers. TTL is in milliseconds. Omit to fall back to each layer's defaultTtlMs, or to no expiration when a layer has none.
await cache.set("user:42", userData, 300_000);A value of undefined is never stored — the write is a no-op on every layer and leaves any existing value under that key untouched.
Removes the key from all layers.
await cache.delete("user:42");Returns the configured adapter layers in order (L1 at index 0). Each call returns a new array — mutations to the returned array do not affect the manager's internal state. The adapter references are the same objects passed to the constructor.
Use this to perform adapter-level operations like clear(), flushAll(), or keys() on individual layers:
const [l1, l2] = cache.getLayers();
// Clear a specific layer
await l1.clear();
// Clear all layers explicitly
for (const layer of cache.getLayers()) {
await layer.clear();
}
// Get keys from a specific layer
const keys = await l2.keys();
// Flush a specific layer
await l2.flushAll();Note: Adapters returned by
getLayers()do not apply the manager's namespace — they expose their raw API.
The primary API. Checks the cache first. On a miss, calls the factory, caches the result across all layers, and returns it. Includes stampede protection via request coalescing.
const user = await cache.wrap(
"user:42",
async () => db.users.findById(42),
300_000,
);Behavior:
- Check all layers sequentially (like
get). - If found, return
entry.value. - If not found and coalescing is enabled, check for an in-flight request for the same key. If one exists, attach to it.
- Otherwise, call the factory, store the result via
set, and return the value. - If the factory throws, the error propagates to all coalesced callers and the in-flight entry is cleaned up.
Step 4 waits for every layer to accept the write before resolving. Set wrapWrites: "background" on the manager to resolve as soon as the factory does; the writes then settle in the background and failures surface only as "error" events.
Alias for delete. Convenience method for developers coming from Redis-style APIs.
Batch get across layers with backfill. Missing keys are absent from the returned Map. Layers are queried sequentially with a shrinking remaining set — keys found in L1 are not queried in L2.
const entries = await cache.mget<number>(["a", "b", "missing"]);
entries.get("a")?.value; // number
entries.has("missing"); // falseBatch set across all layers with optional per-entry TTL. Writes to all layers in parallel.
await cache.mset([
{ key: "a", value: 1, ttlMs: 60_000 },
{ key: "b", value: 2 },
]);Batch delete across all layers.
await cache.mdel(["a", "b"]);Returns TTL information from the first layer with a hit. Returns a discriminated union:
const result = await cache.getTtl("key");
switch (result.kind) {
case "missing":
break; // key not found
case "permanent":
break; // key exists, no expiration
case "expiring": // key exists, expires in result.ttlMs ms
console.log(result.ttlMs);
break;
}Check existence across layers. Returns true on first hit.
Subscribe to cache events for observability. Returns an unsubscribe function. Events are emitted synchronously and have zero cost when no listeners are attached.
// Track hit/miss ratio
const unsub = cache.on("hit", (e) => {
console.log(`Hit on ${e.key} from layer ${e.layerName} in ${e.durationMs}ms`);
});
// Later: stop listening
unsub();Available events:
| Event | Key Fields | Emitted When |
|---|---|---|
hit |
key, layerName, layerIndex, durationMs |
get() finds a value in any layer |
miss |
key, durationMs |
get() exhausts all layers |
set |
key, ttlMs, durationMs |
set() writes to all layers |
delete |
key, durationMs |
delete() removes from all layers |
error |
key, operation, layerName, layerIndex, error |
Any layer throws during an operation |
backfill |
key, sourceLayerName, sourceLayerIndex, targetLayerNames |
A backfill is scheduled after a lower-layer hit. Emitted before the writes settle; failures arrive separately as error events with operation: "backfill" |
wrap:hit |
key, durationMs |
wrap() finds a cached value |
wrap:miss |
key, durationMs, factoryDurationMs |
wrap() calls the factory |
wrap:coalesce |
key |
wrap() joins an in-flight request |
mget |
keys, hitCount, missCount, durationMs |
mget() completes |
mset |
keyCount, durationMs |
mset() completes |
mdel |
keyCount, durationMs |
mdel() completes |
All events include an optional namespace field when the manager has a namespace configured. mget reports its keys, hitCount, and missCount over the deduplicated key set, so repeating a key in one call does not inflate the counts.
See Observability for OpenTelemetry integration.
Input type for mset with optional per-entry TTL.
interface CacheSetEntry<T> {
key: string;
value: T;
ttlMs?: number;
}Discriminated union returned by getTtl.
type TtlResult =
| { kind: "missing" }
| { kind: "permanent" }
| { kind: "expiring"; ttlMs: number };Abstract class that implements CacheAdapter with default implementations for all extended methods. New adapters should extend this class and only implement the 4 core methods: get, set, delete, clear.
Extending it also supplies ttlPolicy from the TTL options you pass to super(), which is how CacheManager keeps backfilled entries inside your layer's policy. The default mget returns a partial result — a key whose get throws is omitted rather than rejecting the batch — while mset/mdel reject so a failed write is reported as a layer failure.
import { BaseCacheAdapter } from "@ziggurat-cache/core";See Custom Adapters for a guide on building your own.
In-process cache adapter backed by node-cache.
import { MemoryAdapter } from "@ziggurat-cache/core";new MemoryAdapter(options?: MemoryAdapterOptions)MemoryAdapterOptions:
| Property | Type | Default | Description |
|---|---|---|---|
defaultTtlMs |
number |
none | Fallback TTL applied when no ttlMs is passed to set/wrap. An explicit ttlMs always wins. Use maxTtlMs to cap all TTLs for the layer. |
maxTtlMs |
number |
none | Upper bound applied to every entry's TTL — explicit TTLs, defaultTtlMs, and otherwise-permanent entries are all capped to this. |
checkPeriodMs |
number |
none (disabled) | Interval for proactive eviction of expired entries. Without it, expired entries are removed only on access. Prefer >= 1000. |
maxKeys |
number |
unlimited | Maximum number of keys; set() throws once exceeded (overwriting an existing key still succeeds at capacity). |
serialization |
"reference" | "json" |
"reference" |
"reference" stores live object references (fastest); "json" JSON round-trips for cross-layer fidelity and mutation safety. |
// Unbounded, no default TTL
const memory = new MemoryAdapter();
// 30-second default TTL
const memory = new MemoryAdapter({ defaultTtlMs: 30_000 });| Property | Type | Value |
|---|---|---|
name |
string |
"memory" |
Implements the full CacheAdapter interface including has, getTtl, keys, mget, mset, mdel, flushAll.
- Expired entries (past their TTL) are lazily cleaned up on
get. - Overrides
has,getTtl,keys, andflushAllusing nativenode-cachemethods for better performance. close()— Stops the periodic eviction timer; call on shutdown whencheckPeriodMsis set.
The standard return type from adapter get methods.
interface CacheEntry<T> {
value: T;
expiresAt: number | null; // Unix timestamp in ms, or null for no expiration
}The contract every storage backend must implement.
interface CacheAdapter {
readonly name: string;
/** Optional; lets CacheManager keep backfilled copies within this layer's policy. */
readonly ttlPolicy?: { defaultTtlMs?: number; maxTtlMs?: number };
get<T>(key: string): Promise<CacheEntry<T> | null>;
set<T>(key: string, value: T, ttlMs?: number): Promise<void>;
delete(key: string): Promise<void>;
clear(): Promise<void>;
has(key: string): Promise<boolean>;
getTtl(key: string): Promise<TtlResult>;
keys(): Promise<string[]>;
mget<T>(keys: readonly string[]): Promise<Map<string, CacheEntry<T>>>;
mset<T>(entries: readonly CacheSetEntry<T>[]): Promise<void>;
mdel(keys: readonly string[]): Promise<void>;
flushAll(): Promise<void>;
}See Custom Adapters for a guide on building your own. Extend BaseCacheAdapter for default implementations of all extended methods.
Cache adapter for Redis using ioredis. Stores values as JSON strings.
import { RedisAdapter } from "@ziggurat-cache/redis";new RedisAdapter(options: RedisAdapterOptions)RedisAdapterOptions:
| Property | Type | Default | Description |
|---|---|---|---|
client |
Redis (ioredis) |
(required) | A configured ioredis client instance. |
prefix |
string |
"" |
Key prefix for infrastructure-level isolation. All keys are stored as prefix + key. |
allowUnprefixedClear |
boolean |
false |
Permits clear()/flushAll() with no prefix configured. Without it they throw rather than deleting every key in the database. |
defaultTtlMs |
number |
none | Fallback TTL applied when no ttlMs is passed to set/wrap. An explicit ttlMs always wins. Use maxTtlMs to cap all TTLs for the layer. |
maxTtlMs |
number |
none | Upper bound applied to every entry's TTL — explicit TTLs, defaultTtlMs, and otherwise-permanent entries are all capped to this. |
import Redis from "ioredis";
const adapter = new RedisAdapter({
client: new Redis("redis://localhost:6379"),
prefix: "myapp:",
defaultTtlMs: 600_000, // 10-minute default TTL
});| Property | Type | Value |
|---|---|---|
name |
string |
"redis" |
Implements the full CacheAdapter interface.
get: Fetches the key from Redis, parses the JSON, and checks expiration. Returnsnullfor missing, expired, or unparseable keys — reads never delete.set: Serializes the value as{ value, expiresAt }JSON. UsesPSETEXfor entries with TTL,SETfor entries without. Anundefinedvalue is skipped.delete: Deletes the prefixed key.clear: Scans for all keys matching the prefix pattern and deletes them using a pipeline. Pipeline command failures throwAggregateError. Throws when noprefixis configured unlessallowUnprefixedClearis set.mget: Uses a pipeline for batch reads. Per-key read errors are skipped and the successful entries are returned — this meansmget()may return a partial result map rather than rejecting the entire batch.mset: Uses a pipeline for batch writes. Entries withttlMs <= 0are skipped. Pipeline command failures throwAggregateError.
See Redis Adapter for detailed usage.
NestJS dynamic module that provides a CacheManager via dependency injection.
import { ZigguratModule } from "@ziggurat-cache/nestjs";Synchronous registration. Creates the CacheManager immediately.
@Module({
imports: [
ZigguratModule.forRoot({
layers: [new MemoryAdapter()],
}),
],
})
export class AppModule {}The module is registered globally — the CacheManager is available for injection in any module without re-importing.
Asynchronous registration. Use when your cache configuration depends on other providers (e.g., ConfigService).
ZigguratModuleAsyncOptions:
| Property | Type | Description |
|---|---|---|
imports |
any[] |
Modules to import (e.g., ConfigModule). |
useFactory |
(...args) => CacheManagerOptions | Promise<CacheManagerOptions> |
Factory function that returns the options. |
inject |
any[] |
Providers to inject into the factory. |
@Module({
imports: [
ZigguratModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
layers: [
new MemoryAdapter({ defaultTtlMs: config.get("CACHE_DEFAULT_TTL") }),
new RedisAdapter({ client: new Redis(config.get("REDIS_URL")) }),
],
}),
}),
],
})
export class AppModule {}Method decorator that transparently wraps a service method with CacheManager.wrap().
import { Cached } from "@ziggurat-cache/nestjs";@Cached(options: CachedDecoratorOptions)CachedDecoratorOptions:
| Property | Type | Description |
|---|---|---|
key |
(...args: any[]) => string |
Function that receives the method's arguments and returns the cache key. |
ttlMs |
number |
(optional) TTL in milliseconds. |
@Injectable()
export class ProductService {
@Cached({
key: (id: string) => `product:${id}`,
ttlMs: 600_000,
})
async getProduct(id: string) {
return this.db.products.findById(id);
}
}The decorator injects the CacheManager automatically. Stampede protection (request coalescing) is included — concurrent calls for the same key share a single method invocation.
Injection token for the CacheManager instance. Use this for manual injection:
import { Inject } from "@nestjs/common";
import { CACHE_MANAGER } from "@ziggurat-cache/nestjs";
import type { CacheManager } from "@ziggurat-cache/core";
@Injectable()
export class MyService {
constructor(@Inject(CACHE_MANAGER) private cache: CacheManager) {}
async doSomething() {
return this.cache.wrap("key", () => this.expensiveWork());
}
}Connects a CacheManager's event system to OpenTelemetry metrics. Requires @opentelemetry/api as a peer dependency — bring your own OTel SDK and exporter.
import { instrumentCacheManager } from "@ziggurat-cache/otel";instrumentCacheManager(cacheManager: CacheManager, options?: InstrumentationOptions): () => voidInstrumentationOptions:
| Property | Type | Default | Description |
|---|---|---|---|
meterName |
string |
"ziggurat" |
Name passed to metrics.getMeter(). |
Returns a cleanup function that unsubscribes all listeners.
Every metric carries a cache.namespace attribute when the instrumented manager has a namespace, so several managers sharing one meter stay distinguishable. Managers without a namespace record no such attribute.
Counters: (cache.namespace is added to every row below when the manager has one)
| Metric Name | Attributes | Description |
|---|---|---|
ziggurat.cache.hit |
cache.layer, cache.operation |
Cache hits |
ziggurat.cache.miss |
cache.operation |
Cache misses |
ziggurat.cache.set |
Set operations | |
ziggurat.cache.delete |
Delete operations | |
ziggurat.cache.mget |
Number of cache mget operations | |
ziggurat.cache.mset |
Number of cache mset operations | |
ziggurat.cache.mdel |
Number of cache mdel operations | |
ziggurat.cache.error |
cache.layer, cache.operation |
Layer errors |
ziggurat.cache.backfill |
cache.source_layer |
Backfill events |
ziggurat.cache.wrap.hit |
Wrap cache hits | |
ziggurat.cache.wrap.miss |
Wrap cache misses (factory called) | |
ziggurat.cache.wrap.coalesce |
Coalesced requests (stampede prevention) |
Histograms:
| Metric Name | Attributes | Unit | Description |
|---|---|---|---|
ziggurat.cache.duration |
cache.operation, cache.layer |
ms | Duration of cache operations |
ziggurat.cache.wrap.factory_duration |
ms | Duration of wrap factory calls |
import { CacheManager, MemoryAdapter } from "@ziggurat-cache/core";
import { instrumentCacheManager } from "@ziggurat-cache/otel";
const cache = new CacheManager({
layers: [new MemoryAdapter()],
});
// Start recording metrics
const cleanup = instrumentCacheManager(cache, { meterName: "my-app" });
// Use cache normally — metrics are recorded automatically
await cache.wrap("key", () => fetchData());
// On shutdown
cleanup();