diff --git a/package-lock.json b/package-lock.json index f206cc1..c4b0600 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3843,6 +3843,20 @@ } } }, + "node_modules/html-encoding-sniffer/node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "license": "MIT", @@ -4041,6 +4055,20 @@ } } }, + "node_modules/jsdom/node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -6690,6 +6718,20 @@ } } }, + "node_modules/whatwg-url/node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, diff --git a/src/cache.ts b/src/cache.ts index e63e455..72ccb52 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -141,3 +141,107 @@ export class SimpleCache { } } } + +/** + * 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(30_000); // 30-second TTL + * cache.set("inv:1", invoice); + * cache.get("inv:1"); // undefined after 30 s + */ +interface CacheEntry { + value: V; + /** Unix ms timestamp recorded at write time. */ + writtenAt: number; +} + +export class Cache { + private readonly store = new Map>(); + 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): boolean { + if (this.ttlMs === undefined) return false; + return Date.now() - entry.writtenAt > this.ttlMs; + } +} diff --git a/src/index.ts b/src/index.ts index 2fe2b4a..d3438cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/test/cache.test.ts b/test/cache.test.ts index 839ed90..ff0872f 100644 --- a/test/cache.test.ts +++ b/test/cache.test.ts @@ -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", () => { @@ -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(1000); + const withoutTtl = new Cache(); + expect(withTtl).toBeDefined(); + expect(withoutTtl).toBeDefined(); + }); + + // ── set / get ───────────────────────────────────────────────────────────── + + it("get() returns the value before TTL elapses", () => { + const cache = new Cache(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(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(1000); + expect(cache.get("nonexistent")).toBeUndefined(); + }); + + it("records the write timestamp at the moment of set()", () => { + const cache = new Cache(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(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(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(1000); + expect(cache.has("ghost")).toBe(false); + }); + + // ── purgeExpired() ──────────────────────────────────────────────────────── + + it("purgeExpired() removes only expired entries", () => { + const cache = new Cache(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(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(); // 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(); + 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(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(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(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); + }); +});