diff --git a/backend/reconcile/src/operation_transformation.rs b/backend/reconcile/src/operation_transformation.rs index 63b5c3c..ef9a5e8 100644 --- a/backend/reconcile/src/operation_transformation.rs +++ b/backend/reconcile/src/operation_transformation.rs @@ -9,6 +9,7 @@ use crate::tokenizer::Tokenizer; #[must_use] pub fn reconcile(original: &str, left: &str, right: &str) -> String { + // Common trivial cases if left == right { return left.to_owned(); } @@ -21,6 +22,7 @@ pub fn reconcile(original: &str, left: &str, right: &str) -> String { return left.to_owned(); } + // 3-way merge let left_operations = EditedText::from_strings(original, left); let right_operations = EditedText::from_strings(original, right); diff --git a/backend/sync_server/src/server/create_document.rs b/backend/sync_server/src/server/create_document.rs index c99af9c..900095c 100644 --- a/backend/sync_server/src/server/create_document.rs +++ b/backend/sync_server/src/server/create_document.rs @@ -12,10 +12,10 @@ use schemars::JsonSchema; use serde::Deserialize; use sync_lib::{base64_to_bytes, merge}; -use super::{auth::auth, requests::CreateDocumentVersion}; +use super::{auth::auth, requests::CreateDocumentVersion, responses::DocumentUpdateResponse}; use crate::{ app_state::AppState, - database::models::{DocumentVersion, StoredDocumentVersion, VaultId}, + database::models::{StoredDocumentVersion, VaultId}, errors::{client_error, server_error, SyncServerError}, }; @@ -34,7 +34,7 @@ pub async fn create_document( Path(PathParams { vault_id }): Path, State(state): State, Json(request): Json, -) -> Result, SyncServerError> { +) -> Result, SyncServerError> { auth(&state, auth_header.token())?; let mut transaction = state @@ -56,22 +56,26 @@ pub async fn create_document( .map_err(server_error)? .and_then(|doc| if doc.is_deleted { None } else { Some(doc) }); - 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 content_bytes = base64_to_bytes(&request.content_base64) + .context("Failed to decode base64 content in request") + .map_err(client_error)?; + let response = if let Some(existing_version) = maybe_existing_version { if content_bytes == existing_version.content { info!( "Content of the new version is the same as the existing version. Not creating a \ new version." ); + transaction .rollback() .await - .context("Failed to rollback unecceseary transaction") + .context("Failed to roll back unecceseary transaction") .map_err(server_error)?; - return Ok(Json(existing_version.into())); + + return Ok(Json(DocumentUpdateResponse::FastForwardUpdate( + existing_version.into(), + ))); } let merged_content = merge( @@ -80,7 +84,7 @@ pub async fn create_document( &content_bytes, ); - StoredDocumentVersion { + let new_version = StoredDocumentVersion { vault_id, vault_update_id: last_update_id + 1, relative_path: request.relative_path, @@ -89,27 +93,35 @@ pub async fn create_document( created_date: request.created_date, updated_date: chrono::Utc::now(), is_deleted: false, - } + }; + + state + .database + .insert_document_version(&new_version, Some(&mut transaction)) + .await + .map_err(server_error)?; + + DocumentUpdateResponse::MergingUpdate(new_version.into()) } else { - StoredDocumentVersion { + let new_version = StoredDocumentVersion { vault_id, vault_update_id: last_update_id + 1, document_id: uuid::Uuid::new_v4(), relative_path: request.relative_path, - content: base64_to_bytes(&request.content_base64) - .context("Cannot convert base64 encoded content to bytes") - .map_err(client_error)?, + content: content_bytes, created_date: request.created_date, updated_date: chrono::Utc::now(), is_deleted: false, - } - }; + }; - state - .database - .insert_document_version(&new_version, Some(&mut transaction)) - .await - .map_err(server_error)?; + state + .database + .insert_document_version(&new_version, Some(&mut transaction)) + .await + .map_err(server_error)?; + + DocumentUpdateResponse::FastForwardUpdate(new_version.into()) + }; transaction .commit() @@ -117,5 +129,5 @@ pub async fn create_document( .context("Failed to commit successful transaction") .map_err(server_error)?; - Ok(Json(new_version.into())) + Ok(Json(response)) } diff --git a/backend/sync_server/src/server/responses.rs b/backend/sync_server/src/server/responses.rs index a869b0e..5d76bb0 100644 --- a/backend/sync_server/src/server/responses.rs +++ b/backend/sync_server/src/server/responses.rs @@ -1,18 +1,40 @@ use schemars::JsonSchema; use serde::{self, Serialize}; -use crate::database::models::{DocumentVersionWithoutContent, VaultUpdateId}; +use crate::database::models::{DocumentVersion, DocumentVersionWithoutContent, VaultUpdateId}; +/// Response to a ping request. #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct PingResponse { + /// Semantic version of the server. pub server_version: String, + + /// Whether the client is authenticated based on the sent Authorization + /// header. pub is_authenticated: bool, } +/// Response to a fetch latest documents request. #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct FetchLatestDocumentsResponse { pub latest_documents: Vec, + + /// The update ID of the latest document in the response. pub last_update_id: VaultUpdateId, } + +/// Response to a create/update document request. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(tag = "type")] +pub enum DocumentUpdateResponse { + /// Returned when the created/updated document's content is the same as was + /// sent in the create/update request and thus the response doesn't contain + /// the content because the client must already have it. + FastForwardUpdate(DocumentVersionWithoutContent), + + /// Returned when the created/updated document's content is different from + /// what was sent in the create/update request. + MergingUpdate(DocumentVersion), +} diff --git a/backend/sync_server/src/server/update_document.rs b/backend/sync_server/src/server/update_document.rs index 9fc06a3..5028539 100644 --- a/backend/sync_server/src/server/update_document.rs +++ b/backend/sync_server/src/server/update_document.rs @@ -12,10 +12,10 @@ use schemars::JsonSchema; use serde::Deserialize; use sync_lib::{base64_to_bytes, merge}; -use super::{auth::auth, requests::UpdateDocumentVersion}; +use super::{auth::auth, requests::UpdateDocumentVersion, responses::DocumentUpdateResponse}; use crate::{ app_state::AppState, - database::models::{DocumentId, DocumentVersion, StoredDocumentVersion, VaultId}, + database::models::{DocumentId, StoredDocumentVersion, VaultId}, errors::{client_error, not_found_error, server_error, SyncServerError}, }; @@ -35,7 +35,7 @@ pub async fn update_document( }): Path, State(state): State, Json(request): Json, -) -> Result, SyncServerError> { +) -> Result, SyncServerError> { auth(&state, auth_header.token())?; // No need for a transaction as document versions are immutable @@ -96,7 +96,9 @@ pub async fn update_document( .context("Failed to roll back transaction") .map_err(server_error)?; - return Ok(Json(latest_version.into())); + return Ok(Json(DocumentUpdateResponse::FastForwardUpdate( + latest_version.into(), + ))); } let merged_content = merge( @@ -104,6 +106,7 @@ pub async fn update_document( &latest_version.content, &content_bytes, ); + let is_different_from_request_content = merged_content != content_bytes; // We can only update the relative path if we're the first one to do so let new_relative_path = if parent_document.relative_path == latest_version.relative_path { @@ -135,5 +138,9 @@ pub async fn update_document( .context("Failed to commit successful transaction") .map_err(server_error)?; - Ok(Json(new_version.into())) + Ok(Json(if is_different_from_request_content { + DocumentUpdateResponse::MergingUpdate(new_version.into()) + } else { + DocumentUpdateResponse::FastForwardUpdate(new_version.into()) + })) } diff --git a/plugin/src/services/types.ts b/plugin/src/services/types.ts index bd51c68..f8f748f 100644 --- a/plugin/src/services/types.ts +++ b/plugin/src/services/types.ts @@ -111,7 +111,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DocumentVersion"]; + "application/json": components["schemas"]["DocumentUpdateResponse"]; }; }; default: { @@ -192,7 +192,7 @@ export interface paths { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DocumentVersion"]; + "application/json": components["schemas"]["DocumentUpdateResponse"]; }; }; default: { @@ -261,6 +261,37 @@ export interface components { createdDate: string; relativePath: string; }; + /** @description Response to a create/update document request. */ + DocumentUpdateResponse: { + /** Format: date-time */ + createdDate: string; + /** Format: uuid */ + documentId: string; + isDeleted: boolean; + relativePath: string; + /** @enum {string} */ + type: "FastForwardUpdate"; + /** Format: date-time */ + updatedDate: string; + vaultId: string; + /** Format: int64 */ + vaultUpdateId: number; + } | { + contentBase64: string; + /** Format: date-time */ + createdDate: string; + /** Format: uuid */ + documentId: string; + isDeleted: boolean; + relativePath: string; + /** @enum {string} */ + type: "MergingUpdate"; + /** Format: date-time */ + updatedDate: string; + vaultId: string; + /** Format: int64 */ + vaultUpdateId: number; + }; DocumentVersion: { contentBase64: string; /** Format: date-time */ @@ -288,8 +319,12 @@ export interface components { /** Format: int64 */ vaultUpdateId: number; }; + /** @description Response to a fetch latest documents request. */ FetchLatestDocumentsResponse: { - /** Format: int64 */ + /** + * Format: int64 + * @description The update ID of the latest document in the response. + */ lastUpdateId: number; latestDocuments: components["schemas"]["DocumentVersionWithoutContent"][]; }; @@ -314,8 +349,11 @@ export interface components { document_id: string; vault_id: string; }; + /** @description Response to a ping request. */ PingResponse: { + /** @description Whether the client is authenticated based on the sent Authorization header. */ isAuthenticated: boolean; + /** @description Semantic version of the server. */ serverVersion: string; }; QueryParams: {