taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 796bf3f95aebf24d5a88d59c0df21d494c5d864d
parent ce49869a0bfe7e13b92b40caeba550cade97fa44
Author: Florian Dold <dold@taler.net>
Date:   Sun, 19 Jul 2026 23:48:34 +0200

wallet: add WalletDbHandle, so the wallet holds one database

The wallet state held an IndexedDB factory and an optional native handle
side by side, and call sites had to pick one; a wrong pick produced a
valid empty database instead of an error.  Backend choice now happens
once, where the handle is built.  wex.db is gone.

Diffstat:
Apackages/taler-wallet-core/src/dbtx-handle-impl.ts | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/dbtx-handle.ts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 133++++++++++++++++++++++++++++++-------------------------------------------------
Mpackages/taler-wallet-core/src/wallet.ts | 237+++++++++++++++++++++++++++----------------------------------------------------
4 files changed, 435 insertions(+), 237 deletions(-)

diff --git a/packages/taler-wallet-core/src/dbtx-handle-impl.ts b/packages/taler-wallet-core/src/dbtx-handle-impl.ts @@ -0,0 +1,219 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU 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. + + GNU 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 + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * The two WalletDbHandle implementations. + * + * Everything that differs between the IndexedDB emulation and the native + * sqlite database is confined to these two classes. + */ + +import { CancellationToken, WalletNotification } from "@gnu-taler/taler-util"; +import { BridgeIDBFactory, IDBDatabase } from "@gnu-taler/idb-bridge"; + +import { + applyFixups, + clearDatabase, + exportDb, + importDb, + openTalerDatabase, + WalletIndexedDbStoresV1, +} from "./db-indexeddb.js"; +import { WalletDbAccessStats, WalletDbHandle } from "./dbtx-handle.js"; +import { IdbWalletTransaction } from "./dbtx-indexeddb.js"; +import { + clearNativeSqliteWalletDb, + exportNativeSqliteDb, + importNativeSqliteDb, + NativeSqliteWalletDb, + runNativeSqliteWalletTx, +} from "./dbtx-sqlite.js"; +import { WalletDbTransaction } from "./dbtx.js"; +import { DbAccess, DbAccessImpl } from "./query.js"; + +/** + * WalletDbHandle over the IndexedDB emulation. + * + * Opens lazily: the wallet is constructed before it is initialised, and + * opening on construction would create a database file for a wallet that is + * never used. + */ +export class IdbWalletDbHandle implements WalletDbHandle { + readonly name = "indexeddb"; + + private idbHandle: IDBDatabase | undefined; + private dbAccess: DbAccess<typeof WalletIndexedDbStoresV1> | undefined; + + constructor( + private idbFactory: BridgeIDBFactory, + private notify: (n: WalletNotification) => void, + private getStats: () => WalletDbAccessStats | undefined, + ) {} + + /** + * Open the database if it is not open yet. + * + * Returns whether fixups changed anything, which the caller needs in order + * to decide whether wallet-level views have to be rebuilt. + */ + async ensureOpen(): Promise<{ fixupsApplied: number }> { + if (this.dbAccess) { + return { fixupsApplied: 0 }; + } + this.idbHandle = await openTalerDatabase(this.idbFactory, async () => {}); + this.dbAccess = this.makeAccess(); + const fixupsApplied = await applyFixups(this.dbAccess); + return { fixupsApplied }; + } + + private makeAccess(): DbAccess<typeof WalletIndexedDbStoresV1> { + if (!this.idbHandle) { + throw Error("wallet database is not open"); + } + return new DbAccessImpl( + this.idbHandle, + WalletIndexedDbStoresV1, + CancellationToken.CONTINUE, + (notifs: WalletNotification[]) => { + for (const n of notifs) { + this.notify(n); + } + }, + ); + } + + /** + * The raw DbAccess, for the two things that are IndexedDB concerns rather + * than database concerns: the fixup log, and the stored-backup database. + * + * Reachable only through this class, so generic code cannot pick it up by + * accident the way it could when the wallet state exposed a factory. + */ + async rawAccess(): Promise<DbAccess<typeof WalletIndexedDbStoresV1>> { + await this.ensureOpen(); + if (!this.dbAccess) { + throw Error("wallet database is not open"); + } + return this.dbAccess; + } + + /** + * The IndexedDB factory, for the stored-backup database, which is a + * separate database that has nothing to do with wallet records. + */ + factory(): BridgeIDBFactory { + return this.idbFactory; + } + + async runReadWriteTx<T>( + f: (tx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + const access = await this.rawAccess(); + return await access.runAllStoresReadWriteTx({}, async (mytx) => { + return await f(new IdbWalletTransaction(mytx)); + }); + } + + async exportDatabase(): Promise<any> { + await this.ensureOpen(); + return await exportDb(this.idbFactory); + } + + async importDatabase(dump: any): Promise<void> { + await this.ensureOpen(); + if (!this.idbHandle) { + throw Error("wallet database is not open"); + } + await importDb(this.idbHandle, dump); + // The imported records may predate any of the fixups, whatever this + // database had applied before. Clearing the log alone only schedules the + // repairs for the next open, which for a running wallet is never, so they + // are re-run here: a backup from before the status-enum digit fix imported + // cleanly and then crashed getTransactions on a value no enum member + // matched. Fixups are idempotent, so re-running them is safe. + const access = await this.rawAccess(); + await access.runAllStoresReadWriteTx({}, async (tx) => { + const fixups = await tx.fixups.getAll(); + for (const fx of fixups) { + await tx.fixups.delete(fx.fixupName); + } + }); + await applyFixups(access); + } + + async clearDatabase(): Promise<void> { + await this.ensureOpen(); + if (!this.idbHandle) { + throw Error("wallet database is not open"); + } + await clearDatabase(this.idbHandle); + } + + getAccessStats(): WalletDbAccessStats | undefined { + return this.getStats(); + } + + async close(): Promise<void> { + this.idbHandle?.close(); + this.idbHandle = undefined; + this.dbAccess = undefined; + } +} + +/** + * WalletDbHandle over the native sqlite database. + * + * No fixups: the schema is clean-slate and evolves through schemaMigrations, + * so there is no legacy record shape for a fixup to repair. + */ +export class SqliteWalletDbHandle implements WalletDbHandle { + readonly name = "sqlite"; + + constructor( + private ndb: NativeSqliteWalletDb, + private notify: (n: WalletNotification) => void, + ) {} + + async runReadWriteTx<T>( + f: (tx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + return await runNativeSqliteWalletTx( + this.ndb, + (n) => this.notify(n), + async (tx) => await f(tx), + ); + } + + async exportDatabase(): Promise<any> { + return await exportNativeSqliteDb(this.ndb); + } + + async importDatabase(dump: any): Promise<void> { + await importNativeSqliteDb(this.ndb, dump); + } + + async clearDatabase(): Promise<void> { + await clearNativeSqliteWalletDb(this.ndb); + } + + getAccessStats(): WalletDbAccessStats | undefined { + return { recordsRead: this.ndb.stats.rowsRead }; + } + + async close(): Promise<void> { + await this.ndb.db.close(); + } +} diff --git a/packages/taler-wallet-core/src/dbtx-handle.ts b/packages/taler-wallet-core/src/dbtx-handle.ts @@ -0,0 +1,83 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU 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. + + GNU 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 + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Backend-neutral handle for the wallet database as a whole. + * + * WalletDbTransaction abstracts a running transaction; this abstracts the + * database that hands them out, together with the operations that act on the + * store as a unit rather than on records: export, import, clear. + * + * The wallet holds exactly one of these. It used to hold an IndexedDB factory + * and an optional native sqlite handle side by side, and every operation that + * worked on the whole database had to pick one -- with the wrong choice + * producing a valid, empty database rather than an error, so a mistake looked + * like a freshly initialised wallet instead of a failure. With a single + * handle, that choice does not exist to get wrong. + */ + +import { WalletDbTransaction } from "./dbtx.js"; + +/** + * Number of records a backend has read, for tests that assert a query is + * bounded rather than scanning. + */ +export interface WalletDbAccessStats { + recordsRead: number; +} + +export interface WalletDbHandle { + /** + * Which backend this is, for logs and test names. + * + * Deliberately not something to branch on: code that needs to know whether a + * capability is present should test for the capability. + */ + readonly name: string; + + /** + * Run f in a read-write transaction over all stores and return its result. + */ + runReadWriteTx<T>(f: (tx: WalletDbTransaction) => Promise<T>): Promise<T>; + + /** + * Serialise the whole database into a backend-specific dump. + * + * The dump is opaque to callers and is only meaningful to importDatabase on + * the same backend. + */ + exportDatabase(): Promise<any>; + + /** + * Replace the contents of the database with a dump. + * + * Returns with the database consistent: a backend whose stored records need + * repairing after an import that may predate its current schema does that + * repair here, so callers cannot forget to. Rebuilding wallet-level views + * from the imported records is the caller's job -- that is not storage. + * + * Throws if the dump did not come from this backend. + */ + importDatabase(dump: any): Promise<void>; + + /** Remove all records, leaving an empty database of the current schema. */ + clearDatabase(): Promise<void>; + + /** Access statistics, if the backend tracks them. */ + getAccessStats(): WalletDbAccessStats | undefined; + + close(): Promise<void>; +} diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -23,11 +23,7 @@ /** * Imports. */ -import { - clearNativeSqliteWalletDb, - exportNativeSqliteDb, - importNativeSqliteDb, -} from "./dbtx-sqlite.js"; +import { IdbWalletDbHandle } from "./dbtx-handle-impl.js"; import { AbortTransactionRequest, AcceptBankIntegratedWithdrawalRequest, @@ -283,10 +279,6 @@ import { } from "./db-common.js"; import { CoinSourceType, - applyFixups, - clearDatabase, - exportDb, - importDb, openStoredBackupsDatabase, walletDbFixups, } from "./db-indexeddb.js"; @@ -603,17 +595,35 @@ async function dumpCoins(wex: WalletExecutionContext): Promise<CoinDumpJson> { return coinsJson; } +/** + * The IndexedDB backend, or an error naming what needs it. + * + * Stored backups and the fixup log are IndexedDB concerns: the native schema + * starts clean and evolves through schemaMigrations, so it has no fixup log, + * and stored backups were never implemented for it. Failing loudly here beats + * the alternative these call sites used to have, where reaching for the + * emulation under the native backend silently operated on an empty database. + */ +function requireIdbBackend( + wex: WalletExecutionContext, + what: string, +): IdbWalletDbHandle { + const idb = wex.ws.idbOnly; + if (!idb) { + throw Error(`${what} is only supported on the IndexedDB backend`); + } + return idb; +} + async function createStoredBackup( wex: WalletExecutionContext, ): Promise<CreateStoredBackupResponse> { - // The backup payload comes from whichever backend actually holds the data. - // The backups themselves are kept in a separate IndexedDB either way, - // which is storage-agnostic: it stores an opaque JSON blob. - const ndb = wex.ws.nativeSqliteDb; - const backup = ndb - ? await exportNativeSqliteDb(ndb) - : await exportDb(wex.ws.idbFactory); - const backupsDb = await openStoredBackupsDatabase(wex.ws.idbFactory); + // The dump comes from the wallet database, whichever backend that is. The + // backups themselves live in a separate IndexedDB database, which is only + // available on the IndexedDB backend. + const backup = await wex.ws.db.exportDatabase(); + const idb = requireIdbBackend(wex, "stored backups"); + const backupsDb = await openStoredBackupsDatabase(idb.factory()); const name = `backup-${new Date().getTime()}`; await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { await tx.backupMeta.add({ @@ -632,7 +642,9 @@ async function listStoredBackups( const storedBackups: StoredBackupList = { storedBackups: [], }; - const backupsDb = await openStoredBackupsDatabase(wex.ws.idbFactory); + const backupsDb = await openStoredBackupsDatabase( + requireIdbBackend(wex, "stored backups").factory(), + ); await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { await tx.backupMeta.iter().forEach((x) => { storedBackups.storedBackups.push({ @@ -647,7 +659,9 @@ async function deleteStoredBackup( wex: WalletExecutionContext, req: DeleteStoredBackupRequest, ): Promise<void> { - const backupsDb = await openStoredBackupsDatabase(wex.ws.idbFactory); + const backupsDb = await openStoredBackupsDatabase( + requireIdbBackend(wex, "stored backups").factory(), + ); await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { await tx.backupData.delete(req.name); await tx.backupMeta.delete(req.name); @@ -660,7 +674,9 @@ async function recoverStoredBackup( ): Promise<void> { logger.info(`Recovering stored backup ${req.name}`); const { name } = req; - const backupsDb = await openStoredBackupsDatabase(wex.ws.idbFactory); + const backupsDb = await openStoredBackupsDatabase( + requireIdbBackend(wex, "stored backups").factory(), + ); const bd = await backupsDb.runAllStoresReadWriteTx({}, async (tx) => { const backupMeta = await tx.backupMeta.get(name); if (!backupMeta) { @@ -673,23 +689,12 @@ async function recoverStoredBackup( return backupData; }); logger.info(`backup found, now importing`); - const ndbRecover = wex.ws.nativeSqliteDb; - if (ndbRecover) { - await importNativeSqliteDb(ndbRecover, bd); - await wex.runWalletDbTx(async (tx) => { - await rematerializeTransactions(wex, tx); - }); - } else { - await importDb(wex.db.idbHandle(), bd); - // Same order as handleImportDb, and for the same reason: clearing the - // log only schedules the repairs, so they have to be re-run here, and - // the transaction view has to be built from repaired records. - await clearFixupLog(wex); - await applyFixups(wex.db); - await wex.runWalletDbTx(async (tx) => { - await rematerializeTransactions(wex, tx); - }); - } + // importDatabase leaves the records repaired, so the transaction view is + // rebuilt from repaired records rather than from whatever the dump held. + await wex.ws.db.importDatabase(bd); + await wex.runWalletDbTx(async (tx) => { + await rematerializeTransactions(wex, tx); + }); // Both branches replaced the database wholesale, bypassing the DAL that // normally notices such changes, so the caches have to be dropped by hand. @@ -1620,45 +1625,15 @@ async function handleExportDbToFile( }; } -/** - * Mark every fixup as not applied. - * - * Goes through the IndexedDB handle rather than the DAL on purpose: the - * fixup log is part of that backend. The native sqlite schema has no - * equivalent -- it starts clean and evolves through schemaMigrations -- so - * there is nothing here for the DAL to abstract over. - */ -async function clearFixupLog(wex: WalletExecutionContext): Promise<void> { - await wex.db.runAllStoresReadWriteTx({}, async (tx) => { - const fixups = await tx.fixups.getAll(); - for (const f of fixups) { - await tx.fixups.delete(f.fixupName); - } - }); -} - async function handleImportDb( wex: WalletExecutionContext, req: ImportDbRequest, ): Promise<EmptyObject> { // FIXME: This should atomically re-materialize transactions! - await importDb(wex.db.idbHandle(), req.dump); - - // Clear the fixup log: the records that just arrived may predate any of - // the fixups, whatever this database had applied before the import. They - // are idempotent, so re-running them is safe. - await clearFixupLog(wex); - - // Then actually re-run them. Clearing the log alone only schedules the - // repairs for the next time the database is opened, which for a running - // wallet is never: every query until the next restart would see the - // imported records unrepaired. A backup from before the status-enum digit - // fix, for instance, imported cleanly and then crashed getTransactions on - // an operationStatus no enum member matched. - await applyFixups(wex.db); - - // Materialise the transaction view last, so it is built from repaired - // records rather than from what the import happened to contain. + // importDatabase leaves the records repaired, so the transaction view below + // is built from repaired records rather than from what the dump contained. + await wex.ws.db.importDatabase(req.dump); + await wex.runWalletDbTx(async (tx) => { await rematerializeTransactions(wex, tx); }); @@ -1779,7 +1754,9 @@ export async function handleTestingRunFixup( } // fixup.fn takes the raw IndexedDB transaction: fixups repair that // backend's records and are not expressible through the DAL. - await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const idb = requireIdbBackend(wex, "running a fixup"); + const access = await idb.rawAccess(); + await access.runAllStoresReadWriteTx({}, async (tx) => { await fixup.fn(tx); }); await wex.runWalletDbTx(async (tx) => { @@ -2494,14 +2471,7 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { [WalletApiOperation.ClearDb]: { codec: codecForEmptyObject(), handler: async (wex, req) => { - await clearDatabase(wex.db.idbHandle()); - // Also clear the native database when one is in use: clearDatabase - // only knows about IndexedDB, so without this the wallet would report - // a cleared database while its actual data survived. - const ndb = wex.ws.nativeSqliteDb; - if (ndb) { - await clearNativeSqliteWalletDb(ndb); - } + await wex.ws.db.clearDatabase(); wex.ws.flightRecords = []; wex.ws.clearAllCaches(); await wex.taskScheduler.reload(); @@ -2517,8 +2487,7 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { [WalletApiOperation.ExportDb]: { codec: codecForEmptyObject(), handler: async (wex, req) => { - const dbDump = await exportDb(wex.ws.idbFactory); - return dbDump; + return await wex.ws.db.exportDatabase(); }, }, [WalletApiOperation.GetDepositWireTypes]: { diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -23,11 +23,7 @@ /** * Imports. */ -import { - AccessStats, - BridgeIDBFactory, - IDBDatabase, -} from "@gnu-taler/idb-bridge"; +import { AccessStats, BridgeIDBFactory } from "@gnu-taler/idb-bridge"; import { AmountJson, AsyncCondition, @@ -73,18 +69,12 @@ import { CryptoWorkerFactory, } from "./crypto/workers/crypto-dispatcher.js"; import { ConfigRecordKey, WalletDenomination } from "./db-common.js"; -import { - WalletIndexedDbStoresV1, - applyFixups, - openTalerDatabase, -} from "./db-indexeddb.js"; import { WalletDbTransaction } from "./dbtx.js"; +import { TransactionAbortedError } from "./query.js"; +import { WalletDbHandle } from "./dbtx-handle.js"; +import { IdbWalletDbHandle, SqliteWalletDbHandle } from "./dbtx-handle-impl.js"; import { watchForCacheInvalidation } from "./dbtx-shared.js"; -import { - NativeSqliteWalletDb, - runNativeSqliteWalletTx, -} from "./dbtx-sqlite.js"; -import { IdbWalletTransaction } from "./dbtx-indexeddb.js"; +import { NativeSqliteWalletDb } from "./dbtx-sqlite.js"; import { UnverifiedDenomError } from "./denomSelection.js"; import { DevExperimentHttpLib, DevExperimentState } from "./dev-experiments.js"; import { @@ -93,13 +83,11 @@ import { fetchFreshExchange, } from "./exchanges.js"; import { - ObservableDbAccess, ObservableTaskScheduler, getCallerInfo, observeTalerCrypto, } from "./observable-wrappers.js"; import { ProgressContext } from "./progress.js"; -import { DbAccess, DbAccessImpl, TransactionAbortedError } from "./query.js"; import { TaskScheduler, TaskSchedulerImpl } from "./shepherd.js"; import { rematerializeTransactions } from "./transactions.js"; import { @@ -131,7 +119,6 @@ export interface WalletExecutionContext { readonly cryptoApi: TalerCryptoInterface; readonly cancellationToken: CancellationToken; readonly http: HttpRequestLibrary; - readonly db: DbAccess<typeof WalletIndexedDbStoresV1>; readonly oc: ObservabilityContext; readonly cts: CancellationToken.Source | undefined; readonly taskScheduler: TaskScheduler; @@ -293,7 +280,6 @@ export function getObservedWalletExecutionContext( cts: CancellationToken.Source | undefined, oc: ObservabilityContext, ): WalletExecutionContext { - const db = ws.createDbAccessHandle(cancellationToken); const wex: WalletExecutionContext = { async runWalletDbTx(f) { return await runWalletDbTx(wex, f); @@ -302,7 +288,6 @@ export function getObservedWalletExecutionContext( cancellationToken, cts, cryptoApi: observeTalerCrypto(ws.cryptoApi, oc), - db: new ObservableDbAccess(db, oc), dbRetryState: { retriedExchangeUpdate: new Set(), }, @@ -391,64 +376,42 @@ async function runWalletDbTx<T>( // transaction that rolls back changed nothing, and an attempt that is about // to be retried resets the flag so a discarded write cannot carry over. const dirty = { dirty: false }; - const ndb = wex.ws.nativeSqliteDb; - if (ndb) { - // Native backend: a real sqlite transaction, no IndexedDB emulation. - // Retries are handled the same way, since the failure modes this guards - // against (transaction aborted, retryable conflict) are not specific to - // the storage layer. - // - // Observability events are emitted here rather than inherited from - // ObservableDbAccess: that wrapper sits on wex.db, which this path does - // not touch, so without this the native backend would be invisible to - // the observability API. - return await handleTxRetries(wex, async () => { - dirty.dirty = false; - const location = getCallerInfo(); + // Retries wrap the transaction for both backends: the failure modes they + // guard against (transaction aborted, retryable conflict) are not specific + // to the storage layer. + return await handleTxRetries(wex, async () => { + dirty.dirty = false; + const location = getCallerInfo(); + wex.oc.observe({ + type: ObservabilityEventType.DbQueryStart, + name: "<unknown>", + location, + }); + const start = performanceNow(); + try { + const ret = await wex.ws.db.runReadWriteTx( + async (tx) => await f(watchForCacheInvalidation(tx, dirty)), + ); wex.oc.observe({ - type: ObservabilityEventType.DbQueryStart, + type: ObservabilityEventType.DbQueryFinishSuccess, name: "<unknown>", location, + durationMs: performanceDelta(start, performanceNow()), }); - const start = performanceNow(); - try { - const ret = await runNativeSqliteWalletTx( - ndb, - (notif) => wex.ws.notify(notif), - async (tx) => await f(watchForCacheInvalidation(tx, dirty)), - ); - wex.oc.observe({ - type: ObservabilityEventType.DbQueryFinishSuccess, - name: "<unknown>", - location, - durationMs: performanceDelta(start, performanceNow()), - }); - if (dirty.dirty) { - wex.ws.clearAllCaches(); - } - return ret; - } catch (e) { - wex.oc.observe({ - type: ObservabilityEventType.DbQueryFinishError, - name: "<unknown>", - location, - error: getErrorDetailFromException(e), - durationMs: performanceDelta(start, performanceNow()), - }); - throw e; + if (dirty.dirty) { + wex.ws.clearAllCaches(); } - }); - } - return await handleTxRetries(wex, async () => { - dirty.dirty = false; - const ret = await wex.db.runAllStoresReadWriteTx({}, async (mytx) => { - const tx = new IdbWalletTransaction(mytx); - return await f(watchForCacheInvalidation(tx, dirty)); - }); - if (dirty.dirty) { - wex.ws.clearAllCaches(); + return ret; + } catch (e) { + wex.oc.observe({ + type: ObservabilityEventType.DbQueryFinishError, + name: "<unknown>", + location, + error: getErrorDetailFromException(e), + durationMs: performanceDelta(start, performanceNow()), + }); + throw e; } - return ret; }); } @@ -458,7 +421,6 @@ export function getNormalWalletExecutionContext( cts: CancellationToken.Source | undefined, oc: ObservabilityContext, ): WalletExecutionContext { - const db = ws.createDbAccessHandle(cancellationToken); const wex: WalletExecutionContext = { async runWalletDbTx(f) { return await runWalletDbTx(wex, f); @@ -467,7 +429,6 @@ export function getNormalWalletExecutionContext( cancellationToken, cts, cryptoApi: ws.cryptoApi, - db, dbRetryState: { retriedExchangeUpdate: new Set(), }, @@ -717,9 +678,7 @@ export class InternalWalletState { private _config: Readonly<WalletRunConfig> | undefined; - private _indexedDbHandle: IDBDatabase | undefined = undefined; - - private _dbAccessHandle: DbAccess<typeof WalletIndexedDbStoresV1> | undefined; + private _dbHandle: WalletDbHandle | undefined = undefined; private _http: HttpRequestLibrary | undefined = undefined; @@ -737,15 +696,45 @@ export class InternalWalletState { performanceStats: PerformanceTable = {}; - public get idbFactory(): BridgeIDBFactory { - return this.dbImplementation.idbFactory; + /** + * The wallet database, whichever backend is behind it. + * + * The single place the storage backend is chosen. Everything above this + * line works through WalletDbHandle and cannot tell the two apart. + */ + public get db(): WalletDbHandle { + if (!this._dbHandle) { + const ndb = this.dbImplementation.nativeSqliteDb; + this._dbHandle = ndb + ? new SqliteWalletDbHandle(ndb, (n) => this.notify(n)) + : new IdbWalletDbHandle( + this.dbImplementation.idbFactory, + (n) => this.notify(n), + () => { + // readItemsPerStore counts records handed back, which is what + // the sqlite backend's rowsRead counts; the per-store split is + // not comparable across backends, so it is summed away here. + const st = this.dbImplementation.getStats(); + let recordsRead = 0; + for (const k of Object.keys(st.readItemsPerStore)) { + recordsRead += st.readItemsPerStore[k]; + } + return { recordsRead }; + }, + ); + } + return this._dbHandle; } /** - * The native sqlite3 database, when the host opened one. + * The IndexedDB-specific handle, for the two concerns that genuinely belong + * to that backend: the fixup log and the stored-backup database. Undefined + * when the native backend is in use, so a caller has to say what it does + * when the capability is absent. */ - public get nativeSqliteDb(): NativeSqliteWalletDb | undefined { - return this.dbImplementation.nativeSqliteDb; + public get idbOnly(): IdbWalletDbHandle | undefined { + const h = this.db; + return h instanceof IdbWalletDbHandle ? h : undefined; } /** @@ -774,29 +763,8 @@ export class InternalWalletState { f: (tx: WalletDbTransaction) => Promise<T>, ): Promise<T> { const dirty = { dirty: false }; - const ndb = this.nativeSqliteDb; - if (ndb) { - const ret = await runNativeSqliteWalletTx( - ndb, - (notif) => this.notify(notif), - async (tx) => await f(watchForCacheInvalidation(tx, dirty)), - ); - if (dirty.dirty) { - this.clearAllCaches(); - } - return ret; - } - if (!this._dbAccessHandle) { - this._dbAccessHandle = this.createDbAccessHandle( - CancellationToken.CONTINUE, - ); - } - const ret = await this._dbAccessHandle.runAllStoresReadWriteTx( - {}, - async (mytx) => - await f( - watchForCacheInvalidation(new IdbWalletTransaction(mytx), dirty), - ), + const ret = await this.db.runReadWriteTx( + async (tx) => await f(watchForCacheInvalidation(tx, dirty)), ); if (dirty.dirty) { this.clearAllCaches(); @@ -857,25 +825,6 @@ export class InternalWalletState { } } - createDbAccessHandle( - cancellationToken: CancellationToken, - ): DbAccess<typeof WalletIndexedDbStoresV1> { - if (!this._indexedDbHandle) { - throw Error("db not initialized"); - } - const iws = this; - return new DbAccessImpl( - this._indexedDbHandle, - WalletIndexedDbStoresV1, - cancellationToken, - (notifs: WalletNotification[]): void => { - for (const notif of notifs) { - iws.notify(notif); - } - }, - ); - } - get config(): WalletRunConfig { if (!this._config) { throw Error("config not initialized"); @@ -914,37 +863,25 @@ export class InternalWalletState { } async ensureWalletDbOpen(): Promise<void> { - if (this._indexedDbHandle) { - return; - } if (this.loadingDb) { while (this.loadingDb) { await this.loadingDbCond.wait(); } - // Another concurrent caller may have finished opening the DB while we - // were waiting; in that case don't re-open it. - if (this._indexedDbHandle) { - return; - } + return; } this.loadingDb = true; - const myVersionChange = async (): Promise<void> => { - logger.info("version change requested for Taler DB"); - }; try { - const myDb = await openTalerDatabase(this.idbFactory, myVersionChange); - this._indexedDbHandle = myDb; - const dbAccess = this.createDbAccessHandle(CancellationToken.CONTINUE); - const count = await applyFixups(dbAccess); - if (count > 0) { - const oc = { - observe(evt: any) {}, - }; + // Opening and repairing is the backend's business. It reports how many + // fixups it applied, because rebuilding the transaction view afterwards + // is a wallet-level concern that no storage layer should know about. + const idb = this.idbOnly; + const fixupsApplied = idb ? (await idb.ensureOpen()).fixupsApplied : 0; + if (fixupsApplied > 0) { const wex = getNormalWalletExecutionContext( this, CancellationToken.CONTINUE, undefined, - oc, + { observe(evt: any) {} }, ); await wex.runWalletDbTx(async (tx) => { await rematerializeTransactions(wex, tx); @@ -975,17 +912,7 @@ export class InternalWalletState { } } this.loadingDb = true; - const dbh = this._indexedDbHandle; - if (!dbh) { - return; - } - this._indexedDbHandle = undefined; - return new Promise((resolve, reject) => { - dbh.addEventListener("close", () => { - resolve(); - }); - dbh.close(); - }); + await this.db.close(); } /**