|
| 1 | +//! Schema migration infrastructure for flowctl-db. |
| 2 | +//! |
| 3 | +//! Tracks schema version in a `_meta` table and runs numbered migrations |
| 4 | +//! sequentially. Migrations are idempotent (safe to re-run). |
| 5 | +
|
| 6 | +use libsql::Connection; |
| 7 | + |
| 8 | +use crate::error::DbError; |
| 9 | + |
| 10 | +/// Current target schema version. Bump this when adding new migrations. |
| 11 | +const TARGET_VERSION: i64 = 2; |
| 12 | + |
| 13 | +/// Ensure `_meta` table exists and run any pending migrations. |
| 14 | +pub async fn migrate(conn: &Connection) -> Result<(), DbError> { |
| 15 | + // Create the _meta table if it doesn't exist. |
| 16 | + conn.execute( |
| 17 | + "CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)", |
| 18 | + (), |
| 19 | + ) |
| 20 | + .await |
| 21 | + .map_err(|e| DbError::Schema(format!("_meta table creation failed: {e}")))?; |
| 22 | + |
| 23 | + let current = get_version(conn).await?; |
| 24 | + |
| 25 | + if current < 2 { |
| 26 | + migrate_v2(conn).await?; |
| 27 | + } |
| 28 | + |
| 29 | + // Update stored version to target. |
| 30 | + if current < TARGET_VERSION { |
| 31 | + set_version(conn, TARGET_VERSION).await?; |
| 32 | + } |
| 33 | + |
| 34 | + Ok(()) |
| 35 | +} |
| 36 | + |
| 37 | +/// Read current schema version from `_meta`. Returns 1 if no version is set |
| 38 | +/// (meaning the DB has the original schema but no migration history). |
| 39 | +async fn get_version(conn: &Connection) -> Result<i64, DbError> { |
| 40 | + let mut rows = conn |
| 41 | + .query( |
| 42 | + "SELECT value FROM _meta WHERE key = 'schema_version'", |
| 43 | + (), |
| 44 | + ) |
| 45 | + .await |
| 46 | + .map_err(|e| DbError::Schema(format!("_meta query failed: {e}")))?; |
| 47 | + |
| 48 | + if let Some(row) = rows |
| 49 | + .next() |
| 50 | + .await |
| 51 | + .map_err(|e| DbError::Schema(format!("_meta row read failed: {e}")))? |
| 52 | + { |
| 53 | + let val: String = row |
| 54 | + .get(0) |
| 55 | + .map_err(|e| DbError::Schema(format!("_meta value read failed: {e}")))?; |
| 56 | + val.parse::<i64>() |
| 57 | + .map_err(|e| DbError::Schema(format!("_meta version parse failed: {e}"))) |
| 58 | + } else { |
| 59 | + // No version stored yet — this is a v1 database (original schema). |
| 60 | + Ok(1) |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +/// Write schema version to `_meta`. |
| 65 | +async fn set_version(conn: &Connection, version: i64) -> Result<(), DbError> { |
| 66 | + conn.execute( |
| 67 | + "INSERT INTO _meta (key, value) VALUES ('schema_version', ?1) \ |
| 68 | + ON CONFLICT(key) DO UPDATE SET value = excluded.value", |
| 69 | + libsql::params![version.to_string()], |
| 70 | + ) |
| 71 | + .await |
| 72 | + .map_err(|e| DbError::Schema(format!("_meta version update failed: {e}")))?; |
| 73 | + Ok(()) |
| 74 | +} |
| 75 | + |
| 76 | +/// Migration v2: Add TTL columns to file_locks. |
| 77 | +/// |
| 78 | +/// - `holder_pid INTEGER` — PID of the process holding the lock |
| 79 | +/// - `expires_at TEXT` — ISO-8601 expiry timestamp for TTL-based cleanup |
| 80 | +/// |
| 81 | +/// Uses `.ok()` on each ALTER TABLE because the column may already exist |
| 82 | +/// on re-run (ALTER TABLE ADD COLUMN is not idempotent in SQLite/libSQL). |
| 83 | +async fn migrate_v2(conn: &Connection) -> Result<(), DbError> { |
| 84 | + let _ = conn |
| 85 | + .execute( |
| 86 | + "ALTER TABLE file_locks ADD COLUMN holder_pid INTEGER", |
| 87 | + (), |
| 88 | + ) |
| 89 | + .await |
| 90 | + .ok(); |
| 91 | + |
| 92 | + let _ = conn |
| 93 | + .execute( |
| 94 | + "ALTER TABLE file_locks ADD COLUMN expires_at TEXT", |
| 95 | + (), |
| 96 | + ) |
| 97 | + .await |
| 98 | + .ok(); |
| 99 | + |
| 100 | + Ok(()) |
| 101 | +} |
| 102 | + |
| 103 | +#[cfg(test)] |
| 104 | +mod tests { |
| 105 | + use super::*; |
| 106 | + use crate::pool; |
| 107 | + |
| 108 | + #[tokio::test] |
| 109 | + async fn test_migrate_fresh_db() { |
| 110 | + let (_db, conn) = pool::open_memory_async().await.unwrap(); |
| 111 | + |
| 112 | + // Verify _meta table exists and version is set. |
| 113 | + let version = get_version(&conn).await.unwrap(); |
| 114 | + assert_eq!(version, TARGET_VERSION, "version should be {TARGET_VERSION} after open"); |
| 115 | + |
| 116 | + // Verify file_locks has the new columns. |
| 117 | + let mut rows = conn |
| 118 | + .query("SELECT name FROM pragma_table_info('file_locks')", ()) |
| 119 | + .await |
| 120 | + .unwrap(); |
| 121 | + |
| 122 | + let mut cols: Vec<String> = Vec::new(); |
| 123 | + while let Some(row) = rows.next().await.unwrap() { |
| 124 | + cols.push(row.get::<String>(0).unwrap()); |
| 125 | + } |
| 126 | + |
| 127 | + assert!(cols.contains(&"holder_pid".to_string()), "holder_pid missing: {cols:?}"); |
| 128 | + assert!(cols.contains(&"expires_at".to_string()), "expires_at missing: {cols:?}"); |
| 129 | + } |
| 130 | + |
| 131 | + #[tokio::test] |
| 132 | + async fn test_migrate_idempotent() { |
| 133 | + let (_db, conn) = pool::open_memory_async().await.unwrap(); |
| 134 | + |
| 135 | + // Run migrate again — should not error. |
| 136 | + migrate(&conn).await.expect("second migrate should be idempotent"); |
| 137 | + |
| 138 | + let version = get_version(&conn).await.unwrap(); |
| 139 | + assert_eq!(version, TARGET_VERSION); |
| 140 | + } |
| 141 | +} |
0 commit comments