taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/db/testing/benchmark.ts | 215++++++++++++++++++++++++-------------------------------------------------------
Apackages/taler-wallet-core/src/dbtx-browser-wasm.test.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/index.node.ts | 10++--------
Mpackages/taler-wallet-core/src/index.ts | 25++++++++++++++++++++++---
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";