commit d869ee4aa1ce74f3db817f70dc45008f9f1b7423
parent 9c3c488efbb606ae210e529c0dc1388afc695915
Author: Florian Dold <dold@taler.net>
Date: Sun, 6 Sep 2026 13:30:58 +0200
wallet-core: default new CLI wallets to native SQLite
Also exercise some integration tests with both wallets.
Diffstat:
10 files changed, 532 insertions(+), 26 deletions(-)
diff --git a/packages/taler-harness/README.md b/packages/taler-harness/README.md
@@ -50,6 +50,26 @@ under `src/` you need:
Otherwise `list-integrationtests` keeps showing the old set and a glob naming a
new test reports "selected 0 tests".
+### Wallet database coverage
+
+Fresh Node.js wallets use native SQLite by default. The `simple-payment`,
+`refund`, `peer-push`, `peer-pull`, and `wallet-refresh-errors` tests also have
+`-indexeddb` variants that run automatically in the same suites. The last test
+includes database export/import as well as refresh error handling.
+
+Each original case explicitly uses `TALER_WALLET_DB_BACKEND=default`, and its
+variant uses `indexeddb`. Both disable automatic migration for the duration of
+the test and check the backend returned by each wallet's initialization.
+Inherited settings are restored afterward, including with `--reuse-worker`.
+
+Run just the legacy variants with:
+
+```sh
+taler-harness run-integrationtests '*-indexeddb'
+```
+
+Use `simple-payment,simple-payment-indexeddb` to select a single pair.
+
### Each test builds its own environment
`createSimpleTestkudosEnvironmentV3` (and friends in `harness/environments.ts`)
diff --git a/packages/taler-harness/src/harness/environments.ts b/packages/taler-harness/src/harness/environments.ts
@@ -662,9 +662,12 @@ export async function createWalletDaemonWithClient(
!!args.emitObservabilityEvents,
},
} satisfies PartialWalletRunConfig;
- await walletClient.client.call(WalletApiOperation.InitWallet, {
+ const init = await walletClient.client.call(WalletApiOperation.InitWallet, {
config: args.config ?? defaultRunConfig,
});
+ if (t.expectedWalletDbBackend !== undefined) {
+ t.assertDeepEqual(init.databaseBackend, t.expectedWalletDbBackend);
+ }
return { walletClient, walletService };
}
diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts
@@ -463,6 +463,8 @@ export class GlobalTestParams {
}
export class GlobalTestState {
+ /** Set by database variants and checked when each wallet initializes. */
+ expectedWalletDbBackend?: "indexeddb" | "sqlite";
testDir: string;
procs: ProcessWrapper[];
servers: http.Server[];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -288,6 +288,7 @@ import { runWithdrawalIdempotentTest } from "./test-withdrawal-idempotent.js";
import { runWithdrawalManualTest } from "./test-withdrawal-manual.js";
import { runWithdrawalShortenTest } from "./test-withdrawal-shorten.js";
import { TodoBugUrl, validateTodoBugUrl } from "./todo.js";
+import { withWalletDbBackend } from "./wallet-db-backends.js";
/**
* Test runner.
@@ -321,7 +322,8 @@ const allTests: TestMainFunction[] = [
runDenomUnofferedTest,
runDepositTest,
runDepositMergeTest,
- runSimplePaymentTest,
+ withWalletDbBackend(runSimplePaymentTest, "default"),
+ withWalletDbBackend(runSimplePaymentTest, "indexeddb"),
runExchangeManagementFaultTest,
runExchangeKeysCherrypickTest,
runExchangeKeysWithdrawFilterTest,
@@ -362,12 +364,15 @@ const allTests: TestMainFunction[] = [
runPeerAbortBeforeCreateTest,
runTransactionFinalAmountsTest,
runPeerPullDebitPurseGoneTest,
- runPeerPullTest,
- runPeerPushTest,
+ withWalletDbBackend(runPeerPullTest, "default"),
+ withWalletDbBackend(runPeerPullTest, "indexeddb"),
+ withWalletDbBackend(runPeerPushTest, "default"),
+ withWalletDbBackend(runPeerPushTest, "indexeddb"),
runRefundAutoTest,
runRefundGoneTest,
runRefundIncrementalTest,
- runRefundTest,
+ withWalletDbBackend(runRefundTest, "default"),
+ withWalletDbBackend(runRefundTest, "indexeddb"),
runRevocationTest,
runWithdrawalManualTest,
runTimetravelAutorefreshTest,
@@ -410,7 +415,8 @@ const allTests: TestMainFunction[] = [
runDenomLostTest,
runWalletDenomExpireTest,
runWalletExchangeUpdateTest,
- runWalletRefreshErrorsTest,
+ withWalletDbBackend(runWalletRefreshErrorsTest, "default"),
+ withWalletDbBackend(runWalletRefreshErrorsTest, "indexeddb"),
runWalletNetworkAvailabilityTest,
runPeerPullLargeTest,
runPeerPushLargeTest,
diff --git a/packages/taler-harness/src/integrationtests/wallet-db-backends.test.ts b/packages/taler-harness/src/integrationtests/wallet-db-backends.test.ts
@@ -0,0 +1,101 @@
+/*
+ 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 { test } from "node:test";
+import { GlobalTestState } from "../harness/harness.js";
+import { getTestInfo, getTestName } from "./testrunner.js";
+import { withWalletDbBackend } from "./wallet-db-backends.js";
+
+test("representative wallet backend variants are registered in the same suites", () => {
+ const tests = getTestInfo();
+ assert.equal(new Set(tests.map((t) => t.name)).size, tests.length);
+ for (const name of [
+ "simple-payment",
+ "refund",
+ "peer-push",
+ "peer-pull",
+ "wallet-refresh-errors",
+ ]) {
+ const original = tests.find((t) => t.name === name);
+ const legacy = tests.find((t) => t.name === `${name}-indexeddb`);
+ assert.ok(original);
+ assert.deepEqual(legacy, { ...original, name: `${name}-indexeddb` });
+ }
+});
+
+for (const backend of ["default", "indexeddb"] as const) {
+ for (const fail of [false, true]) {
+ for (const inherited of [undefined, "sqlite"]) {
+ test(`${backend} variant restores environment after ${fail ? "failure" : "success"}, inherited ${inherited}`, async (t) => {
+ const oldBackend = process.env.TALER_WALLET_DB_BACKEND;
+ const oldMigration = process.env.TALER_WALLET_MIGRATE_NATIVE_DB;
+ t.after(() => {
+ if (oldBackend === undefined)
+ delete process.env.TALER_WALLET_DB_BACKEND;
+ else process.env.TALER_WALLET_DB_BACKEND = oldBackend;
+ if (oldMigration === undefined)
+ delete process.env.TALER_WALLET_MIGRATE_NATIVE_DB;
+ else process.env.TALER_WALLET_MIGRATE_NATIVE_DB = oldMigration;
+ });
+ if (inherited === undefined) {
+ delete process.env.TALER_WALLET_DB_BACKEND;
+ delete process.env.TALER_WALLET_MIGRATE_NATIVE_DB;
+ } else {
+ process.env.TALER_WALLET_DB_BACKEND = inherited;
+ process.env.TALER_WALLET_MIGRATE_NATIVE_DB = "1";
+ }
+ const state = new GlobalTestState({ testDir: "/unused" });
+ state.expectedWalletDbBackend = "sqlite";
+ let called = false;
+ async function runExampleTest(gc: GlobalTestState): Promise<void> {
+ called = true;
+ assert.equal(gc, state);
+ assert.equal(process.env.TALER_WALLET_DB_BACKEND, backend);
+ assert.equal(process.env.TALER_WALLET_MIGRATE_NATIVE_DB, "0");
+ assert.equal(
+ gc.expectedWalletDbBackend,
+ backend === "default" ? "sqlite" : "indexeddb",
+ );
+ if (fail) throw Error("deliberate test failure");
+ }
+ runExampleTest.suites = ["wallet"];
+ runExampleTest.timeoutMs = 123;
+ runExampleTest.experimental = true;
+ runExampleTest.todo = "https://bugs.taler.net/n/9828" as const;
+ const variant = withWalletDbBackend(runExampleTest, backend);
+ assert.equal(
+ getTestName(variant),
+ backend === "default" ? "example" : "example-indexeddb",
+ );
+ assert.equal(variant.timeoutMs, 123);
+ assert.equal(variant.experimental, true);
+ assert.equal(variant.todo, runExampleTest.todo);
+ assert.deepEqual(variant.suites, ["wallet"]);
+ if (fail)
+ await assert.rejects(variant(state), /deliberate test failure/);
+ else await variant(state);
+ assert.equal(called, true);
+ assert.equal(state.expectedWalletDbBackend, "sqlite");
+ assert.equal(process.env.TALER_WALLET_DB_BACKEND, inherited);
+ assert.equal(
+ process.env.TALER_WALLET_MIGRATE_NATIVE_DB,
+ inherited === undefined ? undefined : "1",
+ );
+ });
+ }
+ }
+}
diff --git a/packages/taler-harness/src/integrationtests/wallet-db-backends.ts b/packages/taler-harness/src/integrationtests/wallet-db-backends.ts
@@ -0,0 +1,54 @@
+/*
+ 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 { GlobalTestState } from "../harness/harness.js";
+
+/** Run a test with deterministic wallet storage, including all child wallets. */
+export function withWalletDbBackend<
+ T extends (t: GlobalTestState) => Promise<void>,
+>(test: T, backend: "default" | "indexeddb"): T {
+ const wrapped = async (t: GlobalTestState): Promise<void> => {
+ const previousBackend = process.env.TALER_WALLET_DB_BACKEND;
+ const previousMigration = process.env.TALER_WALLET_MIGRATE_NATIVE_DB;
+ const previousExpectation = t.expectedWalletDbBackend;
+ process.env.TALER_WALLET_DB_BACKEND = backend;
+ process.env.TALER_WALLET_MIGRATE_NATIVE_DB = "0";
+ t.expectedWalletDbBackend = backend === "default" ? "sqlite" : backend;
+ try {
+ await test(t);
+ } finally {
+ t.expectedWalletDbBackend = previousExpectation;
+ if (previousBackend === undefined) {
+ delete process.env.TALER_WALLET_DB_BACKEND;
+ } else {
+ process.env.TALER_WALLET_DB_BACKEND = previousBackend;
+ }
+ if (previousMigration === undefined) {
+ delete process.env.TALER_WALLET_MIGRATE_NATIVE_DB;
+ } else {
+ process.env.TALER_WALLET_MIGRATE_NATIVE_DB = previousMigration;
+ }
+ }
+ };
+ Object.assign(wrapped, test);
+ Object.defineProperty(wrapped, "name", {
+ value:
+ backend === "default"
+ ? test.name
+ : test.name.replace(/Test$/, "IndexeddbTest"),
+ });
+ return wrapped as T;
+}
diff --git a/packages/taler-wallet-cli/README.md b/packages/taler-wallet-cli/README.md
@@ -3,10 +3,33 @@
This package provides `taler-wallet-cli`, the command-line interface for the
GNU Taler wallet.
-## sqlite3 backend
+## Database backend
-To be able to use the sqlite3 backend, make sure that better-sqlite3
-is installed as an optional dependency in the ../idb-bridge package.
+The Node.js CLI uses native SQLite for new and empty wallets. Existing wallets
+continue to use their current backend. Both backends store data in a SQLite
+file; `indexeddb` refers to the legacy IndexedDB-emulation schema.
+
+Set `TALER_WALLET_DB_BACKEND` to select a backend:
+
+- `default` (also unset or empty): use native SQLite for empty storage and
+ detect the backend of existing wallets.
+- `sqlite`: use native SQLite.
+- `indexeddb`: use the legacy IndexedDB-emulation backend.
+
+An explicit selection that conflicts with an existing database fails with
+guidance. It does not convert the wallet. To upgrade an existing legacy wallet:
+
+```sh
+TALER_WALLET_DB_BACKEND=default taler-wallet-cli --wallet-db /path/to/wallet.sqlite3 advanced db-migrate
+```
+
+The default backend logs this migration hint at INFO when opening legacy
+wallets; use `-LINFO` to display INFO logs. Explicitly selecting `indexeddb`
+suppresses the hint.
+`TALER_WALLET_MIGRATE_NATIVE_DB=1` remains available to request migration during
+initialization. `TALER_WALLET_NATIVE_DB` is retired and has no effect.
+These environment-selection rules apply to the Node.js CLI; Qtart/mobile
+configuration is unchanged.
## Offline mode
diff --git a/packages/taler-wallet-core/src/db/migration/native.test.ts b/packages/taler-wallet-core/src/db/migration/native.test.ts
@@ -765,7 +765,18 @@ test("native migration: offline resolution creates the mandatory full backup", a
}
});
-test("useNativeDb initializes empty storage directly as native", async () => {
+test("useNativeDb initializes empty storage directly as native", async (t) => {
+ // Exercise the InitWallet feature even though the Node.js host now defaults
+ // to opening native storage before initialization.
+ const previousBackend = process.env.TALER_WALLET_DB_BACKEND;
+ process.env.TALER_WALLET_DB_BACKEND = "indexeddb";
+ t.after(() => {
+ if (previousBackend === undefined) {
+ delete process.env.TALER_WALLET_DB_BACKEND;
+ } else {
+ process.env.TALER_WALLET_DB_BACKEND = previousBackend;
+ }
+ });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wallet-native-empty-"));
const dbPath = path.join(dir, "wallet.sqlite3");
try {
@@ -849,6 +860,13 @@ for (const sourceBackend of ["indexeddb", "sqlite"] as const) {
}
const sourceBefore = fs.readFileSync(sourcePath);
+ // Fresh Node.js wallets now default to SQLite. Preserve explicit
+ // coverage of imports into an existing IndexedDB wallet.
+ if (targetBackend === "indexeddb") {
+ const target = await makeMinimalIdbDb(targetPath);
+ await target.handle.close();
+ await target.db.close();
+ }
({ wallet } = await createNativeWalletHost2({
persistentStoragePath: targetPath,
}));
diff --git a/packages/taler-wallet-core/src/host-impl.node.test.ts b/packages/taler-wallet-core/src/host-impl.node.test.ts
@@ -0,0 +1,224 @@
+/*
+ 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 fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, test, type TestContext } from "node:test";
+import { createSqliteBackendOverDb } from "@gnu-taler/idb-bridge";
+import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl";
+import { Logger } from "@gnu-taler/taler-util";
+import {
+ createNativeWalletHost2,
+ inspectWalletDbPath,
+} from "./host-impl.node.js";
+import { openNativeSqliteWalletDb } from "./db/sqlite/database.js";
+import { WalletApiOperation } from "./wallet-api-types.js";
+import { Wallet } from "./wallet.js";
+
+const variables = [
+ "TALER_WALLET_DB_BACKEND",
+ "TALER_WALLET_MIGRATE_NATIVE_DB",
+ "TALER_WALLET_NATIVE_DB",
+];
+const previous = variables.map((name) => process.env[name]);
+beforeEach(() => {
+ for (const name of variables) delete process.env[name];
+});
+afterEach(() => {
+ variables.forEach((name, i) => {
+ if (previous[i] === undefined) delete process.env[name];
+ else process.env[name] = previous[i];
+ });
+});
+
+async function withWallet<T>(
+ filename: string,
+ run: (wallet: Wallet, backend: string) => Promise<T>,
+): Promise<T> {
+ const { wallet } = await createNativeWalletHost2({
+ persistentStoragePath: filename,
+ cryptoWorkerType: "sync",
+ });
+ try {
+ const init = await wallet.client.call(WalletApiOperation.InitWallet, {
+ config: { lazyTaskLoop: true, testing: { skipDefaults: true } },
+ });
+ return await run(wallet, init.databaseBackend);
+ } finally {
+ await wallet.client.call(WalletApiOperation.Shutdown, {});
+ }
+}
+
+function databasePath(t: TestContext): string {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "wallet-backend-"));
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
+ return path.join(directory, "wallet's database.sqlite3");
+}
+
+for (const selection of [undefined, "", "default", "sqlite", "indexeddb"]) {
+ test(`fresh storage selects ${selection ?? "unset"}`, async (t) => {
+ if (selection !== undefined)
+ process.env.TALER_WALLET_DB_BACKEND = selection;
+ const expected = selection === "indexeddb" ? "indexeddb" : "sqlite";
+ const filename = databasePath(t);
+ for (const storage of [filename, filename + ".empty", ":memory:"]) {
+ if (storage.endsWith(".empty")) fs.writeFileSync(storage, "");
+ await withWallet(storage, async (_wallet, backend) => {
+ assert.equal(backend, expected);
+ });
+ if (storage !== ":memory:") {
+ assert.equal((await inspectWalletDbPath(storage)).migration, undefined);
+ }
+ }
+ });
+}
+
+test("invalid selection fails before creating storage", async (t) => {
+ const filename = databasePath(t);
+ for (const value of ["native", "SQLITE", "0", " sqlite "]) {
+ process.env.TALER_WALLET_DB_BACKEND = value;
+ await assert.rejects(
+ createNativeWalletHost2({ persistentStoragePath: filename }),
+ /expected indexeddb, sqlite, or default/,
+ );
+ assert.equal(fs.existsSync(filename), false);
+ }
+});
+
+test("retired native selection variable has no effect", async () => {
+ process.env.TALER_WALLET_NATIVE_DB = "1";
+ process.env.TALER_WALLET_DB_BACKEND = "indexeddb";
+ await withWallet(":memory:", async (_wallet, backend) => {
+ assert.equal(backend, "indexeddb");
+ });
+});
+
+test("empty emulation scaffolding initializes natively without migration", async (t) => {
+ const filename = databasePath(t);
+ const imp = await createNodeHelperSqlite3Impl();
+ const db = await imp.open(filename);
+ const bridge = await createSqliteBackendOverDb(imp, db);
+ await bridge.dispose();
+ await db.close();
+ await imp.shutdown();
+ await withWallet(filename, async (_wallet, backend) => {
+ assert.equal(backend, "sqlite");
+ });
+ const info = await inspectWalletDbPath(filename);
+ assert.equal(info.kind, "native");
+ assert.equal(info.migration, undefined);
+ assert.equal(info.indexedDbRecords, 0);
+});
+
+for (const existing of ["indexeddb", "sqlite"] as const) {
+ test(`existing ${existing} survives default reopening and rejected mismatch`, async (t) => {
+ const filename = databasePath(t);
+ process.env.TALER_WALLET_DB_BACKEND = existing;
+ await withWallet(filename, async (wallet) => {
+ await wallet.client.call(WalletApiOperation.AddBankAccount, {
+ label: "test-record",
+ currencies: ["TESTKUDOS"],
+ paytoUri: "payto://iban/DE89370400440532013000",
+ });
+ });
+ const before = fs.readFileSync(filename);
+ process.env.TALER_WALLET_DB_BACKEND =
+ existing === "sqlite" ? "indexeddb" : "sqlite";
+ await assert.rejects(
+ createNativeWalletHost2({ persistentStoragePath: filename }),
+ /TALER_WALLET_DB_BACKEND=default/,
+ );
+ assert.deepEqual(fs.readFileSync(filename), before);
+ // Reopening also proves that the rejected attempt released ownership.
+ for (const selection of ["default", existing]) {
+ process.env.TALER_WALLET_DB_BACKEND = selection;
+ await withWallet(filename, async (wallet, backend) => {
+ assert.equal(backend, existing);
+ assert.match(
+ JSON.stringify(
+ await wallet.client.call(WalletApiOperation.ExportDb, {}),
+ ),
+ /test-record/,
+ );
+ });
+ }
+ });
+}
+
+test("default legacy startup logs INFO migration guidance and can migrate explicitly", async (t) => {
+ const filename = databasePath(t);
+ const messages: string[] = [];
+ const warnings: string[] = [];
+ t.mock.method(Logger.prototype, "info", (message: string) => {
+ messages.push(message);
+ });
+ t.mock.method(Logger.prototype, "warn", (message: string) => {
+ warnings.push(message);
+ });
+ process.env.TALER_WALLET_DB_BACKEND = "indexeddb";
+ await withWallet(filename, async () => {});
+ assert.ok(!messages.some((m) => m.includes("Upgrade to native SQLite")));
+ process.env.TALER_WALLET_DB_BACKEND = "default";
+ await withWallet(filename, async (wallet, backend) => {
+ assert.equal(backend, "indexeddb");
+ const hint = messages.find((m) => m.includes("Upgrade to native SQLite"));
+ assert.ok(hint);
+ assert.match(
+ hint,
+ /TALER_WALLET_DB_BACKEND=default taler-wallet-cli --wallet-db/,
+ );
+ assert.ok(hint.endsWith("advanced db-migrate"));
+ const result = await wallet.client.call(
+ WalletApiOperation.MigrateDatabase,
+ {},
+ );
+ assert.equal(result.databaseBackend, "sqlite");
+ });
+ messages.length = 0;
+ process.env.TALER_WALLET_DB_BACKEND = "sqlite";
+ await withWallet(filename, async (_wallet, backend) => {
+ assert.equal(backend, "sqlite");
+ });
+ assert.ok(!messages.some((m) => m.includes("Upgrade to native SQLite")));
+ assert.ok(!warnings.some((m) => m.includes("experimental")));
+});
+
+for (const status of ["running", "rolled-back"]) {
+ test(`empty ${status} migration retains legacy authority`, async (t) => {
+ const filename = databasePath(t);
+ const imp = await createNodeHelperSqlite3Impl();
+ const db = await imp.open(filename);
+ const bridge = await createSqliteBackendOverDb(imp, db);
+ await openNativeSqliteWalletDb(db);
+ await (
+ await db.prepare(
+ "INSERT INTO idb_migration (id, status, started_at, cleanup_safe) VALUES (1, $status, 1, 1)",
+ )
+ ).run({ status });
+ await bridge.dispose();
+ await db.close();
+ await imp.shutdown();
+ await withWallet(filename, async (_wallet, backend) => {
+ assert.equal(backend, "indexeddb");
+ });
+ assert.equal(
+ (await inspectWalletDbPath(filename)).migration?.status,
+ status,
+ );
+ });
+}
diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts
@@ -61,6 +61,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { IdbWalletDbHandle } from "./db/indexeddb/handle.js";
import { SqliteWalletDbHandle } from "./db/sqlite/handle.js";
+import { IDB_EMULATION_TABLES } from "./db/sqlite/schema.js";
import {
getWalletDbDumpBackend,
importWalletDbDump,
@@ -69,6 +70,10 @@ import {
const logger = new Logger("host-impl.node.ts");
+function shellQuote(value: string): string {
+ return "'" + value.replaceAll("'", "'\\''") + "'";
+}
+
function addNodeDatabaseCapabilities(
handle: WalletDbHandle,
temporaryStoragePath: string | undefined,
@@ -190,6 +195,12 @@ function addNodeDatabaseCapabilities(
async function makeSqliteDb(
args: DefaultNodeWalletArgs,
): Promise<WalletDbHandle> {
+ const selection = process.env.TALER_WALLET_DB_BACKEND || "default";
+ if (!["default", "indexeddb", "sqlite"].includes(selection)) {
+ throw Error(
+ `invalid TALER_WALLET_DB_BACKEND=${JSON.stringify(selection)}; expected indexeddb, sqlite, or default`,
+ );
+ }
const tracing = !!process.env.TALER_WALLET_DBTRACING;
BridgeIDBFactory.enableTracing = tracing;
const dbFilename = getSqlite3FilenameFromStoragePath(
@@ -212,41 +223,85 @@ async function makeSqliteDb(
// works on the whole database had to pick one, and picking wrong produced a
// valid empty database rather than an error.
//
- // Which schema the file's records are already in decides that; the
- // environment variable only says what a database that does not exist yet
- // should be created as.
- const kind = await inspectWalletDbFile(db);
+ // Existing storage decides the default. Explicit selections must agree
+ // with it; changing this setting is never an implicit migration.
+ let kind: WalletDbFileKind;
+ try {
+ kind = await inspectWalletDbFile(db);
+ if (kind === "indexeddb") {
+ // The bridge creates bookkeeping tables before any wallet opens.
+ // Only discard scaffolding with no records, other tables, or recovery
+ // state. In particular, an interrupted or rolled-back migration is
+ // not fresh storage even if it currently contains no wallet records.
+ const tables = await (
+ await db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
+ ).getAll({});
+ if (
+ tables.every((row) =>
+ [...IDB_EMULATION_TABLES, "sqlite_sequence"].includes(
+ String(row.name),
+ ),
+ )
+ ) {
+ const record = await (
+ await db.prepare("SELECT 1 FROM object_data LIMIT 1")
+ ).getFirst({});
+ if (!record) kind = "empty";
+ }
+ }
+ } catch (e) {
+ await db.close();
+ await imp.shutdown();
+ throw e;
+ }
if (kind === "ambiguous") {
await db.close();
+ await imp.shutdown();
throw Error(
`${dbFilename} contains records in both wallet schemas and has no trustworthy authority marker; inspect it and use advanced db-migration-resolve`,
);
}
+ const existingBackend = kind === "native" ? "sqlite" : "indexeddb";
if (
- kind === "native" ||
- (kind === "empty" && process.env.TALER_WALLET_NATIVE_DB)
+ kind !== "empty" &&
+ selection !== "default" &&
+ selection !== existingBackend
) {
- logger.info(`using NATIVE sqlite3 wallet DB at ${dbFilename}`);
- logger.warn("the native sqlite3 wallet DB backend is experimental");
+ await db.close();
+ await imp.shutdown();
+ throw Error(
+ `${dbFilename} uses the ${existingBackend} wallet database backend, but` +
+ ` TALER_WALLET_DB_BACKEND=${selection} was requested. Set` +
+ ` TALER_WALLET_DB_BACKEND=default or ${existingBackend} to open it.` +
+ (existingBackend === "indexeddb"
+ ? ` To migrate, use TALER_WALLET_DB_BACKEND=default taler-wallet-cli --wallet-db ${shellQuote(dbFilename)} advanced db-migrate.`
+ : " Migration from sqlite to indexeddb is not supported by db-migrate."),
+ );
+ }
+ if (kind === "native" || (kind === "empty" && selection !== "indexeddb")) {
+ logger.info(`using sqlite wallet database at ${dbFilename}`);
// Opening is the moment nothing holds the database, which is what
// dropping tables needs.
await dropExpiredMigrationBackup(db);
- const ndb = await openNativeSqliteWalletDb(db);
+ const handle =
+ kind === "empty"
+ ? await openNativeWalletDbForEmptyStorage(db)
+ : new SqliteWalletDbHandle(await openNativeSqliteWalletDb(db));
return addNodeDatabaseCapabilities(
- new SqliteWalletDbHandle(ndb),
+ handle,
args.temporaryStoragePath,
dbFilename,
);
}
- if (process.env.TALER_WALLET_NATIVE_DB) {
- logger.warn(
- `${dbFilename} holds an IndexedDB-emulation wallet database; opening` +
- ` it natively would show an empty wallet. Set` +
- ` TALER_WALLET_MIGRATE_NATIVE_DB=1 to convert it instead.`,
+ if (kind === "indexeddb" && selection === "default") {
+ logger.info(
+ `${dbFilename} uses the legacy IndexedDB-emulation wallet database.` +
+ ` Upgrade to native SQLite with: TALER_WALLET_DB_BACKEND=default` +
+ ` taler-wallet-cli --wallet-db ${shellQuote(dbFilename)} advanced db-migrate`,
);
}
- logger.info(`using database ${dbFilename}`);
+ logger.info(`using indexeddb wallet database at ${dbFilename}`);
const myBackend = await createSqliteBackendOverDb(imp, db);
myBackend.enableTracing = tracing;
if (process.env.TALER_WALLET_STATS) {