Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

104 changes: 104 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,107 @@ export class SimpleCache<T> {
}
}
}

/**
* A lightweight, generic in-memory cache with optional TTL-based entry expiry.
*
* When `ttlMs` is omitted entries never expire, preserving backward-compatible
* behaviour. When supplied, `get()` and `has()` silently evict stale entries
* on access, and `purgeExpired()` sweeps the entire store in one pass.
*
* Usage:
* const cache = new Cache<Invoice>(30_000); // 30-second TTL
* cache.set("inv:1", invoice);
* cache.get("inv:1"); // undefined after 30 s
*/
interface CacheEntry<V> {
value: V;
/** Unix ms timestamp recorded at write time. */
writtenAt: number;
}

export class Cache<V> {
private readonly store = new Map<string, CacheEntry<V>>();
private readonly ttlMs: number | undefined;

/**
* @param ttlMs Time-to-live in milliseconds. Omit (or pass `undefined`)
* for no-expiry behaviour.
*/
constructor(ttlMs?: number) {
this.ttlMs = ttlMs;
}

/**
* Store `value` under `key`, recording the current wall-clock time.
*/
set(key: string, value: V): void {
this.store.set(key, { value, writtenAt: Date.now() });
}

/**
* Retrieve the value for `key`.
*
* Returns `undefined` and **deletes the entry** when the entry is expired
* (i.e. `Date.now() - writtenAt > ttlMs`). Returns `undefined` for
* missing keys regardless of TTL configuration.
*/
get(key: string): V | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (this.isExpired(entry)) {
this.store.delete(key);
return undefined;
}
return entry.value;
}

/**
* Returns `true` only when the key exists **and** is not expired.
* Expired entries are deleted as a side-effect.
*/
has(key: string): boolean {
const entry = this.store.get(key);
if (!entry) return false;
if (this.isExpired(entry)) {
this.store.delete(key);
return false;
}
return true;
}

/**
* Remove all entries whose TTL has elapsed in a single sweep.
* No-op when no TTL is configured.
*/
purgeExpired(): void {
if (this.ttlMs === undefined) return;
for (const [key, entry] of this.store) {
if (this.isExpired(entry)) {
this.store.delete(key);
}
}
}

/** Remove a specific entry by key. */
delete(key: string): void {
this.store.delete(key);
}

/** Remove all entries. */
clear(): void {
this.store.clear();
}

/** Number of entries currently in the store (including not-yet-evicted expired ones). */
get size(): number {
return this.store.size;
}

// ── private helpers ──────────────────────────────────────────────────────

