Skip to main content

max / goingson

Run pending migrations at go-mcp startup go-mcp selected columns it never applied, so any window where the desktop app had not been launched since a migration landed broke every MCP read with a bare "no such column". Startup now migrates the database it opens, or refuses to serve with a message naming the file and the by-hand command. Also stop complete_recurring holding two pool connections at once: the read's connection was shadowed rather than dropped.
Author: Max Johnson <me@maxj.phd> · 2026-08-28 20:03 UTC
Signed with PGP, not checked
Commit: 2bd36bc8e9ba9085ed9c6156495e15eb9e2fb170
Parent: 975dc62
7 files changed, +163 insertions, -7 deletions
M Cargo.lock +5 -4
@@ -2187,6 +2187,7 @@
2187 2187 "painhours",
2188 2188 "rusqlite",
2189 2189 "serde_json",
2190 + "sha2 0.11.0",
2190 2191 "tokio",
2191 2192 "tracing",
2192 2193 "tracing-subscriber",
@@ -8504,10 +8505,6 @@
8504 8505 "winnow 1.0.4",
8505 8506 ]
8506 8507
8507 - [[patch.unused]]
8508 - name = "ops-status"
8509 - version = "0.1.0"
8510 -
8511 8508 [[patch.unused]]
8512 8509 name = "quasi-axum"
8513 8510 version = "0.75.0"
@@ -8523,3 +8520,7 @@
8523 8520 [[patch.unused]]
8524 8521 name = "quasi-store"
8525 8522 version = "0.1.0"
8523 +
8524 + [[patch.unused]]
8525 + name = "ops-status"
8526 + version = "0.1.0"
@@ -30,6 +30,8 @@
30 30 [dev-dependencies]
31 31 # Seeding the fixed desktop user in the e2e test, one raw INSERT.
32 32 rusqlite = { workspace = true }
33 + # Recording ledger rows the migration runner will accept, in the startup test.
34 + sha2 = { workspace = true }
33 35
34 36 [lints]
35 37 workspace = true
@@ -9,4 +9,5 @@
9 9 pub mod caps;
10 10 pub mod context;
11 11 pub mod convert;
12 + pub mod startup;
12 13 pub mod tools;
@@ -134,6 +134,7 @@
134 134 }
135 135
136 136 let db = goingson_db_sqlite::init_pool(Some(&db_path.to_string_lossy()))?;
137 + go_mcp::startup::migrate(&db, &db_path)?;
137 138 let ctx = Arc::new(Ctx::new(db));
138 139 let registry = tools::registry(ctx);
139 140
@@ -1015,9 +1015,14 @@
1015 1015 user_id: UserId,
1016 1016 next: Option<NewTask>,
1017 1017 ) -> Result<(Option<Task>, Option<Task>)> {
1018 - let conn = self.db.conn()?;
1019 - let Some(task) = get_task_by_id(&conn, id, user_id)? else {
1020 - return Ok((None, None));
1018 + // Scoped so the read's connection goes back to the pool before the
1019 + // write checks one out: shadowing it instead held two at once.
1020 + let task = {
1021 + let conn = self.db.conn()?;
1022 + let Some(task) = get_task_by_id(&conn, id, user_id)? else {
1023 + return Ok((None, None));
1024 + };
1025 + task
1021 1026 };
1022 1027
1023 1028 if task.status == TaskStatus::Completed {
@@ -1,0 +1,27 @@
1 + //! Startup checks the binary owes the database it opens.
2 + //!
3 + //! go-mcp is built from the same source as the desktop app and selects the
4 + //! columns that source assumes, so it has to apply pending migrations itself.
5 + //! Leaving that to the app made every window where the app had not been
6 + //! launched since a migration landed a window where every MCP read failed with
7 + //! a bare `no such column`, mid-session and with no clue attached.
8 +
9 + use std::path::Path;
10 +
11 + use goingson_db_sqlite::Db;
12 +
13 + /// Apply pending migrations, or explain what to run by hand.
14 + ///
15 + /// The error names the database and the command that closes the gap, because
16 + /// the failure a caller sees otherwise is a missing column with no context.
17 + pub fn migrate(db: &Db, db_path: &Path) -> Result<(), String> {
18 + goingson_db_sqlite::run_migrations(db).map_err(|e| {
19 + format!(
20 + "could not migrate {path}: {e}\n\
21 + go-mcp will not serve a database whose schema it does not match.\n\
22 + Close GoingsOn, back the file up, and run:\n \
23 + cargo run -p goingson-db-sqlite --example migrate -- {path}",
24 + path = db_path.display()
25 + )
26 + })
27 + }
@@ -1,0 +1,119 @@
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 + }