taler-typescript-core

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

commit dbcf19b6c15df3a5574dd67079422e9b33db0086
parent a9726106bec47d4c2caa9c7b49c3a1fc54599a44
Author: Iván Ávalos <avalos@disroot.org>
Date:   Fri, 21 Aug 2026 15:41:44 +0200

wallet-core: roll back a transaction whose commit failed on the native backend

Diffstat:
Mpackages/taler-wallet-core/src/db/sqlite/database.ts | 16+++++++++++++++-
Mpackages/taler-wallet-core/src/db/sqlite/transaction.test.ts | 39+++++++++++++++++++++++++++++++++++++++
2 files changed, 54 insertions(+), 1 deletion(-)

diff --git a/packages/taler-wallet-core/src/db/sqlite/database.ts b/packages/taler-wallet-core/src/db/sqlite/database.ts @@ -385,7 +385,21 @@ async function runNativeSqliteWalletTxLocked<T>( } throw e; } - await ndb.txc.commit(); + try { + await ndb.txc.commit(); + } catch (e) { + // A COMMIT can fail on its own: deferred foreign keys are checked + // here, not when the row was written. Leaving it at that keeps the + // connection *inside* the transaction, and every transaction after it + // fails with "cannot start a transaction within a transaction" -- one + // bad write and the wallet's database is unusable until it restarts. + try { + await ndb.txc.rollback(); + } catch (rollbackErr) { + logger.warn(`rollback after a failed commit failed: ${rollbackErr}`); + } + throw e; + } await checkpointIfIdle(ndb); // Same order as the IndexedDB backend: the handlers run before a client // can observe the notification. diff --git a/packages/taler-wallet-core/src/db/sqlite/transaction.test.ts b/packages/taler-wallet-core/src/db/sqlite/transaction.test.ts @@ -33,6 +33,7 @@ import { BLOB_COLUMNS } from "./schema.js"; import { conformanceCases } from "../testing/conformance-cases.js"; import { makeSqliteRunner } from "../testing/runners.js"; import { initSqliteWalletDb } from "./database.js"; +import { AmountString } from "@gnu-taler/taler-util"; /** * Run every conformance case against one database, then inspect how the @@ -178,3 +179,41 @@ test("sqlite: schema constraints reject invalid rows", async () => { await db.close(); }); + +test("sqlite: a failed commit does not poison the connection", async () => { + // Deferred foreign keys are checked at COMMIT, not when the row is + // written, so a transaction can fail after its body has run. Leaving it + // there keeps the connection inside the transaction, and every + // transaction afterwards fails with "cannot start a transaction within a + // transaction" -- one bad write and the wallet is unusable until it + // restarts. + const runner = await makeSqliteRunner(); + try { + await assert.rejects( + runner.runReadWriteTx(async (tx) => { + await tx.upsertRefreshSession({ + refreshGroupId: "no-such-group", + coinIndex: 0, + amountRefreshOutput: "TESTKUDOS:1" as AmountString, + newDenoms: [], + }); + }), + /FOREIGN KEY/, + "an orphan session must be refused at commit", + ); + + // The point of the test: the database still works. + const coins = await runner.runReadWriteTx((tx) => tx.listAllCoins()); + assert.strictEqual(coins.length, 0); + // ... and still takes writes. + await runner.runReadWriteTx((tx) => + tx.upsertTombstone({ id: "tmb:test:1" }), + ); + const tombstones = await runner.runReadWriteTx((tx) => + tx.listAllTombstones(), + ); + assert.strictEqual(tombstones.length, 1); + } finally { + await runner.close(); + } +});