taler-typescript-core

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

commit 7841c16a94b343a859c690a82bac2cc5e36053a1
parent e69d25477800eb11eabdd7acafdbe4ce188e9e97
Author: Florian Dold <dold@taler.net>
Date:   Mon, 10 Aug 2026 11:05:23 +0200

idb-bridge: recover from sqlite transaction errors

Diffstat:
Mpackages/idb-bridge/src/SqliteBackend.ts | 476+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/idb-bridge/src/bridge-idb.ts | 36+++++++++++++++++++++++++++++-------
Mpackages/idb-bridge/src/node-helper-sqlite3-impl.test.ts | 31+++++++++++++++++++++++++++++++
Mpackages/idb-bridge/src/node-helper-sqlite3-impl.ts | 16+++-------------
Apackages/idb-bridge/src/sqlite-error-recovery.test.ts | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/idb-bridge/src/sqlite3-interface.ts | 31+++++++++++++++++++++++++++++++
Mpackages/idb-bridge/taler-helper-sqlite3 | 1+
Apackages/taler-wallet-core/src/query-sqlite-error-recovery.test.ts | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/query.ts | 45+++++++++++++++++++++++++++++++++------------
9 files changed, 740 insertions(+), 212 deletions(-)

diff --git a/packages/idb-bridge/src/SqliteBackend.ts b/packages/idb-bridge/src/SqliteBackend.ts @@ -45,6 +45,7 @@ import { structuredRevive, } from "./util/structuredClone.js"; import { + isSqlite3Error, Sqlite3Database, Sqlite3Interface, Sqlite3Statement, @@ -61,9 +62,12 @@ function assertDbInvariant(b: boolean): asserts b { } const SqliteError = { + error: "SQLITE_ERROR", constraintPrimarykey: "SQLITE_CONSTRAINT_PRIMARYKEY", } as const; +const SQLITE_ERROR_ERRNO = 1; + export type SqliteRowid = number | bigint; enum TransactionLevel { @@ -272,6 +276,20 @@ export class SqliteBackend implements Backend { private txLevel: TransactionLevel = TransactionLevel.None; + /** + * State of sqlite's transaction on the shared connection. + * + * A failed BEGIN/COMMIT/ROLLBACK leaves sqlite's actual state unknown: the + * statement might have taken effect before the adapter reported the error. + * Keep that uncertainty separate from txLevel, which is only our scheduler + * lock, so the lock can always be released and the next transaction can + * first try to restore a known sqlite state. + */ + private sqlTransactionState: "none" | "active" | "uncertain" = "none"; + + /** Connections whose version-change metadata must be reloaded after recovery. */ + private connectionsNeedingReload: Set<string> = new Set(); + private connectionMap: Map<string, ConnectionInfo> = new Map(); private transactionMap: Map<string, TransactionInfo> = new Map(); @@ -295,6 +313,88 @@ export class SqliteBackend implements Backend { return newStmt; } + private async _acquireTransactionLevel(level: TransactionLevel) { + while (this.txLevel !== TransactionLevel.None) { + await this.transactionDoneCond.wait(); + } + this.txLevel = level; + } + + private _releaseTransactionLevel() { + this.txLevel = TransactionLevel.None; + this.transactionDoneCond.trigger(); + } + + private _isNoTransactionActiveError(err: unknown): boolean { + return ( + isSqlite3Error(err) && + err.code === SqliteError.error && + err.errno === SQLITE_ERROR_ERRNO + ); + } + + /** Restore a known sqlite transaction state after a control-statement error. */ + private async _recoverSqlTransaction(): Promise<void> { + if (this.sqlTransactionState !== "uncertain") { + return; + } + try { + await (await this._prep(sqlRollback)).run(); + } catch (err) { + // ROLLBACK reporting that there is no transaction is exactly the state + // recovery wanted to establish. + if (!this._isNoTransactionActiveError(err)) { + throw err; + } + } + this.sqlTransactionState = "none"; + } + + private async _beginSqlTransaction(): Promise<void> { + await this._recoverSqlTransaction(); + try { + await this._runSqlBegin(); + this.sqlTransactionState = "active"; + } catch (err) { + this.sqlTransactionState = "uncertain"; + // Best effort only. Preserve the BEGIN error, while retaining the + // uncertain marker if cleanup is also temporarily unavailable. + try { + await this._recoverSqlTransaction(); + } catch { + // Recovery will be retried by the next operation. + } + throw err; + } + } + + private async _commitSqlTransaction(): Promise<void> { + try { + await this._runSqlCommit(); + this.sqlTransactionState = "none"; + } catch (err) { + this.sqlTransactionState = "uncertain"; + throw err; + } + } + + private async _rollbackSqlTransaction(): Promise<void> { + if (this.sqlTransactionState === "none") { + return; + } + try { + await (await this._prep(sqlRollback)).run(); + this.sqlTransactionState = "none"; + } catch (err) { + if (this._isNoTransactionActiveError(err)) { + this.sqlTransactionState = "none"; + return; + } + this.sqlTransactionState = "uncertain"; + throw err; + } + } + async getIndexRecords( btx: DatabaseTransaction, req: IndexGetQuery, @@ -1057,52 +1157,69 @@ export class SqliteBackend implements Backend { const connectionId = this.connectionIdCounter++; const connectionCookie = `connection-${connectionId}`; - // Wait until no transaction is active anymore. - while (1) { - if (this.enableTracing) { - console.log(`connectDatabase - txLevel is ${this.txLevel}`); - } - if (this.txLevel == TransactionLevel.None) { - break; + await this._acquireTransactionLevel(TransactionLevel.Write); + try { + await this._beginSqlTransaction(); + let ver = await this._runSqlGetDatabaseVersion(databaseName); + if (ver == null) { + await this._runSqlCreateDatabase(databaseName); + ver = 0; } - await this.transactionDoneCond.wait(); - } - this.txLevel = TransactionLevel.Write; + const objectStoreNames = await this._loadObjectStoreNames(databaseName); + await this._commitSqlTransaction(); - await this._runSqlBegin(); - let ver = await this._runSqlGetDatabaseVersion(databaseName); - if (ver == null) { - await this._runSqlCreateDatabase(databaseName); - ver = 0; - } + const connInfo: ConnectionInfo = { + databaseName, + storeList: [], + storeMap: new Map(), + }; + for (const storeName of objectStoreNames) { + await this._loadScopeInfo(connInfo, storeName); + } + this.connectionMap.set(connectionCookie, connInfo); - const objectStoreNames: string[] = - await this._loadObjectStoreNames(databaseName); - await this._runSqlCommit(); + return { + conn: { connectionCookie }, + version: ver, + objectStores: objectStoreNames, + }; + } catch (err) { + try { + await this._rollbackSqlTransaction(); + } catch { + // The next operation retries recovery. Do not mask the first error. + } + throw err; + } finally { + this._releaseTransactionLevel(); + } + } - const connInfo = { - databaseName: databaseName, + /** Reload connection metadata atomically after a version-change rollback. */ + private async _reloadConnectionScope(connInfo: ConnectionInfo) { + const freshInfo: ConnectionInfo = { + databaseName: connInfo.databaseName, storeList: [], storeMap: new Map(), }; - - this.connectionMap.set(connectionCookie, connInfo); - - for (const storeName of objectStoreNames) { - await this._loadScopeInfo(connInfo, storeName); + const storeNames = await this._loadObjectStoreNames(connInfo.databaseName); + for (const storeName of storeNames) { + await this._loadScopeInfo(freshInfo, storeName); } + connInfo.storeList = freshInfo.storeList; + connInfo.storeMap = freshInfo.storeMap; + } - this.txLevel = TransactionLevel.None; - this.transactionDoneCond.trigger(); - - return { - conn: { - connectionCookie, - }, - version: ver, - objectStores: objectStoreNames, - }; + private async _reloadConnectionScopeIfNeeded( + connectionCookie: string, + connInfo: ConnectionInfo, + ) { + if (!this.connectionsNeedingReload.has(connectionCookie)) { + return; + } + await this._reloadConnectionScope(connInfo); + this.connectionsNeedingReload.delete(connectionCookie); } private async _loadScopeInfo( @@ -1179,12 +1296,7 @@ export class SqliteBackend implements Backend { } const transactionCookie = `tx-${this.transactionIdCounter++}`; - while (1) { - if (this.txLevel === TransactionLevel.None) { - break; - } - await this.transactionDoneCond.wait(); - } + let level: TransactionLevel; if (this.trackStats) { if (mode === "readonly") { @@ -1195,22 +1307,34 @@ export class SqliteBackend implements Backend { } if (mode === "readonly") { - this.txLevel = TransactionLevel.Read; + level = TransactionLevel.Read; } else if (mode === "readwrite") { - this.txLevel = TransactionLevel.Write; + level = TransactionLevel.Write; } else { throw Error("not supported"); } - await this._runSqlBegin(); - - this.transactionMap.set(transactionCookie, { - connectionCookie: conn.connectionCookie, - }); - - return { - transactionCookie, - }; + await this._acquireTransactionLevel(level); + try { + await this._recoverSqlTransaction(); + await this._reloadConnectionScopeIfNeeded( + conn.connectionCookie, + connInfo, + ); + await this._beginSqlTransaction(); + this.transactionMap.set(transactionCookie, { + connectionCookie: conn.connectionCookie, + }); + return { transactionCookie }; + } catch (err) { + try { + await this._rollbackSqlTransaction(); + } catch { + // Retried before the next transaction. + } + this._releaseTransactionLevel(); + throw err; + } } async enterVersionChange( @@ -1228,28 +1352,33 @@ export class SqliteBackend implements Backend { } const transactionCookie = `tx-vc-${this.transactionIdCounter++}`; - while (1) { - if (this.txLevel === TransactionLevel.None) { - break; - } - await this.transactionDoneCond.wait(); - } - + await this._acquireTransactionLevel(TransactionLevel.VersionChange); if (this.enableTracing) { console.log(`version change transaction unblocked`); } - this.txLevel = TransactionLevel.VersionChange; - this.transactionMap.set(transactionCookie, { - connectionCookie: conn.connectionCookie, - }); - - await this._runSqlBegin(); - await this._runSqlUpdateDbVersion(connInfo.databaseName, newVersion); - - return { - transactionCookie, - }; + try { + await this._recoverSqlTransaction(); + await this._reloadConnectionScopeIfNeeded( + conn.connectionCookie, + connInfo, + ); + await this._beginSqlTransaction(); + this.transactionMap.set(transactionCookie, { + connectionCookie: conn.connectionCookie, + }); + await this._runSqlUpdateDbVersion(connInfo.databaseName, newVersion); + return { transactionCookie }; + } catch (err) { + try { + await this._rollbackSqlTransaction(); + } catch { + this.connectionsNeedingReload.add(conn.connectionCookie); + } + this.transactionMap.delete(transactionCookie); + this._releaseTransactionLevel(); + throw err; + } } async _runSqlUpdateDbVersion( @@ -1268,94 +1397,83 @@ export class SqliteBackend implements Backend { // FIXME: To properly implement the spec semantics, maybe // split delete into prepareDelete and executeDelete? - while (this.txLevel !== TransactionLevel.None) { - await this.transactionDoneCond.wait(); - } - - this.txLevel = TransactionLevel.VersionChange; + await this._acquireTransactionLevel(TransactionLevel.VersionChange); + try { + await this._beginSqlTransaction(); - await this._runSqlBegin(); + const objectStoreNames = await this._loadObjectStoreNames(databaseName); - const objectStoreNames = await this._loadObjectStoreNames(databaseName); - - for (const storeName of objectStoreNames) { - const objRes = await ( - await this._prep(sqlGetObjectStoreMetaByName) - ).getFirst({ - name: storeName, - database_name: databaseName, - }); - if (!objRes) { - throw Error("object store not found"); - } - const objectStoreId = expectDbNumber(objRes, "id"); - const indexRes = await ( - await this._prep(sqlGetIndexesByObjectStoreId) - ).getAll({ - object_store_id: objectStoreId, - }); - if (!indexRes) { - throw Error("db inconsistent"); - } - const indexList: MyIndexMeta[] = []; - for (const idxInfo of indexRes) { - const indexId = expectDbNumber(idxInfo, "id"); - const indexName = expectDbString(idxInfo, "name"); - const indexUnique = expectDbNumber(idxInfo, "unique_index"); - const indexMultiEntry = expectDbNumber(idxInfo, "multientry"); - const indexKeyPath = deserializeKeyPath( - expectDbString(idxInfo, "key_path"), - ); - if (!indexKeyPath) { + for (const storeName of objectStoreNames) { + const objRes = await ( + await this._prep(sqlGetObjectStoreMetaByName) + ).getFirst({ + name: storeName, + database_name: databaseName, + }); + if (!objRes) { + throw Error("object store not found"); + } + const objectStoreId = expectDbNumber(objRes, "id"); + const indexRes = await ( + await this._prep(sqlGetIndexesByObjectStoreId) + ).getAll({ + object_store_id: objectStoreId, + }); + if (!indexRes) { throw Error("db inconsistent"); } - const indexMeta: MyIndexMeta = { - indexId, - keyPath: indexKeyPath, - multiEntry: indexMultiEntry != 0, - unique: indexUnique != 0, - currentName: indexName, - nameDirty: false, - }; - indexList.push(indexMeta); - } + const indexList: MyIndexMeta[] = []; + for (const idxInfo of indexRes) { + const indexId = expectDbNumber(idxInfo, "id"); + const indexName = expectDbString(idxInfo, "name"); + const indexUnique = expectDbNumber(idxInfo, "unique_index"); + const indexMultiEntry = expectDbNumber(idxInfo, "multientry"); + const indexKeyPath = deserializeKeyPath( + expectDbString(idxInfo, "key_path"), + ); + if (!indexKeyPath) { + throw Error("db inconsistent"); + } + indexList.push({ + indexId, + keyPath: indexKeyPath, + multiEntry: indexMultiEntry != 0, + unique: indexUnique != 0, + currentName: indexName, + nameDirty: false, + }); + } - for (const indexInfo of indexList) { - let stmt: Sqlite3Statement; - if (indexInfo.unique) { - stmt = await this._prep(sqlIUniqueIndexDataDeleteAll); - } else { - stmt = await this._prep(sqlIndexDataDeleteAll); + for (const indexInfo of indexList) { + const stmt = await this._prep( + indexInfo.unique + ? sqlIUniqueIndexDataDeleteAll + : sqlIndexDataDeleteAll, + ); + await stmt.run({ index_id: indexInfo.indexId }); + await ( + await this._prep(sqlIndexDelete) + ).run({ index_id: indexInfo.indexId }); } - await stmt.run({ - index_id: indexInfo.indexId, - }); await ( - await this._prep(sqlIndexDelete) - ).run({ - index_id: indexInfo.indexId, - }); + await this._prep(sqlObjectDataDeleteAll) + ).run({ object_store_id: objectStoreId }); + await ( + await this._prep(sqlObjectStoreDelete) + ).run({ object_store_id: objectStoreId }); } - await ( - await this._prep(sqlObjectDataDeleteAll) - ).run({ - object_store_id: objectStoreId, - }); - await ( - await this._prep(sqlObjectStoreDelete) - ).run({ - object_store_id: objectStoreId, - }); + await (await this._prep(sqlDeleteDatabase)).run({ name: databaseName }); + await this._commitSqlTransaction(); + } catch (err) { + try { + await this._rollbackSqlTransaction(); + } catch { + // Recovery remains pending for the next operation. + } + throw err; + } finally { + this._releaseTransactionLevel(); } - await ( - await this._prep(sqlDeleteDatabase) - ).run({ - name: databaseName, - }); - await (await this._prep(sqlCommit)).run(); - - this.txLevel = TransactionLevel.None; - this.transactionDoneCond.trigger(); } async close(db: DatabaseConnection): Promise<void> { @@ -1373,6 +1491,7 @@ export class SqliteBackend implements Backend { if (this.enableTracing) { console.log(`closing connection ${db.connectionCookie}`); } + this.connectionsNeedingReload.delete(db.connectionCookie); this.connectionMap.delete(db.connectionCookie); } @@ -1539,25 +1658,26 @@ export class SqliteBackend implements Backend { if (!connInfo) { throw Error("not connected"); } - if (this.txLevel === TransactionLevel.None) { - return; - } - await (await this._prep(sqlRollback)).run(); - if (this.txLevel === TransactionLevel.VersionChange) { - // Rollback also undoes schema changes, but that is only - // relevant in a versionchange transaction. - connInfo.storeList = []; - connInfo.storeMap.clear(); - const objectStoreNames: string[] = await this._loadObjectStoreNames( - connInfo.databaseName, - ); - for (const storeName of objectStoreNames) { - await this._loadScopeInfo(connInfo, storeName); + const wasVersionChange = this.txLevel === TransactionLevel.VersionChange; + try { + await this._rollbackSqlTransaction(); + if (wasVersionChange) { + // Rollback also undoes schema changes. Load into temporary maps so a + // transient read error cannot leave half-rebuilt metadata behind. + await this._reloadConnectionScope(connInfo); + this.connectionsNeedingReload.delete(txInfo.connectionCookie); } + } catch (err) { + if (wasVersionChange) { + this.connectionsNeedingReload.add(txInfo.connectionCookie); + } + throw err; + } finally { + // Never leave the logical scheduler locked. If sqlite's state is still + // uncertain, the next transaction retries ROLLBACK before BEGIN. + this.transactionMap.delete(btx.transactionCookie); + this._releaseTransactionLevel(); } - this.txLevel = TransactionLevel.None; - this.transactionMap.delete(btx.transactionCookie); - this.transactionDoneCond.trigger(); } async commit(btx: DatabaseTransaction): Promise<void> { @@ -1612,10 +1732,9 @@ export class SqliteBackend implements Backend { } } } - await (await this._prep(sqlCommit)).run(); - this.txLevel = TransactionLevel.None; + await this._commitSqlTransaction(); this.transactionMap.delete(btx.transactionCookie); - this.transactionDoneCond.trigger(); + this._releaseTransactionLevel(); } async _provideObjectStore( @@ -2145,17 +2264,14 @@ export class SqliteBackend implements Backend { } async backupToFile(path: string): Promise<void> { - // Wait until no other transaction is active. - while (this.txLevel !== TransactionLevel.None) { - await this.transactionDoneCond.wait(); + await this._acquireTransactionLevel(TransactionLevel.VersionChange); + try { + await this._recoverSqlTransaction(); + const stmt = await this._prep("VACUUM INTO $filename;"); + await stmt.run({ filename: path }); + } finally { + this._releaseTransactionLevel(); } - this.txLevel = TransactionLevel.VersionChange; - const stmt = await this._prep("VACUUM INTO $filename;"); - await stmt.run({ - filename: path, - }); - this.txLevel = TransactionLevel.None; - this.transactionDoneCond.trigger(); } } diff --git a/packages/idb-bridge/src/bridge-idb.ts b/packages/idb-bridge/src/bridge-idb.ts @@ -2725,7 +2725,7 @@ export class BridgeIDBTransaction } // http://www.w3.org/TR/2015/REC-IndexedDB-20150108/#dfn-steps-for-aborting-a-transaction - _abort(errName: string | null): void { + _abort(err: Error | string | null): void { if (BridgeIDBFactory.enableTracing) { console.log("TRACE: aborting transaction"); } @@ -2737,10 +2737,12 @@ export class BridgeIDBTransaction this._aborted = true; this._active = false; - if (errName !== null) { - const e = new Error(); - e.name = errName; - this._error = e; + if (err instanceof Error) { + this._error = err; + } else if (err !== null) { + const abortError = new Error(); + abortError.name = err; + this._error = abortError; } if (BridgeIDBFactory.enableTracing) { @@ -2814,6 +2816,10 @@ export class BridgeIDBTransaction this._openRequest.result = undefined; this._openRequest.readyState = "pending"; } + + // In particular, BEGIN and COMMIT failures do not have a later request + // pump iteration that could resolve this promise for us. + this._resolveWait(); } public abort() { @@ -2894,6 +2900,22 @@ export class BridgeIDBTransaction * Actually execute the scheduled work for this transaction. */ public async _start() { + try { + await this._startInner(); + } catch (err) { + // Backend failures at transaction boundaries used to escape from the + // queued task as unhandled rejections, leaving this transaction pending. + if (!this._finished) { + this._abort( + err instanceof Error + ? err + : new Error(`database error: ${String(err)}`), + ); + } + } + } + + private async _startInner() { if (BridgeIDBFactory.enableTracing) { console.log( `TRACE: IDBTransaction._start, ${this._requests.length} queued`, @@ -3029,7 +3051,7 @@ export class BridgeIDBTransaction throw err; } if (!event.canceled) { - this._abort(err.name); + this._abort(err); } } } @@ -3057,8 +3079,8 @@ export class BridgeIDBTransaction // against the transactions. if (this._backendTransaction) { const backendTx = this._backendTransaction; - this._backendTransaction = undefined; await this._backend.commit(backendTx); + this._backendTransaction = undefined; } // We must exit the upgrade transaction here, so that the "complete" diff --git a/packages/idb-bridge/src/node-helper-sqlite3-impl.test.ts b/packages/idb-bridge/src/node-helper-sqlite3-impl.test.ts @@ -17,6 +17,7 @@ import { test } from "node:test"; import assert from "node:assert"; import { createNodeHelperSqlite3Impl } from "./node-helper-sqlite3-impl.js"; +import { Sqlite3Error } from "./sqlite3-interface.js"; // Serial test as it touches the FS. test("sqlite3 helper", async (t) => { @@ -73,4 +74,34 @@ test("sqlite3 helper", async (t) => { assert.deepStrictEqual(getRes1.length, 2); assert.deepStrictEqual(getRes1[0].title, "foo"); assert.deepStrictEqual(getRes1[1].title, "foo4"); + + const badStatement = await db.prepare("ROLLBACK"); + await assert.rejects(badStatement.run(), (error: unknown) => { + assert(error instanceof Sqlite3Error); + assert(error instanceof Error); + assert.strictEqual(error.name, "Sqlite3Error"); + assert.strictEqual(error.code, "SQLITE_ERROR"); + assert.strictEqual(error.errno, 1); + assert.match(error.message, /no transaction is active/); + return true; + }); + + await db.exec("CREATE TABLE unique_entries(value TEXT UNIQUE)"); + await db.exec("INSERT INTO unique_entries VALUES ('one')"); + await assert.rejects( + db.exec("INSERT INTO unique_entries VALUES ('one')"), + (error: unknown) => { + assert(error instanceof Sqlite3Error); + assert.strictEqual(error.name, "Sqlite3Error"); + assert.strictEqual(error.code, "SQLITE_CONSTRAINT_UNIQUE"); + assert.strictEqual(error.errno, 2067); + assert.match( + error.message, + /UNIQUE constraint failed: unique_entries.value/, + ); + return true; + }, + ); + + await db.close(); }); diff --git a/packages/idb-bridge/src/node-helper-sqlite3-impl.ts b/packages/idb-bridge/src/node-helper-sqlite3-impl.ts @@ -20,6 +20,7 @@ import { ResultRow, RunResult, Sqlite3Database, + Sqlite3Error, Sqlite3Interface, Sqlite3Statement, } from "./sqlite3-interface.js"; @@ -381,22 +382,11 @@ class Writer { } } -class Sqlite3Error extends Error { - // Name of "code" is to be compatible with better-sqlite3. - constructor( - message: string, - public code: string, - ) { - super(message); - } -} - function throwForFailure(rd: Reader): never { const msg = rd.readString(); - // Numeric error code - rd.readUint16(); + const errno = rd.readUint16(); const errName = rd.readString(); - throw new Sqlite3Error(msg, errName); + throw new Sqlite3Error(msg, errName, errno); } function expectCommunicateSuccess(commRes: Uint8Array): void { diff --git a/packages/idb-bridge/src/sqlite-error-recovery.test.ts b/packages/idb-bridge/src/sqlite-error-recovery.test.ts @@ -0,0 +1,160 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + */ + +import assert from "node:assert"; +import { test } from "node:test"; +import { + BindParams, + Sqlite3Database, + Sqlite3Statement, +} from "./sqlite3-interface.js"; +import { createNodeHelperSqlite3Impl } from "./node-helper-sqlite3-impl.js"; +import { createSqliteBackendOverDb } from "./SqliteBackend.js"; +import { + BridgeIDBDatabase, + BridgeIDBFactory, + BridgeIDBTransaction, +} from "./bridge-idb.js"; +import { promiseFromRequest } from "./idbpromutil.js"; + +class FaultController { + private failures: string[] = []; + + failNext(sqlFragment: string): void { + this.failures.push(sqlFragment); + } + + check(sql: string): void { + const index = this.failures.findIndex((x) => sql.includes(x)); + if (index < 0) { + return; + } + const [fragment] = this.failures.splice(index, 1); + throw Error(`injected sqlite failure for ${fragment.trim()}`); + } +} + +function wrapStatement( + stmt: Sqlite3Statement, + sql: string, + faults: FaultController, +): Sqlite3Statement { + return { + internalStatement: stmt.internalStatement, + run(params?: BindParams) { + faults.check(sql); + return stmt.run(params); + }, + getAll(params?: BindParams) { + faults.check(sql); + return stmt.getAll(params); + }, + getFirst(params?: BindParams) { + faults.check(sql); + return stmt.getFirst(params); + }, + }; +} + +function wrapDatabase( + db: Sqlite3Database, + faults: FaultController, +): Sqlite3Database { + return { + internalDbHandle: db.internalDbHandle, + exec: (sql) => db.exec(sql), + close: () => db.close(), + async prepare(sql) { + return wrapStatement(await db.prepare(sql), sql, faults); + }, + }; +} + +async function setup() { + const sqlite = await createNodeHelperSqlite3Impl({ enableTracing: false }); + const rawDb = await sqlite.open(":memory:"); + const faults = new FaultController(); + const backend = await createSqliteBackendOverDb( + sqlite, + wrapDatabase(rawDb, faults), + ); + backend.enableTracing = false; + BridgeIDBFactory.enableTracing = false; + const factory = new BridgeIDBFactory(backend); + const openRequest = factory.open(`sqlite-recovery-${Math.random()}`, 1); + openRequest.onupgradeneeded = () => { + openRequest.result.createObjectStore("records", { keyPath: "id" }); + }; + const db = (await promiseFromRequest(openRequest)) as BridgeIDBDatabase; + return { db, faults, rawDb }; +} + +interface TransactionResult { + status: "abort" | "complete"; + error: Error | null; +} + +function put(db: BridgeIDBDatabase, id: number): Promise<TransactionResult> { + const tx = db.transaction("records", "readwrite") as BridgeIDBTransaction; + const request = tx.objectStore("records").put({ id }); + // The transaction result is what this test exercises. Still install the + // request handler so the deliberately injected error is handled. + request.onerror = () => {}; + return new Promise((resolve) => { + tx.onerror = () => {}; + tx.onabort = () => resolve({ status: "abort", error: tx.error }); + tx.oncomplete = () => resolve({ status: "complete", error: tx.error }); + }); +} + +test( + "sqlite errors abort the current transaction and release the next", + { timeout: 10_000 }, + async () => { + const { db, faults, rawDb } = await setup(); + + const cases: Array<{ name: string; failures: string[] }> = [ + { + name: "statement", + failures: ["INSERT OR REPLACE INTO object_data"], + }, + { name: "BEGIN", failures: ["BEGIN;"] }, + { name: "COMMIT", failures: ["COMMIT;"] }, + { + name: "ROLLBACK", + failures: ["INSERT OR REPLACE INTO object_data", "ROLLBACK;"], + }, + ]; + + let id = 1; + for (const c of cases) { + for (const failure of c.failures) { + faults.failNext(failure); + } + + const failed = await put(db, id++); + assert.strictEqual(failed.status, "abort", `${c.name} must abort`); + assert.match( + failed.error?.message ?? "", + /injected sqlite failure/, + `${c.name} must be preserved as the transaction error`, + ); + + const recovered = await put(db, id++); + assert.strictEqual( + recovered.status, + "complete", + `transaction after ${c.name} must complete`, + ); + } + + db.close(); + await rawDb.close(); + }, +); diff --git a/packages/idb-bridge/src/sqlite3-interface.ts b/packages/idb-bridge/src/sqlite3-interface.ts @@ -22,6 +22,37 @@ export type BindParams = Record<string, Sqlite3Value | undefined>; export type ResultRow = Record<string, Sqlite3Value>; /** + * SQLite error shape shared by the qtart and taler-helper-sqlite3 adapters. + * + * `code` and `errno` contain SQLite's extended symbolic and numeric result + * codes, respectively. + */ +export class Sqlite3Error extends Error { + override readonly name = "Sqlite3Error"; + + constructor( + message: string, + public readonly code: string, + public readonly errno: number, + ) { + super(message); + } +} + +/** Recognize SQLite errors across adapter/runtime boundaries. */ +export function isSqlite3Error(error: unknown): error is Sqlite3Error { + if (!(error instanceof Error)) { + return false; + } + const candidate = error as Partial<Sqlite3Error>; + return ( + candidate.name === "Sqlite3Error" && + typeof candidate.code === "string" && + Number.isInteger(candidate.errno) + ); +} + +/** * Common interface that multiple sqlite3 bindings * (such as better-sqlite3 or qtart's sqlite3 bindings) * can adapt to. diff --git a/packages/idb-bridge/taler-helper-sqlite3 b/packages/idb-bridge/taler-helper-sqlite3 @@ -213,6 +213,7 @@ def read_exactly(n): def handle_query_failure(req_id, e): pw = PacketWriter() + # Match qtart's Sqlite3Error fields: message, errno and code. pw.write_string(str(e)) pw.write_uint16(e.sqlite_errorcode) pw.write_string(e.sqlite_errorname) diff --git a/packages/taler-wallet-core/src/query-sqlite-error-recovery.test.ts b/packages/taler-wallet-core/src/query-sqlite-error-recovery.test.ts @@ -0,0 +1,156 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + */ + +import { + BindParams, + BridgeIDBDatabase, + BridgeIDBFactory, + createSqliteBackendOverDb, + Sqlite3Database, + Sqlite3Statement, +} from "@gnu-taler/idb-bridge"; +import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; +import { CancellationToken, codecForAny } from "@gnu-taler/taler-util"; +import assert from "node:assert"; +import { test } from "node:test"; +import { DbAccessImpl, describeStoreV2 } from "./query.js"; + +class FaultController { + private failures: string[] = []; + + failNext(sqlFragment: string): void { + this.failures.push(sqlFragment); + } + + check(sql: string): void { + const index = this.failures.findIndex((x) => sql.includes(x)); + if (index < 0) { + return; + } + const [fragment] = this.failures.splice(index, 1); + throw Error(`injected sqlite failure for ${fragment.trim()}`); + } +} + +function wrapStatement( + stmt: Sqlite3Statement, + sql: string, + faults: FaultController, +): Sqlite3Statement { + return { + internalStatement: stmt.internalStatement, + run(params?: BindParams) { + faults.check(sql); + return stmt.run(params); + }, + getAll(params?: BindParams) { + faults.check(sql); + return stmt.getAll(params); + }, + getFirst(params?: BindParams) { + faults.check(sql); + return stmt.getFirst(params); + }, + }; +} + +function wrapDatabase( + db: Sqlite3Database, + faults: FaultController, +): Sqlite3Database { + return { + internalDbHandle: db.internalDbHandle, + exec: (sql) => db.exec(sql), + close: () => db.close(), + async prepare(sql) { + return wrapStatement(await db.prepare(sql), sql, faults); + }, + }; +} + +const stores = { + records: describeStoreV2<"records", { id: number }>({ + storeName: "records", + recordCodec: codecForAny(), + keyPath: "id", + }), +}; + +async function setup() { + const sqlite = await createNodeHelperSqlite3Impl({ enableTracing: false }); + const rawDb = await sqlite.open(":memory:"); + const faults = new FaultController(); + const backend = await createSqliteBackendOverDb( + sqlite, + wrapDatabase(rawDb, faults), + ); + backend.enableTracing = false; + BridgeIDBFactory.enableTracing = false; + const factory = new BridgeIDBFactory(backend); + const openRequest = factory.open(`query-recovery-${Math.random()}`, 1); + openRequest.onupgradeneeded = () => { + openRequest.result.createObjectStore("records", { keyPath: "id" }); + }; + const db = await new Promise<BridgeIDBDatabase>((resolve, reject) => { + openRequest.onsuccess = () => + resolve(openRequest.result as BridgeIDBDatabase); + openRequest.onerror = () => reject(openRequest.error); + }); + return { + db, + faults, + rawDb, + access: new DbAccessImpl(db, stores, CancellationToken.CONTINUE), + }; +} + +test( + "query transactions reject with sqlite errors and then recover", + { timeout: 10_000 }, + async () => { + const { access, db, faults, rawDb } = await setup(); + + const put = (id: number) => + access.runAllStoresReadWriteTx({}, async (tx) => { + await tx.records.put({ id }); + return id; + }); + + const cases: Array<{ name: string; failures: string[] }> = [ + { + name: "statement", + failures: ["INSERT OR REPLACE INTO object_data"], + }, + { name: "BEGIN", failures: ["BEGIN;"] }, + { name: "COMMIT", failures: ["COMMIT;"] }, + { + name: "ROLLBACK", + failures: ["INSERT OR REPLACE INTO object_data", "ROLLBACK;"], + }, + ]; + + let id = 1; + for (const c of cases) { + for (const failure of c.failures) { + faults.failNext(failure); + } + await assert.rejects( + put(id++), + (err: Error) => + err.message.includes("injected sqlite failure") && + !err.message.includes("unknown transaction error"), + `${c.name} should preserve the concrete sqlite error`, + ); + await assert.doesNotReject(put(id++), `transaction after ${c.name}`); + } + + db.close(); + await rawDb.close(); + }, +); diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts @@ -624,19 +624,16 @@ function runTx<Arg, Res>( tx.onerror = () => { logger.trace("transaction had error"); if (cancellationToken.isCancelled) { - reject( - new CancellationToken.CancellationError(cancellationToken.reason), - ); return; } + // IDBTransaction.onerror observes a request error bubbling through the + // transaction. At this point tx.error is not set yet and the abort + // event has not fired. Settling here used to replace the request's + // concrete sqlite exception with "unknown transaction error". The + // request promise records the original exception, and onabort is the + // terminal transaction event that settles runTx. logger.error("error in transaction"); logger.error(`${stack.stack ?? stack}`); - const txError = tx.error; - if (txError) { - reject(txError); - } else { - reject(new Error("unknown transaction error")); - } }; tx.onabort = () => { logger.trace("transaction was aborted"); @@ -655,7 +652,10 @@ function runTx<Arg, Res>( } else { msg = "Transaction aborted (no DB error)"; } - const abortExn = new TransactionAbortedError(msg, transactionException); + const abortCause = + transactionException ?? + (tx.error instanceof Error ? tx.error : undefined); + const abortExn = new TransactionAbortedError(msg, abortCause); internalContext.isAborted = true; internalContext.abortExn = abortExn; unregisterOnCancelled(); @@ -690,13 +690,34 @@ function runTx<Arg, Res>( if (e == TransactionAbort) { logger.trace("aborting transaction"); tx.abort(); - } else if ("name" in e && e.name === "AbortError") { + } else if ( + e != null && + typeof e === "object" && + "name" in e && + e.name === "AbortError" + ) { logger.warn("got AbortError, transaction was aborted"); } else { transactionException = e; logger.error(`Transaction failed: ${safeStringifyException(e)}`); logger.error(`${stack.stack ?? stack}`); - tx.abort(); + try { + tx.abort(); + } catch (abortErr) { + // A request-level error normally aborts the transaction before + // its rejected promise runs. InvalidStateError therefore means + // the desired abort already happened. + if ( + !( + abortErr != null && + typeof abortErr === "object" && + "name" in abortErr && + abortErr.name === "InvalidStateError" + ) + ) { + throw abortErr; + } + } } }) .catch((e) => {