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:
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