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];
80
81pub fn run_migrations(conn: &Connection) -> Result<()> {
93 conn.execute(
95 "CREATE TABLE IF NOT EXISTS schema_migrations (
96 version TEXT PRIMARY KEY,
97 applied_at INTEGER NOT NULL
98 )",
99 [],
100 )?;
101
102 let applied: std::collections::HashSet<String> = conn
104 .prepare("SELECT version FROM schema_migrations")?
105 .query_map([], |row| row.get(0))?
106 .collect::<Result<_, _>>()?;
107
108 for migration in MIGRATIONS {
110 if applied.contains(migration.version) {
111 continue;
112 }
113
114 info!(version = migration.version, "Applying migration");
115
116 if let Err(e) = conn.execute_batch(migration.sql) {
118 let err_str = e.to_string();
119 if err_str.contains("duplicate column name") {
123 warn!(
124 version = migration.version,
125 "Migration partially applied (columns exist), marking complete"
126 );
127 } else if err_str.contains("no such module: vec0") {
128 warn!(
129 version = migration.version,
130 "Skipping sqlite-vec virtual table (not available in Rust CLI)"
131 );
132 } else {
133 return Err(e);
134 }
135 }
136
137 conn.execute(
139 "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, ?2)",
140 rusqlite::params![migration.version, chrono::Utc::now().timestamp_millis()],
141 )?;
142
143 info!(version = migration.version, "Migration complete");
144 }
145
146 Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::storage::schema::SCHEMA_SQL;
153
154 fn setup_db(conn: &Connection) {
156 conn.execute_batch(SCHEMA_SQL).expect("Base schema should apply");
157 }
158
159 #[test]
160 fn test_migrations_compile() {
161 assert!(!MIGRATIONS.is_empty());
164 assert_eq!(MIGRATIONS.len(), 14);
165 }
166
167 #[test]
168 fn test_run_migrations_fresh_db() {
169 let conn = Connection::open_in_memory().unwrap();
170 setup_db(&conn);
171 run_migrations(&conn).expect("Migrations should apply to fresh database");
172
173 let count: i32 = conn
175 .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| {
176 row.get(0)
177 })
178 .unwrap();
179 assert_eq!(count, 14);
180 }
181
182 #[test]
183 fn test_run_migrations_idempotent() {
184 let conn = Connection::open_in_memory().unwrap();
185 setup_db(&conn);
186
187 run_migrations(&conn).expect("First run should succeed");
189 run_migrations(&conn).expect("Second run should succeed (idempotent)");
190
191 let count: i32 = conn
193 .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| {
194 row.get(0)
195 })
196 .unwrap();
197 assert_eq!(count, 14);
198 }
199}