commit 736778f85efd2cbe3208d24d486e4d9da69cac61
parent fd23369b92847616ca45ecff4446df2079f63001
Author: Florian Dold <dold@taler.net>
Date: Sun, 19 Jul 2026 02:52:19 +0200
db: add a WalletDbTransaction conformance suite
Cases run against any backend via a DbTxRunner.
Diffstat:
3 files changed, 674 insertions(+), 0 deletions(-)
diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts
@@ -0,0 +1,488 @@
+/*
+ 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/>
+ */
+
+/**
+ * Conformance cases for {@link WalletDbTransaction}.
+ *
+ * Each case states a property of the interface that any backend must satisfy.
+ * Where a case exists because a real bug was shipped, the case says so: those
+ * are the ones most worth keeping honest, because each of them passed both
+ * tsc and the unit tests at the time.
+ */
+
+import {
+ AmountString,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+import {
+ ConfigRecordKey,
+ DbPreciseTimestamp,
+ DbProtocolTimestamp,
+ DenominationVerificationStatus,
+ RefundGroupStatus,
+ RefundItemStatus,
+ timestampPreciseToDb,
+ timestampProtocolToDb,
+ WalletDenomination,
+ WalletRefundItem,
+} from "./db-common.js";
+import { ConformanceCase } from "./dbtx-conformance.js";
+
+const ts = (seconds: number): DbProtocolTimestamp =>
+ timestampProtocolToDb(TalerProtocolTimestamp.fromSeconds(seconds));
+
+const tsPrecise = (seconds: number): DbPreciseTimestamp =>
+ timestampPreciseToDb(TalerPreciseTimestamp.fromSeconds(seconds));
+
+const amt = (s: string): AmountString => s as AmountString;
+
+function makeDenomination(
+ exchangeBaseUrl: string,
+ denomPubHash: string,
+ opts: {
+ familySerial?: number;
+ stampExpireWithdraw?: number;
+ isOffered?: boolean;
+ } = {},
+): WalletDenomination {
+ return {
+ exchangeBaseUrl,
+ denomPubHash,
+ denomPub: { cipher: 1, rsa_public_key: "dummy" } as any,
+ exchangeMasterPub: "master-pub",
+ currency: "TESTKUDOS",
+ value: amt("TESTKUDOS:1"),
+ denominationFamilySerial: opts.familySerial,
+ stampStart: ts(1000),
+ stampExpireWithdraw: ts(opts.stampExpireWithdraw ?? 100000),
+ stampExpireDeposit: ts(200000),
+ stampExpireLegal: ts(300000),
+ fees: {
+ feeDeposit: amt("TESTKUDOS:0.1"),
+ feeRefresh: amt("TESTKUDOS:0.1"),
+ feeRefund: amt("TESTKUDOS:0.1"),
+ feeWithdraw: amt("TESTKUDOS:0.1"),
+ },
+ isOffered: opts.isOffered ?? true,
+ isRevoked: false,
+ isLost: false,
+ masterSig: "master-sig",
+ verificationStatus: DenominationVerificationStatus.VerifiedGood,
+ listIssueDate: ts(1000),
+ } as WalletDenomination;
+}
+
+function makeRefundItem(
+ refundGroupId: string,
+ coinPub: string,
+ rtxid: number,
+): WalletRefundItem {
+ return {
+ status: RefundItemStatus.Done,
+ refundGroupId,
+ executionTime: ts(5000),
+ obtainedTime: tsPrecise(5000),
+ refundAmount: amt("TESTKUDOS:1"),
+ coinPub,
+ rtxid,
+ };
+}
+
+export const conformanceCases: ConformanceCase[] = [
+ // ---------------------------------------------------------------- basics
+
+ {
+ name: "config: upsert then get round trips",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ await tx.upsertConfig({
+ key: ConfigRecordKey.MaterializedTransactionsVersion,
+ value: 7,
+ });
+ });
+ const got = await runner.runTx((tx) =>
+ tx.getConfig(ConfigRecordKey.MaterializedTransactionsVersion),
+ );
+ t.ok(got, "config record should exist");
+ t.equal(got?.value, 7);
+ },
+ },
+
+ {
+ name: "get on a missing key returns undefined, not a throw",
+ async run(t, runner) {
+ const got = await runner.runTx((tx) => tx.getCoin("no-such-coin-pub"));
+ t.equal(got, undefined);
+ },
+ },
+
+ {
+ name: "contract terms: arbitrary JSON survives a round trip",
+ async run(t, runner) {
+ const raw = {
+ nested: { a: [1, 2, 3], b: null },
+ unicode: "ünïcödé",
+ num: 1.5,
+ };
+ await runner.runTx((tx) =>
+ tx.upsertContractTerms({ h: "hash-1", contractTermsRaw: raw }),
+ );
+ const got = await runner.runTx((tx) => tx.getContractTerms("hash-1"));
+ t.deepEqual(got?.contractTermsRaw, raw);
+ },
+ },
+
+ // ------------------------------------------------- compound / array keys
+
+ {
+ name: "denomination: compound primary key (exchange, denomPubHash)",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ await tx.upsertDenomination(makeDenomination("https://e1/", "dph-a"));
+ await tx.upsertDenomination(makeDenomination("https://e2/", "dph-a"));
+ });
+ const [d1, d2] = await runner.runTx(async (tx) => [
+ await tx.getDenomination("https://e1/", "dph-a"),
+ await tx.getDenomination("https://e2/", "dph-a"),
+ ]);
+ t.ok(d1, "same hash under a different exchange must be a distinct row");
+ t.ok(d2);
+ t.equal(d1?.exchangeBaseUrl, "https://e1/");
+ t.equal(d2?.exchangeBaseUrl, "https://e2/");
+ },
+ },
+
+ {
+ name: "refund items by group are found (regression: array keyPath)",
+ // The IndexedDB index byRefundGroupId is declared with an ARRAY keyPath,
+ // so its keys are single-element arrays. Passing a bare string matched
+ // nothing, getRefundItemsByGroup silently returned [], and refunds ended
+ // in state "done" instead of "failed". It passed tsc and every unit test.
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ await tx.upsertRefundItem(makeRefundItem("grp-1", "coin-1", 1));
+ await tx.upsertRefundItem(makeRefundItem("grp-1", "coin-2", 2));
+ await tx.upsertRefundItem(makeRefundItem("grp-2", "coin-3", 3));
+ });
+ const items = await runner.runTx((tx) =>
+ tx.getRefundItemsByGroup("grp-1"),
+ );
+ t.equal(items.length, 2, "must find both items of the group");
+ const other = await runner.runTx((tx) =>
+ tx.getRefundItemsByGroup("grp-2"),
+ );
+ t.equal(other.length, 1);
+ const none = await runner.runTx((tx) =>
+ tx.getRefundItemsByGroup("grp-missing"),
+ );
+ t.equal(none.length, 0);
+ },
+ },
+
+ {
+ name: "refund item lookup by (coinPub, rtxid)",
+ async run(t, runner) {
+ await runner.runTx((tx) =>
+ tx.upsertRefundItem(makeRefundItem("grp-3", "coin-9", 42)),
+ );
+ const got = await runner.runTx((tx) =>
+ tx.getRefundItemByCoinAndRtxid("coin-9", 42),
+ );
+ t.ok(got, "compound index lookup must find the item");
+ t.equal(got?.refundGroupId, "grp-3");
+ const miss = await runner.runTx((tx) =>
+ tx.getRefundItemByCoinAndRtxid("coin-9", 43),
+ );
+ t.equal(miss, undefined, "wrong rtxid must not match");
+ },
+ },
+
+ // --------------------------------------------------- generated row ids
+
+ {
+ name: "auto-increment stores return a usable generated id",
+ // upsertReserve/upsertRefundItem/upsertExchangeDetails/
+ // upsertDenominationFamily must return the generated key: callers store it
+ // as a foreign key. A backend returning 0 or undefined would corrupt the
+ // exchange entry silently.
+ async run(t, runner) {
+ const id1 = await runner.runTx((tx) =>
+ tx.upsertReserve({ reservePub: "rp-1", reservePriv: "rv-1" } as any),
+ );
+ const id2 = await runner.runTx((tx) =>
+ tx.upsertReserve({ reservePub: "rp-2", reservePriv: "rv-2" } as any),
+ );
+ t.equal(typeof id1, "number");
+ t.equal(typeof id2, "number");
+ t.ok(id1 !== id2, "generated ids must be distinct");
+ const back = await runner.runTx((tx) => tx.getReserve(id1));
+ t.equal(back?.reservePub, "rp-1", "the returned id must address the row");
+ },
+ },
+
+ // ------------------------------------------------------- ordered scans
+
+ {
+ name: "findDenominationByFamilyFromExpiry returns the first match",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ // Same family, ascending expiry. The first two are not offered, so
+ // the predicate must skip them.
+ await tx.upsertDenomination(
+ makeDenomination("https://e/", "d-1", {
+ familySerial: 77,
+ stampExpireWithdraw: 10000,
+ isOffered: false,
+ }),
+ );
+ await tx.upsertDenomination(
+ makeDenomination("https://e/", "d-2", {
+ familySerial: 77,
+ stampExpireWithdraw: 20000,
+ isOffered: false,
+ }),
+ );
+ await tx.upsertDenomination(
+ makeDenomination("https://e/", "d-3", {
+ familySerial: 77,
+ stampExpireWithdraw: 30000,
+ }),
+ );
+ });
+ const found = await runner.runTx((tx) =>
+ tx.findDenominationByFamilyFromExpiry(77, ts(0), (d) => d.isOffered),
+ );
+ t.equal(
+ found?.denomPubHash,
+ "d-3",
+ "must return the first match in order",
+ );
+ },
+ },
+
+ {
+ name: "findDenominationByFamilyFromExpiry stops at the first match",
+ // Regression: this was briefly implemented as "fetch every non-expired
+ // denomination of the family, then filter". Families hold many
+ // denominations, so that turned a one-record read into a full range scan.
+ // The predicate is the only place we can observe how far the scan ran.
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ for (let i = 0; i < 20; i++) {
+ await tx.upsertDenomination(
+ makeDenomination("https://e/", `s-${i}`, {
+ familySerial: 88,
+ stampExpireWithdraw: 10000 + i * 1000,
+ }),
+ );
+ }
+ });
+ let examined = 0;
+ const before = runner.recordsRead?.();
+ const found = await runner.runTx((tx) =>
+ tx.findDenominationByFamilyFromExpiry(88, ts(0), () => {
+ examined++;
+ return true;
+ }),
+ );
+ const after = runner.recordsRead?.();
+ t.ok(found, "should find a denomination");
+ t.equal(examined, 1, "the predicate must be consulted once");
+ if (before !== undefined && after !== undefined) {
+ // The real regression was reading the whole family and filtering in
+ // JS: the predicate still ran once, so only the record count exposes
+ // it. Allow a small constant for cursor positioning.
+ t.ok(
+ after - before <= 3,
+ `must not scan the family: read ${after - before} records for one match`,
+ );
+ }
+ },
+ },
+
+ {
+ name: "findDenominationByFamilyFromExpiry respects the family boundary",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ await tx.upsertDenomination(
+ makeDenomination("https://e/", "f1-a", {
+ familySerial: 101,
+ stampExpireWithdraw: 10000,
+ isOffered: false,
+ }),
+ );
+ await tx.upsertDenomination(
+ makeDenomination("https://e/", "f2-a", {
+ familySerial: 102,
+ stampExpireWithdraw: 20000,
+ }),
+ );
+ });
+ const found = await runner.runTx((tx) =>
+ tx.findDenominationByFamilyFromExpiry(101, ts(0), (d) => d.isOffered),
+ );
+ t.equal(
+ found,
+ undefined,
+ "must not spill into the next family when no match is found",
+ );
+ },
+ },
+
+ {
+ name: "findDenominationByFamilyFromExpiry honours the expiry lower bound",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ await tx.upsertDenomination(
+ makeDenomination("https://e/", "old", {
+ familySerial: 111,
+ stampExpireWithdraw: 5000,
+ }),
+ );
+ await tx.upsertDenomination(
+ makeDenomination("https://e/", "new", {
+ familySerial: 111,
+ stampExpireWithdraw: 50000,
+ }),
+ );
+ });
+ const found = await runner.runTx((tx) =>
+ tx.findDenominationByFamilyFromExpiry(111, ts(10000), () => true),
+ );
+ t.equal(found?.denomPubHash, "new", "must skip records before the bound");
+ },
+ },
+
+ // ------------------------------------------------- transaction lifecycle
+
+ {
+ name: "a throwing transaction rolls back its writes",
+ async run(t, runner) {
+ try {
+ await runner.runTx(async (tx) => {
+ await tx.upsertContractTerms({
+ h: "rollback-hash",
+ contractTermsRaw: { x: 1 },
+ });
+ throw Error("deliberate abort");
+ });
+ t.fail("the transaction should have rethrown");
+ } catch (e) {
+ // expected
+ }
+ const got = await runner.runTx((tx) =>
+ tx.getContractTerms("rollback-hash"),
+ );
+ t.equal(got, undefined, "writes before the throw must not be visible");
+ },
+ },
+
+ {
+ name: "writes are visible within the same transaction",
+ async run(t, runner) {
+ const got = await runner.runTx(async (tx) => {
+ await tx.upsertContractTerms({
+ h: "same-tx",
+ contractTermsRaw: { y: 2 },
+ });
+ return await tx.getContractTerms("same-tx");
+ });
+ t.deepEqual(got?.contractTermsRaw, { y: 2 });
+ },
+ },
+
+ {
+ name: "scheduleOnCommit runs after the transaction, not during it",
+ async run(t, runner) {
+ const order: string[] = [];
+ await runner.runTx(async (tx) => {
+ tx.scheduleOnCommit(() => order.push("after-commit"));
+ order.push("in-tx");
+ });
+ t.deepEqual(order, ["in-tx", "after-commit"]);
+ },
+ },
+
+ {
+ name: "scheduleOnCommit does not run when the transaction aborts",
+ async run(t, runner) {
+ let ran = false;
+ try {
+ await runner.runTx(async (tx) => {
+ tx.scheduleOnCommit(() => {
+ ran = true;
+ });
+ throw Error("deliberate abort");
+ });
+ } catch (e) {
+ // expected
+ }
+ t.equal(ran, false, "commit hooks must not fire on a rolled-back tx");
+ },
+ },
+
+ {
+ name: "notify is safe to pass around unbound",
+ // Regression: notify was a prototype method reading this.tx. Call sites
+ // pass it as a bare function (applyNotifyTransition(tx.notify, ...)), which
+ // is well-typed but lost `this` and broke every transaction that used it.
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ const notify = tx.notify;
+ notify({ type: "balance-change" } as any);
+ });
+ t.ok(true, "extracting notify and calling it must not throw");
+ },
+ },
+
+ // ------------------------------------------------------- delete semantics
+
+ {
+ name: "deleting a missing row is a no-op, not an error",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ await tx.deletePurchase("no-such-proposal");
+ await tx.deleteDenomination("https://nope/", "no-such-hash");
+ await tx.deleteRefundGroup("no-such-group");
+ });
+ t.ok(true, "deleting a missing row must not throw");
+ },
+ },
+
+ {
+ name: "upsert overwrites an existing row rather than duplicating it",
+ async run(t, runner) {
+ await runner.runTx(async (tx) => {
+ await tx.upsertDenomination(
+ makeDenomination("https://e-up/", "dup", {
+ stampExpireWithdraw: 111,
+ }),
+ );
+ await tx.upsertDenomination(
+ makeDenomination("https://e-up/", "dup", {
+ stampExpireWithdraw: 222,
+ }),
+ );
+ });
+ const all = await runner.runTx((tx) =>
+ tx.getDenominationsByExchange("https://e-up/"),
+ );
+ t.equal(all.length, 1, "the second upsert must replace, not append");
+ t.equal(all[0].stampExpireWithdraw, ts(222));
+ },
+ },
+];
diff --git a/packages/taler-wallet-core/src/dbtx-conformance.ts b/packages/taler-wallet-core/src/dbtx-conformance.ts
@@ -0,0 +1,80 @@
+/*
+ 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/>
+ */
+
+/**
+ * Conformance suite for {@link WalletDbTransaction}.
+ *
+ * These tests describe the contract of the DAL, not the behaviour of any one
+ * backend. They are written against the interface and parameterised over a
+ * {@link DbTxRunner}, so the same suite runs against IdbWalletTransaction
+ * today and against a native sqlite3 implementation later.
+ *
+ * Anything backend-specific (how a transaction is opened, how the store is
+ * created) belongs in the runner, never in a test.
+ */
+
+import { WalletDbTransaction } from "./dbtx.js";
+
+/**
+ * Runs a callback inside one database transaction.
+ *
+ * An implementation under test supplies this; the suite never opens a
+ * database itself.
+ */
+export interface DbTxRunner {
+ /** Human-readable name of the implementation, used in test names. */
+ name: string;
+
+ /** Run f in a read-write transaction and return its result. */
+ runTx<T>(f: (tx: WalletDbTransaction) => Promise<T>): Promise<T>;
+
+ /**
+ * Total number of records the backend has read so far, if it can report it.
+ *
+ * Cases use this to assert that a query is bounded rather than scanning:
+ * counting callbacks is not enough, since a backend can materialise a whole
+ * range and still invoke a predicate only once. A runner that cannot report
+ * this returns undefined and the relevant cases skip the assertion.
+ */
+ recordsRead?(): number | undefined;
+
+ /** Release any resources. */
+ close(): Promise<void>;
+}
+
+/**
+ * A single conformance check.
+ *
+ * Kept as plain data so the suite can be enumerated, filtered and reported on
+ * per implementation, rather than being hard-wired into one test runner.
+ */
+export interface ConformanceCase {
+ name: string;
+ run(t: ConformanceAsserts, runner: DbTxRunner): Promise<void>;
+}
+
+/**
+ * The assertions a case may use.
+ *
+ * Deliberately minimal and framework-agnostic so the suite does not depend on
+ * node:test, and can be driven from a harness or a browser if needed.
+ */
+export interface ConformanceAsserts {
+ equal(actual: unknown, expected: unknown, msg?: string): void;
+ deepEqual(actual: unknown, expected: unknown, msg?: string): void;
+ ok(value: unknown, msg?: string): void;
+ fail(msg: string): never;
+}
diff --git a/packages/taler-wallet-core/src/dbtx.test.ts b/packages/taler-wallet-core/src/dbtx.test.ts
@@ -0,0 +1,106 @@
+/*
+ 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/>
+ */
+
+/**
+ * Runs the WalletDbTransaction conformance suite against every available
+ * implementation.
+ *
+ * Adding the sqlite3 implementation means adding one runner here; the cases
+ * themselves do not change.
+ */
+
+import { BridgeIDBFactory, createSqliteBackend } from "@gnu-taler/idb-bridge";
+import { CancellationToken } from "@gnu-taler/taler-util";
+import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl";
+import assert from "node:assert";
+import { test } from "node:test";
+
+import { openTalerDatabase, WalletIndexedDbStoresV1 } from "./db-indexeddb.js";
+import { DbAccessImpl } from "./query.js";
+import { conformanceCases } from "./dbtx-conformance-cases.js";
+import { ConformanceAsserts, DbTxRunner } from "./dbtx-conformance.js";
+import { IdbWalletTransaction } from "./dbtx-indexeddb.js";
+
+const asserts: ConformanceAsserts = {
+ equal: (a, e, m) => assert.strictEqual(a, e, m),
+ deepEqual: (a, e, m) => assert.deepStrictEqual(a, e, m),
+ ok: (v, m) => assert.ok(v, m),
+ fail: (m) => assert.fail(m),
+};
+
+/**
+ * Runner for the IndexedDB implementation, on an in-memory sqlite-backed
+ * BridgeIDB. Each runner gets a fresh database so cases cannot leak into
+ * each other.
+ */
+async function makeIdbRunner(): Promise<DbTxRunner> {
+ const sqlite3Impl = await createNodeHelperSqlite3Impl({
+ enableTracing: false,
+ });
+ const backend = await createSqliteBackend(sqlite3Impl, {
+ filename: ":memory:",
+ });
+ backend.trackStats = true;
+ BridgeIDBFactory.enableTracing = false;
+ const idbFactory = new BridgeIDBFactory(backend);
+ const dbHandle = await openTalerDatabase(idbFactory as any, () => {});
+ const db = new DbAccessImpl(
+ dbHandle,
+ WalletIndexedDbStoresV1,
+ {},
+ CancellationToken.CONTINUE,
+ );
+ return {
+ name: "IdbWalletTransaction",
+ async runTx<T>(f: (tx: any) => Promise<T>): Promise<T> {
+ return await db.runAllStoresReadWriteTx({}, async (mytx) => {
+ return await f(new IdbWalletTransaction(mytx));
+ });
+ },
+ recordsRead() {
+ const st = backend.accessStats;
+ let n = 0;
+ for (const k of Object.keys(st.readItemsPerIndex)) {
+ n += st.readItemsPerIndex[k];
+ }
+ for (const k of Object.keys(st.readItemsPerStore)) {
+ n += st.readItemsPerStore[k];
+ }
+ return n;
+ },
+ async close() {
+ dbHandle.close();
+ },
+ };
+}
+
+const runnerFactories: Array<() => Promise<DbTxRunner>> = [
+ makeIdbRunner,
+ // A native sqlite3 implementation registers itself here.
+];
+
+for (const makeRunner of runnerFactories) {
+ for (const c of conformanceCases) {
+ test(`dbtx conformance: ${c.name}`, async () => {
+ const runner = await makeRunner();
+ try {
+ await c.run(asserts, runner);
+ } finally {
+ await runner.close();
+ }
+ });
+ }
+}