@studnicky/cache
Capacity-bounded LRU cache with per-entry and default TTL, O(1) promotion on read.
Install
pnpm add @studnicky/cacheRequires @studnicky:registry=https://npm.pkg.github.com in .npmrc.
@studnicky/cache exposes runtime cache operations at its root and schema namespaces at @studnicky/cache/entities.
Usage
Create an LruCache instance with a capacity, then use set, get, has, delete, and clear:
import { LruCache } from '../src/index.js';
const cache = LruCache.create<string, number>({ 'capacity': 10 });
// set and get a value
cache.set('score', 99);
console.log('get score:', cache.get('score'));
// has returns true for a stored key
console.log('has score:', cache.has('score'));
// size reflects the number of entries
cache.set('level', 3);
console.log('size after two sets:', cache.size);
// delete removes the entry and returns true
const deleted = cache.delete('score');
console.log('deleted score:', deleted);
console.log('has score after delete:', cache.has('score'));
console.log('get score after delete:', cache.get('score'));
// size decrements after delete
console.log('size after delete:', cache.size);
// clear removes all entries
cache.clear();
console.log('size after clear:', cache.size);
console.log('has level after clear:', cache.has('level'));LRU eviction
When the cache is at capacity, the least-recently-used entry is evicted on the next set. Reading an entry promotes it to most-recently-used:
import { LruCache } from '../src/index.js';
const cache = LruCache.create<string, string>({ 'capacity': 2 });
// Fill to capacity
cache.set('a', 'alpha');
cache.set('b', 'beta');
// Access 'a' — promotes it to MRU; 'b' becomes LRU
const aBeforeEviction = cache.get('a');
console.log('get a (promotes to MRU):', aBeforeEviction);
// Adding 'c' evicts 'b' (least recently used)
cache.set('c', 'gamma');
const aAfter = cache.get('a');
const cAfter = cache.get('c');
const bAfter = cache.get('b');
console.log('get a after eviction:', aAfter);
console.log('get c after eviction:', cAfter);
console.log('get b after eviction (evicted):', bAfter);
console.log('has b:', cache.has('b'));
console.log('size:', cache.size);TTL expiry
Pass ttlMs to expire entries automatically. Eviction is lazy: entries are removed on the next get or has after the TTL has elapsed:
import { LruCache } from '../src/index.js';
const cache = LruCache.create<string, string>({ 'capacity': 10, 'ttlMs': 10 });
cache.set('token', 'abc123');
// Before expiry, the entry is present
const beforeExpiry = cache.get('token');
console.log('get token before expiry:', beforeExpiry);
console.log('has token before expiry:', cache.has('token'));
// Wait 50ms — the 10ms TTL will have elapsed
await new Promise<void>((resolve) => { setTimeout(resolve, 50); });
// After expiry, get returns undefined (lazy eviction on access)
const afterExpiry = cache.get('token');
console.log('get token after expiry:', afterExpiry);
console.log('has token after expiry:', cache.has('token'));Deterministic cache time
LruCache.create accepts a ClockProviderInterface through clock. The provider measures both TTL expiry and the soft staleMs threshold, so virtual time tests do not wait for wall time.
import { VirtualClockProvider, VirtualTimeCounter } from '@studnicky/clock';
import { LruCache } from '@studnicky/cache';
const counter = VirtualTimeCounter.create({ startMs: 0 });
const cache = LruCache.create<string, string>({
capacity: 10,
clock: VirtualClockProvider.create(counter),
ttlMs: 1_000
});Try it
Lifecycle hooks
TracingCache subclasses LruCache and overrides eight hooks: onHit, onMiss, onSet, onUpdate, onEvict, onExpire, onDelete, and onClear. With capacity=2, watch the event sequence: set a, set b, hit a, update a, evict b for capacity, miss b, delete c, set d, clear. A second TTL scenario shows expire firing before miss.
Observability hooks
LruCache exposes protected lifecycle hooks that a subclass can override to add logging, timing, or metrics without any changes to the caller. The base class never calls any logger or metrics library. All hooks are no-ops by default.
| Hook | When it fires | Args |
|---|---|---|
onHit(key, value) | get() finds a live, non-expired entry | key: K, value: V |
onStale(key, value) | get() finds a live entry past its staleMs threshold | key: K, value: V |
onMiss(key) | get() returns undefined (key absent or entry expired) | key: K |
onSet(key) | set() inserts a new key | key: K |
onUpdate(key) | set() overwrites a value for an existing key | key: K |
onEvict(key, reason) | An entry is removed to make room at capacity | key: K, reason: 'capacity' |
onExpire(key) | get() or has() encounters an entry past its TTL and lazily removes it — fires before onMiss | key: K |
onDelete(key) | delete() removes an entry that existed — not called for absent keys | key: K |
onClear(count) | clear() empties the cache | count: number (entries present before wipe) |
import { EventRecorder } from '@studnicky/errors';
import assert from 'node:assert/strict';
import { LruCache } from '../src/index.js';
class TracingCache extends LruCache<string, number> {
readonly #recorder = new EventRecorder<{ 'event': string; 'key'?: string }>();
get events(): readonly { 'event': string; 'key'?: string }[] { return this.#recorder.events; }
constructor(options: { 'capacity': number; 'ttlMs'?: number }) {
super(options);
}
protected override onHit(key: string, value: number): void {
this.#recorder.record({ 'event': 'hit', 'key': key }, `[cache] hit key=${key} value=${value}`);
}
protected override onMiss(key: string): void {
this.#recorder.record({ 'event': 'miss', 'key': key }, `[cache] miss key=${key}`);
}
protected override onSet(key: string): void {
this.#recorder.record({ 'event': 'set', 'key': key }, `[cache] set key=${key}`);
}
protected override onUpdate(key: string): void {
this.#recorder.record({ 'event': 'update', 'key': key }, `[cache] update key=${key}`);
}
protected override onEvict(key: string, reason: 'capacity'): void {
this.#recorder.record({ 'event': 'evict', 'key': key }, `[cache] evict key=${key} reason=${reason}`);
}
protected override onExpire(key: string): void {
this.#recorder.record({ 'event': 'expire', 'key': key }, `[cache] expire key=${key}`);
}
protected override onDelete(key: string): void {
this.#recorder.record({ 'event': 'delete', 'key': key }, `[cache] delete key=${key}`);
}
protected override onClear(count: number): void {
this.#recorder.record({ 'event': 'clear' }, `[cache] clear count=${count}`);
}
eventNames(): string[] {
const result: string[] = [];
const length = this.events.length;
for (let index = 0; index < length; index += 1) {
const event = this.events[index]!;
result.push(event.event);
}
return result;
}
}
// Capacity-2 cache; demonstrates set, hit, miss, update, evict
const cache = new TracingCache({ 'capacity': 2, 'ttlMs': 5_000 });
cache.set('a', 1); // onSet(a)
cache.set('b', 2); // onSet(b)
cache.get('a'); // onHit(a, 1)
cache.set('a', 99); // onUpdate(a)
cache.set('c', 3); // onEvict(b, capacity) then onSet(c)
cache.get('b'); // onMiss(b) — evicted
cache.delete('c'); // onDelete(c)
cache.set('d', 4); // onSet(d)
cache.clear(); // onClear(2)
// TTL expiry scenario
const ttlCache = new TracingCache({ 'capacity': 10 });
ttlCache.set('ttl-key', 7, { 'ttlMs': 1 }); // 1 ms TTL
await new Promise<void>((resolve) => { setTimeout(resolve, 5); });
ttlCache.get('ttl-key'); // onExpire then onMissThe base class never calls any logger or metrics library. All hooks are no-ops by default.
API
| Export | Type | Description |
|---|---|---|
LruCache<K, V> | class | LRU + TTL cache; generic key and value types |
LruCacheCreateOptionsInterface | interface | Schema settings plus an optional clock provider |
CacheError | class | Base package error |
CacheConfigError | class | Invalid cache configuration |
LruCache<K, V>
| Member | Signature | Description |
|---|---|---|
create | static create<K, V>(options: LruCacheCreateOptionsInterface): LruCache<K, V> | Constructs a cache from validated settings and an optional clock provider |
size | get size(): number | Current entry count |
get | (key: K) => V | undefined | Returns value; promotes to MRU; evicts expired |
tryGet | (key: K) => { found: boolean; value: V | undefined } | Distinguishes a miss from a stored undefined value in one traversal |
set | (key: K, value: V, options?: { staleMs?: number; ttlMs?: number }) => void | Stores a value with optional per-entry staleness and expiry thresholds |
has | (key: K) => boolean | True if key exists and has not expired |
delete | (key: K) => boolean | Removes entry; returns whether it existed |
deleteWhere | (predicate: (key: K, value: V) => boolean) => number | Removes matching entries and returns the removal count |
clear | () => void | Removes all entries |
Entities
@studnicky/cache/entities exports cache option and node-timing schemas.
import { LruCacheOptionsEntity } from '@studnicky/cache/entities';Exports
| Symbol | Purpose | Import path |
|---|---|---|
LruCache | Stores bounded least-recently-used values with optional expiry. | @studnicky/cache |
CacheConfigError | Represents invalid cache configuration. | @studnicky/cache |
CacheError | Base error for cache failures. | @studnicky/cache |
LruCacheCreateOptionsInterface | Defines cache settings and the clock collaborator. | @studnicky/cache |