taler-typescript-core

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

commit 8027c7c743e0e7b5d117808c7dd50bde50ee2465
parent 168ba0117bbdefdc61fcaa51115897cd9ff37c4b
Author: Florian Dold <dold@taler.net>
Date:   Wed, 19 Aug 2026 21:41:58 +0200

wallet-core: page transaction metadata with stable cursors

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-transactions.ts | 137++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-wallet-core/src/transactions.ts | 122++++++++++++++++++++++++++++++++++++++++---------------------------------------
2 files changed, 180 insertions(+), 79 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-transactions.ts b/packages/taler-harness/src/integrationtests/test-wallet-transactions.ts @@ -24,6 +24,7 @@ import { j2s, TalerMerchantApi, TransactionIdStr, + TransactionType, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { @@ -190,30 +191,128 @@ export async function runWalletTransactionsTest(t: GlobalTestState) { {}, ); - const paged: string[] = []; - let offset: TransactionIdStr | undefined = undefined; - while (true) { - const req: GetTransactionsV2Request = { limit: 1 }; - if (offset != null) { - req.offsetTransactionId = offset; + const collectPages = async (limit: 1 | -1): Promise<string[]> => { + const paged: string[] = []; + let offset: TransactionIdStr | undefined = undefined; + while (true) { + const req: GetTransactionsV2Request = { limit }; + if (offset != null) { + req.offsetTransactionId = offset; + } + const page = await walletClient.call( + WalletApiOperation.GetTransactionsV2, + req, + ); + if (page.transactions.length === 0) { + break; + } + for (const tx of page.transactions) { + paged.push(tx.transactionId); + } + offset = page.transactions[page.transactions.length - 1].transactionId; } - const page = await walletClient.call( - WalletApiOperation.GetTransactionsV2, - req, - ); - if (page.transactions.length === 0) { - break; - } - for (const tx of page.transactions) { - paged.push(tx.transactionId); - } - offset = page.transactions[page.transactions.length - 1].transactionId; - } + return paged; + }; t.assertDeepEqual( - paged, + await collectPages(1), all.transactions.map((x) => x.transactionId), ); + t.assertDeepEqual( + await collectPages(-1), + all.transactions.map((x) => x.transactionId).reverse(), + ); + } + + { + // If an offset is deleted between page requests, its timestamp fallback + // must not skip any surviving transaction in either direction. + const beforeDelete = await walletClient.call( + WalletApiOperation.GetTransactionsV2, + {}, + ); + const deletedIndex = beforeDelete.transactions.findIndex( + (tx, i) => + tx.type === TransactionType.PeerPullCredit && + i > 0 && + i < beforeDelete.transactions.length - 1, + ); + t.assertTrue(deletedIndex > 0); + const deletedOffset = beforeDelete.transactions[deletedIndex]; + const forwardPrefix = await walletClient.call( + WalletApiOperation.GetTransactionsV2, + { limit: deletedIndex + 1 }, + ); + const backwardPrefix = await walletClient.call( + WalletApiOperation.GetTransactionsV2, + { limit: -(beforeDelete.transactions.length - deletedIndex) }, + ); + t.assertDeepEqual( + forwardPrefix.transactions[forwardPrefix.transactions.length - 1] + .transactionId, + deletedOffset.transactionId, + ); + t.assertDeepEqual( + backwardPrefix.transactions[backwardPrefix.transactions.length - 1] + .transactionId, + deletedOffset.transactionId, + ); + await walletClient.call(WalletApiOperation.DeleteTransaction, { + transactionId: deletedOffset.transactionId, + }); + + const hasDeletedTimestamp = ( + tx: (typeof beforeDelete.transactions)[number], + ): boolean => + AbsoluteTime.cmp( + AbsoluteTime.fromPreciseTimestamp(tx.timestamp), + AbsoluteTime.fromPreciseTimestamp(deletedOffset.timestamp), + ) === 0; + const timestampRunStart = + beforeDelete.transactions.findIndex(hasDeletedTimestamp); + let timestampRunEnd = timestampRunStart; + while ( + timestampRunEnd + 1 < beforeDelete.transactions.length && + hasDeletedTimestamp(beforeDelete.transactions[timestampRunEnd + 1]) + ) { + timestampRunEnd++; + } + + const pageLimit = beforeDelete.transactions.length * 2; + const forward = await walletClient.call( + WalletApiOperation.GetTransactionsV2, + { + offsetTransactionId: deletedOffset.transactionId, + offsetTimestamp: deletedOffset.timestamp, + limit: pageLimit, + }, + ); + const expectedForward = beforeDelete.transactions + .slice(timestampRunStart) + .filter((tx) => tx.transactionId !== deletedOffset.transactionId) + .map((tx) => tx.transactionId); + t.assertDeepEqual( + forward.transactions.map((tx) => tx.transactionId), + expectedForward, + ); + + const backward = await walletClient.call( + WalletApiOperation.GetTransactionsV2, + { + offsetTransactionId: deletedOffset.transactionId, + offsetTimestamp: deletedOffset.timestamp, + limit: -pageLimit, + }, + ); + const expectedBackward = beforeDelete.transactions + .slice(0, timestampRunEnd + 1) + .filter((tx) => tx.transactionId !== deletedOffset.transactionId) + .map((tx) => tx.transactionId) + .reverse(); + t.assertDeepEqual( + backward.transactions.map((tx) => tx.transactionId), + expectedBackward, + ); } } diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts @@ -59,6 +59,7 @@ import { timestampPreciseToDb, } from "./db-common.js"; import { WalletTransactionMeta } from "./db-common.js"; +import { WalletTransactionMetaCursor } from "./db-common.js"; import { DepositTransactionContext } from "./deposits.js"; import { DenomLossTransactionContext } from "./exchanges.js"; import { @@ -433,60 +434,61 @@ function sortTransactions( AbsoluteTime.fromPreciseTimestamp(h1.timestamp), AbsoluteTime.fromPreciseTimestamp(h2.timestamp), ); - // If the timestamp is exactly the same, order by transaction type. + // Match the unique database cursor order for equal timestamps. if (tsCmp === 0) { - return Math.sign(txOrder[h1.type] - txOrder[h2.type]); + const idCmp = + h1.transactionId < h2.transactionId + ? -1 + : h1.transactionId > h2.transactionId + ? 1 + : 0; + return sortSign * idCmp; } return sortSign * tsCmp; }; transactions.sort(txCmp); } -async function findOffsetTransaction( +async function findOffsetCursor( tx: WalletDbTransaction, req?: GetTransactionsV2Request, -): Promise<WalletTransactionMeta | undefined> { - let forwards = req?.limit == null || req.limit >= 0; - let closestTimestamp: DbPreciseTimestamp | undefined = undefined; +): Promise<WalletTransactionMetaCursor | undefined> { + const forwards = req?.limit == null || req.limit >= 0; if (req?.offsetTransactionId) { const res = await tx.getTransactionMeta(req.offsetTransactionId); if (res) { - return res; + return { timestamp: res.timestamp, transactionId: res.transactionId }; } - if (req.offsetTimestamp) { - closestTimestamp = timestampPreciseToDb(req.offsetTimestamp); - } else { + if (!req.offsetTimestamp) { throw TalerError.fromDetail( TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND, { transactionId: req.offsetTransactionId }, `offset transaction ${req.offsetTransactionId} not found and no offset timestamp specified`, ); } - } else if (req?.offsetTimestamp) { - const dbStamp = timestampPreciseToDb(req.offsetTimestamp); - const res = await tx.getTransactionMetaAtTimestamp(dbStamp); - if (res) { - return res; - } - closestTimestamp = timestampPreciseToDb(req.offsetTimestamp); - } else { + } + if (!req?.offsetTimestamp) { return undefined; } - - // We didn't find a precise offset transaction. - // This must mean that it was deleted. - // Depending on the direction, find the prev/next - // transaction and use it as an offset. - - if (forwards) { - // We don't want to skip transactions in pagination, - // so get the transaction before the timestamp - - return await tx.getTransactionMetaBefore(closestTimestamp); - } else { - // Likewise, get the transaction after the timestamp - return await tx.getTransactionMetaAfter(closestTimestamp); + const timestamp = timestampPreciseToDb(req.offsetTimestamp); + if (!req.offsetTransactionId) { + const atTimestamp = await tx.getTransactionMetaAtTimestamp(timestamp); + if (atTimestamp) { + return { + timestamp: atTimestamp.timestamp, + transactionId: atTimestamp.transactionId, + }; + } } + // No live transaction is available to anchor the cursor. Conservatively + // replay the whole run at this timestamp: use its low edge when paging + // forward and its high edge when paging backward. This guarantees that no + // surviving transaction is skipped, at the cost of possibly returning one + // from the preceding page again. + return { + timestamp, + transactionId: forwards ? "" : "\uffff", + }; } export async function getTransactionsV2( @@ -508,9 +510,9 @@ export async function getTransactionsV2( transactionsRequest?.limit != null ? Math.abs(transactionsRequest.limit) : undefined; - let offsetMtx = await findOffsetTransaction(tx, transactionsRequest); + let cursor = await findOffsetCursor(tx, transactionsRequest); - if (limit == null && offsetMtx == null) { + if (limit == null && cursor == null) { // Fast path for returning *everything* that matches the filter. // FIXME: We could use the DB for filtering here const res = await tx.listTransactionMetaByStatus({ @@ -518,36 +520,36 @@ export async function getTransactionsV2( }); await addFiltered(wex, tx, transactionsRequest, resultTransactions, res); } else { - // Slow implementation. Doing it properly would require using cursors, - // which are also slow in IndexedDB. - // - // The list is walked rather than paged through the timestamp index, - // because that index is not unique: a bound on the timestamp alone - // cannot separate two transactions that share one, so paging on it - // skips the rest of a run that a page ended in the middle of. - const res = await tx.listTransactionMetaByTimestamp({}); - if (!forwards) { - res.reverse(); + if (limit === 0) { + return; } - let start: number; - if (offsetMtx != null) { - const needleTxId = offsetMtx.transactionId; - const offsetIdx = res.findIndex((x) => x.transactionId === needleTxId); - if (offsetIdx < 0) { - throw Error("offset transaction not found"); + while (limit == null || resultTransactions.length < limit) { + const remaining = + limit == null ? 128 : Math.max(1, limit - resultTransactions.length); + const pageSize = Math.min(256, Math.max(32, remaining * 2)); + const page = await tx.listTransactionMetaPage({ + cursor, + direction: forwards ? "forward" : "backward", + limit: pageSize, + }); + if (page.length === 0) { + break; } - // The offset transaction was part of the previous page. - start = offsetIdx + 1; - } else { - start = 0; - } - for (let i = start; i < res.length; i++) { - if (limit != null && resultTransactions.length >= limit) { + await addFiltered( + wex, + tx, + transactionsRequest, + resultTransactions, + page, + ); + const last = page[page.length - 1]; + cursor = { + timestamp: last.timestamp, + transactionId: last.transactionId, + }; + if (page.length < pageSize) { break; } - await addFiltered(wex, tx, transactionsRequest, resultTransactions, [ - res[i], - ]); } } });