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
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:
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user