/** * Persistent data cache using IndexedDB. * * Provides cache-first fetch with TTL support for JSON and text data. * Works alongside the service worker: SW caches static assets, * IndexedDB caches fetched data for cross-session persistence. * * Usage: * const data = await DataCache.fetch('/data/ru/lives/01.json'); * await DataCache.set('my-key', { some: 'data' }); * const cached = await DataCache.get('my-key'); * await DataCache.clear(); * const estimate = await DataCache.estimate(); */ const DB_NAME = 'ponomar-cache'; const DB_VERSION = 1; const STORE_NAME = 'data'; const DEFAULT_TTL_MS = Infinity; // never expire — static data interface CacheEntry { key: string; data: unknown; contentType: string; storedAt: number; ttl: number; } let dbPromise: Promise | null = null; function getDB(): Promise { if (!dbPromise) { dbPromise = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' }); store.createIndex('storedAt', 'storedAt', { unique: false }); } }; request.onsuccess = () => { const db = request.result; db.onclose = () => { dbPromise = null; }; db.onversionchange = () => { db.close(); dbPromise = null; }; resolve(db); }; request.onerror = () => { dbPromise = null; reject(request.error); }; }); } return dbPromise; } export class DataCache { /** * Get a value from the cache. * Returns null if the key doesn't exist or the TTL has expired. */ static async get(key: string): Promise { try { const db = await getDB(); return new Promise((resolve) => { const tx = db.transaction(STORE_NAME, 'readonly'); const store = tx.objectStore(STORE_NAME); const request = store.get(key); request.onsuccess = () => { const entry = request.result as CacheEntry | undefined; if (!entry) { resolve(null); return; } // Check TTL if (Date.now() - entry.storedAt > entry.ttl) { // Expired — remove and return null db.transaction(STORE_NAME, 'readwrite') .objectStore(STORE_NAME) .delete(key); resolve(null); return; } resolve(entry.data); }; request.onerror = () => resolve(null); }); } catch { return null; } } /** * Tri-state presence probe: true/false when known, null on read error. * Unlike get(), lets callers distinguish "absent" from "unreadable". */ static async has(key: string): Promise { try { const db = await getDB(); return await new Promise((resolve) => { const tx = db.transaction(STORE_NAME, 'readonly'); const request = tx.objectStore(STORE_NAME).count(key); request.onsuccess = () => resolve(request.result > 0); request.onerror = () => resolve(null); }); } catch { return null; } } /** * Store a value in the cache. * Resolves true on success; false when the write failed (e.g. quota). */ static async set(key: string, data: unknown, ttl: number = DEFAULT_TTL_MS): Promise { try { const db = await getDB(); return await new Promise((resolve) => { const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); const entry: CacheEntry = { key, data, contentType: typeof data === 'string' ? 'text' : 'json', storedAt: Date.now(), ttl, }; store.put(entry); tx.oncomplete = () => resolve(true); tx.onerror = () => resolve(false); tx.onabort = () => resolve(false); }); } catch { // Write failed (e.g. quota exceeded) — report as failure. return false; } } /** * Remove an entry from the cache. */ static async remove(key: string): Promise { try { const db = await getDB(); return new Promise((resolve) => { db.transaction(STORE_NAME, 'readwrite') .objectStore(STORE_NAME) .delete(key); resolve(); }); } catch { // Silently fail } } /** * Clear all cached data. */ static async clear(): Promise { try { const db = await getDB(); return new Promise((resolve) => { db.transaction(STORE_NAME, 'readwrite') .objectStore(STORE_NAME) .clear(); resolve(); }); } catch { // Silently fail } } /** * Get storage usage estimate. * Returns { usage, quota } in bytes, or null if not available. */ static async estimate(): Promise<{ usage: number; quota: number } | null> { try { if (navigator.storage && navigator.storage.estimate) { const est = await navigator.storage.estimate(); return { usage: est.usage ?? 0, quota: est.quota ?? 0 }; } return null; } catch { return null; } } /** * Count entries in the cache. */ static async count(): Promise { try { const db = await getDB(); return new Promise((resolve) => { const tx = db.transaction(STORE_NAME, 'readonly'); const store = tx.objectStore(STORE_NAME); const request = store.count(); request.onsuccess = () => resolve(request.result); request.onerror = () => resolve(0); }); } catch { return 0; } } /** * Network-first fetch with IndexedDB fallback. * * Online: fetches, parses and writes through to IDB under the same * `url:` keys the offline preload uses. Offline: serves the precached * IDB copy, or null when the file was never preloaded. */ static async fetchWithFallback(url: string, type: 'json' | 'text' = 'json'): Promise { try { const resp = await fetch(url); const ct = resp.headers.get('content-type'); if (resp.ok && !(ct && ct.includes('text/html'))) { const data: unknown = type === 'text' ? await resp.text() : await resp.json(); await DataCache.set(`url:${url}`, data); return data as T; } } catch { /* offline or invalid response — fall through to IDB */ } try { return ((await DataCache.get(`url:${url}`)) as T) ?? null; } catch { return null; } } /** * Cache-first fetch: tries IndexedDB first, falls back to network. * If the data is fetched from network, it's cached for future use. */ static async fetch(url: string, options?: { ttl?: number; type?: 'json' | 'text' }): Promise { const cacheKey = `url:${url}`; const cached = await DataCache.get(cacheKey); if (cached !== null) { return cached as T; } try { const resp = await fetch(url); if (!resp.ok) { // Don't cache 404s return null; } const ct = resp.headers.get('content-type'); if (ct && ct.includes('text/html')) { return null; } let data: unknown; if (options?.type === 'text') { data = await resp.text(); } else { data = await resp.json(); } // Cache the fetched data await DataCache.set(cacheKey, data, options?.ttl); return data as T; } catch { return null; } } }