taler-typescript-core

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

commit 903e89eab98470763853a74411cab34cf66cdcb4
parent 3c05c86032e712ba02b3ad93d7b6d59d73f0b8c9
Author: Florian Dold <dold@taler.net>
Date:   Fri, 28 Aug 2026 23:14:46 +0200

wallet-core: add cross-backend import host capability

Diffstat:
Mpackages/taler-wallet-core/src/db/handle.ts | 12++++++++++++
Mpackages/taler-wallet-core/src/host-impl.node.ts | 49++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/taler-wallet-core/src/host-impl.qtart.ts | 96+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mpackages/taler-wallet-core/src/wallet-db-gate.test.ts | 25+++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 13+++++++++++++
5 files changed, 156 insertions(+), 39 deletions(-)

diff --git a/packages/taler-wallet-core/src/db/handle.ts b/packages/taler-wallet-core/src/db/handle.ts @@ -91,6 +91,18 @@ export interface WalletDbHandle { */ importDatabase(dump: any, finalize: WalletDbImportFinalizer): Promise<void>; + /** + * Replace the database from either backend's JSON dump. + * + * Filesystem-capable hosts that carry both SQLite implementations install + * this capability. Other hosts retain same-backend importDatabase only. + */ + importAnyDatabase?( + dump: any, + finalize: WalletDbImportFinalizer, + options?: WalletDbMigrationOptions, + ): Promise<void>; + /** Remove all records, leaving an empty database of the current schema. */ clearDatabase(): Promise<void>; diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts @@ -60,9 +60,43 @@ import { import * as fs from "node:fs"; import { IdbWalletDbHandle } from "./db/indexeddb/handle.js"; import { SqliteWalletDbHandle } from "./db/sqlite/handle.js"; +import { importWalletDbDump } from "./db/migration/import.js"; const logger = new Logger("host-impl.node.ts"); +function addNodeDatabaseCapabilities(handle: WalletDbHandle): WalletDbHandle { + const exportPhysical = handle.exportToFile?.bind(handle); + handle.exportToFile = async (directory, stem, forceFormat) => { + if (forceFormat === "json") { + const path = `${directory}/${stem}.json`; + await fs.promises.writeFile( + path, + JSON.stringify(await handle.exportDatabase()), + "utf-8", + ); + return { path }; + } + if (forceFormat != null && forceFormat !== "sqlite3") { + throw Error(`forcing format ${forceFormat} not supported`); + } + if (!exportPhysical) { + throw Error(`${handle.name} cannot export a sqlite3 database file`); + } + return await exportPhysical(directory, stem, "sqlite3"); + }; + handle.readBackupJson = async (path) => + JSON.parse(await fs.promises.readFile(path, "utf-8")); + handle.importAnyDatabase = async (dump, finalize, options) => + await importWalletDbDump( + await createNodeHelperSqlite3Impl({ enableTracing: false }), + handle, + dump, + finalize, + options, + ); + return handle; +} + async function makeSqliteDb( args: DefaultNodeWalletArgs, ): Promise<WalletDbHandle> { @@ -108,7 +142,7 @@ async function makeSqliteDb( // dropping tables needs. await dropExpiredMigrationBackup(db); const ndb = await openNativeSqliteWalletDb(db); - return new SqliteWalletDbHandle(ndb); + return addNodeDatabaseCapabilities(new SqliteWalletDbHandle(ndb)); } if (process.env.TALER_WALLET_NATIVE_DB) { logger.warn( @@ -128,19 +162,24 @@ async function makeSqliteDb( new BridgeIDBFactory(myBackend), () => myBackend.accessStats, ); - handle.exportToFile = async (directory, stem) => { + handle.exportToFile = async (directory, stem, forceFormat) => { + if (forceFormat != null && forceFormat !== "sqlite3") { + throw Error(`forcing format ${forceFormat} not supported`); + } const path = `${directory}/${stem}.sqlite3`; await myBackend.backupToFile(path); return { path }; }; handle.getDiagnosticStats = () => myBackend.accessStats; handle.migrateToNative = async (options) => - (await migrateWalletDbToNative(db, handle, options)).handle; + addNodeDatabaseCapabilities( + (await migrateWalletDbToNative(db, handle, options)).handle, + ); if (kind === "empty") { handle.openNativeIfEmpty = async () => - openNativeWalletDbForEmptyStorage(db); + addNodeDatabaseCapabilities(await openNativeWalletDbForEmptyStorage(db)); } - return handle; + return addNodeDatabaseCapabilities(handle); } /** diff --git a/packages/taler-wallet-core/src/host-impl.qtart.ts b/packages/taler-wallet-core/src/host-impl.qtart.ts @@ -46,7 +46,6 @@ import { DefaultNodeWalletArgs, getSqlite3FilenameFromStoragePath, } from "./host-common.js"; -import { exportDb } from "./db/indexeddb/dump.js"; import { dropExpiredMigrationBackup, inspectWalletDbFile, @@ -58,11 +57,61 @@ import { Wallet } from "./wallet.js"; import { WalletDbHandle } from "./db/handle.js"; import { IdbWalletDbHandle } from "./db/indexeddb/handle.js"; import { SqliteWalletDbHandle } from "./db/sqlite/handle.js"; +import { importWalletDbDump } from "./db/migration/import.js"; const logger = new Logger("host-impl.qtart.ts"); let numStmt = 0; +function addQtartDatabaseCapabilities(handle: WalletDbHandle): WalletDbHandle { + const exportPhysical = handle.exportToFile?.bind(handle); + handle.exportToFile = async (directory, stem, forceFormat) => { + if (forceFormat === "json") { + const path = `${directory}/${stem}.json`; + const dump = JSON.stringify(await handle.exportDatabase()); + const errObj = { errno: undefined }; + const file = qjsStd.open(path, "w+", errObj); + if (!file) { + throw Error(`could not create file (errno=${errObj.errno})`); + } + try { + file.puts(dump); + } finally { + file.close(); + } + return { path }; + } + if (forceFormat != null && forceFormat !== "sqlite3") { + throw Error(`forcing format ${forceFormat} not supported`); + } + if (!exportPhysical) { + throw Error(`${handle.name} cannot export a sqlite3 database file`); + } + return await exportPhysical(directory, stem, "sqlite3"); + }; + handle.readBackupJson = async (path) => { + const errObj = { errno: undefined }; + const file = qjsStd.open(path, "r", errObj); + if (!file) { + throw Error(`could not open file (errno=${errObj.errno})`); + } + try { + return JSON.parse(file.readAsString()); + } finally { + file.close(); + } + }; + handle.importAnyDatabase = async (dump, finalize, options) => + await importWalletDbDump( + await createQtartSqlite3Impl(), + handle, + dump, + finalize, + options, + ); + return handle; +} + export async function createQtartSqlite3Impl(): Promise<Sqlite3Interface> { const tart: any = (globalThis as any)._tart; if (!tart) { @@ -134,7 +183,9 @@ async function makeSqliteDb( if (kind === "native") { logger.info("opening the wallet database with the native schema"); await dropExpiredMigrationBackup(db); - return new SqliteWalletDbHandle(await openNativeSqliteWalletDb(db)); + return addQtartDatabaseCapabilities( + new SqliteWalletDbHandle(await openNativeSqliteWalletDb(db)), + ); } const myBackend = await createSqliteBackendOverDb(imp, db); @@ -149,45 +200,22 @@ async function makeSqliteDb( 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, "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 { + if (forceFormat != null && forceFormat !== "sqlite3") { throw Error(`forcing format ${forceFormat} not supported`); } + const path = `${directory}/${stem}.sqlite3`; + await myBackend.backupToFile(path); + return { path }; }; handle.migrateToNative = async (options) => - (await migrateWalletDbToNative(db, handle, options)).handle; + addQtartDatabaseCapabilities( + (await migrateWalletDbToNative(db, handle, options)).handle, + ); if (kind === "empty") { handle.openNativeIfEmpty = async () => - openNativeWalletDbForEmptyStorage(db); + addQtartDatabaseCapabilities(await openNativeWalletDbForEmptyStorage(db)); } - 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; + return addQtartDatabaseCapabilities(handle); } export async function createNativeWalletHost2( diff --git a/packages/taler-wallet-core/src/wallet-db-gate.test.ts b/packages/taler-wallet-core/src/wallet-db-gate.test.ts @@ -121,6 +121,31 @@ test("database import has exclusive admission", async () => { ]); }); +test("cross-backend database import has exclusive admission", async () => { + const events: string[] = []; + const gate = new DbOperationGate(); + const handle = fakeHandle("db", events); + const importStarted = deferred(); + const releaseImport = deferred(); + handle.importAnyDatabase = async () => { + events.push("cross-import"); + importStarted.resolve(); + await releaseImport.promise; + }; + const admitted = new AdmittedWalletDbHandle(() => handle, gate); + + const importing = admitted.importAnyDatabase!({}, async () => {}); + const afterImport = admitted.runReadWriteTx(async () => { + events.push("after"); + }); + + await importStarted.promise; + assert.deepStrictEqual(events, ["cross-import"]); + releaseImport.resolve(); + await Promise.all([importing, afterImport]); + assert.deepStrictEqual(events, ["cross-import", "db:tx", "after"]); +}); + test("database gate cancels an exclusive waiter", async () => { const gate = new DbOperationGate(); const active = deferred(); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -227,6 +227,7 @@ export class AdmittedWalletDbHandle implements WalletDbHandle { } exportToFile?: WalletDbHandle["exportToFile"]; readBackupJson?: WalletDbHandle["readBackupJson"]; + importAnyDatabase?: WalletDbHandle["importAnyDatabase"]; getDiagnosticStats?: WalletDbHandle["getDiagnosticStats"]; constructor( @@ -251,6 +252,18 @@ export class AdmittedWalletDbHandle implements WalletDbHandle { return await fn.apply(current(), args); }); } + if (current().importAnyDatabase) { + this.importAnyDatabase = (...args) => + gate.runExclusive(async () => { + const db = current(); + const fn = db.importAnyDatabase; + if (!fn) + throw Error( + "current database backend cannot import a foreign dump", + ); + return await fn.apply(db, args); + }); + } if (current().getDiagnosticStats) { this.getDiagnosticStats = () => current().getDiagnosticStats?.(); }