taler-typescript-core

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

commit f96966800d0a788f1640111a12bb2baf6f779e1e
parent 3f13b8309f4bde0466591b1fd2cc19ea5dcea831
Author: Florian Dold <dold@taler.net>
Date:   Mon, 20 Jul 2026 01:45:17 +0200

db: bound getTransactionMetaBefore, store downloaded_at as a timestamp

getTransactionMetaBefore read every record below the bound and kept the
last.  It now walks the index downwards and stops at the first hit.
mailbox_messages.downloaded_at is an INTEGER now, like every other time in
the schema, instead of a JSON document.

Diffstat:
Mpackages/taler-wallet-core/src/db-sqlite-schema.ts | 14++++++++++----
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/dbtx-indexeddb.ts | 13+++++++------
Mpackages/taler-wallet-core/src/dbtx-sqlite.ts | 6++++--
Mpackages/taler-wallet-core/src/query.ts | 17+++++++++++++++++
5 files changed, 97 insertions(+), 19 deletions(-)

diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts @@ -284,10 +284,9 @@ CREATE TABLE IF NOT EXISTS contacts ( CREATE TABLE IF NOT EXISTS mailbox_messages ( origin_mailbox_base_url TEXT NOT NULL, taler_uri TEXT NOT NULL, - -- JSON: Timestamp. Stored as a JSON document rather than as an INTEGER - -- microsecond timestamp like every other time in this schema, because the - -- record type exposes it whole and nothing queries or orders by it. - downloaded_at TEXT NOT NULL, + -- The record type carries a protocol Timestamp ({ t_s }); the mapper + -- converts, as it does for every other time in this schema. + downloaded_at INTEGER NOT NULL, PRIMARY KEY (origin_mailbox_base_url, taler_uri) ); @@ -404,6 +403,8 @@ CREATE TABLE IF NOT EXISTS bank_accounts ( CREATE INDEX IF NOT EXISTS bank_accounts_by_payto_uri ON bank_accounts (payto_uri); +-- An issued token. See slates below for the pre-issuance form, which +-- carries the same columns except token_issue_sig. CREATE TABLE IF NOT EXISTS tokens ( token_use_pub BLOB PRIMARY KEY, token_use_priv BLOB NOT NULL, @@ -444,6 +445,11 @@ CREATE INDEX IF NOT EXISTS tokens_by_purchase_and_choice CREATE INDEX IF NOT EXISTS tokens_by_family_hash ON tokens (token_family_hash); +-- A slate is a token that has not been issued yet: the same record minus +-- token_issue_sig, which is what the merchant adds on issuance. The columns +-- are repeated rather than shared with tokens because the two are separate +-- stores with their own record types, and a slate becoming a token is a move +-- between them rather than a column being filled in. CREATE TABLE IF NOT EXISTS slates ( token_use_pub BLOB PRIMARY KEY, token_use_priv BLOB NOT NULL, diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -1292,6 +1292,60 @@ export const conformanceCases: ConformanceCase[] = [ }, { + // Also pins the downloaded_at round trip: the record carries a protocol + // Timestamp while the sqlite column is an INTEGER of microseconds, so a + // conversion sits between them on one backend and not the other. + name: "mailbox message: round trips and is keyed by (mailbox, uri)", + async run(t, runner) { + const at = { t_s: 1735689600 }; + await runner.runReadWriteTx(async (tx) => { + await tx.upsertMailboxMessage({ + originMailboxBaseUrl: "https://mbox.test/", + talerUri: "taler://one", + downloadedAt: at, + }); + await tx.upsertMailboxMessage({ + originMailboxBaseUrl: "https://mbox.test/", + talerUri: "taler://two", + downloadedAt: { t_s: 1735689601 }, + }); + // Same URI at a different mailbox: the key is the pair. + await tx.upsertMailboxMessage({ + originMailboxBaseUrl: "https://other.test/", + talerUri: "taler://one", + downloadedAt: { t_s: 1735689602 }, + }); + }); + + const all = await runner.runReadWriteTx((tx) => tx.listMailboxMessages()); + t.equal(all.length, 3); + const one = all.find( + (m) => + m.originMailboxBaseUrl === "https://mbox.test/" && + m.talerUri === "taler://one", + ); + t.ok(one, "the message must be listed"); + t.deepEqual( + one!.downloadedAt, + at, + "downloadedAt must survive the round trip unchanged", + ); + + await runner.runReadWriteTx((tx) => + tx.deleteMailboxMessage("https://mbox.test/", "taler://one"), + ); + const left = await runner.runReadWriteTx((tx) => + tx.listMailboxMessages(), + ); + t.equal(left.length, 2, "delete must remove exactly one message"); + t.ok( + left.some((m) => m.originMailboxBaseUrl === "https://other.test/"), + "the same URI at another mailbox must survive", + ); + }, + }, + + { name: "refresh group: delete cascades to its sessions", async run(t, runner) { await runner.runReadWriteTx(async (tx) => { @@ -3276,13 +3330,11 @@ export const conformanceCases: ConformanceCase[] = [ tx.getTransactionMetaAfter(tsPrecise(1010)), ); - // getTransactionMetaBefore is deliberately left out. The sqlite - // implementation is bounded (ORDER BY ... DESC LIMIT 1, one row read), - // but the IndexedDB one reads the whole range below the bound and - // takes the last entry -- an accepted cost, documented there, because - // it is only reached when a pagination offset transaction was deleted. - // Asserting a bound here would encode an expectation one backend does - // not meet by design; the DAL contract is the result, not the cost. + // Both backends walk the index downwards and stop at the first hit: + // ORDER BY ... DESC LIMIT 1 on sqlite, a "prev" cursor on IndexedDB. + await measure("getTransactionMetaBefore", 3, (tx) => + tx.getTransactionMetaBefore(tsPrecise(1010)), + ); }, }, ]; diff --git a/packages/taler-wallet-core/src/dbtx-indexeddb.ts b/packages/taler-wallet-core/src/dbtx-indexeddb.ts @@ -273,14 +273,15 @@ export class IdbWalletTransaction implements WalletDbTransaction { timestamp: DbPreciseTimestamp, ): Promise<WalletTransactionMeta | undefined> { const tx = this.tx; - // Slow, but only reached when a pagination offset transaction was deleted. - const recs = await tx.transactionsMeta.indexes.byTimestamp.getAll( + // Walk the index downwards from the bound and stop at the first hit. + // Reading the whole range and keeping the last entry gave the same answer + // but touched every record below the bound, which on a wallet with a long + // history is the entire table. + const cursor = tx.transactionsMeta.indexes.byTimestamp.iterPrev( GlobalIDB.KeyRange.upperBound(timestamp, false), ); - if (recs.length > 0) { - return recs[recs.length - 1]; - } - return undefined; + const first = await cursor.next(); + return first.hasValue ? first.value : undefined; } async getTransactionMetaAfter( diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts @@ -115,6 +115,8 @@ import { WalletSlate, WalletToken, WalletTransactionMeta, + timestampProtocolFromDb, + timestampProtocolToDb, } from "./db-common.js"; import { SQLITE_BASELINE_SCHEMA, @@ -4063,7 +4065,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { { url: message.originMailboxBaseUrl, uri: message.talerUri, - at: jsonToDb(message.downloadedAt), + at: timestampProtocolToDb(message.downloadedAt), }, ); } @@ -4084,7 +4086,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { return rows.map((row) => ({ originMailboxBaseUrl: str(row.origin_mailbox_base_url), talerUri: str(row.taler_uri), - downloadedAt: dbToJson(row.downloaded_at), + downloadedAt: timestampProtocolFromDb(dbTimestamp(row.downloaded_at)), })); } diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/query.ts @@ -423,6 +423,13 @@ export function describeIndex( interface IndexReadOnlyAccessor<RecordType> { iter(query?: IDBKeyRange | IDBValidKey): ResultStream<RecordType>; + /** + * Iterate the index from the high end of the range downwards. + * + * Lets "the last entry at or below X" be answered by reading one record + * instead of materialising the whole range and discarding all but the last. + */ + iterPrev(query?: IDBKeyRange | IDBValidKey): ResultStream<RecordType>; get(query: IDBValidKey): Promise<RecordType | undefined>; getAll( query?: IDBKeyRange | IDBValidKey, @@ -441,6 +448,8 @@ type GetIndexReadOnlyAccess<RecordType, IndexMap> = { interface IndexReadWriteAccessor<RecordType> { iter(query?: IDBKeyRange | IDBValidKey): ResultStream<RecordType>; + /** See {@link IndexReadOnlyAccessor.iterPrev}. */ + iterPrev(query?: IDBKeyRange | IDBValidKey): ResultStream<RecordType>; get(query: IDBValidKey): Promise<RecordType | undefined>; getAll( query?: IDBKeyRange | IDBValidKey, @@ -748,6 +757,14 @@ function makeTxClientContext( .openCursor(query); return new ResultStream<any>(req); }, + iterPrev(query) { + internalContext.throwIfInactive(); + const req = tx + .objectStore(storeName) + .index(indexName) + .openCursor(query, "prev"); + return new ResultStream<any>(req); + }, getAll(query, count) { internalContext.throwIfInactive(); const req = tx