Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Implement COB caching
Merged fintohaps opened 2 years ago

This patch implements caching for COBs by using an SQLite database that is used as a write-thru cache. All reads are replaced with using the cache so as to avoid recomputing the COB graph each time.

52 files changed +3167 -315 9e745b68 9e745b68
modified radicle-cli/examples/rad-inbox.md
@@ -21,7 +21,7 @@ $ rad inbox --sort-by id
╭──────────────────────────────────────────────────────────────────────╮
│ heartwood                                                            │
├──────────────────────────────────────────────────────────────────────┤
-
│ 001   ●   58fff44    No license file    issue    open      bob   now │
+
│ 001   ●   [ ... ]    No license file    issue    open      bob   now │
│ 002   ●   bob/copy   Change copyright   branch   created   bob   now │
╰──────────────────────────────────────────────────────────────────────╯
```
@@ -31,12 +31,12 @@ $ rad inbox --all --sort-by id
╭────────────────────────────────────────────────────────────────╮
│ radicle-git                                                    │
├────────────────────────────────────────────────────────────────┤
-
│ 003   ●   4dd5843   Copyright fixes   patch   open   bob   now │
+
│ 003   ●   [ ... ]   Copyright fixes   patch   open   bob   now │
╰────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────╮
│ heartwood                                                            │
├──────────────────────────────────────────────────────────────────────┤
-
│ 001   ●   58fff44    No license file    issue    open      bob   now │
+
│ 001   ●   [ ... ]    No license file    issue    open      bob   now │
│ 002   ●   bob/copy   Change copyright   branch   created   bob   now │
╰──────────────────────────────────────────────────────────────────────╯
```
@@ -67,7 +67,7 @@ $ rad inbox list --sort-by id
╭──────────────────────────────────────────────────────────────────────╮
│ heartwood                                                            │
├──────────────────────────────────────────────────────────────────────┤
-
│ 001   ●   58fff44    No license file    issue    open      bob   now │
+
│ 001   ●   [ ... ]    No license file    issue    open      bob   now │
│ 002       bob/copy   Change copyright   branch   created   bob   now │
╰──────────────────────────────────────────────────────────────────────╯
```
@@ -93,7 +93,7 @@ $ rad inbox --all
╭────────────────────────────────────────────────────────────────╮
│ radicle-git                                                    │
├────────────────────────────────────────────────────────────────┤
-
│ 003   ●   4dd5843   Copyright fixes   patch   open   bob   now │
+
│ 003   ●   [ ... ]   Copyright fixes   patch   open   bob   now │
╰────────────────────────────────────────────────────────────────╯
```

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
@@ -4,10 +4,10 @@ use std::process;
use anyhow::anyhow;

use localtime::LocalTime;
-
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};
@@ -231,8 +231,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 {
@@ -384,13 +384,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
@@ -1,3 +1,6 @@
+
#[path = "issue/cache.rs"]
+
mod cache;
+

use std::collections::BTreeSet;
use std::ffi::OsString;
use std::str::FromStr;
@@ -6,13 +9,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};

@@ -43,6 +47,7 @@ Usage
    rad issue comment <issue-id> [--message <message>] [--reply-to <comment-id>] [<option>...]
    rad issue show <issue-id> [<option>...]
    rad issue state <issue-id> [--closed | --open | --solved] [<option>...]
+
    rad issue cache [<issue-id>] [<option>...]

Assign options

@@ -84,6 +89,7 @@ pub enum OperationName {
    React,
    Show,
    State,
+
    Cache,
}

/// Command line Peer argument.
@@ -141,6 +147,9 @@ pub enum Operation {
        assigned: Option<Assigned>,
        state: Option<State>,
    },
+
    Cache {
+
        id: Option<Rev>,
+
    },
}

#[derive(Debug, Default, PartialEq, Eq)]
@@ -340,6 +349,7 @@ impl Args for Options {
                    "s" | "state" => op = Some(OperationName::State),
                    "assign" => op = Some(OperationName::Assign),
                    "label" => op = Some(OperationName::Label),
+
                    "cache" => op = Some(OperationName::Cache),

                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
@@ -396,6 +406,7 @@ impl Args for Options {
                opts: label_opts,
            },
            OperationName::List => Operation::List { assigned, state },
+
            OperationName::Cache => Operation::Cache { id },
        };

        Ok((
@@ -425,7 +436,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,12 +557,16 @@ 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)?;
            issues.remove(&id, &signer)?;
        }
