|
1 |
+ |
//! Startup closes a schema gap it did not open.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! Reproduces the state that broke every MCP read on 2026-08-28: a database
|
|
4 |
+ |
//! left at an older migration because the desktop app had not been launched
|
|
5 |
+ |
//! since the newer ones landed. The old schema is 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 |
+ |
}
|