Return new & smar rename

This commit is contained in:
Andras Schmelczer 2026-04-27 22:46:02 +01:00
commit 5707add47c
5 changed files with 70 additions and 36 deletions

View file

@ -443,6 +443,7 @@ impl Database {
r#" r#"
select select
vault_update_id, vault_update_id,
creation_vault_update_id,
document_id as "document_id: Hyphenated", document_id as "document_id: Hyphenated",
relative_path, relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>", updated_date as "updated_date: chrono::DateTime<Utc>",
@ -474,6 +475,7 @@ impl Database {
user_id: row.user_id, user_id: row.user_id,
device_id: row.device_id, device_id: row.device_id,
content_size: row.content_size.unwrap_or(0), content_size: row.content_size.unwrap_or(0),
is_new_file: row.creation_vault_update_id == row.vault_update_id,
}) })
.collect() .collect()
}) })
@ -491,6 +493,7 @@ impl Database {
r#" r#"
select select
vault_update_id, vault_update_id,
creation_vault_update_id,
document_id as "document_id: Hyphenated", document_id as "document_id: Hyphenated",
relative_path, relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>", updated_date as "updated_date: chrono::DateTime<Utc>",
@ -526,6 +529,7 @@ impl Database {
user_id: row.user_id, user_id: row.user_id,
device_id: row.device_id, device_id: row.device_id,
content_size: row.content_size.unwrap_or(0), content_size: row.content_size.unwrap_or(0),
is_new_file: row.creation_vault_update_id == row.vault_update_id,
}) })
.collect() .collect()
}) })
@ -750,6 +754,7 @@ impl Database {
r#" r#"
select select
vault_update_id, vault_update_id,
creation_vault_update_id,
document_id as "document_id: Hyphenated", document_id as "document_id: Hyphenated",
relative_path, relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>", updated_date as "updated_date: chrono::DateTime<Utc>",
@ -783,6 +788,7 @@ impl Database {
user_id: row.user_id, user_id: row.user_id,
device_id: row.device_id, device_id: row.device_id,
content_size: row.content_size.unwrap_or(0), content_size: row.content_size.unwrap_or(0),
is_new_file: row.creation_vault_update_id == row.vault_update_id,
}) })
.collect() .collect()
}) })
@ -805,6 +811,7 @@ impl Database {
user_id: row.user_id, user_id: row.user_id,
device_id: row.device_id, device_id: row.device_id,
content_size: row.content_size.unwrap_or(0), content_size: row.content_size.unwrap_or(0),
is_new_file: row.creation_vault_update_id == row.vault_update_id,
}; };
if let Some(before) = before_update_id { if let Some(before) = before_update_id {
@ -813,6 +820,7 @@ impl Database {
r#" r#"
select select
vault_update_id, vault_update_id,
creation_vault_update_id,
document_id as "document_id: Hyphenated", document_id as "document_id: Hyphenated",
relative_path, relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>", updated_date as "updated_date: chrono::DateTime<Utc>",
@ -845,6 +853,7 @@ impl Database {
r#" r#"
select select
vault_update_id, vault_update_id,
creation_vault_update_id,
document_id as "document_id: Hyphenated", document_id as "document_id: Hyphenated",
relative_path, relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>", updated_date as "updated_date: chrono::DateTime<Utc>",

View file

@ -46,10 +46,14 @@ pub struct DocumentVersionWithoutContent {
#[ts(type = "number")] #[ts(type = "number")]
pub content_size: u64, pub content_size: u64,
/// True iff this is the first version of the document
pub is_new_file: bool,
} }
impl From<StoredDocumentVersion> for DocumentVersionWithoutContent { impl From<StoredDocumentVersion> for DocumentVersionWithoutContent {
fn from(value: StoredDocumentVersion) -> Self { fn from(value: StoredDocumentVersion) -> Self {
let is_new_file = value.creation_vault_update_id == value.vault_update_id;
Self { Self {
vault_update_id: value.vault_update_id, vault_update_id: value.vault_update_id,
document_id: value.document_id, document_id: value.document_id,
@ -59,6 +63,7 @@ impl From<StoredDocumentVersion> for DocumentVersionWithoutContent {
user_id: value.user_id, user_id: value.user_id,
device_id: value.device_id, device_id: value.device_id,
content_size: value.content.len() as u64, content_size: value.content.len() as u64,
is_new_file,
} }
} }
} }
@ -82,6 +87,7 @@ pub struct DocumentVersion {
#[derive(Debug)] #[derive(Debug)]
pub struct VaultHistoryRow { pub struct VaultHistoryRow {
pub vault_update_id: VaultUpdateId, pub vault_update_id: VaultUpdateId,
pub creation_vault_update_id: VaultUpdateId,
pub document_id: DocumentId, pub document_id: DocumentId,
pub relative_path: String, pub relative_path: String,
pub updated_date: DateTime<Utc>, pub updated_date: DateTime<Utc>,

View file

@ -89,7 +89,7 @@ pub async fn create_document(
Vec::new(), Vec::new(),
vault_id, vault_id,
latest_version.document_id, latest_version.document_id,
&request.relative_path, Some(&request.relative_path),
new_content, new_content,
user, user,
device_id, device_id,

View file

@ -22,7 +22,9 @@ pub struct CreateDocumentVersion {
#[derive(Debug, TryFromMultipart)] #[derive(Debug, TryFromMultipart)]
pub struct UpdateBinaryDocumentVersion { pub struct UpdateBinaryDocumentVersion {
pub parent_version_id: VaultUpdateId, pub parent_version_id: VaultUpdateId,
pub relative_path: String, // None on a content-only edit; Some on a user rename. When None,
// the server keeps the document at its current path.
pub relative_path: Option<String>,
#[form_data(limit = "unlimited")] #[form_data(limit = "unlimited")]
pub content: FieldData<Bytes>, pub content: FieldData<Bytes>,
@ -35,7 +37,9 @@ pub struct UpdateTextDocumentVersion {
#[ts(type = "number")] #[ts(type = "number")]
pub parent_version_id: VaultUpdateId, pub parent_version_id: VaultUpdateId,
pub relative_path: String, // None on a content-only edit; Some on a user rename. When None,
// the server keeps the document at its current path.
pub relative_path: Option<String>,
#[ts(type = "Array<number | string>")] #[ts(type = "Array<number | string>")]
pub content: Vec<NumberOrText>, pub content: Vec<NumberOrText>,

View file

@ -66,7 +66,7 @@ pub async fn update_binary(
parent_document.content, parent_document.content,
vault_id, vault_id,
document_id, document_id,
&request.relative_path, request.relative_path.as_deref(),
content, content,
user, user,
device_id, device_id,
@ -112,7 +112,7 @@ pub async fn update_text(
parent_document.content, parent_document.content,
vault_id, vault_id,
document_id, document_id,
&request.relative_path, request.relative_path.as_deref(),
content, content,
user, user,
device_id, device_id,
@ -157,7 +157,7 @@ pub async fn update_document(
parent_content: Vec<u8>, parent_content: Vec<u8>,
vault_id: VaultId, vault_id: VaultId,
document_id: DocumentId, document_id: DocumentId,
relative_path: &str, relative_path: Option<&str>,
content: Vec<u8>, content: Vec<u8>,
user: User, user: User,
device_id: DeviceIdHeader, device_id: DeviceIdHeader,
@ -166,7 +166,10 @@ pub async fn update_document(
) -> Result<Json<DocumentUpdateResponse>, SyncServerError> { ) -> Result<Json<DocumentUpdateResponse>, SyncServerError> {
debug!("Updating document `{document_id}` in vault `{vault_id}`"); debug!("Updating document `{document_id}` in vault `{vault_id}`");
let sanitized_relative_path = sanitize_path(relative_path).map_err(client_error)?; let sanitized_relative_path = relative_path
.map(sanitize_path)
.transpose()
.map_err(client_error)?;
let last_update_id = state let last_update_id = state
.database .database
@ -202,9 +205,12 @@ pub async fn update_document(
} }
// Return the latest version if the content and path are the same as the latest // Return the latest version if the content and path are the same as the latest
// version // version. A missing relative_path means "keep current path", so the path
if content == latest_version.content && sanitized_relative_path == latest_version.relative_path // is implicitly unchanged.
{ let path_unchanged = sanitized_relative_path
.as_deref()
.is_none_or(|p| p == latest_version.relative_path);
if content == latest_version.content && path_unchanged {
info!( info!(
"Document content is the same as the latest version for `{document_id}`, skipping update" "Document content is the same as the latest version for `{document_id}`, skipping update"
); );
@ -219,8 +225,14 @@ pub async fn update_document(
))); )));
} }
// For mergability, use whichever path the new version will live at — the
// requested rename target if the client sent one, otherwise the existing
// server-side path.
let mergable_check_path = sanitized_relative_path
.as_deref()
.unwrap_or(&latest_version.relative_path);
let are_all_participants_mergable = is_file_type_mergable( let are_all_participants_mergable = is_file_type_mergable(
&sanitized_relative_path, mergable_check_path,
&state.config.server.mergeable_file_extensions, &state.config.server.mergeable_file_extensions,
) && !is_binary(&parent_content) ) && !is_binary(&parent_content)
&& !is_binary(&latest_version.content) && !is_binary(&latest_version.content)
@ -263,33 +275,36 @@ pub async fn update_document(
(content, false) // false means that the client doesn't need to refetch the file as we can ensure the remote and local versions are the same as LWW is the merging method for binary files (content, false) // false means that the client doesn't need to refetch the file as we can ensure the remote and local versions are the same as LWW is the merging method for binary files
}; };
// Rename resolution: only apply the client's rename if the document's path // Rename resolution: only apply the client's rename if (a) the client
// hasn't changed since this client's parent version. Check the parent // requested one (`sanitized_relative_path` is `Some`) and (b) the
// version's path against the latest version's path. If they differ, another // document's path hasn't changed since this client's parent version.
// client already renamed the document — keep the latest path (first rename // If the parent and latest paths differ, another client already renamed
// wins). Content changes from both clients are still merged correctly via // the document — keep the latest path (first rename wins). Content
// the 3-way reconcile above, independent of which rename wins. // changes from both clients are still merged correctly via the 3-way
let new_relative_path = if parent_relative_path == latest_version.relative_path // reconcile above, independent of which rename wins. A missing
&& sanitized_relative_path != latest_version.relative_path // relative_path means "keep current path" (content-only edit).
let new_relative_path = match sanitized_relative_path.as_deref() {
Some(requested) if parent_relative_path == latest_version.relative_path
&& requested != latest_version.relative_path =>
{ {
let new_path = find_first_available_path( let new_path = find_first_available_path(
&vault_id, &vault_id,
&sanitized_relative_path, requested,
&state.database, &state.database,
&mut transaction, &mut transaction,
) )
.await .await
.map_err(server_error)?; .map_err(server_error)?;
if new_path != sanitized_relative_path { if new_path != requested {
info!( info!(
"Document already exists at new location: `{sanitized_relative_path}` when trying to update it in vault `{vault_id}`, deconflicting by creating at `{new_path}`" "Document already exists at new location: `{requested}` when trying to update it in vault `{vault_id}`, deconflicting by creating at `{new_path}`"
); );
} }
new_path new_path
} else { }
latest_version.relative_path.clone() _ => latest_version.relative_path.clone(),
}; };
let new_version = StoredDocumentVersion { let new_version = StoredDocumentVersion {