rspace-online/shared/local-first/storage-quota.ts

167 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (0100) */
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<StorageInfo> {
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<boolean> {
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<DocumentId[]> {
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<string[]>((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<void>((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<void>((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<boolean> {
const info = await getStorageInfo();
return info.percent >= QUOTA_WARNING_PERCENT;
}
// ============================================================================
// HELPERS
// ============================================================================
function openDb(name: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}