167 lines
4.8 KiB
TypeScript
167 lines
4.8 KiB
TypeScript
/**
|
||
* 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<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);
|
||
});
|
||
}
|