refactor: 5 architectural improvements (Tasks 2-5 + Task 6 fix)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m2s
test / unit (pull_request) Successful in 16m19s
test / integration (pull_request) Failing after 17m15s

- feat(domain): Hashtag value object with canonical extract() — unifies two
  divergent private implementations; fields pre-compute raw/normalized/url_slug/ap_name

- feat(presentation): Deps<S: FromAppState> extractor — each handler now
  declares its exact dependency surface; AppState unchanged; handlers
  become unit-testable without mocking all 20 deps

- refactor(feed): replace 5 flat FeedRepository methods with FeedQuery/FeedScope
  — single query() method; SQL shared logic lives once; adding feed types
  no longer requires 5 edits

- refactor(activitypub): ActivityPubRepository + OutboundFederationPort moved
  out of domain::ports into activitypub-base::ap_ports — domain crate no
  longer knows about AP IDs, inboxes, or actor URLs

- fix(outbox): OutboxRelay now opens a per-row transaction so FOR UPDATE
  SKIP LOCKED actually holds the lock during publish + mark_delivered
This commit is contained in:
2026-05-15 18:54:20 +02:00
parent 6024a65060
commit 0592861edd
37 changed files with 1401 additions and 865 deletions

View File

@@ -4,9 +4,10 @@ version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
event-payload = { workspace = true }
sqlx = { workspace = true }
domain = { workspace = true }
activitypub-base = { workspace = true }
event-payload = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }

View File

@@ -6,10 +6,10 @@ const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use domain::{
errors::DomainError,
models::thought::{Thought, Visibility},
ports::{ActivityPubRepository, ActorApUrls, OutboxEntry},
value_objects::{Content, ThoughtId, UserId, Username},
};
@@ -328,7 +328,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
#[cfg(test)]
mod tests {
use super::*;
use domain::ports::ActivityPubRepository;
use activitypub_base::ActivityPubRepository;
#[sqlx::test(migrations = "./migrations")]
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {

View File

@@ -5,11 +5,11 @@ use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::{
feed::{FeedEntry, PageParams, Paginated},
feed::{FeedEntry, Paginated},
thought::{Thought, Visibility},
user::User,
},
ports::FeedRepository,
ports::{FeedQuery, FeedRepository, FeedScope},
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
};
use sqlx::PgPool;
@@ -150,201 +150,178 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
#[async_trait]
impl FeedRepository for PgFeedRepository {
async fn home_feed(
&self,
following_ids: &[UserId],
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
let viewer = viewer_id.map(|v| v.as_uuid());
let fed_clause = federation_following_clause(viewer);
let count_sql = format!(
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
fed_clause
);
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(&ids)
.fetch_one(&self.pool)
.await
.into_domain()?;
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = q.viewer_id.as_ref().map(|v| v.as_uuid());
let page = &q.page;
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3", fed_clause);
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(&ids)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
match &q.scope {
FeedScope::Home { following_ids } => {
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
let fed_clause = federation_following_clause(viewer);
let count_sql = format!(
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
fed_clause
);
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(&ids)
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3", fed_clause);
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(&ids)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
async fn public_feed(
&self,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid());
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'",
)
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
FeedScope::Public => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'",
)
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
async fn search(
&self,
query: &str,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid());
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
)
.bind(query)
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(query)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
FeedScope::Search { query } => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
)
.bind(query)
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(query)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
async fn tag_feed(
&self,
tag_name: &str,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid());
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'",
)
.bind(tag_name)
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
let sel = feed_select(viewer);
let sql = format!(
"{sel}
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'
ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"
);
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(tag_name)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
FeedScope::Tag { tag_name } => {
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'",
)
.bind(tag_name)
.fetch_one(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
let sel = feed_select(viewer);
let sql = format!(
"{sel}
JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'
ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"
);
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(tag_name)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.into_domain()?;
async fn user_feed(
&self,
user_id: &UserId,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid());
let uid = user_id.as_uuid();
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks.
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
FeedScope::User { user_id } => {
let uid = user_id.as_uuid();
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks.
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted')))))",
)
.bind(uid)
.bind(viewer_uuid)
.fetch_one(&self.pool)
.await
.into_domain()?;
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted')))))",
)
.bind(uid)
.bind(viewer_uuid)
.fetch_one(&self.pool)
.await
.into_domain()?;
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))) ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(uid)
.bind(page.limit())
.bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool)
.await
.into_domain()?;
let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))) ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(uid)
.bind(page.limit())
.bind(page.offset())
.bind(viewer_uuid)
.fetch_all(&self.pool)
.await
.into_domain()?;
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
Ok(Paginated {
items: rows
.into_iter()
.map(|r| row_to_entry(r, viewer))
.collect::<Result<Vec<_>, _>>()?,
total,
page: page.page,
per_page: page.per_page,
})
}
}
}
}
@@ -354,10 +331,11 @@ mod tests {
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
models::{
feed::PageParams,
thought::{Thought, Visibility},
user::User,
},
ports::{ThoughtRepository, UserWriter},
ports::{FeedQuery, ThoughtRepository, UserWriter},
value_objects::*,
};
@@ -389,13 +367,10 @@ mod tests {
let (_, _) = seed(&pool, "alice", "hello").await;
let repo = PgFeedRepository::new(pool);
let result = repo
.public_feed(
&PageParams {
page: 1,
per_page: 20,
},
.query(&FeedQuery::public(
PageParams { page: 1, per_page: 20 },
None,
)
))
.await
.unwrap();
assert_eq!(result.total, 1);
@@ -408,14 +383,11 @@ mod tests {
let (_, _) = seed(&pool, "bob", "goodbye world").await;
let repo = PgFeedRepository::new(pool);
let result = repo
.search(
.query(&FeedQuery::search(
"hello world",
&PageParams {
page: 1,
per_page: 20,
},
PageParams { page: 1, per_page: 20 },
None,
)
))
.await
.unwrap();
assert!(result.total >= 1);