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>
179 lines
8.0 KiB
JavaScript
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.
|
|
}
|
|
}
|