import * as fs from "node:fs"; interface TimerApi { setTimeout(handler: () => void, delayMs: number): unknown; clearTimeout(handle: unknown): void; } const defaultTimerApi: TimerApi = { setTimeout: (handler, delayMs) => { const t = setTimeout(handler, delayMs); // Defense in depth: never let a coalescer timer block process exit. // The timer may be cleared before it fires; .unref() is idempotent. if (typeof t === "object" && t && "unref" in t && typeof t.unref === "function") { t.unref(); } return t; }, clearTimeout: (handle) => clearTimeout(handle as ReturnType), }; export interface FileCoalescer { schedule(file: string, delayMs?: number): boolean; clear(): void; } export function createFileCoalescer( handler: (file: string) => void, defaultDelayMs: number, timerApi: TimerApi = defaultTimerApi, ): FileCoalescer { const pending = new Map(); return { schedule(file, delayMs = defaultDelayMs) { if (pending.has(file)) return false; const timer = timerApi.setTimeout(() => { pending.delete(file); handler(file); }, delayMs); pending.set(file, timer); return true; }, clear() { for (const timer of pending.values()) timerApi.clearTimeout(timer); pending.clear(); }, }; } interface ReadCacheEntry { value: T; mtimeMs: number; size: number; expiresAt: number; } const readCache = new Map>(); const readCacheSizeLimit = 128; function evictOldestCacheEntry(): void { if (readCache.size < readCacheSizeLimit) return; // Map iteration order is insertion order; first key is LRU. const oldestKey = readCache.keys().next().value; if (oldestKey !== undefined) readCache.delete(oldestKey); } export function readJsonFileCoalesced(filePath: string, ttlMs: number, read: () => T): T { const now = Date.now(); const stat = (() => { try { const fileStat = fs.statSync(filePath); return { mtimeMs: fileStat.mtimeMs, size: fileStat.size }; } catch { return undefined; } })(); const cached = readCache.get(filePath); if (cached && stat && cached.expiresAt > now && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { // Re-insert to implement LRU: move to end of Map. readCache.delete(filePath); readCache.set(filePath, cached); return cached.value as T; } const value = read(); if (stat !== undefined) { readCache.set(filePath, { value, mtimeMs: stat.mtimeMs, size: stat.size, expiresAt: now + ttlMs, }); evictOldestCacheEntry(); } return value; }