Files
biosiasandClaude Sonnet 5 96cefba24d Cache per-query search matches so repeat searches skip unchanged articles (AI)
Adds a searchQueries IndexedDB store (js/cache.js) keyed by the exact
search text, holding a { articlePath: { mtime, matched } } map per
query. loadSearch() loads it once per new query, trusts a cached
result whenever an article's mtime still matches instead of running
matchesQuery() again, and writes back whatever's newly checked each
scan round. Repeat searches for the same term — even after a page
reload — now only need to actually check articles that are new or
have changed since, not the whole site again. Capped at
MAX_CACHED_SEARCHES (30) distinct queries with least-recently-used
eviction so trying many different terms over time doesn't grow the
cache unbounded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:18:03 +02:00

179 lines
8.0 KiB
JavaScript

// 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.
}
}