clarc/src/db.ts
Jared Miller 2dfe420555
Rename project from claude-remote to clarc
Updated all references across documentation, config files, and source code.
2026-01-30 08:32:34 -05:00

311 lines
8.7 KiB
TypeScript

// 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<Database["prepare"]>;
let getDeviceBySecretStmt: ReturnType<Database["prepare"]>;
let updateLastSeenStmt: ReturnType<Database["prepare"]>;
let createSessionStmt: ReturnType<Database["prepare"]>;
let getSessionStmt: ReturnType<Database["prepare"]>;
let endSessionStmt: ReturnType<Database["prepare"]>;
let getActiveSessionsStmt: ReturnType<Database["prepare"]>;
let createPromptStmt: ReturnType<Database["prepare"]>;
let getPromptStmt: ReturnType<Database["prepare"]>;
let respondToPromptStmt: ReturnType<Database["prepare"]>;
let getPendingPromptsStmt: ReturnType<Database["prepare"]>;
let appendOutputStmt: ReturnType<Database["prepare"]>;
let getSessionOutputStmt: ReturnType<Database["prepare"]>;
let updateSessionStatsStmt: ReturnType<Database["prepare"]>;
// 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,
);
}