wsb 0.291.8591

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
use r2d2_sqlite::SqliteConnectionManager;

/// The workshop's database: every agent session on the host, and each
/// worktree's test catalogue and workbench layout.
mod store {
    refinery::embed_migrations!("migrations");
}

/// The migrations embedded in this build.
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| {
        // refinery's own words for a database it cannot reconcile name two
        // 64-bit checksums and nothing else. The useful fact is which file and
        // what to do, in one line -- the schema is in the error it wraps, and
        // repeating a thousand lines of DDL at somebody is not a diagnosis.
        //
        // What deleting it costs is said too: not all of it is derived, and a
        // person deciding to delete it is deciding about that.
        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 workshop is paired with go with it. Cause: {e}"
        )
    })?;

    tracing::debug!("Database migrations completed");
    Ok(())
}

#[cfg(test)]
mod tests {
    /// TEST12071: the schema applies over one that already exists.
    ///
    /// Every CREATE says IF NOT EXISTS, as the eight migrations this was
    /// flattened from did. Dumping `sqlite_master` to build the flattened file
    /// dropped that -- SQLite does not store the clause -- and the result was a
    /// migration that could only ever run against an empty file.
    ///
    /// Two openers race in the ordinary case: deleting the database while the
    /// daemon is running has it recreate the file immediately, and the next
    /// command to open it dies on a table the daemon just made. That is what
    /// deleting it and running `wsb upgrade` did -- it failed with `table
    /// workspace_sections already exists` and a thousand lines of DDL.
    ///
    /// Asserted by applying the schema TWICE to one database, because a single
    /// application passes either way.
    #[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",
            );
        }
    }

    /// TEST20049: a database an older wsb made is refused, saying what deleting
    /// it costs.
    ///
    /// A person deciding whether to delete it is deciding about what is in it:
    /// sessions come back from their transcripts, and stars, layouts, the
    /// mirror and the pairings do not.
    #[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");
        // What the wsb before this schema applied as its first migration.
        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}");
        }
    }

    /// TEST8925: an applied migration is never edited again.
    ///
    /// refinery records a checksum of each migration's text when it applies it,
    /// and refuses to run against a database whose recorded checksum no longer
    /// matches the file — every existing database stops opening, with an error
    /// that names the checksum and nothing else. That is exactly what happened
    /// here: V1 had been edited after it was applied, and the live database
    /// could not be opened until the two were reconciled by hand.
    ///
    /// So the checksums are pinned, for both databases. Editing a migration
    /// that has shipped fails here, at the moment of the edit, with somewhere
    /// to go: add the next migration file instead. Adding a new one is expected
    /// and only needs its checksum recorded below.
    #[test]
    fn test8925_shipped_migrations_are_frozen() {
        // version, name, checksum — as recorded when the migration first ran.
        //
        // V1 and V2 shipped as the workshop's sessions database and keep their
        // checksums, so that database becomes the workshop's by being moved
        // into place (`wsb migrate`); V3 brought each worktree's data in.
        assert_frozen(&[
            (1, "sessions", "12586225742379135953"),
            (2, "pins_become_links", "4119140230197863457"),
            (3, "worktrees", "8625481080213569523"),
            (4, "peers", "12447777801135994140"),
            (5, "workbench_layout", "13148491620969662396"),
            (6, "workshop", "11927605138294332375"),
        ]);
    }

    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();
        // Embedded order is not version order.
        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."
            );
        }
    }

    /// TEST20180: the migration to one workshop layout leaves each worktree its open
    /// folders, and nothing else of what it saved.
    #[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");
        // The schema as the previous wsb left it: migrated up to V4.
        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 workshop layout is invented from a worktree's");
    }

    /// TEST20236: a database from before sessions recorded the workshop they
    /// ran in keeps every session and where it ran, under the new name; the
    /// layout's view of this workshop is found by its new id, and the message
    /// layout it used to keep — now the workshop's appearance — is gone from it.
    #[test]
    fn test20236_sessions_and_the_layout_are_carried_to_the_workshops_names() {
        let dir = tempfile::tempdir().expect("a scratch directory");
        let mut conn = rusqlite::Connection::open(dir.path().join("wsb.db")).expect("a database");
        super::runner()
            .set_target(refinery::Target::Version(5))
            .run(&mut conn)
            .expect("the earlier schema");
        conn.execute("INSERT INTO sessions (id, project, worktree, machine) VALUES ('s1', 'wsb', 'main', 'pve')", [])
            .expect("a session as the earlier schema kept it");
        conn.execute("INSERT INTO sessions (id) VALUES ('s2')", []).expect("a session that ran here");
        conn.execute(
            "INSERT INTO workbench_layout (id, layout_json, updated_at) VALUES (1, ?1, '')",
            [r#"{"workshop":{"view":"machine","width":280},"transcript":{"sortNewestFirst":true,"layout":"compact","mode":"focused"}}"#],
        )
        .expect("a layout as the earlier workbench saved it");
        super::runner().run(&mut conn).expect("migrated");

        let mut rows = conn.prepare("SELECT id, workshop FROM sessions ORDER BY id").expect("the column is the workshop's");
        let ran: Vec<(String, String)> =
            rows.query_map([], |row| Ok((row.get(0)?, row.get(1)?))).expect("rows").map(Result::unwrap).collect();
        assert_eq!(ran, [("s1".to_string(), "pve".to_string()), ("s2".to_string(), "local".to_string())]);
        let indexes: Vec<String> = conn
            .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'sessions' AND sql LIKE '%workshop%'")
            .expect("the index list")
            .query_map([], |row| row.get(0))
            .expect("indexes")
            .map(Result::unwrap)
            .collect();
        assert_eq!(indexes, ["idx_sessions_workshop"]);

        let raw: String = conn.query_row("SELECT layout_json FROM workbench_layout", [], |row| row.get(0)).expect("the layout");
        let layout: serde_json::Value = serde_json::from_str(&raw).expect("JSON");
        assert_eq!(
            layout,
            serde_json::json!({ "workshop": { "view": "this-workshop", "width": 280 }, "transcript": { "sortNewestFirst": true, "mode": "focused" } })
        );
    }
}