Files
OmniRoute/src/lib/db/databaseSettings.ts
Paijo 2a865aaaa7 feat(settings): configurable model catalog cache TTL (#8219)
* feat(settings): configurable model catalog cache TTL

Add modelCatalogCacheTtlMs to DatabaseSettings with default 1500ms.
Extend cache-config API route to accept the new field.
Replace hardcoded catalog cache TTL with dynamic settings value.
Add 'Cache' settings tab at /dashboard/settings/cache with sidebar entry,
i18n keys, header description, and legacy route redirect.

* feat(combo): add model connection filter toggle to ModelSelectModal

Adds a 'Show configured only' checkbox below the search bar in
ModelSelectModal that filters each provider group's models through
hasEligibleConnectionForModel. Toggle state persists in localStorage.

- Import hasEligibleConnectionForModel from domain/connectionModelRules
- showConfiguredOnly state + localStorage persistence
- connectionFilteredGroups memo layered on filteredGroups
- Renders both provider section and empty state from connectionFilteredGroups

* test(combo): add connection filter toggle tests for ModelSelectModal

Three test cases: (1) hide excluded models when toggle on,
(2) show empty state when all models excluded,
(3) drop provider group when all its models excluded.
All 3 tests pass.

* fix(settings): correct cache-config route import + add route/tab coverage

The cache-config route imported get/update helpers from a nonexistent
module (@/lib/localDb/databaseSettings) and called an undefined
updateSettings() in PUT, crashing every request. Import the real
databaseSettings module (matching the sibling database/route.ts
convention) and call updateDatabaseSettings(); idempotencyWindowMs is
routed through the flat @/lib/db/settings module instead, since that is
where it is actually read at runtime (idempotencyLayer.ts,
runtimeSettings.ts) — it was never part of the databaseSettings "cache"
section type.

Also fixes a dashboard-typecheck regression in ModelSelectModal.tsx: the
new connection-filter toggle called hasEligibleConnectionForModel() with
activeProviders entries typed too narrowly to include
providerSpecificData, which real connection objects carry at runtime.

Adds:
- tests/unit/cache-config-route-8219.test.ts: GET/PUT resolve without
  crashing, modelCatalogCacheTtlMs and idempotencyWindowMs round-trip.
- tests/unit/ui/cache-settings-tab-bounds-8219.test.tsx: CacheSettingsTab
  min/max TTL bounds (100ms/60000ms) gate the Save button and surface a
  validation message; in-bounds values PUT correctly.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(quality): rebaseline sections.ts for #8219 own-growth

---------

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 08:46:22 -03:00

315 lines
9.8 KiB
TypeScript

import fs from "node:fs";
import { DEFAULT_DATABASE_SETTINGS, type DatabaseSettings } from "@/types/databaseSettings";
import { backupDbFile } from "./backup";
import { DATA_DIR, SQLITE_FILE, applyDatabaseOptimizationSettings, getDbInstance } from "./core";
import { invalidateDbCache } from "./readCache";
import { getDatabaseStats } from "./stats";
import { getState as getVacuumSchedulerState, refreshVacuumScheduler } from "./vacuumScheduler";
const DATABASE_SETTINGS_NAMESPACE = "databaseSettings";
export type UserDatabaseSettings = Omit<DatabaseSettings, "location" | "stats">;
type DatabaseSettingsSection = keyof UserDatabaseSettings;
const DATABASE_SETTINGS_SECTIONS = Object.keys(
DEFAULT_DATABASE_SETTINGS
) as DatabaseSettingsSection[];
const LEGACY_FLAT_KEYS: {
[TSection in DatabaseSettingsSection]: Partial<
Record<keyof UserDatabaseSettings[TSection] & string, string[]>
>;
} = {
logs: {
detailedLogsEnabled: ["detailedLogsEnabled"],
callLogPipelineEnabled: ["callLogPipelineEnabled"],
maxDetailSizeKb: ["maxDetailSizeKb"],
ringBufferSize: ["ringBufferSize"],
},
backup: {
autoBackupEnabled: ["autoBackupEnabled"],
autoBackupFrequency: ["autoBackupFrequency"],
keepLastNBackups: ["keepLastNBackups"],
},
cache: {
semanticCacheEnabled: ["semanticCacheEnabled"],
semanticCacheMaxSize: ["semanticCacheMaxSize"],
semanticCacheTTL: ["semanticCacheTTL"],
promptCacheEnabled: ["promptCacheEnabled"],
promptCacheStrategy: ["promptCacheStrategy"],
alwaysPreserveClientCache: ["alwaysPreserveClientCache"],
modelCatalogCacheTtlMs: ["modelCatalogCacheTtlMs"],
},
retention: {
quotaSnapshots: ["quotaSnapshots"],
compressionAnalytics: ["compressionAnalytics"],
mcpAudit: ["mcpAudit"],
a2aEvents: ["a2aEvents"],
callLogs: ["callLogs"],
usageHistory: ["usageHistory"],
memoryEntries: ["memoryEntries"],
domainCostHistory: ["domainCostHistory"],
compressionCacheStats: ["compressionCacheStats"],
xpAuditLog: ["xpAuditLog"],
compressionRunTelemetry: ["compressionRunTelemetry"],
autoCleanupEnabled: ["autoCleanupEnabled"],
},
aggregation: {
enabled: ["aggregationEnabled", "enabled"],
rawDataRetentionDays: ["rawDataRetentionDays"],
granularity: ["granularity"],
},
optimization: {
autoVacuumMode: ["autoVacuumMode"],
scheduledVacuum: ["scheduledVacuum"],
vacuumHour: ["vacuumHour"],
pageSize: ["pageSize"],
cacheSize: ["cacheSize"],
optimizeOnStartup: ["optimizeOnStartup"],
},
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function cloneDefaultSettings(): UserDatabaseSettings {
return structuredClone(DEFAULT_DATABASE_SETTINGS) as UserDatabaseSettings;
}
function parseStoredValue(rawValue: unknown): unknown {
if (typeof rawValue !== "string") return rawValue;
try {
return JSON.parse(rawValue);
} catch {
return rawValue;
}
}
function toBooleanSetting(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "number") return !Number.isNaN(value) && value !== 0;
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return null;
}
function normalizeOptimizationSettings(settings: UserDatabaseSettings) {
const fallback = DEFAULT_DATABASE_SETTINGS.optimization.cacheSize;
const numericCacheSize = Number(settings.optimization.cacheSize);
settings.optimization.cacheSize =
Number.isFinite(numericCacheSize) && numericCacheSize > 0
? Math.min(1000000, Math.floor(numericCacheSize))
: fallback;
}
function readNamespace(namespace: string): Record<string, unknown> {
const db = getDbInstance();
const rows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = ?")
.all(namespace) as Array<{ key: string; value: string }>;
const values: Record<string, unknown> = {};
for (const row of rows) {
values[row.key] = parseStoredValue(row.value);
}
return values;
}
function mergeSectionObject(
target: UserDatabaseSettings,
section: DatabaseSettingsSection,
value: unknown
) {
if (!isRecord(value)) return;
const sectionTarget = target[section] as Record<string, unknown>;
const defaultSection = DEFAULT_DATABASE_SETTINGS[section] as Record<string, unknown>;
for (const key of Object.keys(defaultSection)) {
if (value[key] !== undefined) {
sectionTarget[key] = value[key];
}
}
}
function mergeTopLevelSections(target: UserDatabaseSettings, values: Record<string, unknown>) {
for (const section of DATABASE_SETTINGS_SECTIONS) {
mergeSectionObject(target, section, values[section]);
}
}
function mergeRuntimeLogSettings(target: UserDatabaseSettings, values: Record<string, unknown>) {
const pipelineEnabled = toBooleanSetting(values.call_log_pipeline_enabled);
if (pipelineEnabled !== null) {
target.logs.callLogPipelineEnabled = pipelineEnabled;
}
const legacyDetailedEnabled = toBooleanSetting(values.detailed_logs_enabled);
if (legacyDetailedEnabled !== null) {
target.logs.detailedLogsEnabled = legacyDetailedEnabled;
}
}
function mergeDatabaseSettingsNamespace(
target: UserDatabaseSettings,
values: Record<string, unknown>
) {
for (const section of DATABASE_SETTINGS_SECTIONS) {
const defaultSection = DEFAULT_DATABASE_SETTINGS[section] as Record<string, unknown>;
const sectionTarget = target[section] as Record<string, unknown>;
const flatAliases = LEGACY_FLAT_KEYS[section] as Partial<Record<string, string[]>>;
for (const key of Object.keys(defaultSection)) {
for (const alias of flatAliases[key] ?? []) {
if (values[alias] !== undefined) {
sectionTarget[key] = values[alias];
}
}
const nestedKey = `${section}.${key}`;
if (values[nestedKey] !== undefined) {
sectionTarget[key] = values[nestedKey];
}
}
}
}
function getWalSizeBytes(): number {
if (!SQLITE_FILE) return 0;
try {
const walPath = `${SQLITE_FILE}-wal`;
return fs.existsSync(walPath) ? fs.statSync(walPath).size : 0;
} catch {
return 0;
}
}
function getSchemaVersion(): number {
const db = getDbInstance();
try {
const row = db
.prepare("SELECT MAX(CAST(version AS INTEGER)) AS version FROM _omniroute_migrations")
.get() as { version: number | null } | undefined;
return row?.version ?? 0;
} catch {
return 0;
}
}
function getFreelistCount(): number {
try {
return getDbInstance().pragma("freelist_count", { simple: true }) as number;
} catch {
return 0;
}
}
function getIntegrityCheck(): "ok" | "error" | null {
try {
const result = getDbInstance().pragma("quick_check", { simple: true }) as string;
return result === "ok" ? "ok" : "error";
} catch {
return null;
}
}
export function getUserDatabaseSettings(): UserDatabaseSettings {
const settings = cloneDefaultSettings();
const mainSettings = readNamespace("settings");
const databaseSettingsValue = mainSettings[DATABASE_SETTINGS_NAMESPACE];
if (isRecord(databaseSettingsValue)) {
mergeTopLevelSections(settings, databaseSettingsValue);
}
mergeTopLevelSections(settings, mainSettings);
mergeDatabaseSettingsNamespace(settings, readNamespace(DATABASE_SETTINGS_NAMESPACE));
mergeRuntimeLogSettings(settings, mainSettings);
normalizeOptimizationSettings(settings);
return settings;
}
export function getDatabaseSettings(): DatabaseSettings {
const dbStats = getDatabaseStats();
const vacuumState = getVacuumSchedulerState();
return {
...getUserDatabaseSettings(),
location: {
databasePath: SQLITE_FILE ?? ":memory:",
dataDir: DATA_DIR,
walSizeBytes: getWalSizeBytes(),
schemaVersion: getSchemaVersion(),
},
stats: {
databaseSizeBytes: dbStats.totalSize,
pageCount: dbStats.pageCount,
freelistCount: getFreelistCount(),
lastVacuumAt:
vacuumState.lastRunAt !== null ? new Date(vacuumState.lastRunAt).toISOString() : null,
lastOptimizationAt: null,
integrityCheck: getIntegrityCheck(),
},
};
}
export function updateDatabaseSettings(
updates: Partial<UserDatabaseSettings>
): UserDatabaseSettings {
const nextSettings = getUserDatabaseSettings();
const optimizationUpdated = updates.optimization !== undefined;
for (const section of DATABASE_SETTINGS_SECTIONS) {
if (updates[section] !== undefined) {
mergeSectionObject(nextSettings, section, updates[section]);
}
}
normalizeOptimizationSettings(nextSettings);
const db = getDbInstance();
const insert = db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
);
const settingsInsert = db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)"
);
const requestedLogs = updates.logs as Partial<UserDatabaseSettings["logs"]> | undefined;
const pipelineEnabled = requestedLogs?.callLogPipelineEnabled;
const detailedEnabled = requestedLogs?.detailedLogsEnabled;
const tx = db.transaction(() => {
for (const section of DATABASE_SETTINGS_SECTIONS) {
const sectionValues = nextSettings[section] as Record<string, unknown>;
for (const [key, value] of Object.entries(sectionValues)) {
insert.run(DATABASE_SETTINGS_NAMESPACE, `${section}.${key}`, JSON.stringify(value));
}
}
if (pipelineEnabled !== undefined) {
settingsInsert.run("call_log_pipeline_enabled", JSON.stringify(Boolean(pipelineEnabled)));
}
});
tx();
backupDbFile("pre-write");
invalidateDbCache("settings");
if (optimizationUpdated) {
applyDatabaseOptimizationSettings(nextSettings.optimization);
refreshVacuumScheduler();
}
return nextSettings;
}