rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwoodf81d8b0242813ae808e0702f90b992dc406f8e9a
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Created",
"patch": {
"id": "49acb9afb3cd2610cb59156fbc2a6b7abeae9473",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": null
},
"title": "Implement COB caching",
"state": {
"status": "merged",
"conflicts": []
},
"before": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"after": "f81d8b0242813ae808e0702f90b992dc406f8e9a",
"commits": [
"f81d8b0242813ae808e0702f90b992dc406f8e9a",
"45c0b0e9688f9795ea88a367fc93f2b5db085e14",
"7c3156adbbdb90319f5d08b887a50a66cb336923",
"457090e250f33f5dcf4b16afa95c9579f2a81a10",
"78b2da7d83e0287254851c3efa9c24ce81b29a64",
"dd127d3b25bc54c100519e9ef1729ce60c454d93",
"16bbce390fe43de16a9ba21d844341ab6b6be8a2",
"bf7c10ba43d56cf7a7a54d9cf693f8d16f117d91",
"fa0a38162787467d56441838513b438b48d797e5"
],
"target": "3fc92f83fe089263c470b328414d923227cf0e4a",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "49acb9afb3cd2610cb59156fbc2a6b7abeae9473",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": null
},
"description": "This patch implements caching for COBs by using an SQLite database\nthat is used as a write-thru cache. All reads are replaced with using\nthe cache so as to avoid recomputing the COB graph each time.",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "e6776d71c51301682e9bb2f65c4a9da1e68e4a52",
"timestamp": 1708089324
},
{
"id": "49c116e75cfda1a31e8ba180653de5f89c290ec8",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": null
},
"description": "Changes:\n- added `cache` subcommands for `patch` and `issue`",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "b47019d852e9fb695bcbf56e1db99f6d13f8ec59",
"timestamp": 1708099779
},
{
"id": "703dd16da8c19b57af7262174af52b58416b266f",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": null
},
"description": "Changes:\n- Document modules and new additions\n- Order by is listing queries\n- Add listing by different statuses for patches (includes adding new\n Status type)\n- recache split into two functions, write and write_all\n- reader db's no longer migrate\n- formatted sql schemas\n- `Remove::remove` no longer required the RID\n- Clean up of PatchMut\n- cache/cobs.db renamed to cobs/cache.db\n- Remove Cacheable type and inline usage of its methods\n- Simplify Cache types to take any R, not necessarily the underlying\n stores. This allows for more flexible caching and definitions,\n e.g. when paired with anything that implements `HasRid` (a newly\n introduced trait), we get all of the `Patches` and `Issues` methods.\n This could potentially mean we could unify the two different `Cache`\n types in a future change.\n\nN.B. all new commits are fixups to help with revision-to-revision\nreview and will be squashed once it's ready for merging.",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "1b16597384a1390f7dc33f3d76a625414af16be7",
"timestamp": 1708606717
},
{
"id": "7e7033ac360a33f043a6707ed75884dd19d042af",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": null
},
"description": "Changes:\n- Removed Filter type in patch command\n- Fixed COB type name in update error\n- Squashed fixup commits",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "f81d8b0242813ae808e0702f90b992dc406f8e9a",
"timestamp": 1708691949
},
{
"id": "098a3c9e8e3c28159520a8e93afafe194c77df2d",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": null
},
"description": "Rebase.",
"base": "17744a03fd9e2bd76304fe52602ea9524d927006",
"oid": "de068021d06f15a6877fb771cd7e90ab92975284",
"timestamp": 1708693881
},
{
"id": "7c10a924ec7a0560decce6573c046763e5fbdc1c",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": null
},
"description": "Fix broken build due to bad import.",
"base": "9767b485c2aad1e23097d2b5165287ba84cfa452",
"oid": "ba342c7748895a224222fa71613d4266e67183db",
"timestamp": 1708700363
},
{
"id": "baf10014a8cb1f7144ab54cd566761e7d6d2dc09",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": null
},
"description": "Reset to master.",
"base": "9e745b68d12d96a29d9aab3320438a1b231bdd17",
"oid": "9e745b68d12d96a29d9aab3320438a1b231bdd17",
"timestamp": 1708700658
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "61720a2e-907e-4944-8b6a-835eb6d9cfb7"
},
"info_url": "https://cci.rad.levitte.org//61720a2e-907e-4944-8b6a-835eb6d9cfb7.html"
}
Started at: 2026-09-07 21:17:26.146767+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/61720a2e-907e-4944-8b6a-835eb6d9cfb7/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 189 issues · 38 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout 49acb9afb3cd2610cb59156fbc2a6b7abeae9473
✓ Switched to branch patch/49acb9a at revision 7e7033a
✓ Branch patch/49acb9a setup to track rad/patches/49acb9afb3cd2610cb59156fbc2a6b7abeae9473
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout f81d8b0242813ae808e0702f90b992dc406f8e9a
HEAD is now at f81d8b02 cli: add caching commands for patch and issue
Exit code: 0
$ rad patch show 49acb9afb3cd2610cb59156fbc2a6b7abeae9473 -p
╭────────────────────────────────────────────────────────────────────────────────╮
│ Title Implement COB caching │
│ Patch 49acb9afb3cd2610cb59156fbc2a6b7abeae9473 │
│ Author fintohaps z6Mkire…SQZ3voM │
│ Head f81d8b0242813ae808e0702f90b992dc406f8e9a │
│ Base 9767b485c2aad1e23097d2b5165287ba84cfa452 │
│ Target master │
│ Branches patch/49acb9a │
│ Commits ahead 9, behind 1609 │
│ Status merged │
│ │
│ 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. │
├────────────────────────────────────────────────────────────────────────────────┤
│ f81d8b0 cli: add caching commands for patch and issue │
│ 45c0b0e radicle: implement caching for issues and patches │
│ 7c3156a radicle: add cob::cache module │
│ 457090e radicle: serialization and deserialization improvements │
│ 78b2da7 radicle: add cob storage for MockRepository │
│ dd127d3 radicle: rename resolved to is_resolved │
│ 16bbce3 radicle: introduce cob::TypedId │
│ bf7c10b cob: move unwrap_used pragma │
│ fa0a381 cli: wildcard for rad-inbox.md test │
├────────────────────────────────────────────────────────────────────────────────┤
│ ● Revision 49acb9a @ 9767b48..e6776d7 by fintohaps z6Mkire…SQZ3voM 2 years ago │
│ ↑ Revision 49c116e @ 9767b48..b47019d by fintohaps z6Mkire…SQZ3voM 2 years ago │
│ └─ ⋄ reviewed by cloudhead z6MksFq…bS9wzpT 2 years ago │
│ ↑ Revision 703dd16 @ 9767b48..1b16597 by fintohaps z6Mkire…SQZ3voM 2 years ago │
│ ↑ Revision 7e7033a @ 9767b48..f81d8b0 by fintohaps z6Mkire…SQZ3voM 2 years ago │
│ ↑ Revision 098a3c9 @ 17744a0..de06802 by cloudhead z6MksFq…bS9wzpT 2 years ago │
│ ↑ Revision 7c10a92 @ 9767b48..ba342c7 by cloudhead z6MksFq…bS9wzpT 2 years ago │
│ ↑ Revision baf1001 @ 9e745b6..9e745b6 by cloudhead z6MksFq…bS9wzpT 2 years ago │
│ └─ ✓ merged by cloudhead z6MksFq…bS9wzpT │
╰────────────────────────────────────────────────────────────────────────────────╯
commit f81d8b0242813ae808e0702f90b992dc406f8e9a
Author: Fintan Halpenny <fintan.halpenny@gmail.com>
Date: Fri Feb 16 15:36:21 2024 +0000
cli: add caching commands for patch and issue
In order to bootstrap the COB cache, and also in case of issues,
supply a command for caching a set of patches/issues, or a single
patch/issue.
This is necessary since reads are not read-thru, it may appear that
COBs are missing when in fact they haven't been cached when first
moving over to the cache.
Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
diff --git a/radicle-cli/src/commands/issue.rs b/radicle-cli/src/commands/issue.rs
index cacb394c4..c2bc8214a 100644
--- a/radicle-cli/src/commands/issue.rs
+++ b/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;
@@ -44,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
@@ -85,6 +89,7 @@ pub enum OperationName {
React,
Show,
State,
+ Cache,
}
/// Command line Peer argument.
@@ -142,6 +147,9 @@ pub enum Operation {
assigned: Option<Assigned>,
state: Option<State>,
},
+ Cache {
+ id: Option<Rev>,
+ },
}
#[derive(Debug, Default, PartialEq, Eq)]
@@ -341,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),
},
@@ -397,6 +406,7 @@ impl Args for Options {
opts: label_opts,
},
OperationName::List => Operation::List { assigned, state },
+ OperationName::Cache => Operation::Cache { id },
};
Ok((
@@ -553,6 +563,10 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
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 {
diff --git a/radicle-cli/src/commands/issue/cache.rs b/radicle-cli/src/commands/issue/cache.rs
new file mode 100644
index 000000000..8582bf55f
--- /dev/null
+++ b/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(())
+}
diff --git a/radicle-cli/src/commands/patch.rs b/radicle-cli/src/commands/patch.rs
index 4a0f08caf..6f4b06c48 100644
--- a/radicle-cli/src/commands/patch.rs
+++ b/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"]
@@ -67,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
@@ -166,6 +169,7 @@ pub enum OperationName {
Edit,
Redact,
Set,
+ Cache,
}
#[derive(Debug, Default, PartialEq, Eq)]
@@ -243,6 +247,9 @@ pub enum Operation {
Set {
patch_id: Rev,
},
+ Cache {
+ patch_id: Option<Rev>,
+ },
}
impl Operation {
@@ -262,7 +269,8 @@ impl Operation {
Operation::Show { .. }
| Operation::Diff { .. }
| Operation::Checkout { .. }
- | Operation::List { .. } => false,
+ | Operation::List { .. }
+ | Operation::Cache { .. } => false,
}
}
}
@@ -518,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) => {
@@ -540,6 +549,7 @@ impl Args for Options {
Some(OperationName::Set),
Some(OperationName::Assign),
Some(OperationName::Label),
+ Some(OperationName::Cache),
]
.contains(&op) =>
{
@@ -615,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((
@@ -790,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 {
diff --git a/radicle-cli/src/commands/patch/cache.rs b/radicle-cli/src/commands/patch/cache.rs
new file mode 100644
index 000000000..0c80a2f84
--- /dev/null
+++ b/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(())
+}
commit 45c0b0e9688f9795ea88a367fc93f2b5db085e14
Author: Fintan Halpenny <fintan.halpenny@gmail.com>
Date: Thu Feb 8 16:52:05 2024 +0000
radicle: implement caching for issues and patches
Implement caching for the `Issue` and `Patch` COB. This is achieved by
having the `Transaction::initial` and `Transaction::commit` methods
take a cache which can update the newly created/updated object. The
`Store::remove` method also takes the cache for removing the object
from the cache, as well as the repository.
This meant that the `*Mut` types for the respective COBs are required
to carry a cache alongside the repository store. Identities will not
be cached, and so this is always set to `NoCache` -- which performs no
caching, and always succeeds.
To perform cache reading for issues and patches, the `cache::Issues`
and `cache::Patches` traits are introduced. They capture the minimal
amount of methods that are needed for the `rad issue` and `rad patch`
CLI commands.
The implementor for each of these traits is there respective `Cache`
types, which combines their backing repository store and the SQLite
database. They both provide convenience methods:
- `create` (and `draft` for patches)
- `get_mut`
- `remove`
which can be used instead of their repository store counterparts.
All uses of the repository stores (where needed) are replaced with
`Cache` types.
Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
diff --git a/radicle-cli/src/commands/clone.rs b/radicle-cli/src/commands/clone.rs
index f46fe015a..8e5bb00ac 100644
--- a/radicle-cli/src/commands/clone.rs
+++ b/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(),
diff --git a/radicle-cli/src/commands/inbox.rs b/radicle-cli/src/commands/inbox.rs
index 702676c5f..a8a8821a9 100644
--- a/radicle-cli/src/commands/inbox.rs
+++ b/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)?;
diff --git a/radicle-cli/src/commands/issue.rs b/radicle-cli/src/commands/issue.rs
index 8554ca6fb..cacb394c4 100644
--- a/radicle-cli/src/commands/issue.rs
+++ b/radicle-cli/src/commands/issue.rs
@@ -6,13 +6,14 @@ use anyhow::{anyhow, Context as _};
use radicle::cob::common::{Label, Reaction};
use radicle::cob::issue;
-use radicle::cob::issue::{CloseReason, Issues, State};
+use radicle::cob::issue::{CloseReason, State};
use radicle::cob::thread;
use radicle::crypto::Signer;
+use radicle::issue::cache::Issues as _;
use radicle::prelude::Did;
use radicle::profile;
use radicle::storage;
-use radicle::storage::{WriteRepository, WriteStorage};
+use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
use radicle::Profile;
use radicle::{cob, Node};
@@ -425,7 +426,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
| Operation::Label { .. }
);
- let mut issues = Issues::open(&repo)?;
+ let mut issues = profile.issues_mut(&repo)?;
match options.op {
Operation::Edit {
@@ -546,7 +547,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
issue.label(labels, &signer)?;
}
Operation::List { assigned, state } => {
- list(&issues, &assigned, &state, &profile)?;
+ list(issues, &assigned, &state, &profile)?;
}
Operation::Delete { id } => {
let id = id.resolve(&repo.backend)?;
@@ -562,13 +563,16 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
Ok(())
}
-fn list<R: WriteRepository + cob::Store>(
- issues: &Issues<R>,
+fn list<C>(
+ cache: C,
assigned: &Option<Assigned>,
state: &Option<State>,
profile: &profile::Profile,
-) -> anyhow::Result<()> {
- if issues.is_empty()? {
+) -> anyhow::Result<()>
+where
+ C: issue::cache::Issues,
+{
+ if cache.is_empty()? {
term::print(term::format::italic("Nothing to show."));
return Ok(());
}
@@ -580,7 +584,8 @@ fn list<R: WriteRepository + cob::Store>(
};
let mut all = Vec::new();
- for result in issues.all()? {
+ let issues = cache.list()?;
+ for result in issues {
let Ok((id, issue)) = result else {
// Skip issues that failed to load.
continue;
@@ -664,16 +669,20 @@ fn list<R: WriteRepository + cob::Store>(
Ok(())
}
-fn open<R: WriteRepository + cob::Store, G: Signer>(
+fn open<R, G>(
title: Option<String>,
description: Option<String>,
labels: Vec<Label>,
assignees: Vec<Did>,
options: &Options,
- issues: &mut Issues<R>,
+ cache: &mut issue::Cache<issue::Issues<'_, R>, cob::cache::StoreWriter>,
signer: &G,
profile: &Profile,
-) -> anyhow::Result<()> {
+) -> anyhow::Result<()>
+where
+ R: ReadRepository + WriteRepository + cob::Store,
+ G: Signer,
+{
let (title, description) = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
(t.to_owned(), d.to_owned())
} else if let Some((t, d)) = term::issue::get_title_description(title, description)? {
@@ -681,7 +690,7 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
} else {
anyhow::bail!("aborting issue creation due to empty title or description");
};
- let issue = issues.create(
+ let issue = cache.create(
&title,
description,
labels.as_slice(),
@@ -696,14 +705,18 @@ fn open<R: WriteRepository + cob::Store, G: Signer>(
Ok(())
}
-fn edit<'a, 'g, R: WriteRepository + cob::Store, G: radicle::crypto::Signer>(
- issues: &'a mut issue::Issues<'a, R>,
+fn edit<'a, 'g, R, G>(
+ issues: &'g mut issue::Cache<issue::Issues<'a, R>, cob::cache::StoreWriter>,
repo: &storage::git::Repository,
id: Rev,
title: Option<String>,
description: Option<String>,
signer: &G,
-) -> anyhow::Result<issue::IssueMut<'a, 'g, R>> {
+) -> anyhow::Result<issue::IssueMut<'a, 'g, R, cob::cache::StoreWriter>>
+where
+ R: WriteRepository + ReadRepository + cob::Store,
+ G: radicle::crypto::Signer,
+{
let id = id.resolve(&repo.backend)?;
let mut issue = issues.get_mut(&id)?;
let (root, _) = issue.root();
diff --git a/radicle-cli/src/commands/patch.rs b/radicle-cli/src/commands/patch.rs
index 3fe7b47cb..4a0f08caf 100644
--- a/radicle-cli/src/commands/patch.rs
+++ b/radicle-cli/src/commands/patch.rs
@@ -34,6 +34,7 @@ use anyhow::anyhow;
use radicle::cob::patch::PatchId;
use radicle::cob::{patch, Label};
+use radicle::patch::cache::Patches as _;
use radicle::storage::git::transport;
use radicle::{prelude::*, Node};
@@ -179,27 +180,6 @@ pub struct LabelOptions {
pub delete: BTreeSet<Label>,
}
-pub struct Filter(fn(&patch::State) -> bool);
-
-impl Filter {
- /// Match everything.
- fn all() -> Self {
- Self(|_| true)
- }
-}
-
-impl Default for Filter {
- fn default() -> Self {
- Self(|state| matches!(state, patch::State::Open { .. }))
- }
-}
-
-impl std::fmt::Debug for Filter {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "Filter(..)")
- }
-}
-
#[derive(Debug)]
pub enum Operation {
Show {
@@ -250,7 +230,7 @@ pub enum Operation {
opts: LabelOptions,
},
List {
- filter: Filter,
+ filter: Option<patch::Status>,
},
Edit {
patch_id: Rev,
@@ -311,7 +291,7 @@ impl Args for Options {
let mut patch_id = None;
let mut revision_id = None;
let mut message = Message::default();
- let mut filter = Filter::default();
+ let mut filter = Some(patch::Status::Open);
let mut diff = false;
let mut debug = false;
let mut undo = false;
@@ -487,19 +467,19 @@ impl Args for Options {
// List options.
Long("all") => {
- filter = Filter::all();
+ filter = None;
}
Long("draft") => {
- filter = Filter(|s| s == &patch::State::Draft);
+ filter = Some(patch::Status::Draft);
}
Long("archived") => {
- filter = Filter(|s| s == &patch::State::Archived);
+ filter = Some(patch::Status::Archived);
}
Long("merged") => {
- filter = Filter(|s| matches!(s, patch::State::Merged { .. }));
+ filter = Some(patch::Status::Merged);
}
Long("open") => {
- filter = Filter(|s| matches!(s, patch::State::Open { .. }));
+ filter = Some(patch::Status::Open);
}
Long("authored") => {
authored = true;
@@ -662,12 +642,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
transport::local::register(profile.storage.clone());
match options.op {
- Operation::List { filter: Filter(f) } => {
+ Operation::List { filter } => {
let mut authors: BTreeSet<Did> = options.authors.iter().cloned().collect();
if options.authored {
authors.insert(profile.did());
}
- list::run(f, authors, &repository, &profile)?;
+ list::run(filter.as_ref(), authors, &repository, &profile)?;
}
Operation::Show {
patch_id,
@@ -694,7 +674,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
.map(|rev| rev.resolve::<radicle::git::Oid>(&repository.backend))
.transpose()?
.map(patch::RevisionId::from);
- diff::run(&patch_id, revision_id, &repository)?;
+ diff::run(&patch_id, revision_id, &repository, &profile)?;
}
Operation::Update {
ref patch_id,
@@ -742,6 +722,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
revision_id,
&repository,
&workdir,
+ &profile,
opts,
)?;
}
@@ -801,7 +782,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
label::run(&patch_id, add, delete, &profile, &repository)?;
}
Operation::Set { patch_id } => {
- let patches = radicle::cob::patch::Patches::open(&repository)?;
+ let patches = profile.patches(&repository)?;
let patch_id = patch_id.resolve(&repository.backend)?;
let patch = patches
.get(&patch_id)?
diff --git a/radicle-cli/src/commands/patch/archive.rs b/radicle-cli/src/commands/patch/archive.rs
index 4d9b4ef9f..eacb50701 100644
--- a/radicle-cli/src/commands/patch/archive.rs
+++ b/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");
};
diff --git a/radicle-cli/src/commands/patch/assign.rs b/radicle-cli/src/commands/patch/assign.rs
index d3a479014..c96db74c3 100644
--- a/radicle-cli/src/commands/patch/assign.rs
+++ b/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");
};
diff --git a/radicle-cli/src/commands/patch/checkout.rs b/radicle-cli/src/commands/patch/checkout.rs
index 1546840ea..815bfae3e 100644
--- a/radicle-cli/src/commands/patch/checkout.rs
+++ b/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"))?;
diff --git a/radicle-cli/src/commands/patch/comment.rs b/radicle-cli/src/commands/patch/comment.rs
index 99d77dbf0..cf3773695 100644
--- a/radicle-cli/src/commands/patch/comment.rs
+++ b/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);
diff --git a/radicle-cli/src/commands/patch/delete.rs b/radicle-cli/src/commands/patch/delete.rs
index 9e3d185dd..4d2c0e635 100644
--- a/radicle-cli/src/commands/patch/delete.rs
+++ b/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(())
diff --git a/radicle-cli/src/commands/patch/diff.rs b/radicle-cli/src/commands/patch/diff.rs
index a7030532b..560caef2f 100644
--- a/radicle-cli/src/commands/patch/diff.rs
+++ b/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");
};
diff --git a/radicle-cli/src/commands/patch/edit.rs b/radicle-cli/src/commands/patch/edit.rs
index 3bd660e7d..3b49f79e2 100644
--- a/radicle-cli/src/commands/patch/edit.rs
+++ b/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,
diff --git a/radicle-cli/src/commands/patch/label.rs b/radicle-cli/src/commands/patch/label.rs
index 128ace016..009c8f457 100644
--- a/radicle-cli/src/commands/patch/label.rs
+++ b/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");
};
diff --git a/radicle-cli/src/commands/patch/list.rs b/radicle-cli/src/commands/patch/list.rs
index 16adfe200..a96ee5a85 100644
--- a/radicle-cli/src/commands/patch/list.rs
+++ b/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;
diff --git a/radicle-cli/src/commands/patch/ready.rs b/radicle-cli/src/commands/patch/ready.rs
index a35fca3f0..4ab878ba8 100644
--- a/radicle-cli/src/commands/patch/ready.rs
+++ b/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");
};
diff --git a/radicle-cli/src/commands/patch/redact.rs b/radicle-cli/src/commands/patch/redact.rs
index f493eb6ea..3e26bb942 100644
--- a/radicle-cli/src/commands/patch/redact.rs
+++ b/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 {
diff --git a/radicle-cli/src/commands/patch/review.rs b/radicle-cli/src/commands/patch/review.rs
index 00a201ea7..d9e2e0033 100644
--- a/radicle-cli/src/commands/patch/review.rs
+++ b/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"))?;
diff --git a/radicle-cli/src/commands/patch/show.rs b/radicle-cli/src/commands/patch/show.rs
index 6be728e46..c187c9e14 100644
--- a/radicle-cli/src/commands/patch/show.rs
+++ b/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");
};
diff --git a/radicle-cli/src/commands/patch/update.rs b/radicle-cli/src/commands/patch/update.rs
index 625377052..447653669 100644
--- a/radicle-cli/src/commands/patch/update.rs
+++ b/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
diff --git a/radicle-cli/src/commands/stats.rs b/radicle-cli/src/commands/stats.rs
index d99d22a58..b93b55bed 100644
--- a/radicle-cli/src/commands/stats.rs
+++ b/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();
diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs
index 68afd8f08..c33e7caf5 100644
--- a/radicle-cli/tests/commands.rs
+++ b/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..");
diff --git a/radicle-httpd/src/api.rs b/radicle-httpd/src/api.rs
index 75fb62183..81c00fcb2 100644
--- a/radicle-httpd/src/api.rs
+++ b/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();
diff --git a/radicle-httpd/src/api/error.rs b/radicle-httpd/src/api/error.rs
index 90c7b4cd7..a9885f040 100644
--- a/radicle-httpd/src/api/error.rs
+++ b/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()))
diff --git a/radicle-httpd/src/api/v1/delegates.rs b/radicle-httpd/src/api/v1/delegates.rs
index d7924df38..96d536f4a 100644
--- a/radicle-httpd/src/api/v1/delegates.rs
+++ b/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 {
diff --git a/radicle-httpd/src/api/v1/projects.rs b/radicle-httpd/src/api/v1/projects.rs
index 149b00583..3968f1104 100644
--- a/radicle-httpd/src/api/v1/projects.rs
+++ b/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(
diff --git a/radicle-httpd/src/test.rs b/radicle-httpd/src/test.rs
index 96c0a75c7..00597e953 100644
--- a/radicle-httpd/src/test.rs
+++ b/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
diff --git a/radicle-node/src/runtime.rs b/radicle-node/src/runtime.rs
index 3c8823395..2a43f02ce 100644
--- a/radicle-node/src/runtime.rs
+++ b/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),
diff --git a/radicle-node/src/test/environment.rs b/radicle-node/src/test/environment.rs
index 90844568c..d89809f94 100644
--- a/radicle-node/src/test/environment.rs
+++ b/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()
diff --git a/radicle-node/src/tests.rs b/radicle-node/src/tests.rs
index b6e272339..e3a7988b5 100644
--- a/radicle-node/src/tests.rs
+++ b/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();
diff --git a/radicle-node/src/worker.rs b/radicle-node/src/worker.rs
index 609404005..41dbc7a41 100644
--- a/radicle-node/src/worker.rs
+++ b/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());
diff --git a/radicle-node/src/worker/fetch.rs b/radicle-node/src/worker/fetch.rs
index 83212afb9..172b14332 100644
--- a/radicle-node/src/worker/fetch.rs
+++ b/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(())
+}
diff --git a/radicle-node/src/worker/fetch/error.rs b/radicle-node/src/worker/fetch/error.rs
index 80c97fb00..b50325595 100644
--- a/radicle-node/src/worker/fetch/error.rs
+++ b/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)]
diff --git a/radicle-remote-helper/src/lib.rs b/radicle-remote-helper/src/lib.rs
index 522ed6cd3..7ada7d44e 100644
--- a/radicle-remote-helper/src/lib.rs
+++ b/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)?;
diff --git a/radicle-remote-helper/src/list.rs b/radicle-remote-helper/src/list.rs
index 77ba7a5ae..fd8c35d33 100644
--- a/radicle-remote-helper/src/list.rs
+++ b/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;
diff --git a/radicle-remote-helper/src/push.rs b/radicle-remote-helper/src/push.rs
index 93b2e32f0..c777610be 100644
--- a/radicle-remote-helper/src/push.rs
+++ b/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,
diff --git a/radicle-tools/src/rad-merge.rs b/radicle-tools/src/rad-merge.rs
index 49fcf6601..5ed1e9ff0 100644
--- a/radicle-tools/src/rad-merge.rs
+++ b/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() {
diff --git a/radicle/src/cob.rs b/radicle/src/cob.rs
index ccecbe484..c41497b49 100644
--- a/radicle/src/cob.rs
+++ b/radicle/src/cob.rs
@@ -20,12 +20,16 @@ pub use radicle_cob::{
};
pub use radicle_cob::{create, get, git, list, remove, update};
+/// The exact identifier for a particular COB.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct TypedId {
+ /// The identifier of the COB in the store.
pub id: ObjectId,
+ /// The type identifier of the COB in the store.
pub type_name: TypeName,
}
+/// Errors that occur when parsing a Git refname into a [`TypedId`].
#[derive(Debug, thiserror::Error)]
pub enum ParseIdentifierError {
#[error(transparent)]
@@ -35,20 +39,39 @@ pub enum ParseIdentifierError {
}
impl TypedId {
+ /// Returns `true` is the [`TypedId::type_name`] is for an
+ /// [`issue::Issue`].
pub fn is_issue(&self) -> bool {
self.type_name == *issue::TYPENAME
}
+ /// Returns `true` is the [`TypedId::type_name`] is for an
+ /// [`patch::Patch`].
pub fn is_patch(&self) -> bool {
self.type_name == *patch::TYPENAME
}
+ /// Parse a [`crate::git::Namespaced`] refname into a [`TypedId`].
+ ///
+ /// All namespaces are stripped before parsing the suffix for the
+ /// [`TypedId`] (see [`TypedId::from_qualified`]).
pub fn from_namespaced(
n: &crate::git::Namespaced,
) -> Result<Option<Self>, ParseIdentifierError> {
- Self::from_qualified(&n.strip_namespace())
+ Self::from_qualified(&n.strip_namespace_recursive())
}
+ /// Parse a [`crate::git::Qualified`] refname into a [`TypedId`].
+ ///
+ /// The refname is expected to be of the form:
+ /// `refs/cobs/<type name>/<object id>`
+ ///
+ /// If the refname is not of that form then `None` will be returned.
+ ///
+ /// # Errors
+ ///
+ /// This will fail if the refname is of the correct form, but the
+ /// type name or object id fail to parse.
pub fn from_qualified(q: &crate::git::Qualified) -> Result<Option<Self>, ParseIdentifierError> {
match q.non_empty_iter() {
("refs", "cobs", type_name, mut id) => {
diff --git a/radicle/src/cob/cache.rs b/radicle/src/cob/cache.rs
index 86b13b559..407a69439 100644
--- a/radicle/src/cob/cache.rs
+++ b/radicle/src/cob/cache.rs
@@ -35,6 +35,11 @@ pub enum Error {
NoRows,
}
+/// Read and write to the store.
+pub type StoreWriter = Store<Write>;
+/// Write to the store.
+pub type StoreReader = Store<Read>;
+
/// Read-only type witness.
#[derive(Clone)]
pub struct Read;
@@ -194,7 +199,10 @@ pub trait Remove<T> {
type Out;
type RemoveError: std::error::Error + Send + Sync + 'static;
- fn remove(&mut self, rid: &RepoId, id: &ObjectId) -> Result<Self::Out, Self::RemoveError>;
+ /// Delete an object in the COB cache.
+ ///
+ /// This assumes that the `id` is unique across repositories.
+ fn remove(&mut self, id: &ObjectId) -> Result<Self::Out, Self::RemoveError>;
}
/// An in-memory cache for storing COB objects.
@@ -259,7 +267,48 @@ impl<T> Remove<T> for NoCache {
type Out = ();
type RemoveError = Infallible;
- fn remove(&mut self, _rid: &RepoId, _id: &ObjectId) -> Result<Self::Out, Self::RemoveError> {
+ fn remove(&mut self, _id: &ObjectId) -> Result<Self::Out, Self::RemoveError> {
Ok(())
}
}
+
+/// Track the progress of cache writes when transferring the
+/// repository COBs to their respective caches.
+///
+/// See [`crate::cob::issue::Cache::write_all`] and
+/// [`crate::cob::patch::Cache::write_all`].
+pub struct WriteAllProgress {
+ total: usize,
+ seen: usize,
+}
+
+impl WriteAllProgress {
+ /// Create a new progress tracker with the given `total` amount.
+ pub fn new(total: usize) -> Self {
+ Self { total, seen: 0 }
+ }
+
+ /// Increment the [`WriteAllProgress::seen`] progress.
+ pub fn inc(&mut self) {
+ self.seen += 1;
+ }
+
+ /// Return the `total` amount.
+ pub fn total(&self) -> usize {
+ self.total
+ }
+
+ /// Return the `seen` amount.
+ pub fn seen(&self) -> usize {
+ self.seen
+ }
+
+ /// Return the percentage of the progress made.
+ ///
+ /// # Panics
+ ///
+ /// If the `total` provided is `0`.
+ pub fn percentage(&self) -> f32 {
+ (self.seen as f32 / self.total as f32) * 100.0
+ }
+}
diff --git a/radicle/src/cob/issue.rs b/radicle/src/cob/issue.rs
index 124118146..b81b40bd9 100644
--- a/radicle/src/cob/issue.rs
+++ b/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.
@@ -398,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)
}
}
@@ -512,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)
@@ -527,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> {
@@ -648,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 {
@@ -674,6 +698,15 @@ impl<'a, R> Deref for Issues<'a, R> {
}
}
+impl<'a, R> HasRepoId for Issues<'a, R>
+where
+ R: ReadRepository,
+{
+ fn rid(&self) -> RepoId {
+ self.raw.as_ref().id()
+ }
+}
+
/// Detailed information on issue states
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -706,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())?;
@@ -743,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,
})
}
@@ -766,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.
@@ -843,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",
@@ -938,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));
@@ -977,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));
@@ -986,7 +1041,7 @@ mod test {
"My first issue",
"Blah blah blah.",
&[],
- &[assignee],
+ &[assignee, assignee_two],
[],
&node.signer,
)
@@ -1010,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",
@@ -1036,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",
@@ -1076,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));
@@ -1104,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",
@@ -1128,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",
@@ -1154,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",
@@ -1184,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",
@@ -1239,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();
@@ -1277,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",
@@ -1317,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",
@@ -1365,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();
@@ -1378,7 +1433,7 @@ mod test {
.unwrap();
let issues = issues
- .all()
+ .list()
.unwrap()
.map(|r| r.map(|(_, i)| i))
.collect::<Result<Vec<_>, _>>()
@@ -1394,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",
@@ -1420,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(),
@@ -1478,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(),
@@ -1522,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",
@@ -1556,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",
@@ -1592,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",
diff --git a/radicle/src/cob/issue/cache.rs b/radicle/src/cob/issue/cache.rs
new file mode 100644
index 000000000..b5272dbc9
--- /dev/null
+++ b/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());
+ }
+ }
+}
diff --git a/radicle/src/cob/patch.rs b/radicle/src/cob/patch.rs
index 7bb92213c..966008c4e 100644
--- a/radicle/src/cob/patch.rs
+++ b/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;
@@ -2439,12 +2661,54 @@ mod test {
);
}
+ #[test]
+ fn test_reactions_json_serialization() {
+ #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
+ #[serde(rename_all = "camelCase")]
+ struct TestReactions {
+ #[serde(
+ serialize_with = "super::ser::serialize_reactions",
+ deserialize_with = "super::ser::deserialize_reactions"
+ )]
+ inner: BTreeMap<Option<CodeLocation>, Reactions>,
+ }
+
+ let reactions = TestReactions {
+ inner: [(
+ None,
+ [
+ (
+ "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8"
+ .parse()
+ .unwrap(),
+ Reaction::new('🚀').unwrap(),
+ ),
+ (
+ "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
+ .parse()
+ .unwrap(),
+ Reaction::new('🙏').unwrap(),
+ ),
+ ]
+ .into_iter()
+ .collect(),
+ )]
+ .into_iter()
+ .collect(),
+ };
+
+ assert_eq!(
+ reactions,
+ serde_json::from_str(&serde_json::to_string(&reactions).unwrap()).unwrap()
+ );
+ }
+
#[test]
fn test_patch_create_and_get() {
let alice = test::setup::NodeWithRepo::default();
let checkout = alice.repo.checkout();
let branch = checkout.branch_with([("README", b"Hello World!")]);
- let mut patches = Patches::open(&*alice.repo).unwrap();
+ let mut patches = Cache::no_cache(&*alice.repo).unwrap();
let author: Did = alice.signer.public_key().into();
let target = MergeTarget::Delegates;
let patch = patches
@@ -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",
diff --git a/radicle/src/cob/patch/cache.rs b/radicle/src/cob/patch/cache.rs
new file mode 100644
index 000000000..327098d32
--- /dev/null
+++ b/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());
+ }
+ }
+}
diff --git a/radicle/src/cob/store.rs b/radicle/src/cob/store.rs
index c7c2e618d..c34a7fb03 100644
--- a/radicle/src/cob/store.rs
+++ b/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))
}
}
diff --git a/radicle/src/profile.rs b/radicle/src/profile.rs
index bf6a12ad8..053d1ca19 100644
--- a/radicle/src/profile.rs
+++ b/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.
diff --git a/radicle/src/storage.rs b/radicle/src/storage.rs
index a0ba80908..a6308138a 100644
--- a/radicle/src/storage.rs
+++ b/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.
commit 7c3156adbbdb90319f5d08b887a50a66cb336923
Author: Fintan Halpenny <fintan.halpenny@gmail.com>
Date: Thu Feb 8 16:44:18 2024 +0000
radicle: add cob::cache module
Add the cob::cache module which describes an SQLite store and schema
for caching COBs. It also provides in-memory and no-cache types for
testing purposes.
The intention is for the store to be used alongside the existing COB
types so that a write-thru cache can be implemented and used for
efficient reads.
Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
diff --git a/radicle/src/cob.rs b/radicle/src/cob.rs
index b9d7db89c..ccecbe484 100644
--- a/radicle/src/cob.rs
+++ b/radicle/src/cob.rs
@@ -1,4 +1,5 @@
#![warn(clippy::unwrap_used)]
+pub mod cache;
pub mod common;
pub mod identity;
pub mod issue;
diff --git a/radicle/src/cob/cache.rs b/radicle/src/cob/cache.rs
new file mode 100644
index 000000000..86b13b559
--- /dev/null
+++ b/radicle/src/cob/cache.rs
@@ -0,0 +1,265 @@
+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-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;
+
+ fn remove(&mut self, rid: &RepoId, 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, _rid: &RepoId, _id: &ObjectId) -> Result<Self::Out, Self::RemoveError> {
+ Ok(())
+ }
+}
diff --git a/radicle/src/cob/cache/migrations/1.sql b/radicle/src/cob/cache/migrations/1.sql
new file mode 100644
index 000000000..eeb134719
--- /dev/null
+++ b/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;
commit 457090e250f33f5dcf4b16afa95c9579f2a81a10
Author: Fintan Halpenny <fintan.halpenny@gmail.com>
Date: Thu Feb 8 16:21:06 2024 +0000
radicle: serialization and deserialization improvements
Add derivations of `Serialize` and `Deserialize` for the `Issue` and
`Thread` cob types.
It's necessary to circumvent `Infallible` for the case of the
`Comment` type. This is because `Infallible` does not have these
traits implemented, and there does not seem to be a plan to do so.
Instead, we can use a custom `Infallible` equivalent, which is dubbed
`Never` here.
Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
diff --git a/radicle/src/cob/issue.rs b/radicle/src/cob/issue.rs
index 5c5a18618..124118146 100644
--- a/radicle/src/cob/issue.rs
+++ b/radicle/src/cob/issue.rs
@@ -106,7 +106,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>,
@@ -674,7 +675,7 @@ impl<'a, R> Deref for Issues<'a, R> {
}
/// 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,
diff --git a/radicle/src/cob/thread.rs b/radicle/src/cob/thread.rs
index ff3c78eae..1bd5c12ad 100644
--- a/radicle/src/cob/thread.rs
+++ b/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)?;
}
@@ -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>>,
commit 78b2da7d83e0287254851c3efa9c24ce81b29a64
Author: Fintan Halpenny <fintan.halpenny@gmail.com>
Date: Thu Feb 8 16:12:23 2024 +0000
radicle: add cob storage for MockRepository
Ensure that MockRepository can be used where the COB storage traits
are required. Currently, the implementations of the methods themselves
are not required -- they are left as `todo!()`.
Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
diff --git a/radicle/src/test/storage.rs b/radicle/src/test/storage.rs
index b5385c9fc..6b4252e7e 100644
--- a/radicle/src/test/storage.rs
+++ b/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!()
+ }
+}
commit dd127d3b25bc54c100519e9ef1729ce60c454d93
Author: Fintan Halpenny <fintan.halpenny@gmail.com>
Date: Thu Feb 8 15:55:30 2024 +0000
radicle: rename resolved to is_resolved
The standard in the codebase for boolean methods is to prefix them
with `is_`.
Change `resolved` to `is_resolved`.
Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
diff --git a/radicle-httpd/src/api/json.rs b/radicle-httpd/src/api/json.rs
index 0902cdaec..ab955e206 100644
--- a/radicle-httpd/src/api/json.rs
+++ b/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(),
})
}
diff --git a/radicle/src/cob/thread.rs b/radicle/src/cob/thread.rs
index 0ed50d4d6..ff3c78eae 100644
--- a/radicle/src/cob/thread.rs
+++ b/radicle/src/cob/thread.rs
@@ -211,7 +211,7 @@ impl<L> Comment<L> {
}
/// Get comment resolution status.
- pub fn resolved(&self) -> bool {
+ pub fn is_resolved(&self) -> bool {
self.resolved
}
commit 16bbce390fe43de16a9ba21d844341ab6b6be8a2
Author: Fintan Halpenny <fintan.halpenny@gmail.com>
Date: Fri Feb 16 10:32:28 2024 +0000
radicle: introduce cob::TypedId
The combination of a COB's `TypeName` and `ObjectId` is useful for
parsing a Radicle COB refname, i.e. `refs/cobs/<typename>/<object
id>`.
Introduce `TypedId` for this and provide helper functions for parsing
from the git refname types, as well as being able to tell what kind of
COB it is.
Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
diff --git a/radicle/src/cob.rs b/radicle/src/cob.rs
index 7f5ff04f8..b9d7db89c 100644
--- a/radicle/src/cob.rs
+++ b/radicle/src/cob.rs
@@ -18,3 +18,48 @@ pub use radicle_cob::{
Store, TypeName, Update, Updated, Version,
};
pub use radicle_cob::{create, get, git, list, remove, update};
+
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct TypedId {
+ pub id: ObjectId,
+ pub type_name: TypeName,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum ParseIdentifierError {
+ #[error(transparent)]
+ TypeName(#[from] TypeNameParse),
+ #[error(transparent)]
+ ObjectId(#[from] object::ParseObjectId),
+}
+
+impl TypedId {
+ pub fn is_issue(&self) -> bool {
+ self.type_name == *issue::TYPENAME
+ }
+
+ pub fn is_patch(&self) -> bool {
+ self.type_name == *patch::TYPENAME
+ }
+
+ pub fn from_namespaced(
+ n: &crate::git::Namespaced,
+ ) -> Result<Option<Self>, ParseIdentifierError> {
+ Self::from_qualified(&n.strip_namespace())
+ }
+
+ 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),
+ }
+ }
+}
commit bf7c10ba43d56cf7a7a54d9cf693f8d16f117d91
Author: Fintan Halpenny <fintan.halpenny@gmail.com>
Date: Fri Feb 9 10:21:31 2024 +0000
cob: move unwrap_used pragma
Clippy does not recognise this `allow` pragma to be used at the
`unwrap` call site. Instead, if it's moved up to the match case it
will be recognised.
Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
diff --git a/radicle-cob/src/trailers.rs b/radicle-cob/src/trailers.rs
index dc7ef317b..2403a8b5a 100644
--- a/radicle-cob/src/trailers.rs
+++ b/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(),
}
commit fa0a38162787467d56441838513b438b48d797e5
Author: Fintan Halpenny <fintan.halpenny@gmail.com>
Date: Thu Feb 15 16:18:30 2024 +0000
cli: wildcard for rad-inbox.md test
The SHAs are not stable from test run to test run. This is strange
since every other CLI test has stable identifiers.
For now, use wildcards to replace the COB identifiers until the
culprit for the instability can be found.
Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
diff --git a/radicle-cli/examples/rad-inbox.md b/radicle-cli/examples/rad-inbox.md
index fb75d4f3e..95069377b 100644
--- a/radicle-cli/examples/rad-inbox.md
+++ b/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 │
╰────────────────────────────────────────────────────────────────╯
```
Exit code: 0
shell: 'cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny clippy::all cargo build --all-targets --workspace cargo doc --workspace cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name 61720a2e-907e-4944-8b6a-835eb6d9cfb7 -v /opt/radcis/ci.rad.levitte.org/cci/state/61720a2e-907e-4944-8b6a-835eb6d9cfb7/s:/61720a2e-907e-4944-8b6a-835eb6d9cfb7/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/61720a2e-907e-4944-8b6a-835eb6d9cfb7/w:/61720a2e-907e-4944-8b6a-835eb6d9cfb7/w -w /61720a2e-907e-4944-8b6a-835eb6d9cfb7/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:trixie bash /61720a2e-907e-4944-8b6a-835eb6d9cfb7/s/script.sh
+ cargo --version
info: syncing channel updates for '1.74-x86_64-unknown-linux-gnu'
info: latest update on 2023-12-07, rust version 1.74.1 (a28077b28 2023-12-04)
info: downloading component 'cargo'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: installing component 'cargo'
info: installing component 'rust-std'
info: installing component 'rustc'
cargo 1.74.1 (ecb9851af 2023-10-18)
+ rustc --version
rustc 1.74.1 (a28077b28 2023-12-04)
+ cargo fmt --check
error: 'cargo-fmt' is not installed for the toolchain '1.74-x86_64-unknown-linux-gnu'.
To install, run `rustup component add --toolchain 1.74-x86_64-unknown-linux-gnu rustfmt`
Exit code: 1
{
"response": "finished",
"result": "failure"
}