import type { Logger } from "@atbb/logger"; /** * Generic in-memory TTL store with periodic cleanup. * * Replaces the duplicated Map + set/get/delete + cleanup interval + destroy * pattern that was previously implemented independently in OAuthStateStore, * OAuthSessionStore, and CookieSessionStore. */ /** * Generic TTL (time-to-live) store backed by a Map. * * Entries are lazily evicted on `get()` if expired, and periodically * swept by a background cleanup interval. * * @typeParam V - The value type stored in the map. */ export class TTLStore { private entries = new Map(); private cleanupInterval: NodeJS.Timeout; private destroyed = false; /** * @param isExpired - Predicate that returns true when an entry should be evicted. * @param storeName - Human-readable name used in structured log messages. * @param cleanupIntervalMs - How often the background sweep runs (default: 5 minutes). * @param logger - Optional structured logger for cleanup messages. */ constructor( private readonly isExpired: (value: V) => boolean, private readonly storeName: string, cleanupIntervalMs = 5 * 60 * 1000, private readonly logger?: Logger, ) { this.cleanupInterval = setInterval( () => this.cleanup(), cleanupIntervalMs ); } /** Store an entry. */ set(key: string, value: V): void { if (this.destroyed) { throw new Error(`Cannot set on destroyed ${this.storeName}`); } this.entries.set(key, value); } /** * Retrieve an entry, returning `undefined` if missing or expired. * Expired entries are eagerly deleted on access. */ get(key: string): V | undefined { if (this.destroyed) { throw new Error(`Cannot get from destroyed ${this.storeName}`); } const entry = this.entries.get(key); if (entry === undefined) return undefined; if (this.isExpired(entry)) { this.entries.delete(key); return undefined; } return entry; } /** Delete an entry by key. */ delete(key: string): void { if (this.destroyed) { throw new Error(`Cannot delete from destroyed ${this.storeName}`); } this.entries.delete(key); } /** * UNSAFE: Retrieve entry without checking expiration. * * Only use when you have external expiration management (e.g., OAuth library * that handles token refresh internally). Most callers should use get() instead. * * This bypasses the TTL contract and returns stale data if the entry is expired. */ getUnchecked(key: string): V | undefined { if (this.destroyed) { throw new Error(`Cannot getUnchecked from destroyed ${this.storeName}`); } return this.entries.get(key); } /** * Stop the background cleanup timer (for graceful shutdown). * Idempotent - safe to call multiple times. */ destroy(): void { if (this.destroyed) return; this.destroyed = true; clearInterval(this.cleanupInterval); } /** * Sweep all expired entries from the map. * Runs on the background interval; errors are caught to avoid crashing the process. */ private cleanup(): void { try { const expired: string[] = []; this.entries.forEach((value, key) => { if (this.isExpired(value)) { expired.push(key); } }); for (const key of expired) { this.entries.delete(key); } if (expired.length > 0) { this.logger?.info(`${this.storeName} cleanup completed`, { operation: `${this.storeName}.cleanup`, cleanedCount: expired.length, remainingCount: this.entries.size, }); } } catch (error) { this.logger?.error(`${this.storeName} cleanup failed`, { operation: `${this.storeName}.cleanup`, error: error instanceof Error ? error.message : String(error), }); } } }