Add SQLite database layer with tests

This commit is contained in:
Jared Miller 2026-01-28 11:15:36 -05:00
parent 59f21cd062
commit 596b0fd013
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 470 additions and 3 deletions

282
src/db.test.ts Normal file
View file

@ -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");
});

171
src/db.ts
View file

@ -1 +1,172 @@
// SQLite database schema and queries // 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[];
}

View file

@ -51,8 +51,22 @@ export type ServerMessage =
// SSE events (Server -> Dashboard) // SSE events (Server -> Dashboard)
export type SSEEvent = 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: "session_end"; session_id: number; exit_code: number }
| { type: "output"; session_id: number; data: string } | { 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";
};