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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- **refactor(db): move `community_servers` auth look-up into `gamification` db module** ([#3500]): extracted raw SQL from two federation route handlers (`/api/gamification/federation/leaderboard`, `/api/gamification/federation/score`) into a new `getConnectedServerByKeyHash(apiKeyHash)` function in `src/lib/db/gamification.ts`. Third slice of #3500 (gamification federation cluster). Behavior unchanged; the two routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (3 assertions seeding a temp SQLite fixture).

- **refactor(db): move `skills UPDATE` + `db-backups` SQL into db modules** ([#3500]): fifth slice of #3500. Extracted the dynamic `UPDATE skills SET …` from `src/app/api/skills/[id]/route.ts` into a new `src/lib/db/skills.ts` module (`updateSkill(id, patch)`). The dynamic SET clause is injection-safe: column names are validated against a hard-coded allowlist of known writable columns before being interpolated; unknown keys are silently ignored. Extended `src/lib/db/backup.ts` with three new functions: `exportAllSummaryRows()` (multi-table SELECT for key_value / combos / provider_connections / api_keys, used by exportAll), `getTableNamesFromAdapter()` (sqlite_master introspection via an adapter arg, used by import validation), and `countImportedRows()` (post-import COUNT(*) per table). The backup domain module is the correct home for sqlite_master introspection — it is not "raw SQL in a route" once moved there. `KNOWN_RAW_SQL` drops by 3 (from 8 → 5). Validated with 11 TDD unit tests (`tests/unit/db-backups-skills-3500.test.ts`).
- **refactor(db): move `usage_logs`/`semantic_cache`/`proxy_logs` SQL into db modules** ([#3500]): extracted raw `db.prepare(...)` SQL from three route handlers (`/api/analytics/auto-routing` → `usageLogs.ts`; `/api/cache/entries` → `semanticCache.ts`; `/api/logs/export` → `proxyLogs.ts`) into new `src/lib/db/` domain modules. New exports: `getAutoRoutingTotalCount`, `getAutoRoutingVariantBreakdown`, `getAutoRoutingTopProviders` (usage_logs), `listSemanticCacheEntries`, `deleteSemanticCacheBySignature`, `deleteSemanticCacheByModel` (semantic_cache), and `exportProxyLogsSince` (proxy_logs). Fourth slice of #3500. `KNOWN_RAW_SQL` drops from 8 → 5. Validated with 13 TDD unit tests (`tests/unit/db-logs-cache-3500.test.ts`) seeding temp SQLite fixtures.

- **Provider-detail god-component decomposition — Phase 0** ([#3501]): introduced `ProviderDetailPageClient.tsx` and reduced `providers/[id]/page.tsx` to a thin 9-line route wrapper (was 12,882 LOC), following the repo's `*PageClient` convention. Added the first-ever smoke render test for the page (Hard Rule #8) as the safety net every later extraction phase is diffed against. Behavior unchanged; the `check-file-size` ratchet now tracks the extracted client. Foundation for Phases 1–6 (strangler-fig). Thanks @oyi77 for the parallel modularization effort in #3627.
- **Provider-detail god-component decomposition — Phase 1a** ([#3501]): extracted the three self-contained auth-import modal clusters (Codex/Claude/Gemini `Import*AuthModal` + `Apply*AuthModal` + their co-located helpers, ~2,160 LOC) into `providers/[id]/components/modals/`. `ProviderDetailPageClient.tsx` drops 12,882 → 10,719 LOC. Behavior unchanged (smoke test green; clusters had clean `{ onClose, onSuccess }` / inline-prop interfaces). Co-authored with @oyi77.
Expand Down
3 changes: 0 additions & 3 deletions scripts/check/check-db-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,6 @@ export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL;
// adicione novos aqui sem justificativa — crie/estenda um módulo db/ em vez disso.
// (Chaves = caminho relativo POSIX a partir da raiz do repo.)
const KNOWN_RAW_SQL = new Set([
"src/app/api/analytics/auto-routing/route.ts", // SELECT … FROM usage_logs
"src/app/api/cache/entries/route.ts", // semantic_cache COUNT/DELETE inline
"src/app/api/logs/export/route.ts", // SELECT de proxy_logs
"src/app/api/oauth/cursor/auto-import/route.ts", // SELECT no itemTable do Cursor (DB externo)
"src/app/api/oauth/kiro/auto-import/route.ts", // SELECT no SQLite do Kiro (DB externo)
]);
Expand Down
49 changes: 8 additions & 41 deletions src/app/api/analytics/auto-routing/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { getDbInstance } from "@/lib/db/core";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import {
getAutoRoutingTotalCount,
getAutoRoutingVariantBreakdown,
getAutoRoutingTopProviders,
} from "@/lib/db/usageLogs";

export const dynamic = "force-dynamic";

Expand All @@ -12,56 +16,19 @@ export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const db = getDbInstance();

// Query usage_logs for auto/ prefix requests
const totalRequests = db
.prepare(
`
SELECT COUNT(*) as count
FROM usage_logs
WHERE model = 'auto' OR model LIKE 'auto/%'
`
)
.get() as { count: number };
const totalRequests = getAutoRoutingTotalCount();

// Variant breakdown
const variantRows = db
.prepare(
`
SELECT
CASE
WHEN model = 'auto' THEN 'default'
WHEN model LIKE 'auto/%' THEN SUBSTR(model, 6)
ELSE 'other'
END as variant,
COUNT(*) as count
FROM usage_logs
WHERE model = 'auto' OR model LIKE 'auto/%'
GROUP BY variant
ORDER BY count DESC
`
)
.all() as Array<{ variant: string; count: number }>;
const variantRows = getAutoRoutingVariantBreakdown();

const variantBreakdown: Record<string, number> = {};
variantRows.forEach((row) => {
variantBreakdown[row.variant] = row.count;
});

// Top providers (from LKGP cache or usage logs)
const topProviders = db
.prepare(
`
SELECT provider, COUNT(*) as count
FROM usage_logs
WHERE model = 'auto' OR model LIKE 'auto/%'
GROUP BY provider
ORDER BY count DESC
LIMIT 10
`
)
.all() as Array<{ provider: string; count: number }>;
const topProviders = getAutoRoutingTopProviders();

return NextResponse.json({
totalRequests: totalRequests.count,
Expand Down
65 changes: 12 additions & 53 deletions src/app/api/cache/entries/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { getDbInstance } from "@/lib/db/core";
import { isAuthenticated } from "@/shared/utils/apiAuth";

interface CacheEntry {
id: string;
signature: string;
model: string;
hit_count: number;
tokens_saved: number;
created_at: string;
expires_at: string;
}
import {
listSemanticCacheEntries,
deleteSemanticCacheBySignature,
deleteSemanticCacheByModel,
} from "@/lib/db/semanticCache";

export async function GET(req: NextRequest) {
if (!(await isAuthenticated(req))) {
Expand All @@ -26,48 +20,15 @@ export async function GET(req: NextRequest) {
const sortBy = searchParams.get("sortBy") || "created_at";
const sortOrder = searchParams.get("sortOrder") || "desc";

const db = getDbInstance();
const offset = (page - 1) * limit;

const conditions: string[] = [];
const params: unknown[] = [];

if (search) {
conditions.push("(signature LIKE ? OR model LIKE ?)");
params.push(`%${search}%`, `%${search}%`);
}

if (model) {
conditions.push("model = ?");
params.push(model);
}

const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";

const validSortColumns = ["created_at", "expires_at", "hit_count", "tokens_saved", "model"];
const orderBy = validSortColumns.includes(sortBy) ? sortBy : "created_at";
const order = sortOrder === "asc" ? "ASC" : "DESC";

const countRow = db
.prepare(`SELECT COUNT(*) as total FROM semantic_cache ${whereClause}`)
.get(...params) as { total: number };

const entries = db
.prepare(
`SELECT id, signature, model, hit_count, tokens_saved, created_at, expires_at
FROM semantic_cache ${whereClause}
ORDER BY ${orderBy} ${order}
LIMIT ? OFFSET ?`
)
.all(...params, limit, offset) as CacheEntry[];
const { entries, total } = listSemanticCacheEntries({ page, limit, search, model, sortBy, sortOrder });

return NextResponse.json({
entries,
pagination: {
page,
limit,
total: countRow?.total || 0,
totalPages: Math.ceil((countRow?.total || 0) / limit),
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
Expand All @@ -85,16 +46,14 @@ export async function DELETE(req: NextRequest) {
const signature = searchParams.get("signature");
const model = searchParams.get("model");

const db = getDbInstance();

if (signature) {
db.prepare("DELETE FROM semantic_cache WHERE signature = ?").run(signature);
return NextResponse.json({ ok: true, deleted: 1 });
const { deleted } = deleteSemanticCacheBySignature(signature);
return NextResponse.json({ ok: true, deleted });
}

if (model) {
const result = db.prepare("DELETE FROM semantic_cache WHERE model = ?").run(model);
return NextResponse.json({ ok: true, deleted: result.changes });
const { deleted } = deleteSemanticCacheByModel(model);
return NextResponse.json({ ok: true, deleted });
}

return NextResponse.json({ error: "Provide signature or model parameter" }, { status: 400 });
Expand Down
10 changes: 3 additions & 7 deletions src/app/api/logs/export/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getDbInstance } from "@/lib/db/core";
import { exportCallLogsSince } from "@/lib/usage/callLogs";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { exportProxyLogsSince } from "@/lib/db/proxyLogs";

/**
* GET /api/logs/export — export logs as JSON
Expand All @@ -17,7 +17,6 @@ export async function GET(request: Request) {
const logType = searchParams.get("type") || "call-logs";

const since = new Date(Date.now() - hours * 3600 * 1000).toISOString();
const db = getDbInstance();

let rows: unknown[] = [];
let tableName = "";
Expand All @@ -27,14 +26,11 @@ export async function GET(request: Request) {
rows = await exportCallLogsSince(since);
} else if (logType === "proxy-logs") {
tableName = "proxy_logs";
// NOTE: raw SELECT * returns the historical `public_ip` column, NOT `clientIp`.
// NOTE: exportProxyLogsSince returns the historical `public_ip` column, NOT `clientIp`.
// This intentionally differs from GET /api/usage/proxy-logs which exposes the
// value as `clientIp`. Callers of this export endpoint should read `public_ip`.
// This inconsistency will be resolved in a future DB migration (#2880).
const stmt = db.prepare(
"SELECT * FROM proxy_logs WHERE timestamp >= @since ORDER BY timestamp DESC"
);
rows = stmt.all({ since });
rows = exportProxyLogsSince(since);
}

const filename = `omniroute-${tableName}-${hours}h-${new Date().toISOString().slice(0, 10)}.json`;
Expand Down
35 changes: 35 additions & 0 deletions src/lib/db/proxyLogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* db/proxyLogs.ts — Read queries over the `proxy_logs` table.
* Extracted from the /api/logs/export route handler.
*
* Hard Rule #5: routes must not embed raw SQL — these queries live here so the
* /api/logs/export route can delegate.
*
* NOTE: The SELECT * intentionally returns the historical `public_ip` column,
* NOT `clientIp`. This differs from GET /api/usage/proxy-logs which exposes
* the value as `clientIp`. Callers of the export endpoint should read
* `public_ip`. This inconsistency will be resolved in a future DB migration
* (#2880).
*
* Sliced out of #3500 (proxy_logs cluster, slice 4).
*/

import { getDbInstance } from "./core";

// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------

/**
* Returns all proxy_logs rows with timestamp >= `since`, ordered by timestamp
* descending (most recent first).
*
* @param since - ISO-8601 timestamp lower bound, e.g. "2024-01-01T00:00:00.000Z".
*/
export function exportProxyLogsSince(since: string): Record<string, unknown>[] {
const db = getDbInstance();
const stmt = db.prepare(
"SELECT * FROM proxy_logs WHERE timestamp >= @since ORDER BY timestamp DESC"
);
return stmt.all({ since }) as Record<string, unknown>[];
}
117 changes: 117 additions & 0 deletions src/lib/db/semanticCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* db/semanticCache.ts — CRUD queries over the `semantic_cache` table.
* Extracted from the /api/cache/entries route handler.
*
* Hard Rule #5: routes must not embed raw SQL — these queries live here so the
* /api/cache/entries route can delegate.
*
* Sliced out of #3500 (semantic_cache cluster, slice 4).
*/

import { getDbInstance } from "./core";

// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------

export interface SemanticCacheEntry {
id: string;
signature: string;
model: string;
hit_count: number;
tokens_saved: number;
created_at: string;
expires_at: string;
}

export interface SemanticCacheListOptions {
page: number;
limit: number;
search: string;
model: string;
sortBy: string;
sortOrder: string;
}

export interface SemanticCacheListResult {
entries: SemanticCacheEntry[];
total: number;
}

// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------

const VALID_SORT_COLUMNS = ["created_at", "expires_at", "hit_count", "tokens_saved", "model"];

/**
* Returns a paginated, filtered, sorted list of semantic cache entries.
* All dynamic inputs (sortBy/sortOrder) are validated before use.
*/
export function listSemanticCacheEntries(opts: SemanticCacheListOptions): SemanticCacheListResult {
const db = getDbInstance();
const { page, limit, search, model, sortBy, sortOrder } = opts;
const offset = (page - 1) * limit;
Comment on lines +53 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If opts.page or opts.limit are parsed as NaN or negative values at runtime, it can lead to invalid SQL queries or unexpected offset calculations. Adding defensive guards using Math.max and isNaN checks ensures the pagination parameters are always valid positive integers.

Suggested change
const { page, limit, search, model, sortBy, sortOrder } = opts;
const offset = (page - 1) * limit;
const page = Math.max(1, isNaN(opts.page) ? 1 : opts.page);
const limit = Math.max(1, isNaN(opts.limit) ? 10 : opts.limit);
const { search, model, sortBy, sortOrder } = opts;
const offset = (page - 1) * limit;


const conditions: string[] = [];
const params: unknown[] = [];

if (search) {
conditions.push("(signature LIKE ? OR model LIKE ?)");
params.push(`%${search}%`, `%${search}%`);
}

if (model) {
conditions.push("model = ?");
params.push(model);
}

const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const orderBy = VALID_SORT_COLUMNS.includes(sortBy) ? sortBy : "created_at";
const order = sortOrder === "asc" ? "ASC" : "DESC";

const countRow = db
.prepare(`SELECT COUNT(*) as total FROM semantic_cache ${whereClause}`)
.get(...params) as { total: number };

const entries = db
.prepare(
`SELECT id, signature, model, hit_count, tokens_saved, created_at, expires_at
FROM semantic_cache ${whereClause}
ORDER BY ${orderBy} ${order}
LIMIT ? OFFSET ?`
)
.all(...params, limit, offset) as SemanticCacheEntry[];

return { entries, total: countRow?.total || 0 };
}

export interface DeleteSemanticCacheBySignatureResult {
deleted: number;
}

/**
* Deletes the single semantic cache entry matching the given signature.
* Returns `{ deleted: 1 }` on success.
*/
export function deleteSemanticCacheBySignature(
signature: string
): DeleteSemanticCacheBySignatureResult {
const db = getDbInstance();
db.prepare("DELETE FROM semantic_cache WHERE signature = ?").run(signature);
return { deleted: 1 };
Comment on lines +101 to +102

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In deleteSemanticCacheBySignature, the function currently hardcodes deleted: 1 in the return statement. To ensure accuracy and consistency with deleteSemanticCacheByModel, we should use the actual number of deleted rows returned by result.changes from the SQLite execution.

Suggested change
db.prepare("DELETE FROM semantic_cache WHERE signature = ?").run(signature);
return { deleted: 1 };
const result = db.prepare("DELETE FROM semantic_cache WHERE signature = ?").run(signature);
return { deleted: result.changes };

}

export interface DeleteSemanticCacheByModelResult {
deleted: number;
}

/**
* Deletes all semantic cache entries for the given model.
* Returns `{ deleted: N }` where N is the number of rows removed.
*/
export function deleteSemanticCacheByModel(model: string): DeleteSemanticCacheByModelResult {
const db = getDbInstance();
const result = db.prepare("DELETE FROM semantic_cache WHERE model = ?").run(model);
return { deleted: result.changes };
}
Loading