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