+
        Operation::Cache { id } => {
+
            let id = id.map(|id| id.resolve(&repo.backend)).transpose()?;
+
            cache::run(id, &repo, &profile)?;
+
        }
    }

    if announce {
@@ -562,13 +577,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 +598,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 +683,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 +704,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 +719,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();
added radicle-cli/src/commands/issue/cache.rs
@@ -0,0 +1,28 @@
+
use std::ops::ControlFlow;
+

+
use radicle::issue::IssueId;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
use crate::terminal as term;
+

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

+
    match id {
+
        Some(id) => {
+
            issues.write(&id)?;
+
            term::success!("Successfully cached issue `{id}`");
+
        }
+
        None => issues.write_all(|result, progress| {
+
            match result {
+
                Ok((id, _)) => term::success!("Successfully cached issue `{id}`"),
+
                Err(e) => term::warning(format!("Failed to retrieve issue: {e}")),
+
            };
+
            term::info!("Cached {} of {}", progress.seen(), progress.total());
+
            ControlFlow::Continue(())
+
        })?,
+
    }
+

+
    Ok(())
+
}
modified radicle-cli/src/commands/patch.rs
@@ -2,6 +2,8 @@
mod archive;
#[path = "patch/assign.rs"]
mod assign;
+
#[path = "patch/cache.rs"]
+
mod cache;
#[path = "patch/checkout.rs"]
mod checkout;
#[path = "patch/comment.rs"]
@@ -34,6 +36,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};

@@ -66,6 +69,7 @@ Usage
    rad patch edit <patch-id> [<option>...]
    rad patch set <patch-id> [<option>...]
    rad patch comment <patch-id | revision-id> [<option>...]
+
    rad patch cache [<patch-id>] [<option>...]

Show options

@@ -165,6 +169,7 @@ pub enum OperationName {
    Edit,
    Redact,
    Set,
+
    Cache,
}

#[derive(Debug, Default, PartialEq, Eq)]
@@ -179,27 +184,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 +234,7 @@ pub enum Operation {
        opts: LabelOptions,
    },
    List {
-
        filter: Filter,
+
        filter: Option<patch::Status>,
    },
    Edit {
        patch_id: Rev,
@@ -263,6 +247,9 @@ pub enum Operation {
    Set {
        patch_id: Rev,
    },
+
    Cache {
+
        patch_id: Option<Rev>,
+
    },
}

impl Operation {
@@ -282,7 +269,8 @@ impl Operation {
            Operation::Show { .. }
            | Operation::Diff { .. }
            | Operation::Checkout { .. }
-
            | Operation::List { .. } => false,
+
            | Operation::List { .. }
+
            | Operation::Cache { .. } => false,
        }
    }
}
@@ -311,7 +299,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 +475,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;
@@ -538,6 +526,7 @@ impl Args for Options {
                    "comment" => op = Some(OperationName::Comment),
                    "review" => op = Some(OperationName::Review),
                    "set" => op = Some(OperationName::Set),
+
                    "cache" => op = Some(OperationName::Cache),
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
                Value(val) if op == Some(OperationName::Redact) => {
@@ -560,6 +549,7 @@ impl Args for Options {
                            Some(OperationName::Set),
                            Some(OperationName::Assign),
                            Some(OperationName::Label),
+
                            Some(OperationName::Cache),
                        ]
                        .contains(&op) =>
                {
@@ -635,6 +625,7 @@ impl Args for Options {
            OperationName::Set => Operation::Set {
                patch_id: patch_id.ok_or_else(|| anyhow!("a patch must be provided"))?,
            },
+
            OperationName::Cache => Operation::Cache { patch_id },
        };

        Ok((
@@ -662,12 +653,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 +685,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 +733,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                revision_id,
                &repository,
                &workdir,
+
                &profile,
                opts,
            )?;
        }
@@ -801,7 +793,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)?
@@ -809,6 +801,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

            radicle::rad::setup_patch_upstream(&patch_id, *patch.head(), &workdir, true)?;
        }
+
        Operation::Cache { patch_id } => {
+
            let patch_id = patch_id
+
                .map(|id| id.resolve(&repository.backend))
+
                .transpose()?;
+
            cache::run(patch_id, &repository, &profile)?;
+
        }
    }

    if announce {
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");
    };
