taler-typescript-core

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

commit 7826e182a8903f499b82e1f994a7027869333249
parent cef5dbc0cffb81c337d7e15a68f8ce240885f011
Author: Florian Dold <dold@taler.net>
Date:   Mon, 20 Jul 2026 00:50:30 +0200

db: cascade the remaining parent deletes, declare them as foreign keys

Five more relationships now delete their children with the parent on both
backends: ON DELETE CASCADE in sqlite, done by hand in the IndexedDB DAL.
A conformance case per relationship pins the behaviour.

Diffstat:
Mpackages/taler-wallet-core/src/db-sqlite-schema.ts | 26+++++++++++++++++++++-----
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mpackages/taler-wallet-core/src/dbtx-indexeddb.ts | 28++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-shared.ts | 2++
4 files changed, 268 insertions(+), 15 deletions(-)

diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts @@ -340,7 +340,11 @@ CREATE TABLE IF NOT EXISTS denominations ( exchange_master_pub BLOB NOT NULL, currency TEXT NOT NULL, value TEXT NOT NULL, - denomination_family_serial INTEGER, + -- Nullable: a denomination may be stored before its family is known. + denomination_family_serial INTEGER + REFERENCES denomination_families(denomination_family_serial) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, stamp_start INTEGER NOT NULL, stamp_expire_withdraw INTEGER NOT NULL, stamp_expire_deposit INTEGER NOT NULL, @@ -472,7 +476,10 @@ CREATE INDEX IF NOT EXISTS slates_by_purchase_choice_output_repeat ON slates (purchase_id, choice_index, output_index, repeat_index); CREATE TABLE IF NOT EXISTS refresh_sessions ( - refresh_group_id TEXT NOT NULL, + refresh_group_id TEXT NOT NULL + REFERENCES refresh_groups(refresh_group_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, coin_index INTEGER NOT NULL, session_public_seed BLOB, amount_refresh_output TEXT NOT NULL, @@ -1023,7 +1030,10 @@ CREATE INDEX IF NOT EXISTS withdrawal_groups_by_taler_withdraw_uri CREATE TABLE IF NOT EXISTS planchets ( coin_pub BLOB PRIMARY KEY, coin_priv BLOB NOT NULL, - withdrawal_group_id TEXT NOT NULL, + withdrawal_group_id TEXT NOT NULL + REFERENCES withdrawal_groups(withdrawal_group_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, coin_idx INTEGER NOT NULL, planchet_status INTEGER NOT NULL, -- JSON: TalerErrorDetail @@ -1095,14 +1105,20 @@ CREATE INDEX IF NOT EXISTS coin_availability_by_exchange_age_fresh ON coin_availability (exchange_base_url, max_age, fresh_coin_count); CREATE TABLE IF NOT EXISTS coin_history ( - coin_pub BLOB PRIMARY KEY, + coin_pub BLOB PRIMARY KEY + REFERENCES coins(coin_pub) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, -- JSON: WalletCoinHistoryItem[] history TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS refund_groups ( refund_group_id TEXT PRIMARY KEY, - proposal_id TEXT NOT NULL, + proposal_id TEXT NOT NULL + REFERENCES purchases(proposal_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, status INTEGER NOT NULL, timestamp_created INTEGER NOT NULL, amount_raw TEXT NOT NULL, diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -316,6 +316,46 @@ function makeFamilyParams( return p; } +/** + * Create the parent rows a child record needs before it can be stored. + * + * The sqlite schema declares foreign keys for these relationships and the + * IndexedDB DAL cascades to match, so a fixture that invents a parent key + * without the parent is describing a state the wallet never produces. These + * helpers keep the cases realistic without repeating the setup in each one. + */ +async function seedDenomFamily( + tx: WalletDbTransaction, + exchangeBaseUrl: string, + serial: number, +): Promise<number> { + return await tx.upsertDenominationFamily({ + denominationFamilySerial: serial, + familyParams: makeFamilyParams(exchangeBaseUrl, `TESTKUDOS:${serial}`), + }); +} + +async function seedWithdrawalGroup( + tx: WalletDbTransaction, + withdrawalGroupId: string, +): Promise<void> { + await tx.upsertWithdrawalGroup(makeWithdrawalGroup(withdrawalGroupId)); +} + +async function seedRefreshGroup( + tx: WalletDbTransaction, + refreshGroupId: string, +): Promise<void> { + await tx.upsertRefreshGroup(makeRefreshGroup(refreshGroupId)); +} + +async function seedPurchase( + tx: WalletDbTransaction, + proposalId: string, +): Promise<void> { + await tx.upsertPurchase(makePurchase(proposalId)); +} + function makeBankInfo(talerWithdrawUri: string): ReserveBankInfo { const info: ReserveBankInfo = { talerWithdrawUri, @@ -730,6 +770,7 @@ export const conformanceCases: ConformanceCase[] = [ name: "denomination: compound primary key (exchange, denomPubHash)", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam1/", 1); await tx.upsertDenomination(makeDenomination("https://e1/", "dph-a")); await tx.upsertDenomination(makeDenomination("https://e2/", "dph-a")); }); @@ -752,6 +793,7 @@ export const conformanceCases: ConformanceCase[] = [ // in state "done" instead of "failed". It passed tsc and every unit test. async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-1"); // Items are written before their group here, deliberately: that is // the order pay-merchant.ts uses, and a backend must tolerate it // within a transaction. @@ -780,6 +822,7 @@ export const conformanceCases: ConformanceCase[] = [ name: "refund item lookup by (coinPub, rtxid)", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-1"); await tx.upsertRefundItem(makeRefundItem("grp-3", "coin-9", 42)); await tx.upsertRefundGroup(makeRefundGroup("grp-3")); }); @@ -834,6 +877,7 @@ export const conformanceCases: ConformanceCase[] = [ name: "findDenominationByFamilyFromExpiry returns the first match", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam77/", 77); // Same family, ascending expiry. The first two are not offered, so // the predicate must skip them. await tx.upsertDenomination( @@ -876,6 +920,7 @@ export const conformanceCases: ConformanceCase[] = [ // The predicate is the only place we can observe how far the scan ran. async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam88/", 88); for (let i = 0; i < 20; i++) { await tx.upsertDenomination( makeDenomination("https://e/", `s-${i}`, { @@ -912,6 +957,8 @@ export const conformanceCases: ConformanceCase[] = [ name: "findDenominationByFamilyFromExpiry respects the family boundary", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam101/", 101); + await seedDenomFamily(tx, "https://fam102/", 102); await tx.upsertDenomination( makeDenomination("https://e/", "f1-a", { familySerial: 101, @@ -941,6 +988,7 @@ export const conformanceCases: ConformanceCase[] = [ name: "findDenominationByFamilyFromExpiry honours the expiry lower bound", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam111/", 111); await tx.upsertDenomination( makeDenomination("https://e/", "old", { familySerial: 111, @@ -1066,6 +1114,7 @@ export const conformanceCases: ConformanceCase[] = [ async run(t, runner) { const before = await runner.runReadWriteTx((tx) => tx.getRecordCounts()); await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam1/", 1); await tx.upsertDenomination(makeDenomination("https://cnt/", "c-1")); await tx.upsertDenomination(makeDenomination("https://cnt/", "c-2")); }); @@ -1082,6 +1131,7 @@ export const conformanceCases: ConformanceCase[] = [ name: "upsert overwrites an existing row rather than duplicating it", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam1/", 1); await tx.upsertDenomination( makeDenomination("https://e-up/", "dup", { stampExpireWithdraw: 111, @@ -1213,6 +1263,8 @@ export const conformanceCases: ConformanceCase[] = [ name: "refund group: get by id and list by proposal", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-x"); + await seedPurchase(tx, "prop-y"); await tx.upsertRefundGroup(makeRefundGroup("rg-a", "prop-x")); await tx.upsertRefundGroup(makeRefundGroup("rg-b", "prop-x")); await tx.upsertRefundGroup(makeRefundGroup("rg-c", "prop-y")); @@ -1240,6 +1292,128 @@ export const conformanceCases: ConformanceCase[] = [ }, { + name: "refresh group: delete cascades to its sessions", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedRefreshGroup(tx, "rg-casc-a"); + await seedRefreshGroup(tx, "rg-casc-b"); + await tx.upsertRefreshSession(makeRefreshSession("rg-casc-a", 0)); + await tx.upsertRefreshSession(makeRefreshSession("rg-casc-a", 1)); + await tx.upsertRefreshSession(makeRefreshSession("rg-casc-b", 0)); + }); + await runner.runReadWriteTx((tx) => tx.deleteRefreshGroup("rg-casc-a")); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getRefreshSessionsByGroup("rg-casc-a"), + ) + ).length, + 0, + ); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getRefreshSessionsByGroup("rg-casc-b"), + ) + ).length, + 1, + "sessions of another group must survive", + ); + }, + }, + + { + name: "withdrawal group: delete cascades to its planchets", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedWithdrawalGroup(tx, "wg-casc-a"); + await seedWithdrawalGroup(tx, "wg-casc-b"); + await tx.upsertPlanchet(makePlanchet("pl-ca-0", "wg-casc-a", 0)); + await tx.upsertPlanchet(makePlanchet("pl-ca-1", "wg-casc-a", 1)); + await tx.upsertPlanchet(makePlanchet("pl-cb-0", "wg-casc-b", 0)); + }); + await runner.runReadWriteTx((tx) => + tx.deleteWithdrawalGroup("wg-casc-a"), + ); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getPlanchetsByGroup("wg-casc-a"), + ) + ).length, + 0, + ); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getPlanchetsByGroup("wg-casc-b"), + ) + ).length, + 1, + "planchets of another group must survive", + ); + }, + }, + + { + // Two levels: the purchase owns the refund groups, which own the items. + name: "purchase: delete cascades through refund groups to their items", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-casc"); + await tx.upsertRefundGroup(makeRefundGroup("rg-pc", "prop-casc")); + await tx.upsertRefundItem(makeRefundItem("rg-pc", "coin-pc", 301)); + }); + await runner.runReadWriteTx((tx) => tx.deletePurchase("prop-casc")); + t.equal( + await runner.runReadWriteTx((tx) => tx.getRefundGroup("rg-pc")), + undefined, + "refund groups of the deleted purchase must be gone", + ); + t.equal( + (await runner.runReadWriteTx((tx) => tx.getRefundItemsByGroup("rg-pc"))) + .length, + 0, + "and their items with them", + ); + }, + }, + + { + name: "denomination family: delete cascades to its denominations", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam-a/", 41); + await seedDenomFamily(tx, "https://fam-b/", 42); + await tx.upsertDenomination( + makeDenomination("https://fam-a/", "dfa-1", { familySerial: 41 }), + ); + await tx.upsertDenomination( + makeDenomination("https://fam-b/", "dfb-1", { familySerial: 42 }), + ); + }); + await runner.runReadWriteTx((tx) => tx.deleteDenominationFamily(41)); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getDenominationsByExchange("https://fam-a/"), + ) + ).length, + 0, + ); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getDenominationsByExchange("https://fam-b/"), + ) + ).length, + 1, + "denominations of another family must survive", + ); + }, + }, + + { // 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 @@ -1247,6 +1421,7 @@ export const conformanceCases: ConformanceCase[] = [ name: "refund group: delete cascades to its items", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-1"); await tx.upsertRefundGroup(makeRefundGroup("rg-casc")); await tx.upsertRefundItem(makeRefundItem("rg-casc", "coin-c1", 201)); await tx.upsertRefundItem(makeRefundItem("rg-casc", "coin-c2", 202)); @@ -1300,6 +1475,7 @@ export const conformanceCases: ConformanceCase[] = [ name: "refund item: delete removes only the targeted item", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-1"); await tx.upsertRefundGroup(makeRefundGroup("rg-del")); await tx.upsertRefundItem(makeRefundItem("rg-del", "coin-d1", 101)); await tx.upsertRefundItem(makeRefundItem("rg-del", "coin-d2", 102)); @@ -1323,6 +1499,7 @@ export const conformanceCases: ConformanceCase[] = [ name: "denomination: list by verification status", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam1/", 1); const a = makeDenomination("https://e1/", "vs-a"); a.verificationStatus = DenominationVerificationStatus.Unverified; const b = makeDenomination("https://e1/", "vs-b"); @@ -1485,7 +1662,7 @@ export const conformanceCases: ConformanceCase[] = [ }, { - name: "coin: delete removes the coin and its history independently", + name: "coin: delete cascades to its history", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { await tx.upsertCoin(makeCoin("cd-1")); @@ -1493,20 +1670,38 @@ export const conformanceCases: ConformanceCase[] = [ coinPub: ck("cd-1"), history: [{ type: "withdraw", transactionId: txnId("txn:cd-1") }], }); + await tx.upsertCoin(makeCoin("cd-2")); + await tx.upsertCoinHistory({ + coinPub: ck("cd-2"), + history: [{ type: "withdraw", transactionId: txnId("txn:cd-2") }], + }); }); await runner.runReadWriteTx((tx) => tx.deleteCoin(ck("cd-1"))); t.equal( await runner.runReadWriteTx((tx) => tx.getCoin(ck("cd-1"))), undefined, ); - const hist = await runner.runReadWriteTx((tx) => - tx.getCoinHistory(ck("cd-1")), - ); - t.ok(hist, "deleteCoin must not delete the history record"); - await runner.runReadWriteTx((tx) => tx.deleteCoinHistory(ck("cd-1"))); t.equal( await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-1"))), undefined, + "deleting a coin must delete its history: every reader looks the" + + " history up for a coin it already holds, so a history row without" + + " its coin is unreachable", + ); + t.ok( + await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-2"))), + "the history of another coin must survive", + ); + // deleteCoinHistory still exists on its own, for callers that want to + // drop the history while keeping the coin. + await runner.runReadWriteTx((tx) => tx.deleteCoinHistory(ck("cd-2"))); + t.equal( + await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-2"))), + undefined, + ); + t.ok( + await runner.runReadWriteTx((tx) => tx.getCoin(ck("cd-2"))), + "deleteCoinHistory must not delete the coin", ); }, }, @@ -1527,9 +1722,12 @@ export const conformanceCases: ConformanceCase[] = [ amount: amt("TESTKUDOS:0.25"), }, ]; - await runner.runReadWriteTx((tx) => - tx.upsertCoinHistory({ coinPub: ck("ch-1"), history }), - ); + await runner.runReadWriteTx(async (tx) => { + // The history belongs to a coin, and deleting that coin cascades to + // it, so the coin has to exist first. + await tx.upsertCoin(makeCoin("ch-1")); + await tx.upsertCoinHistory({ coinPub: ck("ch-1"), history }); + }); const got = await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("ch-1")), ); @@ -1991,6 +2189,7 @@ export const conformanceCases: ConformanceCase[] = [ name: "denominations: listed by exchange", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam1/", 1); await tx.upsertDenomination(makeDenomination("https://dl1/", "d-1")); await tx.upsertDenomination(makeDenomination("https://dl1/", "d-2")); await tx.upsertDenomination(makeDenomination("https://dl2/", "d-3")); @@ -2201,6 +2400,7 @@ export const conformanceCases: ConformanceCase[] = [ name: "planchet: round trips and is addressable by group and index", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedWithdrawalGroup(tx, "wg-p"); await tx.upsertPlanchet(makePlanchet("pl-1", "wg-p", 0)); await tx.upsertPlanchet(makePlanchet("pl-2", "wg-p", 1)); }); @@ -2220,7 +2420,10 @@ export const conformanceCases: ConformanceCase[] = [ async run(t, runner) { const pl = makePlanchet("pl-err", "wg-e", 0); pl.lastError = undefined; - await runner.runReadWriteTx((tx) => tx.upsertPlanchet(pl)); + await runner.runReadWriteTx(async (tx) => { + await seedWithdrawalGroup(tx, "wg-e"); + await tx.upsertPlanchet(pl); + }); const got = await runner.runReadWriteTx((tx) => tx.getPlanchet(ck("pl-err")), ); @@ -2236,6 +2439,8 @@ export const conformanceCases: ConformanceCase[] = [ name: "planchet: list, count and bulk delete by group", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedWithdrawalGroup(tx, "wg-bulk"); + await seedWithdrawalGroup(tx, "wg-keep"); for (let i = 0; i < 4; i++) { await tx.upsertPlanchet(makePlanchet(`pl-g${i}`, "wg-bulk", i)); } @@ -2683,6 +2888,8 @@ export const conformanceCases: ConformanceCase[] = [ name: "refresh session: keyed by (group, coin index)", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { + await seedRefreshGroup(tx, "rs-g"); + await seedRefreshGroup(tx, "rs-other"); await tx.upsertRefreshSession(makeRefreshSession("rs-g", 0)); await tx.upsertRefreshSession(makeRefreshSession("rs-g", 1)); await tx.upsertRefreshSession(makeRefreshSession("rs-other", 0)); diff --git a/packages/taler-wallet-core/src/dbtx-indexeddb.ts b/packages/taler-wallet-core/src/dbtx-indexeddb.ts @@ -529,6 +529,10 @@ export class IdbWalletTransaction implements WalletDbTransaction { async deleteCoin(coinPub: string): Promise<void> { const tx = this.tx; + // Cascade to the history, which describes this coin and nothing else. + // Every reader looks it up for a coin it already holds, so a history row + // without its coin is unreachable. Matches the sqlite constraint. + await tx.coinHistory.delete(coinPub); await tx.coins.delete(coinPub); } @@ -649,6 +653,19 @@ export class IdbWalletTransaction implements WalletDbTransaction { denominationFamilySerial: number, ): Promise<void> { const tx = this.tx; + // Cascade to the denominations of that family. There is no accessor for + // "denominations by family" on its own, so this walks the index whose + // first component is the family serial. + const all = + await tx.denominations.indexes.byDenominationFamilySerialAndStampExpireWithdraw + .iter() + .toArray(); + const doomed = all.filter( + (d) => d.denominationFamilySerial === denominationFamilySerial, + ); + for (const d of doomed) { + await tx.denominations.delete([d.exchangeBaseUrl, d.denomPubHash]); + } await tx.denominationFamilies.delete(denominationFamilySerial); } @@ -803,6 +820,11 @@ export class IdbWalletTransaction implements WalletDbTransaction { async deletePurchase(proposalId: string): Promise<void> { const tx = this.tx; + // Cascade to the refund groups, and through deleteRefundGroup to their + // items -- two levels, matching what the sqlite constraints do. + for (const rg of await this.getRefundGroupsByProposal(proposalId)) { + await this.deleteRefundGroup(rg.refundGroupId); + } await tx.purchases.delete(proposalId); } @@ -1024,6 +1046,8 @@ export class IdbWalletTransaction implements WalletDbTransaction { async deleteWithdrawalGroup(withdrawalGroupId: string): Promise<void> { const tx = this.tx; + // Cascade to the planchets, which exist only as part of the group. + await this.deletePlanchetsByGroup(withdrawalGroupId); await tx.withdrawalGroups.delete(withdrawalGroupId); } @@ -1108,6 +1132,10 @@ export class IdbWalletTransaction implements WalletDbTransaction { async deleteRefreshGroup(refreshGroupId: string): Promise<void> { const tx = this.tx; + // Cascade to the sessions, which exist only as part of the group. + for (const sess of await this.getRefreshSessionsByGroup(refreshGroupId)) { + await tx.refreshSessions.delete([refreshGroupId, sess.coinIndex]); + } await tx.refreshGroups.delete(refreshGroupId); } diff --git a/packages/taler-wallet-core/src/dbtx-shared.ts b/packages/taler-wallet-core/src/dbtx-shared.ts @@ -123,6 +123,8 @@ export const CACHE_INVALIDATING_METHODS: ReadonlySet<string> = new Set([ "deleteExchangeDetails", "upsertDenomination", "deleteDenomination", + // Cascades to denominations, so it invalidates the same caches. + "deleteDenominationFamily", "addGlobalCurrencyExchange", "deleteGlobalCurrencyExchange", "addGlobalCurrencyAuditor",