use std::fmt;
use crate::store::{StoreError, StoreResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct MigrationId {
pub major: u32,
pub minor: u32,
pub ordinal: u32,
}
impl MigrationId {
fn parse_version(version: &str) -> Option<Self> {
let (id, _name) = version.split_once('_')?;
let mut parts = id.split('.');
let mut number = || parts.next()?.parse().ok();
let (major, minor, ordinal) = (number()?, number()?, number()?);
if parts.next().is_some() {
return None;
}
Some(MigrationId {
major,
minor,
ordinal,
})
}
}
impl fmt::Display for MigrationId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{:03}", self.major, self.minor, self.ordinal)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Migration {
pub id: MigrationId,
pub name: &'static str,
pub sql: &'static str,
}
impl Migration {
pub fn version(&self) -> String {
format!("{}_{}", self.id, self.name)
}
}
const MIGRATIONS: &[Migration] = &[
Migration {
id: MigrationId {
major: 0,
minor: 10,
ordinal: 1,
},
name: "initial",
sql: include_str!("migrations/0.10.001_initial.sql"),
},
Migration {
id: MigrationId {
major: 0,
minor: 10,
ordinal: 2,
},
name: "session_execution_context",
sql: include_str!("migrations/0.10.002_session_execution_context.sql"),
},
];
const LEGACY_BASELINE_VERSION: &str = "001_initial";
const RECREATE_MESSAGE: &str =
"incompatible Loopflow database; delete loopflow.db and rerun the command";
const NEWER_MESSAGE: &str =
"loopflow.db was written by a newer Loopflow; upgrade lf to open this database";
pub fn active_namespace() -> (u32, u32) {
let version = env!("CARGO_PKG_VERSION");
let mut parts = version.split('.');
let major = parts.next().and_then(|part| part.parse().ok());
let minor = parts.next().and_then(|part| part.parse().ok());
match (major, minor) {
(Some(major), Some(minor)) => (major, minor),
_ => panic!("package version {version} is not major.minor.patch"),
}
}
pub fn apply_sqlite(conn: &rusqlite::Connection) -> StoreResult<()> {
conn.execute_batch("BEGIN EXCLUSIVE")?;
let result = apply_set(conn, MIGRATIONS);
match result {
Ok(()) => {
conn.execute_batch("COMMIT")?;
Ok(())
}
Err(error) => {
let _ = conn.execute_batch("ROLLBACK");
Err(error)
}
}
}
fn apply_set(conn: &rusqlite::Connection, set: &[Migration]) -> StoreResult<()> {
validate_set(set).map_err(StoreError::InvalidData)?;
let tables = user_tables(conn)?;
if !tables.iter().any(|table| table == "schema_migrations") {
if !tables.is_empty() {
return Err(incompatible());
}
conn.execute_batch(
"CREATE TABLE schema_migrations (
version TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
);",
)?;
}
adopt_legacy_baseline(conn, set)?;
let applied = applied_versions(conn)?;
for migration in pending_migrations(&applied, set)? {
conn.execute_batch(migration.sql)?;
conn.execute(
"INSERT INTO schema_migrations (version, applied_at) VALUES (?1, unixepoch())",
[migration.version()],
)?;
}
validate_schema(conn, set)
}
fn adopt_legacy_baseline(conn: &rusqlite::Connection, set: &[Migration]) -> StoreResult<()> {
if applied_versions(conn)?.as_slice() != [LEGACY_BASELINE_VERSION] {
return Ok(());
}
let Some(baseline) = set.first() else {
return Err(incompatible());
};
conn.execute(
"UPDATE schema_migrations SET version = ?1 WHERE version = ?2",
[baseline.version(), LEGACY_BASELINE_VERSION.to_string()],
)?;
Ok(())
}
fn pending_migrations<'a>(
applied: &[String],
set: &'a [Migration],
) -> StoreResult<&'a [Migration]> {
let known: Vec<String> = set.iter().map(Migration::version).collect();
for version in applied {
if known.contains(version) {
continue;
}
return match MigrationId::parse_version(version) {
Some(_) => Err(StoreError::InvalidData(NEWER_MESSAGE.to_string())),
None => Err(incompatible()),
};
}
if applied.len() > known.len() || *applied != known[..applied.len()] {
return Err(incompatible());
}
Ok(&set[applied.len()..])
}
fn user_tables(conn: &rusqlite::Connection) -> StoreResult<Vec<String>> {
let mut statement = conn.prepare(
"SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name",
)?;
let rows = statement.query_map([], |row| row.get(0))?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
}
fn applied_versions(conn: &rusqlite::Connection) -> StoreResult<Vec<String>> {
let mut statement =
conn.prepare("SELECT version FROM schema_migrations ORDER BY applied_at, rowid")?;
let rows = statement.query_map([], |row| row.get(0))?;
Ok(rows.collect::<Result<Vec<_>, _>>()?)
}
pub fn validate_set(set: &[Migration]) -> Result<(), String> {
let mut previous: Option<&Migration> = None;
for migration in set {
if let Some(previous) = previous {
if migration.id <= previous.id {
return Err(format!(
"migration {} does not come after {}",
migration.version(),
previous.version()
));
}
}
previous = Some(migration);
}
Ok(())
}
fn validate_schema(conn: &rusqlite::Connection, set: &[Migration]) -> StoreResult<()> {
let expected = rusqlite::Connection::open_in_memory()?;
for migration in set {
expected.execute_batch(migration.sql)?;
}
if product_schema(conn)? != product_schema(&expected)? {
return Err(incompatible());
}
Ok(())
}
fn product_schema(conn: &rusqlite::Connection) -> StoreResult<Vec<(String, Vec<String>)>> {
let mut schema = Vec::new();
for table in user_tables(conn)? {
if table == "schema_migrations" {
continue;
}
let mut statement = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let rows = statement.query_map([], |row| row.get(1))?;
schema.push((table, rows.collect::<Result<Vec<_>, _>>()?));
}
Ok(schema)
}
pub fn latest_version_sqlite(conn: &rusqlite::Connection) -> StoreResult<String> {
let applied = applied_versions(conn)?;
if !pending_migrations(&applied, MIGRATIONS)?.is_empty() {
return Err(incompatible());
}
validate_schema(conn, MIGRATIONS)?;
applied.last().cloned().ok_or_else(incompatible)
}
fn incompatible() -> StoreError {
StoreError::InvalidData(RECREATE_MESSAGE.to_string())
}
#[cfg(test)]
mod tests {
use super::{
active_namespace, applied_versions, apply_set, apply_sqlite, latest_version_sqlite,
product_schema, validate_set, Migration, MigrationId, MIGRATIONS,
};
const SECOND_IN_SAME_MINOR: Migration = Migration {
id: MigrationId {
major: 0,
minor: 10,
ordinal: 2,
},
name: "add_note",
sql: "ALTER TABLE waves ADD COLUMN note TEXT;",
};
const FIRST_IN_NEXT_MINOR: Migration = Migration {
id: MigrationId {
major: 0,
minor: 11,
ordinal: 1,
},
name: "add_colour",
sql: "ALTER TABLE waves ADD COLUMN colour TEXT;",
};
fn open() -> rusqlite::Connection {
rusqlite::Connection::open_in_memory().unwrap()
}
fn baseline() -> Migration {
MIGRATIONS[0]
}
fn columns(conn: &rusqlite::Connection, table: &str) -> Vec<String> {
product_schema(conn)
.unwrap()
.into_iter()
.find(|(name, _)| name == table)
.map(|(_, columns)| columns)
.unwrap()
}
#[test]
fn the_shipped_set_is_ordered_and_within_the_active_namespace() {
validate_set(MIGRATIONS).unwrap();
let active = active_namespace();
for migration in MIGRATIONS {
assert!(
(migration.id.major, migration.id.minor) <= active,
"{} is namespaced ahead of the package version",
migration.version()
);
}
}
#[test]
fn every_migration_file_is_registered_under_its_own_name() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/store/migrations");
let mut on_disk: Vec<String> = std::fs::read_dir(dir)
.unwrap()
.map(|entry| {
entry
.unwrap()
.path()
.file_stem()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
on_disk.sort();
let mut registered: Vec<String> = MIGRATIONS.iter().map(Migration::version).collect();
registered.sort();
assert_eq!(on_disk, registered);
}
#[test]
fn a_fresh_database_applies_the_whole_chain_once() {
let conn = open();
conn.execute_batch("PRAGMA foreign_keys = ON").unwrap();
apply_sqlite(&conn).unwrap();
assert_eq!(
latest_version_sqlite(&conn).unwrap(),
"0.10.002_session_execution_context"
);
assert!(product_schema(&conn)
.unwrap()
.iter()
.any(|(table, _)| table == "task_sessions"));
apply_sqlite(&conn).unwrap();
assert_eq!(
applied_versions(&conn).unwrap(),
vec![
"0.10.001_initial".to_string(),
"0.10.002_session_execution_context".to_string()
]
);
}
#[test]
fn a_legacy_baseline_database_is_adopted_and_upgraded_without_data_loss() {
let conn = open();
apply_set(&conn, &[baseline()]).unwrap();
conn.execute(
"UPDATE schema_migrations SET version = '001_initial' WHERE version = ?1",
[baseline().version()],
)
.unwrap();
conn.execute(
"INSERT INTO waves (id, name, repo, created_at) VALUES ('w1', 'infra', '/repo', 1)",
[],
)
.unwrap();
apply_set(
&conn,
&[baseline(), SECOND_IN_SAME_MINOR, FIRST_IN_NEXT_MINOR],
)
.unwrap();
assert_eq!(
applied_versions(&conn).unwrap(),
vec![
"0.10.001_initial",
"0.10.002_add_note",
"0.11.001_add_colour"
]
);
let name: String = conn
.query_row("SELECT name FROM waves WHERE id = 'w1'", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(name, "infra");
}
#[test]
fn several_migrations_in_one_minor_release_apply_in_order() {
let conn = open();
apply_set(&conn, &[baseline(), SECOND_IN_SAME_MINOR]).unwrap();
assert_eq!(
applied_versions(&conn).unwrap(),
vec!["0.10.001_initial", "0.10.002_add_note"]
);
assert!(columns(&conn, "waves").contains(&"note".to_string()));
}
#[test]
fn a_database_from_the_previous_minor_applies_only_the_new_namespace() {
let conn = open();
apply_set(&conn, &[baseline(), SECOND_IN_SAME_MINOR]).unwrap();
let next = [baseline(), SECOND_IN_SAME_MINOR, FIRST_IN_NEXT_MINOR];
apply_set(&conn, &next).unwrap();
apply_set(&conn, &next).unwrap();
assert_eq!(
applied_versions(&conn).unwrap(),
vec![
"0.10.001_initial",
"0.10.002_add_note",
"0.11.001_add_colour"
]
);
assert_eq!(
columns(&conn, "waves")
.iter()
.filter(|column| *column == "colour")
.count(),
1
);
}
#[test]
fn a_database_from_a_newer_loopflow_asks_for_an_upgrade() {
let conn = open();
apply_set(&conn, &[baseline(), FIRST_IN_NEXT_MINOR]).unwrap();
let error = apply_set(&conn, &[baseline()]).unwrap_err();
assert!(error.to_string().contains("upgrade lf"));
}
#[test]
fn a_database_from_the_abandoned_flat_ledger_tells_the_user_to_recreate() {
let conn = open();
conn.execute_batch(
"CREATE TABLE schema_migrations (
version TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
);
INSERT INTO schema_migrations VALUES ('001_initial', 1);
INSERT INTO schema_migrations VALUES ('002_stimulus_enabled', 2);
CREATE TABLE waves (id TEXT PRIMARY KEY, workers INTEGER NOT NULL);",
)
.unwrap();
let error = apply_sqlite(&conn).unwrap_err();
assert!(error.to_string().contains("delete loopflow.db"));
}
#[test]
fn a_stale_edit_of_a_shipped_migration_tells_the_user_to_recreate() {
let conn = open();
apply_sqlite(&conn).unwrap();
conn.execute_batch("ALTER TABLE task_sessions DROP COLUMN project_prompt_context")
.unwrap();
let error = apply_sqlite(&conn).unwrap_err();
assert!(error.to_string().contains("delete loopflow.db"));
}
#[test]
fn a_skipped_migration_tells_the_user_to_recreate() {
let conn = open();
apply_set(&conn, &[baseline()]).unwrap();
conn.execute(
"INSERT INTO schema_migrations (version, applied_at) VALUES ('0.11.001_add_colour', 2)",
[],
)
.unwrap();
let error = apply_set(
&conn,
&[baseline(), SECOND_IN_SAME_MINOR, FIRST_IN_NEXT_MINOR],
)
.unwrap_err();
assert!(error.to_string().contains("delete loopflow.db"));
}
#[test]
fn fresh_on_disk_database_reopens_at_the_latest_version() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("loopflow.db");
{
let conn = rusqlite::Connection::open(&path).unwrap();
apply_sqlite(&conn).unwrap();
}
let conn = rusqlite::Connection::open(&path).unwrap();
apply_sqlite(&conn).unwrap();
assert_eq!(
latest_version_sqlite(&conn).unwrap(),
"0.10.002_session_execution_context"
);
}
#[test]
fn unmarked_existing_schema_is_never_adopted() {
let conn = open();
conn.execute_batch("CREATE TABLE waves (id TEXT PRIMARY KEY)")
.unwrap();
let error = apply_sqlite(&conn).unwrap_err();
assert!(error.to_string().contains("delete loopflow.db"));
}
#[test]
fn a_repeated_id_is_rejected_before_anything_runs() {
let error = validate_set(&[baseline(), baseline()]).unwrap_err();
assert!(error.contains("does not come after"));
}
#[test]
fn ids_order_numerically_rather_than_lexically() {
let id = |major, minor, ordinal| MigrationId {
major,
minor,
ordinal,
};
assert!(id(0, 9, 1) < id(0, 10, 1));
assert!(id(0, 10, 2) < id(0, 11, 1));
assert_eq!(id(0, 10, 1).to_string(), "0.10.001");
}
#[test]
fn only_release_scoped_versions_parse() {
assert_eq!(
MigrationId::parse_version("0.10.001_initial"),
Some(MigrationId {
major: 0,
minor: 10,
ordinal: 1,
})
);
assert_eq!(MigrationId::parse_version("001_initial"), None);
assert_eq!(MigrationId::parse_version("0.10.1.2_initial"), None);
assert_eq!(MigrationId::parse_version("0.10.001"), None);
}
}