added radicle-cli/src/commands/patch/cache.rs
@@ -0,0 +1,28 @@
+
use std::ops::ControlFlow;
+

+
use radicle::patch::PatchId;
+
use radicle::storage::git::Repository;
+
use radicle::Profile;
+

+
use crate::terminal as term;
+

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

+
    match id {
+
        Some(id) => {
+
            patches.write(&id)?;
+
            term::success!("Successfully cached patch `{id}`");
+
        }
+
        None => patches.write_all(|result, progress| {
+
            match result {
+
                Ok((id, _)) => term::success!("Successfully cached patch `{id}`"),
+
                Err(e) => term::warning(format!("Failed to retrieve patch: {e}")),
+
            };
+
            term::info!("Cached {} of {}", progress.seen(), progress.total());
+
            ControlFlow::Continue(())
+
        })?,
+
    }
+

+
    Ok(())
+
}
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-cob/src/trailers.rs
@@ -61,18 +61,18 @@ impl TryFrom<&OwnedTrailer> for CommitTrailer {
impl From<CommitTrailer> for Trailer<'_> {
    fn from(t: CommitTrailer) -> Self {
        match t {
+
            #[allow(clippy::unwrap_used)]
            CommitTrailer::Related(oid) => {
                Trailer {
                    // SAFETY: "Rad-Related" is a valid `Token`.
-
                    #[allow(clippy::unwrap_used)]
                    token: Token::try_from("Rad-Related").unwrap(),
                    value: oid.to_string().into(),
                }
            }
+
            #[allow(clippy::unwrap_used)]
            CommitTrailer::Resource(oid) => {
                Trailer {
                    // SAFETY: "Rad-Resource" is a valid `Token`.
-
                    #[allow(clippy::unwrap_used)]
                    token: Token::try_from("Rad-Resource").unwrap(),
                    value: oid.to_string().into(),
                }
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/json.rs
@@ -213,7 +213,7 @@ fn issue_comment(id: &CommentId, comment: &Comment, aliases: &impl AliasStore) -
        ).collect::<Vec<_>>(),
        "timestamp": comment.timestamp().as_secs(),
        "replyTo": comment.reply_to(),
-
        "resolved": comment.resolved(),
+
        "resolved": comment.is_resolved(),
    })
}

@@ -235,7 +235,7 @@ fn patch_comment(
        "timestamp": comment.timestamp().as_secs(),
        "replyTo": comment.reply_to(),
        "location": comment.location(),
-
        "resolved": comment.resolved(),
+
        "resolved": comment.is_resolved(),
    })
}

@@ -257,7 +257,7 @@ fn review_comment(
        "timestamp": comment.timestamp().as_secs(),
        "replyTo": comment.reply_to(),
        "location": comment.location(),
-
        "resolved": comment.resolved(),
+
        "resolved": comment.is_resolved(),
    })
}

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};
@@ -119,13 +122,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 {
@@ -580,9 +583,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))
@@ -632,7 +635,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,
@@ -665,7 +668,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 {
@@ -716,7 +719,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();
@@ -749,7 +754,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
@@ -788,7 +793,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)?,
@@ -933,9 +938,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))
@@ -961,9 +966,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(),
@@ -189,3 +194,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
@@ -1,4 +1,5 @@
#![warn(clippy::unwrap_used)]
+
pub mod cache;
pub mod common;
pub mod identity;
pub mod issue;
@@ -18,3 +19,71 @@ pub use radicle_cob::{
    Store, TypeName, Update, Updated, Version,
};
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)]
+
    TypeName(#[from] TypeNameParse),
+
    #[error(transparent)]
+
    ObjectId(#[from] object::ParseObjectId),
+
}
+

+
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_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) => {
+
                let Some(id) = id.next() else {
+
                    return Ok(None);
+
                };
+
                Ok(Some(Self {
+
                    id: id.parse()?,
+
                    type_name: type_name.parse()?,
+
                }))
+
            }
+
            _ => Ok(None),
+
        }
+
    }
+
}
added radicle/src/cob/cache.rs
@@ -0,0 +1,314 @@
+
use std::collections::HashMap;
+
use std::convert::Infallible;
+
use std::fmt;
+
use std::marker::PhantomData;
+
use std::path::Path;
+
use std::sync::Arc;
+
use std::time;
+

+
use radicle_cob::ObjectId;
+
use sqlite as sql;
+
use thiserror::Error;
+

