diff --git a/src/utils/index.ts b/src/utils/index.ts index 6d94d2a6..1578f04f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,3 +9,4 @@ export { BufferAccumulator } from './buffer-accumulator.js'; export { LRUMap, type LRUMapOptions } from './lru-map.js'; export { CleanupManager, type TimerOptions } from './cleanup-manager.js'; +export { StaleExpirationMap, type StaleExpirationMapOptions } from './stale-expiration-map.js'; diff --git a/src/utils/stale-expiration-map.ts b/src/utils/stale-expiration-map.ts new file mode 100644 index 00000000..89653911 --- /dev/null +++ b/src/utils/stale-expiration-map.ts @@ -0,0 +1,338 @@ +/** + * @fileoverview Map with automatic TTL-based entry expiration. + * + * StaleExpirationMap automatically removes entries that haven't been accessed + * within a configurable TTL (time-to-live). Useful for caches and tracking + * ephemeral data that should be cleaned up after a period of inactivity. + * + * @module utils/stale-expiration-map + */ + +import type { Disposable } from '../types.js'; + +/** + * Entry wrapper that tracks last access time. + */ +interface TimedEntry { + value: V; + lastAccessedAt: number; + createdAt: number; +} + +/** + * Configuration options for StaleExpirationMap. + */ +export interface StaleExpirationMapOptions { + /** Time-to-live in milliseconds before entries expire */ + ttlMs: number; + /** How often to run cleanup (default: ttlMs / 2) */ + cleanupIntervalMs?: number; + /** Optional callback when an entry expires */ + onExpire?: (key: K, value: V) => void; + /** Whether to refresh TTL on get (default: true) */ + refreshOnGet?: boolean; +} + +/** + * A Map that automatically expires entries after a TTL. + * + * Entries are removed if not accessed within the TTL period. + * Periodic cleanup runs to remove expired entries. + * + * @example + * ```typescript + * const cache = new StaleExpirationMap({ + * ttlMs: 5 * 60 * 1000, // 5 minutes + * onExpire: (key, value) => console.log(`Expired: ${key}`) + * }); + * + * cache.set('key1', { data: 'value' }); + * // After 5 minutes of no access, 'key1' will be automatically removed + * ``` + */ +export class StaleExpirationMap implements Disposable { + private entries = new Map>(); + private cleanupTimer: NodeJS.Timeout | null = null; + private _isDisposed = false; + + private readonly ttlMs: number; + private readonly cleanupIntervalMs: number; + private readonly onExpire?: (key: K, value: V) => void; + private readonly refreshOnGet: boolean; + + /** + * Creates a new StaleExpirationMap. + * + * @param options - Configuration options + */ + constructor(options: StaleExpirationMapOptions) { + this.ttlMs = options.ttlMs; + this.cleanupIntervalMs = options.cleanupIntervalMs ?? Math.floor(options.ttlMs / 2); + this.onExpire = options.onExpire; + this.refreshOnGet = options.refreshOnGet ?? true; + + // Start periodic cleanup + this.startCleanup(); + } + + /** + * Whether this map has been disposed. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * Number of entries in the map. + */ + get size(): number { + return this.entries.size; + } + + /** + * Set a value with automatic TTL tracking. + * + * @param key - Key to set + * @param value - Value to associate + * @returns this (for chaining) + */ + set(key: K, value: V): this { + if (this._isDisposed) return this; + + const now = Date.now(); + this.entries.set(key, { + value, + lastAccessedAt: now, + createdAt: now, + }); + return this; + } + + /** + * Get a value, optionally refreshing its TTL. + * + * @param key - Key to look up + * @returns Value if found and not expired, undefined otherwise + */ + get(key: K): V | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + + // Check if expired + if (this.isExpired(entry)) { + this.delete(key); + return undefined; + } + + // Refresh access time if configured + if (this.refreshOnGet) { + entry.lastAccessedAt = Date.now(); + } + + return entry.value; + } + + /** + * Check if a key exists and is not expired. + * + * @param key - Key to check + * @returns True if key exists and is not expired + */ + has(key: K): boolean { + const entry = this.entries.get(key); + if (!entry) return false; + + if (this.isExpired(entry)) { + this.delete(key); + return false; + } + + return true; + } + + /** + * Peek at a value without refreshing its TTL. + * + * @param key - Key to peek + * @returns Value if found and not expired, undefined otherwise + */ + peek(key: K): V | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + + if (this.isExpired(entry)) { + this.delete(key); + return undefined; + } + + return entry.value; + } + + /** + * Delete an entry. + * + * @param key - Key to delete + * @returns True if entry existed and was deleted + */ + delete(key: K): boolean { + const entry = this.entries.get(key); + if (!entry) return false; + + this.entries.delete(key); + // Don't call onExpire for manual deletes + return true; + } + + /** + * Clear all entries. + */ + clear(): void { + this.entries.clear(); + } + + /** + * Touch an entry to refresh its TTL without returning the value. + * + * @param key - Key to touch + * @returns True if entry exists + */ + touch(key: K): boolean { + const entry = this.entries.get(key); + if (!entry || this.isExpired(entry)) return false; + + entry.lastAccessedAt = Date.now(); + return true; + } + + /** + * Get the age of an entry in milliseconds. + * + * @param key - Key to check + * @returns Age in ms, or undefined if not found + */ + getAge(key: K): number | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + return Date.now() - entry.createdAt; + } + + /** + * Get remaining TTL for an entry in milliseconds. + * + * @param key - Key to check + * @returns Remaining TTL in ms, or undefined if not found/expired + */ + getRemainingTtl(key: K): number | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + + const elapsed = Date.now() - entry.lastAccessedAt; + const remaining = this.ttlMs - elapsed; + return remaining > 0 ? remaining : 0; + } + + /** + * Iterate over all non-expired entries. + */ + *[Symbol.iterator](): IterableIterator<[K, V]> { + for (const [key, entry] of this.entries) { + if (!this.isExpired(entry)) { + yield [key, entry.value]; + } + } + } + + /** + * Get all keys (non-expired). + */ + keys(): IterableIterator { + const self = this; + return (function* () { + for (const [key, entry] of self.entries) { + if (!self.isExpired(entry)) { + yield key; + } + } + })(); + } + + /** + * Get all values (non-expired). + */ + values(): IterableIterator { + const self = this; + return (function* () { + for (const [, entry] of self.entries) { + if (!self.isExpired(entry)) { + yield entry.value; + } + } + })(); + } + + /** + * Run cleanup immediately, removing all expired entries. + * + * @returns Number of entries removed + */ + cleanup(): number { + let removed = 0; + const now = Date.now(); + + for (const [key, entry] of this.entries) { + if (now - entry.lastAccessedAt > this.ttlMs) { + this.entries.delete(key); + this.onExpire?.(key, entry.value); + removed++; + } + } + + return removed; + } + + /** + * Dispose the map, stopping cleanup timer and clearing entries. + */ + dispose(): void { + if (this._isDisposed) return; + this._isDisposed = true; + + this.stopCleanup(); + this.entries.clear(); + } + + /** + * Check if an entry has expired. + */ + private isExpired(entry: TimedEntry): boolean { + return Date.now() - entry.lastAccessedAt > this.ttlMs; + } + + /** + * Start the periodic cleanup timer. + */ + private startCleanup(): void { + if (this.cleanupTimer) return; + + this.cleanupTimer = setInterval(() => { + if (!this._isDisposed) { + this.cleanup(); + } + }, this.cleanupIntervalMs); + + // Don't prevent process exit + this.cleanupTimer.unref(); + } + + /** + * Stop the periodic cleanup timer. + */ + private stopCleanup(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + } +} + +export default StaleExpirationMap; diff --git a/test/utils/stale-expiration-map.test.ts b/test/utils/stale-expiration-map.test.ts new file mode 100644 index 00000000..4fc2c2d3 --- /dev/null +++ b/test/utils/stale-expiration-map.test.ts @@ -0,0 +1,343 @@ +/** + * Tests for StaleExpirationMap utility. + * + * Port: N/A (unit tests, no server) + */ + +import { StaleExpirationMap } from '../../src/utils/stale-expiration-map.js'; + +describe('StaleExpirationMap', () => { + describe('basic operations', () => { + it('should set and get values', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + map.set('a', 1); + map.set('b', 2); + + expect(map.get('a')).toBe(1); + expect(map.get('b')).toBe(2); + expect(map.get('c')).toBeUndefined(); + + map.dispose(); + }); + + it('should check has correctly', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + map.set('a', 1); + + expect(map.has('a')).toBe(true); + expect(map.has('b')).toBe(false); + + map.dispose(); + }); + + it('should delete values', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + map.set('a', 1); + + expect(map.delete('a')).toBe(true); + expect(map.delete('a')).toBe(false); + expect(map.has('a')).toBe(false); + + map.dispose(); + }); + + it('should clear all values', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + map.set('a', 1); + map.set('b', 2); + + map.clear(); + + expect(map.size).toBe(0); + expect(map.has('a')).toBe(false); + + map.dispose(); + }); + + it('should report size correctly', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + expect(map.size).toBe(0); + + map.set('a', 1); + expect(map.size).toBe(1); + + map.set('b', 2); + expect(map.size).toBe(2); + + map.dispose(); + }); + }); + + describe('expiration', () => { + it('should expire entries after TTL', async () => { + const map = new StaleExpirationMap({ + ttlMs: 100, + cleanupIntervalMs: 50, + }); + + map.set('a', 1); + expect(map.get('a')).toBe(1); + + // Wait for expiration + await new Promise(r => setTimeout(r, 150)); + + expect(map.get('a')).toBeUndefined(); + expect(map.has('a')).toBe(false); + + map.dispose(); + }); + + it('should call onExpire callback when entry expires', async () => { + const expired: Array<[string, number]> = []; + const map = new StaleExpirationMap({ + ttlMs: 100, + cleanupIntervalMs: 50, + onExpire: (key, value) => expired.push([key, value]), + }); + + map.set('a', 1); + map.set('b', 2); + + // Wait for cleanup + await new Promise(r => setTimeout(r, 200)); + + expect(expired).toContainEqual(['a', 1]); + expect(expired).toContainEqual(['b', 2]); + + map.dispose(); + }); + + it('should refresh TTL on get when refreshOnGet is true', async () => { + const map = new StaleExpirationMap({ + ttlMs: 150, + cleanupIntervalMs: 50, + refreshOnGet: true, + }); + + map.set('a', 1); + + // Access at 75ms (halfway through TTL) + await new Promise(r => setTimeout(r, 75)); + expect(map.get('a')).toBe(1); // This refreshes TTL + + // Wait another 100ms (entry should still be valid because TTL was refreshed) + await new Promise(r => setTimeout(r, 100)); + expect(map.get('a')).toBe(1); + + map.dispose(); + }); + + it('should not refresh TTL on get when refreshOnGet is false', async () => { + const map = new StaleExpirationMap({ + ttlMs: 100, + cleanupIntervalMs: 50, + refreshOnGet: false, + }); + + map.set('a', 1); + + // Access at 50ms + await new Promise(r => setTimeout(r, 50)); + expect(map.get('a')).toBe(1); // Does NOT refresh TTL + + // Wait another 75ms (entry should be expired) + await new Promise(r => setTimeout(r, 75)); + expect(map.get('a')).toBeUndefined(); + + map.dispose(); + }); + }); + + describe('peek and touch', () => { + it('should peek without refreshing TTL', async () => { + const map = new StaleExpirationMap({ + ttlMs: 100, + cleanupIntervalMs: 50, + refreshOnGet: true, + }); + + map.set('a', 1); + + // Peek at 50ms + await new Promise(r => setTimeout(r, 50)); + expect(map.peek('a')).toBe(1); // Does NOT refresh TTL + + // Wait another 75ms (entry should be expired) + await new Promise(r => setTimeout(r, 75)); + expect(map.peek('a')).toBeUndefined(); + + map.dispose(); + }); + + it('should touch to refresh TTL', async () => { + const map = new StaleExpirationMap({ + ttlMs: 150, + cleanupIntervalMs: 50, + }); + + map.set('a', 1); + + // Touch at 75ms + await new Promise(r => setTimeout(r, 75)); + expect(map.touch('a')).toBe(true); + + // Wait another 100ms (entry should still be valid) + await new Promise(r => setTimeout(r, 100)); + expect(map.has('a')).toBe(true); + + map.dispose(); + }); + + it('should return false for touch on non-existent key', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + + expect(map.touch('nonexistent')).toBe(false); + + map.dispose(); + }); + }); + + describe('age and remaining TTL', () => { + it('should return age of entry', async () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + map.set('a', 1); + + await new Promise(r => setTimeout(r, 50)); + + const age = map.getAge('a'); + expect(age).toBeDefined(); + expect(age!).toBeGreaterThanOrEqual(50); + expect(age!).toBeLessThan(150); + + map.dispose(); + }); + + it('should return undefined age for non-existent key', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + + expect(map.getAge('nonexistent')).toBeUndefined(); + + map.dispose(); + }); + + it('should return remaining TTL', async () => { + const map = new StaleExpirationMap({ ttlMs: 1000 }); + map.set('a', 1); + + await new Promise(r => setTimeout(r, 100)); + + const remaining = map.getRemainingTtl('a'); + expect(remaining).toBeDefined(); + expect(remaining!).toBeLessThanOrEqual(900); + expect(remaining!).toBeGreaterThan(800); + + map.dispose(); + }); + }); + + describe('iteration', () => { + it('should iterate over non-expired entries', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + map.set('a', 1); + map.set('b', 2); + map.set('c', 3); + + const entries = Array.from(map); + expect(entries).toEqual([['a', 1], ['b', 2], ['c', 3]]); + + map.dispose(); + }); + + it('should iterate over keys', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + map.set('a', 1); + map.set('b', 2); + + const keys = Array.from(map.keys()); + expect(keys).toEqual(['a', 'b']); + + map.dispose(); + }); + + it('should iterate over values', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + map.set('a', 1); + map.set('b', 2); + + const values = Array.from(map.values()); + expect(values).toEqual([1, 2]); + + map.dispose(); + }); + }); + + describe('manual cleanup', () => { + it('should remove expired entries on cleanup()', async () => { + const expired: string[] = []; + const map = new StaleExpirationMap({ + ttlMs: 50, + cleanupIntervalMs: 10000, // Long interval so automatic cleanup doesn't run + onExpire: (key) => expired.push(key), + }); + + map.set('a', 1); + map.set('b', 2); + + // Wait for expiration + await new Promise(r => setTimeout(r, 100)); + + // Manual cleanup + const removed = map.cleanup(); + + expect(removed).toBe(2); + expect(expired).toContain('a'); + expect(expired).toContain('b'); + + map.dispose(); + }); + }); + + describe('dispose', () => { + it('should stop cleanup timer on dispose', async () => { + let cleanupCount = 0; + const originalClearInterval = global.clearInterval; + let clearedIntervals = 0; + global.clearInterval = ((id: NodeJS.Timeout) => { + clearedIntervals++; + originalClearInterval(id); + }) as typeof global.clearInterval; + + const map = new StaleExpirationMap({ + ttlMs: 100, + cleanupIntervalMs: 50, + onExpire: () => cleanupCount++, + }); + + map.dispose(); + + // Restore original + global.clearInterval = originalClearInterval; + + expect(clearedIntervals).toBeGreaterThan(0); + expect(map.isDisposed).toBe(true); + }); + + it('should be idempotent', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + + map.dispose(); + map.dispose(); + map.dispose(); + + expect(map.isDisposed).toBe(true); + }); + + it('should not allow new entries after dispose', () => { + const map = new StaleExpirationMap({ ttlMs: 10000 }); + map.dispose(); + + map.set('a', 1); + expect(map.size).toBe(0); + }); + }); +});