Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle: implement caching for issues and patches
Fintan Halpenny committed 2 years ago
commit 985b0af3f6230a0d36e0b413f42dc9a3c1522fe6
parent b26842d4d80c45f969fd244a4a740109316945ed
44 files changed +2632 -289
modified radicle-cli/src/commands/clone.rs
@@ -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(),
modified radicle-cli/src/commands/inbox.rs
@@ -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)?;
modified radicle-cli/src/commands/issue.rs
@@ -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();
modified radicle-cli/src/commands/patch.rs
@@ -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)?
modified radicle-cli/src/commands/patch/archive.rs
@@ -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");
    };
modified radicle-cli/src/commands/patch/assign.rs
@@ -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");
    };
modified radicle-cli/src/commands/patch/checkout.rs
@@ -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"))?;
modified radicle-cli/src/commands/patch/comment.rs
@@ -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);
modified radicle-cli/src/commands/patch/delete.rs
@@ -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(())
modified radicle-cli/src/commands/patch/diff.rs
@@ -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");
    };
modified radicle-cli/src/commands/patch/edit.rs
@@ -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,
modified radicle-cli/src/commands/patch/label.rs
@@ -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");
    };
modified radicle-cli/src/commands/patch/list.rs
@@ -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;
modified radicle-cli/src/commands/patch/ready.rs
@@ -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");
    };
modified radicle-cli/src/commands/patch/redact.rs
@@ -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 {
modified radicle-cli/src/commands/patch/review.rs
@@ -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"))?;
modified radicle-cli/src/commands/patch/show.rs
@@ -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");
    };
modified radicle-cli/src/commands/patch/update.rs
@@ -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
modified radicle-cli/src/commands/stats.rs
@@ -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();
modified radicle-cli/tests/commands.rs
@@ -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..");
modified radicle-httpd/src/api.rs
@@ -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();

modified radicle-httpd/src/api/error.rs
@@ -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()))
modified radicle-httpd/src/api/v1/delegates.rs
@@ -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 {
modified radicle-httpd/src/api/v1/projects.rs
@@ -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(
modified radicle-httpd/src/test.rs
@@ -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
modified radicle-node/src/runtime.rs
@@ -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),
modified radicle-node/src/test/environment.rs
@@ -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()
modified radicle-node/src/tests.rs
@@ -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();
modified radicle-node/src/worker.rs
@@ -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());

modified radicle-node/src/worker/fetch.rs
@@ -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(())
+
}
modified radicle-node/src/worker/fetch/error.rs
@@ -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)]
modified radicle-remote-helper/src/lib.rs
@@ -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)?;
modified radicle-remote-helper/src/list.rs
@@ -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;
modified radicle-remote-helper/src/push.rs
@@ -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,
modified radicle-tools/src/rad-merge.rs
@@ -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() {
modified radicle/src/cob.rs
@@ -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) => {
modified radicle/src/cob/cache.rs
@@ -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
+
    }
+
}
modified radicle/src/cob/issue.rs
@@ -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",
added radicle/src/cob/issue/cache.rs
@@ -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());
+
        }
+
    }
+
}
modified radicle/src/cob/patch.rs
@@ -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;
@@ -2454,11 +2676,53 @@ 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",
added radicle/src/cob/patch/cache.rs
@@ -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());
+
        }
+
    }
+
}
modified radicle/src/cob/store.rs
@@ -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))
    }
}

modified radicle/src/profile.rs
@@ -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.
modified radicle/src/storage.rs
@@ -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.