taler-typescript-core

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

commit e947100230f997d1a474f354eef88de5147b60e8
parent fb68810c335be51598347d92e678b5406210f60c
Author: Florian Dold <dold@taler.net>
Date:   Tue,  1 Sep 2026 23:17:09 +0200

wallet-core: remove orphaned records before database conversion

Diffstat:
Mpackages/taler-wallet-core/src/db/indexeddb/fixups.ts | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db/migration/converter.test.ts | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db/migration/converter.ts | 18+++++++++++++-----
3 files changed, 286 insertions(+), 5 deletions(-)

diff --git a/packages/taler-wallet-core/src/db/indexeddb/fixups.ts b/packages/taler-wallet-core/src/db/indexeddb/fixups.ts @@ -172,6 +172,13 @@ export const walletDbFixups: FixupDescription[] = [ fn: fixup20260807CoinExchangeMasterPub, name: "fixup20260807CoinExchangeMasterPub", }, + // Run after the migrations that populate denominationsV2. Older + // IndexedDB deletion paths did not cascade from these parent records, + // while the native schema does. + { + fn: fixup20260901OrphanCascadeChildren, + name: "fixup20260901OrphanCascadeChildren", + }, ]; /** Schema opening, every record fixup, and transaction rematerialization. */ @@ -330,6 +337,72 @@ async function fixup20260822OrphanCoinHistories( } /** + * Remove children left by pre-cascade IndexedDB deletions. + * + * Each record here is owned by its missing parent and is deleted by the + * corresponding current DAL operation. The native schema expresses the + * same ownership with foreign keys, so none of these orphans can be copied. + */ +async function fixup20260901OrphanCascadeChildren( + tx: WalletIndexedDbTransaction, +): Promise<void> { + await tx.planchets.iter().forEachAsync(async (planchet) => { + if (await tx.withdrawalGroups.get(planchet.withdrawalGroupId)) { + return; + } + await tx.planchets.delete(planchet.coinPub); + }); + + await tx.denominationsV2.iter().forEachAsync(async (denom) => { + const familySerial = denom.denominationFamilySerial; + if ( + typeof familySerial !== "number" || + (await tx.denominationFamilies.get(familySerial)) + ) { + return; + } + await tx.denominationsV2.delete([ + denom.exchangeMasterPub, + denom.denomPubHash, + ]); + }); + + await tx.refreshSessions.iter().forEachAsync(async (session) => { + if (await tx.refreshGroups.get(session.refreshGroupId)) { + return; + } + await tx.refreshSessions.delete([ + session.refreshGroupId, + session.coinIndex, + ]); + }); + + await tx.refundGroups.iter().forEachAsync(async (group) => { + if (await tx.purchases.get(group.proposalId)) { + return; + } + const items = await tx.refundItems.indexes.byRefundGroupId.getAll([ + group.refundGroupId, + ]); + for (const item of items) { + if (typeof item.id === "number") { + await tx.refundItems.delete(item.id); + } + } + await tx.refundGroups.delete(group.refundGroupId); + }); + + await tx.refundItems.iter().forEachAsync(async (item) => { + if (await tx.refundGroups.get(item.refundGroupId)) { + return; + } + if (typeof item.id === "number") { + await tx.refundItems.delete(item.id); + } + }); +} + +/** * Delete signing keys whose exchange details were removed by the old * IndexedDB exchange-purge implementation. * diff --git a/packages/taler-wallet-core/src/db/migration/converter.test.ts b/packages/taler-wallet-core/src/db/migration/converter.test.ts @@ -45,6 +45,7 @@ import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, PeerPushCreditStatus, + PlanchetStatus, PurchaseStatus, ReserveRecordStatus, timestampPreciseToDb, @@ -52,9 +53,11 @@ import { WalletCoin, WalletExchangeDetails, WalletExchangeSignkeys, + WalletPlanchet, WalletPeerPushCredit, WalletPurchase, WithdrawalRecordType, + WithdrawalGroupStatus, } from "../records.js"; import { SQLITE_BASELINE_SCHEMA } from "../sqlite/schema.js"; import { @@ -367,6 +370,203 @@ test("IndexedDB fixup removes orphan coin histories", async () => { } }); +test("IndexedDB fixup removes orphan planchets", async () => { + const src = await makeIdbRunner(); + const key = (): string => encodeCrock(getRandomBytes(32)); + const hash = (): string => encodeCrock(getRandomBytes(64)); + const planchet = ( + withdrawalGroupId: string, + coinIdx: number, + ): WalletPlanchet => ({ + coinPub: key(), + coinPriv: key(), + withdrawalGroupId, + coinIdx, + planchetStatus: PlanchetStatus.Pending, + lastError: undefined, + denomPubHash: hash(), + blindingKey: key(), + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, + withdrawSig: hash(), + coinEv: { + cipher: DenomKeyType.Rsa, + rsa_blinded_planchet: "blinded", + }, + coinEvHash: hash(), + }); + + const validGroupId = "valid-withdrawal"; + const missingGroupId = "missing-withdrawal"; + const orphanPlanchets = [ + planchet(missingGroupId, 0), + planchet(missingGroupId, 1), + ]; + const validPlanchet = planchet(validGroupId, 0); + await src.runReadWriteTx(async (tx) => { + await tx.upsertWithdrawalGroup({ + withdrawalGroupId: validGroupId, + wgInfo: { withdrawalType: WithdrawalRecordType.BankManual }, + secretSeed: key(), + reservePub: key(), + reservePriv: key(), + timestampStart: timestampPreciseToDb( + TalerPreciseTimestamp.fromSeconds(100), + ), + status: WithdrawalGroupStatus.PendingRegisteringBank, + }); + for (const p of [...orphanPlanchets, validPlanchet]) { + await tx.upsertPlanchet(p); + } + }); + + const raw = await (src as IdbWalletDbHandle).rawAccess(); + await raw.runAllStoresReadWriteTx({}, (tx) => + tx.fixups.delete("fixup20260901OrphanCascadeChildren"), + ); + + const rejectedDst = await makeSqliteRunner(); + try { + await assert.rejects( + () => convertWalletDb(src, rejectedDst), + new RegExp( + `planchet references missing withdrawal group ${missingGroupId}`, + ), + ); + } finally { + await rejectedDst.close(); + } + + await applyFixups(raw); + let repaired = await src.runReadWriteTx((tx) => tx.listAllPlanchets()); + assert.deepStrictEqual( + repaired.map((x) => x.coinPub), + [validPlanchet.coinPub], + ); + + // Force a second application rather than merely exercising the marker. + await raw.runAllStoresReadWriteTx({}, (tx) => + tx.fixups.delete("fixup20260901OrphanCascadeChildren"), + ); + await applyFixups(raw); + repaired = await src.runReadWriteTx((tx) => tx.listAllPlanchets()); + assert.deepStrictEqual( + repaired.map((x) => x.coinPub), + [validPlanchet.coinPub], + ); + + const dst = await makeSqliteRunner(); + try { + const report = await convertWalletDb(src, dst); + assert.strictEqual(report.copied.planchets, 1); + assert.deepStrictEqual( + await dst.runReadWriteTx((tx) => tx.listAllPlanchets()), + [validPlanchet], + ); + } finally { + await src.close(); + await dst.close(); + } +}); + +test("IndexedDB fixup removes remaining orphan cascade children", async () => { + const src = await makeIdbRunner(); + const raw = await (src as IdbWalletDbHandle).rawAccess(); + const key = (): string => encodeCrock(getRandomBytes(32)); + const hash = (): string => encodeCrock(getRandomBytes(64)); + const validFamilySerial = 41; + const missingFamilySerial = 42; + const validMasterPub = key(); + const orphanMasterPub = key(); + const validDenomHash = hash(); + const orphanDenomHash = hash(); + + await raw.runAllStoresReadWriteTx({}, async (tx) => { + await tx.denominationFamilies.put({ + denominationFamilySerial: validFamilySerial, + } as any); + await tx.denominationsV2.put({ + exchangeMasterPub: validMasterPub, + denomPubHash: validDenomHash, + denominationFamilySerial: validFamilySerial, + } as any); + await tx.denominationsV2.put({ + exchangeMasterPub: orphanMasterPub, + denomPubHash: orphanDenomHash, + denominationFamilySerial: missingFamilySerial, + } as any); + + await tx.refreshGroups.put({ refreshGroupId: "valid-refresh" } as any); + await tx.refreshSessions.put({ + refreshGroupId: "valid-refresh", + coinIndex: 0, + } as any); + await tx.refreshSessions.put({ + refreshGroupId: "missing-refresh", + coinIndex: 0, + } as any); + + await tx.purchases.put({ proposalId: "valid-purchase" } as any); + await tx.refundGroups.put({ + refundGroupId: "valid-refund", + proposalId: "valid-purchase", + } as any); + await tx.refundGroups.put({ + refundGroupId: "orphan-refund", + proposalId: "missing-purchase", + } as any); + await tx.refundItems.put({ + refundGroupId: "valid-refund", + coinPub: key(), + rtxid: 1, + } as any); + await tx.refundItems.put({ + refundGroupId: "orphan-refund", + coinPub: key(), + rtxid: 2, + } as any); + await tx.refundItems.put({ + refundGroupId: "missing-refund", + coinPub: key(), + rtxid: 3, + } as any); + await tx.fixups.delete("fixup20260901OrphanCascadeChildren"); + }); + + await applyFixups(raw); + await raw.runAllStoresReadWriteTx({}, async (tx) => { + assert.deepStrictEqual( + (await tx.denominationsV2.getAll()).map((x) => x.denomPubHash), + [validDenomHash], + ); + assert.deepStrictEqual( + (await tx.refreshSessions.getAll()).map((x) => x.refreshGroupId), + ["valid-refresh"], + ); + assert.deepStrictEqual( + (await tx.refundGroups.getAll()).map((x) => x.refundGroupId), + ["valid-refund"], + ); + assert.deepStrictEqual( + (await tx.refundItems.getAll()).map((x) => x.refundGroupId), + ["valid-refund"], + ); + }); + + // Force a second application to verify idempotence, rather than merely + // exercising the fixup marker. + await raw.runAllStoresReadWriteTx({}, (tx) => + tx.fixups.delete("fixup20260901OrphanCascadeChildren"), + ); + await applyFixups(raw); + await raw.runAllStoresReadWriteTx({}, async (tx) => { + assert.strictEqual((await tx.denominationsV2.getAll()).length, 1); + assert.strictEqual((await tx.refreshSessions.getAll()).length, 1); + assert.strictEqual((await tx.refundGroups.getAll()).length, 1); + assert.strictEqual((await tx.refundItems.getAll()).length, 1); + }); + await src.close(); +}); + test("IndexedDB fixup removes orphan exchange signing keys", async () => { const src = await makeIdbRunner(); const key = (): string => encodeCrock(getRandomBytes(32)); diff --git a/packages/taler-wallet-core/src/db/migration/converter.ts b/packages/taler-wallet-core/src/db/migration/converter.ts @@ -793,11 +793,19 @@ export async function convertWalletDb( sourceDigest.add(normalize(record)); } options.cancellationToken?.throwIfCancelled(); - await dst.runReadWriteTx(async (tx) => { - for (const rec of page.records) { - await st.write(tx, rec); - } - }); + try { + await dst.runReadWriteTx(async (tx) => { + for (const rec of page.records) { + await st.write(tx, rec); + } + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new Error( + `conversion failed while copying ${st.name}: ${message}`, + { cause: e }, + ); + } options.cancellationToken?.throwIfCancelled(); storeCount += page.records.length; copiedRecords += page.records.length;