taler-typescript-core

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

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:
Mpackages/taler-util/src/types-taler-wallet.ts | 12++++++++++--
Mpackages/taler-wallet-core/src/db/migration/native.test.ts | 25+++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.test.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.ts | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 6++++++
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,