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:
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) => {