diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1ae16ae..3aed323 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1560,7 +1560,6 @@ dependencies = [ "schemars_derive", "serde", "serde_json", - "uuid", ] [[package]] @@ -1848,7 +1847,6 @@ dependencies = [ "tokio-stream", "tracing", "url", - "uuid", ] [[package]] @@ -1930,7 +1928,6 @@ dependencies = [ "stringprep", "thiserror", "tracing", - "uuid", "whoami", ] @@ -1970,7 +1967,6 @@ dependencies = [ "stringprep", "thiserror", "tracing", - "uuid", "whoami", ] @@ -1996,7 +1992,6 @@ dependencies = [ "sqlx-core", "tracing", "url", - "uuid", ] [[package]] @@ -2067,7 +2062,6 @@ dependencies = [ "tokio", "tower-http", "tracing-subscriber", - "uuid", ] [[package]] @@ -2438,16 +2432,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "uuid" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" -dependencies = [ - "getrandom", - "serde", -] - [[package]] name = "valuable" version = "0.1.0" diff --git a/backend/sync_server/Cargo.toml b/backend/sync_server/Cargo.toml index 50fab5c..5b5fdcb 100644 --- a/backend/sync_server/Cargo.toml +++ b/backend/sync_server/Cargo.toml @@ -11,16 +11,15 @@ serde = {workspace = true} thiserror = {workspace = true} anyhow = {workspace = true} log = {workspace = true} -uuid = {workspace = true} axum = { version = "0.7.9", features = ["ws", "macros"]} tokio = { version = "1.42.0", features = ["full"]} tracing-subscriber = "0.3.19" serde_yaml = "0.9.34" -sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] } +sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio", "chrono"] } chrono = { version = "0.4.38", features = ["serde"] } aide = { version = "0.13.4", features = ["axum", "axum-ws", "scalar", "axum-headers"] } -schemars = { version = "0.8.21", features = ["chrono", "uuid1"] } +schemars = { version = "0.8.21", features = ["chrono"] } rand = "0.8.5" axum-extra = { version = "0.9.6", features = ["typed-header"] } tower-http = { version = "0.6.1", features = ["cors"] } diff --git a/backend/sync_server/src/database.rs b/backend/sync_server/src/database.rs index a5ae9e9..3018dfb 100644 --- a/backend/sync_server/src/database.rs +++ b/backend/sync_server/src/database.rs @@ -1,9 +1,7 @@ use std::str::FromStr; use anyhow::{Context, Result}; -use models::{ - DocumentId, DocumentVersionId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, -}; +use models::{DocumentVersionId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}; use sqlx::{sqlite::SqliteConnectOptions, types::chrono::Utc}; pub mod models; use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite}; @@ -19,8 +17,9 @@ pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>; impl Database { pub async fn try_new(config: &DatabaseConfig) -> Result { - let connection_options = - SqliteConnectOptions::from_str(&config.sqlite_url)?.create_if_missing(true); + let connection_options = SqliteConnectOptions::from_str(&config.sqlite_url)? + .create_if_missing(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); let pool = SqlitePoolOptions::new() .max_connections(config.max_connections) @@ -65,13 +64,12 @@ impl Database { r#" select vault_id, - document_id as "document_id: uuid::Uuid", + relative_path, version_id, created_date as "created_date: chrono::DateTime", updated_date as "updated_date: chrono::DateTime", - relative_path, is_deleted - from latest_documents + from latest_document_versions where is_deleted = false and vault_id = ? "#, vault, @@ -85,40 +83,7 @@ impl Database { .context("Cannot fetch latest documents") } - pub async fn get_latest_document_version( - &self, - vault: &VaultId, - document: &DocumentId, - transaction: Option<&mut Transaction<'_>>, - ) -> Result> { - let query = sqlx::query_as!( - StoredDocumentVersion, - r#" - select - vault_id, - document_id as "document_id: uuid::Uuid", - version_id, - created_date as "created_date: chrono::DateTime", - updated_date as "updated_date: chrono::DateTime", - relative_path, - content, - is_deleted - from latest_documents - where vault_id = ? and document_id = ? - "#, - vault, - document - ); - - if let Some(transaction) = transaction { - query.fetch_optional(&mut **transaction).await - } else { - query.fetch_optional(&self.connection_pool).await - } - .context("Cannot fetch latest document version") - } - - pub async fn get_latest_document_version_by_path( + pub async fn get_latest_document( &self, vault: &VaultId, relative_path: &str, @@ -129,15 +94,14 @@ impl Database { r#" select vault_id, - document_id as "document_id: uuid::Uuid", + relative_path, version_id, created_date as "created_date: chrono::DateTime", updated_date as "updated_date: chrono::DateTime", - relative_path, content, is_deleted - from latest_documents - where vault_id = ? and relative_path = ? and is_deleted = false + from latest_document_versions + where vault_id = ? and relative_path = ? "#, vault, relative_path @@ -148,13 +112,13 @@ impl Database { } else { query.fetch_optional(&self.connection_pool).await } - .context("Cannot fetch latest document version by path") + .context("Cannot fetch latest document version") } pub async fn get_document_version( &self, vault: &VaultId, - document: &DocumentId, + relative_path: &str, version: &DocumentVersionId, transaction: Option<&mut Transaction<'_>>, ) -> Result> { @@ -163,17 +127,16 @@ impl Database { r#" select vault_id, - document_id as "document_id: uuid::Uuid", + relative_path, version_id, created_date as "created_date: chrono::DateTime", updated_date as "updated_date: chrono::DateTime", - relative_path, content, is_deleted from documents - where vault_id = ? and document_id = ? and version_id = ?"#, + where vault_id = ? and relative_path = ? and version_id = ?"#, vault, - document, + relative_path, version ); @@ -193,23 +156,21 @@ impl Database { let query = sqlx::query!( r#" insert into documents ( - vault_id, - document_id, + vault_id, + relative_path, version_id, created_date, updated_date, - relative_path, content, is_deleted ) - values (?, ?, ?, ?, ?, ?, ?, ?) + values (?, ?, ?, ?, ?, ?, ?) "#, version.vault_id, - version.document_id, + version.relative_path, version.version_id, version.created_date, version.updated_date, - version.relative_path, version.content, version.is_deleted ); diff --git a/backend/sync_server/src/database/migrations/20241207143519_bootstrap.sql b/backend/sync_server/src/database/migrations/20241207143519_bootstrap.sql index 91e4b78..dbfb950 100644 --- a/backend/sync_server/src/database/migrations/20241207143519_bootstrap.sql +++ b/backend/sync_server/src/database/migrations/20241207143519_bootstrap.sql @@ -1,27 +1,25 @@ CREATE TABLE IF NOT EXISTS documents ( vault_id TEXT NOT NULL, - document_id TEXT NOT NULL, + relative_path TEXT NOT NULL, version_id INTEGER NOT NULL, created_date TIMESTAMP NOT NULL, updated_date TIMESTAMP NOT NULL, - relative_path TEXT NOT NULL, content BLOB NOT NULL, - is_binary BOOLEAN NOT NULL, is_deleted BOOLEAN NOT NULL, - PRIMARY KEY (vault_id, document_id, version_id) + PRIMARY KEY (vault_id, relative_path, version_id) ); -CREATE VIEW IF NOT EXISTS latest_documents AS +CREATE VIEW IF NOT EXISTS latest_document_versions AS SELECT d.* FROM documents d INNER JOIN ( - SELECT vault_id, document_id, MAX(version_id) AS max_version_id + SELECT vault_id, relative_path, MAX(version_id) AS max_version_id FROM documents - GROUP BY vault_id, document_id + GROUP BY vault_id, relative_path ) max_versions ON d.vault_id = max_versions.vault_id -AND d.document_id = max_versions.document_id +AND d.relative_path = max_versions.relative_path AND d.version_id = max_versions.max_version_id; CREATE INDEX IF NOT EXISTS idx_documents_vault_doc -ON documents (vault_id, document_id); +ON documents (vault_id, relative_path); diff --git a/backend/sync_server/src/database/models.rs b/backend/sync_server/src/database/models.rs index 32dc169..9648ef0 100644 --- a/backend/sync_server/src/database/models.rs +++ b/backend/sync_server/src/database/models.rs @@ -4,17 +4,15 @@ use serde::Serialize; use sync_lib::bytes_to_base64; pub type VaultId = String; -pub type DocumentId = uuid::Uuid; pub type DocumentVersionId = i64; #[derive(Debug, Clone)] pub struct StoredDocumentVersion { pub vault_id: VaultId, - pub document_id: DocumentId, + pub relative_path: String, pub version_id: DocumentVersionId, pub created_date: DateTime, pub updated_date: DateTime, - pub relative_path: String, pub content: Vec, pub is_deleted: bool, } @@ -23,11 +21,10 @@ pub struct StoredDocumentVersion { #[serde(rename_all = "camelCase")] pub struct DocumentVersionWithoutContent { pub vault_id: VaultId, - pub document_id: DocumentId, + pub relative_path: String, pub version_id: DocumentVersionId, pub created_date: DateTime, pub updated_date: DateTime, - pub relative_path: String, pub is_deleted: bool, } @@ -35,11 +32,10 @@ impl From for DocumentVersionWithoutContent { fn from(value: StoredDocumentVersion) -> Self { Self { vault_id: value.vault_id, - document_id: value.document_id, + relative_path: value.relative_path, version_id: value.version_id, created_date: value.created_date, updated_date: value.updated_date, - relative_path: value.relative_path, is_deleted: value.is_deleted, } } @@ -56,11 +52,10 @@ pub struct PingResponse { #[serde(rename_all = "camelCase")] pub struct DocumentVersion { pub vault_id: VaultId, - pub document_id: DocumentId, + pub relative_path: String, pub version_id: DocumentVersionId, pub created_date: DateTime, pub updated_date: DateTime, - pub relative_path: String, pub content_base64: String, pub is_deleted: bool, } @@ -69,11 +64,10 @@ impl From for DocumentVersion { fn from(value: StoredDocumentVersion) -> Self { Self { vault_id: value.vault_id, - document_id: value.document_id, + relative_path: value.relative_path, version_id: value.version_id, created_date: value.created_date, updated_date: value.updated_date, - relative_path: value.relative_path, content_base64: bytes_to_base64(&value.content), is_deleted: value.is_deleted, } diff --git a/backend/sync_server/src/server.rs b/backend/sync_server/src/server.rs index 94b18de..b7d8bbf 100644 --- a/backend/sync_server/src/server.rs +++ b/backend/sync_server/src/server.rs @@ -1,6 +1,6 @@ use aide::{ axum::{ - routing::{delete, get, post, put}, + routing::{delete, get, put}, ApiRouter, }, openapi::{Info, OpenApi}, @@ -18,7 +18,6 @@ use tower_http::cors::CorsLayer; use crate::app_state::AppState; mod auth; -mod create_document; mod delete_document; mod fetch_latest_document_version; mod fetch_latest_documents; @@ -47,19 +46,15 @@ pub async fn create_server(app_state: AppState) -> Result<()> { get(fetch_latest_documents::fetch_latest_documents), ) .api_route( - "/vaults/:vault_id/documents", - post(create_document::create_document), - ) - .api_route( - "/vaults/:vault_id/documents/:document_id", + "/vaults/:vault_id/documents/:relative_path", get(fetch_latest_document_version::fetch_latest_document_version), ) .api_route( - "/vaults/:vault_id/documents/:document_id", + "/vaults/:vault_id/documents/:relative_path", put(update_document::update_document), ) .api_route( - "/vaults/:vault_id/documents/:document_id", + "/vaults/:vault_id/documents/:relative_path", delete(delete_document::delete_document), ) .api_route("/ws", get(handler)) diff --git a/backend/sync_server/src/server/create_document.rs b/backend/sync_server/src/server/create_document.rs deleted file mode 100644 index 4268adb..0000000 --- a/backend/sync_server/src/server/create_document.rs +++ /dev/null @@ -1,106 +0,0 @@ -use anyhow::Context; -use axum::{ - extract::{Path, State}, - Json, -}; -use axum_extra::{ - headers::{authorization::Bearer, Authorization}, - TypedHeader, -}; -use schemars::JsonSchema; -use serde::Deserialize; -use sync_lib::{base64_to_bytes, merge}; - -use super::{auth::auth, requests::CreateDocumentVersion}; -use crate::{ - app_state::AppState, - database::models::{DocumentVersion, StoredDocumentVersion, VaultId}, - errors::{client_error, server_error, SyncServerError}, -}; - -// This is required for aide to infer the path parameter types and names -#[derive(Deserialize, JsonSchema)] -pub struct PathParams { - vault_id: VaultId, -} - -/// Create a new document in case a document with the same doesn't exist -/// already. If a document with the same path exists, a new version is created -/// with their content merged. -#[axum::debug_handler] -pub async fn create_document( - TypedHeader(auth_header): TypedHeader>, - Path(PathParams { vault_id }): Path, - State(state): State, - Json(request): Json, -) -> Result, SyncServerError> { - auth(&state, auth_header.token())?; - - let mut transaction = state - .database - .create_transaction() - .await - .map_err(server_error)?; - - let maybe_existing_version = state - .database - .get_latest_document_version_by_path( - &vault_id, - &request.relative_path, - Some(&mut transaction), - ) - .await - .map_err(server_error)?; - - let new_version = if let Some(existing_version) = maybe_existing_version { - let content_bytes = base64_to_bytes(&request.content_base64) - .context("Failed to decode base64 content in request") - .map_err(client_error)?; - - let merged_content = merge( - &[], // the empty string is the first common parent of the two documents, - &existing_version.content, - &content_bytes, - ) - .context("Failed to decode bytes as UTF-8") - .map_err(client_error)?; - - StoredDocumentVersion { - vault_id, - document_id: existing_version.document_id, - version_id: existing_version.version_id + 1, - content: merged_content, - created_date: request.created_date, - relative_path: request.relative_path, - updated_date: chrono::Utc::now(), - is_deleted: false, - } - } else { - StoredDocumentVersion { - vault_id, - document_id: uuid::Uuid::new_v4(), - version_id: 0, - content: base64_to_bytes(&request.content_base64) - .context("Cannot convert base64 encoded content to bytes") - .map_err(client_error)?, - created_date: request.created_date, - relative_path: request.relative_path, - updated_date: chrono::Utc::now(), - is_deleted: false, - } - }; - - state - .database - .insert_document_version(&new_version, Some(&mut transaction)) - .await - .map_err(server_error)?; - - transaction - .commit() - .await - .context("Failed to commit successful transaction") - .map_err(server_error)?; - - Ok(Json(new_version.into())) -} diff --git a/backend/sync_server/src/server/delete_document.rs b/backend/sync_server/src/server/delete_document.rs index a1fafa1..8c62674 100644 --- a/backend/sync_server/src/server/delete_document.rs +++ b/backend/sync_server/src/server/delete_document.rs @@ -13,7 +13,7 @@ use serde::Deserialize; use super::{auth::auth, requests::DeleteDocumentVersion}; use crate::{ app_state::AppState, - database::models::{DocumentId, StoredDocumentVersion, VaultId}, + database::models::{StoredDocumentVersion, VaultId}, errors::{not_found_error, server_error, SyncServerError}, }; @@ -21,7 +21,7 @@ use crate::{ #[derive(Deserialize, JsonSchema)] pub struct PathParams { vault_id: VaultId, - document_id: DocumentId, + relative_path: String, } #[axum::debug_handler] @@ -29,7 +29,7 @@ pub async fn delete_document( TypedHeader(auth_header): TypedHeader>, Path(PathParams { vault_id, - document_id, + relative_path, }): Path, State(state): State, Json(request): Json, @@ -44,25 +44,24 @@ pub async fn delete_document( let latest_version = state .database - .get_latest_document_version(&vault_id, &document_id, Some(&mut transaction)) + .get_latest_document(&vault_id, &relative_path, Some(&mut transaction)) .await .map_err(server_error)? .map(Ok) .unwrap_or_else(|| { Err(not_found_error(anyhow!( "Latest document version of document `{}` not found", - document_id + relative_path ))) })?; let new_version = StoredDocumentVersion { vault_id, - document_id, + relative_path, version_id: latest_version.version_id + 1, content: vec![], created_date: request.created_date, updated_date: chrono::Utc::now(), - relative_path: latest_version.relative_path, is_deleted: true, }; diff --git a/backend/sync_server/src/server/fetch_latest_document_version.rs b/backend/sync_server/src/server/fetch_latest_document_version.rs index 3336830..0e84ff6 100644 --- a/backend/sync_server/src/server/fetch_latest_document_version.rs +++ b/backend/sync_server/src/server/fetch_latest_document_version.rs @@ -13,7 +13,7 @@ use serde::Deserialize; use super::auth::auth; use crate::{ app_state::AppState, - database::models::{DocumentId, DocumentVersion, VaultId}, + database::models::{DocumentVersion, VaultId}, errors::{not_found_error, server_error, SyncServerError}, }; @@ -21,7 +21,7 @@ use crate::{ #[derive(Deserialize, JsonSchema)] pub struct PathParams { vault_id: VaultId, - document_id: DocumentId, + relative_path: String, } #[axum::debug_handler] @@ -29,7 +29,7 @@ pub async fn fetch_latest_document_version( TypedHeader(auth_header): TypedHeader>, Path(PathParams { vault_id, - document_id, + relative_path, }): Path, State(state): State, ) -> Result, SyncServerError> { @@ -37,14 +37,14 @@ pub async fn fetch_latest_document_version( let latest_version = state .database - .get_latest_document_version(&vault_id, &document_id, None) + .get_latest_document(&vault_id, &relative_path, None) .await .map_err(server_error)? .map(Ok) .unwrap_or_else(|| { Err(not_found_error(anyhow!( "Latest document version of document `{}` not found", - document_id + relative_path ))) })?; diff --git a/backend/sync_server/src/server/requests.rs b/backend/sync_server/src/server/requests.rs index 8f04910..4c3a761 100644 --- a/backend/sync_server/src/server/requests.rs +++ b/backend/sync_server/src/server/requests.rs @@ -4,20 +4,11 @@ use serde::{self, Deserialize}; use crate::database::models::DocumentVersionId; -#[derive(Debug, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateDocumentVersion { - pub created_date: DateTime, - pub relative_path: String, - pub content_base64: String, -} - #[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateDocumentVersion { - pub parent_version_id: DocumentVersionId, + pub parent_version_id: Option, pub created_date: DateTime, - pub relative_path: String, pub content_base64: String, } diff --git a/backend/sync_server/src/server/update_document.rs b/backend/sync_server/src/server/update_document.rs index 0627a45..b40774c 100644 --- a/backend/sync_server/src/server/update_document.rs +++ b/backend/sync_server/src/server/update_document.rs @@ -14,7 +14,7 @@ use sync_lib::{base64_to_bytes, merge}; use super::{auth::auth, requests::UpdateDocumentVersion}; use crate::{ app_state::AppState, - database::models::{DocumentId, DocumentVersion, StoredDocumentVersion, VaultId}, + database::models::{DocumentVersion, StoredDocumentVersion, VaultId}, errors::{client_error, not_found_error, server_error, SyncServerError}, }; @@ -22,7 +22,7 @@ use crate::{ #[derive(Deserialize, JsonSchema)] pub struct PathParams { vault_id: VaultId, - document_id: DocumentId, + relative_path: String, } #[axum::debug_handler] @@ -30,25 +30,30 @@ pub async fn update_document( TypedHeader(auth_header): TypedHeader>, Path(PathParams { vault_id, - document_id, + relative_path, }): Path, State(state): State, Json(request): Json, ) -> Result, SyncServerError> { auth(&state, auth_header.token())?; - - let parent = state - .database - .get_document_version(&vault_id, &document_id, &request.parent_version_id, None) - .await - .map_err(server_error)? - .map(Ok) - .unwrap_or_else(|| { - Err(not_found_error(anyhow!( - "Parent version with id `{}` not found", - &request.parent_version_id - ))) - })?; + let parent_content = if let Some(parent_version_id) = request.parent_version_id { + state + .database + .get_document_version(&vault_id, &relative_path, &parent_version_id, None) + .await + .map_err(server_error)? + .map(Ok) + .unwrap_or_else(|| { + Err(not_found_error(anyhow!( + "Parent version with id `{}` not found", + parent_version_id + ))) + }) + .map(|version| version.content) + } else { + // the empty string is the first common parent of the two documents + Ok(Vec::default()) + }?; let mut transaction = state .database @@ -58,39 +63,32 @@ pub async fn update_document( let latest_version = state .database - .get_latest_document_version(&vault_id, &document_id, Some(&mut transaction)) + .get_latest_document(&vault_id, &relative_path, Some(&mut transaction)) .await - .map_err(server_error)? - .map(Ok) - .unwrap_or_else(|| { - Err(not_found_error(anyhow!( - "Latest document version of document `{}` not found", - document_id - ))) - })?; - - if latest_version.is_deleted { - return Err(client_error(anyhow!( - "Document `{}` is deleted", - document_id - ))); - } + .map_err(server_error)?; let content_bytes = base64_to_bytes(&request.content_base64) .context("Failed to decode base64 content in request") .map_err(client_error)?; - let merged_content = merge(&parent.content, &latest_version.content, &content_bytes) + let next_version = latest_version + .as_ref() + .map(|v| v.version_id + 1) + .unwrap_or(0); + let latest_version_content = latest_version + .map(|v| v.content) + .unwrap_or_else(Vec::default); + + let merged_content = merge(&parent_content, &latest_version_content, &content_bytes) .context("Failed to decode bytes as UTF-8") .map_err(client_error)?; let new_version = StoredDocumentVersion { vault_id, - document_id, - version_id: latest_version.version_id + 1, + relative_path, + version_id: next_version, content: merged_content, created_date: request.created_date, - relative_path: request.relative_path, updated_date: chrono::Utc::now(), is_deleted: false, };