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:
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?.();
}