Skip to content
Open
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
67 changes: 66 additions & 1 deletion src/lib/db/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export {
export async function exportDb() {
const db = await getAdapter();
const { exportSettings } = await import("./repos/settingsRepo.js");
const lifetimeRow = db.get(`SELECT value FROM _meta WHERE key = 'totalRequestsLifetime'`);
const totalRequestsLifetime = Number.parseInt(lifetimeRow?.value || "0", 10);

const out = {
settings: await exportSettings(),
Expand All @@ -83,6 +85,18 @@ export async function exportDb() {
customModels: [],
mitmAlias: {},
pricing: {},
usageHistory: db.all(`SELECT * FROM usageHistory ORDER BY id ASC`).map((r) => ({
...r,
tokens: parseJson(r.tokens, {}),
meta: parseJson(r.meta, {}),
})),
usageDaily: db.all(`SELECT * FROM usageDaily ORDER BY dateKey ASC`).map((r) => ({
dateKey: r.dateKey,
data: parseJson(r.data, {}),
})),
usageMeta: {
totalRequestsLifetime: Number.isFinite(totalRequestsLifetime) ? totalRequestsLifetime : 0,
},
};

for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`)) out.modelAliases[r.key] = parseJson(r.value);
Expand All @@ -98,16 +112,21 @@ export async function importDb(payload) {
throw new Error("Invalid database payload");
}
const db = await getAdapter();
const hasUsageHistory = Array.isArray(payload.usageHistory);
const hasUsageDaily = Array.isArray(payload.usageDaily);

db.transaction(() => {
// Wipe all tables (keep _meta)
// Usage tables are only replaced when present, so backups created by an
// older version do not unexpectedly erase usage collected by a newer one.
db.run(`DELETE FROM settings`);
db.run(`DELETE FROM providerConnections`);
db.run(`DELETE FROM providerNodes`);
db.run(`DELETE FROM proxyPools`);
db.run(`DELETE FROM apiKeys`);
db.run(`DELETE FROM combos`);
db.run(`DELETE FROM kv WHERE scope IN ('modelAliases', 'customModels', 'mitmAlias', 'pricing')`);
if (hasUsageHistory) db.run(`DELETE FROM usageHistory`);
if (hasUsageDaily) db.run(`DELETE FROM usageDaily`);

// Settings
if (payload.settings) {
Expand Down Expand Up @@ -160,8 +179,54 @@ export async function importDb(payload) {
for (const [provider, models] of Object.entries(payload.pricing || {})) {
db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('pricing', ?, ?)`, [provider, stringifyJson(models || {})]);
}

if (hasUsageHistory) {
for (const entry of payload.usageHistory) {
const tokens = entry.tokens && typeof entry.tokens === "object" ? entry.tokens : {};
const promptTokens = entry.promptTokens ?? tokens.prompt_tokens ?? tokens.input_tokens ?? 0;
const completionTokens = entry.completionTokens ?? tokens.completion_tokens ?? tokens.output_tokens ?? 0;
db.run(
`INSERT OR REPLACE INTO usageHistory(id, timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, status, tokens, meta) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
entry.id ?? null, entry.timestamp, entry.provider || null, entry.model || null,
entry.connectionId || null, entry.apiKey || null, entry.endpoint || null,
promptTokens, completionTokens, entry.cost || 0, entry.status || "ok",
stringifyJson(tokens), stringifyJson(entry.meta || {}),
]
);
}

const requestedLifetime = Number(payload.usageMeta?.totalRequestsLifetime);
const totalRequestsLifetime = Number.isFinite(requestedLifetime)
? Math.max(0, Math.trunc(requestedLifetime))
: payload.usageHistory.length;
db.run(
`INSERT INTO _meta(key, value) VALUES('totalRequestsLifetime', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
[String(totalRequestsLifetime)]
);
}

if (hasUsageDaily) {
for (const day of payload.usageDaily) {
db.run(
`INSERT OR REPLACE INTO usageDaily(dateKey, data) VALUES(?, ?)`,
[day.dateKey, stringifyJson(day.data || {})]
);
}
}
});

// The usage dashboard keeps a small process-local cache. Force it to reload
// the newly imported rows instead of showing pre-import recent requests.
if (hasUsageHistory && global._recentRing) {
global._recentRing.items = [];
global._recentRing.initialized = false;
}
if (global._connectionMapCache) {
global._connectionMapCache.map = {};
global._connectionMapCache.ts = 0;
}

return await exportDb();
}

Expand Down
37 changes: 36 additions & 1 deletion tests/unit/db-sqlite-vs-lowdb.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,16 +248,51 @@ describe("DB SQLite layer — public API parity", () => {
expect(exported.settings).toBeDefined();
expect(Array.isArray(exported.providerConnections)).toBe(true);
expect(typeof exported.modelAliases).toBe("object");
expect(Array.isArray(exported.usageHistory)).toBe(true);
expect(Array.isArray(exported.usageDaily)).toBe(true);
expect(exported.usageMeta.totalRequestsLifetime).toEqual(expect.any(Number));

// Add marker, export, import a different payload, verify reset
// Add config + usage markers, then verify both are restored from snapshot.
await sqliteDb.setModelAlias("marker", "before");
await sqliteDb.saveRequestUsage({
timestamp: "2025-01-01T00:00:00.000Z",
provider: "backup-snapshot", model: "backup-model", connectionId: "backup-c1",
tokens: { prompt_tokens: 11, completion_tokens: 7 },
endpoint: "/v1/backup", status: "ok",
});
const snap = await sqliteDb.exportDb();

await sqliteDb.setModelAlias("marker", "after");
await sqliteDb.saveRequestUsage({
timestamp: "2025-01-02T00:00:00.000Z",
provider: "backup-extra", model: "backup-model", connectionId: "backup-c2",
tokens: { prompt_tokens: 99, completion_tokens: 1 },
endpoint: "/v1/backup", status: "ok",
});
expect((await sqliteDb.getModelAliases()).marker).toBe("after");
expect(await sqliteDb.getUsageHistory({ provider: "backup-extra" })).toHaveLength(1);

await sqliteDb.importDb(snap);
expect((await sqliteDb.getModelAliases()).marker).toBe("before");
expect(await sqliteDb.getUsageHistory({ provider: "backup-snapshot" })).toHaveLength(1);
expect(await sqliteDb.getUsageHistory({ provider: "backup-extra" })).toHaveLength(0);

const restored = await sqliteDb.exportDb();
expect(restored.usageMeta.totalRequestsLifetime).toBe(snap.usageMeta.totalRequestsLifetime);

// Legacy backups have no usage fields and must preserve current usage.
const legacyBackup = { ...snap };
delete legacyBackup.usageHistory;
delete legacyBackup.usageDaily;
delete legacyBackup.usageMeta;
await sqliteDb.saveRequestUsage({
timestamp: "2025-01-03T00:00:00.000Z",
provider: "legacy-preserved", model: "backup-model", connectionId: "backup-c3",
tokens: { prompt_tokens: 5, completion_tokens: 3 },
endpoint: "/v1/backup", status: "ok",
});
await sqliteDb.importDb(legacyBackup);
expect(await sqliteDb.getUsageHistory({ provider: "legacy-preserved" })).toHaveLength(1);
});

it("pricing: user pricing merged with constants", async () => {
Expand Down