Add SQLite database layer with tests
This commit is contained in:
parent
59f21cd062
commit
596b0fd013
3 changed files with 470 additions and 3 deletions
282
src/db.test.ts
Normal file
282
src/db.test.ts
Normal 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
171
src/db.ts
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
|
||||||
20
src/types.ts
20
src/types.ts
|
|
@ -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";
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue