use crate::turso::Migration;
pub(crate) const SESSION_MIGRATIONS: &[Migration] = &[
Migration {
id: "001_session_token_length",
sql: "ALTER TABLE session_metadata ADD COLUMN token_length INTEGER",
guard: Some(("session_metadata", "token_length")),
},
Migration {
id: "002_session_message_count",
sql: "ALTER TABLE session_metadata ADD COLUMN message_count INTEGER NOT NULL DEFAULT 0; \
UPDATE session_metadata SET message_count = (SELECT COUNT(*) FROM sessions \
WHERE sessions.agent_id = session_metadata.agent_id)",
guard: Some(("session_metadata", "message_count")),
},
];
#[cfg(test)]
mod tests {
use super::*;
use crate::turso::{column_exists, run_pending_migrations};
const OLD_SCHEMA: &str = "CREATE TABLE sessions (\
id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, \
role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL);\
CREATE TABLE session_metadata (agent_id TEXT PRIMARY KEY, last_activity TEXT NOT NULL);";
const FRESH_SCHEMA: &str = "CREATE TABLE sessions (\
id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, \
role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL);\
CREATE TABLE session_metadata (\
agent_id TEXT PRIMARY KEY, last_activity TEXT NOT NULL, \
token_length INTEGER, message_count INTEGER NOT NULL DEFAULT 0);";
async fn applied_ids(conn: &crate::turso::Connection) -> Vec<String> {
conn.query("SELECT id FROM schema_migrations ORDER BY id", ())
.await
.expect("read tracking table")
.into_iter()
.map(|row| row.get::<String>(0).expect("id column"))
.collect()
}
async fn message_count(conn: &crate::turso::Connection, agent_id: &str) -> i64 {
conn.query_optional(
"SELECT message_count FROM session_metadata WHERE agent_id = ?1",
(turso::Value::Text(agent_id.to_string()),),
|row| row.get::<i64>(0),
)
.await
.expect("read message_count")
.expect("session exists")
}
#[tokio::test]
async fn migrations_alter_old_db_exactly_once_with_backfill() {
let tmp = tempfile::TempDir::new().unwrap();
let conn = crate::turso::open_with_schema(&tmp.path().join("sessions.db"), OLD_SCHEMA)
.await
.expect("open old-db test store");
assert!(
!column_exists(&conn, "session_metadata", "token_length")
.await
.unwrap(),
"old DB must lack token_length before the migration"
);
assert!(
!column_exists(&conn, "session_metadata", "message_count")
.await
.unwrap(),
"old DB must lack message_count before the migration"
);
for (agent, n) in [("sess_a", 3), ("sess_b", 1), ("sess_c", 0)] {
conn.execute(
"INSERT INTO session_metadata (agent_id, last_activity) VALUES (?1, '2026-01-01T00:00:00Z')",
(turso::Value::Text(agent.to_string()),),
)
.await
.expect("seed metadata");
for i in 0..n {
conn.execute(
"INSERT INTO sessions (agent_id, role, content, created_at) VALUES (?1, 'user', ?2, '2026-01-01T00:00:00Z')",
(turso::Value::Text(agent.to_string()), turso::Value::Text(format!("m{i}"))),
)
.await
.expect("seed message");
}
}
run_pending_migrations(&conn, "sessions", SESSION_MIGRATIONS)
.await
.expect("first run applies both migrations");
assert!(
column_exists(&conn, "session_metadata", "token_length")
.await
.unwrap(),
"ALTER must add token_length"
);
assert!(
column_exists(&conn, "session_metadata", "message_count")
.await
.unwrap(),
"ALTER must add message_count"
);
assert_eq!(message_count(&conn, "sess_a").await, 3);
assert_eq!(message_count(&conn, "sess_b").await, 1);
assert_eq!(message_count(&conn, "sess_c").await, 0);
assert_eq!(
applied_ids(&conn).await,
vec!["001_session_token_length", "002_session_message_count"],
"both migrations recorded in order"
);
run_pending_migrations(&conn, "sessions", SESSION_MIGRATIONS)
.await
.expect("second run is a no-op");
assert_eq!(applied_ids(&conn).await.len(), 2, "never re-run");
}
#[tokio::test]
async fn migration_skips_sql_on_fresh_db_and_still_records_applied() {
let tmp = tempfile::TempDir::new().unwrap();
let conn = crate::turso::open_with_schema(&tmp.path().join("sessions.db"), FRESH_SCHEMA)
.await
.expect("open fresh-db test store");
assert!(
column_exists(&conn, "session_metadata", "token_length")
.await
.unwrap(),
"fresh DB has token_length from the SCHEMA"
);
assert!(
column_exists(&conn, "session_metadata", "message_count")
.await
.unwrap(),
"fresh DB has message_count from the SCHEMA"
);
run_pending_migrations(&conn, "sessions", SESSION_MIGRATIONS)
.await
.expect("guard turns the pending migrations into recorded no-ops");
assert!(
column_exists(&conn, "session_metadata", "token_length")
.await
.unwrap(),
"column still present, exactly once"
);
assert!(
column_exists(&conn, "session_metadata", "message_count")
.await
.unwrap(),
"column still present, exactly once"
);
assert_eq!(
applied_ids(&conn).await,
vec!["001_session_token_length", "002_session_message_count"],
"both migrations recorded as applied even though the SQL was skipped"
);
}
}