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:
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;