Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Implement a custom `list_patches` method to read directly from cache
Merged did:key:z6MkkfM3...sVz5 opened 1 year ago

Instead of generating a paginated result from an unsorted list of patches, we should try to sort all patches (and issues in a follow up) by their latest timestamp on the database and then in the tauri command paginate the sorted list, so we can provide a infinite loading with a natural order.

In a follow up it would probably also make sense to generate sql statements according to the thing we want cobs to order by

16 files changed +784 -596 038a21cb f34522b6
modified crates/radicle-tauri/src/commands/cob/patch.rs
@@ -2,25 +2,55 @@ use radicle::cob;
use radicle::git;
use radicle::identity;
use radicle::patch;
-

use radicle::patch::{Action, TYPENAME};
+

use radicle_types as types;
+
use radicle_types::cobs;
+
use radicle_types::domain::patch::models;
+
use radicle_types::domain::patch::service::Service;
+
use radicle_types::domain::patch::traits::PatchService;
use radicle_types::error::Error;
+
use radicle_types::outbound::sqlite::Sqlite;
use radicle_types::traits::cobs::Cobs;
use radicle_types::traits::patch::Patches;
use radicle_types::traits::patch::PatchesMut;
+
use radicle_types::traits::Profile;

use crate::AppState;

#[tauri::command]
pub async fn list_patches(
    ctx: tauri::State<'_, AppState>,
+
    sqlite_service: tauri::State<'_, Service<Sqlite>>,
    rid: identity::RepoId,
    status: Option<types::cobs::query::PatchStatus>,
    skip: Option<usize>,
    take: Option<usize>,
-
) -> Result<types::cobs::PaginatedQuery<Vec<types::cobs::patch::Patch>>, Error> {
-
    ctx.list_patches(rid, status, skip, take)
+
) -> Result<types::cobs::PaginatedQuery<Vec<models::patch::Patch>>, Error> {
+
    let profile = ctx.profile();
+
    let cursor = skip.unwrap_or(0);
+
    let take = take.unwrap_or(20);
+
    let aliases = profile.aliases();
+
    let patches = match status {
+
        None => sqlite_service.list(rid)?.collect::<Vec<_>>(),
+
        Some(s) => sqlite_service
+
            .list_by_status(rid, s.into())?
+
            .collect::<Vec<_>>(),
+
    };
+
    let more = cursor + take < patches.len();
+

+
    let patches = patches
+
        .into_iter()
+
        .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
+
        .skip(cursor)
+
        .take(take)
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(cobs::PaginatedQuery {
+
        cursor,
+
        more,
+
        content: patches,
+
    })
}

#[tauri::command]
@@ -28,7 +58,7 @@ pub fn patch_by_id(
    ctx: tauri::State<AppState>,
    rid: identity::RepoId,
    id: git::Oid,
-
) -> Result<Option<types::cobs::patch::Patch>, Error> {
+
) -> Result<Option<models::patch::Patch>, Error> {
    ctx.get_patch(rid, id)
}

@@ -37,7 +67,7 @@ pub fn revisions_by_patch(
    ctx: tauri::State<AppState>,
    rid: identity::RepoId,
    id: git::Oid,
-
) -> Result<Option<Vec<types::cobs::patch::Revision>>, Error> {
+
) -> Result<Option<Vec<models::patch::Revision>>, Error> {
    ctx.revisions_by_patch(rid, id)
}

@@ -47,7 +77,7 @@ pub fn revision_by_patch_and_id(
    rid: identity::RepoId,
    id: git::Oid,
    revision_id: git::Oid,
-
) -> Result<Option<types::cobs::patch::Revision>, Error> {
+
) -> Result<Option<models::patch::Revision>, Error> {
    ctx.revision_by_id(rid, id, revision_id)
}

@@ -86,7 +116,7 @@ pub fn edit_draft_review(
    ctx: tauri::State<AppState>,
    rid: identity::RepoId,
    cob_id: git::Oid,
-
    edit: types::cobs::patch::ReviewEdit,
+
    edit: models::patch::ReviewEdit,
) -> Result<(), Error> {
    ctx.edit_draft_review(rid, cob_id, edit)
}
@@ -97,7 +127,7 @@ pub fn get_draft_review(
    rid: identity::RepoId,
    cob_id: git::Oid,
    revision_id: patch::RevisionId,
-
) -> Option<types::cobs::patch::Review> {
+
) -> Option<models::patch::Review> {
    ctx.get_draft_review(rid, cob_id, revision_id)
}

@@ -106,9 +136,9 @@ pub fn edit_patch(
    ctx: tauri::State<AppState>,
    rid: identity::RepoId,
    cob_id: git::Oid,
-
    action: types::cobs::patch::Action,
-
    opts: types::cobs::CobOptions,
-
) -> Result<types::cobs::patch::Patch, Error> {
+
    action: models::patch::Action,
+
    opts: cobs::CobOptions,
+
) -> Result<models::patch::Patch, Error> {
    ctx.edit_patch(rid, cob_id, action, opts)
}

modified crates/radicle-tauri/src/lib.rs
@@ -2,6 +2,7 @@ mod commands;

use tauri::{Emitter, Manager};

+
use radicle::cob::cache::COBS_DB_FILE;
use radicle::node::{Handle, NOTIFICATIONS_DB_FILE};
use radicle::Node;

@@ -39,6 +40,10 @@ pub fn run() {
            )?;
            let inbox_service = domain::inbox::service::Service::new(inbox_db);

+
            let patch_db =
+
                radicle_types::outbound::sqlite::Sqlite::reader(profile.cobs().join(COBS_DB_FILE))?;
+
            let patch_service = domain::patch::service::Service::new(patch_db);
+

            let events_handler = app.handle().clone();
            let node_handler = app.handle().clone();

