commit 7a7361d34e3e97c5c0e574713da11f7ff764b44b
parent 8b928a2b699a99ed856966a2754009826ca62ef9
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:42 +0200
wallet-core: replace IndexedDB dumps and retry failed repairs
Diffstat:
4 files changed, 272 insertions(+), 52 deletions(-)
diff --git a/packages/taler-wallet-core/src/db-indexeddb.test.ts b/packages/taler-wallet-core/src/db-indexeddb.test.ts
@@ -0,0 +1,137 @@
+/*
+ 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.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { BridgeIDBFactory, createSqliteBackend } from "@gnu-taler/idb-bridge";
+import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl";
+import assert from "node:assert";
+import { test } from "node:test";
+import { ConfigRecordKey } from "./db-common.js";
+import {
+ applyFixups,
+ exportSingleDb,
+ openTalerDatabase,
+ TALER_WALLET_MAIN_DB_NAME,
+} from "./db-indexeddb.js";
+import { IdbWalletDbHandle } from "./dbtx-handle-impl.js";
+import { makeIdbRunner } from "./dbtx-runners.js";
+
+test("indexeddb import clears stores absent from an older dump", async () => {
+ const handle = await makeIdbRunner();
+ try {
+ const dump = await handle.exportDatabase();
+ delete dump.databases[TALER_WALLET_MAIN_DB_NAME].stores.contacts;
+
+ await handle.runReadWriteTx((tx) =>
+ tx.addContact({
+ alias: "alice",
+ aliasType: "email",
+ mailboxBaseUri: "https://mailbox.example/",
+ mailboxAddress: "mailbox-address" as any,
+ source: "test",
+ petname: "Alice",
+ }),
+ );
+ await handle.importDatabase(dump);
+
+ const contacts = await handle.runReadWriteTx((tx) => tx.listContacts());
+ assert.deepStrictEqual(contacts, []);
+ } finally {
+ await handle.close();
+ }
+});
+
+test("notification sink exceptions do not prevent the first database open", async () => {
+ const sqlite3Impl = await createNodeHelperSqlite3Impl({
+ enableTracing: false,
+ });
+ const backend = await createSqliteBackend(sqlite3Impl, {
+ filename: ":memory:",
+ });
+ BridgeIDBFactory.enableTracing = false;
+ const handle = new IdbWalletDbHandle(new BridgeIDBFactory(backend));
+ handle.setNotificationSink(() => {
+ throw Error("host notification failure");
+ });
+ try {
+ const result = await handle.ensureOpen();
+ assert.ok(result.fixupsApplied > 0);
+ await handle.runReadWriteTx((tx) =>
+ tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 1 }),
+ );
+ } finally {
+ await handle.close();
+ }
+});
+
+test("a failed fixup is retried on the next database open", async () => {
+ const sqlite3Impl = await createNodeHelperSqlite3Impl({
+ enableTracing: false,
+ });
+ const backend = await createSqliteBackend(sqlite3Impl, {
+ filename: ":memory:",
+ });
+ BridgeIDBFactory.enableTracing = false;
+ let fixupAttempts = 0;
+ const handle = new IdbWalletDbHandle(
+ new BridgeIDBFactory(backend),
+ undefined,
+ async (access, notify) => {
+ fixupAttempts++;
+ if (fixupAttempts === 1) {
+ throw Error("injected fixup failure");
+ }
+ return await applyFixups(access, notify);
+ },
+ );
+ try {
+ await assert.rejects(handle.ensureOpen(), /injected fixup failure/);
+
+ const result = await handle.ensureOpen();
+ assert.strictEqual(fixupAttempts, 2);
+ assert.ok(result.fixupsApplied > 0);
+ await handle.runReadWriteTx((tx) =>
+ tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 2 }),
+ );
+ } finally {
+ await handle.close();
+ }
+});
+
+test("export closes its database connection", async () => {
+ const sqlite3Impl = await createNodeHelperSqlite3Impl({
+ enableTracing: false,
+ });
+ const backend = await createSqliteBackend(sqlite3Impl, {
+ filename: ":memory:",
+ });
+ BridgeIDBFactory.enableTracing = false;
+ const factory = new BridgeIDBFactory(backend);
+ const db = await openTalerDatabase(factory, async () => {});
+ db.close();
+
+ await exportSingleDb(factory, TALER_WALLET_MAIN_DB_NAME);
+
+ await new Promise<void>((resolve, reject) => {
+ const req = factory.deleteDatabase(TALER_WALLET_MAIN_DB_NAME);
+ req.addEventListener("success", () => resolve());
+ req.addEventListener("error", () =>
+ reject(req.error ?? Error("database deletion failed")),
+ );
+ req.addEventListener("blocked", () =>
+ reject(Error("export leaked an open database connection")),
+ );
+ });
+});
diff --git a/packages/taler-wallet-core/src/db-indexeddb.ts b/packages/taler-wallet-core/src/db-indexeddb.ts
@@ -1425,48 +1425,67 @@ export async function exportSingleDb(
};
return new Promise((resolve, reject) => {
+ let settled = false;
+ const fail = (error: unknown): void => {
+ if (settled) return;
+ settled = true;
+ myDb.close();
+ reject(error);
+ };
const tx = myDb.transaction(Array.from(myDb.objectStoreNames));
tx.addEventListener("complete", () => {
- //myDb.close();
+ if (settled) return;
+ settled = true;
+ myDb.close();
resolve(singleDbDump);
});
- // tslint:disable-next-line:prefer-for-of
- for (let i = 0; i < myDb.objectStoreNames.length; i++) {
- const name = myDb.objectStoreNames[i];
- const store = tx.objectStore(name);
- const storeDump: DbStoreDump = {
- autoIncrement: store.autoIncrement,
- keyPath: store.keyPath,
- indexes: {},
- records: [],
- };
- const indexNames = store.indexNames;
- for (let j = 0; j < indexNames.length; j++) {
- const idxName = indexNames[j];
- const index = store.index(idxName);
- storeDump.indexes[idxName] = {
- keyPath: index.keyPath,
- multiEntry: index.multiEntry,
- unique: index.unique,
+ tx.addEventListener("abort", () =>
+ fail(tx.error ?? Error(`export of ${dbName} was aborted`)),
+ );
+ tx.addEventListener("error", () =>
+ fail(tx.error ?? Error(`export of ${dbName} failed`)),
+ );
+ try {
+ // tslint:disable-next-line:prefer-for-of
+ for (let i = 0; i < myDb.objectStoreNames.length; i++) {
+ const name = myDb.objectStoreNames[i];
+ const store = tx.objectStore(name);
+ const storeDump: DbStoreDump = {
+ autoIncrement: store.autoIncrement,
+ keyPath: store.keyPath,
+ indexes: {},
+ records: [],
};
- }
- singleDbDump.stores[name] = storeDump;
- store.openCursor().addEventListener("success", (e: Event) => {
- const cursor = (e.target as any).result;
- if (cursor) {
- const rec: DbDumpRecord = {
- value: structuredEncapsulate(cursor.value),
+ const indexNames = store.indexNames;
+ for (let j = 0; j < indexNames.length; j++) {
+ const idxName = indexNames[j];
+ const index = store.index(idxName);
+ storeDump.indexes[idxName] = {
+ keyPath: index.keyPath,
+ multiEntry: index.multiEntry,
+ unique: index.unique,
};
- // Only store key if necessary, i.e. when
- // the key is not stored as part of the object via
- // a key path.
- if (store.keyPath == null) {
- rec.key = structuredEncapsulate(cursor.key);
- }
- storeDump.records.push(rec);
- cursor.continue();
}
- });
+ singleDbDump.stores[name] = storeDump;
+ store.openCursor().addEventListener("success", (e: Event) => {
+ const cursor = (e.target as any).result;
+ if (cursor) {
+ const rec: DbDumpRecord = {
+ value: structuredEncapsulate(cursor.value),
+ };
+ // Only store key if necessary, i.e. when
+ // the key is not stored as part of the object via
+ // a key path.
+ if (store.keyPath == null) {
+ rec.key = structuredEncapsulate(cursor.key);
+ }
+ storeDump.records.push(rec);
+ cursor.continue();
+ }
+ });
+ }
+ } catch (e) {
+ fail(e);
}
});
}
@@ -1498,8 +1517,8 @@ async function recoverFromDump(
for (let i = 0; i < storeNames.length; i++) {
const name = db.objectStoreNames[i];
const storeDump = dbDump.stores[name];
- if (!storeDump) continue;
await promiseFromRequest(tx.objectStore(name).clear());
+ if (!storeDump) continue;
logger.info(`importing ${storeDump.records.length} records into ${name}`);
for (let rec of storeDump.records) {
await promiseFromRequest(tx.objectStore(name).put(rec.value, rec.key));
diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts
@@ -1160,6 +1160,26 @@ export const conformanceCases: ConformanceCase[] = [
},
},
+ {
+ name: "notification sink exceptions do not fail committed work",
+ async run(t, runner) {
+ runner.setNotificationSink(() => {
+ throw Error("host notification failure");
+ });
+ await runner.runReadWriteTx(async (tx) => {
+ await tx.upsertConfig({
+ key: ConfigRecordKey.TestLoopTx,
+ value: 123,
+ });
+ tx.notify({ type: "balance-change" } as any);
+ });
+ const record = await runner.runReadWriteTx((tx) =>
+ tx.getConfig(ConfigRecordKey.TestLoopTx),
+ );
+ t.equal(record?.value, 123);
+ },
+ },
+
// ------------------------------------------------------- delete semantics
{
diff --git a/packages/taler-wallet-core/src/dbtx-handle-impl.ts b/packages/taler-wallet-core/src/dbtx-handle-impl.ts
@@ -21,7 +21,11 @@
* sqlite database is confined to these two classes.
*/
-import { CancellationToken, WalletNotification } from "@gnu-taler/taler-util";
+import {
+ CancellationToken,
+ Logger,
+ WalletNotification,
+} from "@gnu-taler/taler-util";
import {
AccessStats,
BridgeIDBFactory,
@@ -48,6 +52,23 @@ import {
import { WalletDbTransaction } from "./dbtx.js";
import { DbAccess, DbAccessImpl } from "./query.js";
+const logger = new Logger("dbtx-handle-impl.ts");
+
+function notifySafely(
+ sink: (notification: WalletNotification) => void,
+ notification: WalletNotification,
+): void {
+ try {
+ sink(notification);
+ } catch (e) {
+ logger.warn(
+ `ignoring exception from wallet notification sink: ${
+ e instanceof Error ? e.message : String(e)
+ }`,
+ );
+ }
+}
+
/**
* WalletDbHandle over the IndexedDB emulation.
*
@@ -60,6 +81,7 @@ export class IdbWalletDbHandle implements WalletDbHandle {
private idbHandle: IDBDatabase | undefined;
private dbAccess: DbAccess<typeof WalletIndexedDbStoresV1> | undefined;
+ private opening: Promise<{ fixupsApplied: number }> | undefined;
private notify: (n: WalletNotification) => void = () => {};
@@ -87,7 +109,7 @@ export class IdbWalletDbHandle implements WalletDbHandle {
}
emitNotification(notification: WalletNotification): void {
- this.notify(notification);
+ notifySafely(this.notify, notification);
}
constructor(
@@ -97,6 +119,7 @@ export class IdbWalletDbHandle implements WalletDbHandle {
* Summed into a single figure by getAccessStats.
*/
private rawStats?: () => AccessStats | undefined,
+ private applyDbFixups: typeof applyFixups = applyFixups,
) {}
/**
@@ -109,25 +132,46 @@ export class IdbWalletDbHandle implements WalletDbHandle {
if (this.dbAccess) {
return { fixupsApplied: 0 };
}
- this.idbHandle = await openTalerDatabase(this.idbFactory, async () => {});
- this.dbAccess = this.makeAccess();
- const fixupsApplied = await applyFixups(this.dbAccess, (n) =>
- this.notify(n),
- );
- return { fixupsApplied };
+ if (this.opening) {
+ return await this.opening;
+ }
+ const opening = this.openDatabase();
+ this.opening = opening;
+ try {
+ return await opening;
+ } finally {
+ if (this.opening === opening) {
+ this.opening = undefined;
+ }
+ }
}
- private makeAccess(): DbAccess<typeof WalletIndexedDbStoresV1> {
- if (!this.idbHandle) {
- throw Error("wallet database is not open");
+ private async openDatabase(): Promise<{ fixupsApplied: number }> {
+ const idbHandle = await openTalerDatabase(this.idbFactory, async () => {});
+ const dbAccess = this.makeAccess(idbHandle);
+ try {
+ const fixupsApplied = await this.applyDbFixups(dbAccess, (n) =>
+ this.emitNotification(n),
+ );
+ this.idbHandle = idbHandle;
+ this.dbAccess = dbAccess;
+ return { fixupsApplied };
+ } catch (e) {
+ idbHandle.close();
+ throw e;
}
+ }
+
+ private makeAccess(
+ idbHandle: IDBDatabase,
+ ): DbAccess<typeof WalletIndexedDbStoresV1> {
return new DbAccessImpl(
- this.idbHandle,
+ idbHandle,
WalletIndexedDbStoresV1,
CancellationToken.CONTINUE,
(notifs: WalletNotification[]) => {
for (const n of notifs) {
- this.notify(n);
+ this.emitNotification(n);
}
},
);
@@ -197,7 +241,7 @@ export class IdbWalletDbHandle implements WalletDbHandle {
await tx.fixups.delete(fx.fixupName);
}
});
- await applyFixups(access, (n) => this.notify(n));
+ await this.applyDbFixups(access, (n) => this.emitNotification(n));
}
async clearDatabase(): Promise<void> {
@@ -249,7 +293,7 @@ export class SqliteWalletDbHandle implements WalletDbHandle {
}
emitNotification(notification: WalletNotification): void {
- this.notify(notification);
+ notifySafely(this.notify, notification);
}
constructor(private ndb: NativeSqliteWalletDb) {}
@@ -289,7 +333,7 @@ export class SqliteWalletDbHandle implements WalletDbHandle {
): Promise<T> {
return await runNativeSqliteWalletTx(
this.ndb,
- (n) => this.notify(n),
+ (n) => this.emitNotification(n),
async (tx) => await f(tx),
);
}