fix: DB backup on schema change, MCP child cleanup, codex models, usage providers OOM
- Backup DB only on real SCHEMA_VERSION change, not every app version bump - Kill idle MCP stdio bridge children to prevent orphan process leaks - Add getDistinctProviders to avoid loading every row JSON blob (OOM fix) - Update codex model list (gpt-5.6 sol/terra/luna, drop 5.3 codex variants) - Reorder Claude default models (fable first) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -45,22 +45,18 @@ export default {
|
||||
},
|
||||
},
|
||||
models: [
|
||||
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" },
|
||||
{ id: "gpt-5.6-sol-review", name: "GPT 5.6 Sol Review", upstreamModelId: "gpt-5.6-sol", quotaFamily: "review" },
|
||||
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra" },
|
||||
{ id: "gpt-5.6-terra-review", name: "GPT 5.6 Terra Review", upstreamModelId: "gpt-5.6-terra", quotaFamily: "review" },
|
||||
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna" },
|
||||
{ id: "gpt-5.6-luna-review", name: "GPT 5.6 Luna Review", upstreamModelId: "gpt-5.6-luna", quotaFamily: "review" },
|
||||
{ id: "gpt-5.5", name: "GPT 5.5" },
|
||||
{ id: "gpt-5.5-review", name: "GPT 5.5 Review", upstreamModelId: "gpt-5.5", quotaFamily: "review" },
|
||||
{ id: "gpt-5.4", name: "GPT 5.4" },
|
||||
{ id: "gpt-5.4-review", name: "GPT 5.4 Review", upstreamModelId: "gpt-5.4", quotaFamily: "review" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini" },
|
||||
{ id: "gpt-5.4-mini-review", name: "GPT 5.4 Mini Review", upstreamModelId: "gpt-5.4-mini", quotaFamily: "review" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
|
||||
{ id: "gpt-5.3-codex-review", name: "GPT 5.3 Codex Review", upstreamModelId: "gpt-5.3-codex", quotaFamily: "review" },
|
||||
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
|
||||
{ id: "gpt-5.3-codex-xhigh-review", name: "GPT 5.3 Codex (xHigh) Review", upstreamModelId: "gpt-5.3-codex-xhigh", quotaFamily: "review" },
|
||||
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
|
||||
{ id: "gpt-5.3-codex-high-review", name: "GPT 5.3 Codex (High) Review", upstreamModelId: "gpt-5.3-codex-high", quotaFamily: "review" },
|
||||
{ id: "gpt-5.3-codex-low", name: "GPT 5.3 Codex (Low)" },
|
||||
{ id: "gpt-5.3-codex-low-review", name: "GPT 5.3 Codex (Low) Review", upstreamModelId: "gpt-5.3-codex-low", quotaFamily: "review" },
|
||||
{ id: "gpt-5.3-codex-none", name: "GPT 5.3 Codex (None)" },
|
||||
{ id: "gpt-5.3-codex-none-review", name: "GPT 5.3 Codex (None) Review", upstreamModelId: "gpt-5.3-codex-none", quotaFamily: "review" },
|
||||
{ id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" },
|
||||
{ id: "gpt-5.3-codex-spark-review", name: "GPT 5.3 Codex Spark Review", upstreamModelId: "gpt-5.3-codex-spark", quotaFamily: "review" },
|
||||
{ id: "gpt-5.5-image", name: "GPT 5.5 Image", capabilities: ["text2img","edit"], params: ["size","quality","background","image_detail","output_format"], kind: "image" },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRequestDetails } from "@/lib/requestDetailsDb";
|
||||
import { getDistinctProviders } from "@/lib/requestDetailsDb";
|
||||
import { getProviderNodes } from "@/lib/localDb";
|
||||
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
||||
|
||||
@@ -9,10 +9,9 @@ import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const { details } = await getRequestDetails({ pageSize: 9999 });
|
||||
|
||||
// Extract unique providers
|
||||
const providerIds = [...new Set(details.map(r => r.provider).filter(Boolean))].sort();
|
||||
// Query DISTINCT provider column directly — avoids parsing every row's
|
||||
// full JSON blob (can be hundreds of MB), which previously caused OOM.
|
||||
const providerIds = await getDistinctProviders();
|
||||
|
||||
const providerNodes = await getProviderNodes();
|
||||
const nodeMap = {};
|
||||
|
||||
@@ -9,8 +9,10 @@ export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const page = parseInt(searchParams.get("page")) || 1;
|
||||
const pageSize = parseInt(searchParams.get("pageSize")) || 20;
|
||||
const pageRaw = parseInt(searchParams.get("page"));
|
||||
const page = Number.isNaN(pageRaw) ? 1 : pageRaw;
|
||||
const pageSizeRaw = parseInt(searchParams.get("pageSize"));
|
||||
const pageSize = Number.isNaN(pageSizeRaw) ? 20 : pageSizeRaw;
|
||||
const provider = searchParams.get("provider");
|
||||
const model = searchParams.get("model");
|
||||
const connectionId = searchParams.get("connectionId");
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
// DB safety backups — taken ONLY before a schema change (see migrate.js).
|
||||
//
|
||||
// ⚠️ AGENT/DEV NOTES:
|
||||
// - Backups are a best-effort safety net before schema migrations. There is NO
|
||||
// automated restore path; recovery is manual (copy a backup file back).
|
||||
// - Backups intentionally EXCLUDE the `requestDetails` table (observability log,
|
||||
// auto-pruned, non-critical) so a multi-hundred-MB DB backs up as a few MB.
|
||||
// - Only the newest KEEP_BACKUPS are kept; older ones are pruned automatically.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { BACKUPS_DIR, ensureDirs } from "./paths.js";
|
||||
import { timestampSlug, getAppVersion } from "./version.js";
|
||||
|
||||
const KEEP_BACKUPS = 5;
|
||||
const KEEP_BACKUPS = 3;
|
||||
|
||||
// Tables excluded from safety backups (large, non-critical, reproducible).
|
||||
const BACKUP_EXCLUDE_TABLES = ["requestDetails"];
|
||||
|
||||
export function makeBackupDir(label) {
|
||||
ensureDirs();
|
||||
@@ -22,6 +33,35 @@ export function backupFile(srcPath, destDir, destName = null) {
|
||||
return dest;
|
||||
}
|
||||
|
||||
// Lightweight DB backup via ATTACH: create an empty sqlite file, copy every
|
||||
// table EXCEPT the excluded ones into it. Avoids duplicating the huge
|
||||
// observability log, so the backup stays small regardless of DB size.
|
||||
export function backupDbLite(adapter, destDir, destName = "data.sqlite") {
|
||||
const dest = path.join(destDir, destName);
|
||||
try { fs.rmSync(dest, { force: true }); } catch {}
|
||||
const escaped = dest.replace(/'/g, "''");
|
||||
|
||||
adapter.exec(`ATTACH DATABASE '${escaped}' AS bak`);
|
||||
try {
|
||||
const excluded = new Set(BACKUP_EXCLUDE_TABLES);
|
||||
const tables = adapter
|
||||
.all(`SELECT name, sql FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
||||
.filter((t) => !excluded.has(t.name));
|
||||
|
||||
adapter.transaction(() => {
|
||||
for (const t of tables) {
|
||||
// Recreate table structure in backup DB, then copy rows.
|
||||
const createSql = t.sql.replace(/CREATE TABLE\s+/i, "CREATE TABLE bak.");
|
||||
adapter.exec(createSql);
|
||||
adapter.exec(`INSERT INTO bak.${t.name} SELECT * FROM main.${t.name}`);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
try { adapter.exec("DETACH DATABASE bak"); } catch {}
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
export function pruneOldBackups() {
|
||||
if (!fs.existsSync(BACKUPS_DIR)) return;
|
||||
const entries = fs.readdirSync(BACKUPS_DIR, { withFileTypes: true })
|
||||
|
||||
@@ -64,7 +64,7 @@ export {
|
||||
|
||||
// Request details
|
||||
export {
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById,
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||
} from "./repos/requestDetailsRepo.js";
|
||||
|
||||
// Export/import full DB
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { LEGACY_FILES, DB_DIR, DATA_FILE } from "./paths.js";
|
||||
import { TABLES, buildCreateTableSql } from "./schema.js";
|
||||
import { LEGACY_FILES, DB_DIR } from "./paths.js";
|
||||
import { TABLES, buildCreateTableSql, SCHEMA_VERSION } from "./schema.js";
|
||||
import { MIGRATIONS, latestVersion } from "./migrations/index.js";
|
||||
import { getMetaSync, setMetaSync } from "./helpers/metaStore.js";
|
||||
import { makeBackupDir, backupFile, pruneOldBackups } from "./backup.js";
|
||||
import { makeBackupDir, backupFile, backupDbLite, pruneOldBackups } from "./backup.js";
|
||||
import { getAppVersion } from "./version.js";
|
||||
import { stringifyJson } from "./helpers/jsonCol.js";
|
||||
|
||||
@@ -221,12 +221,37 @@ export async function runMigrationOnce(adapter) {
|
||||
// a brand-new DB as non-fresh once schemaVersion is written).
|
||||
const fresh = isFreshDb(adapter);
|
||||
|
||||
// Prune stale backups every boot so old oversized backups shrink to KEEP.
|
||||
pruneOldBackups();
|
||||
|
||||
// Bootstrap _meta so we can read the stored backup schema version below
|
||||
// (runVersionedMigrations also ensures this, but we need it earlier here).
|
||||
adapter.exec(buildCreateTableSql("_meta", TABLES._meta));
|
||||
|
||||
// Detect a pending schema change via the central SCHEMA_VERSION const.
|
||||
// A lightweight backup is taken BEFORE any schema mutation below.
|
||||
const storedSchemaVer = parseInt(getMetaSync(adapter, "backupSchemaVersion", "0"), 10) || 0;
|
||||
const schemaChanging = !fresh && storedSchemaVer < SCHEMA_VERSION;
|
||||
if (schemaChanging) {
|
||||
try {
|
||||
const backupDir = makeBackupDir(`schema-${storedSchemaVer}-to-${SCHEMA_VERSION}`);
|
||||
backupDbLite(adapter, backupDir);
|
||||
pruneOldBackups();
|
||||
console.log(`[DB][migrate] pre-schema backup ${storedSchemaVer} → ${SCHEMA_VERSION}: ${backupDir}`);
|
||||
} catch (e) {
|
||||
console.warn(`[DB][migrate] pre-schema backup failed (continuing): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Always run versioned migrations chain (skip-version safe)
|
||||
const migInfo = runVersionedMigrations(adapter);
|
||||
|
||||
// 2. Additive sync (auto add missing columns/indexes declared in TABLES)
|
||||
syncSchemaFromTables(adapter);
|
||||
|
||||
// Stamp the schema version we just reached so future boots skip re-backup.
|
||||
setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION);
|
||||
|
||||
// 3. One-time legacy JSON import (only if DB was fresh on entry)
|
||||
const alreadyImported = fs.existsSync(MIGRATED_MARKER);
|
||||
const legacyMain = readJsonSafe(LEGACY_FILES.main);
|
||||
@@ -247,6 +272,7 @@ export async function runMigrationOnce(adapter) {
|
||||
importLegacyDisabled(adapter, legacyDisabled);
|
||||
importLegacyDetails(adapter, legacyDetails);
|
||||
setMetaSync(adapter, "appVersion", getAppVersion());
|
||||
setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION);
|
||||
setMetaSync(adapter, "migratedAt", new Date().toISOString());
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -263,24 +289,9 @@ export async function runMigrationOnce(adapter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fresh) {
|
||||
setMetaSync(adapter, "appVersion", getAppVersion());
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. App version bump → backup data.sqlite (safety net before user-side upgrade)
|
||||
const oldVer = getMetaSync(adapter, "appVersion", null);
|
||||
// Track app version for informational purposes only. App version bumps no
|
||||
// longer trigger a DB backup — only real schema changes (SCHEMA_VERSION) do.
|
||||
const newVer = getAppVersion();
|
||||
if (oldVer && oldVer !== newVer) {
|
||||
const backupDir = makeBackupDir(`upgrade-${oldVer}-to-${newVer}`);
|
||||
try { backupFile(DATA_FILE, backupDir); } catch {}
|
||||
setMetaSync(adapter, "appVersion", newVer);
|
||||
pruneOldBackups();
|
||||
console.log(`[DB][migrate] App ${oldVer} → ${newVer} | schema ${migInfo.from} → ${migInfo.to} | backup: ${backupDir}`);
|
||||
} else if (migInfo.applied > 0) {
|
||||
// Schema upgrade without app version bump — still backup
|
||||
const backupDir = makeBackupDir(`schema-${migInfo.from}-to-${migInfo.to}`);
|
||||
try { backupFile(DATA_FILE, backupDir); } catch {}
|
||||
pruneOldBackups();
|
||||
}
|
||||
const oldVer = getMetaSync(adapter, "appVersion", null);
|
||||
if (oldVer !== newVer) setMetaSync(adapter, "appVersion", newVer);
|
||||
}
|
||||
|
||||
@@ -174,6 +174,12 @@ export async function getRequestDetails(filter = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDistinctProviders() {
|
||||
const db = await getAdapter();
|
||||
const rows = db.all(`SELECT DISTINCT provider FROM requestDetails WHERE provider IS NOT NULL ORDER BY provider ASC`);
|
||||
return rows.map((r) => r.provider);
|
||||
}
|
||||
|
||||
export async function getRequestDetailById(id) {
|
||||
const db = await getAdapter();
|
||||
const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Latest schema version — bumped when a migration is added in ./migrations/
|
||||
// ⚠️ AGENT/DEV: Bump this by +1 EVERY TIME you change the schema below
|
||||
// (add/remove/alter a table, column, or index in TABLES). It drives the
|
||||
// pre-change safety backup in migrate.js: when the stored version is lower,
|
||||
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
||||
// to bump only skips that backup — it does NOT break the additive auto-sync.
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
export const PRAGMA_SQL = `
|
||||
|
||||
@@ -153,6 +153,20 @@ function unregisterSession(name, sid) {
|
||||
const entry = getStore().get(name);
|
||||
if (!entry) return;
|
||||
entry.sessions.delete(sid);
|
||||
// No sessions left → kill child to avoid idle orphan process leak.
|
||||
if (entry.sessions.size === 0) {
|
||||
try { entry.proc.kill(); } catch { /* ignore */ }
|
||||
getStore().delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Kill all spawned MCP children — called on app shutdown to prevent orphans.
|
||||
function killAllBridges() {
|
||||
const store = getStore();
|
||||
for (const [name, entry] of store) {
|
||||
try { entry.proc.kill(); } catch { /* ignore */ }
|
||||
store.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
function sendToChild(name, jsonRpc) {
|
||||
@@ -166,4 +180,4 @@ function isRunning(name) {
|
||||
return !!(entry?.proc && !entry.proc.killed && entry.proc.exitCode === null);
|
||||
}
|
||||
|
||||
module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin };
|
||||
module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin, killAllBridges };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
|
||||
export {
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById,
|
||||
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
|
||||
} from "@/lib/db/index.js";
|
||||
|
||||
@@ -101,9 +101,9 @@ export const CLI_TOOLS = {
|
||||
modelAliases: ["default", "sonnet", "opus", "fable", "haiku", "opusplan"],
|
||||
settingsFile: "~/.claude/settings.json",
|
||||
defaultModels: [
|
||||
{ id: "fable", name: "Claude Fable", alias: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", defaultValue: "cc/claude-fable-5" },
|
||||
{ id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-8" },
|
||||
{ id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-5" },
|
||||
{ id: "fable", name: "Claude Fable", alias: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", defaultValue: "cc/claude-fable-5" },
|
||||
{ id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
|
||||
import { startQuotaAutoPing } from "@/shared/services/quotaAutoPing";
|
||||
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
|
||||
import { killAllBridges } from "@/lib/mcp/stdioSseBridge";
|
||||
|
||||
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
|
||||
(function bootstrapMitm() {
|
||||
@@ -56,6 +57,7 @@ export async function initializeApp() {
|
||||
if (!g.signalHandlersRegistered) {
|
||||
const cleanup = () => {
|
||||
try { removeAllDNSEntriesSync(); } catch { /* best effort */ }
|
||||
try { killAllBridges(); } catch { /* best effort */ }
|
||||
killCloudflared();
|
||||
process.exit();
|
||||
};
|
||||
|
||||
252
tests/unit/request-details-tab.test.js
Normal file
252
tests/unit/request-details-tab.test.js
Normal file
@@ -0,0 +1,252 @@
|
||||
// Backend logic behind /dashboard/usage?tab=details.
|
||||
// Covers crash-risk edge cases in getRequestDetails() used by
|
||||
// /api/usage/request-details and /api/usage/providers.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
let tempDir;
|
||||
let db;
|
||||
let adapter;
|
||||
|
||||
async function saveDetail(detail) {
|
||||
await db.saveRequestDetail(detail);
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-details-tab-"));
|
||||
process.env.DATA_DIR = tempDir;
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/db/index.js");
|
||||
await db.initDb();
|
||||
await db.updateSettings({ enableObservability2: true, observabilityBatchSize: 1 });
|
||||
|
||||
const { getAdapter } = await import("@/lib/db/driver.js");
|
||||
adapter = await getAdapter();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
describe("request details — tab crash-risk cases", () => {
|
||||
it("corrupt data column → parseJson fallback {}, no throw", async () => {
|
||||
// Inject a row with invalid JSON directly, bypassing save path
|
||||
adapter.run(
|
||||
`INSERT INTO requestDetails(id, timestamp, provider, model, connectionId, status, data) VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||
["corrupt-1", new Date().toISOString(), "openai", "gpt-4", null, "ok", "{not-valid-json"]
|
||||
);
|
||||
|
||||
const res = await db.getRequestDetails({ provider: "openai" });
|
||||
expect(Array.isArray(res.details)).toBe(true);
|
||||
const corrupt = res.details.find((d) => Object.keys(d).length === 0);
|
||||
expect(corrupt).toEqual({});
|
||||
});
|
||||
|
||||
it("pagination beyond last page → empty details, valid meta", async () => {
|
||||
const res = await db.getRequestDetails({ page: 9999, pageSize: 20 });
|
||||
expect(res.details).toEqual([]);
|
||||
expect(res.pagination.page).toBe(9999);
|
||||
expect(res.pagination.hasNext).toBe(false);
|
||||
expect(res.pagination.totalItems).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("invalid startDate → Invalid Date ISO throws inside getRequestDetails is caught upstream", async () => {
|
||||
// new Date("bad").toISOString() throws RangeError; verify it surfaces
|
||||
// so the API route's try/catch returns 500 rather than silent corruption.
|
||||
await expect(db.getRequestDetails({ startDate: "not-a-date" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("valid date filter range → no throw", async () => {
|
||||
const res = await db.getRequestDetails({
|
||||
startDate: "2020-01-01T00:00:00",
|
||||
endDate: "2999-01-01T00:00:00",
|
||||
});
|
||||
expect(Array.isArray(res.details)).toBe(true);
|
||||
});
|
||||
|
||||
it("large pageSize (providers route uses 9999) → returns all, no crash", async () => {
|
||||
await saveDetail({
|
||||
id: "big-1", provider: "anthropic", model: "claude-3",
|
||||
status: "ok", tokens: { input_tokens: 5 },
|
||||
request: { method: "POST" }, response: { content: "hi" },
|
||||
});
|
||||
|
||||
const res = await db.getRequestDetails({ pageSize: 9999 });
|
||||
expect(res.details.length).toBeGreaterThanOrEqual(1);
|
||||
expect(res.pagination.pageSize).toBe(9999);
|
||||
});
|
||||
|
||||
it("oversized field → stored truncated + reparseable (no circular)", async () => {
|
||||
const huge = "x".repeat(20 * 1024);
|
||||
await saveDetail({
|
||||
id: "trunc-1", provider: "openai", model: "gpt-4",
|
||||
status: "ok", tokens: {},
|
||||
request: { blob: huge }, response: { content: "ok" },
|
||||
});
|
||||
|
||||
const got = await db.getRequestDetailById("trunc-1");
|
||||
expect(got).toBeDefined();
|
||||
// Truncated field is a plain object safe for JSON.stringify in the drawer
|
||||
expect(() => JSON.stringify(got)).not.toThrow();
|
||||
expect(got.request._truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("missing tokens/timestamp on row → getInputTokens-style access safe", async () => {
|
||||
adapter.run(
|
||||
`INSERT INTO requestDetails(id, timestamp, provider, model, connectionId, status, data) VALUES(?, ?, ?, ?, ?, ?, ?)`,
|
||||
["sparse-1", new Date().toISOString(), "openai", null, null, null, JSON.stringify({ id: "sparse-1" })]
|
||||
);
|
||||
const got = await db.getRequestDetailById("sparse-1");
|
||||
expect(got.tokens).toBeUndefined();
|
||||
// Drawer reads tokens?.prompt_tokens — optional chaining tolerates undefined
|
||||
expect(got.tokens?.prompt_tokens || 0).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Mirror of RequestDetailsTab token helpers (component is "use client",
|
||||
// helpers are not exported). Keep in sync with the component.
|
||||
function getCachedTokens(tokens) {
|
||||
return tokens?.cached_tokens || tokens?.cache_read_input_tokens || 0;
|
||||
}
|
||||
function getCacheCreationTokens(tokens) {
|
||||
return tokens?.cache_creation_input_tokens || 0;
|
||||
}
|
||||
function getInputTokens(tokens) {
|
||||
const prompt = tokens?.prompt_tokens || tokens?.input_tokens || 0;
|
||||
const cache = getCachedTokens(tokens);
|
||||
return prompt < cache ? cache : prompt;
|
||||
}
|
||||
|
||||
describe("backupDbLite — excludes requestDetails, keeps critical data", () => {
|
||||
it("backup file omits requestDetails rows but keeps other tables", async () => {
|
||||
const { backupDbLite } = await import("@/lib/db/backup.js");
|
||||
await saveDetail({ id: "bk-1", provider: "openai", model: "m", status: "ok", tokens: {}, request: {}, response: {} });
|
||||
|
||||
const backupDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-bklite-"));
|
||||
const dest = backupDbLite(adapter, backupDir);
|
||||
expect(fs.existsSync(dest)).toBe(true);
|
||||
|
||||
// Open backup and assert requestDetails is empty, settings present
|
||||
const Database = (await import("better-sqlite3")).default;
|
||||
const bak = new Database(dest);
|
||||
try {
|
||||
// requestDetails is fully excluded — table must not exist in the backup
|
||||
const rdTable = bak.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='requestDetails'").get();
|
||||
expect(rdTable).toBeUndefined();
|
||||
// Critical data preserved
|
||||
const st = bak.prepare("SELECT COUNT(*) c FROM settings").get();
|
||||
expect(st.c).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
bak.close();
|
||||
fs.rmSync(backupDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDistinctProviders — providers route (no full-row parse)", () => {
|
||||
it("returns unique provider list without parsing data blobs", async () => {
|
||||
await saveDetail({ id: "dp-1", provider: "openai", model: "m", status: "ok", tokens: {}, request: {}, response: {} });
|
||||
await saveDetail({ id: "dp-2", provider: "anthropic", model: "m", status: "ok", tokens: {}, request: {}, response: {} });
|
||||
await saveDetail({ id: "dp-3", provider: "openai", model: "m", status: "ok", tokens: {}, request: {}, response: {} });
|
||||
|
||||
const list = await db.getDistinctProviders();
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
expect(list).toContain("openai");
|
||||
expect(list).toContain("anthropic");
|
||||
// No duplicates
|
||||
expect(new Set(list).size).toBe(list.length);
|
||||
});
|
||||
|
||||
it("skips null providers, returns sorted", async () => {
|
||||
const list = await db.getDistinctProviders();
|
||||
expect(list.every((p) => p !== null)).toBe(true);
|
||||
const sorted = [...list].sort();
|
||||
expect(list).toEqual(sorted);
|
||||
});
|
||||
});
|
||||
|
||||
describe("token helpers — render-time crash safety", () => {
|
||||
it("undefined/null tokens → 0, no throw", () => {
|
||||
expect(getInputTokens(undefined)).toBe(0);
|
||||
expect(getInputTokens(null)).toBe(0);
|
||||
expect(getCachedTokens(undefined)).toBe(0);
|
||||
expect(getCacheCreationTokens(null)).toBe(0);
|
||||
});
|
||||
|
||||
it("empty object → 0 across all helpers", () => {
|
||||
expect(getInputTokens({})).toBe(0);
|
||||
expect(getCachedTokens({})).toBe(0);
|
||||
expect(getCacheCreationTokens({})).toBe(0);
|
||||
});
|
||||
|
||||
it("prompt_tokens preferred, falls back to input_tokens", () => {
|
||||
expect(getInputTokens({ prompt_tokens: 100 })).toBe(100);
|
||||
expect(getInputTokens({ input_tokens: 50 })).toBe(50);
|
||||
});
|
||||
|
||||
it("legacy Claude row (prompt < cache) → returns cache", () => {
|
||||
expect(getInputTokens({ prompt_tokens: 10, cached_tokens: 200 })).toBe(200);
|
||||
});
|
||||
|
||||
it("cached via cache_read_input_tokens alias", () => {
|
||||
expect(getCachedTokens({ cache_read_input_tokens: 42 })).toBe(42);
|
||||
});
|
||||
|
||||
it("toLocaleString on helper result never throws", () => {
|
||||
expect(() => getInputTokens(undefined).toLocaleString()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("API route contract — validation boundary", () => {
|
||||
let GET;
|
||||
beforeAll(async () => {
|
||||
({ GET } = await import("@/app/api/usage/request-details/route.js"));
|
||||
});
|
||||
|
||||
function makeReq(query) {
|
||||
return new Request(`http://localhost/api/usage/request-details?${query}`);
|
||||
}
|
||||
|
||||
it("page=0 → 400 (guard now reachable after NaN-check fix)", async () => {
|
||||
const res = await GET(makeReq("page=0"));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("page=-5 → 400", async () => {
|
||||
const res = await GET(makeReq("page=-5"));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("pageSize=101 → 400", async () => {
|
||||
const res = await GET(makeReq("pageSize=101"));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("pageSize=abc (NaN) → defaults to 20, returns 200", async () => {
|
||||
const res = await GET(makeReq("pageSize=abc"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.pagination.pageSize).toBe(20);
|
||||
});
|
||||
|
||||
it("invalid startDate → route catches, returns 500 (not thrown)", async () => {
|
||||
const res = await GET(makeReq("startDate=not-a-date"));
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("valid request → 200 with details + pagination shape", async () => {
|
||||
const res = await GET(makeReq("page=1&pageSize=20"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body.details)).toBe(true);
|
||||
expect(body.pagination).toMatchObject({ page: 1, pageSize: 20 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user