commit febffad71b6218071c293595ae6911ef6772b093
parent 903e89eab98470763853a74411cab34cf66cdcb4
Author: Florian Dold <dold@taler.net>
Date: Fri, 28 Aug 2026 23:14:58 +0200
wallet-core: expose cross-backend imports through the API
Diffstat:
5 files changed, 196 insertions(+), 24 deletions(-)
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -3815,11 +3815,15 @@ export const codecForGetActiveTasks = (): Codec<GetActiveTasksResponse> =>
export interface ImportDbRequest {
dump?: any;
+
+ /** Correlates progress notifications and allows cancellation. */
+ progressToken?: string;
}
export const codecForImportDbRequest = (): Codec<ImportDbRequest> =>
buildCodecForObject<ImportDbRequest>()
.property("dump", codecForAny())
+ .property("progressToken", codecOptional(codecForString()))
.build("ImportDbRequest");
export interface ForcedDenomSel {
@@ -5030,8 +5034,8 @@ export interface ExportDbToFileRequest {
/**
* Force the format of the export.
*
- * Currently only "json" is supported as a forced
- * export format.
+ * Supported values on filesystem-capable hosts are "json" and "sqlite3".
+ * If omitted, the host's default is "sqlite3".
*/
forceFormat?: string;
}
@@ -5055,12 +5059,16 @@ export interface ImportDbFromFileRequest {
* Full path to the backup.
*/
path: string;
+
+ /** Correlates progress notifications and allows cancellation. */
+ progressToken?: string;
}
export const codecForImportDbFromFileRequest =
(): Codec<ImportDbFromFileRequest> =>
buildCodecForObject<ImportDbFromFileRequest>()
.property("path", codecForString())
+ .property("progressToken", codecOptional(codecForString()))
.build("ImportDbFromFileRequest");
export interface CompleteBaseUrlRequest {
diff --git a/packages/taler-wallet-core/src/db/migration/native.test.ts b/packages/taler-wallet-core/src/db/migration/native.test.ts
@@ -706,6 +706,15 @@ test("useNativeDb initializes empty storage directly as native", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wallet-native-empty-"));
const dbPath = path.join(dir, "wallet.sqlite3");
try {
+ const foreign = await makeMinimalIdbDb();
+ const foreignDumpPath = path.join(dir, "indexeddb-export.json");
+ fs.writeFileSync(
+ foreignDumpPath,
+ JSON.stringify(await foreign.handle.exportDatabase()),
+ );
+ await foreign.handle.close();
+ await foreign.db.close();
+
const { wallet } = await createNativeWalletHost2({
persistentStoragePath: dbPath,
});
@@ -717,6 +726,22 @@ test("useNativeDb initializes empty storage directly as native", async () => {
},
});
assert.strictEqual(init.databaseBackend, "sqlite");
+ await wallet.client.call(WalletApiOperation.ImportDbFromFile, {
+ path: foreignDumpPath,
+ progressToken: "native-file-cross-import",
+ });
+ const imported = await wallet.client.call(WalletApiOperation.ExportDb, {});
+ assert.ok("tables" in imported);
+ assert.match(JSON.stringify(imported), /fault-test/);
+
+ const jsonExport = await wallet.client.call(
+ WalletApiOperation.ExportDbToFile,
+ { directory: dir, stem: "native-export", forceFormat: "json" },
+ );
+ assert.strictEqual(jsonExport.path, path.join(dir, "native-export.json"));
+ const jsonDump = JSON.parse(fs.readFileSync(jsonExport.path, "utf-8"));
+ assert.ok("tables" in jsonDump);
+ assert.match(JSON.stringify(jsonDump), /fault-test/);
await wallet.client.call(WalletApiOperation.Shutdown, {});
const inspection = await inspectWalletDbPath(dbPath);
diff --git a/packages/taler-wallet-core/src/requests.test.ts b/packages/taler-wallet-core/src/requests.test.ts
@@ -46,6 +46,7 @@ import {
import { WalletDbTransaction } from "./db/transaction.js";
import { makeSqliteRunner } from "./db/testing/runners.js";
import { IdbWalletDbHandle } from "./db/indexeddb/handle.js";
+import { importWalletDbDump } from "./db/migration/import.js";
import { markExchangeAddedByUser } from "./exchanges.js";
import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js";
import {
@@ -152,6 +153,84 @@ for (const [expectedBackend, makeRunner] of backendCases) {
});
}
+test("importDb request converts a foreign backend dump", async () => {
+ const source = await makeUnopenedIdbRunner();
+ const target = await makeSqliteRunner();
+ const sqlite3Impl = await createNodeHelperSqlite3Impl({
+ enableTracing: false,
+ });
+ target.importAnyDatabase = async (dump, finalize, options) =>
+ await importWalletDbDump(sqlite3Impl, target, dump, finalize, options);
+ await source.runReadWriteTx(async (tx) => {
+ await tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 2 });
+ await tx.upsertTombstone({ id: "request-cross-import" });
+ });
+ await target.runReadWriteTx((tx) =>
+ tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 1 }),
+ );
+ const http = {
+ async fetch(): Promise<never> {
+ throw Error("unexpected HTTP request");
+ },
+ } as HttpRequestLibrary;
+ const wallet = await Wallet.create(
+ target,
+ () => http,
+ new SetTimeoutTimerAPI(),
+ new SynchronousCryptoWorkerFactoryPlain(),
+ );
+ const progress: DatabaseMaintenanceProgressNotification[] = [];
+ wallet.addNotificationListener((notification) => {
+ if (notification.type === NotificationType.DatabaseMaintenanceProgress) {
+ progress.push(notification);
+ }
+ });
+ let initialized = false;
+ try {
+ await wallet.client.call(WalletApiOperation.SetWalletRunConfig, {
+ config: {
+ lazyTaskLoop: true,
+ testing: { skipDefaults: true },
+ features: { migrateNativeDb: false },
+ },
+ });
+ initialized = true;
+ await wallet.client.call(WalletApiOperation.ImportDb, {
+ dump: await source.exportDatabase(),
+ progressToken: "request-cross-import-progress",
+ });
+ assert.strictEqual(
+ (
+ await target.runReadWriteTx((tx) =>
+ tx.getConfig(ConfigRecordKey.TestLoopTx),
+ )
+ )?.value,
+ 2,
+ );
+ assert.ok(
+ progress.some(
+ (n) =>
+ n.operation === "cross-backend-import" &&
+ n.phase === "complete" &&
+ n.progressToken === "request-cross-import-progress",
+ ),
+ );
+ await assert.rejects(
+ wallet.client.call(WalletApiOperation.ImportDb, { dump: {} }),
+ (error: unknown) =>
+ error instanceof TalerError &&
+ error.errorDetail.code === TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ );
+ } finally {
+ await source.close();
+ if (initialized) {
+ await wallet.client.call(WalletApiOperation.Shutdown, {});
+ } else {
+ await target.close();
+ }
+ }
+});
+
interface TestContext {
wex: WalletExecutionContext;
getStoredRecord(): ConfigRecord | undefined;
diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts
@@ -40,6 +40,7 @@ import {
Amounts,
CanonicalizeBaseUrlRequest,
CanonicalizeBaseUrlResponse,
+ CancellationToken,
Codec,
CoinDumpJson,
CoinStatus,
@@ -289,6 +290,8 @@ import {
} from "./db/records.js";
import { walletDbFixups } from "./db/indexeddb/fixups.js";
import { IdbWalletDbHandle } from "./db/indexeddb/handle.js";
+import { getWalletDbDumpBackend } from "./db/migration/import.js";
+import { WalletDbTransaction } from "./db/transaction.js";
import {
isCandidateWithdrawableDenomRec,
isWithdrawableDenom,
@@ -1909,13 +1912,60 @@ async function handleImportDb(
wex: WalletExecutionContext,
req: ImportDbRequest,
): Promise<EmptyObject> {
+ return await runWithMaybeProgressContext(
+ wex,
+ WalletApiOperation.ImportDb,
+ req.progressToken,
+ async () => await importDbDump(wex, req.dump, req.progressToken),
+ );
+}
+
+async function importDbDump(
+ wex: WalletExecutionContext,
+ dump: unknown,
+ progressToken?: string,
+): Promise<EmptyObject> {
+ const dumpBackend = getWalletDbDumpBackend(dump);
+ if (!dumpBackend) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "dump" },
+ "doesn't look like a valid wallet database dump",
+ );
+ }
// Import, backend repairs and the derived transaction view become visible
// together. The backend keeps the old database authoritative if this
// finalizer throws.
- await wex.ws.db.importDatabase(req.dump, async (tx) => {
+ const db = wex.ws.db;
+ const finalize = async (tx: WalletDbTransaction): Promise<void> => {
await rematerializeTransactionsAtCurrentVersion(wex, tx);
await recomputeCoinAvailabilityAtCurrentVersion(tx);
- });
+ };
+ try {
+ if (db.importAnyDatabase) {
+ await db.importAnyDatabase(dump, finalize, {
+ cancellationToken: wex.cancellationToken,
+ progressToken,
+ });
+ } else {
+ if (dumpBackend !== db.name) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DB_BACKEND_UNSUPPORTED,
+ { backend: db.name },
+ `the ${db.name} backend cannot import a ${dumpBackend} dump`,
+ );
+ }
+ await db.importDatabase(dump, finalize);
+ }
+ } catch (e) {
+ if (e instanceof CancellationToken.CancellationError) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED,
+ {},
+ );
+ }
+ throw e;
+ }
// The import replaced the database underneath the DAL, writing through the
// raw IndexedDB handle, so none of the automatic invalidation saw it. Every
@@ -1929,26 +1979,30 @@ async function handleImportDbFromFile(
wex: WalletExecutionContext,
req: ImportDbFromFileRequest,
): Promise<EmptyObject> {
- if (req.path.endsWith(".json")) {
- const db = wex.ws.db;
- if (!db.readBackupJson) {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_DB_BACKEND_UNSUPPORTED,
- { backend: db.name },
- `the ${db.name} backend cannot read a database dump from a file`,
- );
- }
- const dump = await db.readBackupJson(req.path);
- return await handleImportDb(wex, {
- dump,
- });
- } else {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
- { parameter: "path" },
- "DB file import only supports .json files at the moment",
- );
- }
+ return await runWithMaybeProgressContext(
+ wex,
+ WalletApiOperation.ImportDbFromFile,
+ req.progressToken,
+ async () => {
+ if (!req.path.endsWith(".json")) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "path" },
+ "DB file import only supports .json files at the moment",
+ );
+ }
+ const db = wex.ws.db;
+ if (!db.readBackupJson) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_DB_BACKEND_UNSUPPORTED,
+ { backend: db.name },
+ `the ${db.name} backend cannot read a database dump from a file`,
+ );
+ }
+ const dump = await db.readBackupJson(req.path);
+ return await importDbDump(wex, dump, req.progressToken);
+ },
+ );
}
async function handleAcceptBankIntegratedWithdrawal(
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -2010,9 +2010,15 @@ export const walletApiExpectedErrors = {
[WalletApiOperation.ExportDbToFile]: [
TalerErrorCode.WALLET_DB_BACKEND_UNSUPPORTED,
],
+ [WalletApiOperation.ImportDb]: [
+ TalerErrorCode.WALLET_DB_BACKEND_UNSUPPORTED,
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED,
+ ],
[WalletApiOperation.ImportDbFromFile]: [
TalerErrorCode.WALLET_DB_BACKEND_UNSUPPORTED,
TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ TalerErrorCode.WALLET_CORE_REQUEST_CANCELLED,
],
[WalletApiOperation.MigrateDatabase]: [
TalerErrorCode.WALLET_DB_BACKEND_UNSUPPORTED,