import {openDB, deleteDB} from "idb" import type {IDBPDatabase} from "idb" import {writable} from "svelte/store" import type {Unsubscriber} from "svelte/store" import {call, defer} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import {withGetter} from "@welshman/store" export type IDBAdapter = { name: string keyPath: string[] init: (table: IDBTable) => Promise } export type IDBAdapters = IDBAdapter[] export enum IDBStatus { Ready = "ready", Closed = "closed", Opening = "opening", Closing = "closing", Initial = "initial", } export type IDBOptions = { name: string version: number } export class IDB { idbp: Maybe> ready: Maybe> unsubscribers: Maybe status = IDBStatus.Initial constructor(readonly options: IDBOptions) {} init(adapters: IDBAdapters) { if (this.status !== IDBStatus.Initial) { throw new Error(`Database re-initialized while ${this.status}`) } this.status = IDBStatus.Opening this.idbp = openDB(this.options.name, this.options.version, { upgrade(idbDb: IDBPDatabase) { const names = new Set(adapters.map(a => a.name)) for (const table of idbDb.objectStoreNames) { if (!names.has(table)) { idbDb.deleteObjectStore(table) } } for (const {name, keyPath} of adapters) { try { idbDb.createObjectStore(name, {keyPath}) } catch (e) { console.warn(e) } } }, blocked() {}, blocking() {}, }) this.ready = this.idbp.then(async idbp => { window.addEventListener("beforeunload", () => idbp.close()) this.unsubscribers = await Promise.all(adapters.map(({name, init}) => init(this.table(name)))) this.status = IDBStatus.Ready }) } table = (name: string) => new IDBTable(this, name) _withIDBP = async (f: (db: IDBPDatabase) => Promise) => { if (this.status === IDBStatus.Initial) { throw new Error("Database was accessed in initial state") } // If we're closing, ignore any lingering requests if ([IDBStatus.Closed, IDBStatus.Closing].includes(this.status)) return return f(await this.idbp) } getAll = async (table: string): Promise => this._withIDBP(async idbp => { const tx = idbp.transaction(table, "readwrite") const store = tx.objectStore(table) const result = await store.getAll() await tx.done return result }) bulkPut = async (table: string, data: Iterable) => this._withIDBP(async idbp => { const tx = idbp.transaction(table, "readwrite") const store = tx.objectStore(table) await Promise.all( Array.from(data).map(item => { try { store.put(item) } catch (e) { console.error(e, item) } }), ) await tx.done }) bulkDelete = async (table: string, ids: Iterable) => this._withIDBP(async idbp => { const tx = idbp.transaction(table, "readwrite") const store = tx.objectStore(table) await Promise.all(Array.from(ids).map(id => store.delete(id))) await tx.done }) close = () => this._withIDBP(async idbp => { this.unsubscribers!.forEach(call) this.status = IDBStatus.Closing await idbp.close() // Allow the caller to call reset and re-init immediately if (this.status === IDBStatus.Closing) { this.idbp = undefined this.ready = undefined this.unsubscribers = undefined this.status = IDBStatus.Closed } }) clear = async () => { await this.close() await deleteDB(this.options.name, { blocked() {}, }) } reset = () => { if (![IDBStatus.Closing, IDBStatus.Closed].includes(this.status)) { throw new Error("Database reset when not closed") } this.status = IDBStatus.Initial } } export class IDBTable { constructor( readonly db: IDB, readonly name: string, ) {} getAll = () => this.db.getAll(this.name) bulkPut = (data: Iterable) => this.db.bulkPut(this.name, data) bulkDelete = (ids: Iterable) => this.db.bulkDelete(this.name, ids) }