diff options
Diffstat (limited to 'makima/src/db')
| -rw-r--r-- | makima/src/db/models.rs | 96 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 144 |
2 files changed, 239 insertions, 1 deletions
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 8204b86..617e590 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -149,3 +149,99 @@ impl From<File> for FileSummary { } } } + +// ============================================================================= +// Version History Types +// ============================================================================= + +/// Source of a version change +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, sqlx::Type)] +#[sqlx(type_name = "varchar")] +#[serde(rename_all = "lowercase")] +pub enum VersionSource { + #[sqlx(rename = "user")] + User, + #[sqlx(rename = "llm")] + Llm, + #[sqlx(rename = "system")] + System, +} + +impl std::fmt::Display for VersionSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VersionSource::User => write!(f, "user"), + VersionSource::Llm => write!(f, "llm"), + VersionSource::System => write!(f, "system"), + } + } +} + +impl std::str::FromStr for VersionSource { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "user" => Ok(VersionSource::User), + "llm" => Ok(VersionSource::Llm), + "system" => Ok(VersionSource::System), + _ => Err(format!("Unknown version source: {}", s)), + } + } +} + +/// Full version record from the database +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FileVersion { + pub id: Uuid, + pub file_id: Uuid, + pub version: i32, + pub name: String, + pub description: Option<String>, + pub summary: Option<String>, + #[sqlx(json)] + pub body: Vec<BodyElement>, + pub source: String, + pub change_description: Option<String>, + pub created_at: DateTime<Utc>, +} + +/// Summary of a version for list views +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FileVersionSummary { + pub version: i32, + pub source: String, + pub created_at: DateTime<Utc>, + pub change_description: Option<String>, +} + +impl From<FileVersion> for FileVersionSummary { + fn from(v: FileVersion) -> Self { + Self { + version: v.version, + source: v.source, + created_at: v.created_at, + change_description: v.change_description, + } + } +} + +/// Response for version list endpoint +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FileVersionListResponse { + pub versions: Vec<FileVersionSummary>, + pub total: i64, +} + +/// Request to restore a file to a previous version +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RestoreVersionRequest { + /// The version to restore to + pub target_version: i32, + /// The current version (for optimistic locking) + pub current_version: i32, +} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 5b962ee..4137ba6 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -4,7 +4,7 @@ use chrono::Utc; use sqlx::PgPool; use uuid::Uuid; -use super::models::{CreateFileRequest, File, UpdateFileRequest}; +use super::models::{CreateFileRequest, File, FileVersion, UpdateFileRequest}; /// Default owner ID for anonymous users. pub const ANONYMOUS_OWNER_ID: Uuid = Uuid::from_u128(0x00000000_0000_0000_0000_000000000002); @@ -221,3 +221,145 @@ pub async fn count_files(pool: &PgPool) -> Result<i64, sqlx::Error> { Ok(result.0) } + +// ============================================================================= +// Version History Functions +// ============================================================================= + +/// Set the version source for the current transaction. +/// This is used by the trigger to record who made the change. +pub async fn set_version_source(pool: &PgPool, source: &str) -> Result<(), sqlx::Error> { + sqlx::query(&format!("SET LOCAL app.version_source = '{}'", source)) + .execute(pool) + .await?; + Ok(()) +} + +/// Set the change description for the current transaction. +pub async fn set_change_description(pool: &PgPool, description: &str) -> Result<(), sqlx::Error> { + // Escape single quotes for SQL + let escaped = description.replace('\'', "''"); + sqlx::query(&format!("SET LOCAL app.change_description = '{}'", escaped)) + .execute(pool) + .await?; + Ok(()) +} + +/// List all versions of a file, ordered by version DESC. +pub async fn list_file_versions(pool: &PgPool, file_id: Uuid) -> Result<Vec<FileVersion>, sqlx::Error> { + // First get the current version from the files table + let current = get_file(pool, file_id).await?; + + let mut versions = sqlx::query_as::<_, FileVersion>( + r#" + SELECT id, file_id, version, name, description, summary, body, source, change_description, created_at + FROM file_versions + WHERE file_id = $1 + ORDER BY version DESC + "#, + ) + .bind(file_id) + .fetch_all(pool) + .await?; + + // Add the current version as the first entry if it exists + if let Some(file) = current { + let current_version = FileVersion { + id: file.id, + file_id: file.id, + version: file.version, + name: file.name, + description: file.description, + summary: file.summary, + body: file.body, + source: "user".to_string(), // Current version source + change_description: None, + created_at: file.updated_at, + }; + versions.insert(0, current_version); + } + + Ok(versions) +} + +/// Get a specific version of a file. +pub async fn get_file_version( + pool: &PgPool, + file_id: Uuid, + version: i32, +) -> Result<Option<FileVersion>, sqlx::Error> { + // First check if this is the current version + if let Some(file) = get_file(pool, file_id).await? { + if file.version == version { + return Ok(Some(FileVersion { + id: file.id, + file_id: file.id, + version: file.version, + name: file.name, + description: file.description, + summary: file.summary, + body: file.body, + source: "user".to_string(), + change_description: None, + created_at: file.updated_at, + })); + } + } + + // Otherwise, look in the versions table + sqlx::query_as::<_, FileVersion>( + r#" + SELECT id, file_id, version, name, description, summary, body, source, change_description, created_at + FROM file_versions + WHERE file_id = $1 AND version = $2 + "#, + ) + .bind(file_id) + .bind(version) + .fetch_optional(pool) + .await +} + +/// Restore a file to a previous version. +/// This creates a new version with the content from the target version. +pub async fn restore_file_version( + pool: &PgPool, + file_id: Uuid, + target_version: i32, + current_version: i32, +) -> Result<Option<File>, RepositoryError> { + // Get the target version content + let target = get_file_version(pool, file_id, target_version).await?; + let Some(target) = target else { + return Ok(None); + }; + + // Set version source and description for the trigger + set_version_source(pool, "system").await?; + set_change_description(pool, &format!("Restored from version {}", target_version)).await?; + + // Update the file with the target version's content + // This will trigger the save_file_version trigger to save the current state first + let update_req = UpdateFileRequest { + name: Some(target.name), + description: target.description, + transcript: None, + summary: target.summary, + body: Some(target.body), + version: Some(current_version), + }; + + update_file(pool, file_id, update_req).await +} + +/// Count versions for a file. +pub async fn count_file_versions(pool: &PgPool, file_id: Uuid) -> Result<i64, sqlx::Error> { + let result: (i64,) = sqlx::query_as( + "SELECT COUNT(*) + 1 FROM file_versions WHERE file_id = $1", // +1 for current version + ) + .bind(file_id) + .fetch_one(pool) + .await?; + + Ok(result.0) +} |