@@ -46,6 +51,7 @@ pub fn run() {
            let node_status = node.clone();

            app.manage(inbox_service);
+
            app.manage(patch_service);
            app.manage(AppState { profile });

            tauri::async_runtime::spawn(async move {
modified crates/radicle-types/src/cobs/patch.rs
@@ -1,500 +1 @@
-
use std::collections::BTreeMap;
-
use std::collections::BTreeSet;

-
use radicle::node::AliasStore;
-
use serde::{Deserialize, Serialize};
-
use ts_rs::TS;
-

-
use radicle::cob;
-
use radicle::git;
-
use radicle::identity;
-
use radicle::patch;
-

-
use crate::cobs;
-

-
#[derive(Debug, TS, Serialize)]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
#[serde(rename_all = "camelCase")]
-
pub struct Patch {
-
    id: String,
-
    author: cobs::Author,
-
    title: String,
-
    #[ts(as = "String")]
-
    base: git::Oid,
-
    #[ts(as = "String")]
-
    head: git::Oid,
-
    state: State,
-
    assignees: Vec<cobs::Author>,
-
    #[ts(as = "Vec<String>")]
-
    labels: Vec<cob::Label>,
-
    #[ts(type = "number")]
-
    timestamp: cob::Timestamp,
-
    revision_count: usize,
-
}
-

-
impl Patch {
-
    pub fn new(id: patch::PatchId, patch: &patch::Patch, aliases: &impl AliasStore) -> Self {
-
        Self {
-
            id: id.to_string(),
-
            author: cobs::Author::new(patch.author().id(), aliases),
-
            title: patch.title().to_string(),
-
            state: patch.state().clone().into(),
-
            base: *patch.base(),
-
            head: *patch.head(),
-
            assignees: patch
-
                .assignees()
-
                .map(|did| cobs::Author::new(&did, aliases))
-
                .collect::<Vec<_>>(),
-
            labels: patch.labels().cloned().collect::<Vec<_>>(),
-
            timestamp: patch.timestamp(),
-
            revision_count: patch.revisions().count(),
-
        }
-
    }
-

-
    pub fn timestamp(&self) -> u64 {
-
        self.timestamp.as_millis()
-
    }
-
}
-

-
#[derive(Debug, Serialize, Deserialize, TS)]
-
#[serde(rename_all = "camelCase", tag = "status")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub enum State {
-
    Draft,
-
    Open {
-
        #[serde(skip_serializing_if = "Vec::is_empty")]
-
        #[serde(default)]
-
        #[ts(as = "Option<Vec<(String, String)>>", optional)]
-
        conflicts: Vec<(patch::RevisionId, git::Oid)>,
-
    },
-
    Archived,
-
    Merged {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[ts(as = "String")]
-
        commit: git::Oid,
-
    },
-
}
-

-
impl From<State> for patch::State {
-
    fn from(value: State) -> Self {
-
        match value {
-
            State::Archived => Self::Archived,
-
            State::Draft => Self::Draft,
-
            State::Merged { revision, commit } => Self::Merged { revision, commit },
-
            State::Open { conflicts } => Self::Open { conflicts },
-
        }
-
    }
-
}
-

-
impl From<patch::State> for State {
-
    fn from(value: patch::State) -> Self {
-
        match value {
-
            patch::State::Archived => Self::Archived,
-
            patch::State::Draft => Self::Draft,
-
            patch::State::Merged { revision, commit } => Self::Merged { revision, commit },
-
            patch::State::Open { conflicts } => Self::Open { conflicts },
-
        }
-
    }
-
}
-

-
#[derive(Serialize, Deserialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub struct ReviewEdit {
-
    #[ts(as = "String")]
-
    pub review_id: cob::patch::ReviewId,
-
    #[serde(default, skip_serializing_if = "Option::is_none")]
-
    #[ts(optional)]
-
    pub verdict: Option<Verdict>,
-
    #[serde(default, skip_serializing_if = "Option::is_none")]
-
    #[ts(optional)]
-
    pub summary: Option<String>,
-
    #[ts(as = "Option<Vec<String>>", optional)]
-
    pub labels: Vec<cob::Label>,
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub struct Revision {
-
    #[ts(as = "String")]
-
    id: patch::RevisionId,
-
    author: cobs::Author,
-
    description: Vec<Edit>,
-
    #[ts(as = "String")]
-
    base: git::Oid,
-
    #[ts(as = "String")]
-
    head: git::Oid,
-
    #[ts(as = "Option<_>", optional)]
-
    reviews: Vec<Review>,
-
    #[ts(type = "number")]
-
    timestamp: cob::common::Timestamp,
-
    #[ts(as = "Option<_>", optional)]
-
    discussion: Vec<cobs::thread::Comment<cobs::thread::CodeLocation>>,
-
    #[ts(as = "Option<_>", optional)]
-
    reactions: Vec<cobs::thread::Reaction>,
-
}
-

-
impl Revision {
-
    pub fn new(value: cob::patch::Revision, aliases: &impl AliasStore) -> Self {
-
        Self {
-
            id: value.id(),
-
            author: cobs::Author::new(value.author().id(), aliases),
-
            description: value
-
                .edits()
-
                .map(|e| Edit::new(e, aliases))
-
                .collect::<Vec<_>>(),
-
            base: *value.base(),
-
            head: value.head(),
-
            reviews: value
-
                .reviews()
-
                .map(|(_, r)| Review::new(r.clone(), aliases))
-
                .collect::<Vec<_>>(),
-
            timestamp: value.timestamp(),
-
            discussion: value
-
                .discussion()
-
                .comments()
-
                .map(|(id, c)| {
-
                    cobs::thread::Comment::<cobs::thread::CodeLocation>::new(
-
                        *id,
-
                        c.clone(),
-
                        aliases,
-
                    )
-
                })
-
                .collect::<Vec<_>>(),
-
            reactions: value
-
                .reactions()
-
                .iter()
-
                .flat_map(|(location, reactions)| {
-
                    let reaction_by_author = reactions.iter().fold(
-
                        BTreeMap::new(),
-
                        |mut acc: BTreeMap<&cob::Reaction, Vec<_>>, (author, emoji)| {
-
                            acc.entry(emoji).or_default().push(author);
-
                            acc
-
                        },
-
                    );
-
                    reaction_by_author
-
                        .into_iter()
-
                        .map(|(emoji, authors)| {
-
                            cobs::thread::Reaction::new(
-
                                *emoji,
-
                                authors.into_iter().map(Into::into).collect::<Vec<_>>(),
-
                                location
-
                                    .as_ref()
-
                                    .map(|l| cobs::thread::CodeLocation::new(l.clone())),
-
                                aliases,
-
                            )
-
                        })
-
                        .collect::<Vec<_>>()
-
                })
-
                .collect::<Vec<_>>(),
-
        }
-
    }
-
}
-

-
#[derive(TS, Serialize)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub struct Edit {
-
    pub author: cobs::Author,
-
    #[ts(type = "number")]
-
    pub timestamp: cob::common::Timestamp,
-
    pub body: String,
-
    #[ts(as = "Option<_>", optional)]
-
    pub embeds: Vec<cobs::thread::Embed>,
-
}
-

-
impl Edit {
-
    pub fn new(edit: &cob::thread::Edit, aliases: &impl AliasStore) -> Self {
-
        Self {
-
            author: cobs::Author::new(&edit.author.into(), aliases),
-
            timestamp: edit.timestamp,
-
            body: edit.body.clone(),
-
            embeds: edit
-
                .embeds
-
                .iter()
-
                .cloned()
-
                .map(|e| e.into())
-
                .collect::<Vec<_>>(),
-
        }
-
    }
-
}
-

-
#[derive(Serialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub struct Review {
-
    #[ts(as = "String")]
-
    id: cob::patch::ReviewId,
-
    author: cobs::Author,
-
    #[serde(default, skip_serializing_if = "Option::is_none")]
-
    #[ts(optional)]
-
    verdict: Option<Verdict>,
-
    #[serde(default, skip_serializing_if = "Option::is_none")]
-
    #[ts(optional)]
-
    summary: Option<String>,
-
    comments: Vec<cobs::thread::Comment<cobs::thread::CodeLocation>>,
-
    #[ts(type = "number")]
-
    timestamp: cob::common::Timestamp,
-
}
-

-
impl Review {
-
    pub fn new(review: cob::patch::Review, aliases: &impl AliasStore) -> Self {
-
        Self {
-
            id: review.id(),
-
            author: cobs::Author::new(&review.author().id, aliases),
-
            verdict: review.verdict().map(|v| v.into()),
-
            summary: review.summary().map(|s| s.to_string()),
-
            comments: review
-
                .comments()
-
                .map(|(id, c)| {
-
                    cobs::thread::Comment::<cobs::thread::CodeLocation>::new(
-
                        *id,
-
                        c.clone(),
-
                        aliases,
-
                    )
-
                })
-
                .collect::<Vec<_>>(),
-
            timestamp: review.timestamp(),
-
        }
-
    }
-
}
-

