taler-typescript-core

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

commit 6cb5806858f4a38f11f6b4328357d633431fdb8c
parent e4a8def3d04b9585cc45433b2e3f5bc7c057168f
Author: Florian Dold <dold@taler.net>
Date:   Mon, 31 Aug 2026 11:47:40 +0200

wallet-core: migrate legacy IndexedDB records to native database

Diffstat:
Mpackages/taler-util/src/errors.ts | 7+++++++
Mpackages/taler-wallet-core/src/db/indexeddb/fixups.ts | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db/indexeddb/schema.ts | 3+++
Mpackages/taler-wallet-core/src/db/migration/converter.test.ts | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db/migration/converter.ts | 25++++++++++++++++++++-----
Mpackages/taler-wallet-core/src/db/records.ts | 20++++++++++++++++++++
Mpackages/taler-wallet-core/src/requests.test.ts | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 13++++++++++---
8 files changed, 541 insertions(+), 8 deletions(-)

diff --git a/packages/taler-util/src/errors.ts b/packages/taler-util/src/errors.ts @@ -24,6 +24,7 @@ import { import { PaymentInsufficientBalanceDetails, TalerErrorDetail, + WalletDatabaseBackend, } from "./types-taler-wallet.js"; /** @@ -215,6 +216,12 @@ export interface DetailsMap { }; [TalerErrorCode.WALLET_DB_UNAVAILABLE]: { innerError: TalerErrorDetail | undefined; + /** Operation whose failure was reported through this general DB error. */ + operation?: "migrateDatabase"; + /** Backend that remains active after a failed migration. */ + activeDatabaseBackend?: WalletDatabaseBackend; + /** Whether the migration source remains authoritative and usable. */ + sourceDatabaseRetained?: true; }; [TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED]: { exchangeBaseUrl: string; diff --git a/packages/taler-wallet-core/src/db/indexeddb/fixups.ts b/packages/taler-wallet-core/src/db/indexeddb/fixups.ts @@ -41,6 +41,7 @@ import { WalletCoin, WalletDenomFamilyParams, WalletReserve, + WithdrawalRecordType, WithdrawalGroupStatus, } from "../records.js"; import { DbAccess } from "../query.js"; @@ -62,6 +63,13 @@ export interface FixupDescription { * Fixups *must* be idempotent. */ export const walletDbFixups: FixupDescription[] = [ + // Several short-lived record shapes were never rewritten when their fields + // moved or became mandatory. Repair them before the DAL hides the legacy + // properties and before a native-backend migration enforces its columns. + { + fn: fixup20260831LegacyRecordShapes, + name: "fixup20260831LegacyRecordShapes", + }, // Exchange purging used to delete coins without deleting their histories, // leaving rows that the sqlite foreign key cannot represent. { @@ -172,6 +180,137 @@ export const WALLET_DB_MAINTENANCE_TOTAL_STEPS = walletDbFixups.length + 2; export const WALLET_DB_SCHEMA_UPGRADE_STEP = "indexeddb-schema-upgrade"; export const WALLET_DB_REMATERIALIZE_STEP = "rematerialize-transactions"; +interface LegacyMailboxParts { + mailboxBaseUri: string; + mailboxAddress: string; +} + +function splitLegacyMailboxUri( + mailboxUri: string, +): LegacyMailboxParts | undefined { + try { + const parsed = new URL(mailboxUri); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return undefined; + } + const pathParts = parsed.pathname.split("/"); + const mailboxAddress = pathParts.pop(); + if (!mailboxAddress) { + return undefined; + } + parsed.pathname = `${pathParts.join("/")}/`; + parsed.search = ""; + parsed.hash = ""; + return { + mailboxBaseUri: parsed.href, + mailboxAddress, + }; + } catch { + return undefined; + } +} + +function migrateLegacyKycFields( + rec: Record<string, any>, + legacyInfoField: "kycPending" | "kycInfo", +): boolean { + let changed = false; + const legacyInfo = rec[legacyInfoField]; + if ( + rec.kycPaytoHash === undefined && + typeof legacyInfo?.paytoHash === "string" + ) { + rec.kycPaytoHash = legacyInfo.paytoHash; + changed = true; + } + if (legacyInfoField in rec) { + delete rec[legacyInfoField]; + changed = true; + } + if ("kycUrl" in rec) { + delete rec.kycUrl; + changed = true; + } + return changed; +} + +/** + * Rewrite legacy auxiliary record shapes that predate current required fields. + */ +async function fixup20260831LegacyRecordShapes( + tx: WalletIndexedDbTransaction, +): Promise<void> { + await tx.contacts.iter().forEachAsync(async (contact) => { + const legacyContact = contact as any; + let changed = false; + if ( + typeof legacyContact.mailboxBaseUri !== "string" || + typeof legacyContact.mailboxAddress !== "string" + ) { + const legacyUri = legacyContact.mailboxUri; + const parts = + typeof legacyUri === "string" + ? splitLegacyMailboxUri(legacyUri) + : undefined; + if (!parts) { + logger.warn("dropping a legacy contact with a malformed mailbox URI"); + await tx.contacts.delete([contact.alias, contact.aliasType]); + return; + } + if (typeof legacyContact.mailboxBaseUri !== "string") { + legacyContact.mailboxBaseUri = parts.mailboxBaseUri; + } + if (typeof legacyContact.mailboxAddress !== "string") { + legacyContact.mailboxAddress = parts.mailboxAddress; + } + changed = true; + } + if (typeof legacyContact.petname !== "string") { + legacyContact.petname = contact.alias; + changed = true; + } + if ("mailboxUri" in legacyContact) { + delete legacyContact.mailboxUri; + changed = true; + } + if (changed) { + await tx.contacts.put(contact); + } + }); + + await tx.withdrawalGroups.iter().forEachAsync(async (withdrawalGroup) => { + const legacyGroup = withdrawalGroup as any; + let changed = migrateLegacyKycFields(legacyGroup, "kycPending"); + if ("senderWire" in legacyGroup) { + if ( + typeof legacyGroup.senderWire === "string" && + withdrawalGroup.wgInfo.withdrawalType === + WithdrawalRecordType.BankIntegrated && + withdrawalGroup.wgInfo.bankInfo.senderWire === undefined + ) { + withdrawalGroup.wgInfo.bankInfo.senderWire = legacyGroup.senderWire; + } + delete legacyGroup.senderWire; + changed = true; + } + if (changed) { + await tx.withdrawalGroups.put(withdrawalGroup); + } + }); + + await tx.peerPushCredit.iter().forEachAsync(async (peerCredit) => { + if (migrateLegacyKycFields(peerCredit as any, "kycInfo")) { + await tx.peerPushCredit.put(peerCredit); + } + }); + + await tx.peerPullCredit.iter().forEachAsync(async (peerCredit) => { + if (migrateLegacyKycFields(peerCredit as any, "kycInfo")) { + await tx.peerPullCredit.put(peerCredit); + } + }); +} + /** * Delete coin histories whose coins were removed by the old IndexedDB * exchange-purge implementation. diff --git a/packages/taler-wallet-core/src/db/indexeddb/schema.ts b/packages/taler-wallet-core/src/db/indexeddb/schema.ts @@ -500,6 +500,9 @@ export interface ContactRecord { * The local petname of this alias */ petname: string; + + // Reserved legacy fields, migrated by the IndexedDB fixup: + // * mailboxUri: string } /** diff --git a/packages/taler-wallet-core/src/db/migration/converter.test.ts b/packages/taler-wallet-core/src/db/migration/converter.test.ts @@ -54,6 +54,7 @@ import { WalletExchangeSignkeys, WalletPeerPushCredit, WalletPurchase, + WithdrawalRecordType, } from "../records.js"; import { SQLITE_BASELINE_SCHEMA } from "../sqlite/schema.js"; import { @@ -78,6 +79,170 @@ const quietAsserts: ConformanceAsserts = { }, }; +test("IndexedDB fixup migrates legacy contacts before conversion", async () => { + const src = await makeIdbRunner(); + const raw = await (src as IdbWalletDbHandle).rawAccess(); + await raw.runAllStoresReadWriteTx({}, async (tx) => { + await tx.contacts.put({ + alias: "bob@example.com", + aliasType: "email", + mailboxUri: "https://mailbox.example.com/mb/BOBPKEY", + source: "legacy", + } as any); + await tx.contacts.put({ + alias: "malformed@example.com", + aliasType: "email", + mailboxUri: "not a mailbox URL", + source: "legacy", + } as any); + await tx.contacts.put({ + alias: "alice@example.com", + aliasType: "email", + mailboxBaseUri: "https://mailbox.example.com/mb/", + mailboxAddress: "ALICEPKEY", + source: "intermediate", + } as any); + await tx.fixups.delete("fixup20260831LegacyRecordShapes"); + }); + + await applyFixups(raw); + const fixedContacts = await raw.runAllStoresReadWriteTx({}, (tx) => + tx.contacts.getAll(), + ); + assert.deepStrictEqual(fixedContacts, [ + { + alias: "alice@example.com", + aliasType: "email", + mailboxBaseUri: "https://mailbox.example.com/mb/", + mailboxAddress: "ALICEPKEY", + source: "intermediate", + petname: "alice@example.com", + }, + { + alias: "bob@example.com", + aliasType: "email", + mailboxBaseUri: "https://mailbox.example.com/mb/", + mailboxAddress: "BOBPKEY", + source: "legacy", + petname: "bob@example.com", + }, + ]); + + // Force the implementation to run again rather than only checking its + // marker, and ensure the already-current records remain byte-for-byte equal. + await raw.runAllStoresReadWriteTx({}, (tx) => + tx.fixups.delete("fixup20260831LegacyRecordShapes"), + ); + await applyFixups(raw); + assert.deepStrictEqual( + await raw.runAllStoresReadWriteTx({}, (tx) => tx.contacts.getAll()), + fixedContacts, + ); + + const dst = await makeSqliteRunner(); + await convertWalletDb(src, dst); + assert.deepStrictEqual( + await dst.runReadWriteTx((tx) => tx.listContacts()), + fixedContacts, + ); + await src.close(); + await dst.close(); +}); + +test("IndexedDB fixup migrates legacy withdrawal and peer KYC fields", async () => { + const src = await makeIdbRunner(); + const raw = await (src as IdbWalletDbHandle).rawAccess(); + await raw.runAllStoresReadWriteTx({}, async (tx) => { + await tx.withdrawalGroups.put({ + withdrawalGroupId: "legacy-withdrawal", + wgInfo: { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: { talerWithdrawUri: "taler://withdraw/example/legacy" }, + }, + senderWire: "payto://iban/LEGACY", + kycPending: { paytoHash: "LEGACY-WITHDRAWAL-HASH", requirementRow: 1 }, + kycUrl: "https://exchange.example.com/legacy-kyc", + } as any); + await tx.withdrawalGroups.put({ + withdrawalGroupId: "current-wins", + wgInfo: { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: { + talerWithdrawUri: "taler://withdraw/example/current", + senderWire: "payto://iban/CURRENT", + }, + }, + senderWire: "payto://iban/OLD", + kycPaytoHash: "CURRENT-WITHDRAWAL-HASH", + kycPending: { paytoHash: "OLD-WITHDRAWAL-HASH", requirementRow: 2 }, + } as any); + await tx.peerPushCredit.put({ + peerPushCreditId: "legacy-push-credit", + exchangeBaseUrl: "https://exchange.example.com/", + contractPriv: "PUSH-CONTRACT-PRIV", + kycInfo: { paytoHash: "LEGACY-PUSH-HASH", requirementRow: 3 }, + kycUrl: "https://exchange.example.com/push-kyc", + } as any); + await tx.peerPullCredit.put({ + pursePub: "legacy-pull-credit", + exchangeBaseUrl: "https://exchange.example.com/", + contractPriv: "PULL-CONTRACT-PRIV", + kycPaytoHash: "CURRENT-PULL-HASH", + kycInfo: { paytoHash: "OLD-PULL-HASH", requirementRow: 4 }, + kycUrl: "https://exchange.example.com/pull-kyc", + } as any); + await tx.fixups.delete("fixup20260831LegacyRecordShapes"); + }); + + await applyFixups(raw); + const fixed = await raw.runAllStoresReadWriteTx({}, async (tx) => ({ + legacyWithdrawal: await tx.withdrawalGroups.get("legacy-withdrawal"), + currentWithdrawal: await tx.withdrawalGroups.get("current-wins"), + push: await tx.peerPushCredit.get("legacy-push-credit"), + pull: await tx.peerPullCredit.get("legacy-pull-credit"), + })); + const legacyWithdrawal = fixed.legacyWithdrawal!; + const currentWithdrawal = fixed.currentWithdrawal!; + assert.strictEqual( + (legacyWithdrawal.wgInfo as any).bankInfo.senderWire, + "payto://iban/LEGACY", + ); + assert.strictEqual(legacyWithdrawal.kycPaytoHash, "LEGACY-WITHDRAWAL-HASH"); + assert.strictEqual( + (currentWithdrawal.wgInfo as any).bankInfo.senderWire, + "payto://iban/CURRENT", + ); + assert.strictEqual(currentWithdrawal.kycPaytoHash, "CURRENT-WITHDRAWAL-HASH"); + assert.strictEqual((fixed.push as any)?.kycPaytoHash, "LEGACY-PUSH-HASH"); + assert.strictEqual((fixed.pull as any)?.kycPaytoHash, "CURRENT-PULL-HASH"); + for (const rec of [ + fixed.legacyWithdrawal as any, + fixed.currentWithdrawal as any, + fixed.push as any, + fixed.pull as any, + ]) { + assert.ok(!("senderWire" in rec)); + assert.ok(!("kycPending" in rec)); + assert.ok(!("kycInfo" in rec)); + assert.ok(!("kycUrl" in rec)); + } + + await raw.runAllStoresReadWriteTx({}, (tx) => + tx.fixups.delete("fixup20260831LegacyRecordShapes"), + ); + await applyFixups(raw); + assert.deepStrictEqual( + await raw.runAllStoresReadWriteTx({}, async (tx) => ({ + legacyWithdrawal: await tx.withdrawalGroups.get("legacy-withdrawal"), + currentWithdrawal: await tx.withdrawalGroups.get("current-wins"), + push: await tx.peerPushCredit.get("legacy-push-credit"), + pull: await tx.peerPullCredit.get("legacy-pull-credit"), + })), + fixed, + ); + await src.close(); +}); + test("converter: preserves a legacy orphan coin without a master key", async () => { const src = await makeIdbRunner(); const dst = await makeSqliteRunner(); @@ -339,7 +504,21 @@ test("converter: IndexedDB to sqlite, populated by the conformance corpus", asyn }); const idb = src as IdbWalletDbHandle; const raw = await idb.rawAccess(); + let legacyDenominationHash = ""; + let legacyPeerPushPursePub = ""; await raw.runAllStoresReadWriteTx({}, async (tx) => { + const denomination = (await tx.denominationsV2.getAll())[0]; + const peerPushDebit = (await tx.peerPushDebit.getAll())[0]; + assert.ok( + denomination && peerPushDebit, + "corpus did not create legacy allow-list test records", + ); + legacyDenominationHash = denomination.denomPubHash; + legacyPeerPushPursePub = peerPushDebit.pursePub; + (denomination as any).pendingWithdrawalOutputCoins = 2; + (peerPushDebit as any).amountPurse = peerPushDebit.amount; + await tx.denominationsV2.put(denomination); + await tx.peerPushDebit.put(peerPushDebit); await tx.fixups.delete("fixup20260812ExchangeWithdrawValues"); }); await applyFixups(raw); @@ -392,8 +571,30 @@ test("converter: IndexedDB to sqlite, populated by the conformance corpus", asyn (await tx.getPlanchet(legacyPlanchetPub))?.exchangeWithdrawValues, { cipher: "RSA" }, ); + const denomination = (await tx.listAllDenominations()).find( + (x) => x.denomPubHash === legacyDenominationHash, + ); + const peerPushDebit = await tx.getPeerPushDebit(legacyPeerPushPursePub); + assert.ok(denomination && peerPushDebit); + assert.ok(!("pendingWithdrawalOutputCoins" in denomination)); + assert.ok(!("amountPurse" in peerPushDebit)); }); + const retainedLegacyFields = await raw.runAllStoresReadWriteTx( + {}, + async (tx) => ({ + denomination: (await tx.denominationsV2.getAll()).find( + (x) => x.denomPubHash === legacyDenominationHash, + ), + peerPushDebit: await tx.peerPushDebit.get(legacyPeerPushPursePub), + }), + ); + assert.strictEqual( + (retainedLegacyFields.denomination as any)?.pendingWithdrawalOutputCoins, + 2, + ); + assert.ok("amountPurse" in (retainedLegacyFields.peerPushDebit as any)); + assert.ok( report.totalRecords >= 100, `only ${report.totalRecords} records converted -- the corpus did not` + @@ -568,6 +769,79 @@ test("converter: discards the legacy exchange update retry counter", async () => await dst.close(); }); +test("converter: discards the legacy denomination family hash", async () => { + // Before the tuple index was introduced, IndexedDB records persisted this + // derived lookup key. Schema upgrades remove the index but do not rewrite + // records, while the native schema deliberately has no column for it. + const src = await makeIdbRunner(); + const raw = await (src as IdbWalletDbHandle).rawAccess(); + const familyParamsHash = encodeCrock(getRandomBytes(32)); + const familyParams = { + exchangeBaseUrl: "https://exchange.example.com/", + exchangeMasterPub: encodeCrock(getRandomBytes(32)), + value: "TESTKUDOS:1", + feeWithdraw: "TESTKUDOS:0.01", + feeDeposit: "TESTKUDOS:0.02", + feeRefresh: "TESTKUDOS:0.03", + feeRefund: "TESTKUDOS:0.04", + }; + let denominationFamilySerial: number | undefined; + await raw.runAllStoresReadWriteTx({}, async (tx) => { + const result = await tx.denominationFamilies.put({ + familyParams, + familyParamsHash, + } as any); + assert.strictEqual(typeof result.key, "number"); + denominationFamilySerial = result.key as number; + }); + + const dst = await makeSqliteRunner(); + await convertWalletDb(src, dst); + const families = await dst.runReadWriteTx((tx) => + tx.listAllDenominationFamilies(), + ); + assert.deepStrictEqual(families, [ + { denominationFamilySerial, familyParams }, + ]); + assert.ok(!("familyParamsHash" in families[0])); + + const sourceFamily = await raw.runAllStoresReadWriteTx({}, (tx) => + tx.denominationFamilies.get(denominationFamilySerial!), + ); + assert.strictEqual((sourceFamily as any)?.familyParamsHash, familyParamsHash); + + await src.close(); + await dst.close(); +}); + +test("converter: rejects an unknown denomination family field", async () => { + const src = await makeIdbRunner(); + const raw = await (src as IdbWalletDbHandle).rawAccess(); + await raw.runAllStoresReadWriteTx({}, (tx) => + tx.denominationFamilies.put({ + familyParams: { + exchangeBaseUrl: "https://exchange.example.com/", + exchangeMasterPub: encodeCrock(getRandomBytes(32)), + value: "TESTKUDOS:1", + feeWithdraw: "TESTKUDOS:0.01", + feeDeposit: "TESTKUDOS:0.02", + feeRefresh: "TESTKUDOS:0.03", + feeRefund: "TESTKUDOS:0.04", + }, + unknownFutureField: "must not be discarded silently", + } as any), + ); + + const dst = await makeSqliteRunner(); + await assert.rejects( + () => convertWalletDb(src, dst), + /conversion verification failed: denominationFamilies differs/, + ); + + await src.close(); + await dst.close(); +}); + test("converter: canonicalises a legacy purchase with empty exchanges", async () => { // Old IndexedDB wallets could persist an explicit empty array here. The // native representation uses a junction table, where no rows means the diff --git a/packages/taler-wallet-core/src/db/migration/converter.ts b/packages/taler-wallet-core/src/db/migration/converter.ts @@ -105,10 +105,17 @@ const LEGACY_FIELDS: Record<string, string[]> = { exchanges: ["updateRetryCounter"], // Removed 2024-06-13 ("remove coinAllocationId, simplify coin history"). coins: ["spendAllocation"], - // Dropped when denomination records were restructured; nothing reads it. - denominations: ["listIssueDate"], + // Both fields were removed after their derived bookkeeping became + // unnecessary; neither has a native-schema representation. + denominations: ["listIssueDate", "pendingWithdrawalOutputCoins"], + // Replaced by the byFamilyParams tuple index; current records and the + // native schema no longer store this derived lookup key. + denominationFamilies: ["familyParamsHash"], // Superseded by per-selection UIDs inside denomsSel; removed 2024-06-10. withdrawalGroups: ["denomSelUid"], + // Briefly stored separately from totalCost; removed when the original + // peer-push fee semantics were restored. + peerPushDebit: ["amountPurse"], // Documented in records.ts as a reserved legacy field (v1 refresh); the // current protocol derives a public seed on demand instead. refreshSessions: ["sessionSecretSeed"], @@ -153,6 +160,10 @@ function normalizeContractPriv<T extends { contractPriv: string }>(rec: T): T { return { ...rec, contractPriv: rec.contractPriv.toUpperCase() }; } +function normalizePeerPushDebit<T extends { contractPriv: string }>(rec: T): T { + return normalizeContractPriv(stripLegacy("peerPushDebit")!(rec) as T); +} + /** * Require the one-reserve-per-public-key invariant of the native schema. * @@ -326,7 +337,11 @@ const COPY_PLAN: CopyStep[][] = [ step( "denominationFamilies", (tx) => tx.listAllDenominationFamilies(), - (tx, r) => tx.upsertDenominationFamily(r), + (tx, r) => + tx.upsertDenominationFamily( + stripLegacy("denominationFamilies")!(r) as any, + ), + stripLegacy("denominationFamilies"), ), ], [ @@ -445,8 +460,8 @@ const COPY_PLAN: CopyStep[][] = [ step( "peerPushDebit", (tx) => tx.listAllPeerPushDebits(), - (tx, r) => tx.upsertPeerPushDebit(r), - normalizeContractPriv, + (tx, r) => tx.upsertPeerPushDebit(normalizePeerPushDebit(r)), + normalizePeerPushDebit, ), step( "peerPushCredit", diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts @@ -704,6 +704,11 @@ export interface WalletWithdrawalGroup { abortReason?: TalerErrorDetail; failReason?: TalerErrorDetail; + + // Reserved legacy fields, migrated by the IndexedDB fixup: + // * senderWire: string + // * kycPending: { paytoHash: string; requirementRow: number } + // * kycUrl: string } /** @@ -2434,6 +2439,9 @@ export interface WalletPeerPushDebit { * Status of the peer push payment initiation. */ status: PeerPushDebitStatus; + + // Reserved legacy fields: + // * amountPurse: AmountString } /** @@ -2501,6 +2509,10 @@ export interface WalletPeerPullCredit { failReason?: TalerErrorDetail; withdrawalGroupId: string | undefined; + + // Reserved legacy fields, migrated by the IndexedDB fixup: + // * kycInfo: { paytoHash: string; requirementRow: number } + // * kycUrl: string } /** @@ -2557,6 +2569,10 @@ export interface WalletPeerPushCredit { kycLastRuleGen?: number; kycLastAmlReview?: boolean; kycLastDeny?: DbPreciseTimestamp; + + // Reserved legacy fields, migrated by the IndexedDB fixup: + // * kycInfo: { paytoHash: string; requirementRow: number } + // * kycUrl: string } /** @@ -2945,6 +2961,10 @@ export interface WalletDenomination { * on the denomination. */ exchangeMasterPub: string; + + // Reserved legacy fields: + // * listIssueDate: DbProtocolTimestamp + // * pendingWithdrawalOutputCoins: number } export interface DenomFees { diff --git a/packages/taler-wallet-core/src/requests.test.ts b/packages/taler-wallet-core/src/requests.test.ts @@ -79,6 +79,74 @@ const backendCases = [ ["sqlite", makeSqliteRunner], ] as const; +test("explicit migration failure reports the retained active database", async () => { + const db = await makeUnopenedIdbRunner(); + db.migrateToNative = async () => { + throw Error("simulated migration failure"); + }; + const http = { + async fetch(): Promise<never> { + throw Error("unexpected HTTP request"); + }, + } as HttpRequestLibrary; + const wallet = await Wallet.create( + db, + () => http, + new SetTimeoutTimerAPI(), + new SynchronousCryptoWorkerFactoryPlain(), + ); + + try { + await wallet.client.call(WalletApiOperation.SetWalletRunConfig, { + config: { + lazyTaskLoop: true, + testing: { skipDefaults: true }, + features: { migrateNativeDb: false }, + }, + }); + + await assert.rejects( + wallet.client.call(WalletApiOperation.MigrateDatabase, {}), + (error: unknown) => { + assert.ok(error instanceof TalerError); + assert.strictEqual( + error.errorDetail.code, + TalerErrorCode.WALLET_DB_UNAVAILABLE, + ); + assert.strictEqual( + error.errorDetail.hint, + "Database migration failed; the existing database remains active.", + ); + assert.strictEqual(error.errorDetail.operation, "migrateDatabase"); + assert.strictEqual( + error.errorDetail.activeDatabaseBackend, + "indexeddb", + ); + assert.strictEqual(error.errorDetail.sourceDatabaseRetained, true); + assert.match( + error.errorDetail.innerError?.hint ?? "", + /simulated migration failure/, + ); + return true; + }, + ); + + const response = await wallet.client.call( + WalletApiOperation.SetWalletRunConfig, + { + config: { + lazyTaskLoop: true, + testing: { skipDefaults: true }, + features: { migrateNativeDb: false }, + }, + }, + ); + assert.strictEqual(response.databaseBackend, "indexeddb"); + } finally { + await wallet.client.call(WalletApiOperation.Shutdown, {}).catch(() => {}); + } +}); + for (const [expectedBackend, makeRunner] of backendCases) { test(`init reports and shuts down the ${expectedBackend} database backend`, async () => { const db = await makeRunner(); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -1720,9 +1720,16 @@ export class InternalWalletState { ) { throw e; } - throw TalerError.fromDetail(TalerErrorCode.WALLET_DB_UNAVAILABLE, { - innerError: getErrorDetailFromException(e), - }); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DB_UNAVAILABLE, + { + innerError: getErrorDetailFromException(e), + operation: "migrateDatabase", + activeDatabaseBackend: "indexeddb", + sourceDatabaseRetained: true, + }, + "Database migration failed; the existing database remains active.", + ); } return false; }