commit 3f13b8309f4bde0466591b1fd2cc19ea5dcea831
parent c92de1ab24a6fdc0299b0defe2febb7e312f5ad2
Author: Florian Dold <dold@taler.net>
Date: Mon, 20 Jul 2026 01:34:01 +0200
wallet: open one database per host, retire WalletDatabaseImplementation
Diffstat:
9 files changed, 222 insertions(+), 171 deletions(-)
diff --git a/packages/taler-wallet-core/src/dbtx-handle-impl.ts b/packages/taler-wallet-core/src/dbtx-handle-impl.ts
@@ -61,9 +61,26 @@ export class IdbWalletDbHandle implements WalletDbHandle {
private idbHandle: IDBDatabase | undefined;
private dbAccess: DbAccess<typeof WalletIndexedDbStoresV1> | undefined;
+ private notify: (n: WalletNotification) => void = () => {};
+
+ /**
+ * Filesystem-backed capabilities, supplied by the host when it has them.
+ * A browser extension leaves them unset.
+ */
+ exportToFile?: (
+ directory: string,
+ stem: string,
+ forceFormat?: string,
+ ) => Promise<{ path: string }>;
+ readBackupJson?: (path: string) => Promise<any>;
+ getDiagnosticStats?: () => unknown;
+
+ setNotificationSink(sink: (n: WalletNotification) => void): void {
+ this.notify = sink;
+ }
+
constructor(
private idbFactory: BridgeIDBFactory,
- private notify: (n: WalletNotification) => void,
/**
* Raw backend counters, when the backend was asked to track them.
* Summed into a single figure by getAccessStats.
@@ -204,10 +221,41 @@ export class IdbWalletDbHandle implements WalletDbHandle {
export class SqliteWalletDbHandle implements WalletDbHandle {
readonly name = "sqlite";
- constructor(
- private ndb: NativeSqliteWalletDb,
- private notify: (n: WalletNotification) => void,
- ) {}
+ private notify: (n: WalletNotification) => void = () => {};
+
+ setNotificationSink(sink: (n: WalletNotification) => void): void {
+ this.notify = sink;
+ }
+
+ constructor(private ndb: NativeSqliteWalletDb) {}
+
+ /**
+ * VACUUM INTO, which writes a consistent copy without holding the whole
+ * database in memory. Goes through the transaction queue: it cannot run
+ * inside a transaction, and the queue is what serialises them.
+ */
+ async exportToFile(
+ directory: string,
+ stem: string,
+ forceFormat?: string,
+ ): Promise<{ path: string }> {
+ if (forceFormat != null && forceFormat !== "sqlite3") {
+ throw Error(
+ `the native backend can only export sqlite3, not ${forceFormat}`,
+ );
+ }
+ const path = `${directory}/${stem}.sqlite3`;
+ await this.ndb.lock.run(async () => {
+ await (await this.ndb.db.prepare("VACUUM INTO $filename")).run({
+ filename: path,
+ });
+ });
+ return { path };
+ }
+
+ getDiagnosticStats(): unknown {
+ return { rowsRead: this.ndb.stats.rowsRead };
+ }
async runReadWriteTx<T>(
f: (tx: WalletDbTransaction) => Promise<T>,
diff --git a/packages/taler-wallet-core/src/dbtx-handle.ts b/packages/taler-wallet-core/src/dbtx-handle.ts
@@ -29,6 +29,8 @@
* handle, that choice does not exist to get wrong.
*/
+import { WalletNotification } from "@gnu-taler/taler-util";
+
import { WalletDbTransaction } from "./dbtx.js";
/**
@@ -79,5 +81,39 @@ export interface WalletDbHandle {
/** Access statistics, if the backend tracks them. */
getAccessStats(): WalletDbAccessStats | undefined;
+ /**
+ * Where post-commit notifications go.
+ *
+ * The host opens the database before the wallet that will consume its
+ * notifications exists, so the sink starts as a no-op and the wallet
+ * installs its own during construction.
+ */
+ setNotificationSink(sink: (n: WalletNotification) => void): void;
+
+ /**
+ * Copy the database to a file, in whatever format the backend supports.
+ *
+ * Absent when the host cannot do this -- a browser extension has no
+ * filesystem -- so callers must say what they do without it rather than
+ * receive an error from a method that looked available.
+ */
+ exportToFile?(
+ directory: string,
+ stem: string,
+ forceFormat?: string,
+ ): Promise<{ path: string }>;
+
+ /** Read a dump previously written by exportToFile. Absent if unsupported. */
+ readBackupJson?(path: string): Promise<any>;
+
+ /**
+ * Backend-specific counters for the testing API.
+ *
+ * Deliberately untyped: this is diagnostic output whose shape follows
+ * whichever backend produced it, unlike getAccessStats which is the one
+ * figure both backends agree on.
+ */
+ getDiagnosticStats?(): unknown;
+
close(): Promise<void>;
}
diff --git a/packages/taler-wallet-core/src/dbtx-runners.ts b/packages/taler-wallet-core/src/dbtx-runners.ts
@@ -46,10 +46,8 @@ export async function makeIdbRunner(
backend.trackStats = true;
BridgeIDBFactory.enableTracing = false;
const idbFactory = new BridgeIDBFactory(backend);
- const handle = new IdbWalletDbHandle(
- idbFactory as any,
- () => {},
- () => backend.accessStats,
+ const handle = new IdbWalletDbHandle(idbFactory as any, () =>
+ backend.accessStats,
);
await handle.ensureOpen();
return handle;
@@ -70,7 +68,7 @@ export async function makeSqliteRunner(
enableTracing: false,
});
const ndb = await openNativeSqliteWalletDb(await sqlite3Impl.open(filename));
- return new SqliteWalletDbHandle(ndb, () => {});
+ return new SqliteWalletDbHandle(ndb);
}
export const runnerFactories: Array<
diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts
@@ -45,79 +45,58 @@ import {
NativeSqliteWalletDb,
openNativeSqliteWalletDb,
} from "./dbtx-sqlite.js";
-import { Wallet, WalletDatabaseImplementation } from "./wallet.js";
+import { Wallet } from "./wallet.js";
+import { WalletDbHandle } from "./dbtx-handle.js";
+import {
+ IdbWalletDbHandle,
+ SqliteWalletDbHandle,
+} from "./dbtx-handle-impl.js";
const logger = new Logger("host-impl.node.ts");
async function makeSqliteDb(
args: DefaultNodeWalletArgs,
-): Promise<WalletDatabaseImplementation> {
- if (process.env.TALER_WALLET_DBTRACING) {
- BridgeIDBFactory.enableTracing = true;
- } else {
- BridgeIDBFactory.enableTracing = false;
- }
- const imp = await createNodeHelperSqlite3Impl();
+): Promise<WalletDbHandle> {
+ const tracing = !!process.env.TALER_WALLET_DBTRACING;
+ BridgeIDBFactory.enableTracing = tracing;
const dbFilename = getSqlite3FilenameFromStoragePath(
args.persistentStoragePath,
);
- const useNativeDb = !!process.env.TALER_WALLET_NATIVE_DB;
- // Whichever backend actually holds the wallet's data gets the canonical
- // path, and the other one is moved aside. Anything that treats the wallet
- // DB path as "the wallet database" -- a file copy, an inspection, a
- // snapshot in a test -- then operates on the real data. With the native
- // database in a sidecar, such a copy silently restored nothing.
- const idbFilename =
- useNativeDb && dbFilename !== ":memory:" ? `${dbFilename}.idb` : dbFilename;
- logger.info(`using database ${idbFilename}`);
- const myBackend = await createSqliteBackend(imp, {
- filename: idbFilename,
- });
- if (process.env.TALER_WALLET_DBTRACING) {
- myBackend.enableTracing = true;
- } else {
- myBackend.enableTracing = false;
- }
- if (process.env.TALER_WALLET_STATS) {
- myBackend.trackStats = true;
- }
- const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
-
- // Opt-in native backend. The IndexedDB emulation stays open either way:
- // the stored-backup database and the fixup bookkeeping still live there,
- // and the native schema deliberately starts clean instead of replaying
- // fixups: every fixup repairs records written by an older version of the
- // IndexedDB schema, which the native schema never had.
- let nativeSqliteDb: NativeSqliteWalletDb | undefined;
- if (useNativeDb) {
- const nativeFilename = dbFilename;
- logger.info(`using NATIVE sqlite3 wallet DB at ${nativeFilename}`);
+
+ // Exactly one backend is opened. Opening both meant every operation that
+ // works on the whole database had to pick one, and picking wrong produced a
+ // valid empty database rather than an error.
+ if (process.env.TALER_WALLET_NATIVE_DB) {
+ logger.info(`using NATIVE sqlite3 wallet DB at ${dbFilename}`);
logger.warn("the native sqlite3 wallet DB backend is experimental");
- // A second helper process: each one holds a single connection, so
- // reusing `imp` here fails with "DB already connected".
const nativeImp = await createNodeHelperSqlite3Impl();
- nativeSqliteDb = await openNativeSqliteWalletDb(
- await nativeImp.open(nativeFilename),
+ const ndb = await openNativeSqliteWalletDb(
+ await nativeImp.open(dbFilename),
);
+ return new SqliteWalletDbHandle(ndb);
}
- return {
- nativeSqliteDb,
- getStats() {
- return myBackend.accessStats;
- },
- async exportToFile(directory, stem) {
- const path = `${directory}/${stem}.sqlite3`;
- await myBackend.backupToFile(path);
- return {
- path,
- };
- },
- async readBackupJson(path: string): Promise<any> {
- throw Error("not supported");
- },
- idbFactory: myBridgeIdbFactory,
+ logger.info(`using database ${dbFilename}`);
+ const imp = await createNodeHelperSqlite3Impl();
+ const myBackend = await createSqliteBackend(imp, {
+ filename: dbFilename,
+ });
+ myBackend.enableTracing = tracing;
+ if (process.env.TALER_WALLET_STATS) {
+ myBackend.trackStats = true;
+ }
+ const handle = new IdbWalletDbHandle(
+ new BridgeIDBFactory(myBackend),
+ () => myBackend.accessStats,
+ );
+ handle.exportToFile = async (directory, stem) => {
+ const path = `${directory}/${stem}.sqlite3`;
+ await myBackend.backupToFile(path);
+ return { path };
};
+ handle.getDiagnosticStats = () => myBackend.accessStats;
+ return handle;
+
}
/**
@@ -143,7 +122,7 @@ export async function createNativeWalletHost2(
return myHttpLib;
};
- let dbResp: WalletDatabaseImplementation;
+ let dbResp: WalletDbHandle;
if (
args.persistentStoragePath &&
@@ -155,7 +134,11 @@ export async function createNativeWalletHost2(
dbResp = await makeSqliteDb(args);
}
- shimIndexedDB(dbResp.idbFactory);
+ // Only meaningful for the IndexedDB backend; with the native one there is
+ // no emulation to expose as a global.
+ if (dbResp instanceof IdbWalletDbHandle) {
+ shimIndexedDB(dbResp.factory());
+ }
let workerFactory;
const cryptoWorkerType = args.cryptoWorkerType ?? "node-worker-thread";
diff --git a/packages/taler-wallet-core/src/host-impl.qtart.ts b/packages/taler-wallet-core/src/host-impl.qtart.ts
@@ -47,7 +47,9 @@ import {
getSqlite3FilenameFromStoragePath,
} from "./host-common.js";
import { exportDb } from "./index.js";
-import { Wallet, WalletDatabaseImplementation } from "./wallet.js";
+import { Wallet } from "./wallet.js";
+import { WalletDbHandle } from "./dbtx-handle.js";
+import { IdbWalletDbHandle } from "./dbtx-handle-impl.js";
const logger = new Logger("host-impl.qtart.ts");
@@ -95,7 +97,7 @@ export async function createQtartSqlite3Impl(): Promise<Sqlite3Interface> {
async function makeSqliteDb(
args: DefaultNodeWalletArgs,
-): Promise<WalletDatabaseImplementation> {
+): Promise<WalletDbHandle> {
BridgeIDBFactory.enableTracing = false;
const filename = getSqlite3FilenameFromStoragePath(
args.persistentStoragePath,
@@ -107,54 +109,48 @@ async function makeSqliteDb(
});
myBackend.trackStats = true;
myBackend.enableTracing = false;
- const myBridgeIdbFactory = new BridgeIDBFactory(myBackend);
- return {
- getStats() {
- return {
- ...myBackend.accessStats,
- primitiveStatements: numStmt,
- };
- },
- async exportToFile(directory, stem, forceFormat) {
- if (forceFormat === "json") {
- const path = `${directory}/${stem}.json`;
- const dbDump = await exportDb(myBridgeIdbFactory);
- const errObj = { errno: undefined };
- const file = qjsStd.open(path, "w+", errObj);
- if (!file) {
- throw Error(`could not create file (errno=${errObj.errno})`);
- }
- file.puts(JSON.stringify(dbDump));
- file.close();
- return {
- path,
- };
- } else if (forceFormat === "sqlite3" || forceFormat == null) {
- const path = `${directory}/${stem}.sqlite3`;
- await myBackend.backupToFile(path);
- return {
- path,
- };
- } else {
- throw Error(`forcing format ${forceFormat} not supported`);
- }
- },
- async readBackupJson(path: string): Promise<any> {
+ const handle = new IdbWalletDbHandle(new BridgeIDBFactory(myBackend), () => ({
+ ...myBackend.accessStats,
+ primitiveStatements: numStmt,
+ }));
+ handle.getDiagnosticStats = () => ({
+ ...myBackend.accessStats,
+ primitiveStatements: numStmt,
+ });
+ handle.exportToFile = async (directory, stem, forceFormat) => {
+ if (forceFormat === "json") {
+ const path = `${directory}/${stem}.json`;
+ const dbDump = await handle.exportDatabase();
const errObj = { errno: undefined };
- const file = qjsStd.open(path, "r", errObj);
- if (!path.endsWith(".json")) {
- throw Error("DB file import only supports .json files at the moment");
- }
+ const file = qjsStd.open(path, "w+", errObj);
if (!file) {
- throw Error(`could not open file (errno=${errObj.errno})`);
+ throw Error(`could not create file (errno=${errObj.errno})`);
}
- const dumpStr = file.readAsString();
+ file.puts(JSON.stringify(dbDump));
file.close();
- const dump = JSON.parse(dumpStr);
- return dump;
- },
- idbFactory: myBridgeIdbFactory,
+ return { path };
+ } else if (forceFormat === "sqlite3" || forceFormat == null) {
+ const path = `${directory}/${stem}.sqlite3`;
+ await myBackend.backupToFile(path);
+ return { path };
+ } else {
+ throw Error(`forcing format ${forceFormat} not supported`);
+ }
};
+ handle.readBackupJson = async (path: string): Promise<any> => {
+ const errObj = { errno: undefined };
+ const file = qjsStd.open(path, "r", errObj);
+ if (!path.endsWith(".json")) {
+ throw Error("DB file import only supports .json files at the moment");
+ }
+ if (!file) {
+ throw Error(`could not open file (errno=${errObj.errno})`);
+ }
+ const dumpStr = file.readAsString();
+ file.close();
+ return JSON.parse(dumpStr);
+ };
+ return handle;
}
export async function createNativeWalletHost2(
@@ -164,7 +160,7 @@ export async function createNativeWalletHost2(
}> {
BridgeIDBFactory.enableTracing = false;
- let dbResp: WalletDatabaseImplementation;
+ let dbResp: WalletDbHandle;
if (
args.persistentStoragePath &&
@@ -176,7 +172,9 @@ export async function createNativeWalletHost2(
dbResp = await makeSqliteDb(args);
}
- shimIndexedDB(dbResp.idbFactory);
+ if (dbResp instanceof IdbWalletDbHandle) {
+ shimIndexedDB(dbResp.factory());
+ }
const myHttpFactory = (config: WalletRunConfig) => {
let myHttpLib;
diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts
@@ -47,5 +47,7 @@ export {
} from "./db-indexeddb.js";
export { DbAccess } from "./query.js";
+export { WalletDbHandle } from "./dbtx-handle.js";
+export { IdbWalletDbHandle } from "./dbtx-handle-impl.js";
export { TaskRunResult, TaskRunResultType } from "./common.js";
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -1615,11 +1615,13 @@ async function handleExportDbToFile(
wex: WalletExecutionContext,
req: ExportDbToFileRequest,
): Promise<ExportDbToFileResponse> {
- const res = await wex.ws.dbImplementation.exportToFile(
- req.directory,
- req.stem,
- req.forceFormat,
- );
+ const db = wex.ws.db;
+ if (!db.exportToFile) {
+ throw Error(`the ${db.name} backend cannot export the database to a file`);
+ }
+ // Called as a method: the sqlite implementation reads this.ndb, and
+ // extracting the function first would drop the receiver.
+ const res = await db.exportToFile(req.directory, req.stem, req.forceFormat);
return {
path: res.path,
};
@@ -1651,7 +1653,13 @@ async function handleImportDbFromFile(
req: ImportDbFromFileRequest,
): Promise<EmptyObject> {
if (req.path.endsWith(".json")) {
- const dump = await wex.ws.dbImplementation.readBackupJson(req.path);
+ const db = wex.ws.db;
+ if (!db.readBackupJson) {
+ throw Error(
+ `the ${db.name} backend cannot read a database dump from a file`,
+ );
+ }
+ const dump = await db.readBackupJson(req.path);
return await handleImportDb(wex, {
dump,
});
@@ -2132,7 +2140,7 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
[WalletApiOperation.TestingGetDbStats]: {
codec: codecForEmptyObject(),
handler: async (wex) => {
- return wex.ws.dbImplementation.getStats();
+ return (wex.ws.db.getDiagnosticStats?.() ?? {}) as any;
},
},
[WalletApiOperation.ExportDbToFile]: {
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -557,23 +557,14 @@ export function applyRunConfigDefaults(
export type HttpFactory = (config: WalletRunConfig) => HttpRequestLibrary;
-export interface WalletDatabaseImplementation {
- idbFactory: BridgeIDBFactory;
- /**
- * Native sqlite3 wallet database, when the host has been asked for one.
- *
- * When present, DAL transactions run against this instead of the IndexedDB
- * emulation. Opt-in and experimental: see {@link nativeDbEnabled}.
- */
- nativeSqliteDb?: NativeSqliteWalletDb;
- getStats: () => AccessStats;
- exportToFile: (
- directory: string,
- stem: string,
- forceFormat?: string,
- ) => Promise<{ path: string }>;
- readBackupJson(path: string): Promise<any>;
-}
+/**
+ * How a host hands its database to the wallet.
+ *
+ * Was an interface of its own describing an IndexedDB factory plus an
+ * optional native handle; the host now opens exactly one backend and passes
+ * the handle for it, so there is nothing left to describe separately.
+ */
+export type WalletDatabaseImplementation = WalletDbHandle;
/**
* Public handle to a running wallet.
@@ -583,13 +574,13 @@ export class Wallet {
private _client: WalletCoreApiClient | undefined;
private constructor(
- dbImplementation: WalletDatabaseImplementation,
+ dbHandle: WalletDbHandle,
httpFactory: HttpFactory,
timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
) {
this.ws = new InternalWalletState(
- dbImplementation,
+ dbHandle,
httpFactory,
timer,
cryptoWorkerFactory,
@@ -604,13 +595,13 @@ export class Wallet {
}
static async create(
- dbImplementation: WalletDatabaseImplementation,
+ dbHandle: WalletDbHandle,
httpFactory: HttpFactory,
timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
): Promise<Wallet> {
const w = new Wallet(
- dbImplementation,
+ dbHandle,
httpFactory,
timer,
cryptoWorkerFactory,
@@ -678,7 +669,6 @@ export class InternalWalletState {
private _config: Readonly<WalletRunConfig> | undefined;
- private _dbHandle: WalletDbHandle | undefined = undefined;
private _http: HttpRequestLibrary | undefined = undefined;
@@ -703,17 +693,7 @@ export class InternalWalletState {
* 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),
- () => this.dbImplementation.getStats(),
- );
- }
- return this._dbHandle;
+ return this.dbHandle;
}
/**
@@ -830,11 +810,14 @@ export class InternalWalletState {
}
constructor(
- public dbImplementation: WalletDatabaseImplementation,
+ public dbHandle: WalletDbHandle,
private httpFactory: HttpFactory,
timer: TimerAPI,
cryptoWorkerFactory: CryptoWorkerFactory,
) {
+ // The host opened the database before this wallet existed, so its
+ // notifications had nowhere to go until now.
+ dbHandle.setNotificationSink((n) => this.notify(n));
this.cryptoDispatcher = new CryptoDispatcher(cryptoWorkerFactory);
this.cryptoApi = this.cryptoDispatcher.cryptoApi;
this.timerGroup = new TimerGroup(timer);
diff --git a/packages/taler-wallet-webextension/src/wxBackend.ts b/packages/taler-wallet-webextension/src/wxBackend.ts
@@ -54,6 +54,7 @@ import {
deleteTalerDatabase,
exportDb,
importDb,
+ IdbWalletDbHandle,
} from "@gnu-taler/taler-wallet-core";
import { BrowserFetchHttpLib } from "@gnu-taler/web-util/browser";
import { MessageFromFrontend } from "./platform/api.js";
@@ -476,19 +477,13 @@ async function reinitWallet(): Promise<void> {
const settings = await platform.getSettingsFromStorage();
logger.info("Setting up wallet");
+ // The browser's own IndexedDB, not an emulation. exportToFile,
+ // readBackupJson and getDiagnosticStats are left unset: a browser extension
+ // has no filesystem, and the handlers that need them now say so by name
+ // instead of calling a method that throws.
+ const walletDb = new IdbWalletDbHandle(indexedDB as any);
const wallet = await Wallet.create(
- {
- idbFactory: indexedDB as any,
- readBackupJson(path) {
- throw Error("DB file export not supported for platform webext");
- },
- exportToFile() {
- throw Error("DB file export not supported for platform webext");
- },
- getStats() {
- throw Error("DB statistics not supported for platform webext");
- },
- },
+ walletDb,
httpFactory as any as HttpFactory,
timer,
cryptoWorker,