"use client"; // Memoization layer for the composite-search tree (searchEval.ts). // // Each node's evaluation is keyed by `${canonicalHash(node)}__${scopeHash}` // — same node + same input scope = same result, regardless of where in the // tree it sits. This is what lets the user edit a deeply-nested leaf and // have unchanged sibling subtrees served instantly from cache. // // Backed by an in-memory Map (sync, fast path) plus an IndexedDB object // store (persistent across reloads, LRU-evicted when it grows too large). // IndexedDB writes are batched via queueMicrotask the same way as // transcriptStore.ts. import type { LayerHit } from "../lib/search/leafPipeline"; import type { CachedResult } from "../lib/searchEval"; // Re-exported so this module's importers keep their import site. The type // belongs to `lib/searchEval.ts` now: it is the shape of a memoized leaf // result, and the tree — not its storage — defines it. export type { CachedResult }; // Separate DB from `transcriptStore.ts` (`yt-dlp-transcript-browser`) so the // two stores can evolve their schemas independently — bumping the version // here doesn't force a coordinated upgrade in transcriptStore. const DB_NAME = "yt-dlp-transcript-browser:search-layer-cache"; const DB_VERSION = 1; const STORE = "layers"; const MAX_ENTRIES = 500; type StoredEntry = { key: string; slugs: string[]; hits: Record; ts: number; }; type Mode = "pending" | "ok" | "unavailable"; let mode: Mode = "pending"; let dbPromise: Promise | null = null; function openDb(): Promise { if (typeof indexedDB === "undefined") { mode = "unavailable"; return Promise.resolve(null); } if (dbPromise) return dbPromise; dbPromise = new Promise((resolve) => { let req: IDBOpenDBRequest; try { req = indexedDB.open(DB_NAME, DB_VERSION); } catch { downgrade("open threw"); resolve(null); return; } req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE)) { const s = db.createObjectStore(STORE, { keyPath: "key" }); s.createIndex("ts", "ts", { unique: false }); } }; req.onsuccess = () => { mode = "ok"; resolve(req.result); }; req.onerror = () => { downgrade("open failed"); resolve(null); }; req.onblocked = () => { downgrade("open blocked"); resolve(null); }; }); return dbPromise; } function downgrade(reason: string): void { if (mode === "unavailable") return; mode = "unavailable"; console.warn( `[searchLayerCache] IndexedDB unavailable (${reason}); falling back to memory-only.`, ); } const memory = new Map(); export function cacheKey(nodeHash: string, scopeHash: string): string { return `${nodeHash}__${scopeHash}`; } export function getCachedSync(key: string): CachedResult | null { return memory.get(key) ?? null; } export async function getCached(key: string): Promise { const hit = memory.get(key); if (hit) return hit; const stored = await idbGet(key); if (!stored) return null; const result: CachedResult = { slugs: new Set(stored.slugs), hits: hitMapFromRecord(stored.hits), }; memory.set(key, result); // Touch ts so frequently-read entries float to the top of the LRU. idbTouch(key).catch(() => { /* swallow — touch is best-effort */ }); return result; } export function putCached(key: string, result: CachedResult): void { memory.set(key, result); const entry: StoredEntry = { key, slugs: Array.from(result.slugs), hits: hitRecordFromMap(result.hits), ts: Date.now(), }; schedulePut(entry); } export function invalidateAll(): void { memory.clear(); idbClearAll().catch(() => { /* ignore */ }); } // ─── Internal helpers ─── function hitMapFromRecord( record: Record, ): Map { const m = new Map(); for (const [slug, hits] of Object.entries(record)) m.set(slug, hits); return m; } function hitRecordFromMap( map: ReadonlyMap, ): Record { const out: Record = {}; for (const [slug, hits] of map) out[slug] = hits; return out; } async function idbGet(key: string): Promise { const db = await openDb(); if (!db) return null; return new Promise((resolve) => { let req: IDBRequest; try { const tx = db.transaction(STORE, "readonly"); req = tx.objectStore(STORE).get(key) as IDBRequest< StoredEntry | undefined >; } catch { downgrade("read tx threw"); resolve(null); return; } req.onsuccess = () => resolve(req.result ?? null); req.onerror = () => resolve(null); }); } async function idbTouch(key: string): Promise { const db = await openDb(); if (!db) return; return new Promise((resolve) => { let tx: IDBTransaction; try { tx = db.transaction(STORE, "readwrite"); } catch { downgrade("touch tx threw"); resolve(); return; } const store = tx.objectStore(STORE); const getReq = store.get(key) as IDBRequest; getReq.onsuccess = () => { const entry = getReq.result; if (entry) { entry.ts = Date.now(); try { store.put(entry); } catch { /* ignore */ } } }; tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); tx.onabort = () => resolve(); }); } let pending: StoredEntry[] = []; let flushScheduled = false; function schedulePut(entry: StoredEntry): void { if (mode === "unavailable") return; // De-dupe consecutive writes to the same key — keep only the latest. for (let i = 0; i < pending.length; i++) { if (pending[i].key === entry.key) { pending[i] = entry; if (!flushScheduled) { flushScheduled = true; queueMicrotask(flushPending); } return; } } pending.push(entry); if (flushScheduled) return; flushScheduled = true; queueMicrotask(flushPending); } async function flushPending(): Promise { flushScheduled = false; if (pending.length === 0) return; const batch = pending; pending = []; const db = await openDb(); if (!db) return; try { const tx = db.transaction(STORE, "readwrite"); const store = tx.objectStore(STORE); for (const e of batch) { try { store.put(e); } catch { /* per-entry failure (e.g. unclonable) shouldn't kill the batch */ } } await new Promise((resolve) => { tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); tx.onabort = () => resolve(); }); } catch { downgrade("write tx threw"); return; } // LRU prune. Run opportunistically; missing this round is harmless. pruneIfNeeded().catch(() => { /* ignore */ }); } async function pruneIfNeeded(): Promise { const db = await openDb(); if (!db) return; try { const countTx = db.transaction(STORE, "readonly"); const count = await new Promise((resolve) => { const req = countTx.objectStore(STORE).count(); req.onsuccess = () => resolve(req.result); req.onerror = () => resolve(0); }); if (count <= MAX_ENTRIES) return; const toRemove = count - MAX_ENTRIES; const writeTx = db.transaction(STORE, "readwrite"); const idx = writeTx.objectStore(STORE).index("ts"); let removed = 0; await new Promise((resolve) => { const cursorReq = idx.openCursor(); cursorReq.onsuccess = () => { const c = cursorReq.result; if (!c || removed >= toRemove) { resolve(); return; } try { c.delete(); } catch { /* ignore */ } removed++; c.continue(); }; cursorReq.onerror = () => resolve(); }); } catch { /* ignore — eviction is best-effort */ } } async function idbClearAll(): Promise { const db = await openDb(); if (!db) return; return new Promise((resolve) => { try { const tx = db.transaction(STORE, "readwrite"); tx.objectStore(STORE).clear(); tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); tx.onabort = () => resolve(); } catch { resolve(); } }); }