// Persistent cache for discovered articles (keyed by each article file's own // path) and for per-search-query match results (keyed by the query text — // see setSearchCacheEntry() below). Raw IndexedDB (no wrapper library — see // .claude/nginx-sitemap-crawler-plan.md L4/L5 on the zero-dependency, // long-stable-API requirement). If IndexedDB is unavailable (private // browsing, old browser, blocked), every operation below silently becomes a // no-op so discovery.js/views.js get live-fetch/live-scan behaviour for free // without needing their own availability branching (plan doc R6). // Bump this whenever discovery.js's derived article record shape changes. // The version is baked into the DB name so a code deploy invalidates every // cached entry immediately, instead of stale-shaped records lingering until // each file's mtime happens to change — which, on a "deploy and forget" // site, could be years. const CACHE_SCHEMA_VERSION = 3; // Renaming this prefix has the same effect as a version bump would — every // existing visitor's browser opens a fresh, empty database under the new // name, and the previous one is left behind as orphaned storage (harmless; // this module degrades to live-fetch behaviour whenever the cache is empty // or unavailable). Do this deliberately, not casually. const DB_NAME = `cmkiss-cache-v${CACHE_SCHEMA_VERSION}`; const ARTICLE_STORE = 'articleFiles'; const SEARCH_STORE = 'searchQueries'; // IndexedDB's own upgrade counter — separate from CACHE_SCHEMA_VERSION, // which only governs the *article* record shape (and busts the cache by // renaming the whole database). Bump this instead whenever a store or index // is added/changed; onupgradeneeded below creates only what's missing, so // existing article-cache data survives the upgrade. const DB_VERSION = 2; // Caps how many distinct queries' match results stay cached — otherwise // every unique term ever typed into search would keep its own per-article // map forever, growing unbounded on a long-lived site. Evicted // least-recently-searched-first — see setSearchCacheEntry(). const MAX_CACHED_SEARCHES = 30; let cacheAvailable = true; let dbPromise = null; function openDb() { if (!dbPromise) { dbPromise = new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB unavailable')); return; } const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(ARTICLE_STORE)) db.createObjectStore(ARTICLE_STORE, { keyPath: 'path' }); if (!db.objectStoreNames.contains(SEARCH_STORE)) { db.createObjectStore(SEARCH_STORE, { keyPath: 'query' }).createIndex('lastUsed', 'lastUsed'); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); request.onblocked = () => reject(new Error('IndexedDB open blocked')); }).catch(error => { cacheAvailable = false; throw error; }); } return dbPromise; } // Opens a store and resolves/rejects with the given IDBRequest's own outcome // — the standard pattern, so result timing never depends on transaction // lifecycle details. async function runRequest(storeName, mode, makeRequest) { if (!cacheAvailable) return undefined; let db; try { db = await openDb(); } catch { return undefined; // openDb() itself already flipped cacheAvailable } try { const store = db.transaction(storeName, mode).objectStore(storeName); return await new Promise((resolve, reject) => { const request = makeRequest(store); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } catch { // A single request/transaction failing (e.g. one write hitting a // storage quota) doesn't mean IndexedDB itself is gone — only // openDb() failing disables the cache wholesale. Leave // cacheAvailable as-is so later calls can still try. return undefined; } } // Returns every cached { path, mtime, article } record as a Map keyed by // path — one bulk read instead of a separate IndexedDB round-trip per // article file, which matters once there are hundreds of them: discovery.js // reads this once up front rather than doing a point lookup per file during // indexing. Empty Map on a miss or if the cache is unavailable. export async function getAllCacheEntries() { const records = await runRequest(ARTICLE_STORE, 'readonly', store => store.getAll()); return new Map((records || []).map(record => [record.path, record])); } export async function setCacheEntry(path, entry) { await runRequest(ARTICLE_STORE, 'readwrite', store => store.put({ ...entry, path })); } // Removes any cached entry whose path is no longer in the live directory // listing (renamed/deleted slugs), so the cache doesn't grow unbounded. export async function pruneCache(validPaths) { if (!cacheAvailable) return; const valid = new Set(validPaths); let db; try { db = await openDb(); } catch { return; // openDb() itself already flipped cacheAvailable } try { const store = db.transaction(ARTICLE_STORE, 'readwrite').objectStore(ARTICLE_STORE); await new Promise((resolve, reject) => { const cursorRequest = store.openCursor(); cursorRequest.onsuccess = event => { const cursor = event.target.result; if (!cursor) { resolve(); return; } if (!valid.has(cursor.value.path)) cursor.delete(); cursor.continue(); }; cursorRequest.onerror = () => reject(cursorRequest.error); }); } catch { // Same reasoning as runRequest() above — a failed prune pass doesn't // mean IndexedDB itself is unavailable. } } // Per-query search-match cache: `entries` is a { [articlePath]: { mtime, // matched } } map — see loadSearch() in views.js, which trusts an entry // (skipping a content re-fetch entirely) whenever its mtime still matches // the live article, and only re-checks what's new or changed. Keyed by the // exact (locale-lowercased) text matchesQuery() itself compares against, so // "Linux" and "linux" share one cached entry. export async function getSearchCacheEntry(query) { return runRequest(SEARCH_STORE, 'readonly', store => store.get(query)); } export async function setSearchCacheEntry(query, entries) { await runRequest(SEARCH_STORE, 'readwrite', store => store.put({ query, entries, lastUsed: Date.now() })); await pruneSearchCache(); } // Keeps the search-query cache bounded to MAX_CACHED_SEARCHES entries, // dropping the least-recently-searched ones first — the same "don't grow // unbounded" concern pruneCache() addresses for the article cache, just // against searches instead of renamed/deleted files. async function pruneSearchCache() { if (!cacheAvailable) return; let db; try { db = await openDb(); } catch { return; } try { const store = db.transaction(SEARCH_STORE, 'readwrite').objectStore(SEARCH_STORE); let excess = await new Promise((resolve, reject) => { const request = store.count(); request.onsuccess = () => resolve(request.result - MAX_CACHED_SEARCHES); request.onerror = () => reject(request.error); }); if (excess <= 0) return; await new Promise((resolve, reject) => { const cursorRequest = store.index('lastUsed').openCursor(); cursorRequest.onsuccess = event => { const cursor = event.target.result; if (!cursor || excess <= 0) { resolve(); return; } cursor.delete(); excess -= 1; cursor.continue(); }; cursorRequest.onerror = () => reject(cursorRequest.error); }); } catch { // Same reasoning as runRequest() above. } }