commit 9722d6179b6bbee7ed58cc075f7c1546a6b98de5
parent 4f84417f1cad133cb063324dddf409ce5e01a146
Author: Florian Dold <dold@taler.net>
Date: Mon, 20 Jul 2026 02:24:55 +0200
wallet: convert databases between backends
convertWalletDb copies every record from one WalletDbHandle to another
through the DAL.
Diffstat:
6 files changed, 920 insertions(+), 5 deletions(-)
diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts
@@ -65,6 +65,7 @@ import {
import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
import { JsonMessage, runRpcServer } from "@gnu-taler/taler-util/twrpc";
import {
+ convertWalletDbFile,
createNativeWalletHost2,
nativeCrypto,
Wallet,
@@ -1614,6 +1615,47 @@ const advancedCli = walletCli.subcommand("advancedArgs", "advanced", {
});
advancedCli
+ .subcommand("convertDb", "convert-db", {
+ help: "Convert a wallet database between storage backends.",
+ })
+ .requiredArgument("source", clk.STRING, {
+ help: "Existing wallet database file to read.",
+ })
+ .requiredArgument("dest", clk.STRING, {
+ help: "Fresh file to write the converted database to (must not exist).",
+ })
+ .flag("toIdb", ["--to-indexeddb"], {
+ help: "Convert native-sqlite to IndexedDB instead of the default IndexedDB to native-sqlite.",
+ })
+ .action(async (args) => {
+ // The source is never written to; the conversion goes into a fresh file
+ // and swapping it into place stays an explicit step for the operator.
+ const direction = args.convertDb.toIdb ? "to-idb" : "to-native";
+ const report = await convertWalletDbFile(
+ args.convertDb.source,
+ args.convertDb.dest,
+ direction,
+ );
+ console.log(
+ j2s({
+ direction,
+ source: args.convertDb.source,
+ dest: args.convertDb.dest,
+ totalRecords: report.totalRecords,
+ copied: report.copied,
+ }),
+ );
+ console.log(`conversion complete and verified; the original is untouched.`);
+ console.log(
+ `to switch the wallet over, move ${args.convertDb.dest} into place` +
+ ` yourself` +
+ (direction === "to-native"
+ ? ` and run the wallet with TALER_WALLET_NATIVE_DB=1.`
+ : ` and run the wallet without TALER_WALLET_NATIVE_DB.`),
+ );
+ });
+
+advancedCli
.subcommand("diagnostics", "diagnostics", {
help: "Print diagnostics info.",
})
diff --git a/packages/taler-wallet-core/src/db-converter.test.ts b/packages/taler-wallet-core/src/db-converter.test.ts
@@ -0,0 +1,175 @@
+/*
+ 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/>
+ */
+
+/**
+ * Tests for the backend-to-backend database converter.
+ *
+ * The database is populated by running the whole conformance corpus against
+ * the source, so the converter faces every record type the suite can
+ * produce rather than a hand-picked sample. The converter verifies its own
+ * copy record-by-record; the assertions here are that it succeeds, that it
+ * moved a plausible amount of data, and that it converts in both directions.
+ */
+
+import assert from "node:assert";
+import { test } from "node:test";
+
+import { SQLITE_BASELINE_SCHEMA } from "./db-sqlite-schema.js";
+import { convertWalletDb } from "./db-converter.js";
+import { conformanceCases } from "./dbtx-conformance-cases.js";
+import { ConformanceAsserts } from "./dbtx-conformance.js";
+import { makeIdbRunner, makeSqliteRunner } from "./dbtx-runners.js";
+
+/** Assertions that ignore case-internal failures: only the data matters. */
+const quietAsserts: ConformanceAsserts = {
+ equal: () => {},
+ deepEqual: () => {},
+ ok: () => {},
+ fail: () => {
+ throw Error("unreachable");
+ },
+};
+
+test("converter: IndexedDB to sqlite, populated by the conformance corpus", async () => {
+ const src = await makeIdbRunner();
+ for (const c of conformanceCases) {
+ try {
+ await c.run(quietAsserts, src);
+ } catch (e) {
+ // A case failing its own assertions is the suite's concern; what
+ // matters here is whatever data it managed to write.
+ }
+ }
+
+ const dst = await makeSqliteRunner();
+ // convertWalletDb re-enumerates both sides and compares every record;
+ // a thrown error here is the actual test.
+ const report = await convertWalletDb(src, dst);
+
+ assert.ok(
+ report.totalRecords >= 100,
+ `only ${report.totalRecords} records converted -- the corpus did not` +
+ ` populate the source, so the conversion proved nothing`,
+ );
+ // Every store in the plan must have been visited (0 records is fine for a
+ // store the corpus leaves empty; a missing key means the plan lost a step).
+ assert.ok(
+ Object.keys(report.copied).length >= 35,
+ `only ${Object.keys(report.copied).length} stores visited`,
+ );
+
+ await src.close();
+ await dst.close();
+});
+
+test("converter: sqlite to IndexedDB (reverse direction)", async () => {
+ const src = await makeSqliteRunner();
+ for (const c of conformanceCases) {
+ try {
+ await c.run(quietAsserts, src);
+ } catch (e) {
+ // See above.
+ }
+ }
+
+ const dst = await makeIdbRunner();
+ const report = await convertWalletDb(src, dst);
+ assert.ok(report.totalRecords >= 100);
+
+ await src.close();
+ await dst.close();
+});
+
+test("converter: the copy plan covers every table in the schema", async () => {
+ // An empty conversion still visits every step, so the report's keys are
+ // the plan's coverage. Comparing them against the schema's table list
+ // means a table added later cannot silently miss conversion: this test
+ // fails until the plan (and the mapping here) says what happens to it.
+ const src = await makeIdbRunner();
+ const dst = await makeSqliteRunner();
+ const report = await convertWalletDb(src, dst);
+ await src.close();
+ await dst.close();
+
+ // table -> step that carries it, or the reason no step is needed.
+ const coverage: Record<string, string> = {
+ schema_migrations: "EXCLUDED: describes the schema, not wallet data",
+ purchase_exchanges: "purchases", // stored inside the purchase record
+ config: "config",
+ currency_info: "currencyInfo",
+ contacts: "contacts",
+ mailbox_messages: "mailboxMessages",
+ mailbox_configurations: "mailboxConfigurations",
+ contract_terms: "contractTerms",
+ tombstones: "tombstones",
+ operation_retries: "operationRetries",
+ reserves: "reserves",
+ denominations: "denominations",
+ global_currency_exchanges: "globalCurrencyExchanges",
+ global_currency_auditors: "globalCurrencyAuditors",
+ bank_accounts: "bankAccounts",
+ tokens: "tokens",
+ slates: "slates",
+ refresh_sessions: "refreshSessions",
+ recoup_groups: "recoupGroups",
+ donation_summaries: "donationSummaries",
+ donation_planchets: "donationPlanchets",
+ donation_receipts: "donationReceipts",
+ purchases: "purchases",
+ deposit_groups: "depositGroups",
+ refresh_groups: "refreshGroups",
+ denom_loss_events: "denomLossEvents",
+ peer_push_debit: "peerPushDebit",
+ peer_push_credit: "peerPushCredit",
+ peer_pull_debit: "peerPullDebit",
+ peer_pull_credit: "peerPullCredit",
+ transactions_meta: "transactionsMeta",
+ exchanges: "exchanges",
+ exchange_details: "exchangeDetails",
+ exchange_sign_keys: "exchangeSignKeys",
+ denomination_families: "denominationFamilies",
+ exchange_base_url_fixups: "exchangeBaseUrlFixups",
+ exchange_base_url_migration_log: "exchangeBaseUrlMigrationLog",
+ withdrawal_groups: "withdrawalGroups",
+ planchets: "planchets",
+ coins: "coins",
+ coin_availability: "coinAvailability",
+ coin_history: "coinHistory",
+ refund_groups: "refundGroups",
+ refund_items: "refundItems",
+ };
+
+ const tables = [
+ ...SQLITE_BASELINE_SCHEMA.matchAll(/CREATE TABLE IF NOT EXISTS (\w+)/g),
+ ].map((m) => m[1]);
+ assert.ok(tables.length >= 44, "schema parse failed");
+
+ const visited = new Set(Object.keys(report.copied));
+ for (const table of tables) {
+ const mapped = coverage[table];
+ assert.ok(
+ mapped !== undefined,
+ `table ${table} is not accounted for in the conversion plan --` +
+ ` add a copy step for it, or record here why none is needed`,
+ );
+ if (!mapped.startsWith("EXCLUDED")) {
+ assert.ok(
+ visited.has(mapped),
+ `table ${table} maps to step ${mapped}, which the plan did not visit`,
+ );
+ }
+ }
+});
diff --git a/packages/taler-wallet-core/src/db-converter.ts b/packages/taler-wallet-core/src/db-converter.ts
@@ -0,0 +1,505 @@
+/*
+ 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/>
+ */
+
+/**
+ * Conversion between wallet database backends.
+ *
+ * The converter copies records through the DAL: it reads every entity from
+ * one {@link WalletDbHandle} and writes it into another, knowing nothing
+ * about either storage layout. Opening the IndexedDB source runs the fixup
+ * log, so the converted database contains repaired records by construction --
+ * the native schema has no fixup log to replay.
+ *
+ * Auto-generated serials (reserves, exchange details, denomination families,
+ * refund items) are preserved: other records reference them, and every upsert
+ * honours a supplied key.
+ *
+ * The caller supplies a fresh, empty destination and swaps files afterwards;
+ * nothing here mutates the source.
+ */
+
+import { Logger } from "@gnu-taler/taler-util";
+
+import { WalletDbHandle } from "./dbtx-handle.js";
+import { WalletDbTransaction } from "./dbtx.js";
+
+const logger = new Logger("db-converter.ts");
+
+/**
+ * One entity to copy.
+ *
+ * `read` enumerates every record through the DAL; `write` stores one.
+ * `normalize` is applied before the verification comparison, for the few
+ * stores where the destination legitimately assigns a fresh value (the
+ * global-currency ids, which nothing else references).
+ */
+interface CopyStep {
+ name: string;
+ read: (tx: WalletDbTransaction) => Promise<unknown[]>;
+ write: (tx: WalletDbTransaction, rec: unknown) => Promise<unknown>;
+ normalize?: (rec: unknown) => unknown;
+}
+
+function step<T>(
+ name: string,
+ read: (tx: WalletDbTransaction) => Promise<T[]>,
+ write: (tx: WalletDbTransaction, rec: T) => Promise<unknown>,
+ normalize?: (rec: T) => unknown,
+): CopyStep {
+ return {
+ name,
+ read: read as CopyStep["read"],
+ write: write as CopyStep["write"],
+ normalize: normalize as CopyStep["normalize"],
+ };
+}
+
+const stripId = (rec: unknown): unknown => {
+ const { id, ...rest } = rec as Record<string, unknown>;
+ return rest;
+};
+
+/**
+ * Fields old databases still carry that no current record type has.
+ *
+ * The IndexedDB backend returns stored records as-is, unknown fields
+ * included; the modern code ignores them and the sqlite mapper cannot store
+ * them. Conversion drops them -- that is the "as if every fixup had run"
+ * shape of the data -- but only fields on this list: an unknown field NOT
+ * listed here fails verification, so a genuinely lost field cannot be
+ * mistaken for legacy junk without a human putting its name here.
+ */
+const LEGACY_FIELDS: Record<string, string[]> = {
+ // Removed 2024-06-13 ("remove coinAllocationId, simplify coin history").
+ coins: ["spendAllocation"],
+ // Dropped when denomination records were restructured; nothing reads it.
+ denominations: ["listIssueDate"],
+ // Superseded by per-selection UIDs inside denomsSel; removed 2024-06-10.
+ withdrawalGroups: ["denomSelUid"],
+ // Documented in db-common.ts as a reserved legacy field (v1 refresh); the
+ // current protocol derives a public seed on demand instead.
+ refreshSessions: ["sessionSecretSeed"],
+};
+
+function stripLegacy(
+ stepName: string,
+): ((rec: unknown) => unknown) | undefined {
+ const fields = LEGACY_FIELDS[stepName];
+ if (!fields) return undefined;
+ return (rec: unknown) => {
+ const out = { ...(rec as Record<string, unknown>) };
+ for (const f of fields) {
+ delete out[f];
+ }
+ return out;
+ };
+}
+
+/**
+ * The copy plan, in groups.
+ *
+ * Each group runs in one destination transaction, and groups run in
+ * ownership order (parents before their children), so every commit leaves
+ * the destination's deferred foreign keys satisfied.
+ */
+const COPY_PLAN: CopyStep[][] = [
+ [
+ step(
+ "config",
+ (tx) => tx.listAllConfig(),
+ (tx, r) => tx.upsertConfig(r),
+ ),
+ step(
+ "currencyInfo",
+ (tx) => tx.listAllCurrencyInfo(),
+ (tx, r) => tx.upsertCurrencyInfoEntry(r),
+ ),
+ step(
+ "contacts",
+ (tx) => tx.listContacts(),
+ (tx, r) => tx.addContact(r),
+ ),
+ step(
+ "mailboxMessages",
+ (tx) => tx.listMailboxMessages(),
+ (tx, r) => tx.upsertMailboxMessage(r),
+ ),
+ step(
+ "mailboxConfigurations",
+ (tx) => tx.listAllMailboxConfigurations(),
+ (tx, r) => tx.upsertMailboxConfiguration(r),
+ ),
+ step(
+ "contractTerms",
+ (tx) => tx.listAllContractTerms(),
+ (tx, r) => tx.upsertContractTerms(r),
+ ),
+ step(
+ "tombstones",
+ (tx) => tx.listAllTombstones(),
+ (tx, r) => tx.upsertTombstone(r),
+ ),
+ step(
+ "operationRetries",
+ (tx) => tx.listAllOperationRetries(),
+ (tx, r) => tx.upsertOperationRetry(r),
+ ),
+ step(
+ "bankAccounts",
+ (tx) => tx.listBankAccounts(),
+ (tx, r) => tx.upsertBankAccount(r),
+ ),
+ step(
+ "globalCurrencyExchanges",
+ (tx) => tx.listGlobalCurrencyExchanges(),
+ (tx, r) => tx.addGlobalCurrencyExchange(r),
+ stripId,
+ ),
+ step(
+ "globalCurrencyAuditors",
+ (tx) => tx.listGlobalCurrencyAuditors(),
+ (tx, r) => tx.addGlobalCurrencyAuditor(r),
+ stripId,
+ ),
+ step(
+ "exchangeBaseUrlFixups",
+ (tx) => tx.listAllExchangeBaseUrlFixups(),
+ (tx, r) => tx.upsertExchangeBaseUrlFixup(r),
+ ),
+ step(
+ "exchangeBaseUrlMigrationLog",
+ (tx) => tx.listAllExchangeMigrationLogEntries(),
+ (tx, r) => tx.upsertExchangeMigrationLog(r),
+ ),
+ ],
+ [
+ // Reserves before exchanges: an exchange entry may reference its merge
+ // reserve by row id.
+ step(
+ "reserves",
+ (tx) => tx.listAllReserves(),
+ (tx, r) => tx.upsertReserve(r),
+ ),
+ ],
+ [
+ step(
+ "exchanges",
+ (tx) => tx.getExchanges(),
+ (tx, r) => tx.upsertExchange(r),
+ ),
+ ],
+ [
+ step(
+ "exchangeDetails",
+ (tx) => tx.listAllExchangeDetails(),
+ (tx, r) => tx.upsertExchangeDetails(r),
+ ),
+ ],
+ [
+ step(
+ "exchangeSignKeys",
+ async (tx) => {
+ const out = [];
+ for (const det of await tx.listAllExchangeDetails()) {
+ if (det.rowId == null) continue;
+ out.push(...(await tx.getExchangeSignKeysByDetailsRowId(det.rowId)));
+ }
+ return out;
+ },
+ (tx, r) => tx.upsertExchangeSignKey(r),
+ ),
+ step(
+ "denominationFamilies",
+ (tx) => tx.listAllDenominationFamilies(),
+ (tx, r) => tx.upsertDenominationFamily(r),
+ ),
+ ],
+ [
+ step(
+ "denominations",
+ (tx) => tx.listAllDenominations(),
+ (tx, r) => tx.upsertDenomination(stripLegacy("denominations")!(r) as any),
+ stripLegacy("denominations"),
+ ),
+ ],
+ [
+ step(
+ "withdrawalGroups",
+ (tx) => tx.listAllWithdrawalGroups(),
+ (tx, r) =>
+ tx.upsertWithdrawalGroup(stripLegacy("withdrawalGroups")!(r) as any),
+ stripLegacy("withdrawalGroups"),
+ ),
+ step(
+ "purchases",
+ (tx) => tx.listAllPurchases(),
+ (tx, r) => tx.upsertPurchase(r),
+ ),
+ step(
+ "refreshGroups",
+ (tx) => tx.listAllRefreshGroups(),
+ (tx, r) => tx.upsertRefreshGroup(r),
+ ),
+ step(
+ "coins",
+ (tx) => tx.listAllCoins(),
+ // Written through the strip too, so an IndexedDB destination does not
+ // re-preserve the junk the conversion exists to shed.
+ (tx, r) => tx.upsertCoin(stripLegacy("coins")!(r) as any),
+ stripLegacy("coins"),
+ ),
+ ],
+ [
+ step(
+ "planchets",
+ async (tx) => {
+ const out = [];
+ for (const wg of await tx.listAllWithdrawalGroups()) {
+ out.push(...(await tx.getPlanchetsByGroup(wg.withdrawalGroupId)));
+ }
+ return out;
+ },
+ (tx, r) => tx.upsertPlanchet(r),
+ ),
+ step(
+ "refreshSessions",
+ async (tx) => {
+ const out = [];
+ for (const rg of await tx.listAllRefreshGroups()) {
+ out.push(...(await tx.getRefreshSessionsByGroup(rg.refreshGroupId)));
+ }
+ return out;
+ },
+ (tx, r) =>
+ tx.upsertRefreshSession(stripLegacy("refreshSessions")!(r) as any),
+ stripLegacy("refreshSessions"),
+ ),
+ step(
+ "coinHistory",
+ async (tx) => {
+ const out = [];
+ for (const c of await tx.listAllCoins()) {
+ const h = await tx.getCoinHistory(c.coinPub);
+ if (h) out.push(h);
+ }
+ return out;
+ },
+ (tx, r) => tx.upsertCoinHistory(r),
+ ),
+ step(
+ "coinAvailability",
+ (tx) => tx.getCoinAvailabilities(),
+ (tx, r) => tx.upsertCoinAvailability(r),
+ ),
+ step(
+ "refundGroups",
+ async (tx) => {
+ const out = [];
+ for (const p of await tx.listAllPurchases()) {
+ out.push(...(await tx.getRefundGroupsByProposal(p.proposalId)));
+ }
+ return out;
+ },
+ (tx, r) => tx.upsertRefundGroup(r),
+ ),
+ step(
+ "tokens",
+ (tx) => tx.listTokens(),
+ (tx, r) => tx.upsertToken(r),
+ ),
+ step(
+ "slates",
+ (tx) => tx.listAllSlates(),
+ (tx, r) => tx.upsertSlate(r),
+ ),
+ step(
+ "depositGroups",
+ (tx) => tx.listAllDepositGroups(),
+ (tx, r) => tx.upsertDepositGroup(r),
+ ),
+ step(
+ "recoupGroups",
+ (tx) => tx.listAllRecoupGroups(),
+ (tx, r) => tx.upsertRecoupGroup(r),
+ ),
+ step(
+ "denomLossEvents",
+ (tx) => tx.listAllDenomLossEvents(),
+ (tx, r) => tx.upsertDenomLossEvent(r),
+ ),
+ step(
+ "peerPushDebit",
+ (tx) => tx.listAllPeerPushDebits(),
+ (tx, r) => tx.upsertPeerPushDebit(r),
+ ),
+ step(
+ "peerPushCredit",
+ (tx) => tx.listAllPeerPushCredits(),
+ (tx, r) => tx.upsertPeerPushCredit(r),
+ ),
+ step(
+ "peerPullDebit",
+ (tx) => tx.listAllPeerPullDebits(),
+ (tx, r) => tx.upsertPeerPullDebit(r),
+ ),
+ step(
+ "peerPullCredit",
+ (tx) => tx.listAllPeerPullCredits(),
+ (tx, r) => tx.upsertPeerPullCredit(r),
+ ),
+ step(
+ "donationSummaries",
+ (tx) => tx.getDonationSummaries(),
+ (tx, r) => tx.upsertDonationSummary(r),
+ ),
+ step(
+ "donationPlanchets",
+ (tx) => tx.listAllDonationPlanchets(),
+ (tx, r) => tx.upsertDonationPlanchet(r),
+ ),
+ step(
+ "donationReceipts",
+ (tx) => tx.listAllDonationReceipts(),
+ (tx, r) => tx.upsertDonationReceipt(r),
+ ),
+ step(
+ "transactionsMeta",
+ (tx) => tx.listTransactionMetaByTimestamp({}),
+ (tx, r) => tx.upsertTransactionMeta(r),
+ ),
+ ],
+ [
+ step(
+ "refundItems",
+ async (tx) => {
+ const out = [];
+ for (const p of await tx.listAllPurchases()) {
+ for (const rg of await tx.getRefundGroupsByProposal(p.proposalId)) {
+ out.push(...(await tx.getRefundItemsByGroup(rg.refundGroupId)));
+ }
+ }
+ return out;
+ },
+ (tx, r) => tx.upsertRefundItem(r),
+ ),
+ ],
+];
+
+export interface DbConversionReport {
+ /** Records copied, per store. */
+ copied: Record<string, number>;
+ /** Total number of records copied. */
+ totalRecords: number;
+}
+
+/**
+ * JSON stringification with sorted object keys, so structurally equal
+ * records compare equal regardless of property insertion order -- the two
+ * backends do not construct records in the same order.
+ */
+function stableStringify(v: unknown): string {
+ return JSON.stringify(v, (_k, val) => {
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) {
+ const sorted: Record<string, unknown> = {};
+ for (const key of Object.keys(val).sort()) {
+ // JSON.stringify drops undefined-valued properties on its own, but
+ // only at the top of each value; do it explicitly so a record with
+ // an explicitly-undefined key compares equal to one without it.
+ if (val[key] !== undefined) {
+ sorted[key] = val[key];
+ }
+ }
+ return sorted;
+ }
+ return val;
+ });
+}
+
+/**
+ * Copy every record from src into dst, then verify the copy.
+ *
+ * Verification re-enumerates both databases through the same accessors and
+ * compares the full normalised record sets, not just counts: a mapper that
+ * drops a field produces equal counts and unequal records.
+ *
+ * Throws on any difference; the destination should then be discarded.
+ */
+export async function convertWalletDb(
+ src: WalletDbHandle,
+ dst: WalletDbHandle,
+): Promise<DbConversionReport> {
+ const copied: Record<string, number> = {};
+ let total = 0;
+
+ for (const group of COPY_PLAN) {
+ // Reads and writes use separate transactions on separate handles; the
+ // source is not written to at any point.
+ const batches: Array<{ step: CopyStep; recs: unknown[] }> = [];
+ await src.runReadWriteTx(async (tx) => {
+ for (const st of group) {
+ batches.push({ step: st, recs: await st.read(tx) });
+ }
+ });
+ await dst.runReadWriteTx(async (tx) => {
+ for (const { step: st, recs } of batches) {
+ for (const rec of recs) {
+ await st.write(tx, rec);
+ }
+ copied[st.name] = recs.length;
+ total += recs.length;
+ logger.trace(`copied ${recs.length} ${st.name}`);
+ }
+ });
+ }
+
+ // Verify: same enumeration on both sides, canonicalised and sorted.
+ for (const group of COPY_PLAN) {
+ for (const st of group) {
+ const norm = st.normalize ?? ((r: unknown) => r);
+ const a = (await src.runReadWriteTx((tx) => st.read(tx)))
+ .map((r) => stableStringify(norm(r)))
+ .sort();
+ const b = (await dst.runReadWriteTx((tx) => st.read(tx)))
+ .map((r) => stableStringify(norm(r)))
+ .sort();
+ if (a.length !== b.length) {
+ throw Error(
+ `conversion verification failed: ${st.name} has ${a.length}` +
+ ` records in the source but ${b.length} in the destination`,
+ );
+ }
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) {
+ // The lists are sorted, so one differing record misaligns every
+ // later pair; report the first record on each side without a
+ // partner rather than a misleading side-by-side of two different
+ // records.
+ const bs = new Set(b);
+ const onlySrc = a.find((x) => !bs.has(x));
+ const as = new Set(a);
+ const onlyDst = b.find((x) => !as.has(x));
+ throw Error(
+ `conversion verification failed: ${st.name} differs between` +
+ ` source and destination:\n only in src: ${onlySrc}\n` +
+ ` only in dst: ${onlyDst}`,
+ );
+ }
+ }
+ }
+ }
+
+ return { copied, totalRecords: total };
+}
diff --git a/packages/taler-wallet-core/src/db-indexeddb.ts b/packages/taler-wallet-core/src/db-indexeddb.ts
@@ -1476,6 +1476,23 @@ export interface FixupDescription {
* Fixups *must* be idempotent.
*/
export const walletDbFixups: FixupDescription[] = [
+ // Deduplicate reserve rows left behind by a version that inserted its
+ // merge reserve repeatedly. Needed for as long as pre-2024 databases can
+ // still be imported.
+ {
+ fn: fixup20260720DuplicateReserves,
+ name: "fixup20260720DuplicateReserves",
+ },
+ // Exchange details rows from before tinyAmount existed.
+ {
+ fn: fixup20260720ExchangeDetailsTinyAmount,
+ name: "fixup20260720ExchangeDetailsTinyAmount",
+ },
+ // Refresh groups from before refundRequests existed.
+ {
+ fn: fixup20260720RefreshGroupRefundRequests,
+ name: "fixup20260720RefreshGroupRefundRequests",
+ },
// Removing this would cause old transactions
// to show up under multiple exchanges
{
@@ -1799,6 +1816,117 @@ async function fixupCoinAvailabilityExchangePub(
}
}
+/**
+ * Backfill tinyAmount on exchange details rows from before the field existed.
+ *
+ * Uses the same default the keys update applies when an exchange reports no
+ * tiny_amount, so a backfilled row equals what the next update would have
+ * written anyway. Without this, deposits read undefined where the type
+ * promises an AmountString.
+ */
+async function fixup20260720ExchangeDetailsTinyAmount(
+ tx: WalletIndexedDbTransaction,
+): Promise<void> {
+ for (const det of await tx.exchangeDetails.getAll()) {
+ if ((det as any).tinyAmount === undefined) {
+ det.tinyAmount = `${det.currency}:0.01` as AmountString;
+ await tx.exchangeDetails.put(det);
+ }
+ }
+}
+
+/**
+ * Backfill refundRequests on refresh groups from before the field existed.
+ *
+ * The refresh task reads refundRequests[coinIndex] unguarded, so a group
+ * written before the field existed throws when the task touches it. An
+ * empty map is the correct backfill: those groups have no pending refund
+ * requests, or they would have been recorded.
+ */
+async function fixup20260720RefreshGroupRefundRequests(
+ tx: WalletIndexedDbTransaction,
+): Promise<void> {
+ for (const rg of await tx.refreshGroups.getAll()) {
+ let changed = false;
+ if ((rg as any).refundRequests === undefined) {
+ rg.refundRequests = {};
+ changed = true;
+ }
+ // originatingTransactionId used to be nested in a reasonDetails object;
+ // the modern record carries it top-level and reads it there.
+ const legacyDetails = (rg as any).reasonDetails;
+ if (
+ rg.originatingTransactionId === undefined &&
+ legacyDetails?.originatingTransactionId !== undefined
+ ) {
+ rg.originatingTransactionId = legacyDetails.originatingTransactionId;
+ changed = true;
+ }
+ if (legacyDetails !== undefined) {
+ delete (rg as any).reasonDetails;
+ changed = true;
+ }
+ if (changed) {
+ await tx.refreshGroups.put(rg);
+ }
+ }
+}
+
+/**
+ * Remove duplicate reserve rows.
+ *
+ * An older wallet version inserted its merge reserve again on each update
+ * instead of upserting, leaving several rows with identical key material
+ * under different row ids. Databases like that exist in the wild. The
+ * lowest row id is kept and the two references to reserve rows (the
+ * exchange's current merge reserve and peer-pull-credit merge reserves) are
+ * remapped onto it.
+ *
+ * Rows are only removed when reservePub AND reservePriv both match the kept
+ * row: two reserves sharing a pub with different privs would be actual
+ * corruption, which deleting would paper over, so those are left alone.
+ */
+async function fixup20260720DuplicateReserves(
+ tx: WalletIndexedDbTransaction,
+): Promise<void> {
+ const reserves = await tx.reserves.getAll();
+ // rowId of every dropped duplicate -> rowId of the row it duplicates.
+ const remap = new Map<number, number>();
+ const keptByPub = new Map<string, (typeof reserves)[number]>();
+ for (const r of reserves) {
+ if (r.rowId == null) continue;
+ const kept = keptByPub.get(r.reservePub);
+ if (!kept) {
+ keptByPub.set(r.reservePub, r);
+ continue;
+ }
+ if (kept.reservePriv === r.reservePriv && kept.rowId != null) {
+ remap.set(r.rowId, kept.rowId);
+ }
+ }
+ if (remap.size === 0) {
+ return;
+ }
+ for (const e of await tx.exchanges.getAll()) {
+ if (
+ e.currentMergeReserveRowId != null &&
+ remap.has(e.currentMergeReserveRowId)
+ ) {
+ e.currentMergeReserveRowId = remap.get(e.currentMergeReserveRowId)!;
+ await tx.exchanges.put(e);
+ }
+ }
+ for (const p of await tx.peerPullCredit.getAll()) {
+ if (remap.has(p.mergeReserveRowId)) {
+ p.mergeReserveRowId = remap.get(p.mergeReserveRowId)!;
+ await tx.peerPullCredit.put(p);
+ }
+ }
+ for (const droppedRowId of remap.keys()) {
+ await tx.reserves.delete(droppedRowId);
+ }
+}
+
export async function applyFixups(
db: DbAccess<typeof WalletIndexedDbStoresV1>,
): Promise<number> {
diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts
@@ -47,10 +47,9 @@ import {
} from "./dbtx-sqlite.js";
import { Wallet } from "./wallet.js";
import { WalletDbHandle } from "./dbtx-handle.js";
-import {
- IdbWalletDbHandle,
- SqliteWalletDbHandle,
-} from "./dbtx-handle-impl.js";
+import { convertWalletDb, DbConversionReport } from "./db-converter.js";
+import * as fs from "node:fs";
+import { IdbWalletDbHandle, SqliteWalletDbHandle } from "./dbtx-handle-impl.js";
const logger = new Logger("host-impl.node.ts");
@@ -96,7 +95,6 @@ async function makeSqliteDb(
};
handle.getDiagnosticStats = () => myBackend.accessStats;
return handle;
-
}
/**
@@ -104,6 +102,67 @@ async function makeSqliteDb(
*
* Extended version that allows getting DB stats.
*/
+/**
+ * Convert a wallet database between backends, file to file.
+ *
+ * The direction is chosen by `direction`: "to-native" reads an IndexedDB
+ * emulation file and writes a native sqlite file, "to-idb" the reverse.
+ * The source is opened read-only in effect (nothing writes to it) and the
+ * destination must not exist yet: conversion goes into a fresh file, and
+ * swapping files afterwards is the caller's explicit, reversible step.
+ */
+export async function convertWalletDbFile(
+ sourcePath: string,
+ destPath: string,
+ direction: "to-native" | "to-idb",
+): Promise<DbConversionReport> {
+ if (fs.existsSync(destPath)) {
+ throw Error(
+ `destination ${destPath} already exists; conversion only writes to a` +
+ ` fresh file`,
+ );
+ }
+ if (!fs.existsSync(sourcePath)) {
+ throw Error(`source database ${sourcePath} does not exist`);
+ }
+
+ const openIdb = async (filename: string) => {
+ const imp = await createNodeHelperSqlite3Impl();
+ const backend = await createSqliteBackend(imp, { filename });
+ return new IdbWalletDbHandle(new BridgeIDBFactory(backend));
+ };
+ const openNative = async (filename: string) => {
+ const imp = await createNodeHelperSqlite3Impl();
+ return new SqliteWalletDbHandle(
+ await openNativeSqliteWalletDb(await imp.open(filename)),
+ );
+ };
+
+ const src =
+ direction === "to-native"
+ ? await openIdb(sourcePath)
+ : await openNative(sourcePath);
+ const dst =
+ direction === "to-native"
+ ? await openNative(destPath)
+ : await openIdb(destPath);
+
+ try {
+ return await convertWalletDb(src, dst);
+ } catch (e) {
+ // A failed conversion must not leave a half-written destination around
+ // to be mistaken for a converted database.
+ await dst.close();
+ fs.rmSync(destPath, { force: true });
+ // WAL side files of a native destination.
+ fs.rmSync(`${destPath}-wal`, { force: true });
+ fs.rmSync(`${destPath}-shm`, { force: true });
+ throw e;
+ } finally {
+ await src.close();
+ }
+}
+
export async function createNativeWalletHost2(
args: DefaultNodeWalletArgs = {},
): Promise<{
diff --git a/packages/taler-wallet-core/src/index.node.ts b/packages/taler-wallet-core/src/index.node.ts
@@ -26,4 +26,10 @@ export * from "./crypto/workers/synchronousWorkerFactoryPlain.js";
// process, so this must not reach the browser entry point.
export * from "./dbtx-bench.js";
export { makeIdbRunner, makeSqliteRunner } from "./dbtx-runners.js";
+
+// Backend-to-backend database conversion. Node-only: it opens both
+// backends' files side by side.
+export { convertWalletDbFile } from "./host-impl.node.js";
+export { convertWalletDb } from "./db-converter.js";
+export type { DbConversionReport } from "./db-converter.js";
export type { DbTxRunner } from "./dbtx-conformance.js";