Skip to content

@studnicky/cache

Capacity-bounded LRU cache with per-entry and default TTL, O(1) promotion on read.

Install

bash
pnpm add @studnicky/cache

Requires @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:

ts
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:

ts
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:

ts
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.

typescript
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.

Loading example…

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.

HookWhen it firesArgs
onHit(key, value)get() finds a live, non-expired entrykey: K, value: V
onStale(key, value)get() finds a live entry past its staleMs thresholdkey: K, value: V
onMiss(key)get() returns undefined (key absent or entry expired)key: K
onSet(key)set() inserts a new keykey: K
onUpdate(key)set() overwrites a value for an existing keykey: K
onEvict(key, reason)An entry is removed to make room at capacitykey: K, reason: 'capacity'
onExpire(key)get() or has() encounters an entry past its TTL and lazily removes it — fires before onMisskey: K
onDelete(key)delete() removes an entry that existed — not called for absent keyskey: K
onClear(count)clear() empties the cachecount: number (entries present before wipe)
ts
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 onMiss

The base class never calls any logger or metrics library. All hooks are no-ops by default.

API

ExportTypeDescription
LruCache<K, V>classLRU + TTL cache; generic key and value types
LruCacheCreateOptionsInterfaceinterfaceSchema settings plus an optional clock provider
CacheErrorclassBase package error
CacheConfigErrorclassInvalid cache configuration

LruCache<K, V>

MemberSignatureDescription
createstatic create<K, V>(options: LruCacheCreateOptionsInterface): LruCache<K, V>Constructs a cache from validated settings and an optional clock provider
sizeget size(): numberCurrent entry count
get(key: K) => V | undefinedReturns 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 }) => voidStores a value with optional per-entry staleness and expiry thresholds
has(key: K) => booleanTrue if key exists and has not expired
delete(key: K) => booleanRemoves entry; returns whether it existed
deleteWhere(predicate: (key: K, value: V) => boolean) => numberRemoves matching entries and returns the removal count
clear() => voidRemoves all entries

Entities

@studnicky/cache/entities exports cache option and node-timing schemas.

typescript
import { LruCacheOptionsEntity } from '@studnicky/cache/entities';

Exports

SymbolPurposeImport path
LruCacheStores bounded least-recently-used values with optional expiry.@studnicky/cache
CacheConfigErrorRepresents invalid cache configuration.@studnicky/cache
CacheErrorBase error for cache failures.@studnicky/cache
LruCacheCreateOptionsInterfaceDefines cache settings and the clock collaborator.@studnicky/cache

Source on GitHub