transactions + unique-conflict

This commit is contained in:
nvrl committed 2026-06-15 17:32:26 +02:00
1 parent f8caef64c9
commit 1e0ccf53a4
4 files changed
+48 -10

No files matched your search

+7
View File
@@ -89,3 +89,10 @@ impl IntoResponse for ApiError {
}
pub type ApiResult<T> = Result<T, ApiError>;
/// True if a `DbErr` is a SQLite UNIQUE-constraint violation. SeaORM surfaces the
/// underlying sqlx message verbatim; matching on it lets handlers turn a racing
/// duplicate insert into a clean 400 instead of a generic 500.
pub fn is_unique_violation(e: &sea_orm::DbErr) -> bool {
e.to_string().contains("UNIQUE constraint failed")
}
+3 -3
View File
@@ -5,7 +5,7 @@
use sea_orm::sea_query::{Expr, Func};
use sea_orm::{
ActiveValue::{NotSet, Set},
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter,
ColumnTrait, Condition, ConnectionTrait, EntityTrait, QueryFilter,
};
use crate::entities::prelude::*;
@@ -47,8 +47,8 @@ fn eq_or_null(col: notification::Column, value: Option<i32>) -> Condition {
}
}
pub async fn parse_mentions(
db: &DatabaseConnection,
pub async fn parse_mentions<C: ConnectionTrait>(
db: &C,
text: &str,
actor_id: i32,
rating_id: Option<i32>,
+27 -7
View File
@@ -196,7 +196,13 @@ async fn create(
}
let now = crate::datetime::now_micros();
let model = rating::ActiveModel {
// Insert + mention fan-out in one transaction so a parse_mentions failure
// can't leave a rating with no notifications. The DB's UNIQUE(user_id,
// flavor_id) index is the real guard against a racing double-rate (the
// pre-check above is best-effort and not atomic): map that violation to the
// same clean validation error instead of a 500.
let txn = state.db.begin().await?;
let model = match (rating::ActiveModel {
id: NotSet,
user_id: Set(uid),
flavor_id: Set(flavor_id),
@@ -204,12 +210,22 @@ async fn create(
comment: Set(comment.clone()),
created_at: Set(now),
}
.insert(&state.db)
.await?;
.insert(&txn)
.await)
{
Ok(m) => m,
Err(e) if crate::error::is_unique_violation(&e) => {
return Err(ApiError::Validation(json!([
"You have already rated this flavor."
])));
}
Err(e) => return Err(e.into()),
};
if let Some(text) = comment.as_deref().filter(|s| !s.is_empty()) {
parse_mentions(&state.db, text, uid, Some(model.id), None).await?;
parse_mentions(&txn, text, uid, Some(model.id), None).await?;
}
txn.commit().await?;
let mut dtos = build_ratings(&state, &ctx, vec![model]).await?;
let out = dtos.pop().ok_or(ApiError::Internal)?;
@@ -430,6 +446,9 @@ async fn reply(
}
let now = crate::datetime::now_micros();
// Reply + owner-notification + mention fan-out are one unit: a failure in any
// step must not persist a partial reply (orphaned reply, missing notification).
let txn = state.db.begin().await?;
let reply_model = reply::ActiveModel {
id: NotSet,
user_id: Set(uid),
@@ -437,7 +456,7 @@ async fn reply(
text: Set(text.to_string()),
created_at: Set(now),
}
.insert(&state.db)
.insert(&txn)
.await?;
if rating.user_id != uid {
@@ -453,11 +472,12 @@ async fn reply(
notification_type: Set("reply".to_string()),
profile_comment_id: Set(None),
}
.insert(&state.db)
.insert(&txn)
.await?;
}
parse_mentions(&state.db, text, uid, Some(rating.id), Some(reply_model.id)).await?;
parse_mentions(&txn, text, uid, Some(rating.id), Some(reply_model.id)).await?;
txn.commit().await?;
let mut dtos = build_replies(&state, vec![reply_model]).await?;
let out = dtos.pop().ok_or(ApiError::Internal)?;