cli: Implement `rad cob migrate`

Adds the new `migrate` sub-command to migrate the COB database.
Checks for version when opening the COBs database and suggests the
command on out of date version.

Also adds the 2nd migration to the COBs cache, which updates the patch
JSON schema.
This commit is contained in:
cloudhead 2024-11-18 12:27:07 +01:00
parent 2d13591ec2
commit 3ad84420bd
No known key found for this signature in database
42 changed files with 1013 additions and 205 deletions

View File

@ -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.
```

View File

@ -9,7 +9,6 @@ use radicle::issue::cache::Issues as _;
use radicle::patch::cache::Patches as _; use radicle::patch::cache::Patches as _;
use thiserror::Error; use thiserror::Error;
use radicle::cob::migrate;
use radicle::git::raw; use radicle::git::raw;
use radicle::identity::doc; use radicle::identity::doc;
use radicle::identity::doc::{DocError, RepoId}; 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::bold(proj.name()).into()]);
info.push([term::format::italic(proj.description()).into()]); info.push([term::format::italic(proj.description()).into()]);
let issues = profile.issues(&repo, migrate::ignore)?.counts()?; let issues = term::cob::issues(&profile, &repo)?.counts()?;
let patches = profile.patches(&repo, migrate::ignore)?.counts()?; let patches = term::cob::patches(&profile, &repo)?.counts()?;
info.push([term::Line::spaced([ info.push([term::Line::spaced([
term::format::tertiary(issues.open).into(), term::format::tertiary(issues.open).into(),

View File

@ -5,7 +5,6 @@ use anyhow::anyhow;
use chrono::prelude::*; use chrono::prelude::*;
use nonempty::NonEmpty; use nonempty::NonEmpty;
use radicle::cob; use radicle::cob;
use radicle::cob::migrate;
use radicle::cob::Op; use radicle::cob::Op;
use radicle::identity::Identity; use radicle::identity::Identity;
use radicle::issue::cache::Issues; use radicle::issue::cache::Issues;
@ -27,14 +26,16 @@ pub const HELP: Help = Help {
Usage Usage
rad cob <command> [<option>...] rad cob <command> [<option>...]
rad cob list --repo <rid> --type <typename> rad cob list --repo <rid> --type <typename>
rad cob log --repo <rid> --type <typename> --object <oid> [<option>...] rad cob log --repo <rid> --type <typename> --object <oid> [<option>...]
rad cob show --repo <rid> --type <typename> --object <oid> [<option>...] rad cob show --repo <rid> --type <typename> --object <oid> [<option>...]
rad cob migrate [<option>...]
Commands Commands
list List all COBs of a given type (--object is not needed) list List all COBs of a given type (--object is not needed)
log Print a log of all raw operations on a COB log Print a log of all raw operations on a COB
migrate Migrate the COB database to the latest version
Log options Log options
@ -54,13 +55,26 @@ Other options
enum OperationName { enum OperationName {
List, List,
Log, Log,
Migrate,
Show, Show,
} }
enum Operation { enum Operation {
List, List {
Log(Rev), repo: RepoId,
Show(Rev), 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 { enum Format {
@ -69,9 +83,7 @@ enum Format {
} }
pub struct Options { pub struct Options {
rid: RepoId,
op: Operation, op: Operation,
type_name: cob::TypeName,
format: Format, format: Format,
} }
@ -91,6 +103,7 @@ impl Args for Options {
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() { Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
"list" => op = Some(OperationName::List), "list" => op = Some(OperationName::List),
"log" => op = Some(OperationName::Log), "log" => op = Some(OperationName::Log),
"migrate" => op = Some(OperationName::Migrate),
"show" => op = Some(OperationName::Show), "show" => op = Some(OperationName::Show),
unknown => anyhow::bail!("unknown operation '{unknown}'"), unknown => anyhow::bail!("unknown operation '{unknown}'"),
}, },
@ -129,24 +142,35 @@ impl Args for Options {
_ => return Err(anyhow::anyhow!(arg.unexpected())), _ => 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(( Ok((
Options { Options {
op: { op: {
match op.ok_or_else(|| anyhow!("a command must be specified"))? { match op.ok_or_else(|| anyhow!("a command must be specified"))? {
OperationName::List => Operation::List, OperationName::List => Operation::List {
OperationName::Log => Operation::Log(oid.ok_or_else(|| { repo: repo?,
anyhow!("an object id must be specified with `--object`") type_name: type_name?,
})?), },
OperationName::Show => Operation::Show(oid.ok_or_else(|| { OperationName::Log => Operation::Log {
anyhow!("an object id must be specified with `--object`") 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), format: format.unwrap_or(Format::Pretty),
}, },
vec![], vec![],
@ -157,18 +181,23 @@ impl Args for Options {
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?; let profile = ctx.profile()?;
let storage = &profile.storage; let storage = &profile.storage;
let repo = storage.repository(options.rid)?;
match options.op { match options.op {
Operation::List => { Operation::List { repo, type_name } => {
let cobs = list::<NonEmpty<cob::Entry>, _>(&repo, &options.type_name)?; let repo = storage.repository(repo)?;
let cobs = list::<NonEmpty<cob::Entry>, _>(&repo, &type_name)?;
for cob in cobs { for cob in cobs {
println!("{}", cob.id); 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 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() { for op in ops.into_iter().rev() {
match options.format { 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)?; let oid = &oid.resolve(&repo.backend)?;
if options.type_name == cob::patch::TYPENAME.clone() { if type_name == cob::patch::TYPENAME.clone() {
let patches = profile.patches(&repo, migrate::ignore)?; let patches = term::cob::patches(&profile, &repo)?;
let Some(patch) = patches.get(oid)? else { 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)? serde_json::to_writer_pretty(std::io::stdout(), &patch)?
} else if options.type_name == cob::issue::TYPENAME.clone() { } else if type_name == cob::issue::TYPENAME.clone() {
let issues = profile.issues(&repo, migrate::ignore)?; let issues = term::cob::issues(&profile, &repo)?;
let Some(issue) = issues.get(oid)? else { 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)? serde_json::to_writer_pretty(std::io::stdout(), &issue)?
} else if options.type_name == cob::identity::TYPENAME.clone() { } else if type_name == cob::identity::TYPENAME.clone() {
let Some(cob) = cob::get::<Identity, _>(&repo, &options.type_name, oid)? else { let Some(cob) = cob::get::<Identity, _>(&repo, &type_name, 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(), &cob.object)? serde_json::to_writer_pretty(std::io::stdout(), &cob.object)?
} else { } else {
anyhow::bail!("the type name '{}' is unknown", options.type_name); anyhow::bail!("the type name '{type_name}' is unknown");
} }
println!(); println!();
} }

View File

@ -6,7 +6,6 @@ use anyhow::anyhow;
use git_ref_format::Qualified; use git_ref_format::Qualified;
use localtime::LocalTime; use localtime::LocalTime;
use radicle::cob::migrate;
use radicle::cob::TypedId; use radicle::cob::TypedId;
use radicle::identity::Identity; use radicle::identity::Identity;
use radicle::issue::cache::Issues as _; use radicle::issue::cache::Issues as _;
@ -272,8 +271,8 @@ where
let (_, head) = repo.head()?; let (_, head) = repo.head()?;
let doc = repo.identity_doc()?; let doc = repo.identity_doc()?;
let proj = doc.project()?; let proj = doc.project()?;
let issues = profile.issues(&repo, migrate::ignore)?; let issues = term::cob::issues(profile, &repo)?;
let patches = profile.patches(&repo, migrate::ignore)?; let patches = term::cob::patches(profile, &repo)?;
let mut notifs = notifs.by_repo(&rid, sort_by.field)?.collect::<Vec<_>>(); let mut notifs = notifs.by_repo(&rid, sort_by.field)?.collect::<Vec<_>>();
if !sort_by.reverse { if !sort_by.reverse {
@ -536,7 +535,7 @@ fn show(
match n.kind { match n.kind {
NotificationKind::Cob { typed_id } if typed_id.is_issue() => { 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(); let issue = issues.get(&typed_id.id)?.unwrap();
term::issue::show( term::issue::show(
@ -547,7 +546,7 @@ fn show(
)?; )?;
} }
NotificationKind::Cob { typed_id } if typed_id.is_patch() => { 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(); let patch = patches.get(&typed_id.id)?.unwrap();
term::patch::show(&patch, &typed_id.id, false, &repo, None, profile)?; term::patch::show(&patch, &typed_id.id, false, &repo, None, profile)?;

View File

@ -9,7 +9,7 @@ use anyhow::{anyhow, Context as _};
use radicle::cob::common::{Label, Reaction}; use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue::{CloseReason, State}; use radicle::cob::issue::{CloseReason, State};
use radicle::cob::{issue, migrate, thread}; use radicle::cob::{issue, thread};
use radicle::crypto::Signer; use radicle::crypto::Signer;
use radicle::issue::cache::Issues as _; use radicle::issue::cache::Issues as _;
use radicle::prelude::{Did, RepoId}; use radicle::prelude::{Did, RepoId};
@ -459,8 +459,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
| Operation::Edit { .. } | Operation::Edit { .. }
| Operation::Comment { .. } | Operation::Comment { .. }
); );
let mut issues = term::cob::issues_mut(&profile, &repo)?;
let mut issues = profile.issues_mut(&repo, migrate::ignore)?;
match options.op { match options.op {
Operation::Edit { Operation::Edit {

View File

@ -1,6 +1,5 @@
use std::ops::ControlFlow; use std::ops::ControlFlow;
use radicle::cob::migrate;
use radicle::issue::IssueId; use radicle::issue::IssueId;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
use radicle::storage::ReadStorage as _; 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<()> { 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 { match id {
Some(id) => { Some(id) => {
issues.write(&id)?; issues.write(&id)?;

View File

@ -37,7 +37,7 @@ use std::ffi::OsString;
use anyhow::anyhow; use anyhow::anyhow;
use radicle::cob::patch::PatchId; use radicle::cob::patch::PatchId;
use radicle::cob::{migrate, patch, Label}; use radicle::cob::{patch, Label};
use radicle::git::RefString; use radicle::git::RefString;
use radicle::patch::cache::Patches as _; use radicle::patch::cache::Patches as _;
use radicle::storage::git::transport; 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)?; label::run(&patch_id, add, delete, &profile, &repository)?;
} }
Operation::Set { patch_id, remote } => { 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_id = patch_id.resolve(&repository.backend)?;
let patch = patches let patch = patches
.get(&patch_id)? .get(&patch_id)?

View File

@ -1,9 +1,8 @@
use super::*;
use radicle::cob::migrate;
use radicle::prelude::*; use radicle::prelude::*;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
use super::*;
pub fn run( pub fn run(
patch_id: &PatchId, patch_id: &PatchId,
undo: bool, undo: bool,
@ -11,7 +10,7 @@ pub fn run(
repository: &Repository, repository: &Repository,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let signer = term::signer(profile)?; 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 { let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found"); anyhow::bail!("Patch `{patch_id}` not found");
}; };

View File

@ -2,7 +2,6 @@ use std::collections::BTreeSet;
use super::*; use super::*;
use radicle::cob::migrate;
use radicle::prelude::Did; use radicle::prelude::Did;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
@ -16,7 +15,7 @@ pub fn run(
repository: &Repository, repository: &Repository,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let signer = term::signer(profile)?; 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 { let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found"); anyhow::bail!("Patch `{patch_id}` not found");
}; };

View File

@ -1,6 +1,5 @@
use std::ops::ControlFlow; use std::ops::ControlFlow;
use radicle::cob::migrate;
use radicle::patch::PatchId; use radicle::patch::PatchId;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
use radicle::storage::ReadStorage as _; 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<()> { 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 { match id {
Some(id) => { Some(id) => {

View File

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

View File

@ -1,8 +1,8 @@
use super::*; use super::*;
use radicle::cob; use radicle::cob;
use radicle::cob::patch;
use radicle::cob::thread::CommentId; use radicle::cob::thread::CommentId;
use radicle::cob::{migrate, patch};
use radicle::patch::ByRevision; use radicle::patch::ByRevision;
use radicle::prelude::*; use radicle::prelude::*;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
@ -20,7 +20,7 @@ pub fn run(
profile: &Profile, profile: &Profile,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let signer = term::signer(profile)?; 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 revision_id = revision_id.resolve::<cob::EntryId>(&repo.backend)?;
let ByRevision { let ByRevision {

View File

@ -1,4 +1,3 @@
use radicle::cob::migrate;
use radicle::prelude::*; use radicle::prelude::*;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
@ -6,7 +5,7 @@ use super::*;
pub fn run(patch_id: &PatchId, profile: &Profile, repository: &Repository) -> anyhow::Result<()> { pub fn run(patch_id: &PatchId, profile: &Profile, repository: &Repository) -> anyhow::Result<()> {
let signer = &term::signer(profile)?; 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)?; patches.remove(patch_id, signer)?;
Ok(()) Ok(())

View File

@ -1,6 +1,6 @@
use std::process; use std::process;
use radicle::cob::{migrate, patch}; use radicle::cob::patch;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
use super::*; use super::*;
@ -11,7 +11,7 @@ pub fn run(
stored: &Repository, stored: &Repository,
profile: &Profile, profile: &Profile,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let patches = profile.patches(stored, migrate::ignore)?; let patches = term::cob::patches(profile, stored)?;
let Some(patch) = patches.get(patch_id)? else { let Some(patch) = patches.get(patch_id)? else {
anyhow::bail!("Patch `{patch_id}` not found"); anyhow::bail!("Patch `{patch_id}` not found");
}; };

View File

@ -1,7 +1,7 @@
use super::*; use super::*;
use radicle::cob; use radicle::cob;
use radicle::cob::{migrate, patch}; use radicle::cob::patch;
use radicle::crypto; use radicle::crypto;
use radicle::prelude::*; use radicle::prelude::*;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
@ -16,7 +16,7 @@ pub fn run(
repository: &Repository, repository: &Repository,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let signer = term::signer(profile)?; 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 { let Ok(patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found"); anyhow::bail!("Patch `{patch_id}` not found");
}; };

View File

@ -1,8 +1,7 @@
use super::*;
use radicle::cob::migrate;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
use super::*;
use crate::terminal as term; use crate::terminal as term;
pub fn run( pub fn run(
@ -13,7 +12,7 @@ pub fn run(
repository: &Repository, repository: &Repository,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let signer = term::signer(profile)?; 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 { let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found"); anyhow::bail!("Patch `{patch_id}` not found");
}; };

View File

@ -1,7 +1,7 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use radicle::cob::patch;
use radicle::cob::patch::{Patch, PatchId}; use radicle::cob::patch::{Patch, PatchId};
use radicle::cob::{migrate, patch};
use radicle::patch::cache::Patches as _; use radicle::patch::cache::Patches as _;
use radicle::prelude::*; use radicle::prelude::*;
use radicle::profile::Profile; use radicle::profile::Profile;
@ -21,7 +21,7 @@ pub fn run(
repository: &Repository, repository: &Repository,
profile: &Profile, profile: &Profile,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let patches = profile.patches(repository, migrate::ignore)?; let patches = term::cob::patches(profile, repository)?;
let mut all = Vec::new(); let mut all = Vec::new();
let iter = match filter { let iter = match filter {

View File

@ -1,9 +1,8 @@
use super::*;
use radicle::cob::migrate;
use radicle::prelude::*; use radicle::prelude::*;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
use super::*;
pub fn run( pub fn run(
patch_id: &PatchId, patch_id: &PatchId,
undo: bool, undo: bool,
@ -11,7 +10,7 @@ pub fn run(
repository: &Repository, repository: &Repository,
) -> anyhow::Result<bool> { ) -> anyhow::Result<bool> {
let signer = term::signer(profile)?; 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 { let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found"); anyhow::bail!("Patch `{patch_id}` not found");
}; };

View File

@ -1,4 +1,4 @@
use radicle::cob::{migrate, patch}; use radicle::cob::patch;
use radicle::git::Oid; use radicle::git::Oid;
use radicle::prelude::*; use radicle::prelude::*;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
@ -13,7 +13,7 @@ pub fn run(
repository: &Repository, repository: &Repository,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let signer = &term::signer(profile)?; 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 revision_id = revision_id.resolve::<Oid>(&repository.backend)?;
let patch::ByRevision { let patch::ByRevision {

View File

@ -1,5 +1,4 @@
use anyhow::anyhow; use anyhow::anyhow;
use radicle::cob::migrate;
use radicle::cob::thread::CommentId; use radicle::cob::thread::CommentId;
use radicle::patch::{self, PatchId}; use radicle::patch::{self, PatchId};
use radicle::patch::{cache::Patches as _, ReviewId}; use radicle::patch::{cache::Patches as _, ReviewId};
@ -16,7 +15,7 @@ pub fn resolve(
profile: &Profile, profile: &Profile,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let signer = term::signer(profile)?; 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 let patch = patches
.get(&patch_id)? .get(&patch_id)?
.ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?; .ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;
@ -33,7 +32,7 @@ pub fn unresolve(
profile: &Profile, profile: &Profile,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let signer = term::signer(profile)?; 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 let patch = patches
.get(&patch_id)? .get(&patch_id)?
.ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?; .ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;

View File

@ -3,7 +3,6 @@ mod builder;
use anyhow::{anyhow, Context}; use anyhow::{anyhow, Context};
use radicle::cob::migrate;
use radicle::cob::patch::{PatchId, RevisionId, Verdict}; use radicle::cob::patch::{PatchId, RevisionId, Verdict};
use radicle::git; use radicle::git;
use radicle::prelude::*; use radicle::prelude::*;
@ -62,7 +61,7 @@ pub fn run(
"couldn't load repository {} from local state", "couldn't load repository {} from local state",
repository.id repository.id
))?; ))?;
let mut patches = profile.patches_mut(repository, migrate::ignore)?; let mut patches = term::cob::patches_mut(profile, repository)?;
let mut patch = patches let mut patch = patches
.get_mut(&patch_id) .get_mut(&patch_id)
.context(format!("couldn't find patch {patch_id} locally"))?; .context(format!("couldn't find patch {patch_id} locally"))?;

View File

@ -1,6 +1,6 @@
use std::process; use std::process;
use radicle::cob::{migrate, patch}; use radicle::cob::patch;
use radicle::git; use radicle::git;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
@ -32,7 +32,7 @@ pub fn run(
stored: &Repository, stored: &Repository,
workdir: Option<&git::raw::Repository>, workdir: Option<&git::raw::Repository>,
) -> anyhow::Result<()> { ) -> 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 { let Some(patch) = patches.get(patch_id).map_err(|e| Error::WithHint {
err: e.into(), err: e.into(),
hint: "reset the cache with `rad patch cache` and try again", hint: "reset the cache with `rad patch cache` and try again",

View File

@ -1,4 +1,4 @@
use radicle::cob::{migrate, patch}; use radicle::cob::patch;
use radicle::git; use radicle::git;
use radicle::prelude::*; use radicle::prelude::*;
use radicle::storage::git::Repository; use radicle::storage::git::Repository;
@ -19,7 +19,7 @@ pub fn run(
let head_branch = try_branch(workdir.head()?)?; let head_branch = try_branch(workdir.head()?)?;
let (_, target_oid) = get_merge_target(repository, &head_branch)?; 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 { let Ok(mut patch) = patches.get_mut(&patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found"); anyhow::bail!("Patch `{patch_id}` not found");
}; };

View File

@ -3,7 +3,6 @@ use std::path::Path;
use localtime::LocalDuration; use localtime::LocalDuration;
use localtime::LocalTime; use localtime::LocalTime;
use radicle::cob::migrate;
use radicle::git; use radicle::git;
use radicle::issue::cache::Issues as _; use radicle::issue::cache::Issues as _;
use radicle::node::address; use radicle::node::address;
@ -96,8 +95,8 @@ pub fn run(_options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
for repo in storage.repositories()? { for repo in storage.repositories()? {
let repo = storage.repository(repo.rid)?; let repo = storage.repository(repo.rid)?;
let issues = profile.issues(&repo, migrate::ignore)?.counts()?; let issues = term::cob::issues(&profile, &repo)?.counts()?;
let patches = profile.patches(&repo, migrate::ignore)?.counts()?; let patches = term::cob::patches(&profile, &repo)?.counts()?;
stats.local.issues += issues.total(); stats.local.issues += issues.total();
stats.local.patches += patches.total(); stats.local.patches += patches.total();

View File

@ -4,6 +4,7 @@ pub mod format;
pub mod io; pub mod io;
pub mod job; pub mod job;
pub use io::signer; pub use io::signer;
pub mod cob;
pub mod comment; pub mod comment;
pub mod highlight; pub mod highlight;
pub mod issue; pub mod issue;

View File

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

View File

@ -186,6 +186,25 @@ fn rad_cob_show() {
test("examples/rad-cob-show.md", &working, Some(home), []).unwrap(); test("examples/rad-cob-show.md", &working, Some(home), []).unwrap();
} }
#[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] #[test]
fn rad_init() { fn rad_init() {
let mut environment = Environment::new(); let mut environment = Environment::new();

View File

@ -8,6 +8,7 @@ use std::{fs, io, net};
use crossbeam_channel as chan; use crossbeam_channel as chan;
use cyphernet::Ecdh; use cyphernet::Ecdh;
use netservices::resource::NetAccept; use netservices::resource::NetAccept;
use radicle::cob::migrate;
use radicle_fetch::FetchLimit; use radicle_fetch::FetchLimit;
use radicle_signals::Signal; use radicle_signals::Signal;
use reactor::poller::popol; use reactor::poller::popol;
@ -141,7 +142,17 @@ impl Runtime {
let policies = home.policies_mut()?; let policies = home.policies_mut()?;
let policies = policy::Config::new(policy, policies); let policies = policy::Config::new(policy, policies);
let notifications = home.notifications_mut()?; 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", "Default seeding policy set to '{}'", &policy);
log::info!(target: "node", "Initializing service ({:?})..", network); log::info!(target: "node", "Initializing service ({:?})..", network);

View File

@ -10,8 +10,7 @@ use std::{
use crossbeam_channel as chan; use crossbeam_channel as chan;
use localtime::LocalTime; use localtime::LocalTime;
use radicle::cob::cache::COBS_DB_FILE; use radicle::cob::{issue, migrate};
use radicle::cob::issue;
use radicle::crypto::ssh::{keystore::MemorySigner, Keystore}; use radicle::crypto::ssh::{keystore::MemorySigner, Keystore};
use radicle::crypto::test::signer::MockSigner; use radicle::crypto::test::signer::MockSigner;
use radicle::crypto::{KeyPair, Seed, Signer}; use radicle::crypto::{KeyPair, Seed, Signer};
@ -111,7 +110,6 @@ impl Environment {
let keystore = Keystore::new(&home.keys()); let keystore = Keystore::new(&home.keys());
let keypair = KeyPair::from_seed(Seed::from([!(self.users as u8); 32])); let keypair = KeyPair::from_seed(Seed::from([!(self.users as u8); 32]));
let policies_db = home.node().join(POLICIES_DB_FILE); let policies_db = home.node().join(POLICIES_DB_FILE);
let cobs_db = home.cobs().join(COBS_DB_FILE);
let now = LocalTime::now(); let now = LocalTime::now();
config.write(&home.config()).unwrap(); config.write(&home.config()).unwrap();
@ -126,8 +124,10 @@ impl Environment {
.unwrap(); .unwrap();
let public_key = keypair.pk.into(); 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(); policy::Store::open(policies_db).unwrap();
cob::cache::Store::open(cobs_db).unwrap();
home.database_mut() home.database_mut()
.unwrap() .unwrap()
.init( .init(

View File

@ -11,12 +11,13 @@ use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use std::{env, fmt, io}; use std::{env, fmt, io};
use radicle_cli::git::Rev;
use thiserror::Error; use thiserror::Error;
use radicle::git;
use radicle::storage::git::transport::local::{Url, UrlError}; use radicle::storage::git::transport::local::{Url, UrlError};
use radicle::storage::{ReadRepository, WriteStorage}; 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; use radicle_cli::terminal as cli;
#[derive(Debug, Error)] #[derive(Debug, Error)]
@ -259,3 +260,36 @@ pub(crate) fn hint(s: impl fmt::Display) {
pub(crate) fn warn(s: impl fmt::Display) { pub(crate) fn warn(s: impl fmt::Display) {
eprintln!("{}", cli::format::hint(format!("warn: {s}"))); 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()),
}
}

View File

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

View File

@ -9,8 +9,8 @@ use thiserror::Error;
use radicle::cob; use radicle::cob;
use radicle::cob::object::ParseObjectId; use radicle::cob::object::ParseObjectId;
use radicle::cob::patch;
use radicle::cob::patch::cache::Patches as _; use radicle::cob::patch::cache::Patches as _;
use radicle::cob::{migrate, patch};
use radicle::crypto::Signer; use radicle::crypto::Signer;
use radicle::explorer::ExplorerResource; use radicle::explorer::ExplorerResource;
use radicle::git::canonical; use radicle::git::canonical;
@ -219,6 +219,7 @@ pub fn run(
} }
Command::Push(git::Refspec { src, dst, force }) => { Command::Push(git::Refspec { src, dst, force }) => {
let working = git::raw::Repository::open(working)?; let working = git::raw::Repository::open(working)?;
let patches = crate::patches_mut(profile, stored)?;
if dst == &*rad::PATCHES_REFNAME { if dst == &*rad::PATCHES_REFNAME {
patch_open( patch_open(
@ -227,7 +228,7 @@ pub fn run(
&nid, &nid,
&working, &working,
stored, stored,
profile.patches_mut(stored, migrate::ignore)?, patches,
&signer, &signer,
profile, profile,
opts.clone(), opts.clone(),
@ -247,7 +248,7 @@ pub fn run(
&nid, &nid,
&working, &working,
stored, stored,
profile.patches_mut(stored, migrate::ignore)?, patches,
&signer, &signer,
opts.clone(), opts.clone(),
) )
@ -323,16 +324,7 @@ pub fn run(
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
}; };
} }
push( push(src, &dst, *force, &nid, &working, stored, patches, &signer)
src,
&dst,
*force,
&nid,
&working,
stored,
profile.patches_mut(stored, migrate::ignore)?,
&signer,
)
} }
} }
} }

View File

@ -2,7 +2,6 @@ use std::collections::HashSet;
use std::env; use std::env;
use anyhow::{anyhow, bail}; use anyhow::{anyhow, bail};
use radicle::cob::migrate;
use radicle::cob::patch::{PatchId, RevisionId}; use radicle::cob::patch::{PatchId, RevisionId};
use radicle::git::Oid; use radicle::git::Oid;
use radicle::storage::ReadStorage; use radicle::storage::ReadStorage;
@ -27,7 +26,7 @@ fn main() -> anyhow::Result<()> {
let profile = radicle::Profile::load()?; let profile = radicle::Profile::load()?;
let (working, rid) = radicle::rad::cwd()?; let (working, rid) = radicle::rad::cwd()?;
let stored = profile.storage.repository(rid)?; 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)?; let mut patch = patches.get_mut(&pid)?;
if patch.is_merged() { if patch.is_merged() {

View File

@ -1,3 +1,5 @@
mod migrations;
use std::collections::HashMap; use std::collections::HashMap;
use std::convert::Infallible; use std::convert::Infallible;
use std::fmt; use std::fmt;
@ -23,34 +25,51 @@ const DB_WRITE_TIMEOUT: time::Duration = time::Duration::from_secs(6);
/// Database migrations. /// Database migrations.
/// The first migration is the creation of the initial tables. /// 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. /// 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. /// A database migration.
enum Migration { enum Migration {
/// Migration written in SQL. /// Migration written in SQL.
Sql(&'static str), Sql(&'static str),
/// Migration function written in Rust. /// Migration function written in Rust.
#[allow(dead_code)]
Native(MigrateFn), 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. /// Something that can process migration progress.
pub trait MigrateCallback { pub trait MigrateCallback {
/// A migration has progressed. /// A migration has progressed.
/// The first [`Progress`] parameter refers to the progress within the list of migrations. fn progress(&mut self, progress: MigrateProgress<'_>);
/// 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 impl<F> MigrateCallback for F
where where
F: Fn(Progress, Progress) -> Result<bool, Error>, F: Fn(MigrateProgress),
{ {
fn progress(&mut self, migration: Progress, item: Progress) -> Result<bool, Error> { fn progress(&mut self, progress: MigrateProgress) {
(self)(migration, item) (self)(progress)
} }
} }
@ -59,21 +78,18 @@ pub mod migrate {
use super::*; use super::*;
/// Log progress via installed logger at "info" level. /// Log progress via installed logger at "info" level.
pub fn log(migration: Progress, item: Progress) -> Result<bool, Error> { pub fn log(progress: MigrateProgress<'_>) {
log::info!( log::trace!(
target: "db", target: "db",
"Migration {}/{} in progress.. ({}%)", "Migration {}/{} in progress.. ({}%)",
migration.current() + 1, progress.migration.current(),
migration.total(), progress.migration.total(),
item.percentage() progress.rows.percentage()
); );
Ok(true)
} }
/// Ignore progress, just migrate. /// Ignore progress, just migrate.
pub fn ignore(_migration: Progress, _item: Progress) -> Result<bool, Error> { pub fn ignore(_progress: MigrateProgress<'_>) {}
Ok(true)
}
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -81,9 +97,18 @@ pub enum Error {
/// An Internal error. /// An Internal error.
#[error("internal error: {0}")] #[error("internal error: {0}")]
Internal(#[from] sql::Error), 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. /// No rows returned in query result.
#[error("no rows returned")] #[error("no rows returned")]
NoRows, 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. /// Read and write to the store.
@ -148,7 +173,6 @@ impl Store<Write> {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, Error> { pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
let mut db = sql::Connection::open_thread_safe(path)?; let mut db = sql::Connection::open_thread_safe(path)?;
db.set_busy_timeout(DB_WRITE_TIMEOUT.as_millis() as usize)?; db.set_busy_timeout(DB_WRITE_TIMEOUT.as_millis() as usize)?;
migrate(&db, migrate::ignore)?;
Ok(Self { Ok(Self {
db: Arc::new(db), db: Arc::new(db),
@ -159,7 +183,6 @@ impl Store<Write> {
/// Create a new in-memory database. /// Create a new in-memory database.
pub fn memory() -> Result<Self, Error> { pub fn memory() -> Result<Self, Error> {
let db = Arc::new(sql::Connection::open_thread_safe(":memory:")?); let db = Arc::new(sql::Connection::open_thread_safe(":memory:")?);
migrate(&db, migrate::ignore)?;
Ok(Self { Ok(Self {
db, 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. /// Turn this handle into a read-only handle.
pub fn read_only(self) -> Store<Read> { pub fn read_only(self) -> Store<Read> {
Store { Store {
@ -183,6 +211,47 @@ impl Store<Write> {
{ {
transaction(&self.db, query) 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> { impl<T> Store<T> {
@ -190,6 +259,14 @@ impl<T> Store<T> {
pub fn version(&self) -> Result<usize, Error> { pub fn version(&self) -> Result<usize, Error> {
version(&self.db) 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. /// 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) 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. /// Update a COB object in the cache.
pub trait Update<T> { pub trait Update<T> {
/// The output type, if any, for a successful update. /// 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 /// See [`crate::cob::issue::Cache::write_all`] and
/// [`crate::cob::patch::Cache::write_all`]. /// [`crate::cob::patch::Cache::write_all`].
#[derive(Debug)]
pub struct Progress { pub struct Progress {
current: usize, current: usize,
total: usize, total: usize,
@ -386,11 +431,45 @@ impl Progress {
} }
/// Return the percentage of the progress made. /// Return the percentage of the progress made.
///
/// # Panics
///
/// If the `total` provided is `0`.
pub fn percentage(&self) -> f32 { 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);
} }
} }

2
radicle/src/cob/cache/migrations.rs vendored Normal file
View File

@ -0,0 +1,2 @@
#[path = "migrations/2.rs"]
pub mod _2;

132
radicle/src/cob/cache/migrations/2.rs vendored Normal file
View File

@ -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();
}
}

View File

@ -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"
]
}
}

View File

@ -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"
]
}
}

View File

@ -534,6 +534,7 @@ mod tests {
use radicle_cob::ObjectId; use radicle_cob::ObjectId;
use crate::cob::cache::{Store, Update, Write}; use crate::cob::cache::{Store, Update, Write};
use crate::cob::migrate;
use crate::cob::thread::Thread; use crate::cob::thread::Thread;
use crate::issue::{CloseReason, Issue, IssueCounts, IssueId, State}; use crate::issue::{CloseReason, Issue, IssueCounts, IssueId, State};
use crate::test::arbitrary; use crate::test::arbitrary;
@ -542,7 +543,10 @@ mod tests {
use super::{Cache, Issues}; use super::{Cache, Issues};
fn memory(store: MockRepository) -> Cache<MockRepository, Store<Write>> { 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 } Cache { store, cache }
} }

View File

@ -2739,6 +2739,8 @@ mod test {
use crate::test::arbitrary::gen; use crate::test::arbitrary::gen;
use crate::test::storage::MockRepository; use crate::test::storage::MockRepository;
use cob::migrate;
#[test] #[test]
fn test_json_serialization() { fn test_json_serialization() {
let edit = Action::Label { let edit = Action::Label {
@ -3352,8 +3354,10 @@ mod test {
let branch = checkout.branch_with([("README", b"Hello World!")]); let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = { let mut patches = {
let path = alice.tmp.path().join("cobs.db"); 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(); let store = cob::patch::Patches::open(&*alice.repo).unwrap();
db.migrate(migrate::ignore).unwrap();
cob::patch::Cache::open(store, db) cob::patch::Cache::open(store, db)
}; };
let mut patch = patches let mut patch = patches

View File

@ -362,7 +362,7 @@ impl Update<Patch> for StoreWriter {
"INSERT INTO patches (id, repo, patch) "INSERT INTO patches (id, repo, patch)
VALUES (?1, ?2, ?3) VALUES (?1, ?2, ?3)
ON CONFLICT DO UPDATE ON CONFLICT DO UPDATE
SET patch = (?3)", SET patch = (?3)",
)?; )?;
stmt.bind((1, sql::Value::String(id.to_string())))?; stmt.bind((1, sql::Value::String(id.to_string())))?;
@ -705,6 +705,7 @@ mod tests {
use radicle_cob::ObjectId; use radicle_cob::ObjectId;
use crate::cob::cache::{Store, Update, Write}; use crate::cob::cache::{Store, Update, Write};
use crate::cob::migrate;
use crate::cob::thread::{Comment, Thread}; use crate::cob::thread::{Comment, Thread};
use crate::cob::Author; use crate::cob::Author;
use crate::patch::{ use crate::patch::{
@ -718,7 +719,10 @@ mod tests {
use super::{Cache, Patches}; use super::{Cache, Patches};
fn memory(store: MockRepository) -> Cache<MockRepository, Store<Write>> { 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 } Cache { store, cache }
} }

View File

@ -20,6 +20,7 @@ use std::{fs, io};
use localtime::LocalTime; use localtime::LocalTime;
use thiserror::Error; use thiserror::Error;
use crate::cob::migrate;
use crate::crypto::ssh::agent::Agent; use crate::crypto::ssh::agent::Agent;
use crate::crypto::ssh::{keystore, Keystore, Passphrase}; use crate::crypto::ssh::{keystore, Keystore, Passphrase};
use crate::crypto::{PublicKey, Signer}; use crate::crypto::{PublicKey, Signer};
@ -229,6 +230,10 @@ impl Profile {
config.node.external_addresses.iter(), config.node.external_addresses.iter(),
)?; )?;
// Migrate COBs cache.
let mut cobs = home.cobs_db_mut()?;
cobs.migrate(migrate::ignore)?;
transport::local::register(storage.clone()); transport::local::register(storage.clone());
Ok(Profile { Ok(Profile {
@ -561,71 +566,83 @@ impl Home {
self.database_mut() self.database_mut()
} }
/// Return a read-only handle for the issues cache. /// Get read access to the COBs cache.
pub fn issues<'a, R, M>( pub fn cobs_db(&self) -> Result<cob::cache::StoreReader, Error> {
&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 path = self.cobs().join(cob::cache::COBS_DB_FILE);
let db = cob::cache::Store::reader(path)?; 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>(
&self,
repository: &'a R,
) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreReader>, Error>
where
R: ReadRepository + cob::Store,
{
let db = self.cobs_db()?;
let store = cob::issue::Issues::open(repository)?; let store = cob::issue::Issues::open(repository)?;
db.check_version()?;
Ok(cob::issue::Cache::reader(store, db)) Ok(cob::issue::Cache::reader(store, db))
} }
/// Return a read-write handle for the issues cache. /// Return a read-write handle for the issues cache.
pub fn issues_mut<'a, R, M>( pub fn issues_mut<'a, R>(
&self, &self,
repository: &'a R, repository: &'a R,
_migrate: M,
) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreWriter>, Error> ) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreWriter>, Error>
where where
M: cob::MigrateCallback,
R: ReadRepository + cob::Store, R: ReadRepository + cob::Store,
{ {
let path = self.cobs().join(cob::cache::COBS_DB_FILE); let db = self.cobs_db_mut()?;
let db = cob::cache::Store::open(path)?;
let store = cob::issue::Issues::open(repository)?; let store = cob::issue::Issues::open(repository)?;
db.check_version()?;
Ok(cob::issue::Cache::open(store, db)) Ok(cob::issue::Cache::open(store, db))
} }
/// Return a read-only handle for the patches cache. /// Return a read-only handle for the patches cache.
pub fn patches<'a, R, M>( pub fn patches<'a, R>(
&self, &self,
repository: &'a R, repository: &'a R,
_migrate: M,
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, Error> ) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, Error>
where where
M: cob::MigrateCallback,
R: ReadRepository + cob::Store, R: ReadRepository + cob::Store,
{ {
let path = self.cobs().join(cob::cache::COBS_DB_FILE); let db = self.cobs_db()?;
let db = cob::cache::Store::reader(path)?;
let store = cob::patch::Patches::open(repository)?; let store = cob::patch::Patches::open(repository)?;
db.check_version()?;
Ok(cob::patch::Cache::reader(store, db)) Ok(cob::patch::Cache::reader(store, db))
} }
/// Return a read-write handle for the patches cache. /// Return a read-write handle for the patches cache.
pub fn patches_mut<'a, R, M>( pub fn patches_mut<'a, R>(
&self, &self,
repository: &'a R, repository: &'a R,
_migrate: M,
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreWriter>, Error> ) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreWriter>, Error>
where where
M: cob::MigrateCallback,
R: ReadRepository + cob::Store, R: ReadRepository + cob::Store,
{ {
let path = self.cobs().join(cob::cache::COBS_DB_FILE); let db = self.cobs_db_mut()?;
let db = cob::cache::Store::open(path)?;
let store = cob::patch::Patches::open(repository)?; let store = cob::patch::Patches::open(repository)?;
db.check_version()?;
Ok(cob::patch::Cache::open(store, db)) Ok(cob::patch::Cache::open(store, db))
} }
} }