taler-typescript-core

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

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:
Apackages/taler-wallet-core/src/db-indexeddb.test.ts | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db-indexeddb.ts | 91++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 20++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-handle-impl.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
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), ); }