Skip to main content

max / goingson

4.5 KB · 120 lines History Blame Raw
1 //! Startup closes a schema gap it did not open.
2 //!
3 //! The state under test is a database left at an older migration because the
4 //! desktop app has not been launched since newer ones landed. That schema is
5 //! built by applying every
6 //! migration file but the last few and recording them in the ledger exactly as
7 //! the runner would, so the test does not name a version and does not go stale
8 //! when the next migration lands.
9
10 use std::path::{Path, PathBuf};
11
12 use rusqlite::Connection;
13 use sha2::{Digest, Sha384};
14
15 /// How many of the newest migrations the seeded database is missing.
16 const WITHHELD: usize = 3;
17
18 fn migrations_dir() -> PathBuf {
19 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../migrations/sqlite")
20 }
21
22 /// Every migration file, in version order, as (version, description, sql).
23 fn migration_files() -> Vec<(i64, String, String)> {
24 let mut out: Vec<(i64, String, String)> = std::fs::read_dir(migrations_dir())
25 .expect("read the migrations directory")
26 .filter_map(|entry| {
27 let path = entry.expect("read a directory entry").path();
28 if path.extension().is_none_or(|ext| ext != "sql") {
29 return None;
30 }
31 let stem = path.file_stem()?.to_str()?.to_owned();
32 let (version, description) = stem.split_once('_')?;
33 Some((
34 version.parse::<i64>().expect("a numeric migration prefix"),
35 description.to_owned(),
36 std::fs::read_to_string(&path).expect("read a migration"),
37 ))
38 })
39 .collect();
40 out.sort_by_key(|(version, _, _)| *version);
41 assert!(
42 out.len() > WITHHELD,
43 "not enough migrations to withhold any"
44 );
45 out
46 }
47
48 /// Versions recorded in the ledger, in order.
49 fn applied(conn: &Connection) -> Vec<i64> {
50 let mut stmt = conn
51 .prepare("SELECT version FROM _sqlx_migrations ORDER BY version")
52 .expect("prepare the ledger read");
53 let rows = stmt
54 .query_map([], |row| row.get::<_, i64>(0))
55 .expect("query the ledger");
56 rows.collect::<Result<_, _>>().expect("collect the ledger")
57 }
58
59 /// Write a database that stops short of the newest migrations.
60 fn seed_old_schema(path: &Path, files: &[(i64, String, String)]) {
61 let conn = Connection::open(path).expect("open the seed database");
62 conn.execute_batch(
63 "CREATE TABLE IF NOT EXISTS _sqlx_migrations (
64 version BIGINT PRIMARY KEY,
65 description TEXT NOT NULL,
66 installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
67 success BOOLEAN NOT NULL,
68 checksum BLOB NOT NULL,
69 execution_time BIGINT NOT NULL
70 );",
71 )
72 .expect("create the ledger");
73
74 for (version, description, sql) in &files[..files.len() - WITHHELD] {
75 conn.execute_batch(sql)
76 .unwrap_or_else(|e| panic!("apply migration {version}: {e}"));
77 conn.execute(
78 "INSERT INTO _sqlx_migrations \
79 (version, description, success, checksum, execution_time) \
80 VALUES (?1, ?2, TRUE, ?3, 0)",
81 rusqlite::params![
82 version,
83 description,
84 Sha384::digest(sql.as_bytes()).to_vec()
85 ],
86 )
87 .expect("record the migration");
88 }
89 }
90
91 #[test]
92 fn startup_migrates_a_database_left_at_an_older_schema() {
93 let files = migration_files();
94 let dir = std::env::temp_dir().join(format!("go-mcp-startup-{}", uuid::Uuid::new_v4()));
95 std::fs::create_dir_all(&dir).expect("make a scratch directory");
96 let path = dir.join("goingson.db");
97 seed_old_schema(&path, &files);
98
99 // The seeded database is genuinely behind.
100 let seeded = applied(&Connection::open(&path).expect("reopen the seed"));
101 assert_eq!(seeded.len(), files.len() - WITHHELD);
102
103 let db = goingson_db_sqlite::init_pool(Some(&path.to_string_lossy())).expect("open the pool");
104 go_mcp::startup::migrate(&db, &path).expect("startup migrates the gap away");
105
106 let after = applied(&db.conn().expect("check out a connection"));
107 let expected: Vec<i64> = files.iter().map(|(version, _, _)| *version).collect();
108 assert_eq!(after, expected, "startup left migrations unapplied");
109
110 // Idempotent: a second startup against the same file is a no-op.
111 go_mcp::startup::migrate(&db, &path).expect("second startup is clean");
112 assert_eq!(
113 applied(&db.conn().expect("check out a connection")),
114 expected
115 );
116
117 drop(db);
118 let _ = std::fs::remove_dir_all(&dir);
119 }
120