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

@@ -12,10 +12,12 @@ use crate::{
collections::{PageParams, Paginated},
},
ports::{
EventPublisher, GoalCommand, MovieCommand, ReviewRepository, UserProfileFieldsRepository,
WatchEventCommand, WatchlistRepository,
EventPublisher, FollowCommand, FollowWrite, GoalCommand, MovieCommand, ReviewRepository,
SocialCommand, UserProfileFieldsRepository, WatchEventCommand, WatchlistRepository,
},
value_objects::{
FollowTarget, GoalId, MovieId, ReviewId, SocialIdentity, UserId, WatchEventId,
},
value_objects::{GoalId, MovieId, ReviewId, UserId, WatchEventId},
};
fn refused() -> DomainError {
@@ -151,3 +153,74 @@ impl WatchEventCommand for RefusingWatchEventCommand {
Err(refused())
}
}
pub struct RefusingSocialCommand;
#[async_trait]
impl SocialCommand for RefusingSocialCommand {
async fn follow(
&self,
_: &UserId,
_: &FollowTarget,
_: &[DomainEvent],
) -> Result<(), DomainError> {
Err(refused())
}
async fn unfollow(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[DomainEvent],
) -> Result<(), DomainError> {
Err(refused())
}
async fn accept_follow(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[DomainEvent],
) -> Result<(), DomainError> {
Err(refused())
}
async fn reject_follow(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[DomainEvent],
) -> Result<(), DomainError> {
Err(refused())
}
async fn remove_follower(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[DomainEvent],
) -> Result<(), DomainError> {
Err(refused())
}
async fn block(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[DomainEvent],
) -> Result<(), DomainError> {
Err(refused())
}
async fn unblock(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[DomainEvent],
) -> Result<(), DomainError> {
Err(refused())
}
}
pub struct RefusingFollowCommand;
#[async_trait]
impl FollowCommand for RefusingFollowCommand {
async fn apply(&self, _: &[FollowWrite], _: &[DomainEvent]) -> Result<(), DomainError> {
Err(refused())
}
}

View File

@@ -2,6 +2,7 @@ use async_trait::async_trait;
use crate::{
errors::DomainError,
events::DomainEvent,
ports::{BlockQuery, FollowGraphQuery, SocialCommand},
value_objects::{FollowRelation, FollowStatus, SocialActor, SocialIdentity, UserId},
};
@@ -28,6 +29,7 @@ impl SocialCommand for InMemoryStore {
&self,
follower: &UserId,
target: &crate::value_objects::FollowTarget,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let identity = match target {
crate::value_objects::FollowTarget::Identity(id) => id.clone(),
@@ -50,6 +52,8 @@ impl SocialCommand for InMemoryStore {
return Err(DomainError::ValidationError("Already following".into()));
}
store.push((follower.value(), identity, FollowState::Pending));
drop(store);
self.record_events(events);
Ok(())
}
@@ -57,6 +61,7 @@ impl SocialCommand for InMemoryStore {
&self,
follower: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let mut store = self.follows.write().unwrap();
let before = store.len();
@@ -66,6 +71,8 @@ impl SocialCommand for InMemoryStore {
"Follow relationship not found".into(),
));
}
drop(store);
self.record_events(events);
Ok(())
}
@@ -73,6 +80,7 @@ impl SocialCommand for InMemoryStore {
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let mut store = self.follows.write().unwrap();
let target_identity = SocialIdentity::Local(owner.clone());
@@ -92,6 +100,8 @@ impl SocialCommand for InMemoryStore {
};
if requester_matches && *t == target_identity && *state == FollowState::Pending {
*state = FollowState::Accepted;
drop(store);
self.record_events(events);
return Ok(());
}
}
@@ -104,6 +114,7 @@ impl SocialCommand for InMemoryStore {
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let mut store = self.follows.write().unwrap();
let target_identity = SocialIdentity::Local(owner.clone());
@@ -123,6 +134,8 @@ impl SocialCommand for InMemoryStore {
"Pending follow request not found".into(),
));
}
drop(store);
self.record_events(events);
Ok(())
}
@@ -130,6 +143,7 @@ impl SocialCommand for InMemoryStore {
&self,
owner: &UserId,
follower: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let mut store = self.follows.write().unwrap();
let target_identity = SocialIdentity::Local(owner.clone());
@@ -144,21 +158,38 @@ impl SocialCommand for InMemoryStore {
if store.len() == before {
return Err(DomainError::NotFound("Follower not found".into()));
}
drop(store);
self.record_events(events);
Ok(())
}
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
async fn block(
&self,
blocker: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let mut store = self.blocked.write().unwrap();
store.push((blocker.value(), target.clone()));
// Also remove any existing follow relationships
let mut follows = self.follows.write().unwrap();
follows.retain(|(f, t, _)| !(*f == blocker.value() && t == target));
drop(follows);
drop(store);
self.record_events(events);
Ok(())
}
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
async fn unblock(
&self,
blocker: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let mut store = self.blocked.write().unwrap();
store.retain(|(b, t)| !(*b == blocker.value() && t == target));
drop(store);
self.record_events(events);
Ok(())
}
}