import { type CacheEnvelope, ENVELOPE_VERSION, type KVDriver, withTimeout } from './driver' /** * IndexedDB driver — the primary persistent backend. * * Deliberately dependency-free (no `idb` package): the surface is a single * object store keyed by string, and the wrapper stays small enough to audit. * Opening is race-guarded with a timeout because a corrupt profile or a * blocking `versionchange` in another tab can make `indexedDB.open` hang * forever, and the app must degrade to localStorage instead of waiting. */ const DB_NAME = 'kernel-store' const STORE_NAME = 'kv' const DB_VERSION = 1 const OPEN_TIMEOUT_MS = 2000 const PROBE_KEY = '__probe__' function wrapRequest(req: IDBRequest): Promise { return new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result) req.onerror = () => reject(req.error) }) } function wrapTransaction(tx: IDBTransaction): Promise { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve() tx.onerror = () => reject(tx.error) tx.onabort = () => reject(tx.error ?? new Error('idb transaction aborted')) }) } function openDatabase(): Promise { return new Promise((resolve, reject) => { let req: IDBOpenDBRequest try { req = indexedDB.open(DB_NAME, DB_VERSION) } catch (err) { // Some privacy modes throw synchronously on open. reject(err) return } req.onupgradeneeded = () => { const db = req.result if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME) } req.onsuccess = () => { const db = req.result // Never block a future schema upgrade from another tab. db.onversionchange = () => db.close() resolve(db) } req.onerror = () => reject(req.error) req.onblocked = () => reject(new Error('indexedDB open blocked')) }) } export async function createIdbDriver(): Promise { const db = await withTimeout(openDatabase(), OPEN_TIMEOUT_MS, 'indexedDB open timed out') const store = (mode: IDBTransactionMode) => db.transaction(STORE_NAME, mode).objectStore(STORE_NAME) return { name: 'indexeddb', async get(key) { const value = await wrapRequest(store('readonly').get(key)) return value && value.v === ENVELOPE_VERSION ? value : undefined }, async set(key, value) { const tx = db.transaction(STORE_NAME, 'readwrite') tx.objectStore(STORE_NAME).put(value, key) await wrapTransaction(tx) }, async del(key) { const tx = db.transaction(STORE_NAME, 'readwrite') tx.objectStore(STORE_NAME).delete(key) await wrapTransaction(tx) }, async clearPrefix(prefix) { const tx = db.transaction(STORE_NAME, 'readwrite') const os = tx.objectStore(STORE_NAME) if (prefix === '') { os.clear() } else { // String keys sort lexicographically, so every key with the prefix // lives inside [prefix, prefix + \uffff]. const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`) const cursorReq = os.openCursor(range) await new Promise((resolve, reject) => { cursorReq.onsuccess = () => { const cursor = cursorReq.result if (cursor) { cursor.delete() cursor.continue() } else { resolve() } } cursorReq.onerror = () => reject(cursorReq.error) }) } await wrapTransaction(tx) }, async probe() { const envelope: CacheEnvelope = { v: ENVELOPE_VERSION, at: Date.now(), data: 1 } const tx = db.transaction(STORE_NAME, 'readwrite') tx.objectStore(STORE_NAME).put(envelope, PROBE_KEY) await wrapTransaction(tx) const read = await wrapRequest(store('readonly').get(PROBE_KEY)) const tx2 = db.transaction(STORE_NAME, 'readwrite') tx2.objectStore(STORE_NAME).delete(PROBE_KEY) await wrapTransaction(tx2) if (read?.data !== 1) throw new Error('indexedDB probe roundtrip failed') }, } }