taler-typescript-core

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

commit eda73e896e354f1ce8a0dea9b4a6e04d6c810704
parent dec00d5919deb70850649dbe3e323aae8951e87e
Author: Florian Dold <dold@taler.net>
Date:   Sun, 13 Sep 2026 16:16:27 +0200

wallet-core: export SQLite data from one snapshot

Read all export tables inside a database transaction so concurrent
writers on other connections cannot produce a mixed snapshot. Use the
existing transaction runner to release the transaction after export
failures.

Diffstat:
Apackages/taler-wallet-core/src/db/sqlite/database.test.ts | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db/sqlite/database.ts | 62++++++++++++++++++++++++++++++++++----------------------------
2 files changed, 134 insertions(+), 28 deletions(-)

diff --git a/packages/taler-wallet-core/src/db/sqlite/database.test.ts b/packages/taler-wallet-core/src/db/sqlite/database.test.ts @@ -0,0 +1,100 @@ +/* + 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 assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; +import { + closeNativeSqliteWalletDb, + exportNativeSqliteDb, + openNativeSqliteWalletDb, + runNativeSqliteWalletTx, +} from "./database.js"; + +test("native exports use one snapshot and recover after a read failure", async () => { + const dir = await mkdtemp(join(tmpdir(), "wallet-export-test-")); + const impl = await createNodeHelperSqlite3Impl({ enableTracing: false }); + const otherImpl = await createNodeHelperSqlite3Impl({ enableTracing: false }); + const db = await impl.open(join(dir, "wallet.db")); + const ndb = await openNativeSqliteWalletDb(db); + const other = await otherImpl.open(join(dir, "wallet.db")); + try { + await ( + await db.prepare( + "INSERT INTO bank_accounts VALUES ('sample', 'payto://sample', 'before', NULL, 0)", + ) + ).run({}); + await ( + await db.prepare("INSERT INTO config VALUES ('review', '\"before\"')") + ).run({}); + const prepare = db.prepare.bind(db); + let injected = false; + db.prepare = async (sql) => { + if (sql === 'SELECT * FROM "config"' && !injected) { + injected = true; + await (await other.prepare("BEGIN")).run({}); + await ( + await other.prepare("UPDATE bank_accounts SET label = 'after'") + ).run({}); + await ( + await other.prepare( + "UPDATE config SET value = '\"after\"' WHERE key = 'review'", + ) + ).run({}); + await (await other.prepare("COMMIT")).run({}); + } + return prepare(sql); + }; + const dump = await exportNativeSqliteDb(ndb); + assert.equal(dump.tables.bank_accounts[0].label, "before"); + assert.equal( + dump.tables.config.find((r) => r.key === "review")?.value, + '"before"', + ); + + let fail = true; + db.prepare = async (sql) => { + if (sql === 'SELECT * FROM "config"' && fail) { + fail = false; + throw Error("injected export failure"); + } + return prepare(sql); + }; + await assert.rejects(exportNativeSqliteDb(ndb), /injected export failure/); + await runNativeSqliteWalletTx( + ndb, + () => {}, + async () => { + await ( + await db.prepare("UPDATE bank_accounts SET label = 'after-failure'") + ).run({}); + }, + ); + const next = await exportNativeSqliteDb(ndb); + assert.equal(next.tables.bank_accounts[0].label, "after-failure"); + assert.equal( + next.tables.config.find((r) => r.key === "review")?.value, + '"after"', + ); + } finally { + await other.close(); + await closeNativeSqliteWalletDb(ndb); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/taler-wallet-core/src/db/sqlite/database.ts b/packages/taler-wallet-core/src/db/sqlite/database.ts @@ -590,35 +590,41 @@ function isDumpBlob(v: unknown): v is DumpBlob { export async function exportNativeSqliteDb( ndb: NativeSqliteWalletDb, ): Promise<NativeSqliteDbDump> { - return await ndb.lock.run(async () => { - const tables = await listDataTables(ndb); - const out: NativeSqliteDbDump = { - schemaVersion: SQLITE_SCHEMA_VERSION, - tables: {}, - }; - for (const table of tables) { - const rows = await ( - await ndb.db.prepare(`SELECT * FROM "${table}"`) - ).getAll(); - out.tables[table] = rows.map((row) => { - const clean: Record<string, DumpValue> = {}; - for (const [k, v] of Object.entries(row)) { - if (v instanceof Uint8Array) { - clean[k] = { $blob: encodeCrock(v) }; - } else if (typeof v === "bigint") { - // The helper can return INTEGER columns as bigint, which JSON - // cannot represent. Every integer in this schema (timestamps in - // microseconds, row ids, statuses) is inside the safe range. - clean[k] = Number(v); - } else { - clean[k] = v; + // The handle lock excludes only this connection's writers. Keep all reads + // in one SQLite snapshot even when another process commits during export. + return await runNativeSqliteWalletTx( + ndb, + () => {}, + async () => { + const tables = await listDataTables(ndb); + const out: NativeSqliteDbDump = { + schemaVersion: SQLITE_SCHEMA_VERSION, + tables: {}, + }; + for (const table of tables) { + const rows = await ( + await ndb.db.prepare(`SELECT * FROM "${table}"`) + ).getAll(); + out.tables[table] = rows.map((row) => { + const clean: Record<string, DumpValue> = {}; + for (const [k, v] of Object.entries(row)) { + if (v instanceof Uint8Array) { + clean[k] = { $blob: encodeCrock(v) }; + } else if (typeof v === "bigint") { + // The helper can return INTEGER columns as bigint, which JSON + // cannot represent. Every integer in this schema (timestamps in + // microseconds, row ids, statuses) is inside the safe range. + clean[k] = Number(v); + } else { + clean[k] = v; + } } - } - return clean; - }); - } - return out; - }); + return clean; + }); + } + return out; + }, + ); } export async function importNativeSqliteDb(