From dc87d3fa847f82be1dfbf43d3e2aee7e1f8f5d7a Mon Sep 17 00:00:00 2001 From: Sebastian Martinez Date: Tue, 14 Jan 2025 14:11:23 +0100 Subject: [PATCH] Adding `Service` and `Storage` traits for the inbox logic To start making the backend codebase more future proof, I started introducing the hexagonal architecture concept, of having `Service` traits that is the public API and inner traits like `Storage`, `Metrics`, etc. that can be implemented by different adapters like `Sqlite`. This is a first development in this direction. --- Cargo.lock | 1 + crates/radicle-types/Cargo.toml | 1 + crates/radicle-types/src/domain.rs | 1 + crates/radicle-types/src/domain/inbox.rs | 3 + .../radicle-types/src/domain/inbox/models.rs | 1 + .../src/domain/inbox/models/notification.rs | 43 ++++++++++ .../radicle-types/src/domain/inbox/service.rs | 47 ++++++++++ .../radicle-types/src/domain/inbox/traits.rs | 28 ++++++ crates/radicle-types/src/error.rs | 4 + crates/radicle-types/src/lib.rs | 2 + crates/radicle-types/src/outbound.rs | 1 + crates/radicle-types/src/outbound/sqlite.rs | 86 +++++++++++++++++++ 12 files changed, 218 insertions(+) create mode 100644 crates/radicle-types/src/domain.rs create mode 100644 crates/radicle-types/src/domain/inbox.rs create mode 100644 crates/radicle-types/src/domain/inbox/models.rs create mode 100644 crates/radicle-types/src/domain/inbox/models/notification.rs create mode 100644 crates/radicle-types/src/domain/inbox/service.rs create mode 100644 crates/radicle-types/src/domain/inbox/traits.rs create mode 100644 crates/radicle-types/src/outbound.rs create mode 100644 crates/radicle-types/src/outbound/sqlite.rs diff --git a/Cargo.lock b/Cargo.lock index 73bac1496..b96413579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4144,6 +4144,7 @@ dependencies = [ "radicle-surf", "serde", "serde_json", + "sqlite", "tempfile", "thiserror 1.0.69", "tree-sitter-bash", diff --git a/crates/radicle-types/Cargo.toml b/crates/radicle-types/Cargo.toml index ddbb12cea..1fc558f27 100644 --- a/crates/radicle-types/Cargo.toml +++ b/crates/radicle-types/Cargo.toml @@ -15,6 +15,7 @@ radicle = { version = "0.14.0", features = ["test"] } radicle-surf = { version = "0.22.1", features = ["serde"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = { version = "1.0.132" } +sqlite = { version = "0.32.0", features = ["bundled"] } tempfile = { version = "3.14.0" } thiserror = { version = "1.0.65" } tree-sitter-bash = { version = "0.23.3" } diff --git a/crates/radicle-types/src/domain.rs b/crates/radicle-types/src/domain.rs new file mode 100644 index 000000000..730098e4c --- /dev/null +++ b/crates/radicle-types/src/domain.rs @@ -0,0 +1 @@ +pub mod inbox; diff --git a/crates/radicle-types/src/domain/inbox.rs b/crates/radicle-types/src/domain/inbox.rs new file mode 100644 index 000000000..838215019 --- /dev/null +++ b/crates/radicle-types/src/domain/inbox.rs @@ -0,0 +1,3 @@ +pub mod models; +pub mod service; +pub mod traits; diff --git a/crates/radicle-types/src/domain/inbox/models.rs b/crates/radicle-types/src/domain/inbox/models.rs new file mode 100644 index 000000000..10cdf5de4 --- /dev/null +++ b/crates/radicle-types/src/domain/inbox/models.rs @@ -0,0 +1 @@ +pub mod notification; diff --git a/crates/radicle-types/src/domain/inbox/models/notification.rs b/crates/radicle-types/src/domain/inbox/models/notification.rs new file mode 100644 index 000000000..ac2f8f672 --- /dev/null +++ b/crates/radicle-types/src/domain/inbox/models/notification.rs @@ -0,0 +1,43 @@ +use radicle::{git, identity, node, storage}; + +#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename = "camelCase")] +pub struct NotificationRow { + pub row_id: node::notifications::NotificationId, + pub timestamp: localtime::LocalTime, + pub remote: Option, + pub old: Option, + pub new: Option, +} + +pub type RepoGroup = std::collections::BTreeMap, Vec>; +pub type CountByRepo = (identity::RepoId, usize); + +#[derive(Clone, Debug)] +pub struct CountsByRepoParams { + pub repo: identity::RepoId, +} + +#[derive(Clone, Debug)] +pub struct RepoGroupParams { + pub repo: identity::RepoId, +} + +#[derive(Debug, thiserror::Error)] +pub enum ListNotificationsError { + #[error(transparent)] + RefError(#[from] git::RefError), + + #[error(transparent)] + SerdeJSON(#[from] serde_json::Error), + + #[error(transparent)] + Sqlite(#[from] sqlite::Error), + + #[error(transparent)] + NotificationKindError(#[from] node::notifications::NotificationKindError), + + #[error(transparent)] + Unknown(#[from] anyhow::Error), + // to be extended as new error scenarios are introduced +} diff --git a/crates/radicle-types/src/domain/inbox/service.rs b/crates/radicle-types/src/domain/inbox/service.rs new file mode 100644 index 000000000..f7749bd1b --- /dev/null +++ b/crates/radicle-types/src/domain/inbox/service.rs @@ -0,0 +1,47 @@ +use radicle::git; + +use crate::domain::inbox::models::notification::{ + CountByRepo, ListNotificationsError, NotificationRow, RepoGroupParams, +}; +use crate::domain::inbox::traits::{InboxService, InboxStorage}; + +#[derive(Debug, Clone)] +pub struct Service +where + I: InboxStorage, +{ + inbox: I, +} + +impl Service +where + I: InboxStorage, +{ + pub fn new(inbox: I) -> Self { + Self { inbox } + } +} + +impl InboxService for Service +where + I: InboxStorage, +{ + fn counts_by_repo( + &self, + ) -> Result< + impl Iterator>, + ListNotificationsError, + > { + self.inbox.counts_by_repo() + } + + fn repo_group( + &self, + params: RepoGroupParams, + ) -> Result< + std::collections::BTreeMap, Vec>, + ListNotificationsError, + > { + self.inbox.repo_group(params) + } +} diff --git a/crates/radicle-types/src/domain/inbox/traits.rs b/crates/radicle-types/src/domain/inbox/traits.rs new file mode 100644 index 000000000..69b24093f --- /dev/null +++ b/crates/radicle-types/src/domain/inbox/traits.rs @@ -0,0 +1,28 @@ +use crate::domain::inbox::models::notification::{ + CountByRepo, ListNotificationsError, RepoGroupParams, +}; + +use super::models::notification::RepoGroup; + +pub trait InboxStorage { + fn counts_by_repo( + &self, + ) -> Result< + impl Iterator>, + ListNotificationsError, + >; + + fn repo_group(&self, params: RepoGroupParams) -> Result; +} + +pub trait InboxService { + /// Get the total notification count by repos. + fn counts_by_repo( + &self, + ) -> Result< + impl Iterator>, + ListNotificationsError, + >; + + fn repo_group(&self, params: RepoGroupParams) -> Result; +} diff --git a/crates/radicle-types/src/error.rs b/crates/radicle-types/src/error.rs index e5b539ce9..c231f88bc 100644 --- a/crates/radicle-types/src/error.rs +++ b/crates/radicle-types/src/error.rs @@ -21,6 +21,10 @@ pub enum Error { #[error(transparent)] Io(#[from] std::io::Error), + /// Io error. + #[error(transparent)] + Sqlite(#[from] sqlite::Error), + /// Crypto error. #[error(transparent)] Crypto(#[from] radicle::crypto::ssh::keystore::Error), diff --git a/crates/radicle-types/src/lib.rs b/crates/radicle-types/src/lib.rs index 4c7bd9ec1..ede288ef2 100644 --- a/crates/radicle-types/src/lib.rs +++ b/crates/radicle-types/src/lib.rs @@ -9,7 +9,9 @@ use traits::Profile; pub mod cobs; pub mod config; pub mod diff; +pub mod domain; pub mod error; +pub mod outbound; pub mod repo; pub mod syntax; pub mod test; diff --git a/crates/radicle-types/src/outbound.rs b/crates/radicle-types/src/outbound.rs new file mode 100644 index 000000000..6b1c1083e --- /dev/null +++ b/crates/radicle-types/src/outbound.rs @@ -0,0 +1 @@ +pub mod sqlite; diff --git a/crates/radicle-types/src/outbound/sqlite.rs b/crates/radicle-types/src/outbound/sqlite.rs new file mode 100644 index 000000000..0be944441 --- /dev/null +++ b/crates/radicle-types/src/outbound/sqlite.rs @@ -0,0 +1,86 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; +use std::time; + +use radicle::git::Qualified; +use radicle::{git, identity}; +use sqlite as sql; + +use crate::domain::inbox::models::notification; +use crate::domain::inbox::traits::InboxStorage; +use crate::error::Error; + +pub struct Sqlite { + pub db: Arc, +} + +impl Sqlite { + /// 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); + + pub fn reader>(path: P) -> Result { + let mut db = sql::Connection::open_thread_safe_with_flags( + path, + sqlite::OpenFlags::new().with_read_only(), + )?; + db.set_busy_timeout(Self::DB_READ_TIMEOUT.as_millis() as usize)?; + + Ok(Self { db: Arc::new(db) }) + } +} + +impl InboxStorage for Sqlite { + fn counts_by_repo( + &self, + ) -> Result< + impl Iterator>, + notification::ListNotificationsError, + > { + let stmt = self.db.prepare( + "SELECT repo, COUNT(*) as count + FROM `repository-notifications` + GROUP BY repo", + )?; + + Ok(stmt.into_iter().map(|row| { + let row = row?; + let count = row.try_read::("count")? as usize; + let repo = row.try_read::("repo")?; + + Ok((repo, count)) + })) + } + + fn repo_group( + &self, + params: notification::RepoGroupParams, + ) -> Result< + std::collections::BTreeMap, Vec>, + notification::ListNotificationsError, + > { + let mut stmt = self.db.prepare( + "SELECT ref, substr(ref, 66) ref_without_namespace, json_group_array(json_object('row_id', rowid, 'timestamp', timestamp, 'remote', substr(ref, 17, 48), 'old', old, 'new', new)) as value + FROM 'repository-notifications' + WHERE repo = ? + GROUP BY ref_without_namespace + ORDER BY timestamp DESC" + )?; + stmt.bind((1, ¶ms.repo))?; + + stmt.into_iter() + .map(|row| { + let row = row?; + let refstr = row.try_read::<&str, _>("ref")?; + let value = row.try_read::<&str, _>("value")?; + let items = serde_json::from_str::>(value)?; + let (_, reference) = git::parse_ref::(refstr)?; + + Ok((reference.to_owned(), items)) + }) + .collect::, Vec>, + notification::ListNotificationsError, + >>() + } +}