summaryrefslogtreecommitdiff
path: root/makima/src/db
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/db')
-rw-r--r--makima/src/db/models.rs7
-rw-r--r--makima/src/db/repository.rs132
2 files changed, 117 insertions, 22 deletions
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 135ae75..8204b86 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -68,6 +68,8 @@ pub struct File {
/// Structured body content (headings, paragraphs, charts)
#[sqlx(json)]
pub body: Vec<BodyElement>,
+ /// Version number for optimistic locking
+ pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -100,6 +102,8 @@ pub struct UpdateFileRequest {
pub summary: Option<String>,
/// Structured body content (optional)
pub body: Option<Vec<BodyElement>>,
+ /// Version for optimistic locking (required for updates from frontend)
+ pub version: Option<i32>,
}
/// Response for file list endpoint.
@@ -120,6 +124,8 @@ pub struct FileSummary {
pub transcript_count: usize,
/// Duration derived from last transcript end time
pub duration: Option<f32>,
+ /// Version number for optimistic locking
+ pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -137,6 +143,7 @@ impl From<File> for FileSummary {
description: file.description,
transcript_count: file.transcript.len(),
duration: if duration > 0.0 { Some(duration) } else { None },
+ version: file.version,
created_at: file.created_at,
updated_at: file.updated_at,
}
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.