import type { AsyncStore, Merge, Result, Selection, Selector, } from "./cache.ts"; export interface IDBStoreAddress { name: string; store: string; version: number; } export interface IDBStoreFormat< Model extends object, Address, EncodedValue extends object, EncodedKey extends IDBValidKey, > { value: Codec; key: Encoder; address: Encoder; } export const open = < Model extends object, Address, EncodedValue extends object, EncodedKey extends IDBValidKey, >( address: IDBStoreAddress, format: IDBStoreFormat, ): AsyncStore => Store.open(new Session(address), format); /** * Returns `true` is IDB is supported on the given runtime. */ export const available = () => typeof globalThis?.indexedDB?.open === "function"; export const swap = ( store: IDBObjectStore, key: IDBValidKey, update: (value: T | undefined) => T | undefined, ): Promise> => new Promise((success, fail) => { then( store.get(key) as IDBRequest, (value) => { const next = update(value); if (value !== next) { then( store.put(next, key), () => success({ ok: next ?? NOT_FOUND }), (error) => fail({ error }), ); } else if (next === undefined) { then( store.delete(key), () => success({ ok: NOT_FOUND }), (error) => fail({ error }), ); } else { success({ ok: next }); } }, (error: DOMException) => { fail({ error }); }, ); }); export const NOT_FOUND = Symbol("NOT_FOUND"); export const get = ( store: IDBObjectStore, key: IDBValidKey, ): Promise> => new Promise((success, fail) => { then( store.get(key) as IDBRequest, (value) => success({ ok: value ?? NOT_FOUND }), (error) => fail({ error }), ); }); const then = ( request: IDBRequest, onsuccess: (value: T) => void, onfail: (value: DOMException) => void, ) => { if (request.readyState === "done") { if (request.error) { onfail(request.error); } else { onsuccess(request.result); } } else { request.addEventListener("success", (_) => onsuccess(request.result), { once: true, }); request.addEventListener("error", (_) => onfail(request.error!), { once: true, }); } }; const wait = ( request: IDBRequest, ): Promise> => new Promise((success, fail) => then(request, (ok) => success({ ok }), (error) => fail({ error })) ); class StoreError extends Error { override name = "StoreError" as const; constructor(message: string, override cause: Error) { super(message); } } export interface Encoder { encode: (value: Decoded) => Encoded; } export interface Decoder { decode: (value: Encoded) => Decoded; } export interface Codec extends Encoder, Decoder { } /** * A general interface for working with IndexedDB object store. It only provides * a basic transaction interface for reading and writing data in a single transaction. */ export class Session { connection: Promise>; constructor(public address: IDBStoreAddress) { this.upgrade = this.upgrade.bind(this); this.connection = this.open(); } open() { const request = indexedDB.open(this.address.name, this.address.version); request.onupgradeneeded = this.upgrade; return this.connection = wait(request); } upgrade(event: IDBVersionChangeEvent) { (event.target as IDBOpenDBRequest).result.createObjectStore( this.address.store, ); } async transact( mode: IDBTransactionMode, transact: (store: IDBObjectStore) => Promise>, ): Promise> { const { ok: session, error } = await this.connection; if (session) { return transact( session.transaction(this.address.store, mode).objectStore( this.address.store, ), ); } else { return { error: new StoreError("Opening database failed", error) }; } } } /** * An API for working with IndexedDB object store that allows batch reads and * batch writes. */ export class Store< Model extends object, Address, EncodedValue extends object, EncodedKey extends IDBValidKey, > implements AsyncStore { static open< Model extends object, Address, EncodedValue extends object, EncodedKey extends IDBValidKey, >( session: Session, format: IDBStoreFormat, ): AsyncStore { return new this( session, format, ); } constructor( public session: Session, public format: IDBStoreFormat, ) { } /** * Loads records from the underlying store and returns a map of records keyed * by keys in the provided selector. Entries that did not exist in the store * will not be included in the selection. */ pull( selector: Selector
, ): Promise, StoreError>> { const { key, value } = this.format; return this.session.transact, StoreError>( "readonly", async (store) => { const addresses = [...selector]; const promises = []; for (const address of addresses) { promises.push(get(store, key.encode(address))); } const results = await Promise.all(promises); const selection = new Map(); for (const [at, { ok, error }] of results.entries()) { if (error) { return { error: new StoreError("Failed to load", error) }; } else if (ok !== NOT_FOUND) { selection.set(addresses[at], value.decode(ok)); } } return { ok: selection }; }, ); } merge( entries: Iterable, merge: Merge, ): Promise> { const { key: _, value, address } = this.format; return this.session.transact( "readwrite", async (store) => { const promises = []; for (const entry of entries) { promises.push(swap( store, address.encode(entry), (local) => { const before = local ? value.decode(local) : undefined; const after = merge(before, entry); return before === after ? local : after === undefined ? after : value.encode(after); }, )); } const results = await Promise.all(promises); for (const result of results) { if (result.error) { return { error: new StoreError(result.error.message, result.error), }; } } return { ok: {} }; }, ); } }