feat: atomic goal, watchlist, profile writes

This commit is contained in:
2026-10-26 00:49:28 +01:00
parent c8a1e8bfe4
commit 62ad9f4017
48 changed files with 1296 additions and 264 deletions

View File

@@ -23,6 +23,7 @@ pub trait UserRepository: Send + Sync {
&self,
user_id: &UserId,
profile: &crate::models::UserProfile,
events: &[crate::events::DomainEvent],
) -> Result<(), DomainError>;
}
@@ -36,6 +37,7 @@ pub trait UserProfileFieldsRepository: Send + Sync {
&self,
user_id: &UserId,
fields: Vec<crate::models::ProfileField>,
events: &[crate::events::DomainEvent],
) -> Result<(), DomainError>;
}

View File

@@ -2,15 +2,21 @@ use async_trait::async_trait;
use crate::{
errors::DomainError,
events::DomainEvent,
models::Goal,
value_objects::{GoalId, UserId},
};
#[async_trait]
pub trait GoalCommand: Send + Sync {
async fn save(&self, goal: &Goal) -> Result<(), DomainError>;
async fn update(&self, goal: &Goal) -> Result<(), DomainError>;
async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError>;
async fn save(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError>;
async fn update(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError>;
async fn delete(
&self,
id: &GoalId,
user_id: &UserId,
events: &[DomainEvent],
) -> Result<(), DomainError>;
}
#[async_trait]

View File

@@ -2,6 +2,7 @@ use async_trait::async_trait;
use crate::{
errors::DomainError,
events::DomainEvent,
models::{
WatchlistEntry, WatchlistWithMovie,
collections::{PageParams, Paginated},
@@ -11,14 +12,21 @@ use crate::{
#[async_trait]
pub trait WatchlistRepository: Send + Sync {
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError>;
async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError>;
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError>;
async fn remove(
&self,
user_id: &UserId,
movie_id: &MovieId,
events: &[DomainEvent],
) -> Result<(), DomainError>;
/// `events` are recorded only when an entry was actually removed.
async fn remove_if_present(
&self,
user_id: &UserId,
movie_id: &MovieId,
events: &[DomainEvent],
) -> Result<bool, DomainError>;
async fn get_for_user(

View File

@@ -74,16 +74,17 @@ pub struct NoopGoalCommand;
#[async_trait]
impl crate::ports::GoalCommand for NoopGoalCommand {
async fn save(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
async fn save(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
Ok(())
}
async fn update(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
async fn update(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
Ok(())
}
async fn delete(
&self,
_: &crate::value_objects::GoalId,
_: &UserId,
_: &[DomainEvent],
) -> Result<(), DomainError> {
Ok(())
}

View File

@@ -295,13 +295,18 @@ pub struct PanicWatchlistRepository;
#[async_trait]
impl WatchlistRepository for PanicWatchlistRepository {
async fn add(&self, _: &WatchlistEntry) -> Result<(), DomainError> {
async fn add(&self, _: &WatchlistEntry, _: &[DomainEvent]) -> Result<(), DomainError> {
panic!("PanicWatchlistRepository called")
}
async fn remove(&self, _: &UserId, _: &MovieId) -> Result<(), DomainError> {
async fn remove(&self, _: &UserId, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> {
panic!("PanicWatchlistRepository called")
}
async fn remove_if_present(&self, _: &UserId, _: &MovieId) -> Result<bool, DomainError> {
async fn remove_if_present(
&self,
_: &UserId,
_: &MovieId,
_: &[DomainEvent],
) -> Result<bool, DomainError> {
panic!("PanicWatchlistRepository called")
}
async fn get_for_user(
@@ -357,6 +362,7 @@ impl UserProfileFieldsRepository for PanicProfileFieldsRepo {
&self,
_: &UserId,
_: Vec<crate::models::ProfileField>,
_: &[DomainEvent],
) -> Result<(), DomainError> {
panic!("PanicProfileFieldsRepo called")
}
@@ -645,6 +651,7 @@ impl UserRepository for PanicUserRepository {
&self,
_: &UserId,
_: &crate::models::UserProfile,
_: &[DomainEvent],
) -> Result<(), DomainError> {
panic!("PanicUserRepository called")
}
@@ -675,16 +682,17 @@ pub struct PanicGoalCommand;
#[async_trait]
impl crate::ports::GoalCommand for PanicGoalCommand {
async fn save(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
async fn save(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
panic!("PanicGoalCommand called")
}
async fn update(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
async fn update(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
panic!("PanicGoalCommand called")
}
async fn delete(
&self,
_: &crate::value_objects::GoalId,
_: &crate::value_objects::UserId,
_: &[DomainEvent],
) -> Result<(), DomainError> {
panic!("PanicGoalCommand called")
}

View File

@@ -6,11 +6,14 @@ use crate::{
errors::DomainError,
events::DomainEvent,
models::{
Movie, Review, WatchlistEntry, WatchlistWithMovie,
Goal, Movie, ProfileField, Review, WatchlistEntry, WatchlistWithMovie,
collections::{PageParams, Paginated},
},
ports::{EventPublisher, MovieCommand, ReviewRepository, WatchlistRepository},
value_objects::{MovieId, ReviewId, UserId},
ports::{
EventPublisher, GoalCommand, MovieCommand, ReviewRepository, UserProfileFieldsRepository,
WatchlistRepository,
},
value_objects::{GoalId, MovieId, ReviewId, UserId},
};
fn refused() -> DomainError {
@@ -54,13 +57,18 @@ pub struct RefusingWatchlistRepository;
#[async_trait]
impl WatchlistRepository for RefusingWatchlistRepository {
async fn add(&self, _: &WatchlistEntry) -> Result<(), DomainError> {
async fn add(&self, _: &WatchlistEntry, _: &[DomainEvent]) -> Result<(), DomainError> {
Err(refused())
}
async fn remove(&self, _: &UserId, _: &MovieId) -> Result<(), DomainError> {
async fn remove(&self, _: &UserId, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> {
Err(refused())
}
async fn remove_if_present(&self, _: &UserId, _: &MovieId) -> Result<bool, DomainError> {
async fn remove_if_present(
&self,
_: &UserId,
_: &MovieId,
_: &[DomainEvent],
) -> Result<bool, DomainError> {
Err(refused())
}
async fn get_for_user(
@@ -75,6 +83,38 @@ impl WatchlistRepository for RefusingWatchlistRepository {
}
}
pub struct RefusingGoalCommand;
#[async_trait]
impl GoalCommand for RefusingGoalCommand {
async fn save(&self, _: &Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
Err(refused())
}
async fn update(&self, _: &Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
Err(refused())
}
async fn delete(&self, _: &GoalId, _: &UserId, _: &[DomainEvent]) -> Result<(), DomainError> {
Err(refused())
}
}
pub struct RefusingProfileFieldsRepository;
#[async_trait]
impl UserProfileFieldsRepository for RefusingProfileFieldsRepository {
async fn get_fields(&self, _: &UserId) -> Result<Vec<ProfileField>, DomainError> {
Err(refused())
}
async fn set_fields(
&self,
_: &UserId,
_: Vec<ProfileField>,
_: &[DomainEvent],
) -> Result<(), DomainError> {
Err(refused())
}
}
pub struct RefusingEventPublisher;
#[async_trait]

View File

@@ -2,6 +2,7 @@ use async_trait::async_trait;
use crate::{
errors::DomainError,
events::DomainEvent,
models::Goal,
ports::{GoalCommand, GoalQuery},
value_objects::{GoalId, UserId},
@@ -12,7 +13,7 @@ use super::InMemoryStore;
#[async_trait]
impl GoalCommand for InMemoryStore {
/// `goals` is UNIQUE(user_id, year).
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
async fn save(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> {
let mut goals = self.goals.write().unwrap();
let duplicate = goals.values().any(|g| {
g.id() == goal.id() || (g.user_id() == goal.user_id() && g.year() == goal.year())
@@ -23,14 +24,18 @@ impl GoalCommand for InMemoryStore {
));
}
goals.insert(goal.id().value(), goal.clone());
drop(goals);
self.record_events(events);
Ok(())
}
async fn update(&self, goal: &Goal) -> Result<(), DomainError> {
async fn update(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> {
let mut store = self.goals.write().unwrap();
match store.entry(goal.id().value()) {
std::collections::hash_map::Entry::Occupied(mut e) => {
e.insert(goal.clone());
drop(store);
self.record_events(events);
Ok(())
}
std::collections::hash_map::Entry::Vacant(_) => {
@@ -39,8 +44,14 @@ impl GoalCommand for InMemoryStore {
}
}
async fn delete(&self, id: &GoalId, _user_id: &UserId) -> Result<(), DomainError> {
async fn delete(
&self,
id: &GoalId,
_user_id: &UserId,
events: &[DomainEvent],
) -> Result<(), DomainError> {
self.goals.write().unwrap().remove(&id.value());
self.record_events(events);
Ok(())
}
}

View File

@@ -84,11 +84,14 @@ impl UserRepository for InMemoryStore {
&self,
user_id: &UserId,
profile: &crate::models::UserProfile,
events: &[crate::events::DomainEvent],
) -> Result<(), DomainError> {
let mut store = self.users.write().unwrap();
if let Some(user) = store.get_mut(&user_id.value()) {
user.update_profile(profile.clone());
}
drop(store);
self.record_events(events);
Ok(())
}
}
@@ -104,11 +107,13 @@ impl UserProfileFieldsRepository for InMemoryStore {
&self,
user_id: &UserId,
fields: Vec<ProfileField>,
events: &[crate::events::DomainEvent],
) -> Result<(), DomainError> {
self.profile_fields
.write()
.unwrap()
.insert(user_id.value(), fields);
self.record_events(events);
Ok(())
}
}

View File

@@ -3,6 +3,7 @@ use uuid::Uuid;
use crate::{
errors::DomainError,
events::DomainEvent,
models::{
RemoteWatchlistEntry, WatchlistEntry, WatchlistWithMovie,
collections::{PageParams, Paginated},
@@ -16,23 +17,30 @@ use super::InMemoryStore;
#[async_trait]
impl WatchlistRepository for InMemoryStore {
/// `INSERT OR IGNORE` on UNIQUE(user_id, movie_id): adding twice is a no-op.
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError> {
let key = (entry.user_id.value(), entry.movie_id.value());
self.watchlist
.write()
.unwrap()
.entry(key)
.or_insert_with(|| entry.clone());
self.record_events(events);
Ok(())
}
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
async fn remove(
&self,
user_id: &UserId,
movie_id: &MovieId,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let key = (user_id.value(), movie_id.value());
self.watchlist
.write()
.unwrap()
.remove(&key)
.ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?;
self.record_events(events);
Ok(())
}
@@ -40,9 +48,14 @@ impl WatchlistRepository for InMemoryStore {
&self,
user_id: &UserId,
movie_id: &MovieId,
events: &[DomainEvent],
) -> Result<bool, DomainError> {
let key = (user_id.value(), movie_id.value());
Ok(self.watchlist.write().unwrap().remove(&key).is_some())
let removed = self.watchlist.write().unwrap().remove(&key).is_some();
if removed {
self.record_events(events);
}
Ok(removed)
}
async fn get_for_user(

View File

@@ -73,10 +73,13 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
store.upsert_movie(&movie).await.unwrap();
store
.add(&WatchlistEntry::new(user_id.clone(), movie.id().clone()))
.add(
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
&[],
)
.await
.unwrap();
GoalCommand::save(&**store, &test_goal(&user_id, 2024, 50))
GoalCommand::save(&**store, &test_goal(&user_id, 2024, 50), &[])
.await
.unwrap();
UserSettingsRepository::save(&**store, &UserSettings::new(user_id.clone()))
@@ -89,6 +92,7 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
name: "Site".into(),
value: "https://example.com".into(),
}],
&[],
)
.await
.unwrap();
@@ -197,10 +201,10 @@ async fn deleting_a_user_leaves_other_users_rows_alone() {
let bystander = test_user("carol");
UserRepository::save(&*store, &bystander).await.unwrap();
store
.add(&WatchlistEntry::new(
bystander.id().clone(),
movie.id().clone(),
))
.add(
&WatchlistEntry::new(bystander.id().clone(), movie.id().clone()),
&[],
)
.await
.unwrap();
@@ -219,7 +223,10 @@ async fn seed_movie_with_dependents(
let movie = test_movie("Heat");
store.upsert_movie(&movie).await.unwrap();
store
.add(&WatchlistEntry::new(user.id().clone(), movie.id().clone()))
.add(
&WatchlistEntry::new(user.id().clone(), movie.id().clone()),
&[],
)
.await
.unwrap();
store.upsert(&movie_profile(movie.id())).await.unwrap();
@@ -327,11 +334,11 @@ async fn saving_a_user_with_a_taken_email_is_a_validation_error() {
async fn saving_a_second_goal_for_the_same_user_and_year_is_an_infrastructure_error() {
let store = InMemoryStore::new();
let user_id = UserId::generate();
GoalCommand::save(&*store, &test_goal(&user_id, 2024, 10))
GoalCommand::save(&*store, &test_goal(&user_id, 2024, 10), &[])
.await
.unwrap();
let result = GoalCommand::save(&*store, &test_goal(&user_id, 2024, 20)).await;
let result = GoalCommand::save(&*store, &test_goal(&user_id, 2024, 20), &[]).await;
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
assert_eq!(store.goal_count(), 1);
@@ -344,11 +351,11 @@ async fn adding_the_same_movie_to_a_watchlist_twice_keeps_one_entry() {
let movie_id = MovieId::generate();
store
.add(&WatchlistEntry::new(user_id.clone(), movie_id.clone()))
.add(&WatchlistEntry::new(user_id.clone(), movie_id.clone()), &[])
.await
.unwrap();
store
.add(&WatchlistEntry::new(user_id, movie_id))
.add(&WatchlistEntry::new(user_id, movie_id), &[])
.await
.unwrap();