commit cef5dbc0cffb81c337d7e15a68f8ce240885f011
parent 29a3302916c400707d63555e0ba7bf00699779c6
Author: Florian Dold <dold@taler.net>
Date: Mon, 20 Jul 2026 00:37:53 +0200
db: cascade parent deletes on the IndexedDB backend too
deleteRefundGroup and deleteExchangeDetails removed only the parent. The
sqlite schema cascades to their children, so the backends disagreed.
Conformance cases now pin parent-delete behaviour for both.
Diffstat:
2 files changed, 73 insertions(+), 0 deletions(-)
diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts
@@ -1240,6 +1240,63 @@ export const conformanceCases: ConformanceCase[] = [
},
{
+ // Parent-delete behaviour has to be identical on both backends. sqlite
+ // declares ON DELETE CASCADE, IndexedDB has no constraints and must do it
+ // by hand; without a case here the two silently disagree, and the sqlite
+ // side quietly removes rows the other keeps.
+ name: "refund group: delete cascades to its items",
+ async run(t, runner) {
+ await runner.runReadWriteTx(async (tx) => {
+ await tx.upsertRefundGroup(makeRefundGroup("rg-casc"));
+ await tx.upsertRefundItem(makeRefundItem("rg-casc", "coin-c1", 201));
+ await tx.upsertRefundItem(makeRefundItem("rg-casc", "coin-c2", 202));
+ // A second group, to pin that the cascade is scoped to one parent.
+ await tx.upsertRefundGroup(makeRefundGroup("rg-keep"));
+ await tx.upsertRefundItem(makeRefundItem("rg-keep", "coin-k1", 203));
+ });
+ await runner.runReadWriteTx((tx) => tx.deleteRefundGroup("rg-casc"));
+ const gone = await runner.runReadWriteTx((tx) =>
+ tx.getRefundItemsByGroup("rg-casc"),
+ );
+ t.equal(gone.length, 0, "items of the deleted group must be gone");
+ const kept = await runner.runReadWriteTx((tx) =>
+ tx.getRefundItemsByGroup("rg-keep"),
+ );
+ t.equal(kept.length, 1, "items of another group must survive");
+ },
+ },
+
+ {
+ name: "exchange details: delete cascades to its sign keys",
+ async run(t, runner) {
+ const rowId = await runner.runReadWriteTx(async (tx) => {
+ const id = await tx.upsertExchangeDetails(
+ makeExchangeDetails("https://casc.exchange/", "mp-casc"),
+ );
+ await tx.upsertExchangeSignKey(makeSignKey(id, "sk-casc-1"));
+ await tx.upsertExchangeSignKey(makeSignKey(id, "sk-casc-2"));
+ return id;
+ });
+ const other = await runner.runReadWriteTx(async (tx) => {
+ const id = await tx.upsertExchangeDetails(
+ makeExchangeDetails("https://keep.exchange/", "mp-keep"),
+ );
+ await tx.upsertExchangeSignKey(makeSignKey(id, "sk-keep-1"));
+ return id;
+ });
+ await runner.runReadWriteTx((tx) => tx.deleteExchangeDetails(rowId));
+ const gone = await runner.runReadWriteTx((tx) =>
+ tx.getExchangeSignKeysByDetailsRowId(rowId),
+ );
+ t.equal(gone.length, 0, "sign keys of the deleted details must be gone");
+ const kept = await runner.runReadWriteTx((tx) =>
+ tx.getExchangeSignKeysByDetailsRowId(other),
+ );
+ t.equal(kept.length, 1, "sign keys of other details must survive");
+ },
+ },
+
+ {
name: "refund item: delete removes only the targeted item",
async run(t, runner) {
await runner.runReadWriteTx(async (tx) => {
diff --git a/packages/taler-wallet-core/src/dbtx-indexeddb.ts b/packages/taler-wallet-core/src/dbtx-indexeddb.ts
@@ -732,6 +732,11 @@ export class IdbWalletTransaction implements WalletDbTransaction {
async deleteExchangeDetails(rowId: number): Promise<void> {
const tx = this.tx;
+ // Cascade to the sign keys, which describe this details row and nothing
+ // else. Matches ON DELETE CASCADE in the sqlite schema.
+ for (const sk of await this.getExchangeSignKeysByDetailsRowId(rowId)) {
+ await tx.exchangeSignKeys.delete([rowId, sk.signkeyPub]);
+ }
await tx.exchangeDetails.delete(rowId);
}
@@ -851,6 +856,17 @@ export class IdbWalletTransaction implements WalletDbTransaction {
async deleteRefundGroup(refundGroupId: string): Promise<void> {
const tx = this.tx;
+ // Cascade to the items. A refund item exists only as part of its group,
+ // and the sqlite schema enforces that with ON DELETE CASCADE; deleting
+ // only the group here would leave rows behind on this backend that the
+ // other one removes.
+ for (const item of await this.getRefundItemsByGroup(refundGroupId)) {
+ checkDbInvariant(
+ typeof item.id === "number",
+ "stored refund item must have a row id",
+ );
+ await tx.refundItems.delete(item.id);
+ }
await tx.refundGroups.delete(refundGroupId);
}