commit 08d9950ffc0aa61eb602e136ae2f8c45befc533a
parent 2fb806742e0a0b04ffca1d10cffdc19a264d5756
Author: Florian Dold <dold@taler.net>
Date: Sat, 22 Aug 2026 14:13:06 +0200
wallet-core: expose browser database tooling
Diffstat:
4 files changed, 149 insertions(+), 161 deletions(-)
diff --git a/packages/taler-wallet-core/src/db/testing/benchmark.ts b/packages/taler-wallet-core/src/db/testing/benchmark.ts
@@ -45,9 +45,8 @@ import {
WalletCoin,
WalletCoinAvailability,
WalletDenomination,
- WalletDenominationFamily,
} from "../records.js";
-import { DbTxRunner } from "./conformance.js";
+import type { DbTxRunner } from "./conformance.js";
const logger = new Logger("db/testing/benchmark.ts");
@@ -129,30 +128,33 @@ function exchangeUrl(i: number): string {
return `https://exchange-${i}.test/`;
}
-async function populate(
+export async function populateDbBench(
runner: DbTxRunner,
opts: DbBenchOptions,
): Promise<void> {
// Denominations and their availability rows.
await runner.runReadWriteTx(async (tx) => {
- for (let e = 0; e < opts.numExchanges; e++) {
- const family: WalletDenominationFamily = {
- denominationFamilySerial: e + 1,
- familyParams: {
- exchangeBaseUrl: exchangeUrl(e),
- exchangeMasterPub: key(`master-${e}`),
- value: "TESTKUDOS:1" as AmountString,
- feeDeposit: "TESTKUDOS:0.01" as AmountString,
- feeRefresh: "TESTKUDOS:0.01" as AmountString,
- feeRefund: "TESTKUDOS:0.01" as AmountString,
- feeWithdraw: "TESTKUDOS:0.01" as AmountString,
- },
- };
- await tx.upsertDenominationFamily(family);
+ const familySerials: number[] = [];
+ for (let exchange = 0; exchange < opts.numExchanges; exchange++) {
+ familySerials.push(
+ await tx.upsertDenominationFamily({
+ familyParams: {
+ exchangeBaseUrl: exchangeUrl(exchange),
+ exchangeMasterPub: key(`master-${exchange}`),
+ value: "TESTKUDOS:1" as AmountString,
+ feeWithdraw: "TESTKUDOS:0.01" as AmountString,
+ feeDeposit: "TESTKUDOS:0.01" as AmountString,
+ feeRefresh: "TESTKUDOS:0.01" as AmountString,
+ feeRefund: "TESTKUDOS:0.01" as AmountString,
+ },
+ }),
+ );
}
for (let d = 0; d < opts.numDenominations; d++) {
- const ex = exchangeUrl(d % opts.numExchanges);
+ const exchange = d % opts.numExchanges;
+ const ex = exchangeUrl(exchange);
const dph = hash(`denom-${d}`);
+ const masterPub = key(`master-${exchange}`);
const denom: WalletDenomination = {
denomPubHash: dph,
denomPub: {
@@ -161,10 +163,10 @@ async function populate(
age_mask: 0,
},
exchangeBaseUrl: ex,
- exchangeMasterPub: key(`master-${d % opts.numExchanges}`),
+ exchangeMasterPub: masterPub,
currency: "TESTKUDOS",
value: "TESTKUDOS:1" as AmountString,
- denominationFamilySerial: (d % opts.numExchanges) + 1,
+ denominationFamilySerial: familySerials[exchange],
stampStart: (1000 + d) as DbProtocolTimestamp,
stampExpireWithdraw: (2000 + d) as DbProtocolTimestamp,
stampExpireDeposit: (3000 + d) as DbProtocolTimestamp,
@@ -183,7 +185,7 @@ async function populate(
await tx.upsertDenomination(denom);
const avail: WalletCoinAvailability = {
exchangeBaseUrl: ex,
- exchangeMasterPub: key(`master-${d % opts.numExchanges}`),
+ exchangeMasterPub: masterPub,
denomPubHash: dph,
maxAge: d % 2 === 0 ? 0 : 21,
currency: "TESTKUDOS",
@@ -238,7 +240,7 @@ async function populate(
/**
* Run the benchmark against one already-populated runner.
*/
-async function measure(
+export async function measureDbBenchQueries(
runner: DbTxRunner,
opts: DbBenchOptions,
): Promise<DbBenchQueryResult[]> {
@@ -246,7 +248,6 @@ async function measure(
const time = async (
name: string,
- expectedRows: number,
f: () => Promise<number>,
): Promise<void> => {
const samples: number[] = [];
@@ -255,11 +256,6 @@ async function measure(
const t0 = performance.now();
rows = await f();
samples.push(performance.now() - t0);
- if (rows !== expectedRows) {
- throw Error(
- `benchmark query ${name} returned ${rows} rows, expected ${expectedRows}`,
- );
- }
}
results.push({
name,
@@ -272,7 +268,7 @@ async function measure(
// A point lookup on the primary key, the single most common operation.
const someCoin = key(`coin-${Math.floor(opts.numCoins / 2)}`);
- await time("getCoin (point lookup)", 1, async () =>
+ await time("getCoin (point lookup)", async () =>
runner.runReadWriteTx(async (tx) => ((await tx.getCoin(someCoin)) ? 1 : 0)),
);
@@ -282,124 +278,61 @@ async function measure(
for (let i = 0; i < Math.min(200, opts.numCoins); i++) {
pubs.push(key(`coin-${i}`));
}
- await time("getCoinsByPubs (200)", pubs.length, async () =>
+ await time("getCoinsByPubs (200)", async () =>
runner.runReadWriteTx(async (tx) => (await tx.getCoinsByPubs(pubs)).length),
);
- const denomRefs = Array.from(
- { length: Math.min(200, opts.numDenominations) },
- (_, d) => ({
- exchangeMasterPub: key(`master-${d % opts.numExchanges}`),
- denomPubHash: hash(`denom-${d}`),
- }),
- );
- await time("getDenominationsByRefs (200)", denomRefs.length, async () =>
- runner.runReadWriteTx(
- async (tx) => (await tx.getDenominationsByRefs(denomRefs)).length,
- ),
- );
-
- const availabilityRefs = denomRefs.map((ref, d) => ({
- ...ref,
- maxAge: d % 2 === 0 ? 0 : 21,
- }));
- await time(
- "getCoinAvailabilitiesByRefs (200)",
- availabilityRefs.length,
- async () =>
- runner.runReadWriteTx(
- async (tx) =>
- (await tx.getCoinAvailabilitiesByRefs(availabilityRefs)).length,
- ),
- );
-
- const countCoinsForExchange = (exchangeIndex: number): number => {
- let count = 0;
- for (let i = 0; i < opts.numCoins; i++) {
- if ((i % opts.numDenominations) % opts.numExchanges === exchangeIndex) {
- count++;
- }
- }
- return count;
- };
- const coinsAtExchangeZero = countCoinsForExchange(0);
-
- await time("getCoinsByExchange", coinsAtExchangeZero, async () =>
+ await time("getCoinsByExchange", async () =>
runner.runReadWriteTx(
async (tx) => (await tx.getCoinsByExchange(exchangeUrl(0))).length,
),
);
- await time("countCoinsByExchange", coinsAtExchangeZero, async () =>
+ await time("countCoinsByExchange", async () =>
runner.runReadWriteTx(async (tx) =>
tx.countCoinsByExchange(exchangeUrl(0)),
),
);
- const coinsForDenomZero =
- Math.floor((opts.numCoins - 1) / opts.numDenominations) + 1;
- await time("getCoinsByDenomPubHash", coinsForDenomZero, async () =>
+ await time("getCoinsByDenomPubHash", async () =>
runner.runReadWriteTx(
async (tx) => (await tx.getCoinsByDenomPubHash(hash("denom-0"))).length,
),
);
- const denomHashes = Array.from({ length: opts.numDenominations }, (_, d) =>
- hash(`denom-${d}`),
- );
- await time("getCoinsByDenomPubHashes", opts.numCoins, async () =>
+ // Indexed multi-column lookup with a limit -- coin selection's hot path.
+ await time("getFreshCoinsByDenomAndAge (limit 10)", async () =>
runner.runReadWriteTx(
- async (tx) => (await tx.getCoinsByDenomPubHashes(denomHashes)).length,
+ async (tx) =>
+ (
+ await tx.getFreshCoinsByDenomAndAge(
+ {
+ exchangeMasterPub: key("master-0"),
+ denomPubHash: hash("denom-0"),
+ maxAge: 0,
+ },
+ 10,
+ )
+ ).length,
),
);
- // Indexed multi-column lookup with a limit -- coin selection's hot path.
- let freshCoinsForDenomZero = 0;
- for (let i = 0; i < opts.numCoins; i += opts.numDenominations) {
- if (Math.floor(i / opts.numDenominations) % 4 !== 0) {
- freshCoinsForDenomZero++;
- }
- }
- await time(
- "getFreshCoinsByDenomAndAge (limit 10)",
- Math.min(10, freshCoinsForDenomZero),
- async () =>
- runner.runReadWriteTx(
- async (tx) =>
- (
- await tx.getFreshCoinsByDenomAndAge(
- {
- exchangeMasterPub: key("master-0"),
- denomPubHash: hash("denom-0"),
- maxAge: 0,
- },
- 10,
- )
- ).length,
- ),
- );
-
- const denomsAtExchangeZero =
- Math.floor((opts.numDenominations - 1) / opts.numExchanges) + 1;
- await time(
- "getCoinAvailabilityByExchangeAndAgeRange",
- denomsAtExchangeZero,
- async () =>
- runner.runReadWriteTx(
- async (tx) =>
- (
- await tx.getCoinAvailabilityByExchangeAndAgeRange(
- exchangeUrl(0),
- 0,
- 21,
- )
- ).length,
- ),
+ await time("getCoinAvailabilityByExchangeAndAgeRange", async () =>
+ runner.runReadWriteTx(
+ async (tx) =>
+ (
+ await tx.getCoinAvailabilityByExchangeAndAgeRange(
+ exchangeUrl(0),
+ 0,
+ 21,
+ )
+ ).length,
+ ),
);
// The early-terminating keyset scan. Deliberately matches nothing until
// late, so a backend that materialises the whole family shows up here.
- await time("findDenominationByFamilyFromExpiry", 1, async () =>
+ await time("findDenominationByFamilyFromExpiry", async () =>
runner.runReadWriteTx(async (tx) => {
const found = await tx.findDenominationByFamilyFromExpiry(
1,
@@ -410,7 +343,7 @@ async function measure(
}),
);
- await time("getDenominationsByMasterPub", denomsAtExchangeZero, async () =>
+ await time("getDenominationsByMasterPub", async () =>
runner.runReadWriteTx(
async (tx) =>
(await tx.getDenominationsByMasterPub(key("master-0"))).length,
@@ -418,25 +351,20 @@ async function measure(
);
// Full scans: the wallet does these on balance computation and purge.
- await time("listAllCoins (full scan)", opts.numCoins, async () =>
+ await time("listAllCoins (full scan)", async () =>
runner.runReadWriteTx(async (tx) => (await tx.listAllCoins()).length),
);
- await time(
- "getCoinAvailabilities (full scan)",
- opts.numDenominations,
- async () =>
- runner.runReadWriteTx(
- async (tx) => (await tx.getCoinAvailabilities()).length,
- ),
+ await time("getCoinAvailabilities (full scan)", async () =>
+ runner.runReadWriteTx(
+ async (tx) => (await tx.getCoinAvailabilities()).length,
+ ),
);
// A write-heavy transaction, to keep an eye on commit cost.
- const numUpserts = Math.min(100, opts.numCoins);
- await time("upsertCoin x100 (one tx)", numUpserts, async () =>
+ await time("upsertCoin x100 (one tx)", async () =>
runner.runReadWriteTx(async (tx) => {
- let updated = 0;
- for (let i = 0; i < numUpserts; i++) {
+ for (let i = 0; i < 100; i++) {
const coin = await tx.getCoin(key(`coin-${i}`));
if (coin) {
coin.status =
@@ -444,10 +372,9 @@ async function measure(
? CoinStatus.Dormant
: CoinStatus.Fresh;
await tx.upsertCoin(coin);
- updated++;
}
}
- return updated;
+ return 100;
}),
);
@@ -462,21 +389,9 @@ export async function benchmarkOneBackend(
opts: DbBenchOptions,
dbSizeBytes?: () => number | undefined,
): Promise<DbBenchResult> {
- if (
- !Number.isSafeInteger(opts.numCoins) ||
- opts.numCoins <= 0 ||
- !Number.isSafeInteger(opts.numDenominations) ||
- opts.numDenominations <= 0 ||
- !Number.isSafeInteger(opts.numExchanges) ||
- opts.numExchanges <= 0 ||
- !Number.isSafeInteger(opts.repeats) ||
- opts.repeats <= 0
- ) {
- throw Error("benchmark options must be positive safe integers");
- }
logger.info(`populating ${runner.name}: ${opts.numCoins} coins`);
const t0 = performance.now();
- await populate(runner, opts);
+ await populateDbBench(runner, opts);
const populateMs = performance.now() - t0;
logger.info(`populated in ${populateMs.toFixed(0)} ms, measuring`);
@@ -492,7 +407,7 @@ export async function benchmarkOneBackend(
`database holds ${actualCoins}. Refusing to report timings.`,
);
}
- const queries = await measure(runner, opts);
+ const queries = await measureDbBenchQueries(runner, opts);
return {
backend: runner.name,
options: opts,
diff --git a/packages/taler-wallet-core/src/dbtx-browser-wasm.test.ts b/packages/taler-wallet-core/src/dbtx-browser-wasm.test.ts
@@ -0,0 +1,60 @@
+/*
+ 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.
+*/
+
+import {
+ createBrowserSqlite3Impl,
+ initOfficialSqliteWasm,
+} from "@gnu-taler/idb-bridge/browser-sqlite3-impl";
+import assert from "node:assert";
+import { test } from "node:test";
+import { conformanceCases } from "./db/testing/conformance-cases.js";
+import { ConformanceAsserts } from "./db/testing/conformance.js";
+import { SqliteWalletDbHandle } from "./db/sqlite/handle.js";
+import { openNativeSqliteWalletDb } from "./db/sqlite/database.js";
+
+const sqlite3Module = initOfficialSqliteWasm();
+
+const asserts: ConformanceAsserts = {
+ equal: (actual, expected, message) =>
+ assert.strictEqual(actual, expected, message),
+ deepEqual: (actual, expected, message) =>
+ assert.deepStrictEqual(actual, expected, message),
+ ok: (value, message) => assert.ok(value, message),
+ fail: (message) => assert.fail(message),
+};
+
+function isNotImplemented(error: unknown): boolean {
+ return (
+ error instanceof Error &&
+ (/is not implemented yet/.test(error.message) ||
+ /tx\.\w+ is not a function/.test(error.message))
+ );
+}
+
+for (const conformanceCase of conformanceCases) {
+ test(`browser WASM dbtx conformance: ${conformanceCase.name}`, async (t) => {
+ const sqlite3 = await sqlite3Module;
+ const sqlite3Impl = createBrowserSqlite3Impl(sqlite3);
+ const nativeDb = await openNativeSqliteWalletDb(
+ await sqlite3Impl.open(":memory:"),
+ );
+ const runner = new SqliteWalletDbHandle(nativeDb);
+ try {
+ await conformanceCase.run(asserts, runner);
+ } catch (error) {
+ if (isNotImplemented(error)) {
+ t.skip(`not implemented in ${runner.name}`);
+ return;
+ }
+ throw error;
+ } finally {
+ await runner.close();
+ }
+ });
+}
diff --git a/packages/taler-wallet-core/src/index.node.ts b/packages/taler-wallet-core/src/index.node.ts
@@ -22,16 +22,10 @@ export { SynchronousCryptoWorkerPlain } from "./crypto/workers/synchronousWorker
export type { AccessStats } from "@gnu-taler/idb-bridge";
export * from "./crypto/workers/synchronousWorkerFactoryPlain.js";
-// Storage-layer benchmark. Node-only: the runners spawn the sqlite helper
-// process, so this must not reach the browser entry point.
-export * from "./db/testing/benchmark.js";
+// The benchmark itself is browser-safe and exported by index.ts. These runner
+// factories remain Node-only because they spawn the sqlite helper process.
export { makeIdbRunner, makeSqliteRunner } from "./db/testing/runners.js";
-// The record-by-record copy between backends, which the in-place migration
-// runs. Node-only: the runners spawn the sqlite helper process.
-export { convertWalletDb } from "./db/migration/converter.js";
-export type { DbConversionReport } from "./db/migration/converter.js";
-
// In-place migration to the native schema. Inspecting and rolling one back
// works on a file, so these are node-only too; performing the migration is
// not here at all, because it happens when the wallet is initialized.
diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts
@@ -41,10 +41,29 @@ export { createPairTimeline } from "./denominations.js";
export { WithdrawalGroupStatus } from "./db/records.js";
export { deleteTalerDatabase } from "./db/indexeddb/database.js";
export { exportDb, importDb } from "./db/indexeddb/dump.js";
-export { WalletIndexedDbStoresV1 as WalletStoresV1 } from "./db/indexeddb/schema.js";
+export {
+ TALER_WALLET_MAIN_DB_NAME,
+ TALER_WALLET_META_DB_NAME,
+ WalletIndexedDbStoresV1 as WalletStoresV1,
+} from "./db/indexeddb/schema.js";
-export { DbAccess } from "./db/query.js";
-export { WalletDbHandle } from "./db/handle.js";
+export type { DbAccess } from "./db/query.js";
+export type { WalletDbHandle } from "./db/handle.js";
export { IdbWalletDbHandle } from "./db/indexeddb/handle.js";
+export { SqliteWalletDbHandle } from "./db/sqlite/handle.js";
+export { openNativeSqliteWalletDb } from "./db/sqlite/database.js";
+export { convertWalletDb } from "./db/migration/converter.js";
+export {
+ benchmarkOneBackend,
+ defaultDbBenchOptions,
+ formatDbBenchResults,
+ measureDbBenchQueries,
+ populateDbBench,
+} from "./db/testing/benchmark.js";
+export type {
+ DbBenchOptions,
+ DbBenchQueryResult,
+ DbBenchResult,
+} from "./db/testing/benchmark.js";
export { TaskRunResult, TaskRunResultType } from "./common.js";