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:
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();
}
/**