diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 776c2d8a..d0ec0889 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -41,7 +41,7 @@ export const settingsRouter = Router(); settingsRouter.get('/', authMiddleware, async (_req: Request, res: Response): Promise => { try { - const settings = DatabaseService.getInstance().getGlobalSettings(); + const settings = { ...DatabaseService.getInstance().getGlobalSettings() }; for (const key of PRIVATE_SETTINGS_KEYS) { delete settings[key]; } diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 6f0587c2..ed10aa99 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -470,6 +470,13 @@ export interface ScanSummary { export class DatabaseService { private static instance: DatabaseService; private db: Database.Database; + // Cache of the global_settings table, populated on first read and + // invalidated by updateGlobalSetting(). Hot paths (auth middleware, + // WS upgrade, the audit-log debug gate) read this on every request, + // so the round-trip to SQLite is worth eliminating. Assumes this + // process is the sole writer to global_settings; sidecar tools that + // edit the row directly will not invalidate the cache. + private cachedGlobalSettings: Readonly> | null = null; private constructor() { const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data'); @@ -497,6 +504,11 @@ export class DatabaseService { this.migrateAgentsAndNotificationsNodeId(); this.migratePolicyEvaluationColumn(); this.migrateNotificationCategory(); + + // Reset the cache once at end of constructor in case any migration + // populated it via getGlobalSettings() and a subsequent migration + // changed the underlying rows. + this.cachedGlobalSettings = null; } public static getInstance(): DatabaseService { @@ -1330,18 +1342,20 @@ export class DatabaseService { // --- Global Settings --- - public getGlobalSettings(): Record { + public getGlobalSettings(): Readonly> { + if (this.cachedGlobalSettings) return this.cachedGlobalSettings; const stmt = this.db.prepare('SELECT * FROM global_settings'); + const rows = stmt.all() as Array<{ key: string; value: string }>; const settings: Record = {}; - stmt.all().forEach((row: any) => { - settings[row.key] = row.value; - }); - return settings; + for (const row of rows) settings[row.key] = row.value; + this.cachedGlobalSettings = Object.freeze(settings); + return this.cachedGlobalSettings; } public updateGlobalSetting(key: string, value: string): void { const stmt = this.db.prepare('INSERT OR REPLACE INTO global_settings (key, value) VALUES (?, ?)'); stmt.run(key, value); + this.cachedGlobalSettings = null; } // --- System State (operational/runtime values - not user-defined config) --- diff --git a/backend/src/utils/debug.ts b/backend/src/utils/debug.ts index 3fb2762a..2317d3b2 100644 --- a/backend/src/utils/debug.ts +++ b/backend/src/utils/debug.ts @@ -1,30 +1,18 @@ /** - * Shared diagnostic logging gate. - * - * Reads `developer_mode` from the global settings, cached for a short - * window so hot paths (per-request, per-log-line) do not hit SQLite - * on every call. + * Shared diagnostic logging gate. Reads `developer_mode` from + * DatabaseService, which caches the global_settings snapshot internally + * and invalidates on write, so hot-path callers can query freely. */ -let cachedValue = false; -let cacheExpiry = 0; -const CACHE_TTL_MS = 5_000; - export function isDebugEnabled(): boolean { - const now = Date.now(); - if (now < cacheExpiry) return cachedValue; - try { // Dynamic require avoids circular-dependency issues when this // utility is imported from services that DatabaseService itself // depends on, and prevents SQLite side effects during tests. // eslint-disable-next-line @typescript-eslint/no-require-imports const { DatabaseService } = require('../services/DatabaseService'); - cachedValue = DatabaseService.getInstance().getGlobalSettings().developer_mode === '1'; + return DatabaseService.getInstance().getGlobalSettings().developer_mode === '1'; } catch { - cachedValue = false; + return false; } - - cacheExpiry = now + CACHE_TTL_MS; - return cachedValue; }