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

@@ -0,0 +1,167 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::thought::Thought,
value_objects::{ThoughtId, UserId, Username},
};
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
#[derive(Debug, Clone)]
pub struct ActorApUrls {
pub ap_id: String,
pub inbox_url: String,
}
/// A local thought ready for AP serialization, with the author's username
/// pre-joined so the handler can build AP URLs without a second query.
#[derive(Debug, Clone)]
pub struct OutboxEntry {
pub thought: Thought,
pub author_username: Username,
}
#[async_trait]
pub trait ActivityPubRepository: Send + Sync {
// ── Outbox (local → remote) ──────────────────────────────────────
/// All public local thoughts for this actor. Used for outbox totals
/// and full-collection delivery.
async fn outbox_entries_for_actor(
&self,
user_id: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError>;
/// Cursor page of public local thoughts, newest-first, before `before`.
/// Used for OrderedCollectionPage responses.
async fn outbox_page_for_actor(
&self,
user_id: &UserId,
before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> Result<Vec<OutboxEntry>, DomainError>;
// ── Remote actor resolution ──────────────────────────────────────
/// Find the local UserId for a remote actor by its AP URL.
async fn find_remote_actor_id(&self, actor_ap_url: &str)
-> Result<Option<UserId>, DomainError>;
/// Ensure a remote actor placeholder exists; create one if absent.
/// Idempotent — safe to call multiple times with the same URL.
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
/// Update display_name and avatar_url for an already-interned remote actor.
async fn update_remote_actor_display(
&self,
user_id: &UserId,
display_name: Option<&str>,
avatar_url: Option<&str>,
) -> Result<(), DomainError>;
// ── Inbox processing (remote → local) ───────────────────────────
/// Persist an incoming remote Note. Idempotent on ap_id.
#[allow(clippy::too_many_arguments)]
async fn accept_note(
&self,
ap_id: &str,
author_id: &UserId,
content: &str,
published: chrono::DateTime<chrono::Utc>,
sensitive: bool,
content_warning: Option<String>,
visibility: &str,
in_reply_to: Option<&str>,
) -> Result<(), DomainError>;
/// Apply an Update to a previously accepted remote Note.
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;
/// Remove a specific remote Note (Delete activity). Only touches
/// remotely-originated thoughts.
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
/// Remove all Notes from a remote actor (actor-level Delete/Tombstone).
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
// ── Node-level stats ─────────────────────────────────────────────
/// Total locally-authored thought count for NodeInfo responses.
async fn count_local_notes(&self) -> Result<u64, DomainError>;
/// Return the ActivityPub object URL for a thought, if one is stored.
/// Returns None for local thoughts (caller constructs URL from base_url + thought_id).
async fn get_thought_ap_id(
&self,
thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError>;
/// Return the AP actor URL and inbox URL for a user, if stored.
/// Returns None for users that have not been federated.
async fn get_actor_ap_urls(&self, user_id: &UserId)
-> Result<Option<ActorApUrls>, DomainError>;
}
#[async_trait]
pub trait OutboundFederationPort: Send + Sync {
/// Fan out a new local Note to all accepted followers.
async fn broadcast_create(
&self,
author_user_id: &UserId,
thought: &Thought,
author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>;
/// Fan out a Delete tombstone for a now-deleted local Note.
/// `thought_ap_id` is pre-constructed by the caller because the thought
/// has already been deleted from the DB when this fires.
async fn broadcast_delete(
&self,
author_user_id: &UserId,
thought_ap_id: &str,
) -> Result<(), DomainError>;
/// Fan out an Update(Note) for an edited local thought.
async fn broadcast_update(
&self,
author_user_id: &UserId,
thought: &Thought,
author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError>;
/// Fan out an Announce(object_ap_id) for a boost.
async fn broadcast_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError>;
/// Fan out an Undo(Announce) to followers when a boost is removed.
async fn broadcast_undo_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError>;
/// Send a Like activity to a remote thought author's inbox.
/// Only called when a LOCAL user likes a REMOTE thought (one with an ap_id).
async fn broadcast_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
/// Send Undo(Like) to a remote thought author's inbox.
async fn broadcast_undo_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError>;
/// Fan out an Update(Actor) to all accepted followers after a profile change.
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>;
}

View File

@@ -1,6 +1,7 @@
pub mod activities;
pub mod actor_handler;
pub mod actors;
pub mod ap_ports;
pub mod content;
pub mod data;
pub mod error;
@@ -17,6 +18,7 @@ pub mod user;
pub mod webfinger;
pub use activitypub_federation::kinds::object::NoteType;
pub use ap_ports::{ActorApUrls, ActivityPubRepository, OutboxEntry, OutboundFederationPort};
pub use content::ApObjectHandler;
pub use data::FederationData;
pub use error::Error;

View File

@@ -50,26 +50,6 @@ fn content_to_html(text: &str) -> String {
}
}
fn extract_hashtag_tags(content: &str, base_url: &str) -> Vec<serde_json::Value> {
let mut seen = std::collections::HashSet::new();
let mut tags = Vec::new();
for word in content.split_whitespace() {
let tag = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '#');
if let Some(name) = tag.strip_prefix('#')
&& !name.is_empty()
&& seen.insert(name.to_lowercase())
{
let lower = name.to_lowercase();
tags.push(serde_json::json!({
"type": "Hashtag",
"name": format!("#{}", lower),
"href": format!("{}/tags/{}", base_url, lower),
}));
}
}
tags
}
fn thought_note_json(
thought: &domain::models::thought::Thought,
local_actor: &crate::actors::DbActor,
@@ -114,9 +94,19 @@ fn thought_note_json(
if let Some(updated_at) = thought.updated_at {
note["updated"] = serde_json::json!(updated_at.to_rfc3339());
}
let hashtag_tags = extract_hashtag_tags(thought.content.as_str(), base_url);
if !hashtag_tags.is_empty() {
note["tag"] = serde_json::json!(hashtag_tags);
let hashtags = domain::hashtag::extract(thought.content.as_str());
if !hashtags.is_empty() {
let ap_tags: Vec<serde_json::Value> = hashtags
.iter()
.map(|h| {
serde_json::json!({
"type": "Hashtag",
"name": h.ap_name,
"href": format!("{}/{}", base_url, h.url_slug),
})
})
.collect();
note["tag"] = serde_json::json!(ap_tags);
}
Ok((ap_id, note))
}
@@ -1405,7 +1395,7 @@ impl ActivityPubService {
}
#[async_trait::async_trait]
impl domain::ports::OutboundFederationPort for ActivityPubService {
impl crate::ap_ports::OutboundFederationPort for ActivityPubService {
// Actor identity (ap_id, followers_url) comes from federation config via get_local_actor.
// author_username is provided by the caller but not needed here.
async fn broadcast_create(