taler-typescript-core

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

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

wallet-core: convert database dumps across backends

Diffstat:
Mpackages/taler-util/src/notifications.ts | 5++++-
Mpackages/taler-wallet-core/src/db/handle.ts | 2++
Mpackages/taler-wallet-core/src/db/migration/converter.ts | 8++++++--
Apackages/taler-wallet-core/src/db/migration/import.test.ts | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/db/migration/import.ts | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 396 insertions(+), 3 deletions(-)

diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts @@ -386,7 +386,10 @@ export interface IdleNotification { /** Progress while startup fixups or a database migration hold the DB gate. */ export interface DatabaseMaintenanceProgressNotification { type: NotificationType.DatabaseMaintenanceProgress; - operation: "indexeddb-fixup" | "indexeddb-to-native-migration"; + operation: + | "indexeddb-fixup" + | "indexeddb-to-native-migration" + | "cross-backend-import"; /** Token of the API request that initiated this operation, when available. */ progressToken?: string; phase: "fixup" | "copy" | "verify" | "complete" | "failed"; diff --git a/packages/taler-wallet-core/src/db/handle.ts b/packages/taler-wallet-core/src/db/handle.ts @@ -46,6 +46,8 @@ export interface WalletDbMigrationOptions { progressToken?: string; } +export type WalletDbBackend = "indexeddb" | "sqlite"; + /** * Wallet-level work that must become visible in the same atomic import as the * restored records, such as rebuilding materialized views and counters. diff --git a/packages/taler-wallet-core/src/db/migration/converter.ts b/packages/taler-wallet-core/src/db/migration/converter.ts @@ -528,6 +528,9 @@ export interface DbConversionOptions { /** API progress token to attach to maintenance notifications. */ progressToken?: string; + /** Maintenance operation reported by this use of the converter. */ + operation?: DatabaseMaintenanceProgressNotification["operation"]; + /** * Called after a progress notification has been delivered to the source * handle. Throwing aborts the conversion, which lets callers inject a @@ -667,13 +670,14 @@ export async function convertWalletDb( options: DbConversionOptions = {}, ): Promise<DbConversionReport> { const copied: Record<string, number> = {}; + const operation = options.operation ?? "indexeddb-to-native-migration"; // Let the host display maintenance immediately. Counting is cheap, but an // old emulated database can still spend noticeable time opening its first // transaction on a phone. const initialNotification: DatabaseMaintenanceProgressNotification = { type: NotificationType.DatabaseMaintenanceProgress, - operation: "indexeddb-to-native-migration", + operation, phase: "copy", ...(options.progressToken ? { progressToken: options.progressToken } : {}), completedSteps: 0, @@ -718,7 +722,7 @@ export async function convertWalletDb( const phasePercent = Math.floor((50 * completedUnits) / totalUnits); const notification: DatabaseMaintenanceProgressNotification = { type: NotificationType.DatabaseMaintenanceProgress, - operation: "indexeddb-to-native-migration", + operation, phase, ...(options.progressToken ? { progressToken: options.progressToken } diff --git a/packages/taler-wallet-core/src/db/migration/import.test.ts b/packages/taler-wallet-core/src/db/migration/import.test.ts @@ -0,0 +1,216 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert"; +import { test } from "node:test"; + +import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; +import { + CancellationToken, + DatabaseMaintenanceProgressNotification, + NotificationType, +} from "@gnu-taler/taler-util"; + +import { ConfigRecordKey } from "../records.js"; +import { makeIdbRunner, makeSqliteRunner } from "../testing/runners.js"; +import { getWalletDbDumpBackend, importWalletDbDump } from "./import.js"; + +test("cross import recognizes only one current dump format", () => { + assert.strictEqual(getWalletDbDumpBackend({ databases: {} }), "indexeddb"); + assert.strictEqual( + getWalletDbDumpBackend({ schemaVersion: 1, tables: {} }), + "sqlite", + ); + assert.strictEqual(getWalletDbDumpBackend({}), undefined); + assert.strictEqual( + getWalletDbDumpBackend({ + databases: {}, + schemaVersion: 1, + tables: {}, + }), + undefined, + ); +}); + +for (const direction of [ + ["indexeddb", makeIdbRunner, "sqlite", makeSqliteRunner], + ["sqlite", makeSqliteRunner, "indexeddb", makeIdbRunner], +] as const) { + const [sourceName, makeSource, targetName, makeTarget] = direction; + test(`cross import converts ${sourceName} to ${targetName}`, async () => { + const source = await makeSource(); + const target = await makeTarget(); + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const notifications: DatabaseMaintenanceProgressNotification[] = []; + target.setNotificationSink((notification) => { + if (notification.type === NotificationType.DatabaseMaintenanceProgress) { + notifications.push(notification); + } + }); + try { + await source.runReadWriteTx(async (tx) => { + await tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 2, + }); + await tx.upsertTombstone({ id: "source-tombstone" }); + }); + await target.runReadWriteTx(async (tx) => { + await tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 1, + }); + await tx.upsertTombstone({ id: "old-target-tombstone" }); + }); + + await importWalletDbDump( + sqlite3Impl, + target, + await source.exportDatabase(), + async (tx) => { + await tx.upsertTombstone({ id: "import-finalizer" }); + }, + { progressToken: "cross-import-test" }, + ); + + await target.runReadWriteTx(async (tx) => { + assert.strictEqual( + (await tx.getConfig(ConfigRecordKey.TestLoopTx))?.value, + 2, + ); + const ids = (await tx.listAllTombstones()).map((x) => x.id).sort(); + assert.deepStrictEqual(ids, ["import-finalizer", "source-tombstone"]); + }); + assert.ok( + notifications.some( + (n) => + n.operation === "cross-backend-import" && + n.phase === "complete" && + n.progressToken === "cross-import-test" && + n.completionPercent === 100, + ), + ); + } finally { + await source.close(); + await target.close(); + } + }); +} + +test("cross import leaves the target authoritative when finalization fails", async () => { + const source = await makeIdbRunner(); + const target = await makeSqliteRunner(); + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + try { + await source.runReadWriteTx((tx) => + tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 2, + }), + ); + await target.runReadWriteTx((tx) => + tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 1, + }), + ); + + await assert.rejects( + importWalletDbDump( + sqlite3Impl, + target, + await source.exportDatabase(), + async (tx) => { + await tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 3, + }); + throw Error("injected finalizer failure"); + }, + ), + /injected finalizer failure/, + ); + assert.strictEqual( + ( + await target.runReadWriteTx((tx) => + tx.getConfig(ConfigRecordKey.TestLoopTx), + ) + )?.value, + 1, + ); + } finally { + await source.close(); + await target.close(); + } +}); + +test("cross import cancellation does not publish a partial target", async () => { + const source = await makeIdbRunner(); + const target = await makeSqliteRunner(); + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const cts = CancellationToken.create(); + target.setNotificationSink((notification) => { + if ( + notification.type === NotificationType.DatabaseMaintenanceProgress && + notification.operation === "cross-backend-import" && + notification.phase === "copy" && + (notification.processedRecords ?? 0) >= 100 + ) { + cts.cancel(); + } + }); + try { + await source.runReadWriteTx(async (tx) => { + for (let i = 0; i < 250; i++) { + await tx.upsertTombstone({ id: `cancel-source-${i}` }); + } + }); + await target.runReadWriteTx((tx) => + tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 1, + }), + ); + + await assert.rejects( + importWalletDbDump( + sqlite3Impl, + target, + await source.exportDatabase(), + async () => {}, + { cancellationToken: cts.token }, + ), + CancellationToken.CancellationError, + ); + assert.strictEqual( + ( + await target.runReadWriteTx((tx) => + tx.getConfig(ConfigRecordKey.TestLoopTx), + ) + )?.value, + 1, + ); + } finally { + await source.close(); + await target.close(); + } +}); diff --git a/packages/taler-wallet-core/src/db/migration/import.ts b/packages/taler-wallet-core/src/db/migration/import.ts @@ -0,0 +1,168 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Restore a dump produced by either wallet database backend. + * + * A foreign dump is first materialized through its own backend in a private + * in-memory SQLite database. Both schemas coexist in that one connection, + * allowing the regular DAL converter to copy and verify the wallet without a + * temporary file. The converted destination is then exported in the live + * backend's format and handed to its normal crash-atomic importer. + */ + +import { + BridgeIDBFactory, + createSqliteBackendOverDb, + Sqlite3Database, + Sqlite3Interface, +} from "@gnu-taler/idb-bridge"; +import { + CancellationToken, + DatabaseMaintenanceProgressNotification, + getErrorDetailFromException, + NotificationType, +} from "@gnu-taler/taler-util"; + +import { + WalletDbBackend, + WalletDbHandle, + WalletDbImportFinalizer, + WalletDbMigrationOptions, +} from "../handle.js"; +import { IdbWalletDbHandle } from "../indexeddb/handle.js"; +import { openNativeSqliteWalletDb } from "../sqlite/database.js"; +import { SqliteWalletDbHandle } from "../sqlite/handle.js"; +import { convertWalletDb, DB_CONVERSION_STEP_COUNT } from "./converter.js"; + +export function getWalletDbDumpBackend( + dump: unknown, +): WalletDbBackend | undefined { + if (dump === null || typeof dump !== "object" || Array.isArray(dump)) { + return undefined; + } + const obj = dump as Record<string, unknown>; + const isIndexedDb = Object.hasOwn(obj, "databases"); + const isSqlite = + Object.hasOwn(obj, "schemaVersion") && Object.hasOwn(obj, "tables"); + if (isIndexedDb === isSqlite) { + return undefined; + } + return isIndexedDb ? "indexeddb" : "sqlite"; +} + +export async function importWalletDbDump( + sqlite3Impl: Sqlite3Interface, + target: WalletDbHandle, + dump: unknown, + finalize: WalletDbImportFinalizer, + options: WalletDbMigrationOptions = {}, +): Promise<void> { + const sourceBackend = getWalletDbDumpBackend(dump); + if (!sourceBackend) { + throw Error("doesn't look like a valid wallet database dump"); + } + if (sourceBackend === target.name) { + options.cancellationToken?.throwIfCancelled(); + await target.importDatabase(dump, finalize); + return; + } + if (target.name !== "indexeddb" && target.name !== "sqlite") { + throw Error(`unknown wallet database backend: ${target.name}`); + } + + const operation = "cross-backend-import" as const; + let lastProgress: DatabaseMaintenanceProgressNotification | undefined; + let idbHandle: IdbWalletDbHandle | undefined; + let sqliteHandle: SqliteWalletDbHandle | undefined; + let rawDb: Sqlite3Database | undefined; + try { + rawDb = await sqlite3Impl.open(":memory:"); + const idbBackend = await createSqliteBackendOverDb(sqlite3Impl, rawDb); + idbHandle = new IdbWalletDbHandle(new BridgeIDBFactory(idbBackend)); + sqliteHandle = new SqliteWalletDbHandle( + await openNativeSqliteWalletDb(rawDb), + ); + + const source = sourceBackend === "indexeddb" ? idbHandle : sqliteHandle; + const destination = target.name === "indexeddb" ? idbHandle : sqliteHandle; + source.setNotificationSink((notification) => + target.emitNotification(notification), + ); + + options.cancellationToken?.throwIfCancelled(); + await source.importDatabase(dump, async () => {}); + options.cancellationToken?.throwIfCancelled(); + + const report = await convertWalletDb(source, destination, { + cancellationToken: + options.cancellationToken ?? CancellationToken.CONTINUE, + progressToken: options.progressToken, + operation, + onProgress(notification) { + lastProgress = notification; + }, + }); + options.cancellationToken?.throwIfCancelled(); + + const convertedDump = await destination.exportDatabase(); + options.cancellationToken?.throwIfCancelled(); + await target.importDatabase(convertedDump, finalize); + + target.emitNotification({ + type: NotificationType.DatabaseMaintenanceProgress, + operation, + phase: "complete", + ...(options.progressToken + ? { progressToken: options.progressToken } + : {}), + completedSteps: DB_CONVERSION_STEP_COUNT, + totalSteps: DB_CONVERSION_STEP_COUNT, + processedRecords: report.totalRecords, + totalRecords: report.totalRecords, + completionPercent: 100, + }); + } catch (e) { + target.emitNotification({ + type: NotificationType.DatabaseMaintenanceProgress, + operation, + phase: "failed", + ...(options.progressToken + ? { progressToken: options.progressToken } + : {}), + completedSteps: lastProgress?.completedSteps ?? 0, + totalSteps: DB_CONVERSION_STEP_COUNT, + ...(lastProgress?.processedRecords !== undefined + ? { processedRecords: lastProgress.processedRecords } + : {}), + ...(lastProgress?.totalRecords !== undefined + ? { totalRecords: lastProgress.totalRecords } + : {}), + completionPercent: lastProgress?.completionPercent ?? 0, + error: getErrorDetailFromException(e), + }); + throw e; + } finally { + if (idbHandle) { + await idbHandle.close().catch(() => {}); + } + if (sqliteHandle) { + await sqliteHandle.close().catch(() => {}); + } else if (rawDb) { + await rawDb.close().catch(() => {}); + } + } +}