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