From 7f313c65759d4db20b628cee0f6a5156d6aac6d3 Mon Sep 17 00:00:00 2001 From: pacocartones Date: Thu, 8 Oct 2026 23:51:50 +0000 Subject: [PATCH 1/2] fix(db): keep token_ledger when resetting usage data resetUsageHistory() listed token_ledger among the tables it empties, so Settings > Storage > Reset usage data deleted the token transfer record that balances are computed from (getBalance) and that idempotency keys live in: balances changed and an already-applied transfer could be applied again. token_ledger is not usage analytics. Remove it from the reset targets; the deletedTokenLedger field stays (always 0) so the API response keeps its shape. --- src/lib/db/cleanup.ts | 6 +- .../usage-history-reset-token-ledger.test.ts | 61 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/unit/usage-history-reset-token-ledger.test.ts diff --git a/src/lib/db/cleanup.ts b/src/lib/db/cleanup.ts index 5b10d9e8ae6a..2ae6536b8469 100644 --- a/src/lib/db/cleanup.ts +++ b/src/lib/db/cleanup.ts @@ -827,6 +827,11 @@ export interface ResetUsageHistoryResult extends CleanupResult { deletedCompressionRunTelemetry: number; deletedRoutingDecisions: number; deletedQuotaConsumption: number; + /** + * Always 0. `token_ledger` is the transfer record behind token balances and + * idempotency keys, not usage analytics, so a usage reset never deletes it. + * The field stays so the response shape of the purge API does not change. + */ deletedTokenLedger: number; deletedConversationTurnNodes: number; deletedAgenticConversations: number; @@ -910,7 +915,6 @@ const RESET_TARGETS: Array< cutoff: "epochMs", resultKey: "deletedQuotaConsumption", }, - { table: "token_ledger", column: "created_at", cutoff: "iso", resultKey: "deletedTokenLedger" }, { table: "conversation_turn_nodes", column: "last_seen_at", diff --git a/tests/unit/usage-history-reset-token-ledger.test.ts b/tests/unit/usage-history-reset-token-ledger.test.ts new file mode 100644 index 000000000000..01adaae03f90 --- /dev/null +++ b/tests/unit/usage-history-reset-token-ledger.test.ts @@ -0,0 +1,61 @@ +/** + * The on-demand "Reset usage data" action wipes usage analytics. `token_ledger` + * is not analytics: it is the record of token transfers between API keys, + * balances are computed from it (`getBalance`) and `idempotency_key` is what + * makes a retried transfer a no-op. Deleting its rows silently changes balances + * and lets an already-applied transfer be applied again. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-reset-ledger-test-")); +const originalDataDir = process.env.DATA_DIR; +process.env.DATA_DIR = tempDir; + +const { getDbInstance, resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { resetUsageHistory } = await import("../../src/lib/db/cleanup.ts"); +const { getBalance, transferTokens } = await import("../../src/lib/db/gamification.ts"); + +test.after(() => { + resetDbInstance(); + if (originalDataDir !== undefined) { + process.env.DATA_DIR = originalDataDir; + } else { + delete process.env.DATA_DIR; + } + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function seedLedger() { + const db = getDbInstance(); + db.prepare("DELETE FROM token_ledger").run(); + const old = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + const insert = db.prepare( + "INSERT INTO token_ledger (from_api_key_id, to_api_key_id, amount, reason, idempotency_key, created_at) VALUES (?, ?, ?, ?, ?, ?)" + ); + insert.run("system", "alice", 100, "grant", "grant-1", old); + insert.run("alice", "bob", 40, "share", "transfer-1", old); + return db; +} + +for (const period of ["1d", "all"] as const) { + test(`resetUsageHistory("${period}") keeps token_ledger rows, balances and idempotency keys`, async () => { + const db = seedLedger(); + assert.equal(getBalance("alice"), 60); + + const result = await resetUsageHistory(period); + + const rows = (db.prepare("SELECT COUNT(*) AS c FROM token_ledger").get() as { c: number }).c; + assert.equal(rows, 2, "the ledger must survive a usage reset"); + assert.equal(getBalance("alice"), 60, "balances must not change"); + assert.equal(getBalance("bob"), 40); + assert.equal(result.deletedTokenLedger, 0); + + // A retry of an already-applied transfer must stay a no-op. + transferTokens("alice", "bob", 40, "share", "transfer-1"); + assert.equal(getBalance("alice"), 60, "a retried transfer must not be applied twice"); + }); +} From 9b695753f8d2dab40a5209714ac3fd77cee5440a Mon Sep 17 00:00:00 2001 From: pacocartones Date: Fri, 9 Oct 2026 00:04:54 +0000 Subject: [PATCH 2/2] docs(changelog): add the fragment for #16005 --- changelog.d/fixes/16005-usage-reset-keeps-token-ledger.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/fixes/16005-usage-reset-keeps-token-ledger.md diff --git a/changelog.d/fixes/16005-usage-reset-keeps-token-ledger.md b/changelog.d/fixes/16005-usage-reset-keeps-token-ledger.md new file mode 100644 index 000000000000..4ccecfce8343 --- /dev/null +++ b/changelog.d/fixes/16005-usage-reset-keeps-token-ledger.md @@ -0,0 +1 @@ +- **fix(db):** keep token_ledger when resetting usage data ([#16005](https://github.com/diegosouzapw/OmniRoute/pull/16005)) — thanks @pacocartones