//! Idempotency key storage for safe POST retries. use sqlx::PgPool; use crate::db::UserId; use crate::error::Result; /// A cached idempotency response. #[derive(sqlx::FromRow)] pub struct CachedResponse { pub status_code: i16, pub response_body: String, } /// Look up a cached response for an idempotency key. /// Scoped to (key, user_id, method, path) to prevent cross-endpoint collisions. #[tracing::instrument(skip_all)] pub async fn get_cached_response( pool: &PgPool, key: &str, user_id: UserId, method: &str, path: &str, ) -> Result> { let row = sqlx::query_as::<_, CachedResponse>( "SELECT status_code, response_body FROM idempotency_keys WHERE key = $1 AND user_id = $2 AND method = $3 AND path = $4", ) .bind(key) .bind(user_id) .bind(method) .bind(path) .fetch_optional(pool) .await?; Ok(row) } /// Store a response for an idempotency key. Uses ON CONFLICT to handle /// race conditions (first writer wins). #[tracing::instrument(skip_all)] pub async fn store_response( pool: &PgPool, key: &str, user_id: UserId, method: &str, path: &str, status_code: u16, response_body: &str, ) -> Result<()> { sqlx::query( r#"INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (key, user_id, method, path) DO NOTHING"#, ) .bind(key) .bind(user_id) .bind(method) .bind(path) .bind(status_code as i16) .bind(response_body) .execute(pool) .await?; Ok(()) } /// Delete expired idempotency keys (older than 24 hours). #[tracing::instrument(skip_all)] pub async fn cleanup_expired(pool: &PgPool) -> Result { let result = sqlx::query( "DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'", ) .execute(pool) .await?; Ok(result.rows_affected()) }