Files
ponomar-pwa/src/core/data-cache.ts
T
catarrh 1201e16d5e fix(cache): complete preload coverage + surface silent write failures
Root cause of 'pre-cache done but offline data missing':
1. Preload manifest never listed the services/{prayers,texts,headers,var}
   subtrees the Service view dereferences (CommonPrayers->prayers, etc.),
   plus only 13 hardcoded commands. Generator now enumerates all of them
   (en 1500->1740, ru->1333, cu 1500->2108 files).
2. DataCache.set silently resolved on IDB write failure (quota exceeded),
   so preload counted un-stored files as success and reported 100% done.
   set() now returns boolean; preload treats failed writes as failures
   that feed the existing circuit breaker (visible abort + diag entry).
3. No persistence request: added best-effort navigator.storage.persist()
   before preload, diag-logged.
4. Dropped dead manifest weight (menaion-common/add, horologionadd) -
   never referenced by any view.
2026-09-08 08:04:13 +03:00

270 lines
7.6 KiB
TypeScript

/**
* 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<IDBDatabase> | null = null;
function getDB(): Promise<IDBDatabase> {
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<unknown | null> {
try {
const db = await getDB();
return new Promise<unknown | null>((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<boolean | null> {
try {
const db = await getDB();
return await new Promise<boolean | null>((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<boolean> {
try {
const db = await getDB();
return await new Promise<boolean>((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<void> {
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<void> {
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<number> {
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<T = unknown>(url: string, type: 'json' | 'text' = 'json'): Promise<T | null> {
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<T = unknown>(url: string, options?: { ttl?: number; type?: 'json' | 'text' }): Promise<T | null> {
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;
}
}
}