import type { AnySpec, Spec } from '../utils/schema.js' import { BaseKVStorage, type KVChange, type ScanOptions } from './base-kv-storage.js' export interface KVStorageIndexedDBOptions { /** Database name. */ name: string /** Object store name. Default `kv`. */ storeName?: string /** IndexedDB factory. Default `globalThis.indexedDB`. */ indexedDB?: IDBFactory /** Entries read per transaction while scanning. Default 256. */ scanBatchSize?: number } /** * KV storage in the browser's IndexedDB (one object store, string keys). Values are stored with the * structured clone algorithm, so `bigint` and `Uint8Array` survive. IndexedDB orders string keys by * UTF-16 code unit, like JavaScript string comparison. */ export class KVStorageIndexedDB extends BaseKVStorage { private readonly storeName: string private readonly scanBatchSize: number private readonly db: Promise constructor(options: KVStorageIndexedDBOptions) { super() this.storeName = options.storeName ?? 'kv' this.scanBatchSize = options.scanBatchSize ?? 256 const factory = options.indexedDB ?? globalThis.indexedDB const open = factory.open(options.name, 1) open.onupgradeneeded = () => open.result.createObjectStore(this.storeName) this.db = KVStorageIndexedDB.done(open) } async readMany(keys: string[]): Promise { const store = await this.store('readonly') return Promise.all(keys.map((key) => KVStorageIndexedDB.done(store.get(key)))) } async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> { let remaining = options.limit ?? Infinity let last: string | undefined while (remaining > 0) { const batch = await this.readBatch(options, last, Math.min(remaining, this.scanBatchSize)) for (const entry of batch) { yield entry remaining-- } if (batch.length < this.scanBatchSize) return last = batch.at(-1)![0] } } /** Closes the database connection. */ async close(): Promise { ;(await this.db).close() } protected async write(changes: KVChange[]): Promise { const store = await this.store('readwrite') for (const { key, value } of changes) { if (value === undefined) store.delete(key) else store.put(value, key) } await KVStorageIndexedDB.committed(store.transaction) } /** Up to `count` entries after `last` (exclusive), in scan order, within one transaction. */ private async readBatch(options: ScanOptions, last: string | undefined, count: number): Promise<[string, unknown][]> { const reverse = options.reverse ?? false // The upper bound is always exclusive (`lt`, or the last key seen in reverse); the lower bound // is exclusive only when resuming forwards after `last`. const lower = reverse ? options.gte : (last ?? options.gte) const upper = reverse ? (last ?? options.lt) : options.lt const lowerOpen = !reverse && last !== undefined if (lower !== undefined && upper !== undefined && lower >= upper) return [] let range: IDBKeyRange | undefined if (lower !== undefined && upper !== undefined) range = IDBKeyRange.bound(lower, upper, lowerOpen, true) else if (lower !== undefined) range = IDBKeyRange.lowerBound(lower, lowerOpen) else if (upper !== undefined) range = IDBKeyRange.upperBound(upper, true) const store = await this.store('readonly') const out: [string, unknown][] = [] const request = options.values ? store.openCursor(range, reverse ? 'prev' : 'next') : store.openKeyCursor(range, reverse ? 'prev' : 'next') return new Promise((resolve, reject) => { request.onerror = () => reject(request.error) request.onsuccess = () => { const cursor = request.result if (!cursor || out.length >= count) return resolve(out) out.push([cursor.key as string, options.values ? (cursor as IDBCursorWithValue).value : undefined]) cursor.continue() } }) } private async store(mode: IDBTransactionMode): Promise { return (await this.db).transaction(this.storeName, mode).objectStore(this.storeName) } private static done(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result) request.onerror = () => reject(request.error) }) } private static committed(transaction: IDBTransaction): Promise { return new Promise((resolve, reject) => { transaction.oncomplete = () => resolve() transaction.onerror = transaction.onabort = () => reject(transaction.error) }) } }