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 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"); + }); +}