summaryrefslogtreecommitdiff
path: root/makima/src/db
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/db')
-rw-r--r--makima/src/db/models.rs96
-rw-r--r--makima/src/db/repository.rs144
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)
+}