/* This file is part of TALER (C) 2016 GNUnet e.V. TALER is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. TALER is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with TALER; see the file COPYING. If not, see */ /** * Database query abstractions. * @module Query * @author Florian Dold */ /** * Imports. */ import { openPromise } from "./promiseUtils"; import { IDBRequest, IDBTransaction, IDBValidKey, IDBDatabase, IDBFactory, IDBVersionChangeEvent, Event, IDBCursor, } from "idb-bridge"; import { Logger } from "./logging"; const logger = new Logger("query.ts"); /** * Exception that should be thrown by client code to abort a transaction. */ export const TransactionAbort = Symbol("transaction_abort"); export interface StoreParams { validator?: (v: T) => T; autoIncrement?: boolean; keyPath?: string | string[] | null; /** * Database version that this store was added in, or * undefined if added in the first version. */ versionAdded?: number; } /** * Definition of an object store. */ export class Store { constructor(public name: N, public storeParams?: StoreParams) {} } /** * Options for an index. */ export interface IndexOptions { /** * If true and the path resolves to an array, create an index entry for * each member of the array (instead of one index entry containing the full array). * * Defaults to false. */ multiEntry?: boolean; /** * Database version that this store was added in, or * undefined if added in the first version. */ versionAdded?: number; } function requestToPromise(req: IDBRequest): Promise { const stack = Error("Failed request was started here."); return new Promise((resolve, reject) => { req.onsuccess = () => { resolve(req.result); }; req.onerror = () => { console.error("error in DB request", req.error); reject(req.error); console.error("Request failed:", stack); }; }); } function transactionToPromise(tx: IDBTransaction): Promise { const stack = Error("Failed transaction was started here."); return new Promise((resolve, reject) => { tx.onabort = () => { reject(TransactionAbort); }; tx.oncomplete = () => { resolve(); }; tx.onerror = () => { console.error("Transaction failed:", stack); reject(tx.error); }; }); } function applyMutation( req: IDBRequest, f: (x: T) => T | undefined, ): Promise { return new Promise((resolve, reject) => { req.onsuccess = () => { const cursor = req.result; if (cursor) { const val = cursor.value; const modVal = f(val); if (modVal !== undefined && modVal !== null) { const req2: IDBRequest = cursor.update(modVal); req2.onerror = () => { reject(req2.error); }; req2.onsuccess = () => { cursor.continue(); }; } else { cursor.continue(); } } else { resolve(); } }; req.onerror = () => { reject(req.error); }; }); } type CursorResult = CursorEmptyResult | CursorValueResult; interface CursorEmptyResult { hasValue: false; } interface CursorValueResult { hasValue: true; value: T; } class ResultStream { private currentPromise: Promise; private gotCursorEnd = false; private awaitingResult = false; constructor(private req: IDBRequest) { this.awaitingResult = true; let p = openPromise(); this.currentPromise = p.promise; req.onsuccess = () => { if (!this.awaitingResult) { throw Error("BUG: invariant violated"); } const cursor = req.result; if (cursor) { this.awaitingResult = false; p.resolve(); p = openPromise(); this.currentPromise = p.promise; } else { this.gotCursorEnd = true; p.resolve(); } }; req.onerror = () => { p.reject(req.error); }; } async toArray(): Promise { const arr: T[] = []; while (true) { const x = await this.next(); if (x.hasValue) { arr.push(x.value); } else { break; } } return arr; } async map(f: (x: T) => R): Promise { const arr: R[] = []; while (true) { const x = await this.next(); if (x.hasValue) { arr.push(f(x.value)); } else { break; } } return arr; } async forEachAsync(f: (x: T) => Promise): Promise { while (true) { const x = await this.next(); if (x.hasValue) { await f(x.value); } else { break; } } } async forEach(f: (x: T) => void): Promise { while (true) { const x = await this.next(); if (x.hasValue) { f(x.value); } else { break; } } } async filter(f: (x: T) => boolean): Promise { const arr: T[] = []; while (true) { const x = await this.next(); if (x.hasValue) { if (f(x.value)) { arr.push(x.value); } } else { break; } } return arr; } async next(): Promise> { if (this.gotCursorEnd) { return { hasValue: false }; } if (!this.awaitingResult) { const cursor: IDBCursor | undefined = this.req.result; if (!cursor) { throw Error("assertion failed"); } this.awaitingResult = true; cursor.continue(); } await this.currentPromise; if (this.gotCursorEnd) { return { hasValue: false }; } const cursor = this.req.result; if (!cursor) { throw Error("assertion failed"); } return { hasValue: true, value: cursor.value }; } } export type AnyStoreMap = { [s: string]: Store }; type StoreName = S extends Store ? N : never; type StoreContent = S extends Store ? R : never; type IndexRecord = Ind extends Index ? R : never; type InferStore = S extends Store ? Store : never; type InferIndex = Ind extends Index< infer StN, infer IndN, infer KT, infer RT > ? Index : never; export class TransactionHandle> { constructor(private tx: IDBTransaction) {} put( store: S, value: StoreContent, key?: any, ): Promise { const req = this.tx.objectStore(store.name).put(value, key); return requestToPromise(req); } add( store: S, value: StoreContent, key?: any, ): Promise { const req = this.tx.objectStore(store.name).add(value, key); return requestToPromise(req); } get( store: S, key: any, ): Promise | undefined> { const req = this.tx.objectStore(store.name).get(key); return requestToPromise(req); } getIndexed< St extends StoreTypes, Ind extends Index, string, any, any> >(index: InferIndex, key: any): Promise | undefined> { const req = this.tx .objectStore(index.storeName) .index(index.indexName) .get(key); return requestToPromise(req); } iter>( store: St, key?: any, ): ResultStream> { const req = this.tx.objectStore(store.name).openCursor(key); return new ResultStream>(req); } iterIndexed< St extends InferStore, Ind extends InferIndex, string, any, any>> >(index: Ind, key?: any): ResultStream> { const req = this.tx .objectStore(index.storeName) .index(index.indexName) .openCursor(key); return new ResultStream>(req); } delete( store: InferStore, key: any, ): Promise { const req = this.tx.objectStore(store.name).delete(key); return requestToPromise(req); } mutate( store: InferStore, key: any, f: (x: StoreContent) => StoreContent | undefined, ): Promise { const req = this.tx.objectStore(store.name).openCursor(key); return applyMutation(req, f); } } function runWithTransaction>( db: IDBDatabase, stores: StoreTypes[], f: (t: TransactionHandle) => Promise, mode: "readonly" | "readwrite", ): Promise { const stack = Error("Failed transaction was started here."); return new Promise((resolve, reject) => { const storeName = stores.map((x) => x.name); const tx = db.transaction(storeName, mode); let funResult: any = undefined; let gotFunResult = false; tx.oncomplete = () => { // This is a fatal error: The transaction completed *before* // the transaction function returned. Likely, the transaction // function waited on a promise that is *not* resolved in the // microtask queue, thus triggering the auto-commit behavior. // Unfortunately, the auto-commit behavior of IDB can't be switched // of. There are some proposals to add this functionality in the future. if (!gotFunResult) { const msg = "BUG: transaction closed before transaction function returned"; console.error(msg); reject(Error(msg)); } resolve(funResult); }; tx.onerror = () => { logger.error("error in transaction"); logger.error(`${stack}`); }; tx.onabort = () => { if (tx.error) { logger.error("Transaction aborted with error:", tx.error); } else { logger.error("Trasaction aborted (no error)"); } reject(TransactionAbort); }; const th = new TransactionHandle(tx); const resP = Promise.resolve().then(() => f(th)); resP .then((result) => { gotFunResult = true; funResult = result; }) .catch((e) => { if (e == TransactionAbort) { logger.trace("aborting transaction"); } else { console.error("Transaction failed:", e); console.error(stack); tx.abort(); } }) .catch((e) => { console.error("fatal: aborting transaction failed", e); }); }); } /** * Definition of an index. */ export class Index< StoreName extends string, IndexName extends string, S extends IDBValidKey, T > { /** * Name of the store that this index is associated with. */ storeName: string; /** * Options to use for the index. */ options: IndexOptions; constructor( s: Store, public indexName: IndexName, public keyPath: string | string[], options?: IndexOptions, ) { const defaultOptions = { multiEntry: false, }; this.options = { ...defaultOptions, ...(options || {}) }; this.storeName = s.name; } /** * We want to have the key type parameter in use somewhere, * because otherwise the compiler complains. In iterIndex the * key type is pretty useful. */ protected _dummyKey: S | undefined; } /** * Return a promise that resolves to the opened IndexedDB database. */ export function openDatabase( idbFactory: IDBFactory, databaseName: string, databaseVersion: number, onVersionChange: () => void, onUpgradeNeeded: ( db: IDBDatabase, oldVersion: number, newVersion: number, upgradeTransaction: IDBTransaction, ) => void, ): Promise { return new Promise((resolve, reject) => { const req = idbFactory.open(databaseName, databaseVersion); req.onerror = (e) => { logger.error("database error", e); reject(new Error("database error")); }; req.onsuccess = (e) => { req.result.onversionchange = (evt: IDBVersionChangeEvent) => { logger.info( `handling live db version change from ${evt.oldVersion} to ${evt.newVersion}`, ); req.result.close(); onVersionChange(); }; resolve(req.result); }; req.onupgradeneeded = (e) => { const db = req.result; const newVersion = e.newVersion; if (!newVersion) { throw Error("upgrade needed, but new version unknown"); } const transaction = req.transaction; if (!transaction) { throw Error("no transaction handle available in upgrade handler"); } onUpgradeNeeded(db, e.oldVersion, newVersion, transaction); }; }); } export class Database { constructor(private db: IDBDatabase, stores: StoreMap) {} static deleteDatabase(idbFactory: IDBFactory, dbName: string): void { idbFactory.deleteDatabase(dbName); } async exportDatabase(): Promise { const db = this.db; const dump = { name: db.name, stores: {} as { [s: string]: any }, version: db.version, }; return new Promise((resolve, reject) => { const tx = db.transaction(Array.from(db.objectStoreNames)); tx.addEventListener("complete", () => { resolve(dump); }); // tslint:disable-next-line:prefer-for-of for (let i = 0; i < db.objectStoreNames.length; i++) { const name = db.objectStoreNames[i]; const storeDump = {} as { [s: string]: any }; dump.stores[name] = storeDump; tx.objectStore(name) .openCursor() .addEventListener("success", (e: Event) => { const cursor = (e.target as any).result; if (cursor) { storeDump[cursor.key] = cursor.value; cursor.continue(); } }); } }); } importDatabase(dump: any): Promise { const db = this.db; logger.info("importing db", dump); return new Promise((resolve, reject) => { const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite"); if (dump.stores) { for (const storeName in dump.stores) { const objects = []; const dumpStore = dump.stores[storeName]; for (const key in dumpStore) { objects.push(dumpStore[key]); } logger.info(`importing ${objects.length} records into ${storeName}`); const store = tx.objectStore(storeName); for (const obj of objects) { store.put(obj); } } } tx.addEventListener("complete", () => { resolve(); }); }); } async get( store: S, key: IDBValidKey, ): Promise | undefined> { const tx = this.db.transaction([store.name], "readonly"); const req = tx.objectStore(store.name).get(key); const v = await requestToPromise(req); await transactionToPromise(tx); return v; } async getIndexed>( index: Ind, key: IDBValidKey, ): Promise | undefined> { const tx = this.db.transaction([index.storeName], "readonly"); const req = tx.objectStore(index.storeName).index(index.indexName).get(key); const v = await requestToPromise(req); await transactionToPromise(tx); return v; } async put>( store: St, value: StoreContent, key?: IDBValidKey, ): Promise { const tx = this.db.transaction([store.name], "readwrite"); const req = tx.objectStore(store.name).put(value, key); const v = await requestToPromise(req); await transactionToPromise(tx); return v; } async mutate( store: Store, key: IDBValidKey, f: (x: T) => T | undefined, ): Promise { const tx = this.db.transaction([store.name], "readwrite"); const req = tx.objectStore(store.name).openCursor(key); await applyMutation(req, f); await transactionToPromise(tx); } iter(store: Store): ResultStream { const tx = this.db.transaction([store.name], "readonly"); const req = tx.objectStore(store.name).openCursor(); return new ResultStream(req); } iterIndex>( index: InferIndex, query?: any, ): ResultStream> { const tx = this.db.transaction([index.storeName], "readonly"); const req = tx .objectStore(index.storeName) .index(index.indexName) .openCursor(query); return new ResultStream>(req); } async runWithReadTransaction< T, N extends keyof StoreMap, StoreTypes extends StoreMap[N] >( stores: StoreTypes[], f: (t: TransactionHandle) => Promise, ): Promise { return runWithTransaction(this.db, stores, f, "readonly"); } async runWithWriteTransaction< T, N extends keyof StoreMap, StoreTypes extends StoreMap[N] >( stores: StoreTypes[], f: (t: TransactionHandle) => Promise, ): Promise { return runWithTransaction(this.db, stores, f, "readwrite"); } }