/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * IndexedDB-backed implementation of @ifc-lite/extensions' `ExtensionStorage` * interface. * * Two object stores: * - `extensions` keyed by extension id → InstalledExtensionRecord * - `extension-bundles` keyed by `@` tuple → Uint8Array * * On startup, we open the database, verify both stores exist, and recreate * the database from scratch if anything is missing (mirrors the recovery * pattern used by `services/ifc-cache.ts`). */ import type { ExtensionStorage, InstalledExtensionRecord, } from '@ifc-lite/extensions'; const DB_NAME = 'ifc-lite-extensions'; /** * Bump this when adding/removing/renaming object stores or indexes. * Every new version MUST extend the `onupgradeneeded` switch below * with an idempotent migration step from `event.oldVersion`. */ const DB_VERSION = 1; const STORE_EXT = 'extensions'; const STORE_BUNDLES = 'extension-bundles'; let dbPromise: Promise | null = null; /** * Thrown from any IDB write path when the browser refuses the * operation because the origin's storage quota is exhausted. * Callers (host-installer, host) should catch this specifically and * surface a "free up space / uninstall something" toast — bubbling * the raw `QuotaExceededError` produces a generic console error and * a silent UI. */ export class ExtensionStorageQuotaError extends Error { readonly cause?: unknown; constructor(operation: string, cause?: unknown) { super(`Browser storage quota exceeded while ${operation}.`); this.name = 'ExtensionStorageQuotaError'; this.cause = cause; } } function isQuotaError(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const name = (err as { name?: unknown }).name; return name === 'QuotaExceededError' || name === 'NS_ERROR_DOM_QUOTA_REACHED'; } export class IdbExtensionStorage implements ExtensionStorage { async putExtension(record: InstalledExtensionRecord): Promise { const db = await openDatabase(); try { await runStore(db, STORE_EXT, 'readwrite', (store) => store.put(record)); } catch (err) { if (isQuotaError(err)) { throw new ExtensionStorageQuotaError( `saving extension record "${record.id}"`, err, ); } throw err; } } async getExtension(id: string): Promise { const db = await openDatabase(); return runStore(db, STORE_EXT, 'readonly', (store) => store.get(id)) .then((v) => (v ? (v as InstalledExtensionRecord) : undefined)); } async listExtensions(): Promise { const db = await openDatabase(); return runStore(db, STORE_EXT, 'readonly', (store) => store.getAll()); } async deleteExtension(id: string): Promise { const db = await openDatabase(); await runStore(db, STORE_EXT, 'readwrite', (store) => store.delete(id)); // Cascade: drop bundles for this extension. We can't use runStore // here — it overwrites req.onsuccess to capture the result, which // clobbers the cursor-iteration handler. Roll our own transaction. await new Promise((resolve, reject) => { const tx = db.transaction(STORE_BUNDLES, 'readwrite'); const store = tx.objectStore(STORE_BUNDLES); const req = store.openCursor(); req.onsuccess = () => { const cursor = req.result; if (!cursor) return; const key = String(cursor.key); if (key.startsWith(`${id}@`)) cursor.delete(); cursor.continue(); }; req.onerror = () => reject(req.error); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } async putBundle(id: string, version: string, bytes: Uint8Array): Promise { const db = await openDatabase(); try { await runStore(db, STORE_BUNDLES, 'readwrite', (store) => store.put(new Uint8Array(bytes), bundleKey(id, version)), ); } catch (err) { if (isQuotaError(err)) { throw new ExtensionStorageQuotaError( `saving bundle bytes for "${id}@${version}" (${bytes.byteLength} bytes)`, err, ); } throw err; } } async getBundle(id: string, version: string): Promise { const db = await openDatabase(); const value = await runStore( db, STORE_BUNDLES, 'readonly', (store) => store.get(bundleKey(id, version)), ); return value ? new Uint8Array(value) : undefined; } async deleteBundle(id: string, version: string): Promise { const db = await openDatabase(); await runStore(db, STORE_BUNDLES, 'readwrite', (store) => store.delete(bundleKey(id, version)), ); } async clear(): Promise { const db = await openDatabase(); await runStore(db, STORE_EXT, 'readwrite', (store) => store.clear()); await runStore(db, STORE_BUNDLES, 'readwrite', (store) => store.clear()); } } function bundleKey(id: string, version: string): string { return `${id}@${version}`; } function openDatabase(): Promise { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => { console.error('[extensions/idb] Failed to open database:', request.error); dbPromise = null; reject(request.error); }; request.onupgradeneeded = (event) => { const db = request.result; // Migrations are append-only. When DB_VERSION bumps: // case 1: ... // run v1→v2 migration here (e.g. add an index) // case 2: ... // run v2→v3 here // The createObjectStore calls below seed a fresh database (when // event.oldVersion is 0) and remain idempotent for users who land // here because recovery deleted the database below. switch (event.oldVersion) { case 0: db.createObjectStore(STORE_EXT, { keyPath: 'id' }); db.createObjectStore(STORE_BUNDLES); break; default: // No-op until a v2+ migration exists. Leaving the switch // explicit keeps the contract visible: every future bump // adds its own case. break; } }; request.onsuccess = () => { const db = request.result; if (!db.objectStoreNames.contains(STORE_EXT) || !db.objectStoreNames.contains(STORE_BUNDLES)) { // Recovery: delete and recreate. db.close(); dbPromise = null; const del = indexedDB.deleteDatabase(DB_NAME); del.onsuccess = () => openDatabase().then(resolve).catch(reject); del.onerror = () => reject(new Error('Failed to recreate extensions database.')); // Without onblocked, another tab holding a connection makes the // delete request hang indefinitely and openDatabase() never // resolves. Reject explicitly so the caller can surface the issue. del.onblocked = () => reject(new Error( 'Extensions database recreation is blocked by another open tab. Close other tabs and reload.', )); return; } resolve(db); }; }); return dbPromise; } function runStore( db: IDBDatabase, storeName: string, mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest | void, ): Promise { return new Promise((resolve, reject) => { const tx = db.transaction(storeName, mode); const store = tx.objectStore(storeName); let value: unknown; const req = fn(store); if (req instanceof IDBRequest) { req.onsuccess = () => { value = req.result; }; req.onerror = () => reject(req.error); } tx.oncomplete = () => resolve(value as T); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); }