diff options
| author | soryu <soryu@soryu.co> | 2025-12-23 22:20:52 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2025-12-23 22:20:52 +0000 |
| commit | 72c2590571104b8d10e3f72d7a5b984d0b520c51 (patch) | |
| tree | 735aa03056a44a93b9abdf915545ad034ee2b597 /makima/src/db/repository.rs | |
| parent | f5222a7ae5ade5589436778cb01fc0abe625b3c3 (diff) | |
| download | soryu-72c2590571104b8d10e3f72d7a5b984d0b520c51.tar.gz soryu-72c2590571104b8d10e3f72d7a5b984d0b520c51.zip | |
Add conflict notification and file update WS endpoint
Diffstat (limited to 'makima/src/db/repository.rs')
| -rw-r--r-- | makima/src/db/repository.rs | 132 |
1 files changed, 110 insertions, 22 deletions
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index f8b90b3..5b962ee 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -9,6 +9,43 @@ use super::models::{CreateFileRequest, File, UpdateFileRequest}; /// Default owner ID for anonymous users. pub const ANONYMOUS_OWNER_ID: Uuid = Uuid::from_u128(0x00000000_0000_0000_0000_000000000002); +/// Repository error types. +#[derive(Debug)] +pub enum RepositoryError { + /// Database error + Database(sqlx::Error), + /// Version conflict (optimistic locking failure) + VersionConflict { + /// The version the client expected + expected: i32, + /// The actual current version in the database + actual: i32, + }, +} + +impl From<sqlx::Error> for RepositoryError { + fn from(e: sqlx::Error) -> Self { + RepositoryError::Database(e) + } +} + +impl std::fmt::Display for RepositoryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RepositoryError::Database(e) => write!(f, "Database error: {}", e), + RepositoryError::VersionConflict { expected, actual } => { + write!( + f, + "Version conflict: expected {}, actual {}", + expected, actual + ) + } + } + } +} + +impl std::error::Error for RepositoryError {} + /// Generate a default name based on current timestamp. fn generate_default_name() -> String { let now = Utc::now(); @@ -25,7 +62,7 @@ pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File, r#" INSERT INTO files (owner_id, name, description, transcript, location, summary, body) VALUES ($1, $2, $3, $4, $5, NULL, $6) - RETURNING id, owner_id, name, description, transcript, location, summary, body, created_at, updated_at + RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at "#, ) .bind(ANONYMOUS_OWNER_ID) @@ -42,7 +79,7 @@ pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File, pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Error> { sqlx::query_as::<_, File>( r#" - SELECT id, owner_id, name, description, transcript, location, summary, body, created_at, updated_at + SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at FROM files WHERE id = $1 AND owner_id = $2 "#, @@ -57,7 +94,7 @@ pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Err pub async fn list_files(pool: &PgPool) -> Result<Vec<File>, sqlx::Error> { sqlx::query_as::<_, File>( r#" - SELECT id, owner_id, name, description, transcript, location, summary, body, created_at, updated_at + SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at FROM files WHERE owner_id = $1 ORDER BY created_at DESC @@ -68,18 +105,33 @@ pub async fn list_files(pool: &PgPool) -> Result<Vec<File>, sqlx::Error> { .await } -/// Update a file by ID. +/// Update a file by ID with optimistic locking. +/// +/// If `req.version` is provided, the update will only succeed if the current +/// version matches. Returns `RepositoryError::VersionConflict` if there's a mismatch. +/// +/// If `req.version` is None (e.g., internal system updates), version checking is skipped. pub async fn update_file( pool: &PgPool, id: Uuid, req: UpdateFileRequest, -) -> Result<Option<File>, sqlx::Error> { +) -> Result<Option<File>, RepositoryError> { // Get the existing file first let existing = get_file(pool, id).await?; let Some(existing) = existing else { return Ok(None); }; + // Check version if provided (optimistic locking) + if let Some(expected_version) = req.version { + if existing.version != expected_version { + return Err(RepositoryError::VersionConflict { + expected: expected_version, + actual: existing.version, + }); + } + } + // Apply updates let name = req.name.unwrap_or(existing.name); let description = req.description.or(existing.description); @@ -89,23 +141,59 @@ pub async fn update_file( let body = req.body.unwrap_or(existing.body); let body_json = serde_json::to_value(&body).unwrap_or_default(); - sqlx::query_as::<_, File>( - r#" - UPDATE files - SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW() - WHERE id = $1 AND owner_id = $2 - RETURNING id, owner_id, name, description, transcript, location, summary, body, created_at, updated_at - "#, - ) - .bind(id) - .bind(ANONYMOUS_OWNER_ID) - .bind(&name) - .bind(&description) - .bind(&transcript_json) - .bind(&summary) - .bind(&body_json) - .fetch_optional(pool) - .await + // Update with version check in WHERE clause for race condition safety + let result = if req.version.is_some() { + sqlx::query_as::<_, File>( + r#" + UPDATE files + SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 AND version = $8 + RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + "#, + ) + .bind(id) + .bind(ANONYMOUS_OWNER_ID) + .bind(&name) + .bind(&description) + .bind(&transcript_json) + .bind(&summary) + .bind(&body_json) + .bind(req.version.unwrap()) + .fetch_optional(pool) + .await? + } else { + // No version check for internal updates + sqlx::query_as::<_, File>( + r#" + UPDATE files + SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at + "#, + ) + .bind(id) + .bind(ANONYMOUS_OWNER_ID) + .bind(&name) + .bind(&description) + .bind(&transcript_json) + .bind(&summary) + .bind(&body_json) + .fetch_optional(pool) + .await? + }; + + // If versioned update returned None, there was a race condition + if result.is_none() && req.version.is_some() { + // Re-fetch to get the actual version + if let Some(current) = get_file(pool, id).await? { + return Err(RepositoryError::VersionConflict { + expected: req.version.unwrap(), + actual: current.version, + }); + } + } + + Ok(result) } /// Delete a file by ID. |
