/** * Bounded LRU (Least Recently Used) Cache * * A memory-safe cache implementation with: * - Maximum entry limits (LRU eviction) * - TTL (time-to-live) support * - Automatic cleanup of expired entries * * Memory Tracking Documentation: * ============================== * Each cache entry consumes approximately: * - Key string: 2 bytes per character (JS strings are UTF-16) * - Entry object overhead: ~32 bytes (object header + hidden class) * - value: varies (stored by reference, not counted) * - expiresAt: 8 bytes (number) * - lastAccessed: 8 bytes (number) * - Total overhead per entry: ~48 bytes + (key.length * 2) bytes * * For a 1000-entry cache with average 20-char keys: * - Memory overhead: ~88KB (excluding cached values) * * LRU Implementation: * - Uses Map's insertion order for O(1) LRU eviction * - On cache hit, entry is deleted and re-inserted to move to end (most recent) * - Eviction simply removes the first entry (oldest/least recent) * - This provides O(1) eviction vs O(n) scanning for oldest timestamp * * @module @dotdo/postgres-shared/cache */ import { DEFAULT_CACHE_MAX_SIZE, DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_CLEANUP_INTERVAL_MS, INVALID_TOKEN_TTL_MS, } from './constants.js' /** * Configuration for the bounded cache */ export interface BoundedCacheConfig { /** * Maximum number of entries to store in the cache. * When exceeded, least recently used entries are evicted. * @default 1000 */ maxSize?: number /** * Default TTL in milliseconds for cache entries. * Entries older than this are considered expired. * @default 60000 (1 minute) */ defaultTTL?: number /** * Interval in milliseconds for automatic cleanup of expired entries. * Set to 0 to disable automatic cleanup. * @default 60000 (1 minute) */ cleanupInterval?: number } /** * Internal cache entry structure */ interface CacheEntry { value: T expiresAt: number lastAccessed: number } /** * Default cache configuration values */ const DEFAULT_CONFIG: Required = { maxSize: DEFAULT_CACHE_MAX_SIZE, defaultTTL: DEFAULT_CACHE_TTL_MS, cleanupInterval: DEFAULT_CACHE_CLEANUP_INTERVAL_MS, } /** * A bounded LRU cache with TTL support * * Features: * - O(1) get/set operations (Map-based) * - LRU eviction when maxSize is exceeded * - TTL-based expiration * - Automatic periodic cleanup of expired entries * - Memory-bounded to prevent unbounded growth * * @example * ```typescript * const cache = new BoundedCache({ * maxSize: 100, * defaultTTL: 30000, // 30 seconds * }) * * cache.set('key', 'value') * const value = cache.get('key') // 'value' or null if expired * ``` */ export class BoundedCache { private readonly cache = new Map>() private readonly config: Required private cleanupTimer: ReturnType | null = null private _evictionCount = 0 private _hitCount = 0 private _missCount = 0 constructor(config: BoundedCacheConfig = {}) { this.config = { ...DEFAULT_CONFIG, ...config, } // Validate config if (this.config.maxSize < 1) { throw new Error('maxSize must be at least 1') } if (this.config.defaultTTL < 0) { throw new Error('defaultTTL cannot be negative') } // Start automatic cleanup if configured if (this.config.cleanupInterval > 0) { this.startCleanup() } } /** * Get a value from the cache * * Moves the entry to the end of the Map (most recently used) via delete-and-reinsert. * Returns null if the entry doesn't exist or is expired. * * Time complexity: O(1) * * @param key - The cache key * @returns The cached value or null */ get(key: string): T | null { const entry = this.cache.get(key) if (!entry) { this._missCount++ return null } // Check expiration if (Date.now() > entry.expiresAt) { this.cache.delete(key) this._missCount++ return null } // Move to end of Map (most recently used) via delete-and-reinsert // This leverages Map's insertion order for O(1) LRU eviction this.cache.delete(key) entry.lastAccessed = Date.now() this.cache.set(key, entry) this._hitCount++ return entry.value } /** * Check if a key exists and is not expired * * @param key - The cache key * @returns true if key exists and is not expired */ has(key: string): boolean { const entry = this.cache.get(key) if (!entry) return false if (Date.now() > entry.expiresAt) { this.cache.delete(key) return false } return true } /** * Set a value in the cache * * If the cache is at capacity, the least recently used entry is evicted. * * Time complexity: O(1) * * @param key - The cache key * @param value - The value to cache * @param ttl - Optional TTL in milliseconds (overrides defaultTTL) */ set(key: string, value: T, ttl?: number): void { const now = Date.now() const entryTTL = ttl ?? this.config.defaultTTL const expiresAt = now + entryTTL // Delete existing entry first so new entry goes to end of Map const existed = this.cache.delete(key) // Check if we need to evict before adding (only if key didn't exist) if (!existed && this.cache.size >= this.config.maxSize) { this.evictLRU() } this.cache.set(key, { value, expiresAt, lastAccessed: now, }) } /** * Delete a specific key from the cache * * @param key - The cache key to delete * @returns true if the key was deleted */ delete(key: string): boolean { return this.cache.delete(key) } /** * Clear all entries from the cache */ clear(): void { this.cache.clear() } /** * Get the current number of entries in the cache */ get size(): number { return this.cache.size } /** * Get cache statistics */ get stats(): CacheStats { return { size: this.cache.size, maxSize: this.config.maxSize, hitCount: this._hitCount, missCount: this._missCount, evictionCount: this._evictionCount, hitRate: this._hitCount + this._missCount > 0 ? this._hitCount / (this._hitCount + this._missCount) : 0, } } /** * Get estimated memory usage in bytes (excluding cached values) * * This is useful for monitoring cache memory overhead. * Approximately 48 bytes per entry + 2 bytes per key character. */ get estimatedMemoryBytes(): number { let totalKeyBytes = 0 for (const key of this.cache.keys()) { totalKeyBytes += key.length * 2 // UTF-16 encoding } // ~48 bytes overhead per entry (object + expiresAt + lastAccessed + Map entry) const entryOverhead = this.cache.size * 48 return totalKeyBytes + entryOverhead } /** * Reset statistics counters */ resetStats(): void { this._hitCount = 0 this._missCount = 0 this._evictionCount = 0 } /** * Cleanup expired entries * * This is called automatically based on cleanupInterval, * but can also be called manually. * * @returns Number of entries removed */ cleanup(): number { const now = Date.now() let removed = 0 for (const [key, entry] of this.cache) { if (now > entry.expiresAt) { this.cache.delete(key) removed++ } } return removed } /** * Stop the automatic cleanup timer * * Call this when disposing of the cache to prevent memory leaks. */ dispose(): void { this.stopCleanup() this.cache.clear() } /** * Get all non-expired keys in the cache */ keys(): string[] { const now = Date.now() const keys: string[] = [] for (const [key, entry] of this.cache) { if (now <= entry.expiresAt) { keys.push(key) } } return keys } /** * Evict the least recently used entry * * Time complexity: O(1) - simply removes the first entry in the Map, * which is the oldest due to our delete-and-reinsert LRU strategy. */ private evictLRU(): void { // Map.keys().next() returns the first (oldest) key in O(1) const firstKey = this.cache.keys().next().value if (firstKey !== undefined) { this.cache.delete(firstKey) this._evictionCount++ } } /** * Start the automatic cleanup timer */ private startCleanup(): void { if (this.cleanupTimer !== null) return this.cleanupTimer = setInterval(() => { this.cleanup() }, this.config.cleanupInterval) // Allow the timer to not prevent process exit if (typeof this.cleanupTimer === 'object' && 'unref' in this.cleanupTimer) { this.cleanupTimer.unref() } } /** * Stop the automatic cleanup timer */ private stopCleanup(): void { if (this.cleanupTimer !== null) { clearInterval(this.cleanupTimer) this.cleanupTimer = null } } } /** * Cache statistics */ export interface CacheStats { /** Current number of entries */ size: number /** Maximum allowed entries */ maxSize: number /** Number of cache hits */ hitCount: number /** Number of cache misses */ missCount: number /** Number of LRU evictions */ evictionCount: number /** Hit rate (0-1) */ hitRate: number } /** * Token validation result (for token caching) */ export interface TokenValidationResult { valid: boolean user?: { id: string email: string name?: string metadata?: Record } error?: string expiresAt?: Date } /** * Configuration for the token cache */ export interface TokenCacheConfig extends BoundedCacheConfig { /** * Whether to cache invalid token results * @default false */ cacheInvalidTokens?: boolean /** * TTL for invalid token cache entries (if cacheInvalidTokens is true) * @default 5000 (5 seconds) */ invalidTokenTTL?: number } /** * Default token cache configuration */ const DEFAULT_TOKEN_CACHE_CONFIG: Required = { ...DEFAULT_CONFIG, cacheInvalidTokens: false, invalidTokenTTL: INVALID_TOKEN_TTL_MS, } /** * A specialized bounded cache for token validation results * * Extends BoundedCache with token-specific features: * - Respects token expiration from validation results * - Optional caching of invalid tokens to prevent repeated validation * * @example * ```typescript * const tokenCache = new TokenCache({ * maxSize: 500, * defaultTTL: 60000, // 1 minute * }) * * // Cache a valid token * tokenCache.set('token123', { * valid: true, * user: { id: 'user1', email: 'user@example.com' }, * expiresAt: new Date(Date.now() + 3600000), * }) * * // Get cached result * const result = tokenCache.get('token123') * ``` */ export class TokenCache { private readonly cache: BoundedCache private readonly config: Required constructor(config: TokenCacheConfig = {}) { this.config = { ...DEFAULT_TOKEN_CACHE_CONFIG, ...config, } this.cache = new BoundedCache({ maxSize: this.config.maxSize, defaultTTL: this.config.defaultTTL, cleanupInterval: this.config.cleanupInterval, }) } /** * Get a cached token validation result * * @param token - The token string * @returns The cached validation result or null */ get(token: string): TokenValidationResult | null { return this.cache.get(token) } /** * Cache a token validation result * * The TTL is calculated based on: * 1. The token's expiresAt if available (capped at defaultTTL) * 2. invalidTokenTTL for invalid tokens (if cacheInvalidTokens is true) * 3. defaultTTL as fallback * * @param token - The token string * @param result - The validation result to cache * @param ttl - Optional override TTL in milliseconds */ set(token: string, result: TokenValidationResult, ttl?: number): void { // Don't cache invalid tokens unless configured to do so if (!result.valid && !this.config.cacheInvalidTokens) { return } let effectiveTTL: number if (ttl !== undefined) { // Use explicitly provided TTL effectiveTTL = ttl } else if (!result.valid) { // Use invalid token TTL effectiveTTL = this.config.invalidTokenTTL } else if (result.expiresAt) { // Calculate TTL from token expiration, capped at defaultTTL const tokenTTL = result.expiresAt.getTime() - Date.now() effectiveTTL = Math.min(Math.max(0, tokenTTL), this.config.defaultTTL) } else { // Use default TTL effectiveTTL = this.config.defaultTTL } this.cache.set(token, result, effectiveTTL) } /** * Delete a specific token from the cache */ delete(token: string): boolean { return this.cache.delete(token) } /** * Clear all cached tokens */ clear(): void { this.cache.clear() } /** * Get the current cache size */ get size(): number { return this.cache.size } /** * Get cache statistics */ get stats(): CacheStats { return this.cache.stats } /** * Cleanup expired entries */ cleanup(): number { return this.cache.cleanup() } /** * Dispose of the cache and stop cleanup timers */ dispose(): void { this.cache.dispose() } } /** * Create a bounded cache instance * * @param config - Cache configuration * @returns A new BoundedCache instance */ export function createBoundedCache(config?: BoundedCacheConfig): BoundedCache { return new BoundedCache(config) } /** * Create a token cache instance * * @param config - Token cache configuration * @returns A new TokenCache instance */ export function createTokenCache(config?: TokenCacheConfig): TokenCache { return new TokenCache(config) }