+
use crate::prelude::RepoId;
+
use crate::sql::transaction;
+

+
/// File suffix for storing the COBs database file.
+
pub const COBS_DB_FILE: &str = "cache.db";
+

+
/// How long to wait for the database lock to be released before failing a read.
+
const DB_READ_TIMEOUT: time::Duration = time::Duration::from_secs(3);
+
/// How long to wait for the database lock to be released before failing a write.
+
const DB_WRITE_TIMEOUT: time::Duration = time::Duration::from_secs(6);
+

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

+
#[derive(Error, Debug)]
+
pub enum Error {
+
    /// An Internal error.
+
    #[error("internal error: {0}")]
+
    Internal(#[from] sql::Error),
+
    /// No rows returned in query result.
+
    #[error("no rows returned")]
+
    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;
+
/// Read-write type witness.
+
#[derive(Clone)]
+
pub struct Write;
+

+
/// A file-backed database storing information about the network.
+
#[derive(Clone)]
+
pub struct Store<T> {
+
    pub(super) db: Arc<sql::ConnectionThreadSafe>,
+
    marker: PhantomData<T>,
+
}
+

+
impl<T> fmt::Debug for Store<T> {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        f.debug_struct("Database").finish()
+
    }
+
}
+

+
impl Store<Read> {
+
    /// Same as [`Self::open`], but in read-only mode. This is useful to have multiple
+
    /// open databases, as no locking is required.
+
    pub fn reader<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
+
        let mut db = sql::Connection::open_thread_safe_with_flags(
+
            path,
+
            sqlite::OpenFlags::new().with_read_only(),
+
        )?;
+
        db.set_busy_timeout(DB_READ_TIMEOUT.as_millis() as usize)?;
+

+
        Ok(Self {
+
            db: Arc::new(db),
+
            marker: PhantomData,
+
        })
+
    }
+

+
    /// Create a new in-memory database.
+
    pub fn memory() -> Result<Self, Error> {
+
        let mut db = sql::Connection::open_thread_safe_with_flags(
+
            ":memory:",
+
            sqlite::OpenFlags::new().with_read_only(),
+
        )?;
+
        db.set_busy_timeout(DB_READ_TIMEOUT.as_millis() as usize)?;
+

+
        Ok(Self {
+
            db: Arc::new(db),
+
            marker: PhantomData,
+
        })
+
    }
+
}
+

+
impl Store<Write> {
+
    /// Open a database at the given path. Creates a new database if it
+
    /// doesn't exist.
+
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
+
        let mut db = sql::Connection::open_thread_safe(path)?;
+
        db.set_busy_timeout(DB_WRITE_TIMEOUT.as_millis() as usize)?;
+
        migrate(&db)?;
+

+
        Ok(Self {
+
            db: Arc::new(db),
+
            marker: PhantomData,
+
        })
+
    }
+

+
    /// Create a new in-memory database.
+
    pub fn memory() -> Result<Self, Error> {
+
        let db = Arc::new(sql::Connection::open_thread_safe(":memory:")?);
+
        migrate(&db)?;
+

+
        Ok(Self {
+
            db,
+
            marker: PhantomData,
+
        })
+
    }
+

+
    /// Turn this handle into a read-only handle.
+
    pub fn read_only(self) -> Store<Read> {
+
        Store {
+
            db: self.db,
+
            marker: PhantomData,
+
        }
+
    }
+

+
    /// Perform a raw query on the database handle.
+
    pub fn raw_query<T, E, F>(&self, query: F) -> Result<T, E>
+
    where
+
        F: FnOnce(&sql::Connection) -> Result<T, E>,
+
        E: From<sql::Error>,
+
    {
+
        transaction(&self.db, query)
+
    }
+
}
+

+
impl<T> Store<T> {
+
    /// Get the database version. This is updated on schema changes.
+
    pub fn version(&self) -> Result<usize, Error> {
+
        version(&self.db)
+
    }
+
}
+

+
/// Get the `user_version` value from the database header.
+
pub fn version(db: &sql::Connection) -> Result<usize, Error> {
+
    let version = db
+
        .prepare("PRAGMA user_version")?
+
        .into_iter()
+
        .next()
+
        .ok_or(Error::NoRows)??
+
        .read::<i64, _>(0);
+

+
    Ok(version as usize)
+
}
+

