taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/dbtx-handle-impl.ts | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/taler-wallet-core/src/dbtx-handle.ts | 36++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-runners.ts | 8+++-----
Mpackages/taler-wallet-core/src/host-impl.node.ts | 105+++++++++++++++++++++++++++++++++----------------------------------------------
Mpackages/taler-wallet-core/src/host-impl.qtart.ts | 92+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/taler-wallet-core/src/index.ts | 2++
Mpackages/taler-wallet-core/src/requests.ts | 22+++++++++++++++-------
Mpackages/taler-wallet-core/src/wallet.ts | 51+++++++++++++++++----------------------------------
Mpackages/taler-wallet-webextension/src/wxBackend.ts | 19+++++++------------
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,