-
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
-
#[serde(rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub enum Verdict {
-
    Accept,
-
    Reject,
-
}
-

-
impl From<cob::patch::Verdict> for Verdict {
-
    fn from(value: cob::patch::Verdict) -> Self {
-
        match value {
-
            cob::patch::Verdict::Accept => Self::Accept,
-
            cob::patch::Verdict::Reject => Self::Reject,
-
        }
-
    }
-
}
-

-
impl From<Verdict> for cob::patch::Verdict {
-
    fn from(value: Verdict) -> Self {
-
        match value {
-
            Verdict::Accept => Self::Accept,
-
            Verdict::Reject => Self::Reject,
-
        }
-
    }
-
}
-

-
#[derive(Debug, Serialize, Deserialize, TS)]
-
#[serde(tag = "type", rename_all = "camelCase")]
-
#[ts(export)]
-
#[ts(export_to = "cob/patch/")]
-
pub enum Action {
-
    #[serde(rename = "edit")]
-
    Edit {
-
        title: String,
-
        #[ts(as = "String")]
-
        target: patch::MergeTarget,
-
    },
-
    #[serde(rename = "label")]
-
    Label {
-
        #[ts(as = "Vec<String>")]
-
        labels: BTreeSet<cob::Label>,
-
    },
-
    #[serde(rename = "lifecycle")]
-
    Lifecycle {
-
        #[ts(type = "{ status: 'draft' | 'open' | 'archived' }")]
-
        state: patch::Lifecycle,
-
    },
-
    #[serde(rename = "assign")]
-
    Assign {
-
        #[ts(as = "Vec<String>")]
-
        assignees: BTreeSet<identity::Did>,
-
    },
-
    #[serde(rename = "merge")]
-
    Merge {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[ts(as = "String")]
-
        commit: git::Oid,
-
    },
-

-
    #[serde(rename = "review")]
-
    Review {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        summary: Option<String>,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        verdict: Option<Verdict>,
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        #[ts(as = "Option<Vec<String>>", optional)]
-
        labels: Vec<cob::Label>,
-
    },
-
    #[serde(rename = "review.edit")]
-
    ReviewEdit {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        summary: Option<String>,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        verdict: Option<Verdict>,
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        #[ts(as = "Option<Vec<String>>", optional)]
-
        labels: Vec<cob::Label>,
-
    },
-
    #[serde(rename = "review.redact")]
-
    ReviewRedact {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
    },
-
    #[serde(rename = "review.comment")]
-
    ReviewComment {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        body: String,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        location: Option<cobs::thread::CodeLocation>,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(as = "Option<String>", optional)]
-
        reply_to: Option<cob::thread::CommentId>,
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-
    #[serde(rename = "review.comment.edit")]
-
    ReviewCommentEdit {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[ts(as = "String")]
-
        comment: cob::EntryId,
-
        body: String,
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-
    #[serde(rename = "review.comment.redact")]
-
    ReviewCommentRedact {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[ts(as = "String")]
-
        comment: cob::EntryId,
-
    },
-
    #[serde(rename = "review.comment.react")]
-
    ReviewCommentReact {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[ts(as = "String")]
-
        comment: cob::EntryId,
-
        #[ts(as = "String")]
-
        reaction: cob::Reaction,
-
        active: bool,
-
    },
-
    #[serde(rename = "review.comment.resolve")]
-
    ReviewCommentResolve {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[ts(as = "String")]
-
        comment: cob::EntryId,
-
    },
-
    #[serde(rename = "review.comment.unresolve")]
-
    ReviewCommentUnresolve {
-
        #[ts(as = "String")]
-
        review: patch::ReviewId,
-
        #[ts(as = "String")]
-
        comment: cob::EntryId,
-
    },
-

-
    #[serde(rename = "revision")]
-
    Revision {
-
        description: String,
-
        #[ts(as = "String")]
-
        base: git::Oid,
-
        #[ts(as = "String")]
-
        oid: git::Oid,
-
        #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
-
        #[ts(as = "Option<BTreeSet<(String, String)>>", optional)]
-
        resolves: BTreeSet<(cob::EntryId, cob::thread::CommentId)>,
-
    },
-
    #[serde(rename = "revision.edit")]
-
    RevisionEdit {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        description: String,
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-
    #[serde(rename = "revision.react")]
-
    RevisionReact {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        location: Option<cobs::thread::CodeLocation>,
-
        #[ts(as = "String")]
-
        reaction: cob::Reaction,
-
        active: bool,
-
    },
-
    #[serde(rename = "revision.redact")]
-
    RevisionRedact {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
    },
-
    #[serde(rename_all = "camelCase")]
-
    #[serde(rename = "revision.comment")]
-
    RevisionComment {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(optional)]
-
        location: Option<cobs::thread::CodeLocation>,
-
        body: String,
-
        #[serde(default, skip_serializing_if = "Option::is_none")]
-
        #[ts(as = "Option<String>", optional)]
-
        reply_to: Option<cob::thread::CommentId>,
-
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-
    #[serde(rename = "revision.comment.edit")]
-
    RevisionCommentEdit {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[ts(as = "String")]
-
        comment: cob::thread::CommentId,
-
        body: String,
-
        #[ts(as = "Option<_>", optional)]
-
        embeds: Vec<cobs::thread::Embed>,
-
    },
-
    #[serde(rename = "revision.comment.redact")]
-
    RevisionCommentRedact {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[ts(as = "String")]
-
        comment: cob::thread::CommentId,
-
    },
-
    #[serde(rename = "revision.comment.react")]
-
    RevisionCommentReact {
-
        #[ts(as = "String")]
-
        revision: patch::RevisionId,
-
        #[ts(as = "String")]
-
        comment: cob::thread::CommentId,
-
        #[ts(as = "String")]
-
        reaction: cob::Reaction,
-
        active: bool,
-
    },
-
}
modified crates/radicle-types/src/cobs/thread.rs
@@ -5,6 +5,7 @@ use radicle::node::AliasStore;
use radicle::{cob, git, identity};

use crate::cobs;
+
use crate::domain::patch::models;

#[derive(TS, Serialize, Deserialize)]
#[ts(export)]
@@ -39,7 +40,7 @@ pub struct Comment<T = cobs::Never> {
    #[ts(as = "String")]
    id: cob::thread::CommentId,
    author: cobs::Author,
-
    edits: Vec<cobs::patch::Edit>,
+
    edits: Vec<models::patch::Edit>,
    reactions: Vec<cobs::thread::Reaction>,
    #[ts(as = "Option<String>", optional)]
    reply_to: Option<cob::thread::CommentId>,
@@ -62,7 +63,7 @@ impl Comment<CodeLocation> {
            author: cobs::Author::new(&comment.author().into(), aliases),
            edits: comment
                .edits()
-
                .map(|e| cobs::patch::Edit::new(e, aliases))
+
                .map(|e| models::patch::Edit::new(e, aliases))
                .collect::<Vec<_>>(),
            reactions: comment
                .reactions()
@@ -100,7 +101,7 @@ impl Comment<cobs::Never> {
            author: cobs::Author::new(&comment.author().into(), aliases),
            edits: comment
                .edits()
-
                .map(|e| cobs::patch::Edit::new(e, aliases))
+
                .map(|e| models::patch::Edit::new(e, aliases))
                .collect::<Vec<_>>(),
            reactions: comment
                .reactions()
modified crates/radicle-types/src/domain.rs
@@ -1 +1,2 @@
pub mod inbox;
+
pub mod patch;
modified crates/radicle-types/src/domain/inbox/models/notification.rs
@@ -9,6 +9,7 @@ use ts_rs::TS;

use crate::cobs::stream::{self, CobStream};
use crate::cobs::{self, Author};
+
use crate::domain::patch::models;

#[derive(Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
@@ -157,8 +158,8 @@ pub struct Patch {
    #[ts(type = "number")]
    pub timestamp: localtime::LocalTime,
    pub title: String,
-
    pub status: cobs::patch::State,
-
    pub actions: Vec<ActionWithAuthor<cobs::patch::Action>>,
+
    pub status: models::patch::State,
+
    pub actions: Vec<ActionWithAuthor<models::patch::Action>>,
}

/// Type of notification.
added crates/radicle-types/src/domain/patch.rs
@@ -0,0 +1,3 @@
+
pub mod models;
+
pub mod service;
+
pub mod traits;
added crates/radicle-types/src/domain/patch/models.rs
@@ -0,0 +1 @@
+
pub mod patch;
added crates/radicle-types/src/domain/patch/models/patch.rs
@@ -0,0 +1,510 @@
+
use std::collections::BTreeMap;
+
use std::collections::BTreeSet;
+

+
use radicle::node::AliasStore;
+
use serde::{Deserialize, Serialize};
+
use ts_rs::TS;
+

+
use radicle::cob;
+
use radicle::git;
+
use radicle::identity;
+
use radicle::patch;
+

+
use crate::cobs;
+

+
#[derive(Debug, TS, Serialize)]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
#[serde(rename_all = "camelCase")]
+
pub struct Patch {
+
    id: String,
+
    author: cobs::Author,
+
    title: String,
+
    #[ts(as = "String")]
+
    base: git::Oid,
+
    #[ts(as = "String")]
+
    head: git::Oid,
+
    state: State,
+
    assignees: Vec<cobs::Author>,
+
    #[ts(as = "Vec<String>")]
+
    labels: Vec<cob::Label>,
+
    #[ts(type = "number")]
+
    timestamp: cob::Timestamp,
+
    revision_count: usize,
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum ListPatchesError {
+
    #[error(transparent)]
+
    Sqlite(#[from] sqlite::Error),
+

+
    #[error(transparent)]
+
    Unknown(#[from] anyhow::Error),
+
    // to be extended as new error scenarios are introduced
+
}
+

+
impl Patch {
+
    pub fn new(id: patch::PatchId, patch: &patch::Patch, aliases: &impl AliasStore) -> Self {
+
        Self {
+
            id: id.to_string(),
+
            author: cobs::Author::new(patch.author().id(), aliases),
+
            title: patch.title().to_string(),
+
            state: patch.state().clone().into(),
+
            base: *patch.base(),
+
            head: *patch.head(),
+
            assignees: patch
+
                .assignees()
+
                .map(|did| cobs::Author::new(&did, aliases))
+
                .collect::<Vec<_>>(),
+
            labels: patch.labels().cloned().collect::<Vec<_>>(),
+
            timestamp: patch.timestamp(),
+
            revision_count: patch.revisions().count(),
+
        }
+
    }
+

+
    pub fn timestamp(&self) -> u64 {
+
        self.timestamp.as_millis()
+
    }
+
}
+

+
#[derive(Debug, Serialize, Deserialize, TS)]
+
#[serde(rename_all = "camelCase", tag = "status")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub enum State {
+
    Draft,
+
    Open {
+
        #[serde(skip_serializing_if = "Vec::is_empty")]
+
        #[serde(default)]
+
        #[ts(as = "Option<Vec<(String, String)>>", optional)]
+
        conflicts: Vec<(patch::RevisionId, git::Oid)>,
+
    },
+
    Archived,
+
    Merged {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[ts(as = "String")]
+
        commit: git::Oid,
+
    },
+
}
+

+
impl From<State> for patch::State {
+
    fn from(value: State) -> Self {
+
        match value {
+
            State::Archived => Self::Archived,
+
            State::Draft => Self::Draft,
+
            State::Merged { revision, commit } => Self::Merged { revision, commit },
+
            State::Open { conflicts } => Self::Open { conflicts },
+
        }
+
    }
+
}
+

+
impl From<patch::State> for State {
+
    fn from(value: patch::State) -> Self {
+
        match value {
+
            patch::State::Archived => Self::Archived,
+
            patch::State::Draft => Self::Draft,
+
            patch::State::Merged { revision, commit } => Self::Merged { revision, commit },
+
            patch::State::Open { conflicts } => Self::Open { conflicts },
+
        }
+
    }
+
}
+

+
#[derive(Serialize, Deserialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub struct ReviewEdit {
+
    #[ts(as = "String")]
+
    pub review_id: cob::patch::ReviewId,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    #[ts(optional)]
+
    pub verdict: Option<Verdict>,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    #[ts(optional)]
+
    pub summary: Option<String>,
+
    #[ts(as = "Option<Vec<String>>", optional)]
+
    pub labels: Vec<cob::Label>,
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub struct Revision {
+
    #[ts(as = "String")]
+
    id: patch::RevisionId,
+
    author: cobs::Author,
+
    description: Vec<Edit>,
+
    #[ts(as = "String")]
+
    base: git::Oid,
+
    #[ts(as = "String")]
+
    head: git::Oid,
+
    #[ts(as = "Option<_>", optional)]
+
    reviews: Vec<Review>,
+
    #[ts(type = "number")]
+
    timestamp: cob::common::Timestamp,
+
    #[ts(as = "Option<_>", optional)]
+
    discussion: Vec<cobs::thread::Comment<cobs::thread::CodeLocation>>,
+
    #[ts(as = "Option<_>", optional)]
+
    reactions: Vec<cobs::thread::Reaction>,
+
}
+

+
impl Revision {
+
    pub fn new(value: cob::patch::Revision, aliases: &impl AliasStore) -> Self {
+
        Self {
+
            id: value.id(),
+
            author: cobs::Author::new(value.author().id(), aliases),
+
            description: value
+
                .edits()
+
                .map(|e| Edit::new(e, aliases))
+
                .collect::<Vec<_>>(),
+
            base: *value.base(),
+
            head: value.head(),
+
            reviews: value
+
                .reviews()
+
                .map(|(_, r)| Review::new(r.clone(), aliases))
+
                .collect::<Vec<_>>(),
+
            timestamp: value.timestamp(),
+
            discussion: value
+
                .discussion()
+
                .comments()
+
                .map(|(id, c)| {
+
                    cobs::thread::Comment::<cobs::thread::CodeLocation>::new(
+
                        *id,
+
                        c.clone(),
+
                        aliases,
+
                    )
+
                })
+
                .collect::<Vec<_>>(),
+
            reactions: value
+
                .reactions()
+
                .iter()
+
                .flat_map(|(location, reactions)| {
+
                    let reaction_by_author = reactions.iter().fold(
+
                        BTreeMap::new(),
+
                        |mut acc: BTreeMap<&cob::Reaction, Vec<_>>, (author, emoji)| {
+
                            acc.entry(emoji).or_default().push(author);
+
                            acc
+
                        },
+
                    );
+
                    reaction_by_author
+
                        .into_iter()
+
                        .map(|(emoji, authors)| {
+
                            cobs::thread::Reaction::new(
+
                                *emoji,
+
                                authors.into_iter().map(Into::into).collect::<Vec<_>>(),
+
                                location
+
                                    .as_ref()
+
                                    .map(|l| cobs::thread::CodeLocation::new(l.clone())),
+
                                aliases,
+
                            )
+
                        })
+
                        .collect::<Vec<_>>()
+
                })
+
                .collect::<Vec<_>>(),
+
        }
+
    }
+
}
+

+
#[derive(TS, Serialize)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub struct Edit {
+
    pub author: cobs::Author,
+
    #[ts(type = "number")]
+
    pub timestamp: cob::common::Timestamp,
+
    pub body: String,
+
    #[ts(as = "Option<_>", optional)]
+
    pub embeds: Vec<cobs::thread::Embed>,
+
}
+

+
impl Edit {
+
    pub fn new(edit: &cob::thread::Edit, aliases: &impl AliasStore) -> Self {
+
        Self {
+
            author: cobs::Author::new(&edit.author.into(), aliases),
+
            timestamp: edit.timestamp,
+
            body: edit.body.clone(),
+
            embeds: edit
+
                .embeds
+
                .iter()
+
                .cloned()
+
                .map(|e| e.into())
+
                .collect::<Vec<_>>(),
+
        }
+
    }
+
}
+

+
#[derive(Serialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub struct Review {
+
    #[ts(as = "String")]
+
    id: cob::patch::ReviewId,
+
    author: cobs::Author,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    #[ts(optional)]
+
    verdict: Option<Verdict>,
+
    #[serde(default, skip_serializing_if = "Option::is_none")]
+
    #[ts(optional)]
+
    summary: Option<String>,
+
    comments: Vec<cobs::thread::Comment<cobs::thread::CodeLocation>>,
+
    #[ts(type = "number")]
+
    timestamp: cob::common::Timestamp,
+
}
+

+
impl Review {
+
    pub fn new(review: cob::patch::Review, aliases: &impl AliasStore) -> Self {
+
        Self {
+
            id: review.id(),
+
            author: cobs::Author::new(&review.author().id, aliases),
+
            verdict: review.verdict().map(|v| v.into()),
+
            summary: review.summary().map(|s| s.to_string()),
+
            comments: review
+
                .comments()
+
                .map(|(id, c)| {
+
                    cobs::thread::Comment::<cobs::thread::CodeLocation>::new(
+
                        *id,
+
                        c.clone(),
+
                        aliases,
+
                    )
+
                })
+
                .collect::<Vec<_>>(),
+
            timestamp: review.timestamp(),
+
        }
+
    }
+
}
+

+
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+
#[serde(rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub enum Verdict {
+
    Accept,
+
    Reject,
+
}
+

+
impl From<cob::patch::Verdict> for Verdict {
+
    fn from(value: cob::patch::Verdict) -> Self {
+
        match value {
+
            cob::patch::Verdict::Accept => Self::Accept,
+
            cob::patch::Verdict::Reject => Self::Reject,
+
        }
+
    }
+
}
+

+
impl From<Verdict> for cob::patch::Verdict {
+
    fn from(value: Verdict) -> Self {
+
        match value {
+
            Verdict::Accept => Self::Accept,
+
            Verdict::Reject => Self::Reject,
+
        }
+
    }
+
}
+

+
#[derive(Debug, Serialize, Deserialize, TS)]
+
#[serde(tag = "type", rename_all = "camelCase")]
+
#[ts(export)]
+
#[ts(export_to = "cob/patch/")]
+
pub enum Action {
+
    #[serde(rename = "edit")]
+
    Edit {
+
        title: String,
+
        #[ts(as = "String")]
+
        target: patch::MergeTarget,
+
    },
+
    #[serde(rename = "label")]
+
    Label {
+
        #[ts(as = "Vec<String>")]
+
        labels: BTreeSet<cob::Label>,
+
    },
+
    #[serde(rename = "lifecycle")]
+
    Lifecycle {
+
        #[ts(type = "{ status: 'draft' | 'open' | 'archived' }")]
+
        state: patch::Lifecycle,
+
    },
+
    #[serde(rename = "assign")]
+
    Assign {
+
        #[ts(as = "Vec<String>")]
+
        assignees: BTreeSet<identity::Did>,
+
    },
+
    #[serde(rename = "merge")]
+
    Merge {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[ts(as = "String")]
+
        commit: git::Oid,
+
    },
+

+
    #[serde(rename = "review")]
+
    Review {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        summary: Option<String>,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        verdict: Option<Verdict>,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<Vec<String>>", optional)]
+
        labels: Vec<cob::Label>,
+
    },
+
    #[serde(rename = "review.edit")]
+
    ReviewEdit {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        summary: Option<String>,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        verdict: Option<Verdict>,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<Vec<String>>", optional)]
+
        labels: Vec<cob::Label>,
+
    },
+
    #[serde(rename = "review.redact")]
+
    ReviewRedact {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
    },
+
    #[serde(rename = "review.comment")]
+
    ReviewComment {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        body: String,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        location: Option<cobs::thread::CodeLocation>,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(as = "Option<String>", optional)]
+
        reply_to: Option<cob::thread::CommentId>,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<cobs::thread::Embed>,
+
    },
+
    #[serde(rename = "review.comment.edit")]
+
    ReviewCommentEdit {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[ts(as = "String")]
+
        comment: cob::EntryId,
+
        body: String,
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<cobs::thread::Embed>,
+
    },
+
    #[serde(rename = "review.comment.redact")]
+
    ReviewCommentRedact {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[ts(as = "String")]
+
        comment: cob::EntryId,
+
    },
+
    #[serde(rename = "review.comment.react")]
+
    ReviewCommentReact {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[ts(as = "String")]
+
        comment: cob::EntryId,
+
        #[ts(as = "String")]
+
        reaction: cob::Reaction,
+
        active: bool,
+
    },
+
    #[serde(rename = "review.comment.resolve")]
+
    ReviewCommentResolve {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[ts(as = "String")]
+
        comment: cob::EntryId,
+
    },
+
    #[serde(rename = "review.comment.unresolve")]
+
    ReviewCommentUnresolve {
+
        #[ts(as = "String")]
+
        review: patch::ReviewId,
+
        #[ts(as = "String")]
+
        comment: cob::EntryId,
+
    },
+

+
    #[serde(rename = "revision")]
+
    Revision {
+
        description: String,
+
        #[ts(as = "String")]
+
        base: git::Oid,
+
        #[ts(as = "String")]
+
        oid: git::Oid,
+
        #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
+
        #[ts(as = "Option<BTreeSet<(String, String)>>", optional)]
+
        resolves: BTreeSet<(cob::EntryId, cob::thread::CommentId)>,
+
    },
+
    #[serde(rename = "revision.edit")]
+
    RevisionEdit {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        description: String,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<cobs::thread::Embed>,
+
    },
+
    #[serde(rename = "revision.react")]
+
    RevisionReact {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        location: Option<cobs::thread::CodeLocation>,
+
        #[ts(as = "String")]
+
        reaction: cob::Reaction,
+
        active: bool,
+
    },
+
    #[serde(rename = "revision.redact")]
+
    RevisionRedact {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
    },
+
    #[serde(rename_all = "camelCase")]
+
    #[serde(rename = "revision.comment")]
+
    RevisionComment {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(optional)]
+
        location: Option<cobs::thread::CodeLocation>,
+
        body: String,
+
        #[serde(default, skip_serializing_if = "Option::is_none")]
+
        #[ts(as = "Option<String>", optional)]
+
        reply_to: Option<cob::thread::CommentId>,
+
        #[serde(default, skip_serializing_if = "Vec::is_empty")]
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<cobs::thread::Embed>,
+
    },
+
    #[serde(rename = "revision.comment.edit")]
+
    RevisionCommentEdit {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[ts(as = "String")]
+
        comment: cob::thread::CommentId,
+
        body: String,
+
        #[ts(as = "Option<_>", optional)]
+
        embeds: Vec<cobs::thread::Embed>,
+
    },
+
    #[serde(rename = "revision.comment.redact")]
+
    RevisionCommentRedact {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[ts(as = "String")]
+
        comment: cob::thread::CommentId,
+
    },
+
    #[serde(rename = "revision.comment.react")]
+
    RevisionCommentReact {
+
        #[ts(as = "String")]
+
        revision: patch::RevisionId,
+
        #[ts(as = "String")]
+
        comment: cob::thread::CommentId,
+
        #[ts(as = "String")]
+
        reaction: cob::Reaction,
+
        active: bool,
+
    },
+
}
added crates/radicle-types/src/domain/patch/service.rs
@@ -0,0 +1,45 @@
+
use radicle::identity;
+
use radicle::patch;
+
use radicle::patch::Patch;
+
use radicle::patch::PatchId;
+

+
use crate::domain::patch::traits::{PatchService, PatchStorage};
+

+
#[derive(Debug, Clone)]
+
pub struct Service<I>
+
where
+
    I: PatchStorage,
+
{
+
    patches: I,
+
}
+

+
impl<I> Service<I>
+
where
+
    I: PatchStorage,
+
{
+
    pub fn new(patches: I) -> Self {
+
        Self { patches }
+
    }
+
}
+

+
impl<I> PatchService for Service<I>
+
where
+
    I: PatchStorage,
+
{
+
    fn list(
+
        &self,
+
        rid: identity::RepoId,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, super::models::patch::ListPatchesError>
+
    {
+
        self.patches.list(rid)
+
    }
+

+
    fn list_by_status(
+
        &self,
+
        rid: identity::RepoId,
+
        status: patch::Status,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, super::models::patch::ListPatchesError>
+
    {
+
        self.patches.list_by_status(rid, status)
+
    }
+
}
added crates/radicle-types/src/domain/patch/traits.rs
@@ -0,0 +1,32 @@
+
use radicle::identity;
+
use radicle::patch;
+
use radicle::patch::Patch;
+
use radicle::patch::PatchId;
+

+
use crate::domain::patch::models;
+

+
pub trait PatchStorage {
+
    fn list(
+
        &self,
+
        rid: identity::RepoId,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::patch::ListPatchesError>;
+

+
    fn list_by_status(
+
        &self,
+
        rid: identity::RepoId,
+
        status: patch::Status,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::patch::ListPatchesError>;
+
}
+

+
pub trait PatchService {
+
    fn list(
+
        &self,
+
        rid: identity::RepoId,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::patch::ListPatchesError>;
+

+
    fn list_by_status(
+
        &self,
+
        rid: identity::RepoId,
+
        status: patch::Status,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, models::patch::ListPatchesError>;
+
}
modified crates/radicle-types/src/error.rs
@@ -19,6 +19,10 @@ pub enum Error {

    /// CobStore error.
    #[error(transparent)]
+
    ListPatchesError(#[from] crate::domain::patch::models::patch::ListPatchesError),
+

+
    /// CobStore error.
+
    #[error(transparent)]
    CobStore(#[from] radicle::cob::store::Error),

    /// Anyhow error.
modified crates/radicle-types/src/outbound/sqlite.rs
@@ -1,13 +1,17 @@
use std::collections::BTreeMap;
use std::path::Path;
+
use std::str::FromStr;
use std::sync::Arc;
use std::time;

+
use radicle::patch::{Patch, PatchId, Status};
use radicle::{git, identity};
use sqlite as sql;

use crate::domain::inbox::models::notification;
use crate::domain::inbox::traits::InboxStorage;
+
use crate::domain::patch::models::patch::ListPatchesError;
+
use crate::domain::patch::traits::PatchStorage;
use crate::error::Error;

pub struct Sqlite {
@@ -29,6 +33,57 @@ impl Sqlite {
    }
}

+
impl PatchStorage for Sqlite {
+
    fn list(
+
        &self,
+
        rid: identity::RepoId,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, ListPatchesError> {
+
        let mut stmt = self.db.prepare(
+
            "SELECT id, patch, (
+
                 SELECT MIN(JSON_EXTRACT(revision.value, '$.timestamp'))
+
                 FROM JSON_EACH(JSON_EXTRACT(p.patch, '$.revisions')) AS revision
+
             ) AS last_revision_timestamp
+
             FROM patches AS p
+
             WHERE repo = ?1
+
             ORDER BY last_revision_timestamp DESC;
+
             ",
+
        )?;
+
        stmt.bind((1, &rid))?;
+
        Ok(stmt.into_iter().filter_map(|row| {
+
            let row = row.ok()?;
+
            let id = PatchId::from_str(row.read::<&str, _>("id")).ok()?;
+
            let patch = serde_json::from_str::<Patch>(row.read::<&str, _>("patch")).ok()?;
+
            Some((id, patch))
+
        }))
+
    }
+

+
    fn list_by_status(
+
        &self,
+
        rid: identity::RepoId,
+
        status: Status,
+
    ) -> Result<impl Iterator<Item = (PatchId, Patch)>, ListPatchesError> {
+
        let mut stmt = self.db.prepare(
+
            "SELECT id, patch, (
+
                 SELECT MIN(JSON_EXTRACT(revision.value, '$.timestamp'))
+
                 FROM JSON_EACH(JSON_EXTRACT(p.patch, '$.revisions')) AS revision
+
             ) AS last_revision_timestamp
+
             FROM patches AS p
+
             WHERE repo = ?1
+
             AND patch->>'$.state.status' = ?2
+
             ORDER BY last_revision_timestamp DESC;
+
             ",
+
        )?;
+
        stmt.bind((1, &rid))?;
+
        stmt.bind((2, sql::Value::String(status.to_string())))?;
+
        Ok(stmt.into_iter().filter_map(|row| {
+
            let row = row.ok()?;
+
            let id = PatchId::from_str(row.read::<&str, _>("id")).ok()?;
+
            let patch = serde_json::from_str::<Patch>(row.read::<&str, _>("patch")).ok()?;
+
            Some((id, patch))
+
        }))
+
    }
+
}
+

impl InboxStorage for Sqlite {
    fn counts_by_repo(
        &self,
modified crates/radicle-types/src/traits/patch.rs
@@ -5,59 +5,22 @@ use radicle::storage::ReadStorage;
use radicle::{cob, git, identity, patch, Node};

use crate::cobs;
+
use crate::domain::patch::models;
use crate::error::Error;
use crate::traits::Profile;

pub trait Patches: Profile {
-
    fn list_patches(
-
        &self,
-
        rid: identity::RepoId,
-
        status: Option<cobs::query::PatchStatus>,
-
        skip: Option<usize>,
-
        take: Option<usize>,
-
    ) -> Result<cobs::PaginatedQuery<Vec<cobs::patch::Patch>>, Error> {
-
        let profile = self.profile();
-
        let cursor = skip.unwrap_or(0);
-
        let take = take.unwrap_or(20);
-
        let repo = profile.storage.repository(rid)?;
-
        let aliases = &profile.aliases();
-
        let cache = profile.patches(&repo)?;
-
        let patches = match status {
-
            None => cache.list()?.collect::<Vec<_>>(),
-
            Some(s) => cache.list_by_status(&s.into())?.collect::<Vec<_>>(),
-
        };
-
        let more = cursor + take < patches.len();
-

-
        let mut patches = patches
-
            .into_iter()
-
            .filter_map(|p| {
-
                p.map(|(id, patch)| cobs::patch::Patch::new(id, &patch, aliases))
-
                    .ok()
-
            })
-
            .skip(cursor)
-
            .take(take)
-
            .collect::<Vec<_>>();
-

-
        patches.sort_by_key(|b| std::cmp::Reverse(b.timestamp()));
-

-
        Ok::<_, Error>(cobs::PaginatedQuery {
-
            cursor,
-
            more,
-
            content: patches,
-
        })
-
    }
-

    fn get_patch(
        &self,
        rid: identity::RepoId,
        id: git::Oid,
-
    ) -> Result<Option<cobs::patch::Patch>, Error> {
+
    ) -> Result<Option<models::patch::Patch>, Error> {
        let profile = self.profile();
        let repo = profile.storage.repository(rid)?;
        let patches = profile.patches(&repo)?;
        let patch = patches.get(&id.into())?;
        let aliases = &profile.aliases();
-
        let patches = patch.map(|patch| cobs::patch::Patch::new(id.into(), &patch, aliases));
+
        let patches = patch.map(|patch| models::patch::Patch::new(id.into(), &patch, aliases));

        Ok::<_, Error>(patches)
    }
@@ -66,7 +29,7 @@ pub trait Patches: Profile {
        &self,
        rid: identity::RepoId,
        id: git::Oid,
-
    ) -> Result<Option<Vec<cobs::patch::Revision>>, Error> {
+
    ) -> Result<Option<Vec<models::patch::Revision>>, Error> {
        let profile = self.profile();
        let repo = profile.storage.repository(rid)?;
        let patches = profile.patches(&repo)?;
@@ -75,7 +38,7 @@ pub trait Patches: Profile {

            patch
                .revisions()
-
                .map(|(_, r)| cobs::patch::Revision::new(r.clone(), aliases))
+
                .map(|(_, r)| models::patch::Revision::new(r.clone(), aliases))
                .collect::<Vec<_>>()
        });

@@ -87,7 +50,7 @@ pub trait Patches: Profile {
        rid: identity::RepoId,
        id: git::Oid,
        revision_id: git::Oid,
-
    ) -> Result<Option<cobs::patch::Revision>, Error> {
+
    ) -> Result<Option<models::patch::Revision>, Error> {
        let profile = self.profile();
        let repo = profile.storage.repository(rid)?;
        let patches = profile.patches(&repo)?;
@@ -96,7 +59,7 @@ pub trait Patches: Profile {

            patch
                .revision(&revision_id.into())
-
                .map(|r| cobs::patch::Revision::new(r.clone(), aliases))
+
                .map(|r| models::patch::Revision::new(r.clone(), aliases))
        });

        Ok::<_, Error>(revision)
@@ -108,9 +71,9 @@ pub trait PatchesMut: Profile {
        &self,
        rid: identity::RepoId,
        cob_id: git::Oid,
-
        action: cobs::patch::Action,
+
        action: models::patch::Action,
        opts: cobs::CobOptions,
-
    ) -> Result<cobs::patch::Patch, Error> {
+
    ) -> Result<models::patch::Patch, Error> {
        let profile = self.profile();
        let mut node = Node::new(profile.socket());
        let repo = profile.storage.repository(rid)?;
@@ -120,7 +83,7 @@ pub trait PatchesMut: Profile {
        let mut patch = patches.get_mut(&cob_id.into())?;

        match action {
-
            cobs::patch::Action::RevisionEdit {
+
            models::patch::Action::RevisionEdit {
                revision,
                description,
                embeds,
@@ -132,13 +95,13 @@ pub trait PatchesMut: Profile {
                    &signer,
                )?;
            }
-
            cobs::patch::Action::RevisionCommentRedact { revision, comment } => {
+
            models::patch::Action::RevisionCommentRedact { revision, comment } => {
                patch.comment_redact(revision, comment, &signer)?;
            }
-
            cobs::patch::Action::ReviewCommentRedact { review, comment } => {
+
            models::patch::Action::ReviewCommentRedact { review, comment } => {
                patch.redact_review_comment(review, comment, &signer)?;
            }
-
            cobs::patch::Action::ReviewCommentReact {
+
            models::patch::Action::ReviewCommentReact {
                review,
                comment,
                reaction,
@@ -146,16 +109,16 @@ pub trait PatchesMut: Profile {
            } => {
                patch.react_review_comment(review, comment, reaction, active, &signer)?;
            }
-
            cobs::patch::Action::ReviewCommentResolve { review, comment } => {
+
            models::patch::Action::ReviewCommentResolve { review, comment } => {
                patch.resolve_review_comment(review, comment, &signer)?;
            }
-
            cobs::patch::Action::ReviewCommentUnresolve { review, comment } => {
+
            models::patch::Action::ReviewCommentUnresolve { review, comment } => {
                patch.unresolve_review_comment(review, comment, &signer)?;
            }
-
            cobs::patch::Action::Edit { title, target } => {
+
            models::patch::Action::Edit { title, target } => {
                patch.edit(title, target, &signer)?;
            }
-
            cobs::patch::Action::ReviewEdit {
+
            models::patch::Action::ReviewEdit {
                review,
                summary,
                verdict,
@@ -163,7 +126,7 @@ pub trait PatchesMut: Profile {
            } => {
                patch.review_edit(review, verdict.map(|v| v.into()), summary, labels, &signer)?;
            }
-
            cobs::patch::Action::Review {
+
            models::patch::Action::Review {
                revision,
                summary,
                verdict,
@@ -177,10 +140,10 @@ pub trait PatchesMut: Profile {
                    &signer,
                )?;
            }
-
            cobs::patch::Action::ReviewRedact { review } => {
+
            models::patch::Action::ReviewRedact { review } => {
                patch.redact_review(review, &signer)?;
            }
-
            cobs::patch::Action::ReviewComment {
+
            models::patch::Action::ReviewComment {
                review,
                body,
                location,
@@ -196,7 +159,7 @@ pub trait PatchesMut: Profile {
                    &signer,
                )?;
            }
-
            cobs::patch::Action::ReviewCommentEdit {
+
            models::patch::Action::ReviewCommentEdit {
                review,
                comment,
                body,
@@ -210,16 +173,16 @@ pub trait PatchesMut: Profile {
                    &signer,
                )?;
            }
-
            cobs::patch::Action::Lifecycle { state } => {
+
            models::patch::Action::Lifecycle { state } => {
                patch.lifecycle(state, &signer)?;
            }
-
            cobs::patch::Action::Assign { assignees } => {
+
            models::patch::Action::Assign { assignees } => {
                patch.assign(assignees, &signer)?;
            }
-
            cobs::patch::Action::Label { labels } => {
+
            models::patch::Action::Label { labels } => {
                patch.label(labels, &signer)?;
            }
-
            cobs::patch::Action::RevisionReact {
+
            models::patch::Action::RevisionReact {
                revision,
                reaction,
                location,
@@ -233,7 +196,7 @@ pub trait PatchesMut: Profile {
                    &signer,
                )?;
            }
-
            cobs::patch::Action::RevisionComment {
+
            models::patch::Action::RevisionComment {
                revision,
                location,
                body,
@@ -249,7 +212,7 @@ pub trait PatchesMut: Profile {
                    &signer,
                )?;
            }
-
            cobs::patch::Action::RevisionCommentEdit {
+
            models::patch::Action::RevisionCommentEdit {
                revision,
                comment,
                body,
@@ -263,7 +226,7 @@ pub trait PatchesMut: Profile {
                    &signer,
                )?;
            }
-
            cobs::patch::Action::RevisionCommentReact {
+
            models::patch::Action::RevisionCommentReact {
                revision,
                comment,
                reaction,
@@ -271,13 +234,13 @@ pub trait PatchesMut: Profile {
            } => {
                patch.comment_react(revision, comment, reaction, active, &signer)?;
            }
-
            cobs::patch::Action::RevisionRedact { revision } => {
+
            models::patch::Action::RevisionRedact { revision } => {
                patch.redact(revision, &signer)?;
            }
-
            cobs::patch::Action::Merge { .. } => {
+
            models::patch::Action::Merge { .. } => {
                unimplemented!("We don't support merging of patches through the desktop")
            }
-
            cobs::patch::Action::Revision { .. } => {
+
            models::patch::Action::Revision { .. } => {
                unimplemented!("We don't support creating new revisions through the desktop")
            }
        }
@@ -288,7 +251,7 @@ pub trait PatchesMut: Profile {
            }
        }

-
        Ok::<_, Error>(cobs::patch::Patch::new(*patch.id(), &patch, &aliases))
+
        Ok::<_, Error>(models::patch::Patch::new(*patch.id(), &patch, &aliases))
    }

    /// Gets the draft review of the local user for a specific patch revision in a repository.
@@ -302,7 +265,7 @@ pub trait PatchesMut: Profile {
        rid: identity::RepoId,
        cob_id: git::Oid,
        revision_id: patch::RevisionId,
-
    ) -> Option<cobs::patch::Review> {
+
    ) -> Option<models::patch::Review> {
        let profile = self.profile();
        let aliases = profile.aliases();
        let repo = profile.storage.repository(rid).ok()?;
@@ -315,7 +278,7 @@ pub trait PatchesMut: Profile {
        let review: Option<patch::Review> =
            revision.and_then(|rev| rev.review_by(signer.public_key()).cloned());

-
        review.map(|r| cobs::patch::Review::new(r, &aliases))
+
        review.map(|r| models::patch::Review::new(r, &aliases))
    }

    /// Edits a draft review for a specific patch revision in a repository.
@@ -326,7 +289,7 @@ pub trait PatchesMut: Profile {
        &self,
        rid: identity::RepoId,
        cob_id: git::Oid,
-
        edit: cobs::patch::ReviewEdit,
+
        edit: models::patch::ReviewEdit,
    ) -> Result<(), Error> {
        let profile = self.profile();
        let repo = profile.storage.repository(rid)?;
modified crates/test-http-api/src/api.rs
@@ -8,17 +8,21 @@ use axum::routing::post;
use axum::Router;
use hyper::header::CONTENT_TYPE;
use hyper::Method;
-
use radicle_types::domain::inbox::models::notification::NotificationCount;
use serde::{Deserialize, Serialize};
use tower_http::cors::{self, CorsLayer};

use radicle::{git, identity};
use radicle_types as types;
+
use radicle_types::cobs;
use radicle_types::cobs::issue;
use radicle_types::cobs::issue::NewIssue;
-
use radicle_types::cobs::patch;
use radicle_types::cobs::CobOptions;
+
use radicle_types::domain::inbox::models::notification::NotificationCount;
+
use radicle_types::domain::patch::models;
+
use radicle_types::domain::patch::service::Service;
+
use radicle_types::domain::patch::traits::PatchService;
use radicle_types::error::Error;
+
use radicle_types::outbound::sqlite::Sqlite;
use radicle_types::traits::auth::Auth;
use radicle_types::traits::cobs::Cobs;
use radicle_types::traits::issue::{Issues, IssuesMut};
@@ -30,6 +34,7 @@ use radicle_types::traits::Profile;
#[derive(Clone)]
pub struct Context {
    profile: Arc<radicle::Profile>,
+
    patches: Arc<Service<Sqlite>>,
}

impl Auth for Context {}
@@ -47,8 +52,8 @@ impl Profile for Context {
}

impl Context {
-
    pub fn new(profile: Arc<radicle::Profile>) -> Self {
-
        Self { profile }
+
    pub fn new(profile: Arc<radicle::Profile>, patches: Arc<Service<Sqlite>>) -> Self {
+
        Self { profile, patches }
    }
}

@@ -70,7 +75,7 @@ pub fn router(ctx: Context) -> Router {
        )
        .route(
            "/activity_by_patch",
-
            post(activity_patch_handler::<patch::Action>),
+
            post(activity_patch_handler::<models::patch::Action>),
        )
        .route("/get_diff", post(diff_handler))
        .route("/list_issues", post(issues_handler))
@@ -345,9 +350,31 @@ async fn patches_handler(
        status,
    }): Json<PatchesBody>,
) -> impl IntoResponse {
-
    let patches = ctx.list_patches(rid, status, skip, take)?;
-

-
    Ok::<_, Error>(Json(patches))
+
    let profile = ctx.profile;
+
    let cursor = skip.unwrap_or(0);
+
    let take = take.unwrap_or(20);
+
    let aliases = profile.aliases();
+
    let patches = match status {
+
        None => ctx.patches.list(rid)?.collect::<Vec<_>>(),
+
        Some(s) => ctx
+
            .patches
+
            .list_by_status(rid, s.into())?
+
            .collect::<Vec<_>>(),
+
    };
+
    let more = cursor + take < patches.len();
+

+
    let patches = patches
+
        .into_iter()
+
        .map(|(id, patch)| models::patch::Patch::new(id, &patch, &aliases))
+
        .skip(cursor)
+
        .take(take)
+
        .collect::<Vec<_>>();
+

+
    Ok::<_, Error>(Json(cobs::PaginatedQuery {
+
        cursor,
+
        more,
+
        content: patches,
+
    }))
}

#[derive(Serialize, Deserialize)]
modified crates/test-http-api/src/lib.rs
@@ -4,8 +4,11 @@ use std::sync::Arc;
use axum::Router;
use tokio::net::TcpListener;

+
use radicle::cob::cache::COBS_DB_FILE;
use radicle::Profile;

+
use radicle_types::domain::patch::service::Service as PatchService;
+

mod api;

#[derive(Debug, Clone)]
@@ -25,7 +28,12 @@ pub async fn run(options: Options) -> anyhow::Result<()> {

fn router(profile: Profile) -> anyhow::Result<Router> {
    let profile = Arc::new(profile);
-
    let ctx = api::Context::new(profile);
+

+
    let patch_db =
+
        radicle_types::outbound::sqlite::Sqlite::reader(profile.cobs().join(COBS_DB_FILE))?;
+
    let patch_service = PatchService::new(patch_db);
+

+
    let ctx = api::Context::new(profile, Arc::new(patch_service));

    Ok(api::router(ctx))
}