From 10c848ca2a047534c5d9ebd7eccab39a019fe743 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 3 Feb 2026 00:27:12 -0800 Subject: [PATCH] feat: add SQLite session persistence Co-Authored-By: Claude Opus 4.5 --- src/session/index.ts | 1 + src/session/store.test.ts | 65 +++++++++++++++++++++++++++++++++++++++ src/session/store.ts | 57 ++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/session/index.ts create mode 100644 src/session/store.test.ts create mode 100644 src/session/store.ts diff --git a/src/session/index.ts b/src/session/index.ts new file mode 100644 index 0000000..2038ffd --- /dev/null +++ b/src/session/index.ts @@ -0,0 +1 @@ +export { SessionStore } from './store.js'; diff --git a/src/session/store.test.ts b/src/session/store.test.ts new file mode 100644 index 0000000..b7e8bed --- /dev/null +++ b/src/session/store.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SessionStore } from './store.js'; +import { unlinkSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +describe('SessionStore', () => { + const dbPath = join(tmpdir(), 'flynn-test-sessions.db'); + let store: SessionStore; + + beforeEach(() => { + store = new SessionStore(dbPath); + }); + + afterEach(() => { + store.close(); + if (existsSync(dbPath)) { + unlinkSync(dbPath); + } + }); + + it('saves and retrieves messages', () => { + const sessionId = 'test-session'; + + store.addMessage(sessionId, { role: 'user', content: 'Hello' }); + store.addMessage(sessionId, { role: 'assistant', content: 'Hi there!' }); + + const messages = store.getMessages(sessionId); + + expect(messages).toHaveLength(2); + expect(messages[0].role).toBe('user'); + expect(messages[0].content).toBe('Hello'); + expect(messages[1].role).toBe('assistant'); + expect(messages[1].content).toBe('Hi there!'); + }); + + it('clears session messages', () => { + const sessionId = 'test-session'; + + store.addMessage(sessionId, { role: 'user', content: 'Hello' }); + store.clearSession(sessionId); + + const messages = store.getMessages(sessionId); + expect(messages).toHaveLength(0); + }); + + it('handles multiple sessions independently', () => { + store.addMessage('session-1', { role: 'user', content: 'Session 1' }); + store.addMessage('session-2', { role: 'user', content: 'Session 2' }); + + expect(store.getMessages('session-1')).toHaveLength(1); + expect(store.getMessages('session-2')).toHaveLength(1); + expect(store.getMessages('session-1')[0].content).toBe('Session 1'); + }); + + it('lists all sessions', () => { + store.addMessage('session-a', { role: 'user', content: 'A' }); + store.addMessage('session-b', { role: 'user', content: 'B' }); + + const sessions = store.listSessions(); + + expect(sessions).toContain('session-a'); + expect(sessions).toContain('session-b'); + }); +}); diff --git a/src/session/store.ts b/src/session/store.ts new file mode 100644 index 0000000..edeb21f --- /dev/null +++ b/src/session/store.ts @@ -0,0 +1,57 @@ +import Database from 'better-sqlite3'; +import type { Message } from '../models/types.js'; + +export class SessionStore { + private db: Database.Database; + + constructor(dbPath: string) { + this.db = new Database(dbPath); + this.init(); + } + + private init(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id); + `); + } + + addMessage(sessionId: string, message: Message): void { + const stmt = this.db.prepare( + 'INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)' + ); + stmt.run(sessionId, message.role, message.content); + } + + getMessages(sessionId: string): Message[] { + const stmt = this.db.prepare( + 'SELECT role, content FROM messages WHERE session_id = ? ORDER BY id ASC' + ); + const rows = stmt.all(sessionId) as Array<{ role: string; content: string }>; + return rows.map(row => ({ + role: row.role as 'user' | 'assistant', + content: row.content, + })); + } + + clearSession(sessionId: string): void { + const stmt = this.db.prepare('DELETE FROM messages WHERE session_id = ?'); + stmt.run(sessionId); + } + + listSessions(): string[] { + const stmt = this.db.prepare('SELECT DISTINCT session_id FROM messages'); + const rows = stmt.all() as Array<{ session_id: string }>; + return rows.map(row => row.session_id); + } + + close(): void { + this.db.close(); + } +}