Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli: Implement auto-migration for COBs
Merged did:key:z6MksFqX...wzpT opened 1 year ago
42 files changed +1008 -200 2d13591e 3ad84420
added radicle-cli/examples/rad-cob-migrate.md
@@ -0,0 +1,24 @@
+
``` (fail)
+
$ rad patch list
+
✗ Error: collaborative objects database is out of date
+
✗ Hint: run `rad cob migrate` to update your database
+
```
+

+
``` (fail)
+
$ rad issue list
+
✗ Error: collaborative objects database is out of date
+
✗ Hint: run `rad cob migrate` to update your database
+
```
+

+
```
+
$ rad cob migrate
+
✓ Migration [..]/[..] in progress.. (100%)
+
✓ Migrated collaborative objects database successfully (version = [..])
+
```
+

+
```
+
$ rad issue list
+
Nothing to show.
+
$ rad patch list
+
Nothing to show.
+
```
modified radicle-cli/src/commands/clone.rs
@@ -9,7 +9,6 @@ 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};
@@ -183,8 +182,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, migrate::ignore)?.counts()?;
-
    let patches = profile.patches(&repo, migrate::ignore)?.counts()?;
+
    let issues = term::cob::issues(&profile, &repo)?.counts()?;
+
    let patches = term::cob::patches(&profile, &repo)?.counts()?;

    info.push([term::Line::spaced([
        term::format::tertiary(issues.open).into(),
modified radicle-cli/src/commands/cob.rs
@@ -5,7 +5,6 @@ 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;
@@ -27,14 +26,16 @@ pub const HELP: Help = Help {
Usage

    rad cob <command> [<option>...]
-
    rad cob list  --repo <rid> --type <typename>
-
    rad cob log   --repo <rid> --type <typename> --object <oid> [<option>...]
-
    rad cob show  --repo <rid> --type <typename> --object <oid> [<option>...]
+
    rad cob list --repo <rid> --type <typename>
+
    rad cob log --repo <rid> --type <typename> --object <oid> [<option>...]
+
    rad cob show --repo <rid> --type <typename> --object <oid> [<option>...]
+
    rad cob migrate [<option>...]

Commands

    list                       List all COBs of a given type (--object is not needed)
    log                        Print a log of all raw operations on a COB
+
    migrate                    Migrate the COB database to the latest version

Log options

@@ -54,13 +55,26 @@ Other options
enum OperationName {
    List,
    Log,
+
    Migrate,
    Show,
}

enum Operation {
-
    List,
-
    Log(Rev),
-
    Show(Rev),
+
    List {
+
        repo: RepoId,
+
        type_name: cob::TypeName,
+
    },
+
    Log {
+
        repo: RepoId,
+
        rev: Rev,
+
        type_name: cob::TypeName,
+
    },
+
    Migrate,
+
    Show {
+
        repo: RepoId,
+
        rev: Rev,
+
        type_name: cob::TypeName,
+
    },
}

enum Format {
@@ -69,9 +83,7 @@ enum Format {
}

pub struct Options {
-
    rid: RepoId,
    op: Operation,
-
    type_name: cob::TypeName,
    format: Format,
}

@@ -91,6 +103,7 @@ impl Args for Options {
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
                    "list" => op = Some(OperationName::List),
                    "log" => op = Some(OperationName::Log),
+
                    "migrate" => op = Some(OperationName::Migrate),
                    "show" => op = Some(OperationName::Show),
                    unknown => anyhow::bail!("unknown operation '{unknown}'"),
                },
@@ -129,24 +142,35 @@ impl Args for Options {
                _ => return Err(anyhow::anyhow!(arg.unexpected())),
            }
        }
+
        let repo = rid.ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"));
+
        let type_name =
+
            type_name.ok_or_else(|| anyhow!("an object type must be specified with `--type`"));

        Ok((
            Options {
                op: {
                    match op.ok_or_else(|| anyhow!("a command must be specified"))? {
-
                        OperationName::List => Operation::List,
-
                        OperationName::Log => Operation::Log(oid.ok_or_else(|| {
-
                            anyhow!("an object id must be specified with `--object`")
-
                        })?),
-
                        OperationName::Show => Operation::Show(oid.ok_or_else(|| {
-
                            anyhow!("an object id must be specified with `--object`")
-
                        })?),
+
                        OperationName::List => Operation::List {
+
                            repo: repo?,
+
                            type_name: type_name?,
+
                        },
+
                        OperationName::Log => Operation::Log {
+
                            repo: repo?,
+
                            rev: oid.ok_or_else(|| {
+
                                anyhow!("an object id must be specified with `--object`")
+
                            })?,
+
                            type_name: type_name?,
+
                        },
+
                        OperationName::Migrate => Operation::Migrate,
+
                        OperationName::Show => Operation::Show {
+
                            repo: repo?,
+
                            rev: oid.ok_or_else(|| {
+
                                anyhow!("an object id must be specified with `--object`")
+
                            })?,
+
                            type_name: type_name?,
+
                        },
                    }
                },
-
                rid: rid
-
                    .ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"))?,
-
                type_name: type_name
-
                    .ok_or_else(|| anyhow!("an object type must be specified with `--type`"))?,
                format: format.unwrap_or(Format::Pretty),
            },
            vec![],
@@ -157,18 +181,23 @@ impl Args for Options {
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let storage = &profile.storage;
-
    let repo = storage.repository(options.rid)?;

    match options.op {
-
        Operation::List => {
-
            let cobs = list::<NonEmpty<cob::Entry>, _>(&repo, &options.type_name)?;
+
        Operation::List { repo, type_name } => {
+
            let repo = storage.repository(repo)?;
+
            let cobs = list::<NonEmpty<cob::Entry>, _>(&repo, &type_name)?;
            for cob in cobs {
                println!("{}", cob.id);
            }
        }
-
        Operation::Log(oid) => {
+
        Operation::Log {
+
            repo,
+
            rev: oid,
+
            type_name,
+
        } => {
+
            let repo = storage.repository(repo)?;
            let oid = oid.resolve(&repo.backend)?;
-
            let ops = cob::store::ops(&oid, &options.type_name, &repo)?;
+
            let ops = cob::store::ops(&oid, &type_name, &repo)?;

            for op in ops.into_iter().rev() {
                match options.format {
@@ -177,28 +206,44 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                }
            }
        }
-
        Operation::Show(oid) => {
+
        Operation::Migrate => {
+
            let mut db = profile.cobs_db_mut()?;
+
            if db.check_version().is_ok() {
+
                term::success!("Collaborative objects database is already up to date");
+
            } else {
+
                let version = db.migrate(term::cob::migrate::spinner())?;
+
                term::success!(
+
                    "Migrated collaborative objects database successfully (version={version})"
+
                );
+
            }
+
        }
+
        Operation::Show {
+
            repo,
+
            rev: oid,
+
            type_name,
+
        } => {
+
            let repo = storage.repository(repo)?;
            let oid = &oid.resolve(&repo.backend)?;

-
            if options.type_name == cob::patch::TYPENAME.clone() {
-
                let patches = profile.patches(&repo, migrate::ignore)?;
+
            if type_name == cob::patch::TYPENAME.clone() {
+
                let patches = term::cob::patches(&profile, &repo)?;
                let Some(patch) = patches.get(oid)? else {
-
                    anyhow::bail!(cob::store::Error::NotFound(options.type_name, *oid))
+
                    anyhow::bail!(cob::store::Error::NotFound(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, migrate::ignore)?;
+
            } else if type_name == cob::issue::TYPENAME.clone() {
+
                let issues = term::cob::issues(&profile, &repo)?;
                let Some(issue) = issues.get(oid)? else {
-
                    anyhow::bail!(cob::store::Error::NotFound(options.type_name, *oid))
+
                    anyhow::bail!(cob::store::Error::NotFound(type_name, *oid))
                };
                serde_json::to_writer_pretty(std::io::stdout(), &issue)?
-
            } else if options.type_name == cob::identity::TYPENAME.clone() {
-
                let Some(cob) = cob::get::<Identity, _>(&repo, &options.type_name, oid)? else {
-
                    anyhow::bail!(cob::store::Error::NotFound(options.type_name, *oid))
+
            } else if type_name == cob::identity::TYPENAME.clone() {
+
                let Some(cob) = cob::get::<Identity, _>(&repo, &type_name, oid)? else {
+
                    anyhow::bail!(cob::store::Error::NotFound(type_name, *oid))
                };
                serde_json::to_writer_pretty(std::io::stdout(), &cob.object)?
            } else {
-
                anyhow::bail!("the type name '{}' is unknown", options.type_name);
+
                anyhow::bail!("the type name '{type_name}' is unknown");
            }
            println!();
        }
modified radicle-cli/src/commands/inbox.rs
@@ -6,7 +6,6 @@ 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 _;
@@ -272,8 +271,8 @@ where
    let (_, head) = repo.head()?;
    let doc = repo.identity_doc()?;
    let proj = doc.project()?;
-
    let issues = profile.issues(&repo, migrate::ignore)?;
-
    let patches = profile.patches(&repo, migrate::ignore)?;
+
    let issues = term::cob::issues(profile, &repo)?;
+
    let patches = term::cob::patches(profile, &repo)?;

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

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

            term::issue::show(
@@ -547,7 +546,7 @@ fn show(
            )?;
        }
        NotificationKind::Cob { typed_id } if typed_id.is_patch() => {
-
            let patches = profile.patches(&repo, migrate::ignore)?;
+
            let patches = term::cob::patches(profile, &repo)?;
            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
@@ -9,7 +9,7 @@ use anyhow::{anyhow, Context as _};

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

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

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

-
use radicle::cob::migrate;
use radicle::issue::IssueId;
use radicle::storage::git::Repository;
use radicle::storage::ReadStorage as _;
@@ -38,9 +37,8 @@ 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, migrate::ignore)?;
+
    let mut issues = term::cob::issues_mut(profile, repository)?;

-
    // FIXME: Should this change?
    match id {
        Some(id) => {
            issues.write(&id)?;
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::{migrate, patch, Label};
+
use radicle::cob::{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, migrate::ignore)?;
+
            let patches = term::cob::patches(&profile, &repository)?;
            let patch_id = patch_id.resolve(&repository.backend)?;
            let patch = patches
                .get(&patch_id)?
modified radicle-cli/src/commands/patch/archive.rs
@@ -1,9 +1,8 @@
-
use super::*;
-

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

+
use super::*;
+

pub fn run(
    patch_id: &PatchId,
    undo: bool,
@@ -11,7 +10,7 @@ pub fn run(
    repository: &Repository,
) -> anyhow::Result<()> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
+
    let mut patches = term::cob::patches_mut(profile, repository)?;
    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,7 +2,6 @@ use std::collections::BTreeSet;

use super::*;

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

@@ -16,7 +15,7 @@ pub fn run(
    repository: &Repository,
) -> anyhow::Result<()> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
+
    let mut patches = term::cob::patches_mut(profile, repository)?;
    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,6 +1,5 @@
use std::ops::ControlFlow;

-
use radicle::cob::migrate;
use radicle::patch::PatchId;
use radicle::storage::git::Repository;
use radicle::storage::ReadStorage as _;
@@ -38,7 +37,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, migrate::ignore)?;
+
    let mut patches = term::cob::patches_mut(profile, repository)?;

    match id {
        Some(id) => {
modified radicle-cli/src/commands/patch/checkout.rs
@@ -1,7 +1,6 @@
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;
@@ -39,7 +38,7 @@ pub fn run(
    profile: &Profile,
    opts: Options,
) -> anyhow::Result<()> {
-
    let patches = profile.patches(stored, migrate::ignore)?;
+
    let patches = term::cob::patches(profile, stored)?;
    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, migrate::ignore)?;
+
    let mut patches = term::cob::patches_mut(profile, repo)?;

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

@@ -6,7 +5,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, migrate::ignore)?;
+
    let mut patches = term::cob::patches_mut(profile, repository)?;
    patches.remove(patch_id, signer)?;

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

-
use radicle::cob::{migrate, patch};
+
use radicle::cob::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, migrate::ignore)?;
+
    let patches = term::cob::patches(profile, stored)?;
    let Some(patch) = patches.get(patch_id)? else {
        anyhow::bail!("Patch `{patch_id}` not found");
    };
modified radicle-cli/src/commands/patch/edit.rs
@@ -1,7 +1,7 @@
use super::*;

use radicle::cob;
-
use radicle::cob::{migrate, patch};
+
use radicle::cob::patch;
use radicle::crypto;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@@ -16,7 +16,7 @@ pub fn run(
    repository: &Repository,
) -> anyhow::Result<()> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
+
    let mut patches = term::cob::patches_mut(profile, repository)?;
    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,8 +1,7 @@
-
use super::*;
-

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

+
use super::*;
+

use crate::terminal as term;

pub fn run(
@@ -13,7 +12,7 @@ pub fn run(
    repository: &Repository,
) -> anyhow::Result<()> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
+
    let mut patches = term::cob::patches_mut(profile, repository)?;
    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, migrate::ignore)?;
+
    let patches = term::cob::patches(profile, repository)?;

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

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

+
use super::*;
+

pub fn run(
    patch_id: &PatchId,
    undo: bool,
@@ -11,7 +10,7 @@ pub fn run(
    repository: &Repository,
) -> anyhow::Result<bool> {
    let signer = term::signer(profile)?;
-
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
+
    let mut patches = term::cob::patches_mut(profile, repository)?;
    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::{migrate, patch};
+
use radicle::cob::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, migrate::ignore)?;
+
    let mut patches = term::cob::patches_mut(profile, repository)?;

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

use anyhow::{anyhow, Context};

-
use radicle::cob::migrate;
use radicle::cob::patch::{PatchId, RevisionId, Verdict};
use radicle::git;
use radicle::prelude::*;
@@ -62,7 +61,7 @@ pub fn run(
        "couldn't load repository {} from local state",
        repository.id
    ))?;
-
    let mut patches = profile.patches_mut(repository, migrate::ignore)?;
+
    let mut patches = term::cob::patches_mut(profile, repository)?;
    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::{migrate, patch};
+
use radicle::cob::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, migrate::ignore)?;
+
    let patches = term::cob::patches(profile, stored)?;
    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::{migrate, patch};
+
use radicle::cob::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, migrate::ignore)?;
+
    let mut patches = term::cob::patches_mut(profile, repository)?;
    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,7 +3,6 @@ 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;
@@ -96,8 +95,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, migrate::ignore)?.counts()?;
-
        let patches = profile.patches(&repo, migrate::ignore)?.counts()?;
+
        let issues = term::cob::issues(&profile, &repo)?.counts()?;
+
        let patches = term::cob::patches(&profile, &repo)?.counts()?;

        stats.local.issues += issues.total();
        stats.local.patches += patches.total();
modified radicle-cli/src/terminal.rs
@@ -4,6 +4,7 @@ pub mod format;
pub mod io;
pub mod job;
pub use io::signer;
+
pub mod cob;
pub mod comment;
pub mod highlight;
pub mod issue;
added radicle-cli/src/terminal/cob.rs
@@ -0,0 +1,113 @@
+
use radicle::{
+
    cob::{
+
        self,
+
        cache::{MigrateCallback, MigrateProgress},
+
    },
+
    profile,
+
    storage::ReadRepository,
+
    Profile,
+
};
+
use radicle_term as term;
+

+
use crate::terminal;
+

+
/// Hint to migrate COB database.
+
pub const MIGRATION_HINT: &str = "run `rad cob migrate` to update your database";
+

+
/// COB migration progress spinner.
+
pub struct MigrateSpinner {
+
    spinner: Option<term::Spinner>,
+
}
+

+
impl Default for MigrateSpinner {
+
    /// Create a new [`MigrateSpinner`].
+
    fn default() -> Self {
+
        Self { spinner: None }
+
    }
+
}
+

+
impl MigrateCallback for MigrateSpinner {
+
    fn progress(&mut self, progress: MigrateProgress) {
+
        self.spinner
+
            .get_or_insert_with(|| term::spinner("Migration in progress.."))
+
            .message(format!(
+
                "Migration {}/{} in progress.. ({}%)",
+
                progress.migration.current(),
+
                progress.migration.total(),
+
                progress.rows.percentage()
+
            ));
+

+
        if progress.is_done() {
+
            if let Some(spinner) = self.spinner.take() {
+
                spinner.finish()
+
            }
+
        }
+
    }
+
}
+

+
/// Migrate functions.
+
pub mod migrate {
+
    use super::MigrateSpinner;
+

+
    /// Display migration progress via a spinner.
+
    pub fn spinner() -> MigrateSpinner {
+
        MigrateSpinner::default()
+
    }
+
}
+

+
/// Return a read-only handle for the patches cache.
+
pub fn patches<'a, R>(
+
    profile: &Profile,
+
    repository: &'a R,
+
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, anyhow::Error>
+
where
+
    R: ReadRepository + cob::Store,
+
{
+
    profile.patches(repository).map_err(with_hint)
+
}
+

+
/// Return a read-write handle for the patches cache.
+
pub fn patches_mut<'a, R>(
+
    profile: &Profile,
+
    repository: &'a R,
+
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreWriter>, anyhow::Error>
+
where
+
    R: ReadRepository + cob::Store,
+
{
+
    profile.patches_mut(repository).map_err(with_hint)
+
}
+

+
/// Return a read-only handle for the issues cache.
+
pub fn issues<'a, R>(
+
    profile: &Profile,
+
    repository: &'a R,
+
) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreReader>, anyhow::Error>
+
where
+
    R: ReadRepository + cob::Store,
+
{
+
    profile.issues(repository).map_err(with_hint)
+
}
+

+
/// Return a read-write handle for the issues cache.
+
pub fn issues_mut<'a, R>(
+
    profile: &Profile,
+
    repository: &'a R,
+
) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreWriter>, anyhow::Error>
+
where
+
    R: ReadRepository + cob::Store,
+
{
+
    profile.issues_mut(repository).map_err(with_hint)
+
}
+

+
/// Adds a hint to the COB out-of-date database error.
+
fn with_hint(e: profile::Error) -> anyhow::Error {
+
    match e {
+
        profile::Error::CobsCache(cob::cache::Error::OutOfDate) => {
+
            anyhow::Error::from(terminal::args::Error::WithHint {
+
                err: e.into(),
+
                hint: MIGRATION_HINT,
+
            })
+
        }
+
        e => anyhow::Error::from(e),
+
    }
+
}
modified radicle-cli/tests/commands.rs
@@ -187,6 +187,25 @@ fn rad_cob_show() {
}

#[test]
+
fn rad_cob_migrate() {
+
    let mut environment = Environment::new();
+
    let profile = environment.profile(config::profile("alice"));
+
    let home = &profile.home;
+
    let working = environment.tmp().join("working");
+

+
    home.cobs_db_mut()
+
        .unwrap()
+
        .raw_query(|conn| conn.execute("PRAGMA user_version = 0"))
+
        .unwrap();
+

+
    // Setup a test repository.
+
    fixtures::repository(&working);
+

+
    test("examples/rad-init.md", &working, Some(home), []).unwrap();
+
    test("examples/rad-cob-migrate.md", &working, Some(home), []).unwrap();
+
}
+

+
#[test]
fn rad_init() {
    let mut environment = Environment::new();
    let profile = environment.profile(config::profile("alice"));
modified radicle-node/src/runtime.rs
@@ -8,6 +8,7 @@ use std::{fs, io, net};
use crossbeam_channel as chan;
use cyphernet::Ecdh;
use netservices::resource::NetAccept;
+
use radicle::cob::migrate;
use radicle_fetch::FetchLimit;
use radicle_signals::Signal;
use reactor::poller::popol;
@@ -141,7 +142,17 @@ impl Runtime {
        let policies = home.policies_mut()?;
        let policies = policy::Config::new(policy, policies);
        let notifications = home.notifications_mut()?;
-
        let cobs_cache = cob::cache::Store::open(home.cobs().join(cob::cache::COBS_DB_FILE))?;
+
        let mut cobs_cache = cob::cache::Store::open(home.cobs().join(cob::cache::COBS_DB_FILE))?;
+

+
        match cobs_cache.check_version() {
+
            Ok(()) => {}
+
            Err(cob::cache::Error::OutOfDate) => {
+
                log::info!(target: "node", "Migrating COBs cache..");
+
                let version = cobs_cache.migrate(migrate::log)?;
+
                log::info!(target: "node", "Migration of COBs cache complete (version={version})..");
+
            }
+
            Err(e) => return Err(e.into()),
+
        }

        log::info!(target: "node", "Default seeding policy set to '{}'", &policy);
        log::info!(target: "node", "Initializing service ({:?})..", network);
modified radicle-node/src/test/environment.rs
@@ -10,8 +10,7 @@ use std::{
use crossbeam_channel as chan;

use localtime::LocalTime;
-
use radicle::cob::cache::COBS_DB_FILE;
-
use radicle::cob::issue;
+
use radicle::cob::{issue, migrate};
use radicle::crypto::ssh::{keystore::MemorySigner, Keystore};
use radicle::crypto::test::signer::MockSigner;
use radicle::crypto::{KeyPair, Seed, Signer};
@@ -111,7 +110,6 @@ impl Environment {
        let keystore = Keystore::new(&home.keys());
        let keypair = KeyPair::from_seed(Seed::from([!(self.users as u8); 32]));
        let policies_db = home.node().join(POLICIES_DB_FILE);
-
        let cobs_db = home.cobs().join(COBS_DB_FILE);
        let now = LocalTime::now();

        config.write(&home.config()).unwrap();
@@ -126,8 +124,10 @@ impl Environment {
        .unwrap();
        let public_key = keypair.pk.into();

+
        let mut db = home.cobs_db_mut().unwrap();
+
        db.migrate(migrate::ignore).unwrap();
+

        policy::Store::open(policies_db).unwrap();
-
        cob::cache::Store::open(cobs_db).unwrap();
        home.database_mut()
            .unwrap()
            .init(
modified radicle-remote-helper/src/lib.rs
@@ -11,12 +11,13 @@ use std::path::PathBuf;
use std::str::FromStr;
use std::{env, fmt, io};

-
use radicle_cli::git::Rev;
use thiserror::Error;

-
use radicle::git;
use radicle::storage::git::transport::local::{Url, UrlError};
use radicle::storage::{ReadRepository, WriteStorage};
+
use radicle::{cob, profile};
+
use radicle::{git, storage, Profile};
+
use radicle_cli::git::Rev;
use radicle_cli::terminal as cli;

#[derive(Debug, Error)]
@@ -259,3 +260,36 @@ pub(crate) fn hint(s: impl fmt::Display) {
pub(crate) fn warn(s: impl fmt::Display) {
    eprintln!("{}", cli::format::hint(format!("warn: {s}")));
}
+

+
/// Get the patch store.
+
pub(crate) fn patches<'a, R: ReadRepository + cob::Store>(
+
    profile: &Profile,
+
    repo: &'a R,
+
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, list::Error> {
+
    match profile.patches(repo) {
+
        Ok(patches) => Ok(patches),
+
        Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
+
            hint(cli::cob::MIGRATION_HINT);
+
            Err(err.into())
+
        }
+
        Err(err) => Err(err.into()),
+
    }
+
}
+

+
/// Get the mutable patch store.
+
pub(crate) fn patches_mut<'a>(
+
    profile: &Profile,
+
    repo: &'a storage::git::Repository,
+
) -> Result<
+
    cob::patch::Cache<cob::patch::Patches<'a, storage::git::Repository>, cob::cache::StoreWriter>,
+
    push::Error,
+
> {
+
    match profile.patches_mut(repo) {
+
        Ok(patches) => Ok(patches),
+
        Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
+
            hint(cli::cob::MIGRATION_HINT);
+
            Err(err.into())
+
        }
+
        Err(err) => Err(err.into()),
+
    }
+
}
modified radicle-remote-helper/src/list.rs
@@ -3,7 +3,6 @@ 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;
@@ -88,7 +87,7 @@ fn patch_refs<R: ReadRepository + cob::Store + 'static>(
    profile: &Profile,
    stored: &R,
) -> Result<(), Error> {
-
    let patches = profile.patches(stored, migrate::ignore)?;
+
    let patches = crate::patches(profile, stored)?;
    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;
@@ -219,6 +219,7 @@ pub fn run(
            }
            Command::Push(git::Refspec { src, dst, force }) => {
                let working = git::raw::Repository::open(working)?;
+
                let patches = crate::patches_mut(profile, stored)?;

                if dst == &*rad::PATCHES_REFNAME {
                    patch_open(
@@ -227,7 +228,7 @@ pub fn run(
                        &nid,
                        &working,
                        stored,
-
                        profile.patches_mut(stored, migrate::ignore)?,
+
                        patches,
                        &signer,
                        profile,
                        opts.clone(),
@@ -247,7 +248,7 @@ pub fn run(
                            &nid,
                            &working,
                            stored,
-
                            profile.patches_mut(stored, migrate::ignore)?,
+
                            patches,
                            &signer,
                            opts.clone(),
                        )
@@ -323,16 +324,7 @@ pub fn run(
                                Err(e) => return Err(e.into()),
                            };
                        }
-
                        push(
-
                            src,
-
                            &dst,
-
                            *force,
-
                            &nid,
-
                            &working,
-
                            stored,
-
                            profile.patches_mut(stored, migrate::ignore)?,
-
                            &signer,
-
                        )
+
                        push(src, &dst, *force, &nid, &working, stored, patches, &signer)
                    }
                }
            }
modified radicle-tools/src/rad-merge.rs
@@ -2,7 +2,6 @@ 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;
@@ -27,7 +26,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, migrate::ignore)?;
+
    let mut patches = profile.patches_mut(&stored)?;
    let mut patch = patches.get_mut(&pid)?;

    if patch.is_merged() {
modified radicle/src/cob/cache.rs
@@ -1,3 +1,5 @@
+
mod migrations;
+

use std::collections::HashMap;
use std::convert::Infallible;
use std::fmt;
@@ -23,34 +25,51 @@ 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: &[Migration] = &[Migration::Sql(include_str!("cache/migrations/1.sql"))];
+
const MIGRATIONS: &[Migration] = &[
+
    Migration::Sql(include_str!("cache/migrations/1.sql")),
+
    Migration::Native(migrations::_2::run),
+
];

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

+
/// Progress of a database migration.
+
#[derive(Debug)]
+
pub struct MigrateProgress<'a> {
+
    /// Progress in the list of migrations.
+
    pub migration: &'a Progress,
+
    /// Progress within each individual migration.
+
    pub rows: &'a Progress,
+
}
+

+
impl<'a> MigrateProgress<'a> {
+
    /// If we're done with the migration.
+
    pub fn is_done(&self) -> bool {
+
        self.migration.current() == self.migration.total()
+
            && self.rows.current() == self.rows.total()
+
    }
+
}
+

/// 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>;
+
    fn progress(&mut self, progress: MigrateProgress<'_>);
}

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

@@ -59,21 +78,18 @@ 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!(
+
    pub fn log(progress: MigrateProgress<'_>) {
+
        log::trace!(
            target: "db",
            "Migration {}/{} in progress.. ({}%)",
-
            migration.current() + 1,
-
            migration.total(),
-
            item.percentage()
+
            progress.migration.current(),
+
            progress.migration.total(),
+
            progress.rows.percentage()
        );
-
        Ok(true)
    }

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

#[derive(Error, Debug)]
@@ -81,9 +97,18 @@ pub enum Error {
    /// An Internal error.
    #[error("internal error: {0}")]
    Internal(#[from] sql::Error),
+
    /// Malformed JSON schema, eg. missing fields or wrong field types.
+
    #[error("malformed JSON schema")]
+
    MalformedJsonSchema,
+
    /// Malformed JSON data, ie. not valid JSON.
+
    #[error("malformed JSON data: {0}")]
+
    MalformedJson(serde_json::Error),
    /// No rows returned in query result.
    #[error("no rows returned")]
    NoRows,
+
    /// Schema is out of date, migrations need to be run.
+
    #[error("collaborative objects database is out of date")]
+
    OutOfDate,
}

/// Read and write to the store.
@@ -148,7 +173,6 @@ 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::ignore)?;

        Ok(Self {
            db: Arc::new(db),
@@ -159,7 +183,6 @@ 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::ignore)?;

        Ok(Self {
            db,
@@ -167,6 +190,11 @@ impl Store<Write> {
        })
    }

+
    /// Builder method that migrates the database.
+
    pub fn with_migrations<M: MigrateCallback>(mut self, callback: M) -> Result<Self, Error> {
+
        self.migrate(callback).map(|_| self)
+
    }
+

    /// Turn this handle into a read-only handle.
    pub fn read_only(self) -> Store<Read> {
        Store {
@@ -183,6 +211,47 @@ impl Store<Write> {
    {
        transaction(&self.db, query)
    }
+

+
    /// Migrate this database to the latest version.
+
    /// Returns the verison migrated to.
+
    pub fn migrate<M: MigrateCallback>(&mut self, callback: M) -> Result<usize, Error> {
+
        self.migrate_to(MIGRATIONS.len(), callback)
+
    }
+

+
    /// Migrate this database to the given target version.
+
    /// Returns the verison migrated to.
+
    pub fn migrate_to<M: MigrateCallback>(
+
        &mut self,
+
        target: usize,
+
        mut callback: M,
+
    ) -> Result<usize, Error> {
+
        let db = &self.db;
+
        let mut version = version(db)?;
+
        let total = MIGRATIONS.len();
+

+
        for (i, migration) in MIGRATIONS.iter().enumerate().take(target).skip(version) {
+
            let current = i + 1;
+

+
            transaction(db, |db| {
+
                match migration {
+
                    Migration::Sql(query) => {
+
                        db.execute(query)?;
+
                        callback.progress(MigrateProgress {
+
                            migration: &Progress { total, current },
+
                            rows: &Progress::done(1),
+
                        });
+
                    }
+
                    Migration::Native(migrate) => {
+
                        migrate(db, &Progress { total, current }, &mut callback)?;
+
                    }
+
                }
+
                version = bump(db)?;
+

+
                Ok::<_, Error>(())
+
            })?;
+
        }
+
        Ok(version)
+
    }
}

impl<T> Store<T> {
@@ -190,6 +259,14 @@ impl<T> Store<T> {
    pub fn version(&self) -> Result<usize, Error> {
        version(&self.db)
    }
+

+
    /// Check if the database version is out of date, ie. we need to migrate.
+
    pub fn check_version(&self) -> Result<(), Error> {
+
        if version(&self.db)? < MIGRATIONS.len() {
+
            return Err(Error::OutOfDate);
+
        }
+
        Ok(())
+
    }
}

/// Get the `user_version` value from the database header.
@@ -214,39 +291,6 @@ fn bump(db: &sql::Connection) -> Result<usize, Error> {
    Ok(new as usize)
}

-
/// Migrate the database to the latest schema.
-
fn migrate<M>(db: &sql::Connection, mut callback: M) -> Result<usize, Error>
-
where
-
    M: MigrateCallback,
-
{
-
    let mut version = version(db)?;
-
    let total = MIGRATIONS.len();
-

-
    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)
-
}
-

/// Update a COB object in the cache.
pub trait Update<T> {
    /// The output type, if any, for a successful update.
@@ -351,6 +395,7 @@ impl<T> Remove<T> for NoCache {
///
/// See [`crate::cob::issue::Cache::write_all`] and
/// [`crate::cob::patch::Cache::write_all`].
+
#[derive(Debug)]
pub struct Progress {
    current: usize,
    total: usize,
@@ -386,11 +431,45 @@ impl Progress {
    }

    /// Return the percentage of the progress made.
-
    ///
-
    /// # Panics
-
    ///
-
    /// If the `total` provided is `0`.
    pub fn percentage(&self) -> f32 {
-
        (self.current as f32 / self.total as f32) * 100.0
+
        if self.total == 0 {
+
            100.
+
        } else {
+
            (self.current as f32 / self.total as f32) * 100.0
+
        }
+
    }
+
}
+

+
#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
+
mod tests {
+
    use super::*;
+
    use crate::assert_matches;
+

+
    #[test]
+
    fn test_check_version() {
+
        let mut db = StoreWriter::memory().unwrap();
+
        assert_matches!(db.check_version(), Err(Error::OutOfDate));
+

+
        db.migrate(migrate::ignore).unwrap();
+
        assert_matches!(db.check_version(), Ok(()));
+
    }
+

+
    #[test]
+
    fn test_migrate_to() {
+
        let mut db = StoreWriter::memory().unwrap();
+
        assert_eq!(db.version().unwrap(), 0);
+

+
        assert_eq!(db.migrate_to(1, migrate::ignore).unwrap(), 1); // 0 -> 1
+
        assert_eq!(db.version().unwrap(), 1);
+

+
        assert_eq!(db.migrate_to(2, migrate::ignore).unwrap(), 2); // 1 -> 2
+
        assert_eq!(db.version().unwrap(), 2);
+

+
        assert_eq!(db.migrate_to(1, migrate::ignore).unwrap(), 2); // No-op.
+
        assert_eq!(db.version().unwrap(), 2);
+

+
        assert_eq!(db.migrate_to(99, migrate::ignore).unwrap(), 2); // No-op.
+
        assert_eq!(db.version().unwrap(), 2);
    }
}
added radicle/src/cob/cache/migrations.rs
@@ -0,0 +1,2 @@
+
#[path = "migrations/2.rs"]
+
pub mod _2;
added radicle/src/cob/cache/migrations/2.rs
@@ -0,0 +1,132 @@
+
//! Migration to update the patch `reviews` JSON representation.
+
use crate::cob::cache::*;
+
use serde_json as json;
+

+
/// Run migration.
+
pub fn run(
+
    db: &sql::Connection,
+
    migration: &Progress,
+
    callback: &mut dyn MigrateCallback,
+
) -> Result<usize, Error> {
+
    // Select patches with reviews.
+
    let rows = db
+
        .prepare("SELECT id, patch FROM patches WHERE json_extract(patch, '$.reviews') != '{}'")?
+
        .into_iter()
+
        .collect::<Vec<_>>();
+
    // Query to update a patch to the new schema.
+
    let mut update = db.prepare(
+
        "UPDATE patches
+
         SET patch = ?1
+
         WHERE id = ?2",
+
    )?;
+
    let mut progress = Progress::new(rows.len());
+
    callback.progress(MigrateProgress {
+
        migration,
+
        rows: &progress,
+
    });
+

+
    for row in rows {
+
        let row = row?;
+
        let id = row.read::<&str, _>("id");
+
        let mut patch = json::from_str::<json::Value>(row.read::<&str, _>("patch"))
+
            .map_err(Error::MalformedJson)?;
+
        let patch = patch.as_object_mut().ok_or(Error::MalformedJsonSchema)?;
+
        let revisions = patch["revisions"]
+
            .as_object_mut()
+
            .ok_or(Error::MalformedJsonSchema)?;
+
        let mut transformed = false;
+

+
        for (_, r) in revisions.iter_mut() {
+
            let Some(revision) = r.as_object_mut() else {
+
                // Redacted revision (`null`).
+
                continue;
+
            };
+
            let reviews = revision
+
                .get_mut("reviews")
+
                .ok_or(Error::MalformedJsonSchema)?
+
                .as_object_mut()
+
                .ok_or(Error::MalformedJsonSchema)?;
+

+
            for (_, review) in reviews.iter_mut() {
+
                if let Some(list) = review.as_array_mut() {
+
                    if let Some(last) = list.pop() {
+
                        *review = last;
+
                        transformed = true;
+
                    }
+
                }
+
            }
+
        }
+

+
        if transformed {
+
            let obj = json::to_string(&patch).map_err(Error::MalformedJson)?;
+

+
            update.reset()?;
+
            update.bind((1, obj.as_str()))?;
+
            update.bind((2, id))?;
+
            update.next()?;
+
            progress.inc();
+

+
            callback.progress(MigrateProgress {
+
                migration,
+
                rows: &progress,
+
            });
+
        }
+
    }
+
    Ok(progress.current())
+
}
+

+
#[allow(clippy::unwrap_used)]
+
#[cfg(test)]
+
mod tests {
+
    use crate::cob::cache::*;
+

+
    // Before the migration.
+
    const PATCH_V1: &str = include_str!("samples/patch.v1.json");
+
    // After the migration.
+
    const PATCH_V2: &str = include_str!("samples/patch.v2.json");
+

+
    #[test]
+
    fn test_migration_2() {
+
        let mut db = StoreWriter::memory().unwrap();
+
        db.migrate_to(1, migrate::ignore).unwrap();
+
        db.raw_query(|conn| {
+
            let mut stmt = conn.prepare(
+
                "INSERT INTO patches (id, repo, patch)
+
                 VALUES (?1, ?2, ?3)",
+
            )?;
+
            stmt.bind((1, "016a91d2029ee71b9aee8d927664caf1b7885346"))?;
+
            stmt.bind((2, "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5"))?;
+
            stmt.bind((3, PATCH_V1))?;
+
            stmt.next()?;
+

+
            Ok::<_, sql::Error>(())
+
        })
+
        .unwrap();
+

+
        assert_eq!(db.migrate_to(2, migrate::ignore).unwrap(), 2);
+

+
        let row = db
+
            .raw_query(|conn| {
+
                Ok::<_, sql::Error>(
+
                    conn.prepare("SELECT patch FROM patches LIMIT 1")?
+
                        .into_iter()
+
                        .next()
+
                        .unwrap()
+
                        .unwrap(),
+
                )
+
            })
+
            .unwrap();
+

+
        let patch = row.read::<&str, _>("patch");
+
        let actual: serde_json::Value = serde_json::from_str(patch).unwrap();
+
        let expected: serde_json::Value = serde_json::from_str(PATCH_V2).unwrap();
+

+
        assert_eq!(actual, expected);
+
    }
+

+
    #[test]
+
    fn test_patch_json_deserialization() {
+
        serde_json::from_str::<crate::cob::patch::Patch>(PATCH_V1).unwrap_err();
+
        serde_json::from_str::<crate::cob::patch::Patch>(PATCH_V2).unwrap();
+
    }
+
}
added radicle/src/cob/cache/migrations/samples/patch.v1.json
@@ -0,0 +1,179 @@
+
{
+
  "title": "Remove loading indicator from Share button",
+
  "author": {
+
    "id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5"
+
  },
+
  "state": {
+
    "status": "merged",
+
    "revision": "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
+
    "commit": "f6a77bec9401c35b1baa19d356405a9d77ed8e0d"
+
  },
+
  "target": "delegates",
+
  "labels": [],
+
  "merges": {},
+
  "revisions": {
+
    "016a91d2029ee71b9aee8d927664caf1b7885346": {
+
      "id": "016a91d2029ee71b9aee8d927664caf1b7885346",
+
      "author": { "id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5" },
+
      "description": [
+
        {
+
          "author": "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
+
          "timestamp": 1710884599000,
+
          "body": "",
+
          "embeds": []
+
        }
+
      ],
+
      "base": "daba9fca8281405134d64036bde300f2181f73fe",
+
      "oid": "c24c942261647993fcb0d34224f5c98b0f30266f",
+
      "discussion": {
+
        "comments": {},
+
        "timeline": []
+
      },
+
      "reviews": {},
+
      "timestamp": 1710884599000,
+
      "resolves": [],
+
      "reactions": []
+
    },
+
    "4a98168bfe6e16934b922e7d5df3cf68c7e7d085": {
+
      "id": "4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
+
      "author": { "id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5" },
+
      "description": [
+
        {
+
          "author": "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
+
          "timestamp": 1710939751000,
+
          "body": "Instead of disabeling the Share button, adds a min-width param",
+
          "embeds": []
+
        }
+
      ],
+
      "base": "9c65ee97e8f97a2ae130c36b6bbc1071cc31fd93",
+
      "oid": "5dd21bdb51a6a37dc5957d81f63058344ec6b8e5",
+
      "discussion": {
+
        "comments": {},
+
        "timeline": []
+
      },
+
      "reviews": {
+
        "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx": [
+
          {
+
            "id": "0076749b04e878ca4387bb60cd80a281af2285bb",
+
            "author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
+
            "verdict": "accept",
+
            "summary": null,
+
            "comments": {
+
              "comments": {},
+
              "timeline": []
+
            },
+
            "labels": [],
+
            "timestamp": 1710941558000
+
          },
+
          {
+
            "id": "89d45fb371eb2622ba88188d474347cc526d80bb",
+
            "author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
+
            "verdict": "accept",
+
            "summary": null,
+
            "comments": {
+
              "comments": {},
+
              "timeline": []
+
            },
+
            "labels": [],
+
            "timestamp": 1710947885000
+
          }
+
        ]
+
      },
+
      "timestamp": 1710939751000,
+
      "resolves": [],
+
      "reactions": []
+
    },
+
    "6a71b034d661994d188abac920b41e7f9c57d85d": {
+
      "id": "6a71b034d661994d188abac920b41e7f9c57d85d",
+
      "author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
+
      "description": [
+
        {
+
          "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
          "timestamp": 1710940872000,
+
          "body": "Rebase.",
+
          "embeds": []
+
        }
+
      ],
+
      "base": "cddf8b40fc352eb84885e5adeed65b3abb0639fc",
+
      "oid": "239bc7e5c786ac65d8456164a97bab2419da441a",
+
      "discussion": {
+
        "comments": {},
+
        "timeline": []
+
      },
+
      "reviews": {},
+
      "timestamp": 1710940872000,
+
      "resolves": [],
+
      "reactions": []
+
    },
+
    "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68": {
+
      "id": "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
+
      "author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
+
      "description": [
+
        {
+
          "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
          "timestamp": 1710947911000,
+
          "body": "Rebase.",
+
          "embeds": []
+
        }
+
      ],
+
      "base": "fe7cd1b4e52d15c27f89e23d98e8ccad9c3a8bff",
+
      "oid": "f6a77bec9401c35b1baa19d356405a9d77ed8e0d",
+
      "discussion": {
+
        "comments": {},
+
        "timeline": []
+
      },
+
      "reviews": {},
+
      "timestamp": 1710947911000,
+
      "resolves": [],
+
      "reactions": []
+
    },
+
    "eee18b0bb43e8673c3b32f3e151556906b1214f6": {
+
      "id": "eee18b0bb43e8673c3b32f3e151556906b1214f6",
+
      "author": {
+
        "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
+
      },
+
      "description": [
+
        {
+
          "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
          "timestamp": 1710941119000,
+
          "body": "Rebase.",
+
          "embeds": []
+
        }
+
      ],
+
      "base": "cddf8b40fc352eb84885e5adeed65b3abb0639fc",
+
      "oid": "a9bdc6b43df080adb8086427b363590aea17bf82",
+
      "discussion": {
+
        "comments": {},
+
        "timeline": []
+
      },
+
      "reviews": {},
+
      "timestamp": 1710941119000,
+
      "resolves": [],
+
      "reactions": []
+
    }
+
  },
+
  "assignees": [],
+
  "timeline": [
+
    "016a91d2029ee71b9aee8d927664caf1b7885346",
+
    "4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
+
    "6a71b034d661994d188abac920b41e7f9c57d85d",
+
    "eee18b0bb43e8673c3b32f3e151556906b1214f6",
+
    "0076749b04e878ca4387bb60cd80a281af2285bb",
+
    "e5b200b71a7f413db5697d0e1f9ce267a2095a45",
+
    "27c4e9b9e3837adf417eb816bcea8521b563a030",
+
    "89d45fb371eb2622ba88188d474347cc526d80bb",
+
    "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
+
    "c7d8213bab37f40c8cb6668a98ce7292f6d05bf3",
+
    "081559bfae2f729486f50d93600d1dac0f996d81"
+
  ],
+
  "reviews": {
+
    "0076749b04e878ca4387bb60cd80a281af2285bb": [
+
      "4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
+
      "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
+
    ],
+
    "89d45fb371eb2622ba88188d474347cc526d80bb": [
+
      "4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
+
      "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
+
    ]
+
  }
+
}
added radicle/src/cob/cache/migrations/samples/patch.v2.json
@@ -0,0 +1,165 @@
+
{
+
  "title": "Remove loading indicator from Share button",
+
  "author": {
+
    "id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5"
+
  },
+
  "state": {
+
    "status": "merged",
+
    "revision": "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
+
    "commit": "f6a77bec9401c35b1baa19d356405a9d77ed8e0d"
+
  },
+
  "target": "delegates",
+
  "labels": [],
+
  "merges": {},
+
  "revisions": {
+
    "016a91d2029ee71b9aee8d927664caf1b7885346": {
+
      "id": "016a91d2029ee71b9aee8d927664caf1b7885346",
+
      "author": { "id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5" },
+
      "description": [
+
        {
+
          "author": "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
+
          "timestamp": 1710884599000,
+
          "body": "",
+
          "embeds": []
+
        }
+
      ],
+
      "base": "daba9fca8281405134d64036bde300f2181f73fe",
+
      "oid": "c24c942261647993fcb0d34224f5c98b0f30266f",
+
      "discussion": {
+
        "comments": {},
+
        "timeline": []
+
      },
+
      "reviews": {},
+
      "timestamp": 1710884599000,
+
      "resolves": [],
+
      "reactions": []
+
    },
+
    "4a98168bfe6e16934b922e7d5df3cf68c7e7d085": {
+
      "id": "4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
+
      "author": { "id": "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5" },
+
      "description": [
+
        {
+
          "author": "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
+
          "timestamp": 1710939751000,
+
          "body": "Instead of disabeling the Share button, adds a min-width param",
+
          "embeds": []
+
        }
+
      ],
+
      "base": "9c65ee97e8f97a2ae130c36b6bbc1071cc31fd93",
+
      "oid": "5dd21bdb51a6a37dc5957d81f63058344ec6b8e5",
+
      "discussion": {
+
        "comments": {},
+
        "timeline": []
+
      },
+
      "reviews": {
+
        "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx": {
+
          "id": "89d45fb371eb2622ba88188d474347cc526d80bb",
+
          "author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
+
          "verdict": "accept",
+
          "summary": null,
+
          "comments": {
+
            "comments": {},
+
            "timeline": []
+
          },
+
          "labels": [],
+
          "timestamp": 1710947885000
+
        }
+
      },
+
      "timestamp": 1710939751000,
+
      "resolves": [],
+
      "reactions": []
+
    },
+
    "6a71b034d661994d188abac920b41e7f9c57d85d": {
+
      "id": "6a71b034d661994d188abac920b41e7f9c57d85d",
+
      "author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
+
      "description": [
+
        {
+
          "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
          "timestamp": 1710940872000,
+
          "body": "Rebase.",
+
          "embeds": []
+
        }
+
      ],
+
      "base": "cddf8b40fc352eb84885e5adeed65b3abb0639fc",
+
      "oid": "239bc7e5c786ac65d8456164a97bab2419da441a",
+
      "discussion": {
+
        "comments": {},
+
        "timeline": []
+
      },
+
      "reviews": {},
+
      "timestamp": 1710940872000,
+
      "resolves": [],
+
      "reactions": []
+
    },
+
    "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68": {
+
      "id": "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
+
      "author": { "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx" },
+
      "description": [
+
        {
+
          "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
          "timestamp": 1710947911000,
+
          "body": "Rebase.",
+
          "embeds": []
+
        }
+
      ],
+
      "base": "fe7cd1b4e52d15c27f89e23d98e8ccad9c3a8bff",
+
      "oid": "f6a77bec9401c35b1baa19d356405a9d77ed8e0d",
+
      "discussion": {
+
        "comments": {},
+
        "timeline": []
+
      },
+
      "reviews": {},
+
      "timestamp": 1710947911000,
+
      "resolves": [],
+
      "reactions": []
+
    },
+
    "eee18b0bb43e8673c3b32f3e151556906b1214f6": {
+
      "id": "eee18b0bb43e8673c3b32f3e151556906b1214f6",
+
      "author": {
+
        "id": "did:key:z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
+
      },
+
      "description": [
+
        {
+
          "author": "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx",
+
          "timestamp": 1710941119000,
+
          "body": "Rebase.",
+
          "embeds": []
+
        }
+
      ],
+
      "base": "cddf8b40fc352eb84885e5adeed65b3abb0639fc",
+
      "oid": "a9bdc6b43df080adb8086427b363590aea17bf82",
+
      "discussion": {
+
        "comments": {},
+
        "timeline": []
+
      },
+
      "reviews": {},
+
      "timestamp": 1710941119000,
+
      "resolves": [],
+
      "reactions": []
+
    }
+
  },
+
  "assignees": [],
+
  "timeline": [
+
    "016a91d2029ee71b9aee8d927664caf1b7885346",
+
    "4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
+
    "6a71b034d661994d188abac920b41e7f9c57d85d",
+
    "eee18b0bb43e8673c3b32f3e151556906b1214f6",
+
    "0076749b04e878ca4387bb60cd80a281af2285bb",
+
    "e5b200b71a7f413db5697d0e1f9ce267a2095a45",
+
    "27c4e9b9e3837adf417eb816bcea8521b563a030",
+
    "89d45fb371eb2622ba88188d474347cc526d80bb",
+
    "7bdb1c8a273ace2a0be4b93e9b1b525dc1696e68",
+
    "c7d8213bab37f40c8cb6668a98ce7292f6d05bf3",
+
    "081559bfae2f729486f50d93600d1dac0f996d81"
+
  ],
+
  "reviews": {
+
    "0076749b04e878ca4387bb60cd80a281af2285bb": [
+
      "4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
+
      "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
+
    ],
+
    "89d45fb371eb2622ba88188d474347cc526d80bb": [
+
      "4a98168bfe6e16934b922e7d5df3cf68c7e7d085",
+
      "z6MkwPUeUS2fJMfc2HZN1RQTQcTTuhw4HhPySB8JeUg2mVvx"
+
    ]
+
  }
+
}
modified radicle/src/cob/issue/cache.rs
@@ -534,6 +534,7 @@ mod tests {
    use radicle_cob::ObjectId;

    use crate::cob::cache::{Store, Update, Write};
+
    use crate::cob::migrate;
    use crate::cob::thread::Thread;
    use crate::issue::{CloseReason, Issue, IssueCounts, IssueId, State};
    use crate::test::arbitrary;
@@ -542,7 +543,10 @@ mod tests {
    use super::{Cache, Issues};

    fn memory(store: MockRepository) -> Cache<MockRepository, Store<Write>> {
-
        let cache = Store::<Write>::memory().unwrap();
+
        let cache = Store::<Write>::memory()
+
            .unwrap()
+
            .with_migrations(migrate::ignore)
+
            .unwrap();
        Cache { store, cache }
    }

modified radicle/src/cob/patch.rs
@@ -2739,6 +2739,8 @@ mod test {
    use crate::test::arbitrary::gen;
    use crate::test::storage::MockRepository;

+
    use cob::migrate;
+

    #[test]
    fn test_json_serialization() {
        let edit = Action::Label {
@@ -3352,8 +3354,10 @@ mod test {
        let branch = checkout.branch_with([("README", b"Hello World!")]);
        let mut patches = {
            let path = alice.tmp.path().join("cobs.db");
-
            let db = cob::cache::Store::open(path).unwrap();
+
            let mut db = cob::cache::Store::open(path).unwrap();
            let store = cob::patch::Patches::open(&*alice.repo).unwrap();
+

+
            db.migrate(migrate::ignore).unwrap();
            cob::patch::Cache::open(store, db)
        };
        let mut patch = patches
modified radicle/src/cob/patch/cache.rs
@@ -362,7 +362,7 @@ impl Update<Patch> for StoreWriter {
            "INSERT INTO patches (id, repo, patch)
             VALUES (?1, ?2, ?3)
             ON CONFLICT DO UPDATE
-
             SET patch =  (?3)",
+
             SET patch = (?3)",
        )?;

        stmt.bind((1, sql::Value::String(id.to_string())))?;
@@ -705,6 +705,7 @@ mod tests {
    use radicle_cob::ObjectId;

    use crate::cob::cache::{Store, Update, Write};
+
    use crate::cob::migrate;
    use crate::cob::thread::{Comment, Thread};
    use crate::cob::Author;
    use crate::patch::{
@@ -718,7 +719,10 @@ mod tests {
    use super::{Cache, Patches};

    fn memory(store: MockRepository) -> Cache<MockRepository, Store<Write>> {
-
        let cache = Store::<Write>::memory().unwrap();
+
        let cache = Store::<Write>::memory()
+
            .unwrap()
+
            .with_migrations(migrate::ignore)
+
            .unwrap();
        Cache { store, cache }
    }

modified radicle/src/profile.rs
@@ -20,6 +20,7 @@ use std::{fs, io};
use localtime::LocalTime;
use thiserror::Error;

+
use crate::cob::migrate;
use crate::crypto::ssh::agent::Agent;
use crate::crypto::ssh::{keystore, Keystore, Passphrase};
use crate::crypto::{PublicKey, Signer};
@@ -229,6 +230,10 @@ impl Profile {
                config.node.external_addresses.iter(),
            )?;

+
        // Migrate COBs cache.
+
        let mut cobs = home.cobs_db_mut()?;
+
        cobs.migrate(migrate::ignore)?;
+

        transport::local::register(storage.clone());

        Ok(Profile {
@@ -561,71 +566,83 @@ impl Home {
        self.database_mut()
    }

+
    /// Get read access to the COBs cache.
+
    pub fn cobs_db(&self) -> Result<cob::cache::StoreReader, Error> {
+
        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
+
        let db = cob::cache::Store::reader(path)?;
+

+
        Ok(db)
+
    }
+

+
    /// Get write access to the COBs cache.
+
    pub fn cobs_db_mut(&self) -> Result<cob::cache::StoreWriter, Error> {
+
        let path = self.cobs().join(cob::cache::COBS_DB_FILE);
+
        let db = cob::cache::Store::open(path)?;
+

+
        Ok(db)
+
    }
+

    /// Return a read-only handle for the issues cache.
-
    pub fn issues<'a, R, M>(
+
    pub fn issues<'a, R>(
        &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 db = self.cobs_db()?;
        let store = cob::issue::Issues::open(repository)?;

+
        db.check_version()?;
+

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

    /// Return a read-write handle for the issues cache.
-
    pub fn issues_mut<'a, R, M>(
+
    pub fn issues_mut<'a, R>(
        &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 db = self.cobs_db_mut()?;
        let store = cob::issue::Issues::open(repository)?;

+
        db.check_version()?;
+

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

    /// Return a read-only handle for the patches cache.
-
    pub fn patches<'a, R, M>(
+
    pub fn patches<'a, R>(
        &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 db = self.cobs_db()?;
        let store = cob::patch::Patches::open(repository)?;

+
        db.check_version()?;
+

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

    /// Return a read-write handle for the patches cache.
-
    pub fn patches_mut<'a, R, M>(
+
    pub fn patches_mut<'a, R>(
        &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 db = self.cobs_db_mut()?;
        let store = cob::patch::Patches::open(repository)?;

+
        db.check_version()?;
+

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