taler-typescript-core

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

commit 086341c341a466b12909ed2f55a3f5bd7db0c761
parent f96966800d0a788f1640111a12bb2baf6f779e1e
Author: Florian Dold <dold@taler.net>
Date:   Mon, 20 Jul 2026 02:03:50 +0200

db: add enumeration accessors for every store

Some stores could only be read by key or by parent.  Every store is now
reachable through a listAll* accessor, for the coming converter.

Diffstat:
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-indexeddb.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-sqlite.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 429 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 @@ -35,8 +35,10 @@ import { TransactionIdStr, TalerProtocolTimestamp, } from "@gnu-taler/taler-util"; +import { MailboxConfiguration } from "@gnu-taler/taler-util"; import { ConfigRecordKey, + DonationReceiptStatus, DbPreciseTimestamp, DbProtocolTimestamp, DenominationVerificationStatus, @@ -1468,6 +1470,182 @@ export const conformanceCases: ConformanceCase[] = [ }, { + // The database converter enumerates every store through these accessors, + // so a listAll* that misses records means rows silently absent from the + // converted database. One case per accessor would repeat the seeding; + // this seeds one record per store and checks each enumeration sees it. + name: "listAll accessors enumerate every store", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 7, + }); + await tx.upsertCurrencyInfoEntry({ + scopeInfoStr: "taler-si:global/TESTKUDOS", + currencySpec: { + name: "Test", + num_fractional_input_digits: 2, + num_fractional_normal_digits: 2, + num_fractional_trailing_zero_digits: 2, + alt_unit_names: { "0": "TESTKUDOS" }, + }, + source: "exchange", + }); + await tx.upsertContractTerms({ + h: ckh("ct-la"), + contractTermsRaw: { summary: "listall" }, + }); + await tx.upsertTombstone({ id: "tmb:test:la" }); + await tx.upsertOperationRetry({ + id: "task-la", + retryInfo: { + firstTry: tsPrecise(1), + nextRetry: tsPrecise(2), + retryCounter: 1, + }, + }); + await tx.upsertReserve(makeReserve("res-la")); + await tx.upsertMailboxConfiguration({ + mailboxBaseUrl: "https://mb.example/", + } as MailboxConfiguration); + await tx.upsertExchangeBaseUrlFixup({ + exchangeBaseUrl: "https://old.example/", + replacement: "https://new.example/", + }); + await tx.upsertExchangeMigrationLog({ + oldExchangeBaseUrl: "https://old.example/", + newExchangeBaseUrl: "https://new.example/", + timestamp: tsPrecise(3), + reason: "auto" as ExchangeMigrationReason, + }); + await tx.upsertSlate(makeSlate("slate-la", "prop-la", 0, 0, 0)); + await tx.upsertRecoupGroup(makeRecoupGroup("rec-la", "https://e1/")); + await tx.upsertDonationPlanchet({ + donauBaseUrl: "https://donau.example/", + udiNonce: ckh("udi-la"), + donorTaxIdHash: ckh("tid-la"), + donorHashSalt: "salt-la", + donorTaxId: "tax-la", + donationYear: 2026, + proposalId: "prop-la", + udiIndex: 0, + blindedUdi: { cipher: "RSA", rsa_blinded_identifier: "blind" }, + bks: ck("bks-la"), + donationUnitPubHash: ckh("dup-la"), + value: amt("TESTKUDOS:1"), + }); + await tx.upsertDonationReceipt({ + status: DonationReceiptStatus.DoneSubmitted, + donauBaseUrl: "https://donau.example/", + udiNonce: ckh("udi-la"), + proposalId: "prop-la", + donationYear: 2026, + donationUnitPubHash: ckh("dup-la"), + donationUnitSig: { cipher: "RSA", rsa_signature: "sig" }, + donorTaxIdHash: ckh("tid-la"), + donorHashSalt: "salt-la", + donorTaxId: "tax-la", + value: amt("TESTKUDOS:1"), + udiIndex: 0, + }); + await seedDenomFamily(tx, "https://e1/", 61); + await tx.upsertDenomination( + makeDenomination("https://e1/", "den-la", { familySerial: 61 }), + ); + }); + + // Each enumeration must contain the seeded record; where the store was + // empty before, the length pins that nothing else appeared. + const r = runner; + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllConfig())).length, + 1, + "config", + ); + const ci = await r.runReadWriteTx((tx) => tx.listAllCurrencyInfo()); + t.equal(ci.length, 1, "currencyInfo"); + t.equal( + ci[0].scopeInfoStr, + "taler-si:global/TESTKUDOS", + "the storage key must round-trip opaquely", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllContractTerms())).length, + 1, + "contractTerms", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllTombstones()))[0]?.id, + "tmb:test:la", + "tombstones", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllOperationRetries()))[0]?.id, + "task-la", + "operationRetries", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllReserves())).length, + 1, + "reserves", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllMailboxConfigurations())) + .length, + 1, + "mailboxConfigurations", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllExchangeBaseUrlFixups()))[0] + ?.replacement, + "https://new.example/", + "exchangeBaseUrlFixups", + ); + t.equal( + ( + await r.runReadWriteTx((tx) => + tx.listAllExchangeMigrationLogEntries(), + ) + ).length, + 1, + "exchangeBaseUrlMigrationLog", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllSlates())).length, + 1, + "slates", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllRecoupGroups())).length, + 1, + "recoupGroups", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllDonationPlanchets())).length, + 1, + "donationPlanchets", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllDonationReceipts())).length, + 1, + "donationReceipts", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllDenominationFamilies())) + .length, + 1, + "denominationFamilies", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllDenominations())).length, + 1, + "denominations", + ); + }, + }, + + { // Parent-delete behaviour has to be identical on both backends. sqlite // declares ON DELETE CASCADE, IndexedDB has no constraints and must do it // by hand; without a case here the two silently disagree, and the sqlite diff --git a/packages/taler-wallet-core/src/dbtx-indexeddb.ts b/packages/taler-wallet-core/src/dbtx-indexeddb.ts @@ -89,6 +89,7 @@ import { import type {} from "./db-indexeddb.js"; import { WalletIndexedDbTransaction } from "./db-indexeddb.js"; import type { + WalletCurrencyInfoEntry, WalletDbRecordCounts, GetCurrencyInfoDbResult, StoreCurrencyInfoDbRequest, @@ -147,6 +148,18 @@ export class IdbWalletTransaction implements WalletDbTransaction { await tx.config.put(record); } + async listAllConfig(): Promise<ConfigRecord[]> { + return await this.tx.config.iter().toArray(); + } + + async listAllCurrencyInfo(): Promise<WalletCurrencyInfoEntry[]> { + return await this.tx.currencyInfo.iter().toArray(); + } + + async upsertCurrencyInfoEntry(entry: WalletCurrencyInfoEntry): Promise<void> { + await this.tx.currencyInfo.put(entry); + } + async upsertCurrencyInfo(req: StoreCurrencyInfoDbRequest): Promise<void> { const tx = this.tx; await tx.currencyInfo.put({ @@ -220,6 +233,10 @@ export class IdbWalletTransaction implements WalletDbTransaction { return await tx.mailboxMessages.getAll(); } + async listAllMailboxConfigurations(): Promise<MailboxConfiguration[]> { + return await this.tx.mailboxConfigurations.iter().toArray(); + } + async getMailboxConfiguration( mailboxBaseUrl: string, ): Promise<MailboxConfiguration | undefined> { @@ -341,6 +358,9 @@ export class IdbWalletTransaction implements WalletDbTransaction { retryInfo: rec.retryInfo, }); } + async listAllOperationRetries(): Promise<WalletOperationRetry[]> { + return await this.tx.operationRetries.iter().toArray(); + } async deleteOperationRetry(taskId: string): Promise<void> { const tx = this.tx; @@ -684,6 +704,16 @@ export class IdbWalletTransaction implements WalletDbTransaction { await tx.exchangeBaseUrlFixups.put(rec); } + async listAllExchangeBaseUrlFixups(): Promise<WalletExchangeBaseUrlFixup[]> { + return await this.tx.exchangeBaseUrlFixups.iter().toArray(); + } + + async listAllExchangeMigrationLogEntries(): Promise< + WalletExchangeMigrationLog[] + > { + return await this.tx.exchangeBaseUrlMigrationLog.iter().toArray(); + } + async getExchangeMigrationLog( oldExchangeBaseUrl: string, newExchangeBaseUrl: string, @@ -965,11 +995,43 @@ export class IdbWalletTransaction implements WalletDbTransaction { await tx.slates.delete(tokenUsePub); } + async listAllSlates(): Promise<WalletSlate[]> { + return await this.tx.slates.iter().toArray(); + } + + async listAllRecoupGroups(): Promise<WalletRecoupGroup[]> { + return await this.tx.recoupGroups.iter().toArray(); + } + + async listAllDonationPlanchets(): Promise<WalletDonationPlanchet[]> { + return await this.tx.donationPlanchets.iter().toArray(); + } + + async listAllDonationReceipts(): Promise<WalletDonationReceipt[]> { + return await this.tx.donationReceipts.iter().toArray(); + } + + async listAllDenominationFamilies(): Promise<WalletDenominationFamily[]> { + return await this.tx.denominationFamilies.iter().toArray(); + } + + async listAllDenominations(): Promise<WalletDenomination[]> { + return await this.tx.denominations.iter().toArray(); + } + + async listAllContractTerms(): Promise<WalletContractTerms[]> { + return await this.tx.contractTerms.iter().toArray(); + } + async upsertTombstone(rec: WalletTombstone): Promise<void> { const tx = this.tx; await tx.tombstones.put(rec); } + async listAllTombstones(): Promise<WalletTombstone[]> { + return await this.tx.tombstones.iter().toArray(); + } + async getDonationSummary( donauBaseUrl: string, year: number, @@ -1208,6 +1270,10 @@ export class IdbWalletTransaction implements WalletDbTransaction { return await tx.reserves.indexes.byReservePub.get(reservePub); } + async listAllReserves(): Promise<WalletReserve[]> { + return await this.tx.reserves.iter().toArray(); + } + async upsertReserve(rec: WalletReserve): Promise<number> { const tx = this.tx; const res = await tx.reserves.put(rec); diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts @@ -57,6 +57,7 @@ import { StoreCurrencyInfoDbRequest, WalletDbRecordCounts, WalletDbTransaction, + WalletCurrencyInfoEntry, } from "./dbtx.js"; import { ConfigRecord, @@ -504,6 +505,35 @@ export class SqliteWalletTransaction implements WalletDbTransaction { return dbToJson(row.value); } + async listAllConfig(): Promise<ConfigRecord[]> { + const rows = await this.all("SELECT value FROM config"); + return rows.map((r) => dbToJson<ConfigRecord>(r.value)); + } + + async listAllCurrencyInfo(): Promise<WalletCurrencyInfoEntry[]> { + const rows = await this.all("SELECT * FROM currency_info"); + return rows.map((r) => ({ + scopeInfoStr: str(r.scope_info_str), + currencySpec: dbToJson(r.currency_spec), + source: str(r.source) as WalletCurrencyInfoEntry["source"], + })); + } + + async upsertCurrencyInfoEntry(entry: WalletCurrencyInfoEntry): Promise<void> { + await this.run( + "INSERT INTO currency_info (scope_info_str, currency_spec, source)" + + " VALUES ($s, $spec, $src)" + + " ON CONFLICT(scope_info_str) DO UPDATE SET" + + " currency_spec = excluded.currency_spec," + + " source = excluded.source", + { + s: entry.scopeInfoStr, + spec: jsonToDb(entry.currencySpec), + src: entry.source, + }, + ); + } + async upsertConfig(record: ConfigRecord): Promise<void> { await this.run( "INSERT INTO config (key, value) VALUES ($key, $value)" + @@ -545,6 +575,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { }); } + async listAllTombstones(): Promise<WalletTombstone[]> { + const rows = await this.all("SELECT id FROM tombstones"); + return rows.map((r) => ({ id: str(r.id) })); + } + // --------------------------------------------------- operation retries async getOperationRetry( @@ -577,6 +612,17 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ); } + async listAllOperationRetries(): Promise<WalletOperationRetry[]> { + const rows = await this.all( + "SELECT id, last_error, retry_info FROM operation_retries", + ); + return rows.map((r) => ({ + id: str(r.id), + lastError: dbToOptJson(r.last_error), + retryInfo: dbToJson(r.retry_info), + })); + } + async deleteOperationRetry(taskId: string): Promise<void> { await this.run("DELETE FROM operation_retries WHERE id = $id", { id: taskId, @@ -647,6 +693,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { return row ? this.rowToReserve(row) : undefined; } + async listAllReserves(): Promise<WalletReserve[]> { + const rows = await this.all("SELECT * FROM reserves"); + return rows.map((r) => this.rowToReserve(r)); + } + private rowToReserve(row: ResultRow): WalletReserve { return { rowId: num(row.row_id), @@ -854,6 +905,21 @@ export class SqliteWalletTransaction implements WalletDbTransaction { } } + async listAllDenominations(): Promise<WalletDenomination[]> { + const rows = await this.all("SELECT * FROM denominations"); + return rows.map((r) => this.rowToDenomination(r)); + } + + async listAllContractTerms(): Promise<WalletContractTerms[]> { + const rows = await this.all( + "SELECT h, contract_terms_raw FROM contract_terms", + ); + return rows.map((r) => ({ + h: str(r.h), + contractTermsRaw: dbToJson(r.contract_terms_raw), + })); + } + private rowToDenomination(row: ResultRow): WalletDenomination { const denom: WalletDenomination = { exchangeBaseUrl: str(row.exchange_base_url), @@ -1755,6 +1821,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { // ----------------------------------------------- denomination families + async listAllDenominationFamilies(): Promise<WalletDenominationFamily[]> { + const rows = await this.all("SELECT * FROM denomination_families"); + return rows.map((r) => this.rowToDenominationFamily(r)); + } + private rowToDenominationFamily(row: ResultRow): WalletDenominationFamily { return { denominationFamilySerial: num(row.denomination_family_serial), @@ -1882,6 +1953,14 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ); } + async listAllExchangeBaseUrlFixups(): Promise<WalletExchangeBaseUrlFixup[]> { + const rows = await this.all("SELECT * FROM exchange_base_url_fixups"); + return rows.map((r) => ({ + exchangeBaseUrl: str(r.exchange_base_url), + replacement: str(r.replacement), + })); + } + async getExchangeMigrationLog( oldExchangeBaseUrl: string, newExchangeBaseUrl: string, @@ -1902,6 +1981,20 @@ export class SqliteWalletTransaction implements WalletDbTransaction { }; } + async listAllExchangeMigrationLogEntries(): Promise< + WalletExchangeMigrationLog[] + > { + const rows = await this.all( + "SELECT * FROM exchange_base_url_migration_log", + ); + return rows.map((r) => ({ + oldExchangeBaseUrl: str(r.old_exchange_base_url), + newExchangeBaseUrl: str(r.new_exchange_base_url), + timestamp: dbTimestamp(r.timestamp), + reason: str(r.reason) as ExchangeMigrationReason, + })); + } + async upsertExchangeMigrationLog( rec: WalletExchangeMigrationLog, ): Promise<void> { @@ -3868,6 +3961,16 @@ export class SqliteWalletTransaction implements WalletDbTransaction { return num(row?.n); } + async listAllDonationPlanchets(): Promise<WalletDonationPlanchet[]> { + const rows = await this.all("SELECT * FROM donation_planchets"); + return rows.map((r) => this.rowToDonationPlanchet(r)); + } + + async listAllDonationReceipts(): Promise<WalletDonationReceipt[]> { + const rows = await this.all("SELECT * FROM donation_receipts"); + return rows.map((r) => this.rowToDonationReceipt(r)); + } + private rowToDonationReceipt(row: ResultRow): WalletDonationReceipt { return { udiNonce: dbToCrock(row.udi_nonce), @@ -4090,6 +4193,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { })); } + async listAllMailboxConfigurations(): Promise<MailboxConfiguration[]> { + const rows = await this.all("SELECT payload FROM mailbox_configurations"); + return rows.map((r) => dbToJson<MailboxConfiguration>(r.payload)); + } + async getMailboxConfiguration( mailboxBaseUrl: string, ): Promise<MailboxConfiguration | undefined> { @@ -4479,6 +4587,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { }; } + async listAllSlates(): Promise<WalletSlate[]> { + const rows = await this.all("SELECT * FROM slates"); + return rows.map((r) => this.rowToSlate(r)); + } + async getSlate( purchaseId: string, choiceIndex: number, @@ -4673,6 +4786,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { }; } + async listAllRecoupGroups(): Promise<WalletRecoupGroup[]> { + const rows = await this.all("SELECT * FROM recoup_groups"); + return rows.map((r) => this.rowToRecoupGroup(r)); + } + async getRecoupGroup( recoupGroupId: string, ): Promise<WalletRecoupGroup | undefined> { diff --git a/packages/taler-wallet-core/src/dbtx.ts b/packages/taler-wallet-core/src/dbtx.ts @@ -85,6 +85,20 @@ import { WalletBankAccount, WalletExchangeDetails, } from "./db-common.js"; +/** + * A currency info record with its storage key. + * + * The scope string is kept opaque: stringifyScopeInfo has no exact inverse + * (parseScopeInfoShort reads a different format), so anything that needs to + * enumerate and re-store these records -- the database converter -- must + * round-trip the key without interpreting it. + */ +export interface WalletCurrencyInfoEntry { + scopeInfoStr: string; + currencySpec: CurrencySpecification; + source: "exchange" | "user" | "preset"; +} + export interface GetCurrencyInfoDbResult { /** * Currency specification. @@ -133,6 +147,9 @@ export interface WalletDbTransaction { /** Create or update a config record. */ upsertConfig(record: ConfigRecord): Promise<void>; + /** List every config record. */ + listAllConfig(): Promise<ConfigRecord[]>; + /** * Store currency info for a scope. * @@ -140,6 +157,17 @@ export interface WalletDbTransaction { */ upsertCurrencyInfo(req: StoreCurrencyInfoDbRequest): Promise<void>; + /** List every currency info record with its storage key. */ + listAllCurrencyInfo(): Promise<WalletCurrencyInfoEntry[]>; + + /** + * Store a currency info record under an existing storage key. + * + * For the converter; regular code uses upsertCurrencyInfo, which derives + * the key from a ScopeInfo. + */ + upsertCurrencyInfoEntry(entry: WalletCurrencyInfoEntry): Promise<void>; + /** Store currency info for a scope, keeping any existing entry. */ insertCurrencyInfoUnlessExists( req: StoreCurrencyInfoDbRequest, @@ -174,6 +202,9 @@ export interface WalletDbTransaction { /** Create or update a mailbox configuration. */ upsertMailboxConfiguration(mailboxConf: MailboxConfiguration): Promise<void>; + /** List every mailbox configuration. */ + listAllMailboxConfigurations(): Promise<MailboxConfiguration[]>; + /** Get a purchase by proposal ID. */ getPurchase(proposalId: string): Promise<WalletPurchase | undefined>; @@ -259,6 +290,9 @@ export interface WalletDbTransaction { */ upsertOperationRetry(rec: WalletOperationRetry): Promise<void>; + /** List the retry state of every task that has one. */ + listAllOperationRetries(): Promise<WalletOperationRetry[]>; + /** * Clear the retry state of a task. */ @@ -461,6 +495,9 @@ export interface WalletDbTransaction { /** Record that an exchange base URL should be replaced. */ upsertExchangeBaseUrlFixup(rec: WalletExchangeBaseUrlFixup): Promise<void>; + /** List every pending base-URL fixup. */ + listAllExchangeBaseUrlFixups(): Promise<WalletExchangeBaseUrlFixup[]>; + /** Get the log entry for a base-URL migration, if it has run. */ getExchangeMigrationLog( oldExchangeBaseUrl: string, @@ -470,6 +507,9 @@ export interface WalletDbTransaction { /** Record that a base-URL migration has run. */ upsertExchangeMigrationLog(rec: WalletExchangeMigrationLog): Promise<void>; + /** List every base-URL migration log entry. */ + listAllExchangeMigrationLogEntries(): Promise<WalletExchangeMigrationLog[]>; + /** Get exchange details by the (baseUrl, currency, masterPub) pointer. */ getExchangeDetailsByPointer( exchangeBaseUrl: string, @@ -621,6 +661,9 @@ export interface WalletDbTransaction { /** Record a tombstone, marking a deleted transaction as not to be revived. */ upsertTombstone(rec: WalletTombstone): Promise<void>; + /** List every tombstone. */ + listAllTombstones(): Promise<WalletTombstone[]>; + /** Get the donation summary for a donau, year and currency. */ getDonationSummary( donauBaseUrl: string, @@ -820,6 +863,9 @@ export interface WalletDbTransaction { */ listTokens(): Promise<WalletToken[]>; + /** List every slate. */ + listAllSlates(): Promise<WalletSlate[]>; + /** * Get a wallet token by its token use public key. */ @@ -970,6 +1016,27 @@ export interface WalletDbTransaction { /** List all coin availability records. */ getCoinAvailabilities(): Promise<WalletCoinAvailability[]>; + /** List every reserve. */ + listAllReserves(): Promise<WalletReserve[]>; + + /** List every recoup group. */ + listAllRecoupGroups(): Promise<WalletRecoupGroup[]>; + + /** List every donation planchet. */ + listAllDonationPlanchets(): Promise<WalletDonationPlanchet[]>; + + /** List every donation receipt. */ + listAllDonationReceipts(): Promise<WalletDonationReceipt[]>; + + /** List every denomination family. */ + listAllDenominationFamilies(): Promise<WalletDenominationFamily[]>; + + /** List every denomination. */ + listAllDenominations(): Promise<WalletDenomination[]>; + + /** List every stored contract-terms record. */ + listAllContractTerms(): Promise<WalletContractTerms[]>; + /** Get refresh groups in a non-final state. */ getActiveRefreshGroups(): Promise<WalletRefreshGroup[]>;