Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle: Implement migration callback mechanism
Merged did:key:z6MksFqX...wzpT opened 1 year ago

Add the ability to have native migrations and progress callback functions in migration code for the COB cache.

31 files changed +183 -83 08833985 6c8ee433
modified radicle-cli/src/commands/clone.rs
@@ -9,6 +9,7 @@ use radicle::issue::cache::Issues as _;
use radicle::patch::cache::Patches as _;
use thiserror::Error;

+
use radicle::cob::migrate;
use radicle::git::raw;
use radicle::identity::doc;
use radicle::identity::doc::{DocError, RepoId};
@@ -182,8 +183,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    info.push([term::format::bold(proj.name()).into()]);
    info.push([term::format::italic(proj.description()).into()]);

-
    let issues = profile.issues(&repo)?.counts()?;
-
    let patches = profile.patches(&repo)?.counts()?;
+
    let issues = profile.issues(&repo, migrate::ignore)?.counts()?;
+
    let patches = profile.patches(&repo, migrate::ignore)?.counts()?;

    info.push([term::Line::spaced([
        term::format::tertiary(issues.open).into(),
modified radicle-cli/src/commands/cob.rs
@@ -5,6 +5,7 @@ use anyhow::anyhow;
use chrono::prelude::*;
use nonempty::NonEmpty;
use radicle::cob;
+
use radicle::cob::migrate;
use radicle::cob::Op;
use radicle::identity::Identity;
use radicle::issue::cache::Issues;
@@ -180,13 +181,13 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            let oid = &oid.resolve(&repo.backend)?;

            if options.type_name == cob::patch::TYPENAME.clone() {
-
                let patches = profile.patches(&repo)?;
+
                let patches = profile.patches(&repo, migrate::ignore)?;
                let Some(patch) = patches.get(oid)? else {
                    anyhow::bail!(cob::store::Error::NotFound(options.type_name, *oid))
                };
                serde_json::to_writer_pretty(std::io::stdout(), &patch)?
            } else if options.type_name == cob::issue::TYPENAME.clone() {
-
                let issues = profile.issues(&repo)?;
+
                let issues = profile.issues(&repo, migrate::ignore)?;
                let Some(issue) = issues.get(oid)? else {
                    anyhow::bail!(cob::store::Error::NotFound(options.type_name, *oid))
                };
modified radicle-cli/src/commands/inbox.rs
@@ -6,6 +6,7 @@ use anyhow::anyhow;

use git_ref_format::Qualified;
use localtime::LocalTime;
+
use radicle::cob::migrate;
use radicle::cob::TypedId;
use radicle::identity::Identity;
use radicle::issue::cache::Issues as _;
@@ -271,8 +272,8 @@ where
    let (_, head) = repo.head()?;
    let doc = repo.identity_doc()?;
    let proj = doc.project()?;
-
    let issues = profile.issues(&repo)?;
-
    let patches = profile.patches(&repo)?;
+
    let issues = profile.issues(&repo, migrate::ignore)?;
+
    let patches = profile.patches(&repo, migrate::ignore)?;

    let mut notifs = notifs.by_repo(&rid, sort_by.field)?.collect::<Vec<_>>();
    if !sort_by.reverse {
@@ -535,7 +536,7 @@ fn show(

    match n.kind {
        NotificationKind::Cob { typed_id } if typed_id.is_issue() => {
-
            let issues = profile.issues(&repo)?;
+
            let issues = profile.issues(&repo, migrate::ignore)?;
            let issue = issues.get(&typed_id.id)?.unwrap();

            term::issue::show(
@@ -546,7 +547,7 @@ fn show(
            )?;
        }
        NotificationKind::Cob { typed_id } if typed_id.is_patch() => {
-
            let patches = profile.patches(&repo)?;
+
            let patches = profile.patches(&repo, migrate::ignore)?;
            let patch = patches.get(&typed_id.id)?.unwrap();

            term::patch::show(&patch, &typed_id.id, false, &repo, None, profile)?;
modified radicle-cli/src/commands/issue.rs
@@ -8,9 +8,8 @@ use std::str::FromStr;
use anyhow::{anyhow, Context as _};

use radicle::cob::common::{Label, Reaction};
-
use radicle::cob::issue;
use radicle::cob::issue::{CloseReason, State};
-
use radicle::cob::thread;
+
use radicle::cob::{issue, migrate, thread};
use radicle::crypto::Signer;
use radicle::issue::cache::Issues as _;
use radicle::prelude::{Did, RepoId};
@@ -461,7 +460,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                | Operation::Comment { .. }
        );

-
    let mut issues = profile.issues_mut(&repo)?;
+
    let mut issues = profile.issues_mut(&repo, migrate::ignore)?;

    match options.op {
        Operation::Edit {
modified radicle-cli/src/commands/issue/cache.rs
@@ -1,5 +1,6 @@
use std::ops::ControlFlow;

+
use radicle::cob::migrate;
use radicle::issue::IssueId;
use radicle::storage::git::Repository;
use radicle::storage::ReadStorage as _;
@@ -37,8 +38,9 @@ pub fn run(mode: CacheMode, profile: &Profile) -> anyhow::Result<()> {
}

fn cache(id: Option<IssueId>, repository: &Repository, profile: &Profile) -> anyhow::Result<()> {
-
    let mut issues = profile.issues_mut(repository)?;
+
    let mut issues = profile.issues_mut(repository, migrate::ignore)?;

+
    // FIXME: Should this change?
    match id {
        Some(id) => {
            issues.write(&id)?;
@@ -48,7 +50,7 @@ fn cache(id: Option<IssueId>, repository: &Repository, profile: &Profile) -> any
            match result {
                Ok((id, _)) => term::success!(
                    "Successfully cached issue {id} ({}/{})",
-
                    progress.seen(),
+
                    progress.current(),
                    progress.total()
                ),
                Err(e) => term::warning(format!("Failed to retrieve issue: {e}")),
modified radicle-cli/src/commands/patch.rs
@@ -37,7 +37,7 @@ use std::ffi::OsString;
use anyhow::anyhow;

use radicle::cob::patch::PatchId;
-
use radicle::cob::{patch, Label};
+
use radicle::cob::{migrate, patch, Label};
use radicle::git::RefString;
use radicle::patch::cache::Patches as _;
use radicle::storage::git::transport;
@@ -928,7 +928,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            label::run(&patch_id, add, delete, &profile, &repository)?;
        }
        Operation::Set { patch_id, remote } => {
-
            let patches = profile.patches(&repository)?;
+
            let patches = profile.patches(&repository, migrate::ignore)?;
            let patch_id = patch_id.resolve(&repository.backend)?;
            let patch = patches
                .get(&patch_id)?
modified radicle-cli/src/commands/patch/archive.rs
@@ -1,5 +1,6 @@
use super::*;

+
use radicle::cob::migrate;
use radicle::prelude::*;
use radicle::storage::git::Repository;

@@ -10,7 +11,7 @@ pub fn run(
    repository: &Repository,
) -> anyhow::Result<()> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository)?;
+
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
    let Ok(mut patch) = patches.get_mut(patch_id) else {
        anyhow::bail!("Patch `{patch_id}` not found");
    };
modified radicle-cli/src/commands/patch/assign.rs
@@ -2,6 +2,7 @@ use std::collections::BTreeSet;

use super::*;

+
use radicle::cob::migrate;
use radicle::prelude::Did;
use radicle::storage::git::Repository;

@@ -15,7 +16,7 @@ pub fn run(
    repository: &Repository,
) -> anyhow::Result<()> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository)?;
+
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
    let Ok(mut patch) = patches.get_mut(patch_id) else {
        anyhow::bail!("Patch `{patch_id}` not found");
    };
modified radicle-cli/src/commands/patch/cache.rs
@@ -1,5 +1,6 @@
use std::ops::ControlFlow;

+
use radicle::cob::migrate;
use radicle::patch::PatchId;
use radicle::storage::git::Repository;
use radicle::storage::ReadStorage as _;
@@ -37,7 +38,7 @@ pub fn run(mode: CacheMode, profile: &Profile) -> anyhow::Result<()> {
}

fn cache(id: Option<PatchId>, repository: &Repository, profile: &Profile) -> anyhow::Result<()> {
-
    let mut patches = profile.patches_mut(repository)?;
+
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;

    match id {
        Some(id) => {
@@ -48,7 +49,7 @@ fn cache(id: Option<PatchId>, repository: &Repository, profile: &Profile) -> any
            match result {
                Ok((id, _)) => term::success!(
                    "Successfully cached patch {id} ({}/{})",
-
                    progress.seen(),
+
                    progress.current(),
                    progress.total()
                ),
                Err(e) => term::warning(format!("Failed to retrieve patch: {e}")),
modified radicle-cli/src/commands/patch/checkout.rs
@@ -1,6 +1,7 @@
use anyhow::anyhow;

use git_ref_format::Qualified;
+
use radicle::cob::migrate;
use radicle::cob::patch;
use radicle::cob::patch::RevisionId;
use radicle::git::RefString;
@@ -38,7 +39,7 @@ pub fn run(
    profile: &Profile,
    opts: Options,
) -> anyhow::Result<()> {
-
    let patches = profile.patches(stored)?;
+
    let patches = profile.patches(stored, migrate::ignore)?;
    let patch = patches
        .get(patch_id)?
        .ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;
modified radicle-cli/src/commands/patch/comment.rs
@@ -1,8 +1,8 @@
use super::*;

use radicle::cob;
-
use radicle::cob::patch;
use radicle::cob::thread::CommentId;
+
use radicle::cob::{migrate, patch};
use radicle::patch::ByRevision;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@@ -20,7 +20,7 @@ pub fn run(
    profile: &Profile,
) -> anyhow::Result<()> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repo)?;
+
    let mut patches = profile.patches_mut(repo, migrate::ignore)?;

    let revision_id = revision_id.resolve::<cob::EntryId>(&repo.backend)?;
    let ByRevision {
modified radicle-cli/src/commands/patch/delete.rs
@@ -1,3 +1,4 @@
+
use radicle::cob::migrate;
use radicle::prelude::*;
use radicle::storage::git::Repository;

@@ -5,7 +6,7 @@ use super::*;

pub fn run(patch_id: &PatchId, profile: &Profile, repository: &Repository) -> anyhow::Result<()> {
    let signer = &term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository)?;
+
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
    patches.remove(patch_id, signer)?;

    Ok(())
modified radicle-cli/src/commands/patch/diff.rs
@@ -1,6 +1,6 @@
use std::process;

-
use radicle::cob::patch;
+
use radicle::cob::{migrate, patch};
use radicle::storage::git::Repository;

use super::*;
@@ -11,7 +11,7 @@ pub fn run(
    stored: &Repository,
    profile: &Profile,
) -> anyhow::Result<()> {
-
    let patches = profile.patches(stored)?;
+
    let patches = profile.patches(stored, migrate::ignore)?;
    let Some(patch) = patches.get(patch_id)? else {
        anyhow::bail!("Patch `{patch_id}` not found");
    };
modified radicle-cli/src/commands/patch/edit.rs
@@ -1,6 +1,7 @@
use super::*;

-
use radicle::cob::{self, patch};
+
use radicle::cob;
+
use radicle::cob::{migrate, patch};
use radicle::crypto;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@@ -15,7 +16,7 @@ pub fn run(
    repository: &Repository,
) -> anyhow::Result<()> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository)?;
+
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
    let Ok(patch) = patches.get_mut(patch_id) else {
        anyhow::bail!("Patch `{patch_id}` not found");
    };
modified radicle-cli/src/commands/patch/label.rs
@@ -1,5 +1,6 @@
use super::*;

+
use radicle::cob::migrate;
use radicle::storage::git::Repository;

use crate::terminal as term;
@@ -12,7 +13,7 @@ pub fn run(
    repository: &Repository,
) -> anyhow::Result<()> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository)?;
+
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
    let Ok(mut patch) = patches.get_mut(patch_id) else {
        anyhow::bail!("Patch `{patch_id}` not found");
    };
modified radicle-cli/src/commands/patch/list.rs
@@ -1,7 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};

-
use radicle::cob::patch;
use radicle::cob::patch::{Patch, PatchId};
+
use radicle::cob::{migrate, patch};
use radicle::patch::cache::Patches as _;
use radicle::prelude::*;
use radicle::profile::Profile;
@@ -21,7 +21,7 @@ pub fn run(
    repository: &Repository,
    profile: &Profile,
) -> anyhow::Result<()> {
-
    let patches = profile.patches(repository)?;
+
    let patches = profile.patches(repository, migrate::ignore)?;

    let mut all = Vec::new();
    let iter = match filter {
modified radicle-cli/src/commands/patch/ready.rs
@@ -1,5 +1,6 @@
use super::*;

+
use radicle::cob::migrate;
use radicle::prelude::*;
use radicle::storage::git::Repository;

@@ -10,7 +11,7 @@ pub fn run(
    repository: &Repository,
) -> anyhow::Result<bool> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository)?;
+
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
    let Ok(mut patch) = patches.get_mut(patch_id) else {
        anyhow::bail!("Patch `{patch_id}` not found");
    };
modified radicle-cli/src/commands/patch/redact.rs
@@ -1,4 +1,4 @@
-
use radicle::cob::patch;
+
use radicle::cob::{migrate, patch};
use radicle::git::Oid;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@@ -13,7 +13,7 @@ pub fn run(
    repository: &Repository,
) -> anyhow::Result<()> {
    let signer = &term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository)?;
+
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;

    let revision_id = revision_id.resolve::<Oid>(&repository.backend)?;
    let patch::ByRevision {
modified radicle-cli/src/commands/patch/resolve.rs
@@ -1,4 +1,5 @@
use anyhow::anyhow;
+
use radicle::cob::migrate;
use radicle::cob::thread::CommentId;
use radicle::patch::{self, PatchId};
use radicle::patch::{cache::Patches as _, ReviewId};
@@ -15,7 +16,7 @@ pub fn resolve(
    profile: &Profile,
) -> anyhow::Result<()> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repo)?;
+
    let mut patches = profile.patches_mut(repo, migrate::ignore)?;
    let patch = patches
        .get(&patch_id)?
        .ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;
@@ -32,7 +33,7 @@ pub fn unresolve(
    profile: &Profile,
) -> anyhow::Result<()> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repo)?;
+
    let mut patches = profile.patches_mut(repo, migrate::ignore)?;
    let patch = patches
        .get(&patch_id)?
        .ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;
modified radicle-cli/src/commands/patch/review.rs
@@ -3,6 +3,7 @@ mod builder;

use anyhow::{anyhow, Context};

+
use radicle::cob::migrate;
use radicle::cob::patch::{PatchId, RevisionId, Verdict};
use radicle::git;
use radicle::prelude::*;
@@ -61,7 +62,7 @@ pub fn run(
        "couldn't load repository {} from local state",
        repository.id
    ))?;
-
    let mut patches = profile.patches_mut(repository)?;
+
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
    let mut patch = patches
        .get_mut(&patch_id)
        .context(format!("couldn't find patch {patch_id} locally"))?;
modified radicle-cli/src/commands/patch/show.rs
@@ -1,6 +1,6 @@
use std::process;

-
use radicle::cob::patch;
+
use radicle::cob::{migrate, patch};
use radicle::git;
use radicle::storage::git::Repository;

@@ -32,7 +32,7 @@ pub fn run(
    stored: &Repository,
    workdir: Option<&git::raw::Repository>,
) -> anyhow::Result<()> {
-
    let patches = profile.patches(stored)?;
+
    let patches = profile.patches(stored, migrate::ignore)?;
    let Some(patch) = patches.get(patch_id).map_err(|e| Error::WithHint {
        err: e.into(),
        hint: "reset the cache with `rad patch cache` and try again",
modified radicle-cli/src/commands/patch/update.rs
@@ -1,4 +1,4 @@
-
use radicle::cob::patch;
+
use radicle::cob::{migrate, patch};
use radicle::git;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@@ -19,7 +19,7 @@ pub fn run(
    let head_branch = try_branch(workdir.head()?)?;

    let (_, target_oid) = get_merge_target(repository, &head_branch)?;
-
    let mut patches = profile.patches_mut(repository)?;
+
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
    let Ok(mut patch) = patches.get_mut(&patch_id) else {
        anyhow::bail!("Patch `{patch_id}` not found");
    };
modified radicle-cli/src/commands/stats.rs
@@ -3,6 +3,7 @@ use std::path::Path;

use localtime::LocalDuration;
use localtime::LocalTime;
+
use radicle::cob::migrate;
use radicle::git;
use radicle::issue::cache::Issues as _;
use radicle::node::address;
@@ -95,8 +96,8 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

    for repo in storage.repositories()? {
        let repo = storage.repository(repo.rid)?;
-
        let issues = profile.issues(&repo)?.counts()?;
-
        let patches = profile.patches(&repo)?.counts()?;
+
        let issues = profile.issues(&repo, migrate::ignore)?.counts()?;
+
        let patches = profile.patches(&repo, migrate::ignore)?.counts()?;

        stats.local.issues += issues.total();
        stats.local.patches += patches.total();
modified radicle-remote-helper/src/list.rs
@@ -3,6 +3,7 @@ use radicle::profile;
use thiserror::Error;

use radicle::cob;
+
use radicle::cob::migrate;
use radicle::git;
use radicle::storage::git::transport::local::Url;
use radicle::storage::ReadRepository;
@@ -87,7 +88,7 @@ fn patch_refs<R: ReadRepository + cob::Store + 'static>(
    profile: &Profile,
    stored: &R,
) -> Result<(), Error> {
-
    let patches = profile.patches(stored)?;
+
    let patches = profile.patches(stored, migrate::ignore)?;
    for patch in patches.list()? {
        let Ok((id, patch)) = patch else {
            // Ignore patches that fail to decode.
modified radicle-remote-helper/src/push.rs
@@ -9,8 +9,8 @@ use thiserror::Error;

use radicle::cob;
use radicle::cob::object::ParseObjectId;
-
use radicle::cob::patch;
use radicle::cob::patch::cache::Patches as _;
+
use radicle::cob::{migrate, patch};
use radicle::crypto::Signer;
use radicle::explorer::ExplorerResource;
use radicle::git::canonical;
@@ -227,7 +227,7 @@ pub fn run(
                        &nid,
                        &working,
                        stored,
-
                        profile.patches_mut(stored)?,
+
                        profile.patches_mut(stored, migrate::ignore)?,
                        &signer,
                        profile,
                        opts.clone(),
@@ -247,7 +247,7 @@ pub fn run(
                            &nid,
                            &working,
                            stored,
-
                            profile.patches_mut(stored)?,
+
                            profile.patches_mut(stored, migrate::ignore)?,
                            &signer,
                            opts.clone(),
                        )
@@ -320,7 +320,7 @@ pub fn run(
                            &nid,
                            &working,
                            stored,
-
                            profile.patches_mut(stored)?,
+
                            profile.patches_mut(stored, migrate::ignore)?,
                            &signer,
                        )
                    }
modified radicle-tools/src/rad-merge.rs
@@ -2,6 +2,7 @@ use std::collections::HashSet;
use std::env;

use anyhow::{anyhow, bail};
+
use radicle::cob::migrate;
use radicle::cob::patch::{PatchId, RevisionId};
use radicle::git::Oid;
use radicle::storage::ReadStorage;
@@ -26,7 +27,7 @@ fn main() -> anyhow::Result<()> {
    let profile = radicle::Profile::load()?;
    let (working, rid) = radicle::rad::cwd()?;
    let stored = profile.storage.repository(rid)?;
-
    let mut patches = profile.patches_mut(&stored)?;
+
    let mut patches = profile.patches_mut(&stored, migrate::ignore)?;
    let mut patch = patches.get_mut(&pid)?;

    if patch.is_merged() {
modified radicle/src/cob.rs
@@ -12,6 +12,7 @@ pub mod thread;
#[cfg(test)]
pub mod test;

+
pub use cache::{migrate, MigrateCallback};
pub use common::*;
pub use op::{ActorId, Op};
pub use radicle_cob::{
modified radicle/src/cob/cache.rs
@@ -23,7 +23,58 @@ const DB_WRITE_TIMEOUT: time::Duration = time::Duration::from_secs(6);

/// Database migrations.
/// The first migration is the creation of the initial tables.
-
const MIGRATIONS: &[&str] = &[include_str!("cache/migrations/1.sql")];
+
const MIGRATIONS: &[Migration] = &[Migration::Sql(include_str!("cache/migrations/1.sql"))];
+

+
/// Function signature for native migrations.
+
type MigrateFn = fn(&sql::Connection, Progress, &dyn MigrateCallback) -> Result<usize, Error>;
+

+
/// A database migration.
+
enum Migration {
+
    /// Migration written in SQL.
+
    Sql(&'static str),
+
    /// Migration function written in Rust.
+
    #[allow(dead_code)]
+
    Native(MigrateFn),
+
}
+

+
/// Something that can process migration progress.
+
pub trait MigrateCallback {
+
    /// A migration has progressed.
+
    /// The first [`Progress`] parameter refers to the progress within the list of migrations.
+
    /// The second [`Progress`] parameter refers to the progress within the current migration.
+
    fn progress(&mut self, migration: Progress, item: Progress) -> Result<bool, Error>;
+
}
+

+
impl<F> MigrateCallback for F
+
where
+
    F: Fn(Progress, Progress) -> Result<bool, Error>,
+
{
+
    fn progress(&mut self, migration: Progress, item: Progress) -> Result<bool, Error> {
+
        (self)(migration, item)
+
    }
+
}
+

+
/// Migration functions that implement [`MigrateCallback`].
+
pub mod migrate {
+
    use super::*;
+

+
    /// Log progress via installed logger at "info" level.
+
    pub fn log(migration: Progress, item: Progress) -> Result<bool, Error> {
+
        log::info!(
+
            target: "db",
+
            "Migration {}/{} in progress.. ({}%)",
+
            migration.current() + 1,
+
            migration.total(),
+
            item.percentage()
+
        );
+
        Ok(true)
+
    }
+

+
    /// Ignore progress, just migrate.
+
    pub fn ignore(_migration: Progress, _item: Progress) -> Result<bool, Error> {
+
        Ok(true)
+
    }
+
}

#[derive(Error, Debug)]
pub enum Error {
@@ -97,7 +148,7 @@ impl Store<Write> {
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
        let mut db = sql::Connection::open_thread_safe(path)?;
        db.set_busy_timeout(DB_WRITE_TIMEOUT.as_millis() as usize)?;
-
        migrate(&db)?;
+
        migrate(&db, migrate::ignore)?;

        Ok(Self {
            db: Arc::new(db),
@@ -108,7 +159,7 @@ impl Store<Write> {
    /// Create a new in-memory database.
    pub fn memory() -> Result<Self, Error> {
        let db = Arc::new(sql::Connection::open_thread_safe(":memory:")?);
-
        migrate(&db)?;
+
        migrate(&db, migrate::ignore)?;

        Ok(Self {
            db,
@@ -164,17 +215,34 @@ fn bump(db: &sql::Connection) -> Result<usize, Error> {
}

/// Migrate the database to the latest schema.
-
fn migrate(db: &sql::Connection) -> Result<usize, Error> {
+
fn migrate<M>(db: &sql::Connection, mut callback: M) -> Result<usize, Error>
+
where
+
    M: MigrateCallback,
+
{
    let mut version = version(db)?;
-
    for (i, migration) in MIGRATIONS.iter().enumerate() {
-
        if i >= version {
-
            transaction(db, |db| {
-
                db.execute(migration)?;
-
                version = bump(db)?;
+
    let total = MIGRATIONS.len();

-
                Ok::<_, Error>(())
-
            })?;
+
    for (i, migration) in MIGRATIONS.iter().enumerate() {
+
        if i < version {
+
            continue;
        }
+
        transaction(db, |db| {
+
            match migration {
+
                Migration::Sql(query) => {
+
                    db.execute(query)?;
+
                    callback.progress(
+
                        Progress { total, current: i },
+
                        Progress::done(db.change_count()),
+
                    )?;
+
                }
+
                Migration::Native(migrate) => {
+
                    migrate(db, Progress { total, current: i }, &callback)?;
+
                }
+
            }
+
            version = bump(db)?;
+

+
            Ok::<_, Error>(())
+
        })?;
    }
    Ok(version)
}
@@ -283,20 +351,28 @@ impl<T> Remove<T> for NoCache {
///
/// See [`crate::cob::issue::Cache::write_all`] and
/// [`crate::cob::patch::Cache::write_all`].
-
pub struct WriteAllProgress {
+
pub struct Progress {
+
    current: usize,
    total: usize,
-
    seen: usize,
}

-
impl WriteAllProgress {
+
impl Progress {
    /// Create a new progress tracker with the given `total` amount.
    pub fn new(total: usize) -> Self {
-
        Self { total, seen: 0 }
+
        Self { current: 0, total }
+
    }
+

+
    /// Create a new progress tracker that is "done".
+
    pub fn done(total: usize) -> Self {
+
        Self {
+
            current: total,
+
            total,
+
        }
    }

-
    /// Increment the [`WriteAllProgress::seen`] progress.
+
    /// Increment the [`Progress::current`] progress.
    pub fn inc(&mut self) {
-
        self.seen += 1;
+
        self.current += 1;
    }

    /// Return the `total` amount.
@@ -304,9 +380,9 @@ impl WriteAllProgress {
        self.total
    }

-
    /// Return the `seen` amount.
-
    pub fn seen(&self) -> usize {
-
        self.seen
+
    /// Return the `current` amount.
+
    pub fn current(&self) -> usize {
+
        self.current
    }

    /// Return the percentage of the progress made.
@@ -315,6 +391,6 @@ impl WriteAllProgress {
    ///
    /// If the `total` provided is `0`.
    pub fn percentage(&self) -> f32 {
-
        (self.seen as f32 / self.total as f32) * 100.0
+
        (self.current as f32 / self.total as f32) * 100.0
    }
}
modified radicle/src/cob/issue/cache.rs
@@ -143,10 +143,7 @@ impl<'a, R, C> Cache<super::Issues<'a, R>, C> {
    /// or break from the process.
    pub fn write_all(
        &mut self,
-
        on_issue: impl Fn(
-
            &Result<(IssueId, Issue), store::Error>,
-
            &cache::WriteAllProgress,
-
        ) -> ControlFlow<()>,
+
        on_issue: impl Fn(&Result<(IssueId, Issue), store::Error>, &cache::Progress) -> ControlFlow<()>,
    ) -> Result<(), super::Error>
    where
        R: ReadRepository + cob::Store,
@@ -158,7 +155,7 @@ impl<'a, R, C> Cache<super::Issues<'a, R>, C> {
            .map_err(|e| super::Error::CacheRemoveAll { err: e.into() })?;

        let issues = self.store.all()?;
-
        let mut progress = cache::WriteAllProgress::new(issues.len());
+
        let mut progress = cache::Progress::new(issues.len());
        for issue in self.store.all()? {
            progress.inc();
            match on_issue(&issue, &progress) {
modified radicle/src/cob/patch/cache.rs
@@ -210,10 +210,7 @@ impl<'a, R, C> Cache<super::Patches<'a, R>, C> {
    /// or break from the process.
    pub fn write_all(
        &mut self,
-
        callback: impl Fn(
-
            &Result<(PatchId, Patch), store::Error>,
-
            &cache::WriteAllProgress,
-
        ) -> ControlFlow<()>,
+
        callback: impl Fn(&Result<(PatchId, Patch), store::Error>, &cache::Progress) -> ControlFlow<()>,
    ) -> Result<(), super::Error>
    where
        R: ReadRepository + cob::Store,
@@ -225,7 +222,7 @@ impl<'a, R, C> Cache<super::Patches<'a, R>, C> {
            .map_err(|e| super::Error::CacheRemoveAll { err: e.into() })?;

        let patches = self.store.all()?;
-
        let mut progress = cache::WriteAllProgress::new(patches.len());
+
        let mut progress = cache::Progress::new(patches.len());
        for patch in self.store.all()? {
            progress.inc();
            match callback(&patch, &progress) {
modified radicle/src/profile.rs
@@ -562,58 +562,70 @@ impl Home {
    }

    /// Return a read-only handle for the issues cache.
-
    pub fn issues<'a, R>(
+
    pub fn issues<'a, R, M>(
        &self,
        repository: &'a R,
+
        _migrate: M,
    ) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreReader>, Error>
    where
+
        M: cob::MigrateCallback,
        R: ReadRepository + cob::Store,
    {
        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
        let db = cob::cache::Store::reader(path)?;
        let store = cob::issue::Issues::open(repository)?;
+

        Ok(cob::issue::Cache::reader(store, db))
    }

    /// Return a read-write handle for the issues cache.
-
    pub fn issues_mut<'a, R>(
+
    pub fn issues_mut<'a, R, M>(
        &self,
        repository: &'a R,
+
        _migrate: M,
    ) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreWriter>, Error>
    where
+
        M: cob::MigrateCallback,
        R: ReadRepository + cob::Store,
    {
        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
        let db = cob::cache::Store::open(path)?;
        let store = cob::issue::Issues::open(repository)?;
+

        Ok(cob::issue::Cache::open(store, db))
    }

    /// Return a read-only handle for the patches cache.
-
    pub fn patches<'a, R>(
+
    pub fn patches<'a, R, M>(
        &self,
        repository: &'a R,
+
        _migrate: M,
    ) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, Error>
    where
+
        M: cob::MigrateCallback,
        R: ReadRepository + cob::Store,
    {
        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
        let db = cob::cache::Store::reader(path)?;
        let store = cob::patch::Patches::open(repository)?;
+

        Ok(cob::patch::Cache::reader(store, db))
    }

    /// Return a read-write handle for the patches cache.
-
    pub fn patches_mut<'a, R>(
+
    pub fn patches_mut<'a, R, M>(
        &self,
        repository: &'a R,
+
        _migrate: M,
    ) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreWriter>, Error>
    where
+
        M: cob::MigrateCallback,
        R: ReadRepository + cob::Store,
    {
        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
        let db = cob::cache::Store::open(path)?;
        let store = cob::patch::Patches::open(repository)?;
+

        Ok(cob::patch::Cache::open(store, db))
    }
}