Remove no longer necessary functions
This commit is contained in:
parent
02f70d5f9f
commit
9f84dce6c3
13 changed files with 0 additions and 1063 deletions
|
|
@ -1,41 +0,0 @@
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
use sqlx::types::chrono::Utc;
|
|
||||||
use uuid::fmt::Hyphenated;
|
|
||||||
|
|
||||||
use super::models::{StoredDocumentVersion, VaultId, VaultUpdateId};
|
|
||||||
use super::{Database, Transaction};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
pub async fn get_document_version(
|
|
||||||
&self,
|
|
||||||
vault: &VaultId,
|
|
||||||
vault_update_id: VaultUpdateId,
|
|
||||||
transaction: Option<&mut Transaction<'_>>,
|
|
||||||
) -> Result<Option<StoredDocumentVersion>> {
|
|
||||||
let query = sqlx::query_as!(
|
|
||||||
StoredDocumentVersion,
|
|
||||||
r#"
|
|
||||||
select
|
|
||||||
vault_update_id,
|
|
||||||
document_id as "document_id: Hyphenated",
|
|
||||||
relative_path,
|
|
||||||
updated_date as "updated_date: chrono::DateTime<Utc>",
|
|
||||||
content,
|
|
||||||
is_deleted,
|
|
||||||
user_id,
|
|
||||||
device_id
|
|
||||||
from documents
|
|
||||||
where vault_update_id = ?"#,
|
|
||||||
vault_update_id
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(transaction) = transaction {
|
|
||||||
query.fetch_optional(&mut **transaction).await
|
|
||||||
} else {
|
|
||||||
query
|
|
||||||
.fetch_optional(&self.get_connection_pool(vault).await?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.context("Cannot fetch document version")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
use sqlx::types::chrono::Utc;
|
|
||||||
use uuid::fmt::Hyphenated;
|
|
||||||
|
|
||||||
use super::models::{DocumentId, StoredDocumentVersion, VaultId};
|
|
||||||
use super::{Database, Transaction};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
pub async fn get_latest_document(
|
|
||||||
&self,
|
|
||||||
vault: &VaultId,
|
|
||||||
document_id: &DocumentId,
|
|
||||||
transaction: Option<&mut Transaction<'_>>,
|
|
||||||
) -> Result<Option<StoredDocumentVersion>> {
|
|
||||||
let document_id = document_id.as_hyphenated();
|
|
||||||
let query = sqlx::query_as!(
|
|
||||||
StoredDocumentVersion,
|
|
||||||
r#"
|
|
||||||
select
|
|
||||||
vault_update_id,
|
|
||||||
document_id as "document_id: Hyphenated",
|
|
||||||
relative_path,
|
|
||||||
updated_date as "updated_date: chrono::DateTime<Utc>",
|
|
||||||
content,
|
|
||||||
is_deleted,
|
|
||||||
user_id,
|
|
||||||
device_id
|
|
||||||
from latest_document_versions
|
|
||||||
where document_id = ?
|
|
||||||
"#,
|
|
||||||
document_id
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(transaction) = transaction {
|
|
||||||
query.fetch_optional(&mut **transaction).await
|
|
||||||
} else {
|
|
||||||
query
|
|
||||||
.fetch_optional(&self.get_connection_pool(vault).await?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.context("Cannot fetch latest document version")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
use sqlx::types::chrono::Utc;
|
|
||||||
use uuid::fmt::Hyphenated;
|
|
||||||
|
|
||||||
use super::models::{StoredDocumentVersion, VaultId};
|
|
||||||
use super::{Database, Transaction};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
pub async fn get_latest_document_by_path(
|
|
||||||
&self,
|
|
||||||
vault: &VaultId,
|
|
||||||
relative_path: &str,
|
|
||||||
transaction: Option<&mut Transaction<'_>>,
|
|
||||||
) -> Result<Option<StoredDocumentVersion>> {
|
|
||||||
let query = sqlx::query_as!(
|
|
||||||
StoredDocumentVersion,
|
|
||||||
r#"
|
|
||||||
select
|
|
||||||
vault_update_id,
|
|
||||||
document_id as "document_id: Hyphenated",
|
|
||||||
relative_path,
|
|
||||||
updated_date as "updated_date: chrono::DateTime<Utc>",
|
|
||||||
content,
|
|
||||||
is_deleted,
|
|
||||||
user_id,
|
|
||||||
device_id
|
|
||||||
from latest_document_versions
|
|
||||||
where relative_path = ? and is_deleted = false
|
|
||||||
order by vault_update_id desc -- `latest_document_versions` only contains a single latest version of each document, however,
|
|
||||||
-- multiple documents can have the same `relative_path`, if they have been deleted. That's
|
|
||||||
-- why we only care about the latest version of the document with the given relative path.
|
|
||||||
limit 1
|
|
||||||
"#,
|
|
||||||
relative_path
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(transaction) = transaction {
|
|
||||||
query.fetch_optional(&mut **transaction).await
|
|
||||||
} else {
|
|
||||||
query
|
|
||||||
.fetch_optional(&self.get_connection_pool(vault).await?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.context("Cannot fetch latest document version")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
use sqlx::types::chrono::Utc;
|
|
||||||
use uuid::fmt::Hyphenated;
|
|
||||||
|
|
||||||
use super::models::{DocumentVersionWithoutContent, VaultId};
|
|
||||||
use super::{Database, Transaction};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// Return the latest state of all documents in the vault
|
|
||||||
pub async fn get_latest_documents(
|
|
||||||
&self,
|
|
||||||
vault: &VaultId,
|
|
||||||
transaction: Option<&mut Transaction<'_>>,
|
|
||||||
) -> Result<Vec<DocumentVersionWithoutContent>> {
|
|
||||||
let query = sqlx::query!(
|
|
||||||
r#"
|
|
||||||
select
|
|
||||||
vault_update_id,
|
|
||||||
document_id as "document_id: Hyphenated",
|
|
||||||
relative_path,
|
|
||||||
updated_date as "updated_date: chrono::DateTime<Utc>",
|
|
||||||
is_deleted,
|
|
||||||
user_id,
|
|
||||||
device_id,
|
|
||||||
length(content) as "content_size: u64"
|
|
||||||
from latest_document_versions
|
|
||||||
order by vault_update_id
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(transaction) = transaction {
|
|
||||||
query.fetch_all(&mut **transaction).await
|
|
||||||
} else {
|
|
||||||
query
|
|
||||||
.fetch_all(&self.get_connection_pool(vault).await?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.context("Cannot fetch latest documents")
|
|
||||||
.map(|rows| {
|
|
||||||
rows.into_iter()
|
|
||||||
.map(|row| DocumentVersionWithoutContent {
|
|
||||||
vault_update_id: row.vault_update_id,
|
|
||||||
document_id: row.document_id.into(),
|
|
||||||
relative_path: row.relative_path,
|
|
||||||
updated_date: row.updated_date,
|
|
||||||
is_deleted: row.is_deleted,
|
|
||||||
user_id: row.user_id,
|
|
||||||
device_id: row.device_id,
|
|
||||||
content_size: row
|
|
||||||
.content_size
|
|
||||||
.expect("Content size can't be null but sqlx can't infer it"),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
use sqlx::types::chrono::Utc;
|
|
||||||
use uuid::fmt::Hyphenated;
|
|
||||||
|
|
||||||
use super::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId};
|
|
||||||
use super::{Database, Transaction};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// Return the latest state of all documents (including deleted) in the
|
|
||||||
/// vault which have changed since the given update id
|
|
||||||
pub async fn get_latest_documents_since(
|
|
||||||
&self,
|
|
||||||
vault: &VaultId,
|
|
||||||
vault_update_id: VaultUpdateId,
|
|
||||||
transaction: Option<&mut Transaction<'_>>,
|
|
||||||
) -> Result<Vec<DocumentVersionWithoutContent>> {
|
|
||||||
let query = sqlx::query!(
|
|
||||||
r#"
|
|
||||||
select
|
|
||||||
vault_update_id,
|
|
||||||
document_id as "document_id: Hyphenated",
|
|
||||||
relative_path,
|
|
||||||
updated_date as "updated_date: chrono::DateTime<Utc>",
|
|
||||||
is_deleted,
|
|
||||||
user_id,
|
|
||||||
device_id,
|
|
||||||
length(content) as "content_size: u64"
|
|
||||||
from latest_document_versions
|
|
||||||
where vault_update_id > ?
|
|
||||||
order by vault_update_id
|
|
||||||
"#,
|
|
||||||
vault_update_id
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(transaction) = transaction {
|
|
||||||
query.fetch_all(&mut **transaction).await
|
|
||||||
} else {
|
|
||||||
query
|
|
||||||
.fetch_all(&self.get_connection_pool(vault).await?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.with_context(|| {
|
|
||||||
format!("Cannot fetch latest documents since vault_update_id `{vault_update_id}`")
|
|
||||||
})
|
|
||||||
.map(|rows| {
|
|
||||||
rows.into_iter()
|
|
||||||
.map(|row| DocumentVersionWithoutContent {
|
|
||||||
vault_update_id: row.vault_update_id,
|
|
||||||
document_id: row.document_id.into(),
|
|
||||||
relative_path: row.relative_path,
|
|
||||||
updated_date: row.updated_date,
|
|
||||||
is_deleted: row.is_deleted,
|
|
||||||
user_id: row.user_id,
|
|
||||||
device_id: row.device_id,
|
|
||||||
content_size: row
|
|
||||||
.content_size
|
|
||||||
.expect("Content size can't be null but sqlx can't infer it"),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
|
|
||||||
use super::models::VaultId;
|
|
||||||
use super::{Database, Transaction};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
pub async fn get_max_update_id_in_vault(
|
|
||||||
&self,
|
|
||||||
vault: &VaultId,
|
|
||||||
transaction: Option<&mut Transaction<'_>>,
|
|
||||||
) -> Result<i64> {
|
|
||||||
let query = sqlx::query!(
|
|
||||||
r#"
|
|
||||||
select coalesce(max(vault_update_id), 0) as max_vault_update_id
|
|
||||||
from documents
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(transaction) = transaction {
|
|
||||||
query.fetch_one(&mut **transaction).await
|
|
||||||
} else {
|
|
||||||
query
|
|
||||||
.fetch_one(&self.get_connection_pool(vault).await?)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
.map(|row| row.max_vault_update_id)
|
|
||||||
.context("Cannot fetch max update id in vault")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
|
|
||||||
use super::models::{StoredDocumentVersion, VaultId};
|
|
||||||
use super::{Database, Transaction};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// Look up the immutable version acknowledged by a previous push.
|
|
||||||
pub async fn get_push_acknowledgement(
|
|
||||||
&self,
|
|
||||||
vault_id: &VaultId,
|
|
||||||
request_id: uuid::Uuid,
|
|
||||||
transaction: &mut Transaction<'_>,
|
|
||||||
) -> Result<Option<(Vec<u8>, StoredDocumentVersion)>> {
|
|
||||||
let request_id = request_id.hyphenated().to_string();
|
|
||||||
let acknowledgement = sqlx::query!(
|
|
||||||
"select vault_update_id, request_fingerprint from push_acknowledgements where request_id = ?",
|
|
||||||
request_id
|
|
||||||
)
|
|
||||||
.fetch_optional(&mut **transaction)
|
|
||||||
.await
|
|
||||||
.context("Cannot fetch push acknowledgement")?;
|
|
||||||
|
|
||||||
match acknowledgement {
|
|
||||||
Some(acknowledgement) => {
|
|
||||||
let version = self
|
|
||||||
.get_document_version(
|
|
||||||
vault_id,
|
|
||||||
acknowledgement.vault_update_id,
|
|
||||||
Some(transaction),
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.context("Acknowledged document version is missing")?;
|
|
||||||
Ok(Some((acknowledgement.request_fingerprint, version)))
|
|
||||||
}
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
use anyhow::{Context as _, Result};
|
|
||||||
|
|
||||||
use super::models::{StoredDocumentVersion, VaultId};
|
|
||||||
use super::{Database, Transaction};
|
|
||||||
use crate::app_state::websocket::models::{
|
|
||||||
WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate,
|
|
||||||
};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// Commit the snapshot and acknowledgement atomically, then notify subscribers.
|
|
||||||
pub async fn insert_document_version(
|
|
||||||
&self,
|
|
||||||
vault_id: &VaultId,
|
|
||||||
version: &StoredDocumentVersion,
|
|
||||||
request_id: uuid::Uuid,
|
|
||||||
request_fingerprint: &[u8],
|
|
||||||
mut transaction: Transaction<'_>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let document_id = version.document_id.as_hyphenated();
|
|
||||||
sqlx::query!(
|
|
||||||
r#"
|
|
||||||
insert into documents (
|
|
||||||
vault_update_id,
|
|
||||||
document_id,
|
|
||||||
relative_path,
|
|
||||||
updated_date,
|
|
||||||
content,
|
|
||||||
is_deleted,
|
|
||||||
user_id,
|
|
||||||
device_id
|
|
||||||
)
|
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
"#,
|
|
||||||
version.vault_update_id,
|
|
||||||
document_id,
|
|
||||||
version.relative_path,
|
|
||||||
version.updated_date,
|
|
||||||
version.content,
|
|
||||||
version.is_deleted,
|
|
||||||
version.user_id,
|
|
||||||
version.device_id
|
|
||||||
)
|
|
||||||
.execute(&mut *transaction)
|
|
||||||
.await
|
|
||||||
.context("Cannot insert document version")?;
|
|
||||||
|
|
||||||
let request_id = request_id.hyphenated().to_string();
|
|
||||||
sqlx::query!(
|
|
||||||
"insert into push_acknowledgements (request_id, request_fingerprint, vault_update_id) values (?, ?, ?)",
|
|
||||||
request_id,
|
|
||||||
request_fingerprint,
|
|
||||||
version.vault_update_id
|
|
||||||
)
|
|
||||||
.execute(&mut *transaction)
|
|
||||||
.await
|
|
||||||
.context("Cannot save push acknowledgement")?;
|
|
||||||
|
|
||||||
transaction
|
|
||||||
.commit()
|
|
||||||
.await
|
|
||||||
.context("Failed to commit push")?;
|
|
||||||
|
|
||||||
// notifying the listeners here means that no update can circumvent this step
|
|
||||||
self.broadcasts
|
|
||||||
.send_document_update(
|
|
||||||
vault_id.clone(),
|
|
||||||
WebSocketServerMessageWithOrigin::with_origin(
|
|
||||||
version.device_id.clone(),
|
|
||||||
WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate {
|
|
||||||
documents: vec![version.clone().into()],
|
|
||||||
is_initial_sync: false,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::{Path, State},
|
|
||||||
};
|
|
||||||
use log::debug;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
app_state::{
|
|
||||||
AppState,
|
|
||||||
database::models::{DocumentVersionWithoutContent, VaultId},
|
|
||||||
},
|
|
||||||
errors::{SyncServerError, server_error},
|
|
||||||
utils::normalize::normalize,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct FetchLatestDocumentsPathParams {
|
|
||||||
#[serde(deserialize_with = "normalize")]
|
|
||||||
vault_id: VaultId,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[axum::debug_handler]
|
|
||||||
pub async fn fetch_latest_documents(
|
|
||||||
Path(FetchLatestDocumentsPathParams { vault_id }): Path<FetchLatestDocumentsPathParams>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Vec<DocumentVersionWithoutContent>>, SyncServerError> {
|
|
||||||
debug!("Fetching latest documents in {vault_id}");
|
|
||||||
|
|
||||||
state
|
|
||||||
.database
|
|
||||||
.get_latest_documents(&vault_id, None)
|
|
||||||
.await
|
|
||||||
.map(Json)
|
|
||||||
.map_err(server_error)
|
|
||||||
}
|
|
||||||
|
|
@ -1,423 +0,0 @@
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::{
|
|
||||||
app_state::{
|
|
||||||
database::models::{DocumentVersionWithoutContent, VaultUpdateId},
|
|
||||||
websocket::broadcasts::Broadcasts,
|
|
||||||
},
|
|
||||||
config::{database_config::DatabaseConfig, server_config::ServerConfig},
|
|
||||||
};
|
|
||||||
|
|
||||||
struct Push {
|
|
||||||
document_id: DocumentId,
|
|
||||||
request_id: uuid::Uuid,
|
|
||||||
parent_version_id: Option<VaultUpdateId>,
|
|
||||||
relative_path: String,
|
|
||||||
content: PushContent,
|
|
||||||
user_id: String,
|
|
||||||
device_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Push {
|
|
||||||
async fn send(
|
|
||||||
self,
|
|
||||||
database: &Database,
|
|
||||||
vault: &VaultId,
|
|
||||||
) -> Result<DocumentUpdateResponse, SyncServerError> {
|
|
||||||
accept_push(
|
|
||||||
database,
|
|
||||||
vault,
|
|
||||||
self.document_id,
|
|
||||||
self.user_id,
|
|
||||||
self.device_id,
|
|
||||||
PushDocument {
|
|
||||||
request_id: self.request_id,
|
|
||||||
parent_version_id: self.parent_version_id,
|
|
||||||
relative_path: self.relative_path,
|
|
||||||
content: self.content,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TestVault {
|
|
||||||
directory: PathBuf,
|
|
||||||
database: Database,
|
|
||||||
vault: VaultId,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestVault {
|
|
||||||
async fn new() -> Self {
|
|
||||||
let directory =
|
|
||||||
std::env::temp_dir().join(format!("vaultlink-cas-{}", uuid::Uuid::new_v4()));
|
|
||||||
let database = Self::open(&directory).await;
|
|
||||||
Self {
|
|
||||||
directory,
|
|
||||||
database,
|
|
||||||
vault: "test".to_owned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn open(directory: &std::path::Path) -> Database {
|
|
||||||
Database::try_new(
|
|
||||||
&DatabaseConfig {
|
|
||||||
databases_directory_path: directory.to_owned(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
&Broadcasts::new(&ServerConfig {
|
|
||||||
max_clients_per_vault: 16,
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send(&self, push: Push) -> DocumentUpdateResponse {
|
|
||||||
push.send(&self.database, &self.vault).await.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn latest(&self, document: DocumentId) -> StoredDocumentVersion {
|
|
||||||
self.database
|
|
||||||
.get_latest_document(&self.vault, &document, None)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TestVault {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_dir_all(&self.directory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot(document_id: DocumentId, parent: Option<VaultUpdateId>, text: &str) -> Push {
|
|
||||||
Push {
|
|
||||||
document_id,
|
|
||||||
request_id: uuid::Uuid::new_v4(),
|
|
||||||
parent_version_id: parent,
|
|
||||||
relative_path: "note.md".to_owned(),
|
|
||||||
content: PushContent::Snapshot(STANDARD.encode(text)),
|
|
||||||
user_id: "user".to_owned(),
|
|
||||||
device_id: "device".to_owned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn accepted(response: DocumentUpdateResponse) -> DocumentVersionWithoutContent {
|
|
||||||
match response {
|
|
||||||
DocumentUpdateResponse::Accepted(version) => version,
|
|
||||||
response @ DocumentUpdateResponse::StaleBase(_) => {
|
|
||||||
panic!("Expected Accepted, got {response:?}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn json_snapshot_preserves_bytes_and_rejects_invalid_base64_without_a_version() {
|
|
||||||
let vault = TestVault::new().await;
|
|
||||||
let document = uuid::Uuid::new_v4();
|
|
||||||
let content = [0, 255, 13, 10, 42];
|
|
||||||
let request: PushDocument = serde_json::from_value(serde_json::json!({
|
|
||||||
"requestId": uuid::Uuid::new_v4(),
|
|
||||||
"parentVersionId": null,
|
|
||||||
"relativePath": "snapshot.bin",
|
|
||||||
"content": { "type": "Snapshot", "value": STANDARD.encode(content) },
|
|
||||||
}))
|
|
||||||
.unwrap();
|
|
||||||
let base = accepted(
|
|
||||||
accept_push(
|
|
||||||
&vault.database,
|
|
||||||
&vault.vault,
|
|
||||||
document,
|
|
||||||
"user".to_owned(),
|
|
||||||
"device".to_owned(),
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
assert_eq!(vault.latest(document).await.content, content);
|
|
||||||
|
|
||||||
let invalid: PushDocument = serde_json::from_value(serde_json::json!({
|
|
||||||
"requestId": uuid::Uuid::new_v4(),
|
|
||||||
"parentVersionId": base.vault_update_id,
|
|
||||||
"relativePath": "snapshot.bin",
|
|
||||||
"content": { "type": "Snapshot", "value": "not base64!" },
|
|
||||||
}))
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
accept_push(
|
|
||||||
&vault.database,
|
|
||||||
&vault.vault,
|
|
||||||
document,
|
|
||||||
"user".to_owned(),
|
|
||||||
"device".to_owned(),
|
|
||||||
invalid
|
|
||||||
)
|
|
||||||
.await,
|
|
||||||
Err(SyncServerError::ClientError(_))
|
|
||||||
));
|
|
||||||
assert_eq!(
|
|
||||||
vault.latest(document).await.vault_update_id,
|
|
||||||
base.vault_update_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn concurrent_writers_only_accept_one_snapshot_and_preserve_the_base() {
|
|
||||||
let vault = TestVault::new().await;
|
|
||||||
let document = uuid::Uuid::new_v4();
|
|
||||||
let base = accepted(vault.send(snapshot(document, None, "base")).await);
|
|
||||||
let (left, right) = tokio::join!(
|
|
||||||
vault.send(snapshot(document, Some(base.vault_update_id), "left")),
|
|
||||||
vault.send(snapshot(document, Some(base.vault_update_id), "right")),
|
|
||||||
);
|
|
||||||
let (winner, rejected) = match (left, right) {
|
|
||||||
(DocumentUpdateResponse::Accepted(winner), DocumentUpdateResponse::StaleBase(rejected))
|
|
||||||
| (DocumentUpdateResponse::StaleBase(rejected), DocumentUpdateResponse::Accepted(winner)) => {
|
|
||||||
(winner, rejected)
|
|
||||||
}
|
|
||||||
outcomes => panic!("Expected one accepted push and one stale base: {outcomes:?}"),
|
|
||||||
};
|
|
||||||
let latest = vault.latest(document).await;
|
|
||||||
assert_eq!(winner.vault_update_id, rejected.vault_update_id);
|
|
||||||
assert_eq!(
|
|
||||||
STANDARD.decode(rejected.content_base64).unwrap(),
|
|
||||||
latest.content
|
|
||||||
);
|
|
||||||
assert!(latest.content == b"left" || latest.content == b"right");
|
|
||||||
let original = vault
|
|
||||||
.database
|
|
||||||
.get_document_version(&vault.vault, base.vault_update_id, None)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(original.content, b"base");
|
|
||||||
assert_eq!(
|
|
||||||
vault
|
|
||||||
.database
|
|
||||||
.get_max_update_id_in_vault(&vault.vault, None)
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn acknowledgements_survive_reopening_and_return_the_original_version() {
|
|
||||||
let vault = TestVault::new().await;
|
|
||||||
let document = uuid::Uuid::new_v4();
|
|
||||||
let create = snapshot(document, None, "created");
|
|
||||||
let create_id = create.request_id;
|
|
||||||
let base = accepted(vault.send(create).await);
|
|
||||||
let update = snapshot(document, Some(base.vault_update_id), "updated");
|
|
||||||
let update_id = update.request_id;
|
|
||||||
let updated = accepted(vault.send(update).await);
|
|
||||||
let latest = accepted(
|
|
||||||
vault
|
|
||||||
.send(snapshot(document, Some(updated.vault_update_id), "newest"))
|
|
||||||
.await,
|
|
||||||
);
|
|
||||||
|
|
||||||
let reopened = TestVault::open(&vault.directory).await;
|
|
||||||
for (request_id, parent, text, original) in [
|
|
||||||
(create_id, None, "created", base),
|
|
||||||
(update_id, Some(1), "updated", updated),
|
|
||||||
] {
|
|
||||||
let mut retry = snapshot(document, parent, text);
|
|
||||||
retry.request_id = request_id;
|
|
||||||
retry.device_id = "new-session-after-restart".to_owned();
|
|
||||||
let acknowledged = accepted(retry.send(&reopened, &vault.vault).await.unwrap());
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(acknowledged).unwrap(),
|
|
||||||
serde_json::to_value(original).unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
vault.latest(document).await.vault_update_id,
|
|
||||||
latest.vault_update_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut reused = snapshot(uuid::Uuid::new_v4(), None, "wrong document");
|
|
||||||
reused.request_id = create_id;
|
|
||||||
assert!(matches!(
|
|
||||||
reused.send(&reopened, &vault.vault).await,
|
|
||||||
Err(SyncServerError::ClientError(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut changed_path = snapshot(document, None, "created");
|
|
||||||
changed_path.relative_path = "other.md".to_owned();
|
|
||||||
let mut changed_operation = snapshot(document, None, "created");
|
|
||||||
changed_operation.content = PushContent::Delete;
|
|
||||||
for mut changed_payload in [
|
|
||||||
snapshot(document, None, "different content"),
|
|
||||||
snapshot(document, Some(1), "created"),
|
|
||||||
changed_path,
|
|
||||||
changed_operation,
|
|
||||||
] {
|
|
||||||
changed_payload.request_id = create_id;
|
|
||||||
assert!(matches!(
|
|
||||||
changed_payload.send(&reopened, &vault.vault).await,
|
|
||||||
Err(SyncServerError::ClientError(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn deletes_compare_the_base_and_replay_without_resurrecting_documents() {
|
|
||||||
let vault = TestVault::new().await;
|
|
||||||
let document = uuid::Uuid::new_v4();
|
|
||||||
let base = accepted(vault.send(snapshot(document, None, "base")).await);
|
|
||||||
let updated = accepted(
|
|
||||||
vault
|
|
||||||
.send(snapshot(document, Some(base.vault_update_id), "edited"))
|
|
||||||
.await,
|
|
||||||
);
|
|
||||||
let mut delete = snapshot(document, Some(base.vault_update_id), "");
|
|
||||||
delete.content = PushContent::Delete;
|
|
||||||
assert!(matches!(
|
|
||||||
vault.send(delete).await,
|
|
||||||
DocumentUpdateResponse::StaleBase(_)
|
|
||||||
));
|
|
||||||
assert!(!vault.latest(document).await.is_deleted);
|
|
||||||
|
|
||||||
let mut delete = snapshot(document, Some(updated.vault_update_id), "");
|
|
||||||
delete.content = PushContent::Delete;
|
|
||||||
let request_id = delete.request_id;
|
|
||||||
let deleted = accepted(vault.send(delete).await);
|
|
||||||
assert!(deleted.is_deleted);
|
|
||||||
assert_eq!(vault.latest(document).await.content, b"edited");
|
|
||||||
let mut retry = snapshot(document, Some(updated.vault_update_id), "");
|
|
||||||
retry.content = PushContent::Delete;
|
|
||||||
retry.request_id = request_id;
|
|
||||||
assert_eq!(
|
|
||||||
accepted(vault.send(retry).await).vault_update_id,
|
|
||||||
deleted.vault_update_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let terminal = vault
|
|
||||||
.send(snapshot(
|
|
||||||
document,
|
|
||||||
Some(deleted.vault_update_id),
|
|
||||||
"resurrect",
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
assert!(matches!(terminal, DocumentUpdateResponse::StaleBase(version) if version.is_deleted));
|
|
||||||
let recreated = accepted(
|
|
||||||
vault
|
|
||||||
.send(snapshot(uuid::Uuid::new_v4(), None, "new identity"))
|
|
||||||
.await,
|
|
||||||
);
|
|
||||||
assert_eq!(recreated.relative_path, "note.md");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn deleting_a_missing_document_without_a_parent_is_a_client_error() {
|
|
||||||
let vault = TestVault::new().await;
|
|
||||||
let document = uuid::Uuid::new_v4();
|
|
||||||
let mut delete = snapshot(document, None, "");
|
|
||||||
delete.content = PushContent::Delete;
|
|
||||||
assert!(matches!(
|
|
||||||
delete.send(&vault.database, &vault.vault).await,
|
|
||||||
Err(SyncServerError::ClientError(_))
|
|
||||||
));
|
|
||||||
assert_eq!(
|
|
||||||
vault
|
|
||||||
.database
|
|
||||||
.get_latest_document(&vault.vault, &document, None)
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn transport_diffs_reconstruct_exact_snapshots_and_reject_invalid_bases() {
|
|
||||||
let vault = TestVault::new().await;
|
|
||||||
let document = uuid::Uuid::new_v4();
|
|
||||||
let original = "Hello world 🌍";
|
|
||||||
let desired = "Hi beautiful world 🌍";
|
|
||||||
let base = accepted(vault.send(snapshot(document, None, original)).await);
|
|
||||||
let mut update = snapshot(document, Some(base.vault_update_id), "");
|
|
||||||
update.content =
|
|
||||||
PushContent::Diff(EditedText::from_strings(original, &desired.into()).to_diff());
|
|
||||||
let updated = accepted(vault.send(update).await);
|
|
||||||
assert_eq!(vault.latest(document).await.content, desired.as_bytes());
|
|
||||||
|
|
||||||
for invalid_length in [i64::MIN, i64::MAX] {
|
|
||||||
let mut malformed = snapshot(document, Some(updated.vault_update_id), "");
|
|
||||||
malformed.content = PushContent::Diff(vec![NumberOrText::Number(invalid_length)]);
|
|
||||||
assert!(matches!(
|
|
||||||
malformed.send(&vault.database, &vault.vault).await,
|
|
||||||
Err(SyncServerError::ClientError(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
vault.latest(document).await.vault_update_id,
|
|
||||||
updated.vault_update_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let another = accepted(
|
|
||||||
vault
|
|
||||||
.send(snapshot(uuid::Uuid::new_v4(), None, "another document"))
|
|
||||||
.await,
|
|
||||||
);
|
|
||||||
let mut foreign_base = snapshot(document, Some(another.vault_update_id), "");
|
|
||||||
foreign_base.content = PushContent::Diff(vec![]);
|
|
||||||
assert!(
|
|
||||||
matches!(vault.send(foreign_base).await, DocumentUpdateResponse::StaleBase(version) if version.vault_update_id == updated.vault_update_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
let binary_document = uuid::Uuid::new_v4();
|
|
||||||
let mut binary = snapshot(binary_document, None, "");
|
|
||||||
binary.content = PushContent::Snapshot(STANDARD.encode([0xff]));
|
|
||||||
let binary_base = accepted(vault.send(binary).await);
|
|
||||||
let mut invalid_text = snapshot(binary_document, Some(binary_base.vault_update_id), "");
|
|
||||||
invalid_text.content = PushContent::Diff(vec![]);
|
|
||||||
assert!(matches!(
|
|
||||||
invalid_text.send(&vault.database, &vault.vault).await,
|
|
||||||
Err(SyncServerError::ClientError(_))
|
|
||||||
));
|
|
||||||
assert_eq!(
|
|
||||||
vault.latest(binary_document).await.vault_update_id,
|
|
||||||
binary_base.vault_update_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn renames_allocate_paths_atomically_without_rewriting_history() {
|
|
||||||
let vault = TestVault::new().await;
|
|
||||||
let first = uuid::Uuid::new_v4();
|
|
||||||
let second = uuid::Uuid::new_v4();
|
|
||||||
let base = accepted(vault.send(snapshot(first, None, "first")).await);
|
|
||||||
let collision = accepted(vault.send(snapshot(second, None, "second")).await);
|
|
||||||
assert_ne!(base.relative_path, collision.relative_path);
|
|
||||||
|
|
||||||
let mut rename = snapshot(first, Some(base.vault_update_id), "first");
|
|
||||||
rename.relative_path = "renamed.md".to_owned();
|
|
||||||
let renamed = accepted(vault.send(rename).await);
|
|
||||||
let mut stale_rename = snapshot(first, Some(base.vault_update_id), "first");
|
|
||||||
stale_rename.relative_path = "stale.md".to_owned();
|
|
||||||
assert!(
|
|
||||||
matches!(vault.send(stale_rename).await, DocumentUpdateResponse::StaleBase(version) if version.relative_path == renamed.relative_path)
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut move_into_occupied = snapshot(second, Some(collision.vault_update_id), "second");
|
|
||||||
move_into_occupied.relative_path = renamed.relative_path.clone();
|
|
||||||
let allocated = accepted(vault.send(move_into_occupied).await);
|
|
||||||
assert_ne!(allocated.relative_path, renamed.relative_path);
|
|
||||||
assert_eq!(vault.latest(first).await.relative_path, "renamed.md");
|
|
||||||
assert_eq!(
|
|
||||||
vault
|
|
||||||
.database
|
|
||||||
.get_document_version(&vault.vault, base.vault_update_id, None)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap()
|
|
||||||
.relative_path,
|
|
||||||
"note.md"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
use regex::Regex;
|
|
||||||
|
|
||||||
pub fn dedup_paths(path: &str) -> impl Iterator<Item = String> {
|
|
||||||
let mut path_parts = path.split('/').collect::<Vec<_>>();
|
|
||||||
let file_name = path_parts.pop().unwrap().to_owned();
|
|
||||||
|
|
||||||
let mut directory = path_parts.join("/");
|
|
||||||
if !directory.is_empty() {
|
|
||||||
directory.push('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should split as ".config" + ".json"
|
|
||||||
let is_simple_dotfile = file_name.starts_with('.') && file_name.matches('.').count() == 1;
|
|
||||||
|
|
||||||
let (stem, extension) = if is_simple_dotfile {
|
|
||||||
(file_name.clone(), String::new())
|
|
||||||
} else {
|
|
||||||
// Regular file or dotfile with extension
|
|
||||||
let name_parts = file_name.rsplitn(2, '.').collect::<Vec<_>>();
|
|
||||||
let mut reverse_parts = name_parts.into_iter().rev();
|
|
||||||
match (reverse_parts.next(), reverse_parts.next()) {
|
|
||||||
(Some(stem), maybe_extension) => (
|
|
||||||
stem.to_owned(),
|
|
||||||
maybe_extension
|
|
||||||
.map(|ext| format!(".{ext}"))
|
|
||||||
.unwrap_or_default(),
|
|
||||||
),
|
|
||||||
_ => unreachable!("Path must have at least one part"),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let regex = Regex::new(r" \((\d+)\)$").unwrap();
|
|
||||||
let start_number = regex
|
|
||||||
.captures(&stem)
|
|
||||||
.and_then(|caps| caps.get(1))
|
|
||||||
.and_then(|m| m.as_str().parse::<u32>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let clean_stem = regex.replace(&stem, "").to_string();
|
|
||||||
|
|
||||||
(start_number..).map(move |dedup_number| {
|
|
||||||
if dedup_number == 0 {
|
|
||||||
format!("{directory}{clean_stem}{extension}")
|
|
||||||
} else {
|
|
||||||
format!("{directory}{clean_stem} ({dedup_number}){extension}")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_dedup_paths() {
|
|
||||||
let mut deduped = dedup_paths("file.txt");
|
|
||||||
assert_eq!(deduped.next(), Some("file.txt".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("file (1).txt".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("file (2).txt".to_owned()));
|
|
||||||
|
|
||||||
let mut deduped = dedup_paths("file");
|
|
||||||
assert_eq!(deduped.next(), Some("file".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("file (1)".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("file (2)".to_owned()));
|
|
||||||
|
|
||||||
let mut deduped = dedup_paths("file (51).md");
|
|
||||||
assert_eq!(deduped.next(), Some("file (51).md".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("file (52).md".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("file (53).md".to_owned()));
|
|
||||||
|
|
||||||
let mut deduped = dedup_paths("file (5)");
|
|
||||||
assert_eq!(deduped.next(), Some("file (5)".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("file (6)".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("file (7)".to_owned()));
|
|
||||||
|
|
||||||
let mut deduped = dedup_paths("my/path.with.dots/file (5).md");
|
|
||||||
assert_eq!(
|
|
||||||
deduped.next(),
|
|
||||||
Some("my/path.with.dots/file (5).md".to_owned())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
deduped.next(),
|
|
||||||
Some("my/path.with.dots/file (6).md".to_owned())
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut deduped = dedup_paths("my/path.with.dots/file (5)");
|
|
||||||
assert_eq!(
|
|
||||||
deduped.next(),
|
|
||||||
Some("my/path.with.dots/file (5)".to_owned())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
deduped.next(),
|
|
||||||
Some("my/path.with.dots/file (6)".to_owned())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_regex_capturing_group() {
|
|
||||||
// Single digit in parentheses
|
|
||||||
let mut deduped = dedup_paths("document (5).md");
|
|
||||||
assert_eq!(deduped.next(), Some("document (5).md".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("document (6).md".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("document (7).md".to_owned()));
|
|
||||||
|
|
||||||
// Multi-digit number
|
|
||||||
let mut deduped = dedup_paths("report (123).pdf");
|
|
||||||
assert_eq!(deduped.next(), Some("report (123).pdf".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("report (124).pdf".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("report (125).pdf".to_owned()));
|
|
||||||
|
|
||||||
// Number without extension
|
|
||||||
let mut deduped = dedup_paths("folder (99)");
|
|
||||||
assert_eq!(deduped.next(), Some("folder (99)".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("folder (100)".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("folder (101)".to_owned()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_dedup_dotfiles() {
|
|
||||||
// Simple dotfile (no extension)
|
|
||||||
let mut deduped = dedup_paths(".gitignore");
|
|
||||||
assert_eq!(deduped.next(), Some(".gitignore".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some(".gitignore (1)".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some(".gitignore (2)".to_owned()));
|
|
||||||
|
|
||||||
// Dotfile with extension
|
|
||||||
let mut deduped = dedup_paths(".config.json");
|
|
||||||
assert_eq!(deduped.next(), Some(".config.json".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some(".config (1).json".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some(".config (2).json".to_owned()));
|
|
||||||
|
|
||||||
// Dotfile with number
|
|
||||||
let mut deduped = dedup_paths(".gitignore (5)");
|
|
||||||
assert_eq!(deduped.next(), Some(".gitignore (5)".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some(".gitignore (6)".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some(".gitignore (7)".to_owned()));
|
|
||||||
|
|
||||||
// Dotfile with extension and number
|
|
||||||
let mut deduped = dedup_paths(".config (3).json");
|
|
||||||
assert_eq!(deduped.next(), Some(".config (3).json".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some(".config (4).json".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some(".config (5).json".to_owned()));
|
|
||||||
|
|
||||||
// Dotfile in subdirectory
|
|
||||||
let mut deduped = dedup_paths("my/path/.gitignore");
|
|
||||||
assert_eq!(deduped.next(), Some("my/path/.gitignore".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("my/path/.gitignore (1)".to_owned()));
|
|
||||||
assert_eq!(deduped.next(), Some("my/path/.gitignore (2)".to_owned()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
use crate::app_state::database::models::VaultId;
|
|
||||||
use crate::{app_state::database::Transaction, utils::dedup_paths::dedup_paths};
|
|
||||||
use anyhow::Result;
|
|
||||||
use log::{debug, info};
|
|
||||||
|
|
||||||
pub async fn find_first_available_path(
|
|
||||||
vault_id: &VaultId,
|
|
||||||
sanitized_relative_path: &str,
|
|
||||||
database: &crate::app_state::database::Database,
|
|
||||||
transaction: &mut Transaction<'_>,
|
|
||||||
) -> Result<String> {
|
|
||||||
info!("Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}`");
|
|
||||||
for candidate in dedup_paths(sanitized_relative_path) {
|
|
||||||
debug!("Checking candidate path for deconflicting names: `{candidate}`");
|
|
||||||
if database
|
|
||||||
.get_latest_document_by_path(vault_id, &candidate, Some(transaction))
|
|
||||||
.await?
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
info!("Selected available path: `{candidate}`");
|
|
||||||
return Ok(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unreachable!("dedup_paths produces infinite paths");
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
/// Sanitize the document's path to allow all clients to create the same path in
|
|
||||||
/// their filesystem. If we didn't do this server-side, client's would need to
|
|
||||||
/// deal with mapping invalid names to valid ones and then back.
|
|
||||||
pub fn sanitize_path(path: &str) -> String {
|
|
||||||
let options = sanitize_filename::Options {
|
|
||||||
truncate: true,
|
|
||||||
windows: true, // Windows is the lowest common denominator
|
|
||||||
replacement: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
path.split('/')
|
|
||||||
.map(|part| {
|
|
||||||
let proposal = sanitize_filename::sanitize_with_options(part, options.clone());
|
|
||||||
if !part.is_empty() && proposal.is_empty() {
|
|
||||||
"_".to_owned()
|
|
||||||
} else {
|
|
||||||
proposal
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("/")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sanitize_path() {
|
|
||||||
assert_eq!(sanitize_path("/my/path/what?"), "/my/path/what");
|
|
||||||
assert_eq!(sanitize_path("file (1).md"), "file (1).md");
|
|
||||||
assert_eq!(sanitize_path("/my/path/\\\\:?"), "/my/path/_");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue