1use rusqlite::{Connection, Result};
10use tracing::{info, warn};
11
12struct Migration {
14 version: &'static str,
15 sql: &'static str,
16}
17
18const MIGRATIONS: &[Migration] = &[
23 Migration {
24 version: "001_add_session_lifecycle",
25 sql: include_str!("../../migrations/001_add_session_lifecycle.sql"),
26 },
27 Migration {
28 version: "002_add_multi_path_sessions",
29 sql: include_str!("../../migrations/002_add_multi_path_sessions.sql"),
30 },
31 Migration {
32 version: "003_add_agent_sessions",
33 sql: include_str!("../../migrations/003_add_agent_sessions.sql"),
34 },
35 Migration {
36 version: "004_add_memory_and_tasks",
37 sql: include_str!("../../migrations/004_add_memory_and_tasks.sql"),
38 },
39 Migration {
40 version: "005_add_checkpoint_grouping",
41 sql: include_str!("../../migrations/005_add_checkpoint_grouping.sql"),
42 },
43 Migration {
44 version: "006_rename_tasks_to_issues",
45 sql: include_str!("../../migrations/006_rename_tasks_to_issues.sql"),
46 },
47 Migration {
48 version: "007_embeddings_support",
49 sql: include_str!("../../migrations/007_embeddings_support.sql"),
50 },
51 Migration {
52 version: "008_dynamic_vec_dimensions",
53 sql: include_str!("../../migrations/008_dynamic_vec_dimensions.sql"),
54 },
55 Migration {
56 version: "009_rename_task_to_reminder",
57 sql: include_str!("../../migrations/009_rename_task_to_reminder.sql"),
58 },
59 Migration {
60 version: "010_issue_projects",
61 sql: include_str!("../../migrations/010_issue_projects.sql"),
62 },
63 Migration {
64 version: "011_blob_embeddings",
65 sql: include_str!("../../migrations/011_blob_embeddings.sql"),
66 },
67 Migration {
68 version: "012_tiered_embeddings",
69 sql: include_str!("../../migrations/012_tiered_embeddings.sql"),
70 },
71 Migration {
72 version: "013_plan_session_binding",
73 sql: include_str!("../../migrations/013_plan_session_binding.sql"),
74 },
75 Migration {
76 version: "014_close_reason",
77 sql: include_str!("../../migrations/014_close_reason.sql"),
78 },
79 Migration {
80 version: "015_add_time_entries",
81 sql: include_str!("../../migrations/015_add_time_entries.sql"),
82 },
83];
84
85pub fn run_migrations(conn: &Connection) -> Result<()> {
97 conn.execute(
99 "CREATE TABLE IF NOT EXISTS schema_migrations (
100 version TEXT PRIMARY KEY,
101 applied_at INTEGER NOT NULL
102 )",
103 [],
104 )?;
105
106 let applied: std::collections::HashSet<String> = conn
108 .prepare("SELECT version FROM schema_migrations")?
109 .query_map([], |row| row.get(0))?
110 .collect::<Result<_, _>>()?;
111
112 for migration in MIGRATIONS {
114 if applied.contains(migration.version) {
115 continue;
116 }
117
118 info!(version = migration.version, "Applying migration");
119
120 if let Err(e) = conn.execute_batch(migration.sql) {
122 let err_str = e.to_string();
123 if err_str.contains("duplicate column name") {
127 warn!(
128 version = migration.version,
129 "Migration partially applied (columns exist), marking complete"
130 );
131 } else if err_str.contains("no such module: vec0") {
132 warn!(
133 version = migration.version,
134 "Skipping sqlite-vec virtual table (not available in Rust CLI)"
135 );
136 } else {
137 return Err(e);
138 }
139 }
140
141 conn.execute(
143 "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, ?2)",
144 rusqlite::params![migration.version, chrono::Utc::now().timestamp_millis()],
145 )?;
146
147 info!(version = migration.version, "Migration complete");
148 }
149
150 Ok(())
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::storage::schema::SCHEMA_SQL;
157
158 fn setup_db(conn: &Connection) {
160 conn.execute_batch(SCHEMA_SQL).expect("Base schema should apply");
161 }
162
163 #[test]
164 fn test_migrations_compile() {
165 assert!(!MIGRATIONS.is_empty());
168 assert_eq!(MIGRATIONS.len(), 15);
169 }
170
171 #[test]
172 fn test_run_migrations_fresh_db() {
173 let conn = Connection::open_in_memory().unwrap();
174 setup_db(&conn);
175 run_migrations(&conn).expect("Migrations should apply to fresh database");
176
177 let count: i32 = conn
179 .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| {
180 row.get(0)
181 })
182 .unwrap();
183 assert_eq!(count, 15);
184 }
185
186 #[test]
187 fn test_run_migrations_idempotent() {
188 let conn = Connection::open_in_memory().unwrap();
189 setup_db(&conn);
190
191 run_migrations(&conn).expect("First run should succeed");
193 run_migrations(&conn).expect("Second run should succeed (idempotent)");
194
195 let count: i32 = conn
197 .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| {
198 row.get(0)
199 })
200 .unwrap();
201 assert_eq!(count, 15);
202 }
203}