taler-typescript-core

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

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:
Mpackages/taler-harness/README.md | 20++++++++++++++++++++
Mpackages/taler-harness/src/harness/environments.ts | 5++++-
Mpackages/taler-harness/src/harness/harness.ts | 2++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 16+++++++++++-----
Apackages/taler-harness/src/integrationtests/wallet-db-backends.test.ts | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/wallet-db-backends.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-cli/README.md | 29++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/db/migration/native.test.ts | 20+++++++++++++++++++-
Apackages/taler-wallet-core/src/host-impl.node.test.ts | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/host-impl.node.ts | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
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) {