diff --git a/src/db.test.ts b/src/db.test.ts new file mode 100644 index 0000000..6c1a3d6 --- /dev/null +++ b/src/db.test.ts @@ -0,0 +1,282 @@ +import { beforeEach, expect, test } from "bun:test"; +import { + appendOutput, + createDevice, + createPrompt, + createSession, + endSession, + getActiveSessions, + getDeviceBySecret, + getPendingPrompts, + getPrompt, + getSession, + getSessionOutput, + initDb, + respondToPrompt, + updateLastSeen, +} from "./db"; + +beforeEach(() => { + // Use in-memory database for each test + initDb(":memory:"); +}); + +// Device tests + +test("createDevice creates a device with secret and optional name", () => { + const device = createDevice("secret123", "laptop"); + + expect(device.id).toBe(1); + expect(device.secret).toBe("secret123"); + expect(device.name).toBe("laptop"); + expect(device.created_at).toBeGreaterThan(0); + expect(device.last_seen).toBeGreaterThan(0); + expect(device.created_at).toBe(device.last_seen); +}); + +test("createDevice creates a device without name", () => { + const device = createDevice("secret456"); + + expect(device.id).toBe(1); + expect(device.secret).toBe("secret456"); + expect(device.name).toBeNull(); +}); + +test("getDeviceBySecret retrieves device by secret", () => { + const created = createDevice("secret789", "desktop"); + const retrieved = getDeviceBySecret("secret789"); + + expect(retrieved).not.toBeNull(); + expect(retrieved?.id).toBe(created.id); + expect(retrieved?.secret).toBe("secret789"); + expect(retrieved?.name).toBe("desktop"); +}); + +test("getDeviceBySecret returns null for non-existent secret", () => { + const device = getDeviceBySecret("nonexistent"); + expect(device).toBeNull(); +}); + +test("updateLastSeen updates the last_seen timestamp", async () => { + const device = createDevice("secret123"); + const originalLastSeen = device.last_seen; + + // Wait a bit to ensure timestamp changes + await new Promise((resolve) => setTimeout(resolve, 10)); + + updateLastSeen(device.id); + const updated = getDeviceBySecret("secret123"); + + expect(updated).not.toBeNull(); + expect(updated?.last_seen).toBeGreaterThan(originalLastSeen); +}); + +// Session tests + +test("createSession creates a session with all fields", () => { + const device = createDevice("secret123"); + const session = createSession(device.id, "/home/user", "claude"); + + expect(session.id).toBe(1); + expect(session.device_id).toBe(device.id); + expect(session.started_at).toBeGreaterThan(0); + expect(session.ended_at).toBeNull(); + expect(session.cwd).toBe("/home/user"); + expect(session.command).toBe("claude"); +}); + +test("createSession creates a session with minimal fields", () => { + const device = createDevice("secret123"); + const session = createSession(device.id); + + expect(session.id).toBe(1); + expect(session.device_id).toBe(device.id); + expect(session.cwd).toBeNull(); + expect(session.command).toBeNull(); +}); + +test("getSession retrieves session by id", () => { + const device = createDevice("secret123"); + const created = createSession(device.id, "/tmp", "test"); + const retrieved = getSession(created.id); + + expect(retrieved).not.toBeNull(); + expect(retrieved?.id).toBe(created.id); + expect(retrieved?.cwd).toBe("/tmp"); + expect(retrieved?.command).toBe("test"); +}); + +test("getSession returns null for non-existent id", () => { + const session = getSession(999); + expect(session).toBeNull(); +}); + +test("endSession sets the ended_at timestamp", () => { + const device = createDevice("secret123"); + const session = createSession(device.id); + + expect(session.ended_at).toBeNull(); + + endSession(session.id); + const ended = getSession(session.id); + + expect(ended).not.toBeNull(); + expect(ended?.ended_at).toBeGreaterThan(0); + expect(ended?.ended_at).toBeGreaterThanOrEqual(session.started_at); +}); + +test("getActiveSessions returns only sessions without ended_at", () => { + const device = createDevice("secret123"); + const session1 = createSession(device.id); + const session2 = createSession(device.id); + const session3 = createSession(device.id); + + endSession(session2.id); + + const activeSessions = getActiveSessions(); + + expect(activeSessions).toHaveLength(2); + expect(activeSessions.map((s) => s.id)).toContain(session1.id); + expect(activeSessions.map((s) => s.id)).toContain(session3.id); + expect(activeSessions.map((s) => s.id)).not.toContain(session2.id); +}); + +test("getActiveSessions returns empty array when no active sessions", () => { + const device = createDevice("secret123"); + const session = createSession(device.id); + endSession(session.id); + + const activeSessions = getActiveSessions(); + expect(activeSessions).toHaveLength(0); +}); + +// Prompt tests + +test("createPrompt creates a prompt with text", () => { + const device = createDevice("secret123"); + const session = createSession(device.id); + const prompt = createPrompt(session.id, "Approve this action?"); + + expect(prompt.id).toBe(1); + expect(prompt.session_id).toBe(session.id); + expect(prompt.created_at).toBeGreaterThan(0); + expect(prompt.prompt_text).toBe("Approve this action?"); + expect(prompt.response).toBeNull(); + expect(prompt.responded_at).toBeNull(); +}); + +test("getPrompt retrieves prompt by id", () => { + const device = createDevice("secret123"); + const session = createSession(device.id); + const created = createPrompt(session.id, "Test prompt"); + const retrieved = getPrompt(created.id); + + expect(retrieved).not.toBeNull(); + expect(retrieved?.id).toBe(created.id); + expect(retrieved?.prompt_text).toBe("Test prompt"); +}); + +test("getPrompt returns null for non-existent id", () => { + const prompt = getPrompt(999); + expect(prompt).toBeNull(); +}); + +test("respondToPrompt sets response and timestamp", () => { + const device = createDevice("secret123"); + const session = createSession(device.id); + const prompt = createPrompt(session.id, "Test prompt"); + + respondToPrompt(prompt.id, "approve"); + const responded = getPrompt(prompt.id); + + expect(responded).not.toBeNull(); + expect(responded?.response).toBe("approve"); + expect(responded?.responded_at).toBeGreaterThan(0); +}); + +test("getPendingPrompts returns only prompts without response", () => { + const device = createDevice("secret123"); + const session = createSession(device.id); + const prompt1 = createPrompt(session.id, "Prompt 1"); + const prompt2 = createPrompt(session.id, "Prompt 2"); + const prompt3 = createPrompt(session.id, "Prompt 3"); + + respondToPrompt(prompt2.id, "reject"); + + const pendingPrompts = getPendingPrompts(); + + expect(pendingPrompts).toHaveLength(2); + expect(pendingPrompts.map((p) => p.id)).toContain(prompt1.id); + expect(pendingPrompts.map((p) => p.id)).toContain(prompt3.id); + expect(pendingPrompts.map((p) => p.id)).not.toContain(prompt2.id); +}); + +test("getPendingPrompts returns empty array when no pending prompts", () => { + const device = createDevice("secret123"); + const session = createSession(device.id); + const prompt = createPrompt(session.id, "Test"); + respondToPrompt(prompt.id, "approve"); + + const pendingPrompts = getPendingPrompts(); + expect(pendingPrompts).toHaveLength(0); +}); + +// OutputLog tests + +test("appendOutput adds output line to session", () => { + const device = createDevice("secret123"); + const session = createSession(device.id); + + appendOutput(session.id, "Hello, world!"); + const output = getSessionOutput(session.id); + + expect(output).toHaveLength(1); + expect(output[0]?.session_id).toBe(session.id); + expect(output[0]?.line).toBe("Hello, world!"); + expect(output[0]?.timestamp).toBeGreaterThan(0); +}); + +test("getSessionOutput retrieves all output lines in order", () => { + const device = createDevice("secret123"); + const session = createSession(device.id); + + appendOutput(session.id, "Line 1"); + appendOutput(session.id, "Line 2"); + appendOutput(session.id, "Line 3"); + + const output = getSessionOutput(session.id); + + expect(output).toHaveLength(3); + expect(output[0]?.line).toBe("Line 1"); + expect(output[1]?.line).toBe("Line 2"); + expect(output[2]?.line).toBe("Line 3"); + // biome-ignore lint/style/noNonNullAssertion: verified by length check + expect(output[0]!.timestamp).toBeLessThanOrEqual(output[1]!.timestamp); + // biome-ignore lint/style/noNonNullAssertion: verified by length check + expect(output[1]!.timestamp).toBeLessThanOrEqual(output[2]!.timestamp); +}); + +test("getSessionOutput returns empty array for session with no output", () => { + const device = createDevice("secret123"); + const session = createSession(device.id); + + const output = getSessionOutput(session.id); + expect(output).toHaveLength(0); +}); + +test("getSessionOutput only returns output for specific session", () => { + const device = createDevice("secret123"); + const session1 = createSession(device.id); + const session2 = createSession(device.id); + + appendOutput(session1.id, "Session 1 output"); + appendOutput(session2.id, "Session 2 output"); + + const output1 = getSessionOutput(session1.id); + const output2 = getSessionOutput(session2.id); + + expect(output1).toHaveLength(1); + expect(output2).toHaveLength(1); + expect(output1[0]?.line).toBe("Session 1 output"); + expect(output2[0]?.line).toBe("Session 2 output"); +}); diff --git a/src/db.ts b/src/db.ts index 903a18c..531b716 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1 +1,172 @@ // SQLite database schema and queries + +import { Database } from "bun:sqlite"; +import type { Device, OutputLog, Prompt, Session } from "./types"; + +let db: Database; + +export function initDb(path = "claude-remote.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, + 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 + ); + `); + + return db; +} + +// Device functions + +const createDeviceStmt = () => + db.prepare( + "INSERT INTO devices (secret, name, created_at, last_seen) VALUES (?, ?, ?, ?) RETURNING *", + ); + +export function createDevice( + secret: string, + name: string | null = null, +): Device { + const now = Date.now(); + return createDeviceStmt().get(secret, name, now, now) as Device; +} + +const getDeviceBySecretStmt = () => + db.prepare("SELECT * FROM devices WHERE secret = ?"); + +export function getDeviceBySecret(secret: string): Device | null { + return (getDeviceBySecretStmt().get(secret) as Device) ?? null; +} + +const updateLastSeenStmt = () => + db.prepare("UPDATE devices SET last_seen = ? WHERE id = ?"); + +export function updateLastSeen(deviceId: number): void { + updateLastSeenStmt().run(Date.now(), deviceId); +} + +// Session functions + +const createSessionStmt = () => + db.prepare( + "INSERT INTO sessions (device_id, started_at, cwd, command) VALUES (?, ?, ?, ?) RETURNING *", + ); + +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; +} + +const getSessionStmt = () => db.prepare("SELECT * FROM sessions WHERE id = ?"); + +export function getSession(sessionId: number): Session | null { + return (getSessionStmt().get(sessionId) as Session) ?? null; +} + +const endSessionStmt = () => + db.prepare("UPDATE sessions SET ended_at = ? WHERE id = ?"); + +export function endSession(sessionId: number): void { + endSessionStmt().run(Date.now(), sessionId); +} + +const getActiveSessionsStmt = () => + db.prepare( + "SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC", + ); + +export function getActiveSessions(): Session[] { + return getActiveSessionsStmt().all() as Session[]; +} + +// Prompt functions + +const createPromptStmt = () => + db.prepare( + "INSERT INTO prompts (session_id, created_at, prompt_text) VALUES (?, ?, ?) RETURNING *", + ); + +export function createPrompt(sessionId: number, promptText: string): Prompt { + const now = Date.now(); + return createPromptStmt().get(sessionId, now, promptText) as Prompt; +} + +const getPromptStmt = () => db.prepare("SELECT * FROM prompts WHERE id = ?"); + +export function getPrompt(promptId: number): Prompt | null { + return (getPromptStmt().get(promptId) as Prompt) ?? null; +} + +const respondToPromptStmt = () => + db.prepare("UPDATE prompts SET response = ?, responded_at = ? WHERE id = ?"); + +export function respondToPrompt(promptId: number, response: string): void { + respondToPromptStmt().run(response, Date.now(), promptId); +} + +const getPendingPromptsStmt = () => + db.prepare( + "SELECT * FROM prompts WHERE response IS NULL ORDER BY created_at ASC", + ); + +export function getPendingPrompts(): Prompt[] { + return getPendingPromptsStmt().all() as Prompt[]; +} + +// OutputLog functions + +const appendOutputStmt = () => + db.prepare( + "INSERT INTO output_log (session_id, timestamp, line) VALUES (?, ?, ?)", + ); + +export function appendOutput(sessionId: number, line: string): void { + appendOutputStmt().run(sessionId, Date.now(), line); +} + +const getSessionOutputStmt = () => + db.prepare( + "SELECT * FROM output_log WHERE session_id = ? ORDER BY timestamp ASC", + ); + +export function getSessionOutput(sessionId: number): OutputLog[] { + return getSessionOutputStmt().all(sessionId) as OutputLog[]; +} diff --git a/src/types.ts b/src/types.ts index a66e4bf..1231083 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,8 +51,22 @@ export type ServerMessage = // SSE events (Server -> Dashboard) export type SSEEvent = - | { type: "session_start"; session_id: number; cwd: string | null; command: string | null } + | { + type: "session_start"; + session_id: number; + cwd: string | null; + command: string | null; + } | { type: "session_end"; session_id: number; exit_code: number } | { type: "output"; session_id: number; data: string } - | { type: "prompt"; prompt_id: number; session_id: number; prompt_text: string } - | { type: "prompt_response"; prompt_id: number; response: "approve" | "reject" } + | { + type: "prompt"; + prompt_id: number; + session_id: number; + prompt_text: string; + } + | { + type: "prompt_response"; + prompt_id: number; + response: "approve" | "reject"; + };