wsb 0.258.6441

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

use super::config::Schema;

/// A worktree's database.
mod worktree {
    refinery::embed_migrations!("migrations");
}

/// The machine's sessions database.
mod sessions {
    refinery::embed_migrations!("migrations-sessions");
}

fn runner(schema: Schema) -> refinery::Runner {
    match schema {
        Schema::Worktree => worktree::migrations::runner(),
        Schema::Sessions => sessions::migrations::runner(),
    }
}

pub fn run_migrations(
    mut conn: r2d2::PooledConnection<SqliteConnectionManager>,
    schema: Schema,
) -> anyhow::Result<()> {
    runner(schema).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, per database: neither is all
        // derived, and a person deciding to delete one is deciding about that.
        let path = conn.path().unwrap_or("the database");
        let lost = match schema {
            Schema::Worktree => {
                "this worktree's notes, path notes, tasks, features and workbench layout go with it"
            }
            Schema::Sessions => {
                "sessions are read again from the transcripts that still exist, \
                 sessions you deleted included; stars and pins go with it"
            }
        };
        anyhow::anyhow!(
            "{path} does not match this build's schema. \
             Deleting it lets wsb make a new one on the next start: {lost}. 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 `.wsb/wsb.db` 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
    /// `rm -rf .wsb/wsb.db && 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__schema.sql"),
            include_str!("../../../migrations-sessions/V1__sessions.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.
    ///
    /// The refusal used to say everything in it was derived. A worktree's
    /// database holds its notes and tasks, which nothing rebuilds, and a person
    /// told otherwise deletes them believing nothing is lost.
    #[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"), super::Schema::Worktree)
            .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 lost in ["notes", "tasks", "workbench layout"] {
            assert!(error.contains(lost), "deleting it loses its {lost}, and it does not say so: {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.
        //
        // The worktree's V1 replaced the `V1__base_schema` wsb shipped before
        // it took sdx's schema. A database that applied that one is refused by
        // `run_migrations`, which says to delete it and have it rebuilt.
        assert_frozen(
            super::Schema::Worktree,
            &[
                (1, "schema", "16186640057407429720"),
                (2, "github_mirror_moved", "1355168889716370572"),
                (3, "sessions_moved", "12725709392405332032"),
            ],
        );
        assert_frozen(super::Schema::Sessions, &[(1, "sessions", "12586225742379135953")]);
    }

    fn assert_frozen(schema: super::Schema, frozen: &[(u32, &str, &str)]) {
        let mut applied: Vec<(u32, String, String)> = super::runner(schema)
            .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(),
            "{schema:?}: 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, "{schema:?}: migration order changed");
            assert_eq!(name, want_name, "{schema:?}: V{version} was renamed");
            assert_eq!(
                checksum, want_checksum,
                "{schema:?}: 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."
            );
        }
    }
}