private isExpired(entry: CacheEntry<V>): boolean {
if (this.ttlMs === undefined) return false;
return Date.now() - entry.writtenAt > this.ttlMs;
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ export type { PathQuery, PathQueryResult, StrictSendPathQuery, StrictReceivePath
export { OfferTracker } from "./offerTracker.js";
export type { OfferTrackerConfig, OfferTrackerEventMap } from "./offerTracker.js";

export { SimpleCache } from "./cache.js";
export { SimpleCache, Cache } from "./cache.js";
export { Recorder, createRecorder } from "./recorder.js";
export type { SessionRecording, RecordingEntry, ReplayResult } from "./recorder.js";

Expand Down
155 changes: 153 additions & 2 deletions test/cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { SimpleCache } from "../src/cache.js";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { SimpleCache, Cache } from "../src/cache.js";

describe("SimpleCache LRU", () => {
it("evicts the oldest entry when maxEntries is exceeded", () => {
Expand Down Expand Up @@ -71,3 +71,154 @@ describe("SimpleCache LRU", () => {
expect(stats.evictions).toBe(0);
});
});

describe("Cache – TTL-based expiry", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

// ── construction ──────────────────────────────────────────────────────────

it("accepts an optional ttlMs constructor argument", () => {
const withTtl = new Cache<string>(1000);
const withoutTtl = new Cache<string>();
expect(withTtl).toBeDefined();
expect(withoutTtl).toBeDefined();
});

// ── set / get ─────────────────────────────────────────────────────────────

it("get() returns the value before TTL elapses", () => {
const cache = new Cache<string>(1000);
cache.set("key", "value");
vi.advanceTimersByTime(999);
expect(cache.get("key")).toBe("value");
});

it("get() returns undefined and removes the entry after TTL elapses", () => {
const cache = new Cache<string>(1000);
cache.set("key", "value");
vi.advanceTimersByTime(1001);
expect(cache.get("key")).toBeUndefined();
// Entry must have been deleted
expect(cache.size).toBe(0);
});

it("get() returns undefined for a missing key", () => {
const cache = new Cache<string>(1000);
expect(cache.get("nonexistent")).toBeUndefined();
});

it("records the write timestamp at the moment of set()", () => {
const cache = new Cache<number>(500);
vi.advanceTimersByTime(200);
cache.set("k", 42); // written at t=200
vi.advanceTimersByTime(400); // now t=600; 400 ms after write > 500 ms TTL? no: 400 < 500
expect(cache.get("k")).toBe(42);
vi.advanceTimersByTime(101); // now t=701; 501 ms after write — expired
expect(cache.get("k")).toBeUndefined();
});

// ── has() ─────────────────────────────────────────────────────────────────

it("has() returns true for an entry that has not expired", () => {
const cache = new Cache<string>(1000);
cache.set("key", "value");
vi.advanceTimersByTime(500);
expect(cache.has("key")).toBe(true);
});

it("has() returns false for an expired entry and removes it", () => {
const cache = new Cache<string>(1000);
cache.set("key", "value");
vi.advanceTimersByTime(1001);
expect(cache.has("key")).toBe(false);
expect(cache.size).toBe(0);
});

it("has() returns false for a missing key", () => {
const cache = new Cache<string>(1000);
expect(cache.has("ghost")).toBe(false);
});

// ── purgeExpired() ────────────────────────────────────────────────────────

it("purgeExpired() removes only expired entries", () => {
const cache = new Cache<string>(1000);
cache.set("fresh", "a");
vi.advanceTimersByTime(500);
cache.set("also-fresh", "b");
vi.advanceTimersByTime(600); // "fresh" is now 1100 ms old (expired); "also-fresh" is 600 ms old (not expired)
cache.purgeExpired();
expect(cache.get("fresh")).toBeUndefined();
expect(cache.get("also-fresh")).toBe("b");
expect(cache.size).toBe(1);
});

it("purgeExpired() removes all entries when all are expired", () => {
const cache = new Cache<number>(500);
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
vi.advanceTimersByTime(600);
cache.purgeExpired();
expect(cache.size).toBe(0);
});

it("purgeExpired() is a no-op when no TTL is configured", () => {
const cache = new Cache<string>(); // no TTL
cache.set("x", "1");
cache.set("y", "2");
vi.advanceTimersByTime(999999);
cache.purgeExpired();
// entries should still be present
expect(cache.get("x")).toBe("1");
expect(cache.get("y")).toBe("2");
});

// ── no-expiry (backward compatibility) ───────────────────────────────────

it("entries never expire when ttlMs is not provided", () => {
const cache = new Cache<string>();
cache.set("forever", "value");
vi.advanceTimersByTime(Number.MAX_SAFE_INTEGER / 2);
expect(cache.get("forever")).toBe("value");
expect(cache.has("forever")).toBe(true);
});

// ── delete / clear ────────────────────────────────────────────────────────

it("delete() removes a specific entry", () => {
const cache = new Cache<string>(5000);
cache.set("a", "1");
cache.set("b", "2");
cache.delete("a");
expect(cache.get("a")).toBeUndefined();
expect(cache.get("b")).toBe("2");
});

it("clear() removes all entries", () => {
const cache = new Cache<string>(5000);
cache.set("a", "1");
cache.set("b", "2");
cache.clear();
expect(cache.size).toBe(0);
});

// ── size ──────────────────────────────────────────────────────────────────

it("size reflects the number of stored entries", () => {
const cache = new Cache<number>(5000);
expect(cache.size).toBe(0);
cache.set("x", 1);
expect(cache.size).toBe(1);
cache.set("y", 2);
expect(cache.size).toBe(2);
cache.delete("x");
expect(cache.size).toBe(1);
});
});