+
/// Bump the `user_version` value.
+
fn bump(db: &sql::Connection) -> Result<usize, Error> {
+
    let old = version(db)?;
+
    let new = old + 1;
+

+
    db.execute(format!("PRAGMA user_version = {new}"))?;
+

+
    Ok(new as usize)
+
}
+

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

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

+
/// Update a COB object in the cache.
+
pub trait Update<T> {
+
    /// The output type, if any, for a successful update.
+
    type Out;
+
    type UpdateError: std::error::Error + Send + Sync + 'static;
+

+
    fn update(
+
        &mut self,
+
        rid: &RepoId,
+
        id: &ObjectId,
+
        object: &T,
+
    ) -> Result<Self::Out, Self::UpdateError>;
+
}
+

+
/// Remove a COB object in the cache.
+
pub trait Remove<T> {
+
    /// The output type, if any, for a successful removal.
+
    type Out;
+
    type RemoveError: std::error::Error + Send + Sync + 'static;
+

+
    /// 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.
+
///
+
/// The intention is for this to be used in tests that expect cache
+
/// reads.
+
#[derive(Clone, Debug)]
+
pub struct InMemory<T> {
+
    inner: HashMap<RepoId, HashMap<ObjectId, T>>,
+
}
+

+
impl<T> Default for InMemory<T> {
+
    fn default() -> Self {
+
        Self {
+
            inner: HashMap::new(),
+
        }
+
    }
+
}
+

+
impl<T> Update<T> for InMemory<T>
+
where
+
    T: Clone,
+
{
+
    type Out = Option<T>;
+
    type UpdateError = Infallible;
+

+
    fn update(
+
        &mut self,
+
        rid: &RepoId,
+
        id: &ObjectId,
+
        object: &T,
+
    ) -> Result<Self::Out, Self::UpdateError> {
+
        let objects = self.inner.entry(*rid).or_default();
+
        Ok(objects.insert(*id, object.clone()))
+
    }
+
}
+

+
/// The `/dev/null` of caches.
+
///
+
/// It will ignore any updates, and successfully return on each call
+
/// of [`Update::update`].
+
///
+
/// The intention is for this to be used in tests that do not expect
+
/// any cache reads.
+
pub struct NoCache;
+

+
impl<T> Update<T> for NoCache {
+
    type Out = ();
+
    type UpdateError = Infallible;
+

+
    fn update(
+
        &mut self,
+
        _rid: &RepoId,
+
        _id: &ObjectId,
+
        _object: &T,
+
    ) -> Result<Self::Out, Self::UpdateError> {
+
        Ok(())
+
    }
+
}
+

+
impl<T> Remove<T> for NoCache {
+
    type Out = ();
+
    type RemoveError = Infallible;
+

+
    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
+
    }
+
}
added radicle/src/cob/cache/migrations/1.sql
@@ -0,0 +1,19 @@
+
-- Issues
+
create table if not exists "issues" (
+
       -- Issue ID
+
       "id"            text      primary key not null,
+
       -- Repository ID
+
       "repo"          text      not null,
+
       -- Issue in JSON format
+
       "issue"         text      not null
+
) strict;
+

+
-- Patches
+
create table if not exists "patches" (
+
       -- Patch ID
+
       "id"            text      primary key not null,
+
       -- Repository ID
+
       "repo"          text      not null,
+
       -- Patch in JSON format
+
       "patch"         text      not null
+
) strict;
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.
@@ -106,7 +122,8 @@ impl State {
}

/// Issue state. Accumulates [`Action`].
-
#[derive(Debug, Clone, PartialEq, Eq)]
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
pub struct Issue {
    /// Actors assigned to this issue.
    pub(super) assignees: BTreeSet<Did>,
@@ -397,8 +414,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)
    }
}
@@ -511,13 +528,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)
@@ -526,9 +544,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> {
@@ -647,13 +666,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 {
@@ -673,8 +698,17 @@ 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(Default, Serialize)]
+
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueCounts {
    pub open: usize,
@@ -705,35 +739,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.assign(assignees.to_owned())?;
@@ -742,11 +762,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,
        })
    }

@@ -765,11 +826,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.
@@ -842,16 +898,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",
@@ -937,7 +993,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));
@@ -976,7 +1032,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));
@@ -985,7 +1041,7 @@ mod test {
                "My first issue",
                "Blah blah blah.",
                &[],
-
                &[assignee],
+
                &[assignee, assignee_two],
                [],
                &node.signer,
            )
