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

@@ -4,8 +4,10 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, SocialCommand, UserRepository,
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowWrite, SocialCommand,
UserRepository,
},
testing::InMemoryStore,
value_objects::{
@@ -30,6 +32,8 @@ struct FollowFakeStore {
follows: Mutex<Vec<(Uuid, String, FollowStatus)>>,
// (local_user_id, follower_actor_url, status) — written by add_follower/update_follower_status/remove_follower_record
followers: Mutex<Vec<(Uuid, String, FollowStatus)>>,
recorded: Mutex<Vec<DomainEvent>>,
refuse_writes: bool,
}
impl FollowFakeStore {
@@ -37,8 +41,23 @@ impl FollowFakeStore {
Arc::new(Self {
follows: Mutex::new(Vec::new()),
followers: Mutex::new(Vec::new()),
recorded: Mutex::new(Vec::new()),
refuse_writes: false,
})
}
fn refusing() -> Arc<Self> {
Arc::new(Self {
follows: Mutex::new(Vec::new()),
followers: Mutex::new(Vec::new()),
recorded: Mutex::new(Vec::new()),
refuse_writes: true,
})
}
fn recorded_events(&self) -> Vec<DomainEvent> {
self.recorded.lock().unwrap().clone()
}
}
fn fake_actor(url: &str) -> SocialActor {
@@ -54,86 +73,63 @@ fn fake_actor(url: &str) -> SocialActor {
#[async_trait]
impl FollowCommand for FollowFakeStore {
async fn add_follow(
async fn apply(
&self,
follower_id: Uuid,
target_actor_url: &str,
status: FollowStatus,
writes: &[FollowWrite],
events: &[DomainEvent],
) -> Result<(), DomainError> {
self.follows
.lock()
.unwrap()
.push((follower_id, target_actor_url.to_string(), status));
Ok(())
}
async fn update_follow_status(
&self,
follower_id: Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let mut store = self.follows.lock().unwrap();
if let Some(entry) = store
.iter_mut()
.find(|(f, t, _)| *f == follower_id && t == target_actor_url)
{
entry.2 = status;
if self.refuse_writes {
return Err(DomainError::InfrastructureError("refused".into()));
}
Ok(())
}
async fn remove_follow(
&self,
follower_id: Uuid,
target_actor_url: &str,
) -> Result<(), DomainError> {
self.follows
.lock()
.unwrap()
.retain(|(f, t, _)| !(*f == follower_id && t == target_actor_url));
Ok(())
}
async fn add_follower(
&self,
local_user_id: Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
self.followers.lock().unwrap().push((
local_user_id,
follower_actor_url.to_string(),
status,
));
Ok(())
}
async fn update_follower_status(
&self,
local_user_id: Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let mut store = self.followers.lock().unwrap();
if let Some(entry) = store
.iter_mut()
.find(|(u, f, _)| *u == local_user_id && f == follower_actor_url)
{
entry.2 = status;
let mut follows = self.follows.lock().unwrap();
let mut followers = self.followers.lock().unwrap();
for write in writes {
match write {
FollowWrite::AddFollow {
follower_id,
target_actor_url,
status,
} => follows.push((*follower_id, target_actor_url.clone(), *status)),
FollowWrite::UpdateFollowStatus {
follower_id,
target_actor_url,
status,
} => {
if let Some(entry) = follows
.iter_mut()
.find(|(f, t, _)| f == follower_id && t == target_actor_url)
{
entry.2 = *status;
}
}
FollowWrite::RemoveFollow {
follower_id,
target_actor_url,
} => follows.retain(|(f, t, _)| !(f == follower_id && t == target_actor_url)),
FollowWrite::AddFollower {
local_user_id,
follower_actor_url,
status,
} => followers.push((*local_user_id, follower_actor_url.clone(), *status)),
FollowWrite::UpdateFollowerStatus {
local_user_id,
follower_actor_url,
status,
} => {
if let Some(entry) = followers
.iter_mut()
.find(|(u, f, _)| u == local_user_id && f == follower_actor_url)
{
entry.2 = *status;
}
}
FollowWrite::RemoveFollowerRecord {
local_user_id,
follower_actor_url,
} => followers.retain(|(u, f, _)| !(u == local_user_id && f == follower_actor_url)),
}
}
Ok(())
}
async fn remove_follower_record(
&self,
local_user_id: Uuid,
follower_actor_url: &str,
) -> Result<(), DomainError> {
self.followers
.lock()
.unwrap()
.retain(|(u, f, _)| !(*u == local_user_id && f == follower_actor_url));
self.recorded.lock().unwrap().extend_from_slice(events);
Ok(())
}
}
@@ -280,6 +276,7 @@ async fn follow_local_user_writes_both_sides() {
svc.follow(
&alice_id,
&FollowTarget::Handle("@bob@md.example".to_string()),
&[],
)
.await
.unwrap();
@@ -311,6 +308,7 @@ async fn follow_self_is_rejected() {
.follow(
&alice_id,
&FollowTarget::Handle("@alice@md.example".to_string()),
&[],
)
.await;
@@ -344,6 +342,7 @@ async fn follow_remote_target_errors_rather_than_silently_succeeding() {
.follow(
&alice_id,
&FollowTarget::Handle("@carol@other.example".to_string()),
&[],
)
.await;
@@ -386,11 +385,12 @@ async fn reject_follow_updates_follower_status_but_removes_the_follow_row() {
svc.follow(
&requester_id,
&FollowTarget::Handle("@owner@md.example".to_string()),
&[],
)
.await
.unwrap();
svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone()))
svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone()), &[])
.await
.unwrap();
@@ -431,9 +431,60 @@ async fn block_errors_and_get_blocked_is_empty() {
actor_url: "https://other.example/users/carol".to_string(),
};
let result = svc.block(&alice_id, &target).await;
let result = svc.block(&alice_id, &target, &[]).await;
assert!(matches!(result, Err(DomainError::ValidationError(_))));
let blocked = svc.get_blocked(&alice_id).await.unwrap();
assert_eq!(blocked.len(), 0);
}
#[tokio::test]
async fn a_local_follow_records_both_rows_and_its_event_together() {
let user_repo = InMemoryStore::new();
let bob_id = register_user(&user_repo, "bob").await;
let alice_id = register_user(&user_repo, "alice").await;
let follow_store = FollowFakeStore::new();
let svc = service(user_repo, &follow_store, instance());
let event = DomainEvent::FollowRequested {
follower: alice_id.clone(),
target: FollowTarget::Identity(SocialIdentity::Local(bob_id.clone())),
};
svc.follow(
&alice_id,
&FollowTarget::Handle("@bob@md.example".to_string()),
std::slice::from_ref(&event),
)
.await
.unwrap();
assert_eq!(svc.get_followers(&bob_id).await.unwrap().len(), 1);
assert_eq!(svc.get_following(&alice_id).await.unwrap().len(), 1);
assert_eq!(follow_store.recorded_events().len(), 1);
}
#[tokio::test]
async fn a_refused_local_follow_write_propagates_and_records_nothing() {
let user_repo = InMemoryStore::new();
let bob_id = register_user(&user_repo, "bob").await;
let alice_id = register_user(&user_repo, "alice").await;
let follow_store = FollowFakeStore::refusing();
let svc = service(user_repo, &follow_store, instance());
let event = DomainEvent::FollowRequested {
follower: alice_id.clone(),
target: FollowTarget::Identity(SocialIdentity::Local(bob_id.clone())),
};
let result = svc
.follow(
&alice_id,
&FollowTarget::Handle("@bob@md.example".to_string()),
std::slice::from_ref(&event),
)
.await;
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
assert!(svc.get_followers(&bob_id).await.unwrap().is_empty());
assert!(svc.get_following(&alice_id).await.unwrap().is_empty());
assert!(follow_store.recorded_events().is_empty());
}