/** * Storage Quota Management — monitors IndexedDB usage and evicts stale docs. * * Uses the Storage API (navigator.storage.estimate) to check quota, * and evicts documents not accessed in 30+ days via LRU policy. */ import type { DocumentId } from './document'; // ============================================================================ // TYPES // ============================================================================ export interface StorageInfo { /** Bytes used */ usage: number; /** Total quota in bytes */ quota: number; /** Usage as a percentage (0–100) */ percent: number; /** Whether persistent storage is granted */ persisted: boolean; } // ============================================================================ // CONSTANTS // ============================================================================ /** Warn the user when storage usage exceeds this percentage */ export const QUOTA_WARNING_PERCENT = 70; /** Evict docs not accessed in this many milliseconds (30 days) */ const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // ============================================================================ // PUBLIC API // ============================================================================ /** * Get current storage usage info. */ export async function getStorageInfo(): Promise { if (!navigator.storage?.estimate) { return { usage: 0, quota: 0, percent: 0, persisted: false }; } const [estimate, persisted] = await Promise.all([ navigator.storage.estimate(), navigator.storage.persisted?.() ?? Promise.resolve(false), ]); const usage = estimate.usage ?? 0; const quota = estimate.quota ?? 0; const percent = quota > 0 ? Math.round((usage / quota) * 100) : 0; return { usage, quota, percent, persisted }; } /** * Request persistent storage so the browser won't evict our data under pressure. * Returns true if granted. */ export async function requestPersistentStorage(): Promise { if (!navigator.storage?.persist) return false; return navigator.storage.persist(); } /** * Evict stale documents from IndexedDB. * Removes docs whose `updatedAt` is older than 30 days. * Returns the list of evicted document IDs. */ export async function evictStaleDocs(dbName = 'rspace-docs'): Promise { const evicted: DocumentId[] = []; const cutoff = Date.now() - STALE_THRESHOLD_MS; let db: IDBDatabase; try { db = await openDb(dbName); } catch { return evicted; } try { // Scan meta store for stale entries const staleIds = await new Promise((resolve, reject) => { const tx = db.transaction('meta', 'readonly'); const request = tx.objectStore('meta').openCursor(); const ids: string[] = []; request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(ids); return; } const meta = cursor.value as { docId: string; updatedAt: number }; if (meta.updatedAt < cutoff) { ids.push(meta.docId); } cursor.continue(); }; request.onerror = () => reject(request.error); }); // Delete stale docs + meta + sync states if (staleIds.length > 0) { const tx = db.transaction(['docs', 'meta', 'sync'], 'readwrite'); const docsStore = tx.objectStore('docs'); const metaStore = tx.objectStore('meta'); for (const id of staleIds) { docsStore.delete(id); metaStore.delete(id); evicted.push(id as DocumentId); } // Clean sync states for evicted docs const syncStore = tx.objectStore('sync'); const syncCursor = syncStore.openCursor(); await new Promise((resolve, reject) => { syncCursor.onsuccess = () => { const cursor = syncCursor.result; if (!cursor) { resolve(); return; } const key = cursor.key as string; if (staleIds.some(id => key.startsWith(`${id}\0`))) { cursor.delete(); } cursor.continue(); }; syncCursor.onerror = () => reject(syncCursor.error); }); await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } } finally { db.close(); } if (evicted.length > 0) { console.log(`[StorageQuota] Evicted ${evicted.length} stale documents`); } return evicted; } /** * Check if storage usage is above the warning threshold. */ export async function isQuotaWarning(): Promise { const info = await getStorageInfo(); return info.percent >= QUOTA_WARNING_PERCENT; } // ============================================================================ // HELPERS // ============================================================================ function openDb(name: string): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(name); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); }