feat: atomic social local writes, remote review enrichment

SocialCommand methods take events. Local follow ops = one FollowCommand::apply
tx (rows + events). k_ap path (remote, block/unblock): best-effort publish in
CompositeSocialAdapter after k_ap write; not atomic, k_ap owns tx.
save_remote_review takes events, enqueued in same tx via
sqlite::persistence::enqueue_events. Deps drop event_publisher: social
command, ReviewObjectHandler.

Remaining .publish( sites:
announce-only: users/delete_account UserDeleted, person/get + get_credits
PersonEnrichmentRequested, jobs/enrichment_staleness, http-axum search,
ImageStored (sync_poster, poster_sync_handler, image-converter backfill,
update_profile; object storage), federation_event_bridge (handler reaction),
nats relay (transport).
non-atomic by necessity: CompositeSocialAdapter::announce (k_ap-owned write).
Everything else rides its write.
This commit is contained in:
2026-10-26 01:12:58 +01:00
parent 6c295866a0
commit 9a4d0c1714
29 changed files with 1115 additions and 408 deletions

View File

@@ -1,10 +1,9 @@
use std::sync::Arc;
use domain::ports::{BlockQuery, EventPublisher, FollowGraphQuery, SocialCommand};
use domain::ports::{BlockQuery, FollowGraphQuery, SocialCommand};
pub struct SocialCommandDeps {
pub social_command: Arc<dyn SocialCommand>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct SocialQueryDeps {

View File

@@ -2,61 +2,78 @@ use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
use super::{commands::SocialCmd, deps::SocialCommandDeps};
/// The command's event rides the write: the social port records it with the
/// change (atomically where the adapter owns the transaction).
pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result<(), DomainError> {
let event = match cmd {
let command = &deps.social_command;
match cmd {
SocialCmd::Follow {
follower_id,
target,
} => {
let follower = UserId::from_uuid(follower_id);
deps.social_command.follow(&follower, &target).await?;
DomainEvent::FollowRequested { follower, target }
let events = [DomainEvent::FollowRequested {
follower: follower.clone(),
target: target.clone(),
}];
command.follow(&follower, &target, &events).await
}
SocialCmd::Unfollow {
follower_id,
target,
} => {
let follower = UserId::from_uuid(follower_id);
deps.social_command.unfollow(&follower, &target).await?;
DomainEvent::Unfollowed { follower, target }
let events = [DomainEvent::Unfollowed {
follower: follower.clone(),
target: target.clone(),
}];
command.unfollow(&follower, &target, &events).await
}
SocialCmd::AcceptFollow {
owner_id,
requester,
} => {
let owner = UserId::from_uuid(owner_id);
deps.social_command
.accept_follow(&owner, &requester)
.await?;
DomainEvent::FollowAccepted { owner, requester }
let events = [DomainEvent::FollowAccepted {
owner: owner.clone(),
requester: requester.clone(),
}];
command.accept_follow(&owner, &requester, &events).await
}
SocialCmd::RejectFollow {
owner_id,
requester,
} => {
let owner = UserId::from_uuid(owner_id);
deps.social_command
.reject_follow(&owner, &requester)
.await?;
DomainEvent::FollowRejected { owner, requester }
let events = [DomainEvent::FollowRejected {
owner: owner.clone(),
requester: requester.clone(),
}];
command.reject_follow(&owner, &requester, &events).await
}
SocialCmd::RemoveFollower { owner_id, follower } => {
let owner = UserId::from_uuid(owner_id);
deps.social_command
.remove_follower(&owner, &follower)
.await?;
DomainEvent::FollowerRemoved { owner, follower }
let events = [DomainEvent::FollowerRemoved {
owner: owner.clone(),
follower: follower.clone(),
}];
command.remove_follower(&owner, &follower, &events).await
}
SocialCmd::Block { blocker_id, target } => {
let blocker = UserId::from_uuid(blocker_id);
deps.social_command.block(&blocker, &target).await?;
DomainEvent::ActorBlocked { blocker, target }
let events = [DomainEvent::ActorBlocked {
blocker: blocker.clone(),
target: target.clone(),
}];
command.block(&blocker, &target, &events).await
}
SocialCmd::Unblock { blocker_id, target } => {
let blocker = UserId::from_uuid(blocker_id);
deps.social_command.unblock(&blocker, &target).await?;
DomainEvent::ActorUnblocked { blocker, target }
let events = [DomainEvent::ActorUnblocked {
blocker: blocker.clone(),
target: target.clone(),
}];
command.unblock(&blocker, &target, &events).await
}
};
deps.event_publisher.publish(&event).await
}
}

View File

@@ -3,9 +3,10 @@ use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowTargetResolver,
ResolvedFollow, SocialCommand, UserRepository,
FollowWrite, ResolvedFollow, SocialCommand, UserRepository,
},
value_objects::{
FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity,
@@ -72,27 +73,41 @@ fn remote_unsupported(actor: &str) -> DomainError {
#[async_trait]
impl SocialCommand for LocalSocialService {
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
async fn follow(
&self,
follower: &UserId,
target: &FollowTarget,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let identity = self.resolve_target_identity(target).await?;
self.follow_resolved(follower, &identity).await
self.follow_resolved(follower, &identity, events).await
}
async fn unfollow(
&self,
follower: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(target);
match target {
SocialIdentity::Local(target_id) => {
let follower_url = self.instance.actor_url_for(follower);
self.follow_command
.remove_follow(follower.value(), &actor_url)
.await?;
self.follow_command
.remove_follower_record(target_id.value(), &follower_url)
.await?;
Ok(())
.apply(
&[
FollowWrite::RemoveFollow {
follower_id: follower.value(),
target_actor_url: actor_url,
},
FollowWrite::RemoveFollowerRecord {
local_user_id: target_id.value(),
follower_actor_url: follower_url,
},
],
events,
)
.await
}
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
}
@@ -102,18 +117,29 @@ impl SocialCommand for LocalSocialService {
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(requester);
match requester {
SocialIdentity::Local(requester_id) => {
let owner_url = self.instance.actor_url_for(owner);
self.follow_command
.update_follower_status(owner.value(), &actor_url, FollowStatus::Accepted)
.await?;
self.follow_command
.update_follow_status(requester_id.value(), &owner_url, FollowStatus::Accepted)
.await?;
Ok(())
.apply(
&[
FollowWrite::UpdateFollowerStatus {
local_user_id: owner.value(),
follower_actor_url: actor_url,
status: FollowStatus::Accepted,
},
FollowWrite::UpdateFollowStatus {
follower_id: requester_id.value(),
target_actor_url: owner_url,
status: FollowStatus::Accepted,
},
],
events,
)
.await
}
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
}
@@ -123,18 +149,28 @@ impl SocialCommand for LocalSocialService {
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(requester);
match requester {
SocialIdentity::Local(requester_id) => {
let owner_url = self.instance.actor_url_for(owner);
self.follow_command
.update_follower_status(owner.value(), &actor_url, FollowStatus::Rejected)
.await?;
self.follow_command
.remove_follow(requester_id.value(), &owner_url)
.await?;
Ok(())
.apply(
&[
FollowWrite::UpdateFollowerStatus {
local_user_id: owner.value(),
follower_actor_url: actor_url,
status: FollowStatus::Rejected,
},
FollowWrite::RemoveFollow {
follower_id: requester_id.value(),
target_actor_url: owner_url,
},
],
events,
)
.await
}
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
}
@@ -144,24 +180,38 @@ impl SocialCommand for LocalSocialService {
&self,
owner: &UserId,
follower: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let actor_url = self.instance.actor_url_of(follower);
match follower {
SocialIdentity::Local(follower_id) => {
let owner_url = self.instance.actor_url_for(owner);
self.follow_command
.remove_follower_record(owner.value(), &actor_url)
.await?;
self.follow_command
.remove_follow(follower_id.value(), &owner_url)
.await?;
Ok(())
.apply(
&[
FollowWrite::RemoveFollowerRecord {
local_user_id: owner.value(),
follower_actor_url: actor_url,
},
FollowWrite::RemoveFollow {
follower_id: follower_id.value(),
target_actor_url: owner_url,
},
],
events,
)
.await
}
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
}
}
async fn block(&self, _blocker: &UserId, _target: &SocialIdentity) -> Result<(), DomainError> {
async fn block(
&self,
_blocker: &UserId,
_target: &SocialIdentity,
_events: &[DomainEvent],
) -> Result<(), DomainError> {
Err(DomainError::ValidationError(
"blocking requires the federation feature".into(),
))
@@ -171,6 +221,7 @@ impl SocialCommand for LocalSocialService {
&self,
_blocker: &UserId,
_target: &SocialIdentity,
_events: &[DomainEvent],
) -> Result<(), DomainError> {
Err(DomainError::ValidationError(
"blocking requires the federation feature".into(),
@@ -242,6 +293,7 @@ impl ResolvedFollow for LocalSocialService {
&self,
follower: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let SocialIdentity::Local(target_id) = target else {
let actor_url = self.instance.actor_url_of(target);
@@ -255,11 +307,21 @@ impl ResolvedFollow for LocalSocialService {
let follower_url = self.instance.actor_url_for(follower);
let target_url = self.instance.actor_url_for(target_id);
self.follow_command
.add_follower(target_id.value(), &follower_url, FollowStatus::Pending)
.await?;
self.follow_command
.add_follow(follower.value(), &target_url, FollowStatus::Pending)
.await?;
Ok(())
.apply(
&[
FollowWrite::AddFollower {
local_user_id: target_id.value(),
follower_actor_url: follower_url,
status: FollowStatus::Pending,
},
FollowWrite::AddFollow {
follower_id: follower.value(),
target_actor_url: target_url,
status: FollowStatus::Pending,
},
],
events,
)
.await
}
}