@@ -1009,7 +1065,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",
@@ -1035,7 +1091,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",
@@ -1075,7 +1131,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));
@@ -1103,7 +1159,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",
@@ -1127,7 +1184,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",
@@ -1153,7 +1210,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",
@@ -1183,7 +1240,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",
@@ -1238,7 +1295,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();
@@ -1276,7 +1333,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",
@@ -1316,7 +1373,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",
@@ -1364,8 +1421,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();
@@ -1377,7 +1433,7 @@ mod test {
            .unwrap();

        let issues = issues
-
            .all()
+
            .list()
            .unwrap()
            .map(|r| r.map(|(_, i)| i))
            .collect::<Result<Vec<_>, _>>()
@@ -1393,7 +1449,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",
@@ -1419,7 +1475,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(),
@@ -1477,7 +1533,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(),
@@ -1521,7 +1577,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",
@@ -1555,7 +1611,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",
@@ -1591,7 +1647,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,
@@ -1289,7 +1304,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,
@@ -1308,6 +1324,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>,
}

@@ -1431,6 +1451,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")]
@@ -1473,7 +1526,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,
@@ -1494,23 +1548,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,
@@ -1835,19 +1872,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 {
@@ -1878,6 +1922,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)
@@ -2202,7 +2252,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 {
@@ -2211,7 +2261,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,
@@ -2227,6 +2277,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>,
}
@@ -2239,6 +2300,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,
@@ -2270,23 +2340,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)
@@ -2320,10 +2394,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,
@@ -2331,8 +2405,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,
@@ -2341,12 +2420,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,
@@ -2354,8 +2434,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,
@@ -2364,12 +2448,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)?
@@ -2379,11 +2468,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,
@@ -2392,8 +2482,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)?;
@@ -2404,8 +2498,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))
    }
}

@@ -2423,6 +2644,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;
@@ -2440,11 +2662,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
@@ -2477,7 +2741,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);
    }

@@ -2486,7 +2750,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",
@@ -2519,7 +2783,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",
@@ -2550,7 +2814,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",
@@ -2602,7 +2866,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",
@@ -2790,7 +3054,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",
@@ -2833,7 +3097,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",
@@ -2880,7 +3144,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",
@@ -2914,7 +3178,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",
@@ -2963,7 +3227,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,975 @@
+
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::{ReadRepository, RepositoryError, SignRepository, WriteRepository};
+
use crate::test::storage::HasRepoId;
+

+
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/cob/thread.rs
@@ -1,6 +1,5 @@
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
-
use std::convert::Infallible;
use std::str::FromStr;

use once_cell::sync::Lazy;
@@ -54,7 +53,8 @@ pub type CommentId = EntryId;
pub type Reactions = BTreeSet<(ActorId, Reaction)>;

/// A comment edit is just some text and an edit time.
-
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
pub struct Edit {
    /// Edit author.
    pub author: ActorId,
@@ -83,22 +83,29 @@ impl Edit {
    }
}

+
/// The `Infallible` type does not have a `Serialize`/`Deserialize`
+
/// implementation. The `Never` type imitates `Infallible` and
+
/// provides the derived implementations.
+
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
pub enum Never {}
+

