main
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<T>(req: IDBRequest<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
function wrapTransaction(tx: IDBTransaction): Promise<void> {
return new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
tx.onabort = () => reject(tx.error ?? new Error('idb transaction aborted'))
})
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise<IDBDatabase>((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<KVDriver> {
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<CacheEnvelope | undefined>(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<void>((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<CacheEnvelope | undefined>(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')
},
}
}