590 lines
16 KiB
TypeScript
590 lines
16 KiB
TypeScript
/*
|
|
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 <http://www.gnu.org/licenses/>
|
|
*/
|
|
|
|
/**
|
|
* Database query abstractions.
|
|
* @module Query
|
|
* @author Florian Dold
|
|
*/
|
|
|
|
/**
|
|
* Imports.
|
|
*/
|
|
import { openPromise } from "./promiseUtils.js";
|
|
import {
|
|
IDBRequest,
|
|
IDBTransaction,
|
|
IDBValidKey,
|
|
IDBDatabase,
|
|
IDBFactory,
|
|
IDBVersionChangeEvent,
|
|
IDBCursor,
|
|
IDBKeyPath,
|
|
} from "@gnu-taler/idb-bridge";
|
|
import { Logger } from "@gnu-taler/taler-util";
|
|
|
|
const logger = new Logger("query.ts");
|
|
|
|
/**
|
|
* Exception that should be thrown by client code to abort a transaction.
|
|
*/
|
|
export const TransactionAbort = Symbol("transaction_abort");
|
|
|
|
/**
|
|
* 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<any> {
|
|
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);
|
|
};
|
|
});
|
|
}
|
|
|
|
type CursorResult<T> = CursorEmptyResult<T> | CursorValueResult<T>;
|
|
|
|
interface CursorEmptyResult<T> {
|
|
hasValue: false;
|
|
}
|
|
|
|
interface CursorValueResult<T> {
|
|
hasValue: true;
|
|
value: T;
|
|
}
|
|
|
|
class ResultStream<T> {
|
|
private currentPromise: Promise<void>;
|
|
private gotCursorEnd = false;
|
|
private awaitingResult = false;
|
|
|
|
constructor(private req: IDBRequest) {
|
|
this.awaitingResult = true;
|
|
let p = openPromise<void>();
|
|
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<void>();
|
|
this.currentPromise = p.promise;
|
|
} else {
|
|
this.gotCursorEnd = true;
|
|
p.resolve();
|
|
}
|
|
};
|
|
req.onerror = () => {
|
|
p.reject(req.error);
|
|
};
|
|
}
|
|
|
|
async toArray(): Promise<T[]> {
|
|
const arr: T[] = [];
|
|
while (true) {
|
|
const x = await this.next();
|
|
if (x.hasValue) {
|
|
arr.push(x.value);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
async map<R>(f: (x: T) => R): Promise<R[]> {
|
|
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<void>): Promise<void> {
|
|
while (true) {
|
|
const x = await this.next();
|
|
if (x.hasValue) {
|
|
await f(x.value);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
async forEach(f: (x: T) => void): Promise<void> {
|
|
while (true) {
|
|
const x = await this.next();
|
|
if (x.hasValue) {
|
|
f(x.value);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
async filter(f: (x: T) => boolean): Promise<T[]> {
|
|
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<CursorResult<T>> {
|
|
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 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<IDBDatabase> {
|
|
return new Promise<IDBDatabase>((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 interface IndexDescriptor {
|
|
name: string;
|
|
keyPath: IDBKeyPath | IDBKeyPath[];
|
|
multiEntry?: boolean;
|
|
}
|
|
|
|
export interface StoreDescriptor<RecordType> {
|
|
_dummy: undefined & RecordType;
|
|
name: string;
|
|
keyPath?: IDBKeyPath | IDBKeyPath[];
|
|
autoIncrement?: boolean;
|
|
}
|
|
|
|
export interface StoreOptions {
|
|
keyPath?: IDBKeyPath | IDBKeyPath[];
|
|
autoIncrement?: boolean;
|
|
}
|
|
|
|
export function describeContents<RecordType = never>(
|
|
name: string,
|
|
options: StoreOptions,
|
|
): StoreDescriptor<RecordType> {
|
|
return { name, keyPath: options.keyPath, _dummy: undefined as any };
|
|
}
|
|
|
|
export function describeIndex(
|
|
name: string,
|
|
keyPath: IDBKeyPath | IDBKeyPath[],
|
|
options: IndexOptions = {},
|
|
): IndexDescriptor {
|
|
return {
|
|
keyPath,
|
|
name,
|
|
multiEntry: options.multiEntry,
|
|
};
|
|
}
|
|
|
|
interface IndexReadOnlyAccessor<RecordType> {
|
|
iter(query?: IDBValidKey): ResultStream<RecordType>;
|
|
get(query: IDBValidKey): Promise<RecordType | undefined>;
|
|
}
|
|
|
|
type GetIndexReadOnlyAccess<RecordType, IndexMap> = {
|
|
[P in keyof IndexMap]: IndexReadOnlyAccessor<RecordType>;
|
|
};
|
|
|
|
interface IndexReadWriteAccessor<RecordType> {
|
|
iter(query: IDBValidKey): ResultStream<RecordType>;
|
|
get(query: IDBValidKey): Promise<RecordType | undefined>;
|
|
}
|
|
|
|
type GetIndexReadWriteAccess<RecordType, IndexMap> = {
|
|
[P in keyof IndexMap]: IndexReadWriteAccessor<RecordType>;
|
|
};
|
|
|
|
export interface StoreReadOnlyAccessor<RecordType, IndexMap> {
|
|
get(key: IDBValidKey): Promise<RecordType | undefined>;
|
|
iter(query?: IDBValidKey): ResultStream<RecordType>;
|
|
indexes: GetIndexReadOnlyAccess<RecordType, IndexMap>;
|
|
}
|
|
|
|
export interface StoreReadWriteAccessor<RecordType, IndexMap> {
|
|
get(key: IDBValidKey): Promise<RecordType | undefined>;
|
|
iter(query?: IDBValidKey): ResultStream<RecordType>;
|
|
put(r: RecordType): Promise<void>;
|
|
add(r: RecordType): Promise<void>;
|
|
delete(key: IDBValidKey): Promise<void>;
|
|
indexes: GetIndexReadWriteAccess<RecordType, IndexMap>;
|
|
}
|
|
|
|
export interface StoreWithIndexes<
|
|
SD extends StoreDescriptor<unknown>,
|
|
IndexMap
|
|
> {
|
|
store: SD;
|
|
indexMap: IndexMap;
|
|
|
|
/**
|
|
* Type marker symbol, to check that the descriptor
|
|
* has been created through the right function.
|
|
*/
|
|
mark: Symbol;
|
|
}
|
|
|
|
export type GetRecordType<T> = T extends StoreDescriptor<infer X> ? X : unknown;
|
|
|
|
const storeWithIndexesSymbol = Symbol("StoreWithIndexesMark");
|
|
|
|
export function describeStore<SD extends StoreDescriptor<unknown>, IndexMap>(
|
|
s: SD,
|
|
m: IndexMap,
|
|
): StoreWithIndexes<SD, IndexMap> {
|
|
return {
|
|
store: s,
|
|
indexMap: m,
|
|
mark: storeWithIndexesSymbol,
|
|
};
|
|
}
|
|
|
|
export type GetReadOnlyAccess<BoundStores> = {
|
|
[P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes<
|
|
infer SD,
|
|
infer IM
|
|
>
|
|
? StoreReadOnlyAccessor<GetRecordType<SD>, IM>
|
|
: unknown;
|
|
};
|
|
|
|
export type GetReadWriteAccess<BoundStores> = {
|
|
[P in keyof BoundStores]: BoundStores[P] extends StoreWithIndexes<
|
|
infer SD,
|
|
infer IM
|
|
>
|
|
? StoreReadWriteAccessor<GetRecordType<SD>, IM>
|
|
: unknown;
|
|
};
|
|
|
|
type ReadOnlyTransactionFunction<BoundStores, T> = (
|
|
t: GetReadOnlyAccess<BoundStores>,
|
|
) => Promise<T>;
|
|
|
|
type ReadWriteTransactionFunction<BoundStores, T> = (
|
|
t: GetReadWriteAccess<BoundStores>,
|
|
) => Promise<T>;
|
|
|
|
export interface TransactionContext<BoundStores> {
|
|
runReadWrite<T>(f: ReadWriteTransactionFunction<BoundStores, T>): Promise<T>;
|
|
runReadOnly<T>(f: ReadOnlyTransactionFunction<BoundStores, T>): Promise<T>;
|
|
}
|
|
|
|
type CheckDescriptor<T> = T extends StoreWithIndexes<infer SD, infer IM>
|
|
? StoreWithIndexes<SD, IM>
|
|
: unknown;
|
|
|
|
type GetPickerType<F, SM> = F extends (x: SM) => infer Out
|
|
? { [P in keyof Out]: CheckDescriptor<Out[P]> }
|
|
: unknown;
|
|
|
|
function runTx<Arg, Res>(
|
|
tx: IDBTransaction,
|
|
arg: Arg,
|
|
f: (t: Arg) => Promise<Res>,
|
|
): Promise<Res> {
|
|
const stack = Error("Failed transaction was started here.");
|
|
return new Promise((resolve, reject) => {
|
|
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("Transaction aborted (no error)");
|
|
}
|
|
reject(TransactionAbort);
|
|
};
|
|
const resP = Promise.resolve().then(() => f(arg));
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
|
|
function makeReadContext(
|
|
tx: IDBTransaction,
|
|
storePick: { [n: string]: StoreWithIndexes<any, any> },
|
|
): any {
|
|
const ctx: { [s: string]: StoreReadOnlyAccessor<any, any> } = {};
|
|
for (const storeAlias in storePick) {
|
|
const indexes: { [s: string]: IndexReadOnlyAccessor<any> } = {};
|
|
const swi = storePick[storeAlias];
|
|
const storeName = swi.store.name;
|
|
for (const indexAlias in storePick[storeAlias].indexMap) {
|
|
const indexDescriptor: IndexDescriptor =
|
|
storePick[storeAlias].indexMap[indexAlias];
|
|
const indexName = indexDescriptor.name;
|
|
indexes[indexAlias] = {
|
|
get(key) {
|
|
const req = tx.objectStore(storeName).index(indexName).get(key);
|
|
return requestToPromise(req);
|
|
},
|
|
iter(query) {
|
|
const req = tx
|
|
.objectStore(storeName)
|
|
.index(indexName)
|
|
.openCursor(query);
|
|
return new ResultStream<any>(req);
|
|
},
|
|
};
|
|
}
|
|
ctx[storeAlias] = {
|
|
indexes,
|
|
get(key) {
|
|
const req = tx.objectStore(storeName).get(key);
|
|
return requestToPromise(req);
|
|
},
|
|
iter(query) {
|
|
const req = tx.objectStore(storeName).openCursor(query);
|
|
return new ResultStream<any>(req);
|
|
},
|
|
};
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
function makeWriteContext(
|
|
tx: IDBTransaction,
|
|
storePick: { [n: string]: StoreWithIndexes<any, any> },
|
|
): any {
|
|
const ctx: { [s: string]: StoreReadWriteAccessor<any, any> } = {};
|
|
for (const storeAlias in storePick) {
|
|
const indexes: { [s: string]: IndexReadWriteAccessor<any> } = {};
|
|
const swi = storePick[storeAlias];
|
|
const storeName = swi.store.name;
|
|
for (const indexAlias in storePick[storeAlias].indexMap) {
|
|
const indexDescriptor: IndexDescriptor =
|
|
storePick[storeAlias].indexMap[indexAlias];
|
|
const indexName = indexDescriptor.name;
|
|
indexes[indexAlias] = {
|
|
get(key) {
|
|
const req = tx.objectStore(storeName).index(indexName).get(key);
|
|
return requestToPromise(req);
|
|
},
|
|
iter(query) {
|
|
const req = tx
|
|
.objectStore(storeName)
|
|
.index(indexName)
|
|
.openCursor(query);
|
|
return new ResultStream<any>(req);
|
|
},
|
|
};
|
|
}
|
|
ctx[storeAlias] = {
|
|
indexes,
|
|
get(key) {
|
|
const req = tx.objectStore(storeName).get(key);
|
|
return requestToPromise(req);
|
|
},
|
|
iter(query) {
|
|
const req = tx.objectStore(storeName).openCursor(query);
|
|
return new ResultStream<any>(req);
|
|
},
|
|
add(r) {
|
|
const req = tx.objectStore(storeName).add(r);
|
|
return requestToPromise(req);
|
|
},
|
|
put(r) {
|
|
const req = tx.objectStore(storeName).put(r);
|
|
return requestToPromise(req);
|
|
},
|
|
delete(k) {
|
|
const req = tx.objectStore(storeName).delete(k);
|
|
return requestToPromise(req);
|
|
},
|
|
};
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
/**
|
|
* Type-safe access to a database with a particular store map.
|
|
*
|
|
* A store map is the metadata that describes the store.
|
|
*/
|
|
export class DbAccess<StoreMap> {
|
|
constructor(private db: IDBDatabase, private stores: StoreMap) {}
|
|
|
|
mktx<
|
|
PickerType extends (x: StoreMap) => unknown,
|
|
BoundStores extends GetPickerType<PickerType, StoreMap>
|
|
>(f: PickerType): TransactionContext<BoundStores> {
|
|
const storePick = f(this.stores) as any;
|
|
if (typeof storePick !== "object" || storePick === null) {
|
|
throw Error();
|
|
}
|
|
const storeNames: string[] = [];
|
|
for (const storeAlias of Object.keys(storePick)) {
|
|
const swi = (storePick as any)[storeAlias] as StoreWithIndexes<any, any>;
|
|
if (swi.mark !== storeWithIndexesSymbol) {
|
|
throw Error("invalid store descriptor returned from selector function");
|
|
}
|
|
storeNames.push(swi.store.name);
|
|
}
|
|
|
|
const runReadOnly = <T>(
|
|
txf: ReadOnlyTransactionFunction<BoundStores, T>,
|
|
): Promise<T> => {
|
|
const tx = this.db.transaction(storeNames, "readonly");
|
|
const readContext = makeReadContext(tx, storePick);
|
|
return runTx(tx, readContext, txf);
|
|
};
|
|
|
|
const runReadWrite = <T>(
|
|
txf: ReadWriteTransactionFunction<BoundStores, T>,
|
|
): Promise<T> => {
|
|
const tx = this.db.transaction(storeNames, "readwrite");
|
|
const writeContext = makeWriteContext(tx, storePick);
|
|
return runTx(tx, writeContext, txf);
|
|
};
|
|
|
|
return {
|
|
runReadOnly,
|
|
runReadWrite,
|
|
};
|
|
}
|
|
}
|