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
|
||||
|
||||
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)
|
||||
|
||||
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";
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue