use r2d2_sqlite::SqliteConnectionManager;
mod store {
refinery::embed_migrations!("migrations");
}
pub fn runner() -> refinery::Runner {
store::migrations::runner()
}
pub fn run_migrations(mut conn: r2d2::PooledConnection<SqliteConnectionManager>) -> anyhow::Result<()> {
runner().run(&mut *conn).map_err(|e| {
let path = conn.path().unwrap_or("the database");
anyhow::anyhow!(
"{path} does not match this build's schema. Deleting it lets wsb make a new one on the \
next start: sessions are read again from the transcripts that still exist, sessions \
you deleted included; stars, every worktree's workbench layout, the GitHub mirror and \
the peers this machine is paired with go with it. Cause: {e}"
)
})?;
tracing::debug!("Database migrations completed");
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn test12071_the_schema_applies_over_an_existing_one() {
for sql in [
include_str!("../../../migrations/V1__sessions.sql"),
include_str!("../../../migrations/V3__worktrees.sql"),
] {
let conn = rusqlite::Connection::open_in_memory().expect("a database");
conn.execute_batch(sql).expect("the first application");
conn.execute_batch(sql).expect(
"applying the schema over one that already exists failed; a second \
opener -- the daemon recreating the file it was just deleted from, \
or any concurrent command -- then dies on a table the first one made",
);
}
}
#[test]
fn test20049_an_old_database_is_refused_naming_what_deleting_it_loses() {
let dir = tempfile::tempdir().expect("a directory");
let path = dir.path().join("wsb.db");
let pool = r2d2::Pool::new(r2d2_sqlite::SqliteConnectionManager::file(&path)).expect("a pool");
let old = refinery::Migration::unapplied("V1__base_schema", "CREATE TABLE notes (id INTEGER);")
.expect("a migration");
refinery::Runner::new(&[old])
.run(&mut *pool.get().expect("a connection"))
.expect("the old schema applies");
let error = super::run_migrations(pool.get().expect("a connection"))
.expect_err("a database another schema made was opened")
.to_string();
assert!(error.contains(&path.display().to_string()), "the file is not named: {error}");
for said in ["read again from the transcripts", "stars", "workbench layout", "peers"] {
assert!(error.contains(said), "what deleting it costs is not said ({said}): {error}");
}
}
#[test]
fn test8925_shipped_migrations_are_frozen() {
assert_frozen(&[
(1, "sessions", "12586225742379135953"),
(2, "pins_become_links", "4119140230197863457"),
(3, "worktrees", "8625481080213569523"),
(4, "peers", "12447777801135994140"),
(5, "workbench_layout", "13148491620969662396"),
]);
}
fn assert_frozen(frozen: &[(u32, &str, &str)]) {
let mut applied: Vec<(u32, String, String)> = super::runner()
.get_migrations()
.iter()
.map(|m| (m.version(), m.name().to_string(), m.checksum().to_string()))
.collect();
applied.sort_by_key(|(version, ..)| *version);
assert_eq!(
applied.len(),
frozen.len(),
"the migration set changed: {:?} on disk, {:?} pinned. A NEW \
migration is fine — add its (version, name, checksum) to `frozen`. A \
migration that disappeared is not: every database that ran it still \
records it.",
applied.iter().map(|(v, n, c)| (*v, n.as_str(), c.as_str())).collect::<Vec<_>>(),
frozen.iter().map(|(v, n, _)| (*v, *n)).collect::<Vec<_>>(),
);
for ((version, name, checksum), (want_version, want_name, want_checksum)) in
applied.iter().zip(frozen)
{
assert_eq!(version, want_version, "migration order changed");
assert_eq!(name, want_name, "V{version} was renamed");
assert_eq!(
checksum, want_checksum,
"V{version}__{name}.sql was edited after it shipped. Every \
database that already applied it refuses to open against the new text. \
Revert the edit and put the change in the next migration file."
);
}
}
#[test]
fn test20180_a_worktrees_old_layout_keeps_only_its_open_folders() {
let dir = tempfile::tempdir().expect("a scratch directory");
let path = dir.path().join("wsb.db");
let mut conn = rusqlite::Connection::open(&path).expect("a database");
super::runner()
.set_target(refinery::Target::Version(4))
.run(&mut conn)
.expect("the earlier schema");
conn.execute(
"INSERT INTO workbench_state (worktree, state_json, updated_at) VALUES ('fleet/main', ?1, '')",
[r#"{"groups":[{"id":"group:1","tabs":[]}],"active_group_id":"group:1","expanded_paths":["src","docs"],"sidebar_open":true}"#],
)
.expect("an old layout");
conn.execute(
"INSERT INTO workbench_state (worktree, state_json, updated_at) VALUES ('fleet/fix', ?1, '')",
[r#"{"groups":[],"active_group_id":"group:1"}"#],
)
.expect("an old layout without folders");
super::runner().run(&mut conn).expect("migrated");
let read = |key: &str| -> serde_json::Value {
let raw: String = conn.query_row("SELECT state_json FROM workbench_state WHERE worktree = ?1", [key], |row| row.get(0)).expect("a row");
serde_json::from_str(&raw).expect("JSON")
};
assert_eq!(read("fleet/main"), serde_json::json!({ "expanded_paths": ["src", "docs"] }));
assert_eq!(read("fleet/fix"), serde_json::json!({ "expanded_paths": [] }));
let layouts: i64 = conn.query_row("SELECT count(*) FROM workbench_layout", [], |row| row.get(0)).expect("the table exists");
assert_eq!(layouts, 0, "no machine layout is invented from a worktree's");
}
}