/** * Small shared utilities with no `pi`/`ctx` dependency, so they can be * unit-tested in isolation under `node --test`. */ /** * Promise-valued TTL cache with size bound, in-flight dedup, and LRU-on-hit. * * Stores a Promise per key (so concurrent callers for the same key share one * in-flight computation). A fresh hit is re-inserted to refresh its LRU position * (Map preserves insertion order; eviction drops the oldest). Generalizes the * pattern used by llms-txt.ts. */ export class TtlCache { private readonly map = new Map }>(); private readonly ttlMs: number; private readonly maxEntries: number; constructor(ttlMs: number, maxEntries: number) { this.ttlMs = ttlMs; this.maxEntries = maxEntries; } private prune(now: number): void { for (const [k, v] of this.map) { if (now - v.at >= this.ttlMs) this.map.delete(k); } while (this.map.size > this.maxEntries) { const oldest = this.map.keys().next().value; if (oldest === undefined) break; this.map.delete(oldest); } } /** Return the cached (fresh) promise for `key`, or undefined. Refreshes LRU position. */ get(key: string, now: number = Date.now()): Promise | undefined { this.prune(now); const hit = this.map.get(key); if (hit && now - hit.at < this.ttlMs) { this.map.delete(key); this.map.set(key, hit); // refresh LRU position return hit.promise; } return undefined; } /** Store a promise for `key`. */ set(key: string, promise: Promise, now: number = Date.now()): void { this.map.set(key, { at: now, promise }); this.prune(now); } /** Evict a key (e.g. after a transient failure so it can be retried). */ delete(key: string): void { this.map.delete(key); } /** * Get-or-compute: returns the cached fresh promise, else runs `compute`, caches * it, and evicts the entry if it rejects (so failures aren't cached). */ getOrCompute(key: string, compute: () => Promise, now: number = Date.now()): Promise { const hit = this.get(key, now); if (hit) return hit; const promise = (async () => { try { return await compute(); } catch (e) { this.map.delete(key); throw e; } })(); this.set(key, promise, now); return promise; } get size(): number { return this.map.size; } } /** * Run `fn` over `items` with at most `limit` in flight, preserving result order. * * Each item is isolated: if `fn` rejects, that slot resolves to * `{ ok: false, error }` instead of rejecting the whole batch, so one bad page * never sinks the others. Successful slots are `{ ok: true, value }`. */ export type Settled = { ok: true; value: R } | { ok: false; error: unknown }; export async function mapLimitSettled( items: T[], limit: number, fn: (item: T, i: number) => Promise, ): Promise[]> { const results: Settled[] = new Array(items.length); let next = 0; const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => { while (next < items.length) { const i = next++; try { results[i] = { ok: true, value: await fn(items[i], i) }; } catch (error) { results[i] = { ok: false, error }; } } }); await Promise.all(workers); return results; }