Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit 737017c

Browse files
z23ccclaude
andcommitted
feat(flowctl-db): add schema migration infrastructure [fn-110.1]
Add _meta table with schema_version tracking and numbered migration system. Migration v2 adds holder_pid and expires_at columns to file_locks for TTL support. Migrations are idempotent and run automatically on DB open. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 69fa893 commit 737017c

3 files changed

Lines changed: 149 additions & 3 deletions

File tree

flowctl/crates/flowctl-db/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
//!
88
//! - **libSQL is the single source of truth.** All reads and writes go
99
//! through async repository methods. Markdown files are an export format.
10-
//! - **Schema is applied on open** via a single embedded SQL blob. No
11-
//! migrations — this crate assumes a fresh DB.
10+
//! - **Schema is applied on open** via a single embedded SQL blob, then
11+
//! migrations run to upgrade existing databases (see `migration.rs`).
1212
//! - **Connections are cheap clones.** `libsql::Connection` is `Send + Sync`,
1313
//! pass by value. Do not wrap in `Arc<Mutex<_>>`.
1414
//!
@@ -22,6 +22,7 @@ pub mod events;
2222
pub mod indexer;
2323
pub mod memory;
2424
pub mod metrics;
25+
pub mod migration;
2526
pub mod pool;
2627
pub mod repo;
2728
pub mod skill;
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
}

flowctl/crates/flowctl-db/src/pool.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//!
55
//! - **libSQL** is fully async, Tokio-based. All DB calls are `.await`.
66
//! - Schema is applied once on open via `apply_schema()` — a single SQL
7-
//! blob (see `schema.sql`). Fresh DBs only; no migration story.
7+
//! blob (see `schema.sql`). Migrations run after schema init (see `migration.rs`).
88
//! - `libsql::Connection` is cheap and `Clone`. Pass by value; do not wrap
99
//! in `Arc<Mutex<_>>`.
1010
//! - PRAGMAs (WAL, busy_timeout, foreign_keys) are set per-connection on
@@ -23,6 +23,7 @@ use std::process::Command;
2323
use libsql::{Builder, Connection, Database};
2424

2525
use crate::error::DbError;
26+
use crate::migration;
2627

2728
/// Embedded schema applied to fresh databases.
2829
const SCHEMA_SQL: &str = include_str!("schema.sql");
@@ -135,6 +136,7 @@ pub async fn open_async(working_dir: &Path) -> Result<Database, DbError> {
135136
let conn = db.connect()?;
136137
apply_pragmas(&conn).await?;
137138
apply_schema(&conn).await?;
139+
migration::migrate(&conn).await?;
138140

139141
Ok(db)
140142
}
@@ -178,6 +180,7 @@ pub async fn open_memory_async() -> Result<(Database, Connection), DbError> {
178180
let conn = db.connect()?;
179181
apply_pragmas(&conn).await.ok();
180182
apply_schema(&conn).await?;
183+
migration::migrate(&conn).await?;
181184

182185
Ok((db, conn))
183186
}
@@ -223,6 +226,7 @@ mod tests {
223226
"monthly_rollup",
224227
"memory",
225228
"skills",
229+
"_meta",
226230
] {
227231
assert!(
228232
tables.contains(&expected.to_string()),

0 commit comments

Comments
 (0)