//! Database schema migrations for the claudbg cache. use crate::db::connection::DbHandle; use crate::error::Result; /// Run all database migrations to bring the schema up to date. /// /// Creates all tables if they don't exist. Safe to call on an existing database /// (all statements use `CREATE TABLE IF NOT EXISTS`). pub async fn run_migrations(db: &DbHandle) -> Result<()> { let conn = db .connect() .map_err(|e| crate::error::AppError::Db(e.to_string()))?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, project_path TEXT, model TEXT, first_msg_at TEXT, last_msg_at TEXT, message_count INTEGER NOT NULL DEFAULT 0, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, file_path TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_last_msg ON sessions(last_msg_at DESC); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(session_id), sequence_num INTEGER NOT NULL, timestamp TEXT, role TEXT, content_preview TEXT, entry_type TEXT, UNIQUE(session_id, sequence_num) ); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, sequence_num); CREATE TABLE IF NOT EXISTS tool_uses ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(session_id), tool_name TEXT NOT NULL, tool_use_id TEXT, sequence_num INTEGER, timestamp TEXT ); CREATE INDEX IF NOT EXISTS idx_tool_uses_session ON tool_uses(session_id); CREATE TABLE IF NOT EXISTS raw_sessions ( session_id TEXT PRIMARY KEY, raw_jsonl TEXT NOT NULL, file_mtime INTEGER NOT NULL, file_size INTEGER NOT NULL );", ) .await .map_err(|e| crate::error::AppError::Db(e.to_string()))?; Ok(()) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; /// `run_migrations` succeeds on a fresh database. #[tokio::test] async fn migrations_fresh_db() { let dir = tempfile::tempdir().expect("tempdir"); let path = PathBuf::from(dir.path()).join("schema_test.db"); let db = libsql::Builder::new_local(&path) .build() .await .expect("build db"); let handle = std::sync::Arc::new(db); let result = run_migrations(&handle).await; assert!(result.is_ok(), "migration failed: {:?}", result.err()); } /// `run_migrations` is idempotent — calling it twice does not error. #[tokio::test] async fn migrations_idempotent() { let dir = tempfile::tempdir().expect("tempdir"); let path = PathBuf::from(dir.path()).join("schema_idempotent.db"); let db = libsql::Builder::new_local(&path) .build() .await .expect("build db"); let handle = std::sync::Arc::new(db); run_migrations(&handle).await.expect("first migration"); let result = run_migrations(&handle).await; assert!( result.is_ok(), "second migration failed: {:?}", result.err() ); } /// After migration, inserting and reading a `raw_sessions` row works. #[tokio::test] async fn raw_sessions_insert_and_read() { let dir = tempfile::tempdir().expect("tempdir"); let path = PathBuf::from(dir.path()).join("raw_sessions_test.db"); let db = libsql::Builder::new_local(&path) .build() .await .expect("build db"); let handle = std::sync::Arc::new(db); run_migrations(&handle).await.expect("migration"); let conn = handle.connect().expect("connect"); // Insert a sessions row first (raw_sessions has no FK to sessions, but // let's also ensure the sessions table is accessible). conn.execute( "INSERT INTO raw_sessions (session_id, raw_jsonl, file_mtime, file_size) VALUES (?1, ?2, ?3, ?4)", libsql::params![ "test-session-1", "{\"type\":\"user\"}", 1700000000_i64, 42_i64 ], ) .await .expect("insert raw_sessions"); let mut rows = conn .query( "SELECT session_id, file_mtime, file_size FROM raw_sessions WHERE session_id = ?1", libsql::params!["test-session-1"], ) .await .expect("query"); let row = rows.next().await.expect("next").expect("row exists"); let sid: String = row.get(0).expect("session_id"); let mtime: i64 = row.get(1).expect("file_mtime"); let size: i64 = row.get(2).expect("file_size"); assert_eq!(sid, "test-session-1"); assert_eq!(mtime, 1700000000); assert_eq!(size, 42); } /// After migration, inserting and reading a `sessions` row works. #[tokio::test] async fn sessions_table_insert_and_read() { let dir = tempfile::tempdir().expect("tempdir"); let path = PathBuf::from(dir.path()).join("sessions_table_test.db"); let db = libsql::Builder::new_local(&path) .build() .await .expect("build db"); let handle = std::sync::Arc::new(db); run_migrations(&handle).await.expect("migration"); let conn = handle.connect().expect("connect"); conn.execute( "INSERT INTO sessions (session_id, file_path) VALUES (?1, ?2)", libsql::params!["sess-abc", "/tmp/sess-abc.jsonl"], ) .await .expect("insert sessions"); let mut rows = conn .query( "SELECT session_id FROM sessions WHERE session_id = ?1", libsql::params!["sess-abc"], ) .await .expect("query"); let row = rows.next().await.expect("next").expect("row exists"); let sid: String = row.get(0).expect("session_id"); assert_eq!(sid, "sess-abc"); } }