Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(db):** keep token_ledger when resetting usage data ([#16005](https://github.com/diegosouzapw/OmniRoute/pull/16005)) — thanks @pacocartones
6 changes: 5 additions & 1 deletion src/lib/db/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions tests/unit/usage-history-reset-token-ledger.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
}
Loading