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:
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(