// SQLite database schema and queries import { Database } from "bun:sqlite"; import type { Device, OutputLog, PromptData, Session } from "./types"; // Extend Prompt interface to include prompt_json field export interface Prompt { id: number; session_id: number; created_at: number; prompt_text: string; response: string | null; responded_at: number | null; prompt_json: PromptData | null; } let db: Database; // Prepared statements - initialized once in initDb() let createDeviceStmt: ReturnType; let getDeviceBySecretStmt: ReturnType; let updateLastSeenStmt: ReturnType; let createSessionStmt: ReturnType; let getSessionStmt: ReturnType; let endSessionStmt: ReturnType; let getActiveSessionsStmt: ReturnType; let createPromptStmt: ReturnType; let getPromptStmt: ReturnType; let respondToPromptStmt: ReturnType; let getPendingPromptsStmt: ReturnType; let appendOutputStmt: ReturnType; let getSessionOutputStmt: ReturnType; let updateSessionStatsStmt: ReturnType; // Migration function to add new columns to existing tables function runMigrations(): void { // Add Phase 2.3 session state and stats columns const migrations = [ // Phase 2.1: Rich prompt support "ALTER TABLE prompts ADD COLUMN prompt_json TEXT", // Stats columns "ALTER TABLE sessions ADD COLUMN state TEXT DEFAULT 'ready'", "ALTER TABLE sessions ADD COLUMN prompts INTEGER DEFAULT 0", "ALTER TABLE sessions ADD COLUMN completions INTEGER DEFAULT 0", "ALTER TABLE sessions ADD COLUMN tools INTEGER DEFAULT 0", "ALTER TABLE sessions ADD COLUMN compressions INTEGER DEFAULT 0", "ALTER TABLE sessions ADD COLUMN thinking_seconds INTEGER DEFAULT 0", "ALTER TABLE sessions ADD COLUMN work_seconds INTEGER DEFAULT 0", "ALTER TABLE sessions ADD COLUMN mode TEXT DEFAULT 'normal'", "ALTER TABLE sessions ADD COLUMN model TEXT", "ALTER TABLE sessions ADD COLUMN idle_since INTEGER", // Git columns (for later phase) "ALTER TABLE sessions ADD COLUMN git_branch TEXT", "ALTER TABLE sessions ADD COLUMN git_files_json TEXT", ]; for (const migration of migrations) { try { db.exec(migration); } catch (error: any) { // Ignore "duplicate column" errors - column already exists if (!error.message?.includes("duplicate column")) { throw error; } } } } export function initDb( path = process.env.DB_PATH || "data/dev/clarc.db", ): Database { db = new Database(path); // Enable WAL mode for better concurrency db.exec("PRAGMA journal_mode = WAL"); // Create tables if they don't exist db.exec(` CREATE TABLE IF NOT EXISTS devices ( id INTEGER PRIMARY KEY, secret TEXT NOT NULL UNIQUE, name TEXT, created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY, device_id INTEGER NOT NULL, started_at INTEGER NOT NULL, ended_at INTEGER, cwd TEXT, command TEXT ); CREATE TABLE IF NOT EXISTS prompts ( id INTEGER PRIMARY KEY, session_id INTEGER NOT NULL, created_at INTEGER NOT NULL, prompt_text TEXT NOT NULL, prompt_json TEXT, response TEXT, responded_at INTEGER ); CREATE TABLE IF NOT EXISTS output_log ( id INTEGER PRIMARY KEY, session_id INTEGER NOT NULL, timestamp INTEGER NOT NULL, line TEXT NOT NULL ); `); // Run migrations to add new columns runMigrations(); // Prepare all statements once createDeviceStmt = db.prepare( "INSERT INTO devices (secret, name, created_at, last_seen) VALUES (?, ?, ?, ?) RETURNING *", ); getDeviceBySecretStmt = db.prepare("SELECT * FROM devices WHERE secret = ?"); updateLastSeenStmt = db.prepare( "UPDATE devices SET last_seen = ? WHERE id = ?", ); createSessionStmt = db.prepare( "INSERT INTO sessions (device_id, started_at, cwd, command) VALUES (?, ?, ?, ?) RETURNING *", ); getSessionStmt = db.prepare("SELECT * FROM sessions WHERE id = ?"); endSessionStmt = db.prepare("UPDATE sessions SET ended_at = ? WHERE id = ?"); getActiveSessionsStmt = db.prepare( "SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC", ); createPromptStmt = db.prepare( "INSERT INTO prompts (session_id, created_at, prompt_text, prompt_json) VALUES (?, ?, ?, ?) RETURNING *", ); getPromptStmt = db.prepare("SELECT * FROM prompts WHERE id = ?"); respondToPromptStmt = db.prepare( "UPDATE prompts SET response = ?, responded_at = ? WHERE id = ?", ); getPendingPromptsStmt = db.prepare( "SELECT * FROM prompts WHERE response IS NULL ORDER BY created_at ASC", ); appendOutputStmt = db.prepare( "INSERT INTO output_log (session_id, timestamp, line) VALUES (?, ?, ?)", ); getSessionOutputStmt = db.prepare( "SELECT * FROM output_log WHERE session_id = ? ORDER BY timestamp ASC", ); updateSessionStatsStmt = db.prepare(` UPDATE sessions SET state = ?, prompts = ?, completions = ?, tools = ?, compressions = ?, thinking_seconds = ?, work_seconds = ?, mode = ?, model = ?, idle_since = ?, git_branch = ?, git_files_json = ? WHERE id = ? `); return db; } // Device functions export function createDevice( secret: string, name: string | null = null, ): Device { const now = Date.now(); return createDeviceStmt.get(secret, name, now, now) as Device; } export function getDeviceBySecret(secret: string): Device | null { return (getDeviceBySecretStmt.get(secret) as Device) ?? null; } export function updateLastSeen(deviceId: number): void { updateLastSeenStmt.run(Date.now(), deviceId); } // Session functions export function createSession( deviceId: number, cwd: string | null = null, command: string | null = null, ): Session { const now = Date.now(); return createSessionStmt.get(deviceId, now, cwd, command) as Session; } export function getSession(sessionId: number): Session | null { return (getSessionStmt.get(sessionId) as Session) ?? null; } export function endSession(sessionId: number): void { endSessionStmt.run(Date.now(), sessionId); } export function getActiveSessions(): Session[] { return getActiveSessionsStmt.all() as Session[]; } // Prompt functions export function createPrompt( sessionId: number, promptText: string, promptJson?: string, ): Prompt { const now = Date.now(); const row = createPromptStmt.get( sessionId, now, promptText, promptJson ?? null, ); const prompt = row as any; // Parse prompt_json if present if (prompt.prompt_json) { try { prompt.prompt_json = JSON.parse(prompt.prompt_json); } catch { prompt.prompt_json = null; } } return prompt as Prompt; } export function getPrompt(promptId: number): Prompt | null { const row = getPromptStmt.get(promptId); if (!row) return null; const prompt = row as any; // Parse prompt_json if present if (prompt.prompt_json) { try { prompt.prompt_json = JSON.parse(prompt.prompt_json); } catch { prompt.prompt_json = null; } } return prompt as Prompt; } export function respondToPrompt(promptId: number, response: string): void { respondToPromptStmt.run(response, Date.now(), promptId); } export function getPendingPrompts(): Prompt[] { const rows = getPendingPromptsStmt.all(); return rows.map((row: any) => { // Parse prompt_json if present if (row.prompt_json) { try { row.prompt_json = JSON.parse(row.prompt_json); } catch { row.prompt_json = null; } } return row as Prompt; }); } // OutputLog functions export function appendOutput(sessionId: number, line: string): void { appendOutputStmt.run(sessionId, Date.now(), line); } export function getSessionOutput(sessionId: number): OutputLog[] { return getSessionOutputStmt.all(sessionId) as OutputLog[]; } // Session state functions export function updateSessionStats( sessionId: number, state: { state: string; prompts: number; completions: number; tools: number; compressions: number; thinking_seconds: number; work_seconds: number; mode: string; model: string | null; idle_since: number | null; git_branch: string | null; git_files_json: string | null; }, ): void { updateSessionStatsStmt.run( state.state, state.prompts, state.completions, state.tools, state.compressions, state.thinking_seconds, state.work_seconds, state.mode, state.model, state.idle_since, state.git_branch, state.git_files_json, sessionId, ); }