/// A comment on a discussion thread.
-
#[derive(Debug, Clone, PartialEq, Eq)]
-
pub struct Comment<T = Infallible> {
+
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct Comment<T = Never> {
    /// Comment author.
-
    author: ActorId,
+
    pub(in crate::cob) author: ActorId,
    /// The comment body.
-
    edits: Vec<Edit>,
+
    pub(in crate::cob) edits: Vec<Edit>,
    /// Reactions to this comment.
-
    reactions: Reactions,
+
    pub(in crate::cob) reactions: Reactions,
    /// Comment this is a reply to.
    /// Should always be set, except for the root comment.
-
    reply_to: Option<CommentId>,
+
    pub(in crate::cob) reply_to: Option<CommentId>,
    /// Location of comment, if this is an inline comment.
-
    location: Option<T>,
+
    pub(in crate::cob) location: Option<T>,
    /// Whether the comment has been resolved.
-
    resolved: bool,
+
    pub(in crate::cob) resolved: bool,
}

impl<T: Serialize> Serialize for Comment<T> {
@@ -106,11 +113,8 @@ impl<T: Serialize> Serialize for Comment<T> {
    where
        S: serde::ser::Serializer,
    {
-
        let mut state = serializer.serialize_struct("Comment", 9)?;
+
        let mut state = serializer.serialize_struct("Comment", 8)?;
        state.serialize_field("author", &self.author())?;
-
        if let Some(loc) = &self.location {
-
            state.serialize_field("location", loc)?;
-
        }
        if let Some(to) = self.reply_to {
            state.serialize_field("replyTo", &to)?;
        }
@@ -211,7 +215,7 @@ impl<L> Comment<L> {
    }

    /// Get comment resolution status.
-
    pub fn resolved(&self) -> bool {
+
    pub fn is_resolved(&self) -> bool {
        self.resolved
    }

@@ -277,7 +281,8 @@ impl From<Action> for nonempty::NonEmpty<Action> {
}

/// A discussion thread.
-
#[derive(Debug, Clone, PartialEq, Eq)]
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
pub struct Thread<T = Comment> {
    /// The comments under the thread.
    pub(crate) comments: BTreeMap<CommentId, Option<T>>,
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)]
@@ -418,7 +423,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)?;
            }
@@ -447,6 +452,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)
@@ -486,6 +495,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.
modified radicle/src/test/storage.rs
@@ -1,4 +1,5 @@
use std::collections::HashMap;
+
use std::convert::Infallible;
use std::io;
use std::path::{Path, PathBuf};
use std::str::FromStr;
@@ -313,3 +314,87 @@ impl SignRepository for MockRepository {
        todo!()
    }
}
+

+
impl radicle_cob::Store for MockRepository {}
+

+
impl radicle_cob::object::Storage for MockRepository {
+
    type ObjectsError = Infallible;
+
    type TypesError = Infallible;
+
    type UpdateError = Infallible;
+
    type RemoveError = Infallible;
+

+
    fn objects(
+
        &self,
+
        _typename: &radicle_cob::TypeName,
+
        _object_id: &radicle_cob::ObjectId,
+
    ) -> Result<radicle_cob::object::Objects, Self::ObjectsError> {
+
        todo!()
+
    }
+

+
    fn types(
+
        &self,
+
        _typename: &radicle_cob::TypeName,
+
    ) -> Result<
+
        std::collections::BTreeMap<radicle_cob::ObjectId, radicle_cob::object::Objects>,
+
        Self::TypesError,
+
    > {
+
        todo!()
+
    }
+

+
    fn update(
+
        &self,
+
        _identifier: &radicle_crypto::PublicKey,
+
        _typename: &radicle_cob::TypeName,
+
        _object_id: &radicle_cob::ObjectId,
+
        _entry: &radicle_cob::EntryId,
+
    ) -> Result<(), Self::UpdateError> {
+
        todo!()
+
    }
+

+
    fn remove(
+
        &self,
+
        _identifier: &radicle_crypto::PublicKey,
+
        _typename: &radicle_cob::TypeName,
+
        _object_id: &radicle_cob::ObjectId,
+
    ) -> Result<(), Self::RemoveError> {
+
        todo!()
+
    }
+
}
+

+
impl radicle_cob::change::Storage for MockRepository {
+
    type StoreError = radicle_cob::git::change::error::Create;
+
    type LoadError = radicle_cob::git::change::error::Load;
+
    type ObjectId = Oid;
+
    type Parent = Oid;
+
    type Signatures = radicle_cob::signatures::ExtendedSignature;
+

+
    fn store<G>(
+
        &self,
+
        _resource: Option<Self::Parent>,
+
        _related: Vec<Self::Parent>,
+
        _signer: &G,
+
        _template: radicle_cob::change::Template<Self::ObjectId>,
+
    ) -> Result<
+
        radicle_cob::change::store::Entry<Self::Parent, Self::ObjectId, Self::Signatures>,
+
        Self::StoreError,
+
    >
+
    where
+
        G: radicle_crypto::Signer,
+
    {
+
        todo!()
+
    }
+

+
    fn load(
+
        &self,
+
        _id: Self::ObjectId,
+
    ) -> Result<
+
        radicle_cob::change::store::Entry<Self::Parent, Self::ObjectId, Self::Signatures>,
+
        Self::LoadError,
+
    > {
+
        todo!()
+
    }
+

+
    fn parents_of(&self, _id: &Oid) -> Result<Vec<Oid>, Self::LoadError> {
+
        todo!()
+
    }
+
}