@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.
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'));Bulk insert
setMany inserts many entries at once. Entries are processed in argument order; the last entry in the array is the most-recently-used after the call. When the batch causes capacity overflow, entries earlier in the argument list are evicted first:
import { LruCache } from '../src/index.js';
const cache = LruCache.create<string, number>({ 'capacity': 3 });
// Insert three entries at once; argument order determines recency.
// After the call: 'a' is LRU, 'c' is MRU.
cache.setMany([['a', 1], ['b', 2], ['c', 3]]);
console.log('get a:', cache.get('a')); // 1
console.log('get b:', cache.get('b')); // 2
console.log('get c:', cache.get('c')); // 3
// When the batch exceeds capacity the oldest-by-arg-order entry is evicted first.
// The cache is at capacity (3). Adding two more entries evicts 'a' then 'b'.
cache.setMany([['d', 4], ['e', 5]]);
console.log('get a after eviction:', cache.get('a')); // undefined — evicted (was LRU)
console.log('get b after eviction:', cache.get('b')); // undefined — evicted next
console.log('get c after eviction:', cache.get('c')); // 3
console.log('get d after eviction:', cache.get('d')); // 4
console.log('get e after eviction:', cache.get('e')); // 5
// A batch TTL applies to every entry in the call.
const ttlCache = LruCache.create<string, string>({ 'capacity': 10 });
ttlCache.setMany([['token', 'abc'], ['session', 'xyz']], 10_000);
console.log('has token:', ttlCache.has('token')); // true
console.log('has session:', ttlCache.has('session')); // true
// An empty array is a no-op.
const size = cache.size;
cache.setMany([]);
console.log('size unchanged after empty batch:', cache.size === size); // trueTry it
Builder
LruCache.builder().withCapacity(2).withTtlMs(5_000).withPrefix('demo').build() constructs the cache through the fluent builder. Press Execute to watch set, get, and LRU eviction at capacity 2: reading a key promotes it, so the next insert evicts the other key. The final assertions confirm which keys survived.
/** builder-cache — construct an LruCache via the fluent builder API. Run: npx tsx packages/cache/examples/builder-cache.ts */
import assert from 'node:assert/strict';
import { LruCache } from '../src/index.js';
// #region usage
// Build a capacity-2 cache with a 5-second TTL and a 'demo' key prefix.
const cache = LruCache.builder<string, number>()
.withCapacity(2)
.withTtlMs(5_000)
.withPrefix('demo')
.build();
console.log('builder-cache: cache constructed via fluent builder');
console.log('builder-cache: initial size:', cache.size);
// Set two entries — cache is now at capacity.
cache.set('a', 1);
console.log('builder-cache: set("a", 1) — size:', cache.size);
cache.set('b', 2);
console.log('builder-cache: set("b", 2) — size:', cache.size);
// Read 'a' to promote it to most-recently-used.
const hitA = cache.get('a');
console.log('builder-cache: get("a") —>', hitA);
// Insert a third key. Cache is at capacity; 'b' is least-recently-used and is evicted.
cache.set('c', 3);
console.log('builder-cache: set("c", 3) — size:', cache.size);
// 'b' was evicted; 'a' and 'c' survive.
const missB = cache.get('b');
console.log('builder-cache: get("b") after eviction —>', missB);
console.log('builder-cache: has("a"):', cache.has('a'));
console.log('builder-cache: has("b"):', cache.has('b'));
console.log('builder-cache: has("c"):', cache.has('c'));
// #endregion usage
assert.equal(hitA, 1);
assert.equal(missB, undefined);
assert.equal(cache.has('b'), false);
assert.equal(cache.has('a'), true);
assert.equal(cache.has('c'), true);
assert.equal(cache.size, 2);
console.log('builder-cache: all assertions passed');
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.
/** observedCache — subclass overrides emit console.log trace lines on every cache event. Run: npx tsx examples/observedCache.ts */
// #region usage
import { EventRecorder } from '@studnicky/errors/observers';
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(): { '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[] = [];
for (const e of this.events) {
result.push(e.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.events.length = 0;
ttlCache.set('ttl-key', 7, { 'ttlMs': 1 }); // 1 ms TTL
await new Promise<void>((resolve) => { setTimeout(resolve, 5); });
ttlCache.get('ttl-key'); // onExpire then onMiss
// #endregion usage
// ---- assertions ----
assert.deepEqual(cache.eventNames(), [
'set', // set('a', 1)
'set', // set('b', 2)
'hit', // get('a')
'update', // set('a', 99) — existing key
'evict', // b evicted for capacity
'set', // set('c', 3)
'miss', // get('b') — was evicted
'delete', // delete('c')
'set', // set('d', 4)
'clear' // clear()
]);
assert.deepEqual(ttlCache.eventNames(), ['set', 'expire', 'miss']);
console.log('observedCache: all assertions passed');
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 |
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/observers';
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(): { '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[] = [];
for (const e of this.events) {
result.push(e.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.events.length = 0;
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 |
LruCacheBuilder<K, V> | class | Fluent builder for LruCache; produced by LruCache.builder() |
LruCacheOptionsType | type | { capacity, prefix?, ttlMs? } |
LruCache<K, V>
| Member | Signature | Description |
|---|---|---|
create | static create<K, V>(options): LruCache<K, V> | Constructs a cache from options |
builder | static builder<K, V>(): LruCacheBuilder<K, V> | Returns a fluent builder for constructing a cache |
size | get size(): number | Current entry count |
get | (key: K) => V | undefined | Returns value; promotes to MRU; evicts expired |
set | (key: K, value: V, ttlMs?: number) => void | Stores value; evicts LRU tail when at capacity |
setMany | (entries: ReadonlyArray<readonly [K, V]>, ttlMs?: number) => void | Inserts entries in argument order; last entry is MRU; empty array is a no-op |
has | (key: K) => boolean | True if key exists and has not expired |
delete | (key: K) => boolean | Removes entry; returns whether it existed |
clear | () => void | Removes all entries |
LruCacheBuilder<K, V>
| Member | Signature | Description |
|---|---|---|
withCapacity | (value: number) => this | Sets the cache capacity (required before build()) |
withTtlMs | (value: number) => this | Sets the default TTL in milliseconds |
withPrefix | (value: string) => this | Sets the key namespace prefix |
build | () => LruCache<K, V> | Constructs the cache; throws if capacity was not set |