Skip to main content

sc/storage/
migrations.rs

1//! Database migrations embedded at compile time.
2//!
3//! Migrations are sourced from `cli/migrations/` and embedded into the
4//! binary using `include_str!`. This ensures the package is self-contained
5//! for crates.io publishing.
6//!
7//! To sync migrations from repo root: `npm run sync:migrations`
8
9use rusqlite::{Connection, Result};
10use tracing::{info, warn};
11
12/// A single migration with version identifier and SQL content.
13struct Migration {
14    version: &'static str,
15    sql: &'static str,
16}
17
18/// All migrations in order, embedded at compile time.
19///
20/// Version names match the SQL filenames (without .sql extension).
21/// The `schema_migrations` table tracks which have been applied.
22const 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];
72
73/// Run all pending migrations on the database.
74///
75/// Migrations are applied in order. Already-applied migrations (tracked in
76/// the `schema_migrations` table) are skipped. This is idempotent and safe
77/// to call on every database open.
78///
79/// # Errors
80///
81/// Returns an error if a migration fails to apply. Note that ALTER TABLE
82/// errors for duplicate columns are handled gracefully (logged as warnings)
83/// since the schema may already have those columns from the base DDL.
84pub fn run_migrations(conn: &Connection) -> Result<()> {
85    // Ensure schema_migrations table exists
86    conn.execute(
87        "CREATE TABLE IF NOT EXISTS schema_migrations (
88            version TEXT PRIMARY KEY,
89            applied_at INTEGER NOT NULL
90        )",
91        [],
92    )?;
93
94    // Get already applied migrations
95    let applied: std::collections::HashSet<String> = conn
96        .prepare("SELECT version FROM schema_migrations")?
97        .query_map([], |row| row.get(0))?
98        .collect::<Result<_, _>>()?;
99
100    // Apply pending migrations in order
101    for migration in MIGRATIONS {
102        if applied.contains(migration.version) {
103            continue;
104        }
105
106        info!(version = migration.version, "Applying migration");
107
108        // Execute migration SQL
109        if let Err(e) = conn.execute_batch(migration.sql) {
110            let err_str = e.to_string();
111            // Handle expected failures gracefully:
112            // 1. ALTER TABLE with duplicate column (base schema already has columns)
113            // 2. vec0 module not found (sqlite-vec not available in Rust)
114            if err_str.contains("duplicate column name") {
115                warn!(
116                    version = migration.version,
117                    "Migration partially applied (columns exist), marking complete"
118                );
119            } else if err_str.contains("no such module: vec0") {
120                warn!(
121                    version = migration.version,
122                    "Skipping sqlite-vec virtual table (not available in Rust CLI)"
123                );
124            } else {
125                return Err(e);
126            }
127        }
128
129        // Record migration as applied
130        conn.execute(
131            "INSERT INTO schema_migrations (version, applied_at) VALUES (?1, ?2)",
132            rusqlite::params![migration.version, chrono::Utc::now().timestamp_millis()],
133        )?;
134
135        info!(version = migration.version, "Migration complete");
136    }
137
138    Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::storage::schema::SCHEMA_SQL;
145
146    /// Apply base schema before running migrations (mirrors production flow)
147    fn setup_db(conn: &Connection) {
148        conn.execute_batch(SCHEMA_SQL).expect("Base schema should apply");
149    }
150
151    #[test]
152    fn test_migrations_compile() {
153        // This test verifies that all include_str! paths are valid
154        // If any path is wrong, compilation will fail
155        assert!(!MIGRATIONS.is_empty());
156        assert_eq!(MIGRATIONS.len(), 12);
157    }
158
159    #[test]
160    fn test_run_migrations_fresh_db() {
161        let conn = Connection::open_in_memory().unwrap();
162        setup_db(&conn);
163        run_migrations(&conn).expect("Migrations should apply to fresh database");
164
165        // Verify all migrations are recorded
166        let count: i32 = conn
167            .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| {
168                row.get(0)
169            })
170            .unwrap();
171        assert_eq!(count, 12);
172    }
173
174    #[test]
175    fn test_run_migrations_idempotent() {
176        let conn = Connection::open_in_memory().unwrap();
177        setup_db(&conn);
178
179        // Run twice - should not fail
180        run_migrations(&conn).expect("First run should succeed");
181        run_migrations(&conn).expect("Second run should succeed (idempotent)");
182
183        // Still only 12 migrations recorded
184        let count: i32 = conn
185            .query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| {
186                row.get(0)
187            })
188            .unwrap();
189        assert_eq!(count, 12);
190    }
191}