You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

182 lines
6.4 KiB
Rust

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