taler-typescript-core

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

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:
Mpackages/taler-wallet-cli/src/index.ts | 42++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/db-converter.test.ts | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-wallet-core/src/db-converter.ts | 505+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db-indexeddb.ts | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/host-impl.node.ts | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/taler-wallet-core/src/index.node.ts | 6++++++
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";