export interface CacheEntry { value: V; createdAt: number; expiresAt: number; staleUntil: number; tags: string[]; } export type CacheLookup = { state: "miss" } | { state: "fresh" | "stale"; entry: CacheEntry }; export interface CacheSetOptions { ttlMs?: number; staleWhileRevalidateMs?: number; tags?: string[]; } export interface TagCacheOptions { ttlMs?: number; staleWhileRevalidateMs?: number; maxEntries?: number; clock?: () => number; onEvent?: (event: CacheEvent) => void; } export interface CacheEvent { operation: "hit" | "stale" | "miss" | "set" | "delete" | "invalidate" | "clear" | "load"; key?: string; tags?: string[]; at: number; } export interface CacheSnapshotEntry { key: string; state: "fresh" | "stale"; createdAt: number; expiresAt: number; staleUntil: number; tags: string[]; } export class TagCache { private entries = new Map>(); private tagIndex = new Map>(); private pending = new Map>(); private locks = new Map>(); private revisions = new Map(); private readonly ttlMs: number; private readonly staleMs: number; private readonly maxEntries: number; private readonly clock: () => number; private readonly onEvent?: (event: CacheEvent) => void; constructor(options: TagCacheOptions = {}) { this.ttlMs = options.ttlMs ?? 60_000; this.staleMs = options.staleWhileRevalidateMs ?? 0; this.maxEntries = Math.max(1, options.maxEntries ?? 10_000); this.clock = options.clock ?? Date.now; this.onEvent = options.onEvent; } private emit(event: Omit): void { this.onEvent?.({ ...event, at: this.clock() }); } lookup(key: string): CacheLookup { const entry = this.entries.get(key); if (!entry) { this.emit({ operation: "miss", key }); return { state: "miss" }; } const now = this.clock(); if (entry.staleUntil <= now) { this.delete(key); this.emit({ operation: "miss", key }); return { state: "miss" }; } this.entries.delete(key); this.entries.set(key, entry); const state = entry.expiresAt > now ? "fresh" : "stale"; this.emit({ operation: state === "fresh" ? "hit" : "stale", key, tags: entry.tags }); return { state, entry }; } get(key: string): V | undefined { const hit = this.lookup(key); return hit.state === "miss" ? undefined : hit.entry.value; } set(key: string, value: V, options: CacheSetOptions = {}): void { this.bump(key); this.store(key, value, options); } private store(key: string, value: V, options: CacheSetOptions): void { this.removeEntry(key); const now = this.clock(); const ttlMs = Math.max(0, options.ttlMs ?? this.ttlMs); const staleMs = Math.max(0, options.staleWhileRevalidateMs ?? this.staleMs); const tags = [...new Set(options.tags ?? [])]; const entry: CacheEntry = { value, createdAt: now, expiresAt: now + ttlMs, staleUntil: now + ttlMs + staleMs, tags, }; this.entries.set(key, entry); this.emit({ operation: "set", key, tags }); for (const tag of tags) { const keys = this.tagIndex.get(tag) ?? new Set(); keys.add(key); this.tagIndex.set(tag, keys); } while (this.entries.size > this.maxEntries) { const oldest = this.entries.keys().next().value as string | undefined; if (oldest === undefined) break; this.delete(oldest); } } async getOrLoad( key: string, loader: () => V | Promise, options: CacheSetOptions = {}, ): Promise { const hit = this.lookup(key); if (hit.state === "fresh") return hit.entry.value; if (hit.state === "stale") { if (!this.pending.has(key)) { const revision = this.revision(key); this.emit({ operation: "load", key, tags: options.tags }); const refresh = Promise.resolve() .then(loader) .then((value) => { if (this.revision(key) === revision) this.store(key, value, options); return value; }) .finally(() => this.pending.delete(key)); this.pending.set(key, refresh); } return hit.entry.value; } const existing = this.pending.get(key); if (existing) return existing; const revision = this.revision(key); this.emit({ operation: "load", key, tags: options.tags }); const pending = Promise.resolve() .then(loader) .then((value) => { if (this.revision(key) === revision) this.store(key, value, options); return value; }) .finally(() => this.pending.delete(key)); this.pending.set(key, pending); return pending; } /** Serialize arbitrary cache-adjacent work for a key without storing its result. */ async withLock(key: string, task: () => T | Promise): Promise { const previous = this.locks.get(key) ?? Promise.resolve(); let release!: () => void; const current = new Promise((resolve) => (release = resolve)); const queued = previous.then(() => current); this.locks.set(key, queued); await previous; try { return await task(); } finally { release(); if (this.locks.get(key) === queued) this.locks.delete(key); } } delete(key: string): boolean { const removed = this.removeEntry(key); if (removed || this.pending.has(key)) this.bump(key); if (removed) this.emit({ operation: "delete", key }); return removed; } private removeEntry(key: string): boolean { const entry = this.entries.get(key); if (!entry) return false; this.entries.delete(key); for (const tag of entry.tags) { const keys = this.tagIndex.get(tag); keys?.delete(key); if (keys?.size === 0) this.tagIndex.delete(tag); } return true; } invalidateTag(tag: string): number { const keys = [...(this.tagIndex.get(tag) ?? [])]; for (const key of keys) this.delete(key); this.emit({ operation: "invalidate", tags: [tag] }); return keys.length; } invalidateTags(tags: Iterable): number { const requested = [...tags]; const keys = new Set(); for (const tag of requested) for (const key of this.tagIndex.get(tag) ?? []) keys.add(key); for (const key of keys) this.delete(key); this.emit({ operation: "invalidate", tags: requested }); return keys.size; } clear(): void { for (const key of new Set([...this.entries.keys(), ...this.pending.keys()])) this.bump(key); this.entries.clear(); this.tagIndex.clear(); this.emit({ operation: "clear" }); } get size(): number { return this.entries.size; } snapshot(): CacheSnapshotEntry[] { const now = this.clock(); return [...this.entries.entries()].map(([key, entry]) => ({ key, state: entry.expiresAt > now ? "fresh" : "stale", createdAt: entry.createdAt, expiresAt: entry.expiresAt, staleUntil: entry.staleUntil, tags: [...entry.tags], })); } private revision(key: string): number { return this.revisions.get(key) ?? 0; } private bump(key: string): void { this.revisions.set(key, this.revision(key) + 1); } }