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

@@ -6,8 +6,9 @@ mod repositories;
mod shared;
pub use event_queue::{DbEventQueueConfig, SqliteEventQueue};
use repositories::SqliteUserSettingsRepository;
pub use repositories::{
SqliteApContentQuery, SqliteDiaryRepository, SqliteImageRefAdapter,
SqliteApContentQuery, SqliteDiaryRepository, SqliteGoalRepository, SqliteImageRefAdapter,
SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery,
SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository,
SqlitePersonAdapter, SqliteProcessedEventLedger, SqliteProfileFieldsRepository,
@@ -17,7 +18,6 @@ pub use repositories::{
SqliteWrapUpRepository, SqliteWrapUpStatsQuery, create_federated_profile_query,
create_image_ref, create_person_adapter, create_search_adapter,
};
use repositories::{SqliteGoalRepository, SqliteUserSettingsRepository};
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
/// here because it depends on `activitypub`/`k-ap`).

View File

@@ -1,12 +1,15 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Goal, GoalType},
ports::{GoalCommand, GoalQuery},
value_objects::{GoalId, UserId},
};
use sqlx::{Row, SqlitePool};
use crate::shared::enqueue_events;
pub struct SqliteGoalRepository {
pool: SqlitePool,
}
@@ -19,7 +22,7 @@ impl SqliteGoalRepository {
#[async_trait]
impl GoalCommand for SqliteGoalRepository {
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
async fn save(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> {
let id = goal.id().value().to_string();
let user_id = goal.user_id().value().to_string();
let year = goal.year() as i64;
@@ -27,6 +30,11 @@ impl GoalCommand for SqliteGoalRepository {
let goal_type = goal.goal_type().as_str();
let created_at = goal.created_at().format("%Y-%m-%d %H:%M:%S").to_string();
let mut tx = self
.pool
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query(
"INSERT INTO goals (id, user_id, year, target_count, goal_type, created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
@@ -37,45 +45,62 @@ impl GoalCommand for SqliteGoalRepository {
.bind(target)
.bind(goal_type)
.bind(&created_at)
.execute(&self.pool)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
enqueue_events(&mut tx, events).await?;
tx.commit().await.map_err(crate::shared::map_sqlx_error)
}
async fn update(&self, goal: &Goal) -> Result<(), DomainError> {
async fn update(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> {
let id = goal.id().value().to_string();
let target = goal.target_count() as i64;
let mut tx = self
.pool
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
let result = sqlx::query("UPDATE goals SET target_count = ? WHERE id = ?")
.bind(target)
.bind(&id)
.execute(&self.pool)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
}
Ok(())
enqueue_events(&mut tx, events).await?;
tx.commit().await.map_err(crate::shared::map_sqlx_error)
}
async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError> {
async fn delete(
&self,
id: &GoalId,
user_id: &UserId,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let gid = id.value().to_string();
let uid = user_id.value().to_string();
let mut tx = self
.pool
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
let result = sqlx::query("DELETE FROM goals WHERE id = ? AND user_id = ?")
.bind(&gid)
.bind(&uid)
.execute(&self.pool)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
}
Ok(())
enqueue_events(&mut tx, events).await?;
tx.commit().await.map_err(crate::shared::map_sqlx_error)
}
}

View File

@@ -2,10 +2,12 @@ use async_trait::async_trait;
use sqlx::{Row, SqlitePool};
use domain::{
errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository,
value_objects::UserId,
errors::DomainError, events::DomainEvent, models::ProfileField,
ports::UserProfileFieldsRepository, value_objects::UserId,
};
use crate::shared::enqueue_events;
pub struct SqliteProfileFieldsRepository {
pool: SqlitePool,
}
@@ -41,12 +43,18 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
&self,
user_id: &UserId,
fields: Vec<ProfileField>,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let id_str = user_id.value().to_string();
let mut tx = self
.pool
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query("DELETE FROM user_profile_fields WHERE user_id = ?")
.bind(&id_str)
.execute(&self.pool)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
@@ -61,11 +69,12 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
.bind(&field.name)
.bind(&field.value)
.bind(position)
.execute(&self.pool)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
}
Ok(())
enqueue_events(&mut tx, events).await?;
tx.commit().await.map_err(crate::shared::map_sqlx_error)
}
}

View File

@@ -161,8 +161,14 @@ impl UserRepository for SqliteUserRepository {
&self,
user_id: &UserId,
profile: &domain::models::UserProfile,
events: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
let id_str = user_id.value().to_string();
let mut tx = self
.pool
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query(
"UPDATE users SET display_name = ?, bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?",
)
@@ -172,10 +178,11 @@ impl UserRepository for SqliteUserRepository {
.bind(&profile.banner_path)
.bind(&profile.also_known_as)
.bind(&id_str)
.execute(&self.pool)
.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
crate::shared::enqueue_events(&mut tx, events).await?;
tx.commit().await.map_err(crate::shared::map_sqlx_error)
}
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {

View File

@@ -1,6 +1,7 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{
WatchlistEntry, WatchlistWithMovie,
collections::{PageParams, Paginated},
@@ -13,7 +14,7 @@ use sqlx::{Row, SqlitePool};
use rows::WatchlistRow;
mod rows;
use crate::shared::datetime_to_str;
use crate::shared::{datetime_to_str, enqueue_events};
pub struct SqliteWatchlistRepository {
pool: SqlitePool,
@@ -27,12 +28,17 @@ impl SqliteWatchlistRepository {
#[async_trait]
impl WatchlistRepository for SqliteWatchlistRepository {
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError> {
let id = entry.id.value().to_string();
let user_id = entry.user_id.value().to_string();
let movie_id = entry.movie_id.value().to_string();
let added_at = datetime_to_str(&entry.added_at);
let mut tx = self
.pool
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query(
"INSERT OR IGNORE INTO watchlist_entries (id, user_id, movie_id, added_at) \
VALUES (?, ?, ?, ?)",
@@ -41,22 +47,32 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(&user_id)
.bind(&movie_id)
.bind(&added_at)
.execute(&self.pool)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
Ok(())
enqueue_events(&mut tx, events).await?;
tx.commit().await.map_err(crate::shared::map_sqlx_error)
}
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 uid = user_id.value().to_string();
let mid = movie_id.value().to_string();
let mut tx = self
.pool
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
let result =
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
@@ -66,24 +82,36 @@ impl WatchlistRepository for SqliteWatchlistRepository {
mid
)));
}
Ok(())
enqueue_events(&mut tx, events).await?;
tx.commit().await.map_err(crate::shared::map_sqlx_error)
}
async fn remove_if_present(
&self,
user_id: &UserId,
movie_id: &MovieId,
events: &[DomainEvent],
) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let mid = movie_id.value().to_string();
let mut tx = self
.pool
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
let result =
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
Ok(result.rows_affected() > 0)
let removed = result.rows_affected() > 0;
if removed {
enqueue_events(&mut tx, events).await?;
}
tx.commit().await.map_err(crate::shared::map_sqlx_error)?;
Ok(removed)
}
async fn get_for_user(