radicle: implement caching for issues and patches

Implement caching for the `Issue` and `Patch` COB. This is achieved by
having the `Transaction::initial` and `Transaction::commit` methods
take a cache which can update the newly created/updated object. The
`Store::remove` method also takes the cache for removing the object
from the cache, as well as the repository.

This meant that the `*Mut` types for the respective COBs are required
to carry a cache alongside the repository store. Identities will not
be cached, and so this is always set to `NoCache` -- which performs no
caching, and always succeeds.

To perform cache reading for issues and patches, the `cache::Issues`
and `cache::Patches` traits are introduced. They capture the minimal
amount of methods that are needed for the `rad issue` and `rad patch`
CLI commands.

The implementor for each of these traits is there respective `Cache`
types, which combines their backing repository store and the SQLite
database. They both provide convenience methods:
- `create` (and `draft` for patches)
- `get_mut`
- `remove`
which can be used instead of their repository store counterparts.

All uses of the repository stores (where needed) are replaced with
`Cache` types.

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
This commit is contained in:
Fintan Halpenny 2024-02-08 16:52:05 +00:00 committed by cloudhead
parent b26842d4d8
commit 985b0af3f6
No known key found for this signature in database
44 changed files with 2632 additions and 289 deletions

View File

@ -5,9 +5,10 @@ use std::str::FromStr;
use std::time;
use anyhow::anyhow;
use radicle::issue::cache::Issues as _;
use radicle::patch::cache::Patches as _;
use thiserror::Error;
use radicle::cob;
use radicle::git::raw;
use radicle::identity::doc;
use radicle::identity::doc::{DocError, RepoId};
@ -186,8 +187,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 = cob::issue::Issues::open(&repo)?.counts()?;
let patches = cob::patch::Patches::open(&repo)?.counts()?;
let issues = profile.issues(&repo)?.counts()?;
let patches = profile.patches(&repo)?.counts()?;
info.push([term::Line::spaced([
term::format::tertiary(issues.open).into(),

View File

@ -6,10 +6,10 @@ use anyhow::anyhow;
use localtime::LocalTime;
use radicle::identity::Identity;
use radicle::issue::Issues;
use radicle::issue::cache::Issues as _;
use radicle::node::notifications;
use radicle::node::notifications::*;
use radicle::patch::Patches;
use radicle::patch::cache::Patches as _;
use radicle::prelude::{Profile, RepoId};
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::{cob, Storage};
@ -233,8 +233,8 @@ where
let (_, head) = repo.head()?;
let doc = repo.identity_doc()?;
let proj = doc.project()?;
let issues = Issues::open(&repo)?;
let patches = Patches::open(&repo)?;
let issues = profile.issues(&repo)?;
let patches = profile.patches(&repo)?;
let mut notifs = notifs.by_repo(&rid, sort_by.field)?.collect::<Vec<_>>();
if !sort_by.reverse {
@ -406,13 +406,13 @@ fn show(
match n.kind {
NotificationKind::Cob { type_name, id } if type_name == *cob::issue::TYPENAME => {
let issues = Issues::open(&repo)?;
let issues = profile.issues(&repo)?;
let issue = issues.get(&id)?.unwrap();
term::issue::show(&issue, &id, term::issue::Format::default(), profile)?;
}
NotificationKind::Cob { type_name, id } if type_name == *cob::patch::TYPENAME => {
let patches = Patches::open(&repo)?;
let patches = profile.patches(&repo)?;
let patch = patches.get(&id)?.unwrap();
term::patch::show(&patch, &id, false, &repo, None, profile)?;

View File

@ -6,13 +6,14 @@ use anyhow::{anyhow, Context as _};
use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue;
use radicle::cob::issue::{CloseReason, Issues, State};
use radicle::cob::issue::{CloseReason, State};
use radicle::cob::thread;
use radicle::crypto::Signer;
use radicle::issue::cache::Issues as _;
use radicle::prelude::Did;
use radicle::profile;
use radicle::storage;
use radicle::storage::{WriteRepository, WriteStorage};
use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
use radicle::Profile;
use radicle::{cob, Node};
@ -425,7 +426,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
| Operation::Label { .. }
);
let mut issues = Issues::open(&repo)?;
let mut issues = profile.issues_mut(&repo)?;
match options.op {
Operation::Edit {
@ -546,7 +547,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
issue.label(labels, &signer)?;
}
Operation::List { assigned, state } => {
list(&issues, &assigned, &state, &profile)?;
list(issues, &assigned, &state, &profile)?;
}
Operation::Delete { id } => {
let id = id.resolve(&repo.backend)?;
@ -562,13 +563,16 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
Ok(())
}
fn list<R: WriteRepository + cob::Store>(
issues: &Issues<R>,
fn list<C>(
cache: C,
assigned: &Option<Assigned>,
state: &Option<State>,
profile: &profile::Profile,
) -> anyhow::Result<()> {
if issues.is_empty()? {
) -> anyhow::Result<()>
where
C: issue::cache::Issues,
{
if cache.is_empty()? {
term::print(term::format::italic("Nothing to show."));
return Ok(());
}
@ -580,7 +584,8 @@ fn list<R: WriteRepository + cob::Store>(
};
let mut all = Vec::new();
for result in issues.all()? {
let issues = cache.list()?;
for result in issues {
let Ok((id, issue)) = result else {
// Skip issues that failed to load.
continue;
@ -664,16 +669,20 @@ fn list<R: WriteRepository + cob::Store>(
Ok(())
}
fn open<R: WriteRepository + cob::Store, G: Signer>(
fn open<R, G>(
title: Option<String>,
description: Option<String>,
labels: Vec<Label>,
assignees: Vec<Did>,
options: &Options,
issues: &mut Issues<R>,
cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
signer: &G,
profile: &Profile,
) -> anyhow::Result<()> {
) -> anyhow::Result<()>
where
R: ReadRepository + WriteRepository + cob::Store,
G: Signer,
{
let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
(t.to_owned(), d.to_owned())
} else if let Some((t, d)) = term::issue::get_title_description(title, description)? {
@ -681,7 +690,7 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
} else {
anyhow::bail!("aborting issue creation due to empty title or description");
};
let issue = issues.create(
let issue = cache.create(
&title,
description,
labels.as_slice(),
@ -696,14 +705,18 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
Ok(())
}
fn edit<'a, 'g, R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
issues: &'a mut issue::Issues<'a, R>,
fn edit<'a, 'g, R, G>(
issues: &'g mut issue::Cache<issue::Issues<'a, R>, cob::cache::StoreWriter>,
repo: &storage::git::Repository,
id: Rev,
title: Option<String>,
description: Option<String>,
signer: &G,
) -> anyhow::Result<issue::IssueMut<'a, 'g, R>> {
) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
where
R: WriteRepository + ReadRepository + cob::Store,
G: radicle::crypto::Signer,
{
let id = id.resolve(&repo.backend)?;
let mut issue = issues.get_mut(&id)?;
let (root, _) = issue.root();

View File

@ -34,6 +34,7 @@ use anyhow::anyhow;
use radicle::cob::patch::PatchId;
use radicle::cob::{patch, Label};
use radicle::patch::cache::Patches as _;
use radicle::storage::git::transport;
use radicle::{prelude::*, Node};
@ -179,27 +180,6 @@ pub struct LabelOptions {
pub delete: BTreeSet<Label>,
}
pub struct Filter(fn(&patch::State) -> bool);
impl Filter {
/// Match everything.
fn all() -> Self {
Self(|_| true)
}
}
impl Default for Filter {
fn default() -> Self {
Self(|state| matches!(state, patch::State::Open { .. }))
}
}
impl std::fmt::Debug for Filter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Filter(..)")
}
}
#[derive(Debug)]
pub enum Operation {
Show {
@ -250,7 +230,7 @@ pub enum Operation {
opts: LabelOptions,
},
List {
filter: Filter,
filter: Option<patch::Status>,
},
Edit {
patch_id: Rev,
@ -311,7 +291,7 @@ impl Args for Options {
let mut patch_id = None;
let mut revision_id = None;
let mut message = Message::default();
let mut filter = Filter::default();
let mut filter = Some(patch::Status::Open);
let mut diff = false;
let mut debug = false;
let mut undo = false;
@ -487,19 +467,19 @@ impl Args for Options {
// List options.
Long("all") => {
filter = Filter::all();
filter = None;
}
Long("draft") => {
filter = Filter(|s| s == &patch::State::Draft);
filter = Some(patch::Status::Draft);
}
Long("archived") => {
filter = Filter(|s| s == &patch::State::Archived);
filter = Some(patch::Status::Archived);
}
Long("merged") => {
filter = Filter(|s| matches!(s, patch::State::Merged { .. }));
filter = Some(patch::Status::Merged);
}
Long("open") => {
filter = Filter(|s| matches!(s, patch::State::Open { .. }));
filter = Some(patch::Status::Open);
}
Long("authored") => {
authored = true;
@ -662,12 +642,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
transport::local::register(profile.storage.clone());
match options.op {
Operation::List { filter: Filter(f) } => {
Operation::List { filter } => {
let mut authors: BTreeSet<Did> = options.authors.iter().cloned().collect();
if options.authored {
authors.insert(profile.did());
}
list::run(f, authors, &repository, &profile)?;
list::run(filter.as_ref(), authors, &repository, &profile)?;
}
Operation::Show {
patch_id,
@ -694,7 +674,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
.map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
.transpose()?
.map(patch::RevisionId::from);
diff::run(&patch_id, revision_id, &repository)?;
diff::run(&patch_id, revision_id, &repository, &profile)?;
}
Operation::Update {
ref patch_id,
@ -742,6 +722,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
revision_id,
&repository,
&workdir,
&profile,
opts,
)?;
}
@ -801,7 +782,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
label::run(&patch_id, add, delete, &profile, &repository)?;
}
Operation::Set { patch_id } => {
let patches = radicle::cob::patch::Patches::open(&repository)?;
let patches = profile.patches(&repository)?;
let patch_id = patch_id.resolve(&repository.backend)?;
let patch = patches
.get(&patch_id)?

View File

@ -1,12 +1,11 @@
use super::*;
use radicle::cob::patch;
use radicle::prelude::*;
use radicle::storage::git::Repository;
pub fn run(patch_id: &PatchId, profile: &Profile, repository: &Repository) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = patch::Patches::open(repository)?;
let mut patches = profile.patches_mut(repository)?;
let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -15,7 +15,7 @@ pub fn run(
repository: &Repository,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = radicle::cob::patch::Patches::open(repository)?;
let mut patches = profile.patches_mut(repository)?;
let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -4,9 +4,10 @@ use git_ref_format::Qualified;
use radicle::cob::patch;
use radicle::cob::patch::RevisionId;
use radicle::git::RefString;
use radicle::patch::cache::Patches as _;
use radicle::patch::PatchId;
use radicle::storage::git::Repository;
use radicle::{git, rad};
use radicle::{git, rad, Profile};
use crate::terminal as term;
@ -33,10 +34,10 @@ pub fn run(
revision_id: Option<RevisionId>,
stored: &Repository,
working: &git::raw::Repository,
profile: &Profile,
opts: Options,
) -> anyhow::Result<()> {
let patches = patch::Patches::open(stored)?;
let patches = profile.patches(stored)?;
let patch = patches
.get(patch_id)?
.ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;

View File

@ -3,6 +3,7 @@ use super::*;
use radicle::cob;
use radicle::cob::patch;
use radicle::cob::thread::CommentId;
use radicle::patch::ByRevision;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@ -19,10 +20,15 @@ pub fn run(
profile: &Profile,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = patch::Patches::open(repo)?;
let mut patches = profile.patches_mut(repo)?;
let revision_id = revision_id.resolve::<cob::EntryId>(&repo.backend)?;
let (patch_id, patch, revision_id, revision) = patches
let ByRevision {
id: patch_id,
patch,
revision_id,
revision,
} = patches
.find_by_revision(&patch::RevisionId::from(revision_id))?
.ok_or_else(|| anyhow!("Patch revision `{revision_id}` not found"))?;
let mut patch = patch::PatchMut::new(patch_id, patch, &mut patches);

View File

@ -1,4 +1,3 @@
use radicle::cob::patch;
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 patches = patch::Patches::open(repository)?;
let mut patches = profile.patches_mut(repository)?;
patches.remove(patch_id, signer)?;
Ok(())

View File

@ -9,8 +9,9 @@ pub fn run(
patch_id: &PatchId,
revision_id: Option<patch::RevisionId>,
stored: &Repository,
profile: &Profile,
) -> anyhow::Result<()> {
let patches = patch::Patches::open(stored)?;
let patches = profile.patches(stored)?;
let Some(patch) = patches.get(patch_id)? else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -1,6 +1,6 @@
use super::*;
use radicle::cob::{patch, resolve_embed};
use radicle::cob::{self, patch, resolve_embed};
use radicle::crypto;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@ -15,7 +15,7 @@ pub fn run(
repository: &Repository,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = patch::Patches::open(repository)?;
let mut patches = profile.patches_mut(repository)?;
let Ok(patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};
@ -29,7 +29,7 @@ pub fn run(
}
fn edit_root<G>(
mut patch: patch::PatchMut<'_, '_, Repository>,
mut patch: patch::PatchMut<'_, '_, Repository, cob::cache::StoreWriter>,
title: String,
description: String,
repository: &Repository,
@ -76,7 +76,7 @@ where
}
fn edit_revision<G>(
mut patch: patch::PatchMut<'_, '_, Repository>,
mut patch: patch::PatchMut<'_, '_, Repository, cob::cache::StoreWriter>,
revision: patch::RevisionId,
mut title: String,
description: String,

View File

@ -12,7 +12,7 @@ pub fn run(
repository: &Repository,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = radicle::cob::patch::Patches::open(repository)?;
let mut patches = profile.patches_mut(repository)?;
let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -1,7 +1,8 @@
use std::collections::BTreeSet;
use radicle::cob::patch;
use radicle::cob::patch::{Patch, PatchId, Patches};
use radicle::cob::patch::{Patch, PatchId};
use radicle::patch::cache::Patches as _;
use radicle::prelude::*;
use radicle::profile::Profile;
use radicle::storage::git::Repository;
@ -15,22 +16,23 @@ use crate::terminal::patch as common;
/// List patches.
pub fn run(
filter: fn(&patch::State) -> bool,
filter: Option<&patch::Status>,
authors: BTreeSet<Did>,
repository: &Repository,
profile: &Profile,
) -> anyhow::Result<()> {
let patches = Patches::open(repository)?;
let patches = profile.patches(repository)?;
let mut all = Vec::new();
for patch in patches.all()? {
let iter = match filter {
Some(status) => patches.list_by_status(status)?,
None => patches.list()?,
};
for patch in iter {
let Ok((id, patch)) = patch else {
// Skip patches that failed to load.
continue;
};
if !filter(patch.state()) {
continue;
}
if !authors.is_empty() {
if !authors.contains(patch.author().id()) {
continue;

View File

@ -1,6 +1,5 @@
use super::*;
use radicle::cob::patch;
use radicle::prelude::*;
use radicle::storage::git::Repository;
@ -11,7 +10,7 @@ pub fn run(
repository: &Repository,
) -> anyhow::Result<()> {
let signer = term::signer(profile)?;
let mut patches = patch::Patches::open(repository)?;
let mut patches = profile.patches_mut(repository)?;
let Ok(mut patch) = patches.get_mut(patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -13,10 +13,14 @@ pub fn run(
repository: &Repository,
) -> anyhow::Result<()> {
let signer = &term::signer(profile)?;
let mut patches = patch::Patches::open(repository)?;
let mut patches = profile.patches_mut(repository)?;
let revision_id = revision_id.resolve::<Oid>(&repository.backend)?;
let (patch_id, _, revision_id, _) = patches
let patch::ByRevision {
id: patch_id,
revision_id,
..
} = patches
.find_by_revision(&patch::RevisionId::from(revision_id))?
.ok_or_else(|| anyhow!("Patch revision `{revision_id}` not found"))?;
let Ok(mut patch) = patches.get_mut(&patch_id) else {

View File

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

View File

@ -33,7 +33,7 @@ pub fn run(
// TODO: Should be optional.
workdir: &git::raw::Repository,
) -> anyhow::Result<()> {
let patches = patch::Patches::open(stored)?;
let patches = profile.patches(stored)?;
let Some(patch) = patches.get(patch_id)? else {
anyhow::bail!("Patch `{patch_id}` not found");
};

View File

@ -12,14 +12,14 @@ pub fn run(
base_id: Option<git::raw::Oid>,
message: term::patch::Message,
profile: &Profile,
storage: &Repository,
repository: &Repository,
workdir: &git::raw::Repository,
) -> anyhow::Result<()> {
// `HEAD`; This is what we are proposing as a patch.
let head_branch = try_branch(workdir.head()?)?;
let (_, target_oid) = get_merge_target(storage, &head_branch)?;
let mut patches = patch::Patches::open(storage)?;
let (_, target_oid) = get_merge_target(repository, &head_branch)?;
let mut patches = profile.patches_mut(repository)?;
let Ok(mut patch) = patches.get_mut(&patch_id) else {
anyhow::bail!("Patch `{patch_id}` not found");
};
@ -27,7 +27,7 @@ pub fn run(
let head_oid = branch_oid(&head_branch)?;
let base_oid = match base_id {
Some(oid) => oid,
None => storage.backend.merge_base(*target_oid, *head_oid)?,
None => repository.backend.merge_base(*target_oid, *head_oid)?,
};
// N.b. we don't update if both the head and base are the same as

View File

@ -3,11 +3,11 @@ use std::path::Path;
use localtime::LocalDuration;
use localtime::LocalTime;
use radicle::cob::issue;
use radicle::cob::patch;
use radicle::git;
use radicle::issue::cache::Issues as _;
use radicle::node::address;
use radicle::node::routing;
use radicle::patch::cache::Patches as _;
use radicle::storage::{ReadRepository, ReadStorage, WriteRepository};
use radicle_term::Element;
use serde::Serialize;
@ -90,8 +90,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 = issue::Issues::open(&repo)?.counts()?;
let patches = patch::Patches::open(&repo)?.counts()?;
let issues = profile.issues(&repo)?.counts()?;
let patches = profile.patches(&repo)?.counts()?;
stats.local.issues += issues.total();
stats.local.patches += patches.total();

View File

@ -1456,6 +1456,7 @@ fn test_cob_replication() {
let bob_repo = bob.storage.repository(rid).unwrap();
let mut bob_issues = radicle::cob::issue::Issues::open(&bob_repo).unwrap();
let mut bob_cache = radicle::cob::cache::InMemory::default();
let issue = bob_issues
.create(
"Something's fishy",
@ -1463,6 +1464,7 @@ fn test_cob_replication() {
&[],
&[],
[],
&mut bob_cache,
&bob.signer,
)
.unwrap();
@ -1505,7 +1507,7 @@ fn test_cob_deletion() {
bob.routes_to(&[(rid, alice.id)]);
let alice_repo = alice.storage.repository(rid).unwrap();
let mut alice_issues = radicle::cob::issue::Issues::open(&alice_repo).unwrap();
let mut alice_issues = radicle::cob::issue::Cache::no_cache(&alice_repo).unwrap();
let issue = alice_issues
.create(
"Something's fishy",
@ -1526,7 +1528,7 @@ fn test_cob_deletion() {
let bob_issues = radicle::cob::issue::Issues::open(&bob_repo).unwrap();
assert!(bob_issues.get(issue_id).unwrap().is_some());
let alice_issues = radicle::cob::issue::Issues::open(&alice_repo).unwrap();
let mut alice_issues = radicle::cob::issue::Cache::no_cache(&alice_repo).unwrap();
alice_issues.remove(issue_id, &alice.signer).unwrap();
log::debug!(target: "test", "Removing issue..");

View File

@ -9,6 +9,8 @@ use axum::http::Method;
use axum::response::{IntoResponse, Json};
use axum::routing::get;
use axum::Router;
use radicle::issue::cache::Issues as _;
use radicle::patch::cache::Patches as _;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::RwLock;
@ -60,8 +62,8 @@ impl Context {
let payload = doc.project()?;
let delegates = doc.delegates;
let issues = issue::Issues::open(&repo)?.counts()?;
let patches = patch::Patches::open(&repo)?.counts()?;
let issues = self.profile.issues(&repo)?.counts()?;
let patches = self.profile.patches(&repo)?.counts()?;
let db = &self.profile.database()?;
let seeding = db.count(&id).unwrap_or_default();

View File

@ -30,6 +30,14 @@ pub enum Error {
#[error(transparent)]
Storage(#[from] radicle::storage::Error),
/// Cob cache error.
#[error(transparent)]
CobCache(#[from] radicle::cob::cache::Error),
/// Cob issue cache error.
#[error(transparent)]
CacheIssue(#[from] radicle::cob::issue::cache::Error),
/// Cob issue error.
#[error(transparent)]
CobIssue(#[from] radicle::cob::issue::Error),
@ -38,6 +46,10 @@ pub enum Error {
#[error(transparent)]
CobPatch(#[from] radicle::cob::patch::Error),
/// Cob patch cache error.
#[error(transparent)]
CachePatch(#[from] radicle::cob::patch::cache::Error),
/// Cob store error.
#[error(transparent)]
CobStore(#[from] radicle::cob::store::Error),
@ -123,6 +135,7 @@ impl IntoResponse for Error {
Error::BadRequest(msg) => (StatusCode::BAD_REQUEST, Some(msg)),
other => {
tracing::error!("Error: {message}");
tracing::debug!("Error Debug: {:?}", other);
if cfg!(debug_assertions) {
(StatusCode::INTERNAL_SERVER_ERROR, Some(other.to_string()))

View File

@ -5,10 +5,10 @@ use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};
use radicle::cob::issue::Issues;
use radicle::cob::patch::Patches;
use radicle::identity::{Did, Visibility};
use radicle::issue::cache::Issues as _;
use radicle::node::routing::Store;
use radicle::patch::cache::Patches as _;
use radicle::storage::{ReadRepository, ReadStorage};
use crate::api::error::Error;
@ -44,7 +44,6 @@ async fn delegates_projects_handler(
let storage = &ctx.profile.storage;
let db = &ctx.profile.database()?;
let pinned = &ctx.profile.config.web.pinned;
let mut projects = match show {
ProjectQuery::All => storage
.repositories()?
@ -73,13 +72,13 @@ async fn delegates_projects_handler(
let Ok(payload) = id.doc.project() else {
return None;
};
let Ok(issues) = Issues::open(&repo) else {
let Ok(issues) = ctx.profile.issues(&repo) else {
return None;
};
let Ok(issues) = issues.counts() else {
return None;
};
let Ok(patches) = Patches::open(&repo) else {
let Ok(patches) = ctx.profile.patches(&repo) else {
return None;
};
let Ok(patches) = patches.counts() else {

View File

@ -14,7 +14,10 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tower_http::set_header::SetResponseHeaderLayer;
use radicle::cob::{issue, patch, resolve_embed, Embed, Label, Uri};
use radicle::cob::{
issue, issue::cache::Issues as _, patch, patch::cache::Patches as _, resolve_embed, Embed,
Label, Uri,
};
use radicle::identity::{Did, RepoId, Visibility};
use radicle::node::routing::Store;
use radicle::node::{AliasStore, Node, NodeId};
@ -126,13 +129,13 @@ async fn project_root_handler(
let Ok(payload) = info.doc.project() else {
return None;
};
let Ok(issues) = issue::Issues::open(&repo) else {
let Ok(issues) = ctx.profile.issues(&repo) else {
return None;
};
let Ok(issues) = issues.counts() else {
return None;
};
let Ok(patches) = patch::Patches::open(&repo) else {
let Ok(patches) = ctx.profile.patches(&repo) else {
return None;
};
let Ok(patches) = patches.counts() else {
@ -587,9 +590,9 @@ async fn issues_handler(
let state = state.unwrap_or_default();
let storage = &ctx.profile.storage;
let repo = storage.repository(project)?;
let issues = issue::Issues::open(&repo)?;
let issues = ctx.profile.issues(&repo)?;
let mut issues: Vec<_> = issues
.all()?
.list()?
.filter_map(|r| {
let (id, issue) = r.ok()?;
(state.matches(issue.state())).then_some((id, issue))
@ -639,7 +642,7 @@ async fn issue_create_handler(
.filter_map(|embed| resolve_embed(&repo, embed))
.collect();
let mut issues = issue::Issues::open(&repo)?;
let mut issues = ctx.profile.issues_mut(&repo)?;
let issue = issues
.create(
issue.title,
@ -672,7 +675,7 @@ async fn issue_update_handler(
let storage = &ctx.profile.storage;
let signer = ctx.profile.signer()?;
let repo = storage.repository(project)?;
let mut issues = issue::Issues::open(&repo)?;
let mut issues = ctx.profile.issues_mut(&repo)?;
let mut issue = issues.get_mut(&issue_id.into())?;
let id = match action {
@ -723,7 +726,9 @@ async fn issue_handler(
) -> impl IntoResponse {
let storage = &ctx.profile.storage;
let repo = storage.repository(project)?;
let issue = issue::Issues::open(&repo)?
let issue = ctx
.profile
.issues(&repo)?
.get(&issue_id.into())?
.ok_or(Error::NotFound)?;
let aliases = ctx.profile.aliases();
@ -756,7 +761,7 @@ async fn patch_create_handler(
.signer()
.map_err(|_| Error::Auth("Unauthorized"))?;
let repo = storage.repository(project)?;
let mut patches = patch::Patches::open(&repo)?;
let mut patches = ctx.profile.patches_mut(&repo)?;
let base_oid = repo.raw().merge_base(*patch.target, *patch.oid)?;
let patch = patches
@ -795,7 +800,7 @@ async fn patch_update_handler(
.signer()
.map_err(|_| Error::Auth("Unauthorized"))?;
let repo = storage.repository(project)?;
let mut patches = patch::Patches::open(&repo)?;
let mut patches = ctx.profile.patches_mut(&repo)?;
let mut patch = patches.get_mut(&patch_id.into())?;
let id = match action {
patch::Action::Edit { title, target } => patch.edit(title, target, &signer)?,
@ -940,9 +945,9 @@ async fn patches_handler(
let state = state.unwrap_or_default();
let storage = &ctx.profile.storage;
let repo = storage.repository(project)?;
let patches = patch::Patches::open(&repo)?;
let patches = ctx.profile.patches(&repo)?;
let mut patches = patches
.all()?
.list()?
.filter_map(|r| {
let (id, patch) = r.ok()?;
(state.matches(patch.state())).then_some((id, patch))
@ -968,9 +973,8 @@ async fn patch_handler(
) -> impl IntoResponse {
let storage = &ctx.profile.storage;
let repo = storage.repository(project)?;
let patch = patch::Patches::open(&repo)?
.get(&patch_id.into())?
.ok_or(Error::NotFound)?;
let patches = ctx.profile.patches(&repo)?;
let patch = patches.get(&patch_id.into())?.ok_or(Error::NotFound)?;
let aliases = ctx.profile.aliases();
Ok::<_, Error>(Json(api::json::patch(

View File

@ -11,18 +11,16 @@ use serde_json::Value;
use time::OffsetDateTime;
use tower::ServiceExt;
use radicle::cob::issue::Issues;
use radicle::cob::patch::{MergeTarget, Patches};
use radicle::cob::patch::MergeTarget;
use radicle::crypto::ssh::keystore::MemorySigner;
use radicle::crypto::ssh::Keystore;
use radicle::crypto::{KeyPair, Seed, Signer};
use radicle::git::{raw as git2, RefString};
use radicle::identity::Visibility;
use radicle::node;
use radicle::profile;
use radicle::profile::Home;
use radicle::storage::ReadStorage;
use radicle::Storage;
use radicle::{node, profile};
use radicle_crypto::test::signer::MockSigner;
use crate::api::{auth, Context};
@ -231,7 +229,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
let storage = &profile.storage;
let repo = storage.repository(rid).unwrap();
let mut issues = Issues::open(&repo).unwrap();
let mut issues = profile.issues_mut(&repo).unwrap();
let issue = issues
.create(
"Issue #1".to_string(),
@ -245,7 +243,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
tracing::debug!(target: "test", "Contributor issue: {}", issue.id());
// eq. rad patch open
let mut patches = Patches::open(&repo).unwrap();
let mut patches = profile.patches_mut(&repo).unwrap();
let oid = radicle::git::Oid::from_str(HEAD).unwrap();
let base = radicle::git::Oid::from_str(PARENT).unwrap();
let patch = patches

View File

@ -14,7 +14,6 @@ use reactor::poller::popol;
use reactor::Reactor;
use thiserror::Error;
use radicle::git;
use radicle::node;
use radicle::node::address;
use radicle::node::address::Store as _;
@ -22,6 +21,7 @@ use radicle::node::notifications;
use radicle::node::Handle as _;
use radicle::profile::Home;
use radicle::Storage;
use radicle::{cob, git};
use crate::control;
use crate::crypto::Signer;
@ -42,6 +42,9 @@ pub enum Error {
/// A routing database error.
#[error("routing database error: {0}")]
Routing(#[from] routing::Error),
/// A cobs cache database error.
#[error("cobs cache database error: {0}")]
CobsCache(#[from] cob::cache::Error),
/// A node database error.
#[error("node database error: {0}")]
Database(#[from] node::db::Error),
@ -156,6 +159,7 @@ impl Runtime {
let policies = home.policies_mut()?;
let policies = policy::Config::new(policy, scope, policies);
let notifications = home.notifications_mut()?;
let cobs_cache = cob::cache::Store::open(home.cobs().join(cob::cache::COBS_DB_FILE))?;
log::info!(target: "node", "Default seeding policy set to '{}'", &policy);
log::info!(target: "node", "Initializing service ({:?})..", network);
@ -257,6 +261,7 @@ impl Runtime {
nid,
handle.clone(),
notifications,
cobs_cache,
worker::Config {
capacity: 8,
timeout: time::Duration::from_secs(9),

View File

@ -9,6 +9,7 @@ use std::{
use crossbeam_channel as chan;
use radicle::cob::cache::COBS_DB_FILE;
use radicle::cob::issue;
use radicle::crypto::ssh::{keystore::MemorySigner, Keystore};
use radicle::crypto::test::signer::MockSigner;
@ -110,6 +111,7 @@ 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);
config.write(&home.config()).unwrap();
@ -124,6 +126,7 @@ impl Environment {
policy::Store::open(policies_db).unwrap();
home.database_mut().unwrap(); // Just create the database.
cob::cache::Store::open(cobs_db).unwrap();
transport::local::register(storage.clone());
keystore.store(keypair.clone(), "radicle", None).unwrap();
@ -380,7 +383,7 @@ impl<G: Signer + cyphernet::Ecdh> NodeHandle<G> {
/// Create an [`issue::Issue`] in the `NodeHandle`'s storage.
pub fn issue(&self, rid: RepoId, title: &str, desc: &str) -> cob::ObjectId {
let repo = self.storage.repository(rid).unwrap();
let mut issues = issue::Issues::open(&repo).unwrap();
let mut issues = issue::Cache::no_cache(&repo).unwrap();
*issues
.create(title, desc, &[], &[], [], &self.signer)
.unwrap()

View File

@ -880,7 +880,7 @@ fn test_refs_announcement_offline() {
// Create an issue without telling the node.
let repo = alice.storage().repository(*rid).unwrap();
let old_refs = RefsAt::new(&repo, alice.id).unwrap();
let mut issues = radicle::issue::Issues::open(&repo).unwrap();
let mut issues = radicle::issue::Cache::no_cache(&repo).unwrap();
issues
.create("Issue while offline!", "", &[], &[], [], alice.signer())
.unwrap();

View File

@ -15,7 +15,7 @@ use radicle::node::notifications;
use radicle::prelude::NodeId;
use radicle::storage::refs::RefsAt;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::{crypto, Storage};
use radicle::{cob, crypto, Storage};
use radicle_fetch::FetchLimit;
use crate::runtime::{thread, Handle};
@ -179,6 +179,7 @@ struct Worker {
handle: Handle,
policies: policy::Config<policy::store::Read>,
notifications: notifications::StoreWriter,
cache: cob::cache::StoreWriter,
}
impl Worker {
@ -199,7 +200,13 @@ impl Worker {
} = task;
let remote = fetch.remote();
let channels = channels::ChannelsFlush::new(self.handle.clone(), channels, remote, stream);
let result = self._process(fetch, stream, channels, self.notifications.clone());
let result = self._process(
fetch,
stream,
channels,
self.notifications.clone(),
self.cache.clone(),
);
log::trace!(target: "worker", "Sending response back to service..");
@ -222,6 +229,7 @@ impl Worker {
stream: StreamId,
mut channels: channels::ChannelsFlush,
notifs: notifications::StoreWriter,
cache: cob::cache::StoreWriter,
) -> FetchResult {
match fetch {
FetchRequest::Initiator {
@ -232,7 +240,7 @@ impl Worker {
timeout: _timeout,
} => {
log::debug!(target: "worker", "Worker processing outgoing fetch for {rid}");
let result = self.fetch(rid, remote, refs_at, channels, notifs);
let result = self.fetch(rid, remote, refs_at, channels, notifs, cache);
FetchResult::Initiator { rid, result }
}
FetchRequest::Responder { remote } => {
@ -282,6 +290,7 @@ impl Worker {
refs_at: Option<Vec<RefsAt>>,
channels: channels::ChannelsFlush,
notifs: notifications::StoreWriter,
mut cache: cob::cache::StoreWriter,
) -> Result<fetch::FetchResult, FetchError> {
let FetchConfig {
limit,
@ -302,7 +311,7 @@ impl Worker {
channels,
notifs,
)?;
let result = handle.fetch(rid, &self.storage, *limit, remote, refs_at)?;
let result = handle.fetch(rid, &self.storage, &mut cache, *limit, remote, refs_at)?;
if let Err(e) = garbage::collect(&self.storage, rid, *expiry) {
// N.b. ensure that `git gc` works in debug mode.
@ -326,6 +335,7 @@ impl Pool {
nid: NodeId,
handle: Handle,
notifications: notifications::StoreWriter,
cache: cob::cache::StoreWriter,
config: Config,
) -> Result<Self, policy::Error> {
let mut pool = Vec::with_capacity(config.capacity);
@ -343,6 +353,7 @@ impl Pool {
fetch_config: config.fetch.clone(),
policies,
notifications: notifications.clone(),
cache: cache.clone(),
};
let thread = thread::spawn(&nid, format!("worker#{i}"), || worker.run());

View File

@ -8,8 +8,10 @@ use localtime::LocalTime;
use radicle::crypto::PublicKey;
use radicle::prelude::RepoId;
use radicle::storage::refs::RefsAt;
use radicle::storage::{ReadStorage as _, RefUpdate, RemoteRepository, WriteRepository as _};
use radicle::{git, node, Storage};
use radicle::storage::{
ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, WriteRepository as _,
};
use radicle::{cob, git, node, Storage};
use radicle_fetch::{Allowed, BlockList, FetchLimit};
use super::channels::ChannelsFlush;
@ -62,6 +64,7 @@ impl Handle {
self,
rid: RepoId,
storage: &Storage,
cache: &mut cob::cache::StoreWriter,
limit: FetchLimit,
remote: PublicKey,
refs_at: Option<Vec<RefsAt>>,
@ -117,6 +120,8 @@ impl Handle {
}
}
cache_cobs(&rid, &applied.updated, &repo, cache)?;
Ok(FetchResult {
updated: applied.updated,
namespaces: remotes.into_iter().collect(),
@ -194,3 +199,89 @@ fn notify(
}
Ok(())
}
/// Write new `RefUpdate`s that are related a `Patch` or an `Issue`
/// COB to the COB cache.
fn cache_cobs<S, C>(
rid: &RepoId,
refs: &[RefUpdate],
storage: &S,
cache: &mut C,
) -> Result<(), error::Cache>
where
S: ReadRepository + cob::Store,
C: cob::cache::Update<cob::issue::Issue> + cob::cache::Update<cob::patch::Patch>,
C: cob::cache::Remove<cob::issue::Issue> + cob::cache::Remove<cob::patch::Patch>,
{
let issues = cob::issue::Issues::open(storage)?;
let patches = cob::patch::Patches::open(storage)?;
for update in refs {
match update {
RefUpdate::Updated { name, .. } | RefUpdate::Created { name, .. } => {
match name.to_namespaced() {
Some(name) => {
let Some(identifier) = cob::TypedId::from_namespaced(&name)? else {
continue;
};
if identifier.is_issue() {
if let Some(issue) = issues.get(&identifier.id)? {
cache
.update(rid, &identifier.id, &issue)
.map(|_| ())
.map_err(|e| error::Cache::Update {
id: identifier.id,
type_name: identifier.type_name,
err: e.into(),
})?;
}
} else if identifier.is_patch() {
if let Some(patch) = patches.get(&identifier.id)? {
cache
.update(rid, &identifier.id, &patch)
.map(|_| ())
.map_err(|e| error::Cache::Update {
id: identifier.id,
type_name: identifier.type_name,
err: e.into(),
})?;
}
}
}
None => continue,
}
}
RefUpdate::Deleted { name, .. } => match name.to_namespaced() {
Some(name) => {
let Some(identifier) = cob::TypedId::from_namespaced(&name)? else {
continue;
};
if identifier.is_issue() {
cob::cache::Remove::<cob::issue::Issue>::remove(cache, &identifier.id)
.map(|_| ())
.map_err(|e| error::Cache::Remove {
id: identifier.id,
type_name: identifier.type_name,
err: e.into(),
})?;
} else if identifier.is_patch() {
cob::cache::Remove::<cob::patch::Patch>::remove(cache, &identifier.id)
.map(|_| ())
.map_err(
|e: <C as cob::cache::Remove<cob::patch::Patch>>::RemoveError| {
error::Cache::Remove {
id: identifier.id,
type_name: identifier.type_name,
err: e.into(),
}
},
)?;
}
}
None => continue,
},
RefUpdate::Skipped { .. } => { /* Do nothing */ }
}
}
Ok(())
}

View File

@ -2,7 +2,7 @@ use std::io;
use thiserror::Error;
use radicle::{git, identity, storage};
use radicle::{cob, git, identity, storage};
use radicle_fetch as fetch;
#[derive(Debug, Error)]
@ -19,6 +19,32 @@ pub enum Fetch {
Repository(#[from] radicle::storage::RepositoryError),
#[error("validation of storage repository failed")]
Validation,
#[error(transparent)]
Cache(#[from] Cache),
}
#[derive(Debug, Error)]
pub enum Cache {
#[error(transparent)]
Parse(#[from] cob::ParseIdentifierError),
#[error(transparent)]
Repository(#[from] storage::RepositoryError),
#[error("failed to remove {type_name} '{id}' from cache: {err}")]
Remove {
id: cob::ObjectId,
type_name: cob::TypeName,
#[source]
err: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error(transparent)]
Store(#[from] cob::store::Error),
#[error("failed to update {type_name} '{id}' in cache: {err}")]
Update {
id: cob::ObjectId,
type_name: cob::TypeName,
#[source]
err: Box<dyn std::error::Error + Send + Sync + 'static>,
},
}
#[derive(Debug, Error)]

View File

@ -185,7 +185,7 @@ pub fn run(profile: radicle::Profile) -> Result<(), Error> {
.map_err(Error::from);
}
["list"] => {
list::for_fetch(&url, &stored)?;
list::for_fetch(&url, &profile, &stored)?;
}
["list", "for-push"] => {
list::for_push(&profile, &stored)?;

View File

@ -1,3 +1,5 @@
use radicle::patch::cache::Patches as _;
use radicle::profile;
use thiserror::Error;
use radicle::cob;
@ -17,9 +19,15 @@ pub enum Error {
/// Git error.
#[error(transparent)]
Git(#[from] radicle::git::ext::Error),
/// Profile error.
#[error(transparent)]
Profile(#[from] profile::Error),
/// COB store error.
#[error(transparent)]
CobStore(#[from] cob::store::Error),
/// Patch COB cache error.
#[error(transparent)]
PatchCache(#[from] radicle::patch::cache::Error),
/// General repository error.
#[error(transparent)]
Repository(#[from] radicle::storage::RepositoryError),
@ -28,6 +36,7 @@ pub enum Error {
/// List refs for fetching (`git fetch` and `git ls-remote`).
pub fn for_fetch<R: ReadRepository + cob::Store + 'static>(
url: &Url,
profile: &Profile,
stored: &R,
) -> Result<(), Error> {
if let Some(namespace) = url.namespace {
@ -48,7 +57,7 @@ pub fn for_fetch<R: ReadRepository + cob::Store + 'static>(
}
// List the patch refs, but don't abort if there's an error, as this would break
// all fetch behavior. Instead, just output an error to the user.
if let Err(e) = patch_refs(stored) {
if let Err(e) = patch_refs(profile, stored) {
eprintln!("remote: error listing patch refs: {e}");
}
}
@ -74,9 +83,12 @@ pub fn for_push<R: ReadRepository>(profile: &Profile, stored: &R) -> Result<(),
}
/// List canonical patch references. These are magic refs that can be used to pull patch updates.
fn patch_refs<R: ReadRepository + cob::Store + 'static>(stored: &R) -> Result<(), Error> {
let patches = radicle::cob::patch::Patches::open(stored)?;
for patch in patches.all()? {
fn patch_refs<R: ReadRepository + cob::Store + 'static>(
profile: &Profile,
stored: &R,
) -> Result<(), Error> {
let patches = profile.patches(stored)?;
for patch in patches.list()? {
let Ok((id, patch)) = patch else {
// Ignore patches that fail to decode.
continue;

View File

@ -1,3 +1,4 @@
#![allow(clippy::too_many_arguments)]
use std::collections::{HashMap, HashSet};
use std::io::IsTerminal;
use std::ops::ControlFlow;
@ -8,8 +9,10 @@ use std::{assert_eq, io};
use thiserror::Error;
use radicle::cob;
use radicle::cob::object::ParseObjectId;
use radicle::cob::patch;
use radicle::cob::patch::cache::Patches as _;
use radicle::crypto::Signer;
use radicle::explorer::ExplorerResource;
use radicle::identity::Did;
@ -81,6 +84,9 @@ pub enum Error {
/// Patch COB error.
#[error(transparent)]
Patch(#[from] radicle::cob::patch::Error),
/// Error from COB patch cache.
#[error(transparent)]
PatchCache(#[from] patch::cache::Error),
/// Patch edit message error.
#[error(transparent)]
PatchEdit(#[from] cli::patch::Error),
@ -209,7 +215,16 @@ pub fn run(
let working = git::raw::Repository::open(working)?;
if dst == &*rad::PATCHES_REFNAME {
patch_open(src, &nid, &working, stored, &signer, profile, opts.clone())
patch_open(
src,
&nid,
&working,
stored,
profile.patches_mut(stored)?,
&signer,
profile,
opts.clone(),
)
} else {
let dst = git::Qualified::from_refstr(dst)
.ok_or_else(|| Error::InvalidQualifiedRef(dst.clone()))?;
@ -225,6 +240,7 @@ pub fn run(
&nid,
&working,
stored,
profile.patches_mut(stored)?,
&signer,
opts.clone(),
)
@ -266,7 +282,16 @@ pub fn run(
return Err(Error::HeadsDiverge(head.into(), *canonical_oid));
}
}
push(src, &dst, *force, &nid, &working, stored, &signer)
push(
src,
&dst,
*force,
&nid,
&working,
stored,
profile.patches_mut(stored)?,
&signer,
)
}
}
}
@ -323,6 +348,10 @@ fn patch_open<G: Signer>(
nid: &NodeId,
working: &git::raw::Repository,
stored: &storage::git::Repository,
mut patches: patch::Cache<
patch::Patches<'_, storage::git::Repository>,
cob::cache::StoreWriter,
>,
signer: &G,
profile: &Profile,
opts: Options,
@ -353,7 +382,6 @@ fn patch_open<G: Signer>(
let (title, description) =
cli::patch::get_create_message(opts.message, &stored.backend, &base, &head)?;
let mut patches = patch::Patches::open(stored)?;
let patch = if opts.draft {
patches.draft(
&title,
@ -442,6 +470,10 @@ fn patch_update<G: Signer>(
nid: &NodeId,
working: &git::raw::Repository,
stored: &storage::git::Repository,
mut patches: patch::Cache<
patch::Patches<'_, storage::git::Repository>,
cob::cache::StoreWriter,
>,
signer: &G,
opts: Options,
) -> Result<Option<ExplorerResource>, Error> {
@ -452,7 +484,6 @@ fn patch_update<G: Signer>(
push_ref(src, &dst, force, working, stored.raw())?;
let mut patches = patch::Patches::open(stored)?;
let Ok(mut patch) = patches.get_mut(&patch_id) else {
return Err(Error::NotFound(patch_id));
};
@ -504,6 +535,7 @@ fn push<G: Signer>(
nid: &NodeId,
working: &git::raw::Repository,
stored: &storage::git::Repository,
patches: patch::Cache<patch::Patches<'_, storage::git::Repository>, cob::cache::StoreWriter>,
signer: &G,
) -> Result<Option<ExplorerResource>, Error> {
let head = working.find_reference(src.as_str())?;
@ -524,7 +556,7 @@ fn push<G: Signer>(
let old = old.peel_to_commit()?.id();
// Only delegates should publish the merge result to the COB.
if stored.delegates()?.contains(&nid.into()) {
patch_merge_all(old.into(), head.into(), working, stored, signer)?;
patch_merge_all(old.into(), head.into(), working, patches, signer)?;
}
}
}
@ -536,7 +568,10 @@ fn patch_merge_all<G: Signer>(
old: git::Oid,
new: git::Oid,
working: &git::raw::Repository,
stored: &storage::git::Repository,
mut patches: patch::Cache<
patch::Patches<'_, storage::git::Repository>,
cob::cache::StoreWriter,
>,
signer: &G,
) -> Result<(), Error> {
let mut revwalk = working.revwalk()?;
@ -547,15 +582,13 @@ fn patch_merge_all<G: Signer>(
.map(|r| r.map(git::Oid::from))
.collect::<Result<Vec<git::Oid>, _>>()?;
let mut patches = patch::Patches::open(stored)?;
for patch in patches.all()? {
let Ok((id, patch)) = patch else {
// Skip patches that failed to load.
continue;
};
if !patch.is_open() && !patch.is_draft() {
continue;
}
let all = patches
.opened()?
.chain(patches.drafted()?)
// Skip patches that failed to load.
.filter_map(|patch| patch.ok())
.collect::<Vec<_>>();
for (id, patch) in all {
// Later revisions are more likely to be merged, so we build the list backwards.
let revisions = patch
.revisions()
@ -578,8 +611,8 @@ fn patch_merge_all<G: Signer>(
Ok(())
}
fn patch_merge<G: Signer>(
mut patch: patch::PatchMut<storage::git::Repository>,
fn patch_merge<C: cob::cache::Update<patch::Patch>, G: Signer>(
mut patch: patch::PatchMut<storage::git::Repository, C>,
revision: patch::RevisionId,
commit: git::Oid,
working: &git::raw::Repository,

View File

@ -2,7 +2,7 @@ use std::collections::HashSet;
use std::env;
use anyhow::anyhow;
use radicle::cob::patch::{PatchId, Patches};
use radicle::cob::patch::PatchId;
use radicle::git::Oid;
use radicle::storage::ReadStorage;
use radicle_cli::terminal as term;
@ -15,7 +15,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 = Patches::open(&stored)?;
let mut patches = profile.patches_mut(&stored)?;
let mut patch = patches.get_mut(&pid)?;
if patch.is_merged() {

View File

@ -20,12 +20,16 @@ pub use radicle_cob::{
};
pub use radicle_cob::{create, get, git, list, remove, update};
/// The exact identifier for a particular COB.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TypedId {
/// The identifier of the COB in the store.
pub id: ObjectId,
/// The type identifier of the COB in the store.
pub type_name: TypeName,
}
/// Errors that occur when parsing a Git refname into a [`TypedId`].
#[derive(Debug, thiserror::Error)]
pub enum ParseIdentifierError {
#[error(transparent)]
@ -35,20 +39,39 @@ pub enum ParseIdentifierError {
}
impl TypedId {
/// Returns `true` is the [`TypedId::type_name`] is for an
/// [`issue::Issue`].
pub fn is_issue(&self) -> bool {
self.type_name == *issue::TYPENAME
}
/// Returns `true` is the [`TypedId::type_name`] is for an
/// [`patch::Patch`].
pub fn is_patch(&self) -> bool {
self.type_name == *patch::TYPENAME
}
/// Parse a [`crate::git::Namespaced`] refname into a [`TypedId`].
///
/// All namespaces are stripped before parsing the suffix for the
/// [`TypedId`] (see [`TypedId::from_qualified`]).
pub fn from_namespaced(
n: &crate::git::Namespaced,
) -> Result<Option<Self>, ParseIdentifierError> {
Self::from_qualified(&n.strip_namespace())
Self::from_qualified(&n.strip_namespace_recursive())
}
/// Parse a [`crate::git::Qualified`] refname into a [`TypedId`].
///
/// The refname is expected to be of the form:
/// `refs/cobs/<type name>/<object id>`
///
/// If the refname is not of that form then `None` will be returned.
///
/// # Errors
///
/// This will fail if the refname is of the correct form, but the
/// type name or object id fail to parse.
pub fn from_qualified(q: &crate::git::Qualified) -> Result<Option<Self>, ParseIdentifierError> {
match q.non_empty_iter() {
("refs", "cobs", type_name, mut id) => {

View File

@ -35,6 +35,11 @@ pub enum Error {
NoRows,
}
/// Read and write to the store.
pub type StoreWriter = Store<Write>;
/// Write to the store.
pub type StoreReader = Store<Read>;
/// Read-only type witness.
#[derive(Clone)]
pub struct Read;
@ -194,7 +199,10 @@ pub trait Remove<T> {
type Out;
type RemoveError: std::error::Error + Send + Sync + 'static;
fn remove(&mut self, rid: &RepoId, id: &ObjectId) -> Result<Self::Out, Self::RemoveError>;
/// Delete an object in the COB cache.
///
/// This assumes that the `id` is unique across repositories.
fn remove(&mut self, id: &ObjectId) -> Result<Self::Out, Self::RemoveError>;
}
/// An in-memory cache for storing COB objects.
@ -259,7 +267,48 @@ impl<T> Remove<T> for NoCache {
type Out = ();
type RemoveError = Infallible;
fn remove(&mut self, _rid: &RepoId, _id: &ObjectId) -> Result<Self::Out, Self::RemoveError> {
fn remove(&mut self, _id: &ObjectId) -> Result<Self::Out, Self::RemoveError> {
Ok(())
}
}
/// Track the progress of cache writes when transferring the
/// repository COBs to their respective caches.
///
/// See [`crate::cob::issue::Cache::write_all`] and
/// [`crate::cob::patch::Cache::write_all`].
pub struct WriteAllProgress {
total: usize,
seen: usize,
}
impl WriteAllProgress {
/// Create a new progress tracker with the given `total` amount.
pub fn new(total: usize) -> Self {
Self { total, seen: 0 }
}
/// Increment the [`WriteAllProgress::seen`] progress.
pub fn inc(&mut self) {
self.seen += 1;
}
/// Return the `total` amount.
pub fn total(&self) -> usize {
self.total
}
/// Return the `seen` amount.
pub fn seen(&self) -> usize {
self.seen
}
/// Return the percentage of the progress made.
///
/// # Panics
///
/// If the `total` provided is `0`.
pub fn percentage(&self) -> f32 {
(self.seen as f32 / self.total as f32) * 100.0
}
}

View File

@ -1,3 +1,5 @@
pub mod cache;
use std::collections::BTreeSet;
use std::ops::Deref;
use std::str::FromStr;
@ -15,8 +17,10 @@ use crate::cob::thread::{Comment, CommentId, Thread};
use crate::cob::{op, store, ActorId, Embed, EntryId, ObjectId, TypeName};
use crate::crypto::Signer;
use crate::identity::doc::{Doc, DocError};
use crate::prelude::{Did, ReadRepository, Verified};
use crate::storage::{RepositoryError, WriteRepository};
use crate::prelude::{Did, ReadRepository, RepoId, Verified};
use crate::storage::{HasRepoId, RepositoryError, WriteRepository};
pub use cache::Cache;
/// Issue operation.
pub type Op = cob::Op<Action>;
@ -56,6 +60,18 @@ pub enum Error {
/// Error decoding an operation.
#[error("op decoding failed: {0}")]
Op(#[from] op::OpEncodingError),
#[error("failed to update issue {id} in cache: {err}")]
CacheUpdate {
id: IssueId,
#[source]
err: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("failed to remove issue {id} from cache : {err}")]
CacheRemove {
id: IssueId,
#[source]
err: Box<dyn std::error::Error + Send + Sync + 'static>,
},
}
/// Reason why an issue was closed.
@ -410,8 +426,8 @@ impl Issue {
}
}
impl<'a, 'g, R> From<IssueMut<'a, 'g, R>> for (IssueId, Issue) {
fn from(value: IssueMut<'a, 'g, R>) -> Self {
impl<'a, 'g, R, C> From<IssueMut<'a, 'g, R, C>> for (IssueId, Issue) {
fn from(value: IssueMut<'a, 'g, R, C>) -> Self {
(value.id, value.issue)
}
}
@ -524,13 +540,14 @@ impl<R: ReadRepository> store::Transaction<Issue, R> {
}
}
pub struct IssueMut<'a, 'g, R> {
pub struct IssueMut<'a, 'g, R, C> {
id: ObjectId,
issue: Issue,
store: &'g mut Issues<'a, R>,
cache: &'g mut C,
}
impl<'a, 'g, R> std::fmt::Debug for IssueMut<'a, 'g, R> {
impl<'a, 'g, R, C> std::fmt::Debug for IssueMut<'a, 'g, R, C> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.debug_struct("IssueMut")
.field("id", &self.id)
@ -539,9 +556,10 @@ impl<'a, 'g, R> std::fmt::Debug for IssueMut<'a, 'g, R> {
}
}
impl<'a, 'g, R> IssueMut<'a, 'g, R>
impl<'a, 'g, R, C> IssueMut<'a, 'g, R, C>
where
R: WriteRepository + cob::Store,
C: cob::cache::Update<Issue>,
{
/// Reload the issue data from storage.
pub fn reload(&mut self) -> Result<(), store::Error> {
@ -660,13 +678,19 @@ where
operations(&mut tx)?;
let (issue, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
self.cache
.update(&self.store.as_ref().id(), &self.id, &issue)
.map_err(|e| Error::CacheUpdate {
id: self.id,
err: e.into(),
})?;
self.issue = issue;
Ok(commit)
}
}
impl<'a, 'g, R> Deref for IssueMut<'a, 'g, R> {
impl<'a, 'g, R, C> Deref for IssueMut<'a, 'g, R, C> {
type Target = Issue;
fn deref(&self) -> &Self::Target {
@ -686,6 +710,15 @@ impl<'a, R> Deref for Issues<'a, R> {
}
}
impl<'a, R> HasRepoId for Issues<'a, R>
where
R: ReadRepository,
{
fn rid(&self) -> RepoId {
self.raw.as_ref().id()
}
}
/// Detailed information on issue states
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "camelCase")]
@ -718,35 +751,21 @@ impl<'a, R> Issues<'a, R>
where
R: WriteRepository + cob::Store,
{
/// Get an issue.
pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, store::Error> {
self.raw.get(id)
}
/// Get an issue mutably.
pub fn get_mut<'g>(&'g mut self, id: &ObjectId) -> Result<IssueMut<'a, 'g, R>, store::Error> {
let issue = self
.raw
.get(id)?
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
Ok(IssueMut {
id: *id,
issue,
store: self,
})
}
/// Create a new issue.
pub fn create<'g, G: Signer>(
pub fn create<'g, G, C>(
&'g mut self,
title: impl ToString,
description: impl ToString,
labels: &[Label],
assignees: &[Did],
embeds: impl IntoIterator<Item = Embed>,
cache: &'g mut C,
signer: &G,
) -> Result<IssueMut<'a, 'g, R>, Error> {
) -> Result<IssueMut<'a, 'g, R, C>, Error>
where
G: Signer,
C: cob::cache::Update<Issue>,
{
let (id, issue) = Transaction::initial("Create issue", &mut self.raw, signer, |tx| {
tx.thread(description, embeds)?;
tx.edit(title)?;
@ -759,11 +778,52 @@ where
}
Ok(())
})?;
cache
.update(&self.raw.as_ref().id(), &id, &issue)
.map_err(|e| Error::CacheUpdate { id, err: e.into() })?;
Ok(IssueMut {
id,
issue,
store: self,
cache,
})
}
/// Remove an issue.
pub fn remove<C, G: Signer>(&self, id: &ObjectId, signer: &G) -> Result<(), store::Error>
where
C: cob::cache::Remove<Issue>,
{
self.raw.remove(id, signer)
}
}
impl<'a, R> Issues<'a, R>
where
R: ReadRepository + cob::Store,
{
/// Get an issue.
pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, store::Error> {
self.raw.get(id)
}
/// Get an issue mutably.
pub fn get_mut<'g, C>(
&'g mut self,
id: &ObjectId,
cache: &'g mut C,
) -> Result<IssueMut<'a, 'g, R, C>, store::Error> {
let issue = self
.raw
.get(id)?
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
Ok(IssueMut {
id: *id,
issue,
store: self,
cache,
})
}
@ -782,11 +842,6 @@ where
Ok(state_groups)
}
/// Remove an issue.
pub fn remove<G: Signer>(&self, id: &ObjectId, signer: &G) -> Result<(), store::Error> {
self.raw.remove(id, signer)
}
}
/// Issue action.
@ -859,16 +914,16 @@ mod test {
use super::*;
use crate::cob::{ActorId, Reaction};
use crate::git::Oid;
use crate::issue::cache::Issues as _;
use crate::test;
use crate::test::arbitrary;
#[test]
fn test_concurrency() {
let t = test::setup::Network::default();
let mut issues_alice = Issues::open(&*t.alice.repo).unwrap();
let mut bob_issues = Issues::open(&*t.bob.repo).unwrap();
let mut eve_issues = Issues::open(&*t.eve.repo).unwrap();
let mut issues_alice = Cache::no_cache(&*t.alice.repo).unwrap();
let mut bob_issues = Cache::no_cache(&*t.bob.repo).unwrap();
let mut eve_issues = Cache::no_cache(&*t.eve.repo).unwrap();
let mut issue_alice = issues_alice
.create(
"Alice Issue",
@ -954,7 +1009,7 @@ mod test {
#[test]
fn test_issue_create_and_assign() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let assignee = Did::from(arbitrary::gen::<ActorId>(1));
let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
@ -993,7 +1048,7 @@ mod test {
#[test]
fn test_issue_create_and_reassign() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let assignee = Did::from(arbitrary::gen::<ActorId>(1));
let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
@ -1002,7 +1057,7 @@ mod test {
"My first issue",
"Blah blah blah.",
&[],
&[assignee],
&[assignee, assignee_two],
[],
&node.signer,
)
@ -1026,7 +1081,7 @@ mod test {
#[test]
fn test_issue_create_and_get() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let created = issues
.create(
"My first issue",
@ -1052,7 +1107,7 @@ mod test {
#[test]
fn test_issue_create_and_change_state() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let mut issue = issues
.create(
"My first issue",
@ -1092,7 +1147,7 @@ mod test {
#[test]
fn test_issue_create_and_unassign() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let assignee = Did::from(arbitrary::gen::<ActorId>(1));
let assignee_two = Did::from(arbitrary::gen::<ActorId>(1));
@ -1120,7 +1175,8 @@ mod test {
#[test]
fn test_issue_edit() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let mut issue = issues
.create(
"My first issue",
@ -1144,7 +1200,7 @@ mod test {
#[test]
fn test_issue_edit_description() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let mut issue = issues
.create(
"My first issue",
@ -1170,7 +1226,7 @@ mod test {
#[test]
fn test_issue_react() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let mut issue = issues
.create(
"My first issue",
@ -1200,7 +1256,7 @@ mod test {
#[test]
fn test_issue_reply() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let mut issue = issues
.create(
"My first issue",
@ -1255,7 +1311,7 @@ mod test {
#[test]
fn test_issue_label() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let bug_label = Label::new("bug").unwrap();
let ux_label = Label::new("ux").unwrap();
let wontfix_label = Label::new("wontfix").unwrap();
@ -1293,7 +1349,7 @@ mod test {
fn test_issue_comment() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let author = *node.signer.public_key();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let mut issue = issues
.create(
"My first issue",
@ -1333,7 +1389,7 @@ mod test {
#[test]
fn test_issue_comment_redact() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let mut issue = issues
.create(
"My first issue",
@ -1381,8 +1437,7 @@ mod test {
#[test]
fn test_issue_all() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
issues
.create("First", "Blah", &[], &[], [], &node.signer)
.unwrap();
@ -1394,7 +1449,7 @@ mod test {
.unwrap();
let issues = issues
.all()
.list()
.unwrap()
.map(|r| r.map(|(_, i)| i))
.collect::<Result<Vec<_>, _>>()
@ -1410,7 +1465,7 @@ mod test {
#[test]
fn test_issue_multilines() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let created = issues
.create(
"My first issue",
@ -1436,7 +1491,7 @@ mod test {
#[test]
fn test_embeds() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let embed1 = Embed {
name: String::from("example.html"),
content: b"<html>Hello World!</html>".to_vec(),
@ -1494,7 +1549,7 @@ mod test {
#[test]
fn test_embeds_edit() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let embed1 = Embed {
name: String::from("example.html"),
content: b"<html>Hello World!</html>".to_vec(),
@ -1538,7 +1593,7 @@ mod test {
#[test]
fn test_invalid_actions() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let mut issue = issues
.create(
"My first issue",
@ -1572,7 +1627,7 @@ mod test {
#[test]
fn test_invalid_tx() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let mut issue = issues
.create(
"My first issue",
@ -1608,7 +1663,7 @@ mod test {
let identity = repo.identity().unwrap().head();
let missing = arbitrary::oid();
let type_name = Issue::type_name().clone();
let mut issues = Issues::open(&*repo).unwrap();
let mut issues = Cache::no_cache(&*repo).unwrap();
let mut issue = issues
.create(
"My first issue",

View File

@ -0,0 +1,685 @@
use std::ops::ControlFlow;
use std::str::FromStr;
use sqlite as sql;
use thiserror::Error;
use crate::cob;
use crate::cob::cache;
use crate::cob::cache::{Remove, StoreReader, StoreWriter, Update};
use crate::cob::store;
use crate::cob::{Embed, Label, ObjectId, TypeName};
use crate::crypto::Signer;
use crate::prelude::{Did, RepoId};
use crate::sql::transaction;
use crate::storage::{HasRepoId, ReadRepository, RepositoryError, SignRepository, WriteRepository};
use super::{Issue, IssueCounts, IssueId, IssueMut, State};
/// A set of read-only methods for a [`Issue`] store.
pub trait Issues {
type Error: std::error::Error + Send + Sync + 'static;
/// An iterator for returning a set of issues from the store.
type Iter<'a>: Iterator<Item = Result<(IssueId, Issue), Self::Error>> + 'a
where
Self: 'a;
/// Get the `Issue`, identified by `id`, returning `None` if it
/// was not found.
fn get(&self, id: &IssueId) -> Result<Option<Issue>, Self::Error>;
/// List all issues that are in the store.
fn list(&self) -> Result<Self::Iter<'_>, Self::Error>;
/// Get the [`IssueCounts`] of all the issues in the store.
fn counts(&self) -> Result<IssueCounts, Self::Error>;
/// Returns `true` if there are no issues in the store.
fn is_empty(&self) -> Result<bool, Self::Error> {
Ok(self.counts()?.total() == 0)
}
}
/// [`Issues`] store that can also [`Update`] and [`Remove`]
/// [`Issue`] in/from the store.
pub trait IssuesMut: Issues + Update<Issue> + Remove<Issue> {}
impl<T> IssuesMut for T where T: Issues + Update<Issue> + Remove<Issue> {}
/// An `Issue` store that relies on the `cache` for reads and as a
/// write-through cache.
///
/// The `store` is used for the main storage when performing a
/// write-through. It is also used for identifying which `RepoId` is
/// being used for the `cache`.
pub struct Cache<R, C> {
store: R,
cache: C,
}
impl<R, C> Cache<R, C> {
pub fn new(store: R, cache: C) -> Self {
Self { store, cache }
}
pub fn rid(&self) -> RepoId
where
R: HasRepoId,
{
self.store.rid()
}
}
impl<'a, R, C> Cache<super::Issues<'a, R>, C> {
/// Create a new [`Issue`] using the [`super::Issues`] as the
/// main storage, and writing the update to the `cache`.
pub fn create<'g, G>(
&'g mut self,
title: impl ToString,
description: impl ToString,
labels: &[Label],
assignees: &[Did],
embeds: impl IntoIterator<Item = Embed>,
signer: &G,
) -> Result<IssueMut<'a, 'g, R, C>, super::Error>
where
R: ReadRepository + WriteRepository + cob::Store,
G: Signer,
C: Update<Issue>,
{
self.store.create(
title,
description,
labels,
assignees,
embeds,
&mut self.cache,
signer,
)
}
/// Remove the given `id` from the [`super::Issues`] storage, and
/// removing the entry from the `cache`.
pub fn remove<G>(&mut self, id: &IssueId, signer: &G) -> Result<(), super::Error>
where
G: Signer,
R: ReadRepository + SignRepository + cob::Store,
C: Remove<Issue>,
{
self.store.remove(id, signer)?;
self.cache
.remove(id)
.map_err(|e| super::Error::CacheRemove {
id: *id,
err: e.into(),
})?;
Ok(())
}
/// Read the given `id` from the [`super::Issues`] store and
/// writing it to the `cache`.
pub fn write(&mut self, id: &IssueId) -> Result<(), super::Error>
where
R: ReadRepository + cob::Store,
C: Update<Issue>,
{
let issue = self
.store
.get(id)?
.ok_or_else(|| store::Error::NotFound((*super::TYPENAME).clone(), *id))?;
self.update(&self.rid(), id, &issue)
.map_err(|e| super::Error::CacheUpdate {
id: *id,
err: e.into(),
})?;
Ok(())
}
/// Read all the issues from the [`super::Issues`] store and
/// writing them to `cache`.
///
/// The `callback` is used for reporting success, failures, and
/// progress to the caller. The caller may also decide to continue
/// or break from the process.
pub fn write_all(
&mut self,
on_issue: impl Fn(
&Result<(IssueId, Issue), store::Error>,
&cache::WriteAllProgress,
) -> ControlFlow<()>,
) -> Result<(), super::Error>
where
R: ReadRepository + cob::Store,
C: Update<Issue>,
{
let issues = self.store.all()?;
let mut progress = cache::WriteAllProgress::new(issues.len());
for issue in self.store.all()? {
progress.inc();
match on_issue(&issue, &progress) {
ControlFlow::Continue(()) => match issue {
Ok((id, issue)) => {
self.update(&self.rid(), &id, &issue)
.map_err(|e| super::Error::CacheUpdate { id, err: e.into() })?;
}
Err(_) => continue,
},
ControlFlow::Break(()) => break,
}
}
Ok(())
}
}
impl<'a, R> Cache<super::Issues<'a, R>, cache::NoCache>
where
R: ReadRepository + cob::Store,
{
/// Get a `Cache` that does no write-through modifications and
/// uses the [`super::Issues`] store for all reads and writes.
pub fn no_cache(repository: &'a R) -> Result<Self, RepositoryError> {
let store = super::Issues::open(repository)?;
Ok(Self {
store,
cache: cache::NoCache,
})
}
/// Get the [`IssueMut`], identified by `id`.
pub fn get_mut<'g>(
&'g mut self,
id: &ObjectId,
) -> Result<IssueMut<'a, 'g, R, cache::NoCache>, super::Error> {
let issue = self
.store
.get(id)?
.ok_or_else(move || store::Error::NotFound(super::TYPENAME.clone(), *id))?;
Ok(IssueMut {
id: *id,
issue,
store: &mut self.store,
cache: &mut self.cache,
})
}
}
impl<R> Cache<R, StoreReader> {
pub fn reader(store: R, cache: StoreReader) -> Self {
Self { store, cache }
}
}
impl<R> Cache<R, StoreWriter> {
pub fn open(store: R, cache: StoreWriter) -> Self {
Self { store, cache }
}
}
impl<'a, R> Cache<super::Issues<'a, R>, StoreWriter>
where
R: ReadRepository + cob::Store,
{
/// Get the [`IssueMut`], identified by `id`, using the
/// `StoreWriter` for retrieving the `Issue`.
pub fn get_mut<'g>(
&'g mut self,
id: &ObjectId,
) -> Result<IssueMut<'a, 'g, R, StoreWriter>, Error> {
let issue = Issues::get(self, id)?
.ok_or_else(move || Error::NotFound(super::TYPENAME.clone(), *id))?;
Ok(IssueMut {
id: *id,
issue,
store: &mut self.store,
cache: &mut self.cache,
})
}
}
impl<R, C> cache::Update<Issue> for Cache<R, C>
where
C: cache::Update<Issue>,
{
type Out = <C as cache::Update<Issue>>::Out;
type UpdateError = <C as cache::Update<Issue>>::UpdateError;
fn update(
&mut self,
rid: &RepoId,
id: &ObjectId,
object: &Issue,
) -> Result<Self::Out, Self::UpdateError> {
self.cache.update(rid, id, object)
}
}
impl<R, C> cache::Remove<Issue> for Cache<R, C>
where
C: cache::Remove<Issue>,
{
type Out = <C as cache::Remove<Issue>>::Out;
type RemoveError = <C as cache::Remove<Issue>>::RemoveError;
fn remove(&mut self, id: &ObjectId) -> Result<Self::Out, Self::RemoveError> {
self.cache.remove(id)
}
}
#[derive(Debug, Error)]
pub enum UpdateError {
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Sql(#[from] sql::Error),
}
impl Update<Issue> for StoreWriter {
type Out = bool;
type UpdateError = UpdateError;
fn update(
&mut self,
rid: &RepoId,
id: &ObjectId,
object: &Issue,
) -> Result<Self::Out, Self::UpdateError> {
transaction::<_, UpdateError>(&self.db, move |db| {
let mut stmt = db.prepare(
"INSERT INTO issues (id, repo, issue)
VALUES (?1, ?2, ?3)
ON CONFLICT DO UPDATE
SET issue = (?3)",
)?;
stmt.bind((1, sql::Value::String(id.to_string())))?;
stmt.bind((2, rid))?;
stmt.bind((3, sql::Value::String(serde_json::to_string(&object)?)))?;
stmt.next()?;
Ok(db.change_count() > 0)
})
}
}
impl Remove<Issue> for StoreWriter {
type Out = bool;
type RemoveError = sql::Error;
fn remove(&mut self, id: &ObjectId) -> Result<Self::Out, Self::RemoveError> {
transaction::<_, sql::Error>(&self.db, move |db| {
let mut stmt = db.prepare(
"DELETE FROM issues
WHERE id = ?1",
)?;
stmt.bind((1, sql::Value::String(id.to_string())))?;
stmt.next()?;
Ok(db.change_count() > 0)
})
}
}
pub struct NoCacheIter<'a> {
inner: Box<dyn Iterator<Item = Result<(IssueId, Issue), super::Error>> + 'a>,
}
impl<'a> Iterator for NoCacheIter<'a> {
type Item = Result<(IssueId, Issue), super::Error>;
fn next(&mut self) -> Option<Self::Item> {
self.inner.next()
}
}
impl<'a, R> Issues for Cache<super::Issues<'a, R>, cache::NoCache>
where
R: ReadRepository + cob::Store,
{
type Error = super::Error;
type Iter<'b> = NoCacheIter<'b> where Self: 'b;
fn get(&self, id: &IssueId) -> Result<Option<Issue>, Self::Error> {
self.store.get(id).map_err(super::Error::from)
}
fn list(&self) -> Result<Self::Iter<'_>, Self::Error> {
self.store
.all()
.map(|inner| NoCacheIter {
inner: Box::new(inner.into_iter().map(|res| res.map_err(super::Error::from))),
})
.map_err(super::Error::from)
}
fn counts(&self) -> Result<IssueCounts, Self::Error> {
self.store.counts().map_err(super::Error::from)
}
}
#[derive(Debug, Error)]
pub enum Error {
#[error("object `{1}` of type `{0}` was not found")]
NotFound(TypeName, ObjectId),
#[error(transparent)]
Object(#[from] cob::object::ParseObjectId),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Sql(#[from] sql::Error),
}
/// Iterator that returns a set of issues based on an SQL query.
///
/// The query is expected to return rows with columns identified by
/// the `id` and `issue` names.
pub struct IssuesIter<'a> {
inner: sql::CursorWithOwnership<'a>,
}
impl<'a> IssuesIter<'a> {
fn parse_row(row: sql::Row) -> Result<(IssueId, Issue), Error> {
let id = IssueId::from_str(row.read::<&str, _>("id"))?;
let issue = serde_json::from_str::<Issue>(row.read::<&str, _>("issue"))?;
Ok((id, issue))
}
}
impl<'a> Iterator for IssuesIter<'a> {
type Item = Result<(IssueId, Issue), Error>;
fn next(&mut self) -> Option<Self::Item> {
let row = self.inner.next()?;
Some(row.map_err(Error::from).and_then(IssuesIter::parse_row))
}
}
impl<R> Issues for Cache<R, StoreWriter>
where
R: HasRepoId,
{
type Error = Error;
type Iter<'b> = IssuesIter<'b> where Self: 'b;
fn get(&self, id: &IssueId) -> Result<Option<Issue>, Self::Error> {
query::get(&self.cache.db, &self.rid(), id)
}
fn list(&self) -> Result<Self::Iter<'_>, Self::Error> {
query::list(&self.cache.db, &self.rid())
}
fn counts(&self) -> Result<IssueCounts, Self::Error> {
query::counts(&self.cache.db, &self.rid())
}
}
impl<R> Issues for Cache<R, StoreReader>
where
R: HasRepoId,
{
type Error = Error;
type Iter<'b> = IssuesIter<'b> where Self: 'b;
fn get(&self, id: &IssueId) -> Result<Option<Issue>, Self::Error> {
query::get(&self.cache.db, &self.rid(), id)
}
fn list(&self) -> Result<Self::Iter<'_>, Self::Error> {
query::list(&self.cache.db, &self.rid())
}
fn counts(&self) -> Result<IssueCounts, Self::Error> {
query::counts(&self.cache.db, &self.rid())
}
}
/// Helper SQL queries for [ `Issues`] trait implementations.
mod query {
use sqlite as sql;
use super::*;
pub(super) fn get(
db: &sql::ConnectionThreadSafe,
rid: &RepoId,
id: &IssueId,
) -> Result<Option<Issue>, Error> {
let id = sql::Value::String(id.to_string());
let mut stmt = db.prepare(
"SELECT issue
FROM issues
WHERE id = ?1 and repo = ?2",
)?;
stmt.bind((1, id))?;
stmt.bind((2, rid))?;
match stmt.into_iter().next().transpose()? {
None => Ok(None),
Some(row) => {
let issue = row.read::<&str, _>("issue");
let issue = serde_json::from_str(issue)?;
Ok(Some(issue))
}
}
}
pub(super) fn list<'a>(
db: &'a sql::ConnectionThreadSafe,
rid: &RepoId,
) -> Result<IssuesIter<'a>, Error> {
let mut stmt = db.prepare(
"SELECT id, issue
FROM issues
WHERE repo = ?1
",
)?;
stmt.bind((1, rid))?;
Ok(IssuesIter {
inner: stmt.into_iter(),
})
}
pub(super) fn counts(
db: &sql::ConnectionThreadSafe,
rid: &RepoId,
) -> Result<IssueCounts, Error> {
let mut stmt = db.prepare(
"SELECT
issue->'$.state' AS state,
COUNT(*) AS count
FROM issues
WHERE repo = ?1
GROUP BY issue->'$.state.status'",
)?;
stmt.bind((1, rid))?;
stmt.into_iter()
.try_fold(IssueCounts::default(), |mut counts, row| {
let row = row?;
let count = row.read::<i64, _>("count") as usize;
let status = serde_json::from_str::<State>(row.read::<&str, _>("state"))?;
match status {
State::Closed { .. } => counts.closed += count,
State::Open => counts.open += count,
}
Ok(counts)
})
}
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::str::FromStr;
use radicle_cob::ObjectId;
use crate::cob::cache::{Store, Update, Write};
use crate::cob::thread::Thread;
use crate::issue::{CloseReason, Issue, IssueCounts, IssueId, State};
use crate::test::arbitrary;
use crate::test::storage::MockRepository;
use super::{Cache, Issues};
fn memory(store: MockRepository) -> Cache<MockRepository, Store<Write>> {
let cache = Store::<Write>::memory().unwrap();
Cache { store, cache }
}
#[test]
fn test_is_empty() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
assert!(cache.is_empty().unwrap());
let issue = Issue::new(Thread::default());
let id = ObjectId::from_str("47799cbab2eca047b6520b9fce805da42b49ecab").unwrap();
cache.update(&cache.rid(), &id, &issue).unwrap();
let issue = Issue {
state: State::Closed {
reason: CloseReason::Solved,
},
..Issue::new(Thread::default())
};
let id = ObjectId::from_str("ae981ded6ed2ed2cdba34c8603714782667f18a3").unwrap();
cache.update(&cache.rid(), &id, &issue).unwrap();
assert!(!cache.is_empty().unwrap())
}
#[test]
fn test_counts() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
let n_open = arbitrary::gen::<u8>(0);
let n_closed = arbitrary::gen::<u8>(1);
let open_ids = (0..n_open)
.map(|_| IssueId::from(arbitrary::oid()))
.collect::<BTreeSet<IssueId>>();
let closed_ids = (0..n_closed)
.map(|_| IssueId::from(arbitrary::oid()))
.collect::<BTreeSet<IssueId>>();
for id in open_ids.iter() {
let issue = Issue::new(Thread::default());
cache
.update(&cache.rid(), &IssueId::from(*id), &issue)
.unwrap();
}
for id in closed_ids.iter() {
let issue = Issue {
state: State::Closed {
reason: CloseReason::Solved,
},
..Issue::new(Thread::default())
};
cache
.update(&cache.rid(), &IssueId::from(*id), &issue)
.unwrap();
}
assert_eq!(
cache.counts().unwrap(),
IssueCounts {
open: open_ids.len(),
closed: closed_ids.len()
}
);
}
#[test]
fn test_get() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
let ids = (0..arbitrary::gen::<u8>(1))
.map(|_| IssueId::from(arbitrary::oid()))
.collect::<BTreeSet<IssueId>>();
let missing = (0..arbitrary::gen::<u8>(2))
.filter_map(|_| {
let id = IssueId::from(arbitrary::oid());
(!ids.contains(&id)).then_some(id)
})
.collect::<BTreeSet<IssueId>>();
let mut issues = Vec::with_capacity(ids.len());
for id in ids.iter() {
let issue = Issue {
title: id.to_string(),
..Issue::new(Thread::default())
};
cache
.update(&cache.rid(), &IssueId::from(*id), &issue)
.unwrap();
issues.push((*id, issue));
}
for (id, issue) in issues.into_iter() {
assert_eq!(Some(issue), cache.get(&id).unwrap());
}
for id in &missing {
assert_eq!(cache.get(id).unwrap(), None);
}
}
#[test]
fn test_list() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
let ids = (0..arbitrary::gen::<u8>(1))
.map(|_| IssueId::from(arbitrary::oid()))
.collect::<BTreeSet<IssueId>>();
let mut issues = Vec::with_capacity(ids.len());
for id in ids.iter() {
let issue = Issue {
title: id.to_string(),
..Issue::new(Thread::default())
};
cache
.update(&cache.rid(), &IssueId::from(*id), &issue)
.unwrap();
issues.push((*id, issue));
}
let mut list = cache
.list()
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
list.sort_by_key(|(id, _)| *id);
issues.sort_by_key(|(id, _)| *id);
assert_eq!(issues, list);
}
#[test]
fn test_remove() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
let ids = (0..arbitrary::gen::<u8>(1))
.map(|_| IssueId::from(arbitrary::oid()))
.collect::<BTreeSet<IssueId>>();
for id in ids.iter() {
let issue = Issue {
title: id.to_string(),
..Issue::new(Thread::default())
};
cache
.update(&cache.rid(), &IssueId::from(*id), &issue)
.unwrap();
assert_eq!(Some(issue), cache.get(id).unwrap());
super::Remove::remove(&mut cache, id).unwrap();
assert_eq!(None, cache.get(id).unwrap());
}
}
}

View File

@ -1,4 +1,5 @@
#![allow(clippy::too_many_arguments)]
pub mod cache;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt;
use std::ops::Deref;
@ -7,9 +8,8 @@ use std::str::FromStr;
use amplify::Wrapper;
use nonempty::NonEmpty;
use once_cell::sync::Lazy;
use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize};
use storage::RepositoryError;
use storage::{HasRepoId, RepositoryError};
use thiserror::Error;
use crate::cob;
@ -27,6 +27,8 @@ use crate::identity::PayloadError;
use crate::prelude::*;
use crate::storage;
pub use cache::Cache;
/// Type name of a patch.
pub static TYPENAME: Lazy<TypeName> =
Lazy::new(|| FromStr::from_str("xyz.radicle.patch").expect("type name is valid"));
@ -124,6 +126,18 @@ pub enum Error {
/// Initialization failed.
#[error("initialization failed: {0}")]
Init(&'static str),
#[error("failed to update patch {id} in cache: {err}")]
CacheUpdate {
id: PatchId,
#[source]
err: Box<dyn std::error::Error + Send + Sync + 'static>,
},
#[error("failed to remove patch {id} from cache : {err}")]
CacheRemove {
id: PatchId,
#[source]
err: Box<dyn std::error::Error + Send + Sync + 'static>,
},
}
/// Patch operation.
@ -359,7 +373,8 @@ impl MergeTarget {
}
/// Patch state.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Patch {
/// Title of the patch.
pub(super) title: String,
@ -1301,7 +1316,8 @@ mod lookup {
}
/// A patch revision.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Revision {
/// Author of the revision.
pub(super) author: Author,
@ -1320,6 +1336,10 @@ pub struct Revision {
/// Review comments resolved by this revision.
pub(super) resolves: BTreeSet<(EntryId, CommentId)>,
/// Reactions on code locations and revision itself
#[serde(
serialize_with = "ser::serialize_reactions",
deserialize_with = "ser::deserialize_reactions"
)]
pub(super) reactions: BTreeMap<Option<CodeLocation>, Reactions>,
}
@ -1443,6 +1463,39 @@ impl fmt::Display for State {
}
}
impl From<&State> for Status {
fn from(value: &State) -> Self {
match value {
State::Draft => Self::Draft,
State::Open { .. } => Self::Open,
State::Archived => Self::Archived,
State::Merged { .. } => Self::Merged,
}
}
}
/// A simplified enumeration of a [`State`] that can be used for
/// filtering purposes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Status {
Draft,
#[default]
Open,
Archived,
Merged,
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Archived => write!(f, "archived"),
Self::Draft => write!(f, "draft"),
Self::Open => write!(f, "open"),
Self::Merged => write!(f, "merged"),
}
}
}
/// A lifecycle operation, resulting in a new state.
#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "status")]
@ -1485,7 +1538,8 @@ impl fmt::Display for Verdict {
}
/// A patch review on a revision.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Review {
/// Review author.
pub(super) author: Author,
@ -1506,23 +1560,6 @@ pub struct Review {
pub(super) timestamp: Timestamp,
}
impl Serialize for Review {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
let mut state = serializer.serialize_struct("Review", 4)?;
state.serialize_field("verdict", &self.verdict())?;
state.serialize_field("summary", &self.summary())?;
state.serialize_field(
"comments",
&self.comments().map(|(_, c)| c.body()).collect::<Vec<_>>(),
)?;
state.serialize_field("timestamp", &self.timestamp())?;
state.end()
}
}
impl Review {
pub fn new(
author: Author,
@ -1847,19 +1884,26 @@ impl<R: ReadRepository> store::Transaction<Patch, R> {
}
}
pub struct PatchMut<'a, 'g, R> {
pub struct PatchMut<'a, 'g, R, C> {
pub id: ObjectId,
patch: Patch,
store: &'g mut Patches<'a, R>,
cache: &'g mut C,
}
impl<'a, 'g, R> PatchMut<'a, 'g, R>
impl<'a, 'g, R, C> PatchMut<'a, 'g, R, C>
where
R: ReadRepository + SignRepository + cob::Store + 'static,
C: cob::cache::Update<Patch>,
R: ReadRepository + SignRepository + cob::Store,
{
pub fn new(id: ObjectId, patch: Patch, store: &'g mut Patches<'a, R>) -> Self {
Self { id, patch, store }
pub fn new(id: ObjectId, patch: Patch, cache: &'g mut Cache<Patches<'a, R>, C>) -> Self {
Self {
id,
patch,
store: &mut cache.store,
cache: &mut cache.cache,
}
}
pub fn id(&self) -> &ObjectId {
@ -1890,6 +1934,12 @@ where
operations(&mut tx)?;
let (patch, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
self.cache
.update(&self.store.as_ref().id(), &self.id, &patch)
.map_err(|e| Error::CacheUpdate {
id: self.id,
err: e.into(),
})?;
self.patch = patch;
Ok(commit)
@ -2214,7 +2264,7 @@ where
}
}
impl<'a, 'g, R> Deref for PatchMut<'a, 'g, R> {
impl<'a, 'g, R, C> Deref for PatchMut<'a, 'g, R, C> {
type Target = Patch;
fn deref(&self) -> &Self::Target {
@ -2223,7 +2273,7 @@ impl<'a, 'g, R> Deref for PatchMut<'a, 'g, R> {
}
/// Detailed information on patch states
#[derive(Default, Serialize)]
#[derive(Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PatchCounts {
pub open: usize,
@ -2239,6 +2289,17 @@ impl PatchCounts {
}
}
/// Result of looking up a `Patch`'s `Revision`.
///
/// See [`Patches::find_by_revision`].
#[derive(Debug, PartialEq, Eq)]
pub struct ByRevision {
pub id: PatchId,
pub patch: Patch,
pub revision_id: RevisionId,
pub revision: Revision,
}
pub struct Patches<'a, R> {
raw: store::Store<'a, Patch, R>,
}
@ -2251,6 +2312,15 @@ impl<'a, R> Deref for Patches<'a, R> {
}
}
impl<'a, R> HasRepoId for Patches<'a, R>
where
R: ReadRepository,
{
fn rid(&self) -> RepoId {
self.as_ref().id()
}
}
impl<'a, R> Patches<'a, R>
where
R: ReadRepository + cob::Store,
@ -2282,23 +2352,27 @@ where
}
/// Find the `Patch` containing the given `Revision`.
pub fn find_by_revision(
&self,
revision: &RevisionId,
) -> Result<Option<(PatchId, Patch, RevisionId, Revision)>, Error> {
pub fn find_by_revision(&self, revision: &RevisionId) -> Result<Option<ByRevision>, Error> {
// Revision may be the patch's first, making it have the same ID.
let p_id = ObjectId::from(revision.into_inner());
if let Some(p) = self.get(&p_id)? {
return Ok(p
.revision(revision)
.map(|r| (p_id, p.clone(), *revision, r.clone())));
return Ok(p.revision(revision).map(|r| ByRevision {
id: p_id,
patch: p.clone(),
revision_id: *revision,
revision: r.clone(),
}));
}
let result = self
.all()?
.filter_map(|result| result.ok())
.find_map(|(p_id, p)| {
p.revision(revision)
.map(|r| (p_id, p.clone(), *revision, r.clone()))
p.revision(revision).map(|r| ByRevision {
id: p_id,
patch: p.clone(),
revision_id: *revision,
revision: r.clone(),
})
});
Ok(result)
@ -2332,10 +2406,10 @@ where
impl<'a, R> Patches<'a, R>
where
R: ReadRepository + SignRepository + cob::Store + 'static,
R: ReadRepository + SignRepository + cob::Store,
{
/// Open a new patch.
pub fn create<'g, G: Signer>(
pub fn create<'g, C, G>(
&'g mut self,
title: impl ToString,
description: impl ToString,
@ -2343,8 +2417,13 @@ where
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
labels: &[Label],
cache: &'g mut C,
signer: &G,
) -> Result<PatchMut<'a, 'g, R>, Error> {
) -> Result<PatchMut<'a, 'g, R, C>, Error>
where
C: cob::cache::Update<Patch>,
G: Signer,
{
self._create(
title,
description,
@ -2353,12 +2432,13 @@ where
oid,
labels,
Lifecycle::default(),
cache,
signer,
)
}
/// Draft a patch. This patch will be created in a [`State::Draft`] state.
pub fn draft<'g, G: Signer>(
pub fn draft<'g, C, G: Signer>(
&'g mut self,
title: impl ToString,
description: impl ToString,
@ -2366,8 +2446,12 @@ where
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
labels: &[Label],
cache: &'g mut C,
signer: &G,
) -> Result<PatchMut<'a, 'g, R>, Error> {
) -> Result<PatchMut<'a, 'g, R, C>, Error>
where
C: cob::cache::Update<Patch>,
{
self._create(
title,
description,
@ -2376,12 +2460,17 @@ where
oid,
labels,
Lifecycle::Draft,
cache,
signer,
)
}
/// Get a patch mutably.
pub fn get_mut<'g>(&'g mut self, id: &ObjectId) -> Result<PatchMut<'a, 'g, R>, store::Error> {
pub fn get_mut<'g, C>(
&'g mut self,
id: &ObjectId,
cache: &'g mut C,
) -> Result<PatchMut<'a, 'g, R, C>, store::Error> {
let patch = self
.raw
.get(id)?
@ -2391,11 +2480,12 @@ where
id: *id,
patch,
store: self,
cache,
})
}
/// Create a patch. This is an internal function used by `create` and `draft`.
fn _create<'g, G: Signer>(
fn _create<'g, C, G: Signer>(
&'g mut self,
title: impl ToString,
description: impl ToString,
@ -2404,8 +2494,12 @@ where
oid: impl Into<git::Oid>,
labels: &[Label],
state: Lifecycle,
cache: &'g mut C,
signer: &G,
) -> Result<PatchMut<'a, 'g, R>, Error> {
) -> Result<PatchMut<'a, 'g, R, C>, Error>
where
C: cob::cache::Update<Patch>,
{
let (id, patch) = Transaction::initial("Create patch", &mut self.raw, signer, |tx| {
tx.revision(description, base, oid)?;
tx.edit(title, target)?;
@ -2418,8 +2512,135 @@ where
}
Ok(())
})?;
cache
.update(&self.raw.as_ref().id(), &id, &patch)
.map_err(|e| Error::CacheUpdate { id, err: e.into() })?;
Ok(PatchMut::new(id, patch, self))
Ok(PatchMut {
id,
patch,
store: self,
cache,
})
}
}
/// Helpers for de/serialization of patch data types.
mod ser {
use std::collections::{BTreeMap, BTreeSet};
use serde::ser::SerializeSeq;
use crate::cob::{thread::Reactions, ActorId, CodeLocation};
/// Serialize a `Revision`'s reaction as an object containing the
/// `location`, `emoji`, and all `authors` that have performed the
/// same reaction.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Reaction {
location: Option<CodeLocation>,
emoji: super::Reaction,
authors: Vec<ActorId>,
}
impl Reaction {
fn as_revision_reactions(
reactions: Vec<Reaction>,
) -> BTreeMap<Option<CodeLocation>, Reactions> {
reactions.into_iter().fold(
BTreeMap::<Option<CodeLocation>, Reactions>::new(),
|mut reactions,
Reaction {
location,
emoji,
authors,
}| {
let mut inner = authors
.into_iter()
.map(|author| (author, emoji))
.collect::<BTreeSet<_>>();
let entry = reactions.entry(location).or_default();
entry.append(&mut inner);
reactions
},
)
}
}
/// Helper to serialize a `Revision`'s reactions, since
/// `CodeLocation` cannot be a key for a JSON object.
///
/// The set `reactions` are first turned into a set of
/// [`Reaction`]s and then serialized via a `Vec`.
pub fn serialize_reactions<S>(
reactions: &BTreeMap<Option<CodeLocation>, Reactions>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let reactions = reactions
.iter()
.flat_map(|(location, reaction)| {
let reactions = reaction.iter().fold(
BTreeMap::new(),
|mut acc: BTreeMap<&super::Reaction, Vec<_>>, (author, emoji)| {
acc.entry(emoji).or_default().push(*author);
acc
},
);
reactions
.into_iter()
.map(|(emoji, authors)| Reaction {
location: location.clone(),
emoji: *emoji,
authors,
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let mut s = serializer.serialize_seq(Some(reactions.len()))?;
for r in &reactions {
s.serialize_element(r)?;
}
s.end()
}
/// Helper to deserialize a `Revision`'s reactions, the inverse of
/// `serialize_reactions`.
///
/// The `Vec` of [`Reaction`]s are deserialized and converted to a
/// `BTreeMap<Option<CodeLocation>, Reactions>`.
pub fn deserialize_reactions<'de, D>(
deserializer: D,
) -> Result<BTreeMap<Option<CodeLocation>, Reactions>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ReactionsVisitor;
impl<'de> serde::de::Visitor<'de> for ReactionsVisitor {
type Value = Vec<Reaction>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a reaction of the form {'location', 'emoji', 'authors'}")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut reactions = Vec::new();
while let Some(reaction) = seq.next_element()? {
reactions.push(reaction);
}
Ok(reactions)
}
}
let reactions = deserializer.deserialize_seq(ReactionsVisitor)?;
Ok(Reaction::as_revision_reactions(reactions))
}
}
@ -2437,6 +2658,7 @@ mod test {
use crate::cob::test::Actor;
use crate::crypto::test::signer::MockSigner;
use crate::identity;
use crate::patch::cache::Patches as _;
use crate::test;
use crate::test::arbitrary;
use crate::test::arbitrary::gen;
@ -2453,12 +2675,54 @@ mod test {
);
}
#[test]
fn test_reactions_json_serialization() {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct TestReactions {
#[serde(
serialize_with = "super::ser::serialize_reactions",
deserialize_with = "super::ser::deserialize_reactions"
)]
inner: BTreeMap<Option<CodeLocation>, Reactions>,
}
let reactions = TestReactions {
inner: [(
None,
[
(
"z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"
.parse()
.unwrap(),
Reaction::new('🚀').unwrap(),
),
(
"z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
.parse()
.unwrap(),
Reaction::new('🙏').unwrap(),
),
]
.into_iter()
.collect(),
)]
.into_iter()
.collect(),
};
assert_eq!(
reactions,
serde_json::from_str(&serde_json::to_string(&reactions).unwrap()).unwrap()
);
}
#[test]
fn test_patch_create_and_get() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Patches::open(&*alice.repo).unwrap();
let mut patches = Cache::no_cache(&*alice.repo).unwrap();
let author: Did = alice.signer.public_key().into();
let target = MergeTarget::Delegates;
let patch = patches
@ -2491,7 +2755,7 @@ mod test {
assert_eq!(revision.oid, branch.oid);
assert_eq!(revision.base, branch.base);
let (id, _, _, _) = patches.find_by_revision(&rev_id).unwrap().unwrap();
let ByRevision { id, .. } = patches.find_by_revision(&rev_id).unwrap().unwrap();
assert_eq!(id, patch_id);
}
@ -2500,7 +2764,7 @@ mod test {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Patches::open(&*alice.repo).unwrap();
let mut patches = Cache::no_cache(&*alice.repo).unwrap();
let patch = patches
.create(
"My first patch",
@ -2533,7 +2797,7 @@ mod test {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Patches::open(&*alice.repo).unwrap();
let mut patches = Cache::no_cache(&*alice.repo).unwrap();
let mut patch = patches
.create(
"My first patch",
@ -2564,7 +2828,7 @@ mod test {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Patches::open(&*alice.repo).unwrap();
let mut patches = Cache::no_cache(&*alice.repo).unwrap();
let mut patch = patches
.create(
"My first patch",
@ -2616,7 +2880,7 @@ mod test {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Patches::open(&*alice.repo).unwrap();
let mut patches = Cache::no_cache(&*alice.repo).unwrap();
let mut patch = patches
.create(
"My first patch",
@ -2804,7 +3068,7 @@ mod test {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Patches::open(&*alice.repo).unwrap();
let mut patches = Cache::no_cache(&*alice.repo).unwrap();
let mut patch = patches
.create(
"My first patch",
@ -2847,7 +3111,7 @@ mod test {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Patches::open(&*alice.repo).unwrap();
let mut patches = Cache::no_cache(&*alice.repo).unwrap();
let mut patch = patches
.create(
"My first patch",
@ -2894,7 +3158,7 @@ mod test {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Patches::open(&*alice.repo).unwrap();
let mut patches = Cache::no_cache(&*alice.repo).unwrap();
let mut patch = patches
.create(
"My first patch",
@ -2928,7 +3192,7 @@ mod test {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
let mut patches = Patches::open(&*alice.repo).unwrap();
let mut patches = Cache::no_cache(&*alice.repo).unwrap();
let mut patch = patches
.create(
"My first patch",
@ -2977,7 +3241,7 @@ mod test {
let branch = repo
.checkout()
.branch_with([("README.md", b"Hello, World!")]);
let mut patches = Patches::open(&*repo).unwrap();
let mut patches = Cache::no_cache(&*repo).unwrap();
let mut patch = patches
.create(
"My first patch",

View File

@ -0,0 +1,974 @@
use std::ops::ControlFlow;
use std::str::FromStr;
use sqlite as sql;
use thiserror::Error;
use crate::cob;
use crate::cob::cache::{self, StoreReader};
use crate::cob::cache::{Remove, StoreWriter, Update};
use crate::cob::store;
use crate::cob::{Label, ObjectId, TypeName};
use crate::crypto::Signer;
use crate::git;
use crate::prelude::RepoId;
use crate::sql::transaction;
use crate::storage::{HasRepoId, ReadRepository, RepositoryError, SignRepository, WriteRepository};
use super::{
ByRevision, MergeTarget, Patch, PatchCounts, PatchId, PatchMut, Revision, RevisionId, State,
Status,
};
/// A set of read-only methods for a [`Patch`] store.
pub trait Patches {
type Error: std::error::Error + Send + Sync + 'static;
/// An iterator for returning a set of patches from the store.
type Iter<'a>: Iterator<Item = Result<(PatchId, Patch), Self::Error>> + 'a
where
Self: 'a;
/// Get the `Patch`, identified by `id`, returning `None` if it
/// was not found.
fn get(&self, id: &PatchId) -> Result<Option<Patch>, Self::Error>;
/// Get the `Patch` and its `Revision`, identified by the revision
/// `id`, returning `None` if it was not found.
fn find_by_revision(&self, id: &RevisionId) -> Result<Option<ByRevision>, Self::Error>;
/// List all patches that are in the store.
fn list(&self) -> Result<Self::Iter<'_>, Self::Error>;
/// List all patches in the store that match the provided
/// `status`.
///
/// Also see [`Patches::opened`], [`Patches::archived`],
/// [`Patches::drafted`], [`Patches::merged`].
fn list_by_status(&self, status: &Status) -> Result<Self::Iter<'_>, Self::Error>;
/// Get the [`PatchCounts`] of all the patches in the store.
fn counts(&self) -> Result<PatchCounts, Self::Error>;
/// List all opened patches in the store.
fn opened(&self) -> Result<Self::Iter<'_>, Self::Error> {
self.list_by_status(&Status::Open)
}
/// List all archived patches in the store.
fn archived(&self) -> Result<Self::Iter<'_>, Self::Error> {
self.list_by_status(&Status::Archived)
}
/// List all drafted patches in the store.
fn drafted(&self) -> Result<Self::Iter<'_>, Self::Error> {
self.list_by_status(&Status::Draft)
}
/// List all merged patches in the store.
fn merged(&self) -> Result<Self::Iter<'_>, Self::Error> {
self.list_by_status(&Status::Merged)
}
/// Returns `true` if there are no patches in the store.
fn is_empty(&self) -> Result<bool, Self::Error> {
Ok(self.counts()?.total() == 0)
}
}
/// [`Patches`] store that can also [`Update`] and [`Remove`]
/// [`Patch`] in/from the store.
pub trait PatchesMut: Patches + Update<Patch> + Remove<Patch> {}
impl<T> PatchesMut for T where T: Patches + Update<Patch> + Remove<Patch> {}
/// A `Patch` store that relies on the `cache` for reads and as a
/// write-through cache.
///
/// The `store` is used for the main storage when performing a
/// write-through. It is also used for identifying which `RepoId` is
/// being used for the `cache`.
pub struct Cache<R, C> {
pub(super) store: R,
pub(super) cache: C,
}
impl<R, C> Cache<R, C> {
pub fn new(store: R, cache: C) -> Self {
Self { store, cache }
}
pub fn rid(&self) -> RepoId
where
R: HasRepoId,
{
self.store.rid()
}
}
impl<'a, R, C> Cache<super::Patches<'a, R>, C> {
/// Create a new [`Patch`] using the [`super::Patches`] as the
/// main storage, and writing the update to the `cache`.
pub fn create<'g, G>(
&'g mut self,
title: impl ToString,
description: impl ToString,
target: MergeTarget,
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
labels: &[Label],
signer: &G,
) -> Result<PatchMut<'a, 'g, R, C>, super::Error>
where
R: WriteRepository + cob::Store,
G: Signer,
C: Update<Patch>,
{
self.store.create(
title,
description,
target,
base,
oid,
labels,
&mut self.cache,
signer,
)
}
/// Create a new [`Patch`], in a draft state, using the
/// [`super::Patches`] as the main storage, and writing the update
/// to the `cache`.
pub fn draft<'g, G>(
&'g mut self,
title: impl ToString,
description: impl ToString,
target: MergeTarget,
base: impl Into<git::Oid>,
oid: impl Into<git::Oid>,
labels: &[Label],
signer: &G,
) -> Result<PatchMut<'a, 'g, R, C>, super::Error>
where
R: WriteRepository + cob::Store,
G: Signer,
C: Update<Patch>,
{
self.store.draft(
title,
description,
target,
base,
oid,
labels,
&mut self.cache,
signer,
)
}
/// Remove the given `id` from the [`super::Patches`] storage, and
/// removing the entry from the `cache`.
pub fn remove<G>(&mut self, id: &PatchId, signer: &G) -> Result<(), super::Error>
where
G: Signer,
R: ReadRepository + SignRepository + cob::Store,
C: Remove<Patch>,
{
self.store.remove(id, signer)?;
self.cache
.remove(id)
.map_err(|e| super::Error::CacheRemove {
id: *id,
err: e.into(),
})?;
Ok(())
}
/// Read the given `id` from the [`super::Patches`] store and
/// writing it to the `cache`.
pub fn write(&mut self, id: &PatchId) -> Result<(), super::Error>
where
R: ReadRepository + cob::Store,
C: Update<Patch>,
{
let issue = self
.store
.get(id)?
.ok_or_else(|| store::Error::NotFound((*super::TYPENAME).clone(), *id))?;
self.update(&self.rid(), id, &issue)
.map_err(|e| super::Error::CacheUpdate {
id: *id,
err: e.into(),
})?;
Ok(())
}
/// Read all the patches from the [`super::Patches`] store and
/// writing them to `cache`.
///
/// The `callback` is used for reporting success, failures, and
/// progress to the caller. The caller may also decide to continue
/// or break from the process.
pub fn write_all(
&mut self,
callback: impl Fn(
&Result<(PatchId, Patch), store::Error>,
&cache::WriteAllProgress,
) -> ControlFlow<()>,
) -> Result<(), super::Error>
where
R: ReadRepository + cob::Store,
C: Update<Patch>,
{
let patches = self.store.all()?;
let mut progress = cache::WriteAllProgress::new(patches.len());
for patch in self.store.all()? {
progress.inc();
match callback(&patch, &progress) {
ControlFlow::Continue(()) => match patch {
Ok((id, patch)) => {
self.update(&self.rid(), &id, &patch)
.map_err(|e| super::Error::CacheUpdate { id, err: e.into() })?;
}
Err(_) => continue,
},
ControlFlow::Break(()) => break,
}
}
Ok(())
}
}
impl<R> Cache<R, StoreReader> {
pub fn reader(store: R, cache: StoreReader) -> Self {
Self { store, cache }
}
}
impl<R> Cache<R, StoreWriter> {
pub fn open(store: R, cache: StoreWriter) -> Self {
Self { store, cache }
}
}
impl<'a, R> Cache<super::Patches<'a, R>, StoreWriter>
where
R: ReadRepository + cob::Store,
{
/// Get the [`PatchMut`], identified by `id`, using the
/// `StoreWriter` for retrieving the `Patch`.
pub fn get_mut<'g>(
&'g mut self,
id: &ObjectId,
) -> Result<PatchMut<'a, 'g, R, StoreWriter>, Error> {
let patch = Patches::get(self, id)?
.ok_or_else(move || Error::NotFound(super::TYPENAME.clone(), *id))?;
Ok(PatchMut {
id: *id,
patch,
store: &mut self.store,
cache: &mut self.cache,
})
}
}
impl<'a, R> Cache<super::Patches<'a, R>, cache::NoCache>
where
R: ReadRepository + cob::Store,
{
/// Get a `Cache` that does no write-through modifications and
/// uses the [`super::Patches`] store for all reads and writes.
pub fn no_cache(repository: &'a R) -> Result<Self, RepositoryError> {
let store = super::Patches::open(repository)?;
Ok(Self {
store,
cache: cache::NoCache,
})
}
/// Get the [`PatchMut`], identified by `id`.
pub fn get_mut<'g>(
&'g mut self,
id: &ObjectId,
) -> Result<PatchMut<'a, 'g, R, cache::NoCache>, super::Error> {
let patch = self
.store
.get(id)?
.ok_or_else(move || store::Error::NotFound(super::TYPENAME.clone(), *id))?;
Ok(PatchMut {
id: *id,
patch,
store: &mut self.store,
cache: &mut self.cache,
})
}
}
impl<R, C> cache::Update<Patch> for Cache<R, C>
where
C: cache::Update<Patch>,
{
type Out = <C as cache::Update<Patch>>::Out;
type UpdateError = <C as cache::Update<Patch>>::UpdateError;
fn update(
&mut self,
rid: &RepoId,
id: &radicle_cob::ObjectId,
object: &Patch,
) -> Result<Self::Out, Self::UpdateError> {
self.cache.update(rid, id, object)
}
}
impl<R, C> cache::Remove<Patch> for Cache<R, C>
where
C: cache::Remove<Patch>,
{
type Out = <C as cache::Remove<Patch>>::Out;
type RemoveError = <C as cache::Remove<Patch>>::RemoveError;
fn remove(&mut self, id: &ObjectId) -> Result<Self::Out, Self::RemoveError> {
self.cache.remove(id)
}
}
#[derive(Debug, Error)]
pub enum UpdateError {
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Sql(#[from] sql::Error),
}
impl Update<Patch> for StoreWriter {
type Out = bool;
type UpdateError = UpdateError;
fn update(
&mut self,
rid: &RepoId,
id: &ObjectId,
object: &Patch,
) -> Result<Self::Out, Self::UpdateError> {
transaction::<_, UpdateError>(&self.db, move |db| {
let mut stmt = db.prepare(
"INSERT INTO patches (id, repo, patch)
VALUES (?1, ?2, ?3)
ON CONFLICT DO UPDATE
SET patch = (?3)",
)?;
stmt.bind((1, sql::Value::String(id.to_string())))?;
stmt.bind((2, rid))?;
stmt.bind((3, sql::Value::String(serde_json::to_string(&object)?)))?;
stmt.next()?;
Ok(db.change_count() > 0)
})
}
}
impl Remove<Patch> for StoreWriter {
type Out = bool;
type RemoveError = sql::Error;
fn remove(&mut self, id: &ObjectId) -> Result<Self::Out, Self::RemoveError> {
transaction::<_, sql::Error>(&self.db, move |db| {
let mut stmt = db.prepare(
"DELETE FROM patches
WHERE id = ?1",
)?;
stmt.bind((1, sql::Value::String(id.to_string())))?;
stmt.next()?;
Ok(db.change_count() > 0)
})
}
}
#[derive(Debug, Error)]
pub enum Error {
#[error("object `{1}` of type `{0}` was not found")]
NotFound(TypeName, ObjectId),
#[error(transparent)]
Object(#[from] cob::object::ParseObjectId),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Sql(#[from] sql::Error),
}
/// Iterator that returns a set of patches based on an SQL query.
///
/// The query is expected to return rows with columns identified by
/// the `id` and `patch` names.
pub struct PatchesIter<'a> {
inner: sql::CursorWithOwnership<'a>,
}
impl<'a> PatchesIter<'a> {
fn parse_row(row: sql::Row) -> Result<(PatchId, Patch), Error> {
let id = PatchId::from_str(row.read::<&str, _>("id"))?;
let patch = serde_json::from_str::<Patch>(row.read::<&str, _>("patch"))?;
Ok((id, patch))
}
}
impl<'a> Iterator for PatchesIter<'a> {
type Item = Result<(PatchId, Patch), Error>;
fn next(&mut self) -> Option<Self::Item> {
let row = self.inner.next()?;
Some(row.map_err(Error::from).and_then(PatchesIter::parse_row))
}
}
impl<R> Patches for Cache<R, StoreReader>
where
R: HasRepoId,
{
type Error = Error;
type Iter<'b> = PatchesIter<'b>
where
Self: 'b;
fn get(&self, id: &PatchId) -> Result<Option<Patch>, Self::Error> {
query::get(&self.cache.db, &self.rid(), id)
}
fn find_by_revision(&self, id: &RevisionId) -> Result<Option<ByRevision>, Error> {
query::find_by_revision(&self.cache.db, &self.rid(), id)
}
fn list(&self) -> Result<Self::Iter<'_>, Self::Error> {
query::list(&self.cache.db, &self.rid())
}
fn list_by_status(&self, status: &Status) -> Result<Self::Iter<'_>, Self::Error> {
query::list_by_status(&self.cache.db, &self.rid(), status)
}
fn counts(&self) -> Result<PatchCounts, Self::Error> {
query::counts(&self.cache.db, &self.rid())
}
}
pub struct NoCacheIter<'a> {
inner: Box<dyn Iterator<Item = Result<(PatchId, Patch), super::Error>> + 'a>,
}
impl<'a> Iterator for NoCacheIter<'a> {
type Item = Result<(PatchId, Patch), super::Error>;
fn next(&mut self) -> Option<Self::Item> {
self.inner.next()
}
}
impl<'a, R> Patches for Cache<super::Patches<'a, R>, cache::NoCache>
where
R: ReadRepository + cob::Store,
{
type Error = super::Error;
type Iter<'b> = NoCacheIter<'b> where Self: 'b;
fn get(&self, id: &PatchId) -> Result<Option<Patch>, Self::Error> {
self.store.get(id).map_err(super::Error::from)
}
fn find_by_revision(&self, id: &RevisionId) -> Result<Option<ByRevision>, Self::Error> {
self.store.find_by_revision(id)
}
fn list(&self) -> Result<Self::Iter<'_>, Self::Error> {
self.store
.all()
.map(|inner| NoCacheIter {
inner: Box::new(inner.into_iter().map(|res| res.map_err(super::Error::from))),
})
.map_err(super::Error::from)
}
fn list_by_status(&self, status: &Status) -> Result<Self::Iter<'_>, Self::Error> {
let status = *status;
self.store
.all()
.map(move |inner| NoCacheIter {
inner: Box::new(inner.into_iter().filter_map(move |res| {
match res {
Ok((id, patch)) => (status == Status::from(&patch.state))
.then_some((id, patch))
.map(Ok),
Err(e) => Some(Err(e.into())),
}
})),
})
.map_err(super::Error::from)
}
fn counts(&self) -> Result<PatchCounts, Self::Error> {
self.store.counts().map_err(super::Error::from)
}
}
impl<R> Patches for Cache<R, StoreWriter>
where
R: HasRepoId,
{
type Error = Error;
type Iter<'b> = PatchesIter<'b>
where
Self: 'b;
fn get(&self, id: &PatchId) -> Result<Option<Patch>, Self::Error> {
query::get(&self.cache.db, &self.rid(), id)
}
fn find_by_revision(&self, id: &RevisionId) -> Result<Option<ByRevision>, Error> {
query::find_by_revision(&self.cache.db, &self.rid(), id)
}
fn list(&self) -> Result<Self::Iter<'_>, Self::Error> {
query::list(&self.cache.db, &self.rid())
}
fn list_by_status(&self, status: &Status) -> Result<Self::Iter<'_>, Self::Error> {
query::list_by_status(&self.cache.db, &self.rid(), status)
}
fn counts(&self) -> Result<PatchCounts, Self::Error> {
query::counts(&self.cache.db, &self.rid())
}
}
/// Helper SQL queries for [ `Patches`] trait implementations.
mod query {
use sqlite as sql;
use crate::patch::Status;
use super::*;
pub(super) fn get(
db: &sql::ConnectionThreadSafe,
rid: &RepoId,
id: &PatchId,
) -> Result<Option<Patch>, Error> {
let id = sql::Value::String(id.to_string());
let mut stmt = db.prepare(
"SELECT patch
FROM patches
WHERE id = ?1 AND repo = ?2",
)?;
stmt.bind((1, id))?;
stmt.bind((2, rid))?;
match stmt.into_iter().next().transpose()? {
None => Ok(None),
Some(row) => {
let patch = row.read::<&str, _>("patch");
let patch = serde_json::from_str(patch)?;
Ok(Some(patch))
}
}
}
pub(super) fn find_by_revision(
db: &sql::ConnectionThreadSafe,
rid: &RepoId,
id: &RevisionId,
) -> Result<Option<ByRevision>, Error> {
let revision_id = *id;
let mut stmt = db.prepare(
"SELECT patches.id, patch, revisions.value AS revision
FROM patches, json_tree(patches.patch, '$.revisions') AS revisions
WHERE repo = ?1
AND revisions.key = ?2
",
)?;
stmt.bind((1, rid))?;
stmt.bind((2, sql::Value::String(id.to_string())))?;
match stmt.into_iter().next().transpose()? {
None => Ok(None),
Some(row) => {
let id = PatchId::from_str(row.read::<&str, _>("id"))?;
let patch = serde_json::from_str::<Patch>(row.read::<&str, _>("patch"))?;
let revision = serde_json::from_str::<Revision>(row.read::<&str, _>("revision"))?;
Ok(Some(ByRevision {
id,
patch,
revision_id,
revision,
}))
}
}
}
pub(super) fn list<'a>(
db: &'a sql::ConnectionThreadSafe,
rid: &RepoId,
) -> Result<PatchesIter<'a>, Error> {
let mut stmt = db.prepare(
"SELECT id, patch
FROM patches
WHERE repo = ?1
ORDER BY id
",
)?;
stmt.bind((1, rid))?;
Ok(PatchesIter {
inner: stmt.into_iter(),
})
}
pub(super) fn list_by_status<'a>(
db: &'a sql::ConnectionThreadSafe,
rid: &RepoId,
filter: &Status,
) -> Result<PatchesIter<'a>, Error> {
let mut stmt = db.prepare(
"SELECT patches.id, patch
FROM patches
WHERE repo = ?1
AND patch->>'$.state.status' = ?2
ORDER BY id
",
)?;
stmt.bind((1, rid))?;
stmt.bind((2, sql::Value::String(filter.to_string())))?;
Ok(PatchesIter {
inner: stmt.into_iter(),
})
}
pub(super) fn counts(
db: &sql::ConnectionThreadSafe,
rid: &RepoId,
) -> Result<PatchCounts, Error> {
let mut stmt = db.prepare(
"SELECT
patch->'$.state' AS state,
COUNT(*) AS count
FROM patches
WHERE repo = ?1
GROUP BY patch->'$.state.status'",
)?;
stmt.bind((1, rid))?;
stmt.into_iter()
.try_fold(PatchCounts::default(), |mut counts, row| {
let row = row?;
let count = row.read::<i64, _>("count") as usize;
let status = serde_json::from_str::<State>(row.read::<&str, _>("state"))?;
match status {
State::Draft => counts.draft += count,
State::Open { .. } => counts.open += count,
State::Archived => counts.archived += count,
State::Merged { .. } => counts.merged += count,
}
Ok(counts)
})
}
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU8;
use std::str::FromStr;
use amplify::Wrapper;
use radicle_cob::ObjectId;
use crate::cob::cache::{Store, Update, Write};
use crate::cob::thread::{Comment, Thread};
use crate::cob::{Author, Timestamp};
use crate::patch::{
ByRevision, MergeTarget, Patch, PatchCounts, PatchId, Revision, RevisionId, State, Status,
};
use crate::prelude::Did;
use crate::test::arbitrary;
use crate::test::storage::MockRepository;
use super::{Cache, Patches};
fn memory(store: MockRepository) -> Cache<MockRepository, Store<Write>> {
let cache = Store::<Write>::memory().unwrap();
Cache { store, cache }
}
fn revision() -> (RevisionId, Revision) {
let author = arbitrary::gen::<Did>(1);
let description = arbitrary::gen::<String>(1);
let base = arbitrary::oid();
let oid = arbitrary::oid();
let timestamp = Timestamp::now();
let resolves = BTreeSet::new();
let mut revision = Revision::new(
Author { id: author },
description,
base,
oid,
timestamp,
resolves,
);
let comment = Comment::new(
*author,
"#1 comment".to_string(),
None,
None,
vec![],
Timestamp::now(),
);
let thread = Thread::new(arbitrary::oid(), comment);
revision.discussion = thread;
let id = RevisionId::from(arbitrary::oid());
(id, revision)
}
#[test]
fn test_is_empty() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
assert!(cache.is_empty().unwrap());
let patch = Patch::new("Patch #1".to_string(), MergeTarget::Delegates, revision());
let id = ObjectId::from_str("47799cbab2eca047b6520b9fce805da42b49ecab").unwrap();
cache.update(&cache.rid(), &id, &patch).unwrap();
let patch = Patch {
state: State::Archived,
..Patch::new("Patch #2".to_string(), MergeTarget::Delegates, revision())
};
let id = ObjectId::from_str("ae981ded6ed2ed2cdba34c8603714782667f18a3").unwrap();
cache.update(&cache.rid(), &id, &patch).unwrap();
assert!(!cache.is_empty().unwrap())
}
#[test]
fn test_counts() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
let n_open = arbitrary::gen::<u8>(0);
let n_draft = arbitrary::gen::<u8>(1);
let n_archived = arbitrary::gen::<u8>(1);
let n_merged = arbitrary::gen::<u8>(1);
let open_ids = (0..n_open)
.map(|_| PatchId::from(arbitrary::oid()))
.collect::<BTreeSet<PatchId>>();
let draft_ids = (0..n_draft)
.map(|_| PatchId::from(arbitrary::oid()))
.collect::<BTreeSet<PatchId>>();
let archived_ids = (0..n_archived)
.map(|_| PatchId::from(arbitrary::oid()))
.collect::<BTreeSet<PatchId>>();
let merged_ids = (0..n_merged)
.map(|_| PatchId::from(arbitrary::oid()))
.collect::<BTreeSet<PatchId>>();
for id in open_ids.iter() {
let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
cache
.update(&cache.rid(), &PatchId::from(*id), &patch)
.unwrap();
}
for id in draft_ids.iter() {
let patch = Patch {
state: State::Draft,
..Patch::new(id.to_string(), MergeTarget::Delegates, revision())
};
cache
.update(&cache.rid(), &PatchId::from(*id), &patch)
.unwrap();
}
for id in archived_ids.iter() {
let patch = Patch {
state: State::Archived,
..Patch::new(id.to_string(), MergeTarget::Delegates, revision())
};
cache
.update(&cache.rid(), &PatchId::from(*id), &patch)
.unwrap();
}
for id in merged_ids.iter() {
let patch = Patch {
state: State::Merged {
revision: arbitrary::oid().into(),
commit: arbitrary::oid(),
},
..Patch::new(id.to_string(), MergeTarget::Delegates, revision())
};
cache
.update(&cache.rid(), &PatchId::from(*id), &patch)
.unwrap();
}
assert_eq!(
cache.counts().unwrap(),
PatchCounts {
open: open_ids.len(),
draft: draft_ids.len(),
archived: archived_ids.len(),
merged: merged_ids.len(),
}
);
}
#[test]
fn test_get() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
let ids = (0..arbitrary::gen::<u8>(1))
.map(|_| PatchId::from(arbitrary::oid()))
.collect::<BTreeSet<PatchId>>();
let missing = (0..arbitrary::gen::<u8>(2))
.filter_map(|_| {
let id = PatchId::from(arbitrary::oid());
(!ids.contains(&id)).then_some(id)
})
.collect::<BTreeSet<PatchId>>();
let mut patches = Vec::with_capacity(ids.len());
for id in ids.iter() {
let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
cache
.update(&cache.rid(), &PatchId::from(*id), &patch)
.unwrap();
patches.push((*id, patch));
}
for (id, patch) in patches.into_iter() {
assert_eq!(Some(patch), cache.get(&id).unwrap());
}
for id in &missing {
assert_eq!(cache.get(id).unwrap(), None);
}
}
#[test]
fn test_find_by_revision() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
let patch_id = PatchId::from(arbitrary::oid());
let revisions = (0..arbitrary::gen::<NonZeroU8>(1).into())
.map(|_| revision())
.collect::<BTreeMap<RevisionId, Revision>>();
let (rev_id, rev) = revisions
.iter()
.next()
.expect("at least one revision should have been created");
let mut patch = Patch::new(
patch_id.to_string(),
MergeTarget::Delegates,
(*rev_id, rev.clone()),
);
let timeline = revisions.keys().copied().collect::<Vec<_>>();
patch
.timeline
.extend(timeline.iter().map(|id| id.into_inner()));
patch
.revisions
.extend(revisions.iter().map(|(id, rev)| (*id, Some(rev.clone()))));
cache
.update(&cache.rid(), &PatchId::from(*patch_id), &patch)
.unwrap();
for entry in timeline {
let rev = revisions.get(&entry).unwrap().clone();
assert_eq!(
Some(ByRevision {
id: patch_id,
patch: patch.clone(),
revision_id: entry,
revision: rev
}),
cache.find_by_revision(&entry).unwrap()
);
}
}
#[test]
fn test_list() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
let ids = (0..arbitrary::gen::<u8>(1))
.map(|_| PatchId::from(arbitrary::oid()))
.collect::<BTreeSet<PatchId>>();
let mut patches = Vec::with_capacity(ids.len());
for id in ids.iter() {
let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
cache
.update(&cache.rid(), &PatchId::from(*id), &patch)
.unwrap();
patches.push((*id, patch));
}
let mut list = cache
.list()
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
list.sort_by_key(|(id, _)| *id);
patches.sort_by_key(|(id, _)| *id);
assert_eq!(patches, list);
}
#[test]
fn test_list_by_status() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
let ids = (0..arbitrary::gen::<u8>(1))
.map(|_| PatchId::from(arbitrary::oid()))
.collect::<BTreeSet<PatchId>>();
let mut patches = Vec::with_capacity(ids.len());
for id in ids.iter() {
let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
cache
.update(&cache.rid(), &PatchId::from(*id), &patch)
.unwrap();
patches.push((*id, patch));
}
let mut list = cache
.list_by_status(&Status::Open)
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
list.sort_by_key(|(id, _)| *id);
patches.sort_by_key(|(id, _)| *id);
assert_eq!(patches, list);
}
#[test]
fn test_remove() {
let repo = arbitrary::gen::<MockRepository>(1);
let mut cache = memory(repo);
let ids = (0..arbitrary::gen::<u8>(1))
.map(|_| PatchId::from(arbitrary::oid()))
.collect::<BTreeSet<PatchId>>();
for id in ids.iter() {
let patch = Patch::new(id.to_string(), MergeTarget::Delegates, revision());
cache
.update(&cache.rid(), &PatchId::from(*id), &patch)
.unwrap();
assert_eq!(Some(patch), cache.get(id).unwrap());
super::Remove::remove(&mut cache, id).unwrap();
assert_eq!(None, cache.get(id).unwrap());
}
}
}

View File

@ -5,6 +5,7 @@ use std::fmt::Debug;
use std::marker::PhantomData;
use nonempty::NonEmpty;
use radicle_cob::CollaborativeObject;
use serde::{Deserialize, Serialize};
use crate::cob::op::Op;
@ -112,7 +113,10 @@ impl<'a, T, R> AsRef<R> for Store<'a, T, R> {
}
}
impl<'a, T, R: ReadRepository + cob::Store> Store<'a, T, R> {
impl<'a, T, R> Store<'a, T, R>
where
R: ReadRepository + cob::Store,
{
/// Open a new generic store.
pub fn open(repo: &'a R) -> Result<Self, Error> {
Ok(Self {
@ -234,7 +238,9 @@ where
}
/// Return all objects.
pub fn all(&self) -> Result<impl Iterator<Item = Result<(ObjectId, T), Error>> + 'a, Error> {
pub fn all(
&self,
) -> Result<impl ExactSizeIterator<Item = Result<(ObjectId, T), Error>> + 'a, Error> {
let raw = cob::list::<T, _>(self.repo, T::type_name())?;
Ok(raw.into_iter().map(|o| Ok((*o.id(), o.object))))
@ -271,7 +277,10 @@ impl<T: Cob + cob::Evaluate<R>, R> Default for Transaction<T, R> {
}
}
impl<T: Cob + cob::Evaluate<R>, R> Transaction<T, R> {
impl<T, R> Transaction<T, R>
where
T: Cob + cob::Evaluate<R>,
{
/// Create a new transaction to be used as the initial set of operations for a COB.
pub fn initial<G, F>(
message: &str,
@ -324,9 +333,13 @@ impl<T: Cob + cob::Evaluate<R>, R> Transaction<T, R> {
{
let actions = NonEmpty::from_vec(self.actions)
.expect("Transaction::commit: transaction must not be empty");
let Updated { head, object, .. } = store.update(id, msg, actions, self.embeds, signer)?;
let Updated {
head,
object: CollaborativeObject { object, .. },
..
} = store.update(id, msg, actions, self.embeds, signer)?;
Ok((object.object, head))
Ok((object, head))
}
}

View File

@ -27,7 +27,8 @@ use crate::prelude::Did;
use crate::prelude::NodeId;
use crate::storage::git::transport;
use crate::storage::git::Storage;
use crate::{cli, git, node, web};
use crate::storage::{self, ReadRepository};
use crate::{cli, cob, git, node, web};
/// Environment variables used by radicle.
pub mod env {
@ -104,6 +105,10 @@ pub enum Error {
NotificationsStore(#[from] node::notifications::store::Error),
#[error(transparent)]
DatabaseStore(#[from] node::db::Error),
#[error(transparent)]
Repository(#[from] storage::RepositoryError),
#[error(transparent)]
CobsCache(#[from] cob::cache::Error),
}
#[derive(Debug, Error)]
@ -410,7 +415,7 @@ impl Home {
path: path.canonicalize()?,
};
for dir in &[home.storage(), home.keys(), home.node()] {
for dir in &[home.storage(), home.keys(), home.node(), home.cobs()] {
if !dir.exists() {
fs::create_dir_all(dir)?;
}
@ -439,6 +444,10 @@ impl Home {
self.path.join("node")
}
pub fn cobs(&self) -> PathBuf {
self.path.join("cobs")
}
pub fn socket(&self) -> PathBuf {
env::var_os(env::RAD_SOCKET)
.map(PathBuf::from)
@ -478,6 +487,62 @@ impl Home {
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 path = self.cobs().join(cob::cache::COBS_DB_FILE);
let db = cob::cache::Store::reader(path)?;
let store = cob::issue::Issues::open(repository)?;
Ok(cob::issue::Cache::reader(store, db))
}
/// Return a read-write handle for the issues cache.
pub fn issues_mut<'a, R>(
&self,
repository: &'a R,
) -> Result<cob::issue::Cache<cob::issue::Issues<'a, R>, cob::cache::StoreWriter>, Error>
where
R: ReadRepository + cob::Store,
{
let path = self.cobs().join(cob::cache::COBS_DB_FILE);
let db = cob::cache::Store::open(path)?;
let store = cob::issue::Issues::open(repository)?;
Ok(cob::issue::Cache::open(store, db))
}
/// Return a read-only handle for the patches cache.
pub fn patches<'a, R>(
&self,
repository: &'a R,
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, Error>
where
R: ReadRepository + cob::Store,
{
let path = self.cobs().join(cob::cache::COBS_DB_FILE);
let db = cob::cache::Store::reader(path)?;
let store = cob::patch::Patches::open(repository)?;
Ok(cob::patch::Cache::reader(store, db))
}
/// Return a read-write handle for the patches cache.
pub fn patches_mut<'a, R>(
&self,
repository: &'a R,
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreWriter>, Error>
where
R: ReadRepository + cob::Store,
{
let path = self.cobs().join(cob::cache::COBS_DB_FILE);
let db = cob::cache::Store::open(path)?;
let store = cob::patch::Patches::open(repository)?;
Ok(cob::patch::Cache::open(store, db))
}
}
// Private methods.

View File

@ -404,6 +404,17 @@ pub trait WriteStorage: ReadStorage {
fn clean(&self, rid: RepoId) -> Result<Vec<RemoteId>, RepositoryError>;
}
/// Anything can return the [`RepoId`] that it is associated with.
pub trait HasRepoId {
fn rid(&self) -> RepoId;
}
impl<T: ReadRepository> HasRepoId for T {
fn rid(&self) -> RepoId {
ReadRepository::id(self)
}
}
/// Allows read-only access to a repository.
pub trait ReadRepository: Sized + ValidateRepository {
/// Return the repository id.