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

@@ -2,49 +2,52 @@ use async_trait::async_trait;
use crate::{
errors::DomainError,
events::DomainEvent,
value_objects::{FollowRelation, FollowStatus, SocialActor},
};
/// One row-level change to the local follow tables. A local follow operation
/// is a batch of these applied in a single transaction with its events.
#[derive(Clone, Debug, PartialEq)]
pub enum FollowWrite {
AddFollow {
follower_id: uuid::Uuid,
target_actor_url: String,
status: FollowStatus,
},
UpdateFollowStatus {
follower_id: uuid::Uuid,
target_actor_url: String,
status: FollowStatus,
},
RemoveFollow {
follower_id: uuid::Uuid,
target_actor_url: String,
},
AddFollower {
local_user_id: uuid::Uuid,
follower_actor_url: String,
status: FollowStatus,
},
UpdateFollowerStatus {
local_user_id: uuid::Uuid,
follower_actor_url: String,
status: FollowStatus,
},
RemoveFollowerRecord {
local_user_id: uuid::Uuid,
follower_actor_url: String,
},
}
#[async_trait]
pub trait FollowCommand: Send + Sync {
async fn add_follow(
/// Applies every write and records `events` in one transaction: all rows
/// and events land, or none do.
async fn apply(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError>;
async fn update_follow_status(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError>;
async fn remove_follow(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<(), DomainError>;
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError>;
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError>;
async fn remove_follower_record(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
writes: &[FollowWrite],
events: &[DomainEvent],
) -> Result<(), DomainError>;
}

View File

@@ -44,25 +44,56 @@ impl super::SocialCommand for NoopSocialCommand {
&self,
_: &UserId,
_: &crate::value_objects::FollowTarget,
_: &[crate::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
async fn unfollow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
async fn unfollow(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[crate::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
async fn accept_follow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
async fn accept_follow(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[crate::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
async fn reject_follow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
async fn reject_follow(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[crate::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
async fn remove_follower(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
async fn remove_follower(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[crate::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
async fn block(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
async fn block(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[crate::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
async fn unblock(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
async fn unblock(
&self,
_: &UserId,
_: &SocialIdentity,
_: &[crate::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
}

View File

@@ -3,6 +3,7 @@ use chrono::NaiveDateTime;
use crate::{
errors::DomainError,
events::DomainEvent,
models::{
DiaryEntry, FederationFlags, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry,
WatchlistWithMovie,
@@ -14,32 +15,54 @@ use crate::{
#[async_trait]
pub trait SocialCommand: Send + Sync {
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError>;
async fn follow(
&self,
follower: &UserId,
target: &FollowTarget,
events: &[DomainEvent],
) -> Result<(), DomainError>;
async fn unfollow(&self, follower: &UserId, target: &SocialIdentity)
-> Result<(), DomainError>;
async fn unfollow(
&self,
follower: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError>;
async fn accept_follow(
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError>;
async fn reject_follow(
&self,
owner: &UserId,
requester: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError>;
async fn remove_follower(
&self,
owner: &UserId,
follower: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError>;
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError>;
async fn block(
&self,
blocker: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError>;
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError>;
async fn unblock(
&self,
blocker: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError>;
}
#[async_trait]
@@ -138,6 +161,7 @@ pub trait ResolvedFollow: Send + Sync {
&self,
follower: &UserId,
target: &SocialIdentity,
events: &[DomainEvent],
) -> Result<(), DomainError>;
}

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(())
}
}

View File

@@ -14,6 +14,7 @@ impl SocialCommand for Stub {
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::FollowTarget,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
@@ -21,6 +22,7 @@ impl SocialCommand for Stub {
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
@@ -28,6 +30,7 @@ impl SocialCommand for Stub {
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
@@ -35,6 +38,7 @@ impl SocialCommand for Stub {
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
@@ -42,6 +46,7 @@ impl SocialCommand for Stub {
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
@@ -49,6 +54,7 @@ impl SocialCommand for Stub {
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
@@ -56,6 +62,7 @@ impl SocialCommand for Stub {
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}
@@ -145,6 +152,7 @@ impl ResolvedFollow for Stub {
&self,
_: &domain::value_objects::UserId,
_: &SocialIdentity,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}

View File

@@ -128,6 +128,7 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
&FollowTarget::Identity(SocialIdentity::Remote {
actor_url: REMOTE_ACTOR.into(),
}),
&[],
)
.await
.unwrap();
@@ -135,6 +136,7 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
.follow(
other.id(),
&FollowTarget::Identity(SocialIdentity::Local(user_id.clone())),
&[],
)
.await
.unwrap();
@@ -144,6 +146,7 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
&SocialIdentity::Remote {
actor_url: REMOTE_ACTOR.into(),
},
&[],
)
.await
.unwrap();