feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1

Merged
GKaszewski merged 334 commits from v2 into master 2026-05-16 09:42:43 +00:00
3 changed files with 162 additions and 3 deletions
Showing only changes of commit 2d2b5dde6a - Show all commits

View File

@@ -14,6 +14,7 @@ sha2 = "0.10"
hex = "0.4" hex = "0.4"
tracing = { workspace = true } tracing = { workspace = true }
url = { workspace = true } url = { workspace = true }
tokio = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }

View File

@@ -104,7 +104,7 @@ mod tests {
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{feed::UserSummary, user::User}, models::{feed::{PageParams, Paginated, UserSummary}, user::User},
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter}, ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
testing::{NoOpEventPublisher, TestStore}, testing::{NoOpEventPublisher, TestStore},
value_objects::{Email, PasswordHash, UserId, Username}, value_objects::{Email, PasswordHash, UserId, Username},
@@ -135,6 +135,12 @@ mod tests {
async fn count(&self) -> Result<i64, DomainError> { async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await self.0.count().await
} }
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
self.0.list_paginated(page).await
}
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
self.0.find_by_ids(ids).await
}
} }
#[async_trait] #[async_trait]
@@ -177,6 +183,12 @@ mod tests {
async fn count(&self) -> Result<i64, DomainError> { async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await self.0.count().await
} }
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
self.0.list_paginated(page).await
}
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
self.0.find_by_ids(ids).await
}
} }
#[async_trait] #[async_trait]

View File

@@ -1,10 +1,14 @@
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::thought::{Thought, Visibility}, models::{
ports::{EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader}, feed::{EngagementStats, FeedEntry},
thought::{Thought, Visibility},
},
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
value_objects::{Content, ThoughtId, UserId}, value_objects::{Content, ThoughtId, UserId},
}; };
use std::collections::HashMap;
fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> { fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> {
if thought.user_id != *user_id { if thought.user_id != *user_id {
@@ -127,6 +131,67 @@ pub async fn get_thread(
thoughts.get_thread(id).await thoughts.get_thread(id).await
} }
/// Fetches a single thought enriched with author + real engagement stats.
pub async fn get_thought_view(
thoughts: &dyn ThoughtRepository,
users: &dyn UserReader,
engagement: &dyn EngagementRepository,
id: &ThoughtId,
viewer: Option<&UserId>,
) -> Result<FeedEntry, DomainError> {
let thought = thoughts
.find_by_id(id)
.await?
.ok_or(DomainError::NotFound)?;
let author = users
.find_by_id(&thought.user_id)
.await?
.ok_or(DomainError::NotFound)?;
let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?;
let (stats, viewer_ctx) = map.remove(id).unwrap_or_else(|| {
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
});
Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx })
}
/// Fetches a thread (root + replies) enriched with authors + real engagement stats.
/// Batches all DB lookups — one query per resource type regardless of thread length.
pub async fn get_thread_views(
thoughts: &dyn ThoughtRepository,
users: &dyn UserReader,
engagement: &dyn EngagementRepository,
root_id: &ThoughtId,
viewer: Option<&UserId>,
) -> Result<Vec<FeedEntry>, DomainError> {
let thread = thoughts.get_thread(root_id).await?;
if thread.is_empty() {
return Ok(vec![]);
}
let thought_ids: Vec<ThoughtId> = thread.iter().map(|t| t.id.clone()).collect();
let user_ids: Vec<UserId> = thread.iter().map(|t| t.user_id.clone()).collect();
let (authors_map, engagement_map) = tokio::join!(
users.find_by_ids(&user_ids),
engagement.get_for_thoughts(&thought_ids, viewer),
);
let authors_map = authors_map?;
let mut engagement_map = engagement_map?;
let mut entries = Vec::with_capacity(thread.len());
for thought in thread {
let author = authors_map
.get(&thought.user_id)
.cloned()
.ok_or(DomainError::NotFound)?;
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or_else(|| {
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
});
entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx });
}
Ok(entries)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -323,3 +388,84 @@ mod tests {
assert_eq!(reply.in_reply_to_id, Some(original.id.clone())); assert_eq!(reply.in_reply_to_id, Some(original.id.clone()));
} }
} }
#[cfg(test)]
mod enrichment_tests {
use super::*;
use domain::testing::TestStore;
use domain::models::user::User;
use domain::models::thought::{Thought, Visibility};
use domain::value_objects::*;
use domain::ports::{ThoughtRepository, UserWriter};
fn make_user() -> User {
User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("a@a.com").unwrap(),
PasswordHash("h".into()),
)
}
fn make_thought(user_id: UserId) -> Thought {
Thought::new_local(
ThoughtId::new(),
user_id,
Content::new_local(String::from("hello")).unwrap(),
None,
Visibility::Public,
None,
false,
)
}
#[tokio::test]
async fn get_thought_view_returns_feed_entry() {
let store = TestStore::default();
let user = make_user();
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
let thought = make_thought(user.id.clone());
<TestStore as ThoughtRepository>::save(&store, &thought).await.unwrap();
let entry = get_thought_view(&store, &store, &store, &thought.id, None)
.await
.unwrap();
assert_eq!(entry.thought.id, thought.id);
assert_eq!(entry.author.id, user.id);
assert_eq!(entry.stats.like_count, 0);
assert!(entry.viewer.is_none());
}
#[tokio::test]
async fn get_thought_view_returns_not_found_for_missing_thought() {
let store = TestStore::default();
let err = get_thought_view(&store, &store, &store, &ThoughtId::new(), None)
.await
.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
#[tokio::test]
async fn get_thread_views_batches_correctly() {
let store = TestStore::default();
let user = make_user();
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
let root = make_thought(user.id.clone());
<TestStore as ThoughtRepository>::save(&store, &root).await.unwrap();
let reply = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local(String::from("reply")).unwrap(),
Some(root.id.clone()),
Visibility::Public,
None,
false,
);
<TestStore as ThoughtRepository>::save(&store, &reply).await.unwrap();
let entries = get_thread_views(&store, &store, &store, &root.id, None)
.await
.unwrap();
assert_eq!(entries.len(), 2);
}
}