Migrate server to CAS only
This commit is contained in:
parent
d99e249fa5
commit
1619c51ad9
36 changed files with 1163 additions and 1092 deletions
|
|
@ -1,5 +1,38 @@
|
||||||
# Sync server
|
# Sync server
|
||||||
|
|
||||||
|
## Document pushes (API v3)
|
||||||
|
|
||||||
|
The server stores immutable document versions. One compare-and-swap endpoint,
|
||||||
|
`PUT /vaults/:vault_id/documents/:document_id`, handles every mutation. The client
|
||||||
|
generates the document UUID; the JSON body contains:
|
||||||
|
|
||||||
|
- `requestId`: a UUID identifying this exact push.
|
||||||
|
- `parentVersionId`: `null` to create an absent document, otherwise its base version.
|
||||||
|
- `relativePath`: the desired filename.
|
||||||
|
- `content`: `{ "type": "Snapshot", "value": "<base64>" }`,
|
||||||
|
`{ "type": "Diff", "value": [3, "inserted text", -2] }`, or `{ "type": "Delete" }`.
|
||||||
|
|
||||||
|
All pushes return one of:
|
||||||
|
|
||||||
|
- `Accepted`: version metadata for the exact snapshot committed. A retry with the
|
||||||
|
same request ID returns the original acknowledgement, even after a restart or
|
||||||
|
subsequent updates. Clients must persist the ID with the unchanged request.
|
||||||
|
A payload fingerprint rejects reusing that ID for a different mutation.
|
||||||
|
- `StaleBase`: the current version, including `contentBase64`. Nothing was written;
|
||||||
|
the client merges locally and submits a new push with a new request ID.
|
||||||
|
|
||||||
|
The base check, path allocation, new version, and durable acknowledgement share
|
||||||
|
one SQLite write transaction. A deleted document stays deleted; recreating a file
|
||||||
|
uses a new document ID. Occupied paths receive an available numbered filename.
|
||||||
|
Text updates accept reconcile-text transport diffs against the base version;
|
||||||
|
the server only reconstructs snapshots and never merges concurrent edits.
|
||||||
|
|
||||||
|
Snapshot requests use base64, adding roughly one third to the raw file size.
|
||||||
|
Server and reverse-proxy request limits apply to the encoded JSON body.
|
||||||
|
|
||||||
|
API v3 uses a fresh schema. Update clients together with the server and initialise
|
||||||
|
new vault databases; compatibility with previous releases is not supported.
|
||||||
|
|
||||||
## Creating/resetting the Database for development
|
## Creating/resetting the Database for development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// generated by `sqlx migrate build-script`
|
// generated by `sqlx migrate build-script`
|
||||||
fn main() {
|
fn main() {
|
||||||
// trigger recompilation when a new migration is added
|
// trigger recompilation when a new migration is added
|
||||||
println!("cargo:rerun-if-changed=migrations");
|
println!("cargo:rerun-if-changed=src/app_state/database/migrations");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,24 @@ use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use log::info;
|
use log::info;
|
||||||
use models::{
|
use models::VaultId;
|
||||||
DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, VaultUpdateId,
|
use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions};
|
||||||
};
|
|
||||||
use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions, types::chrono::Utc};
|
|
||||||
|
|
||||||
pub mod models;
|
|
||||||
use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions};
|
use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::time::Instant;
|
use tokio::time::Instant;
|
||||||
use uuid::fmt::Hyphenated;
|
|
||||||
|
|
||||||
use super::websocket::{
|
pub mod models;
|
||||||
broadcasts::Broadcasts,
|
|
||||||
models::{WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate},
|
mod get_document_version;
|
||||||
};
|
mod get_latest_document;
|
||||||
|
mod get_latest_document_by_path;
|
||||||
|
mod get_latest_documents;
|
||||||
|
mod get_latest_documents_since;
|
||||||
|
mod get_max_update_id_in_vault;
|
||||||
|
mod get_push_acknowledgement;
|
||||||
|
mod insert_document_version;
|
||||||
|
|
||||||
|
use super::websocket::broadcasts::Broadcasts;
|
||||||
use crate::config::database_config::DatabaseConfig;
|
use crate::config::database_config::DatabaseConfig;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -176,305 +179,6 @@ impl Database {
|
||||||
Ok(transaction)
|
Ok(transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
has_been_merged
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
has_been_merged
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
has_been_merged
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// inserting the document must be the last step of the transaction if there's one
|
|
||||||
pub async fn insert_document_version(
|
|
||||||
&self,
|
|
||||||
vault_id: &VaultId,
|
|
||||||
version: &StoredDocumentVersion,
|
|
||||||
transaction: Option<Transaction<'_>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let document_id = version.document_id.as_hyphenated();
|
|
||||||
let query = 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
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(mut transaction) = transaction {
|
|
||||||
query
|
|
||||||
.execute(&mut *transaction)
|
|
||||||
.await
|
|
||||||
.context("Cannot insert document version")?;
|
|
||||||
|
|
||||||
transaction
|
|
||||||
.commit()
|
|
||||||
.await
|
|
||||||
.context("Failed to commit transaction")?;
|
|
||||||
} else {
|
|
||||||
query
|
|
||||||
.execute(&self.get_connection_pool(vault_id).await?)
|
|
||||||
.await
|
|
||||||
.context("Cannot insert document version")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cleanup idle connection pools that haven't been accessed in more than 5 minutes
|
/// Cleanup idle connection pools that haven't been accessed in more than 5 minutes
|
||||||
async fn cleanup_idle_pools(&self) {
|
async fn cleanup_idle_pools(&self) {
|
||||||
let mut pools = self.connection_pools.lock().await;
|
let mut pools = self.connection_pools.lock().await;
|
||||||
|
|
|
||||||
41
sync-server/src/app_state/database/get_document_version.rs
Normal file
41
sync-server/src/app_state/database/get_document_version.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
43
sync-server/src/app_state/database/get_latest_document.rs
Normal file
43
sync-server/src/app_state/database/get_latest_document.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
56
sync-server/src/app_state/database/get_latest_documents.rs
Normal file
56
sync-server/src/app_state/database/get_latest_documents.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,9 @@ CREATE TABLE IF NOT EXISTS documents (
|
||||||
relative_path TEXT NOT NULL,
|
relative_path TEXT NOT NULL,
|
||||||
updated_date TIMESTAMP NOT NULL,
|
updated_date TIMESTAMP NOT NULL,
|
||||||
content BLOB NOT NULL,
|
content BLOB NOT NULL,
|
||||||
is_deleted BOOLEAN NOT NULL
|
is_deleted BOOLEAN NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE VIEW IF NOT EXISTS latest_document_versions AS
|
CREATE VIEW IF NOT EXISTS latest_document_versions AS
|
||||||
|
|
@ -19,3 +21,10 @@ ON d.vault_update_id = max_versions.max_version_id;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_documents_vault_id_relative_path
|
CREATE INDEX IF NOT EXISTS idx_documents_vault_id_relative_path
|
||||||
ON documents (relative_path);
|
ON documents (relative_path);
|
||||||
|
|
||||||
|
-- The immutable version and its acknowledgement commit in one transaction.
|
||||||
|
CREATE TABLE push_acknowledgements (
|
||||||
|
request_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
request_fingerprint BLOB NOT NULL,
|
||||||
|
vault_update_id INTEGER NOT NULL REFERENCES documents(vault_update_id)
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
ALTER TABLE documents ADD COLUMN user_id TEXT NOT NULL DEFAULT "";
|
|
||||||
ALTER TABLE documents ADD COLUMN device_id TEXT NOT NULL DEFAULT "";
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
ALTER TABLE documents ADD COLUMN has_been_merged BOOLEAN NOT NULL DEFAULT False;
|
|
||||||
|
|
||||||
DROP VIEW latest_document_versions;
|
|
||||||
|
|
||||||
CREATE VIEW IF NOT EXISTS latest_document_versions AS --recreate view as it now includes one more field
|
|
||||||
SELECT d.*
|
|
||||||
FROM documents d
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT MAX(vault_update_id) AS max_version_id
|
|
||||||
FROM documents
|
|
||||||
GROUP BY document_id
|
|
||||||
) max_versions
|
|
||||||
ON d.vault_update_id = max_versions.max_version_id;
|
|
||||||
|
|
@ -20,8 +20,6 @@ pub struct StoredDocumentVersion {
|
||||||
pub is_deleted: bool,
|
pub is_deleted: bool,
|
||||||
pub user_id: UserId,
|
pub user_id: UserId,
|
||||||
pub device_id: DeviceId,
|
pub device_id: DeviceId,
|
||||||
#[allow(dead_code)] // This is for manual analysis
|
|
||||||
pub has_been_merged: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq<Self> for StoredDocumentVersion {
|
impl PartialEq<Self> for StoredDocumentVersion {
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,4 @@ pub const DEFAULT_LOG_LEVEL: LogLevel = LogLevel::Info;
|
||||||
|
|
||||||
pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"];
|
pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"];
|
||||||
|
|
||||||
pub const SUPPORTED_API_VERSION: u32 = 2;
|
pub const SUPPORTED_API_VERSION: u32 = 3;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
mod create_document;
|
|
||||||
mod delete_document;
|
|
||||||
mod device_id_header;
|
mod device_id_header;
|
||||||
mod fetch_document_version;
|
mod endpoints;
|
||||||
mod fetch_document_version_content;
|
|
||||||
mod fetch_latest_document_version;
|
|
||||||
mod fetch_latest_documents;
|
|
||||||
mod index;
|
|
||||||
mod ping;
|
|
||||||
mod requests;
|
mod requests;
|
||||||
mod responses;
|
mod responses;
|
||||||
mod update_document;
|
|
||||||
mod websocket;
|
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, anyhow};
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
use auth::auth_middleware;
|
use auth::auth_middleware;
|
||||||
|
|
@ -21,7 +12,7 @@ use axum::{
|
||||||
http::{self, HeaderValue, Method},
|
http::{self, HeaderValue, Method},
|
||||||
middleware,
|
middleware,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{IntoMakeService, delete, get, post, put},
|
routing::{IntoMakeService, get},
|
||||||
};
|
};
|
||||||
use device_id_header::DEVICE_ID_HEADER_NAME;
|
use device_id_header::DEVICE_ID_HEADER_NAME;
|
||||||
use log::info;
|
use log::info;
|
||||||
|
|
@ -53,9 +44,12 @@ pub async fn create_server(config: Config) -> Result<()> {
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/", get_authed_routes(app_state.clone()))
|
.nest("/", get_authed_routes(app_state.clone()))
|
||||||
.route("/", get(index::index))
|
.route("/", get(endpoints::index::index))
|
||||||
.route("/vaults/:vault_id/ping", get(ping::ping))
|
.route("/vaults/:vault_id/ping", get(endpoints::ping::ping))
|
||||||
.route("/vaults/:vault_id/ws", get(websocket::websocket_handler))
|
.route(
|
||||||
|
"/vaults/:vault_id/ws",
|
||||||
|
get(endpoints::websocket::websocket_handler),
|
||||||
|
)
|
||||||
.layer(DefaultBodyLimit::disable())
|
.layer(DefaultBodyLimit::disable())
|
||||||
.layer(RequestBodyLimitLayer::new(
|
.layer(RequestBodyLimitLayer::new(
|
||||||
app_state.config.server.max_body_size_mb * 1024 * 1024,
|
app_state.config.server.max_body_size_mb * 1024 * 1024,
|
||||||
|
|
@ -69,7 +63,7 @@ pub async fn create_server(config: Config) -> Result<()> {
|
||||||
http::header::AUTHORIZATION,
|
http::header::AUTHORIZATION,
|
||||||
DEVICE_ID_HEADER_NAME.clone(),
|
DEVICE_ID_HEADER_NAME.clone(),
|
||||||
])
|
])
|
||||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]),
|
.allow_methods([Method::GET, Method::PUT]),
|
||||||
)
|
)
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http()
|
TraceLayer::new_for_http()
|
||||||
|
|
@ -102,35 +96,16 @@ fn get_authed_routes(app_state: AppState) -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/vaults/:vault_id/documents",
|
"/vaults/:vault_id/documents",
|
||||||
get(fetch_latest_documents::fetch_latest_documents),
|
get(endpoints::fetch_latest_documents::fetch_latest_documents),
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/vaults/:vault_id/documents",
|
|
||||||
post(create_document::create_document),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/vaults/:vault_id/documents/:document_id",
|
"/vaults/:vault_id/documents/:document_id",
|
||||||
get(fetch_latest_document_version::fetch_latest_document_version),
|
get(endpoints::fetch_latest_document_version::fetch_latest_document_version)
|
||||||
)
|
.put(endpoints::push_document::push_document),
|
||||||
.route(
|
|
||||||
"/vaults/:vault_id/documents/:document_id/binary",
|
|
||||||
put(update_document::update_binary),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/vaults/:vault_id/documents/:document_id/text",
|
|
||||||
put(update_document::update_text),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/vaults/:vault_id/documents/:document_id/versions/:vault_update_id",
|
|
||||||
get(fetch_document_version::fetch_document_version),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/vaults/:vault_id/documents/:document_id/versions/:vault_update_id/content",
|
"/vaults/:vault_id/documents/:document_id/versions/:vault_update_id/content",
|
||||||
get(fetch_document_version_content::fetch_document_version_content),
|
get(endpoints::fetch_document_version_content::fetch_document_version_content),
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/vaults/:vault_id/documents/:document_id",
|
|
||||||
delete(delete_document::delete_document),
|
|
||||||
)
|
)
|
||||||
.layer(middleware::from_fn_with_state(app_state, auth_middleware))
|
.layer(middleware::from_fn_with_state(app_state, auth_middleware))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
use axum::{
|
|
||||||
Extension, Json,
|
|
||||||
extract::{Path, State},
|
|
||||||
};
|
|
||||||
use axum_extra::TypedHeader;
|
|
||||||
use axum_typed_multipart::TypedMultipart;
|
|
||||||
use log::{debug, info};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion};
|
|
||||||
use crate::{
|
|
||||||
app_state::{
|
|
||||||
AppState,
|
|
||||||
database::models::{DocumentVersionWithoutContent, StoredDocumentVersion, VaultId},
|
|
||||||
},
|
|
||||||
config::user_config::User,
|
|
||||||
errors::{SyncServerError, client_error, server_error},
|
|
||||||
utils::{
|
|
||||||
find_first_available_path::find_first_available_path, normalize::normalize,
|
|
||||||
sanitize_path::sanitize_path,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct CreateDocumentPathParams {
|
|
||||||
#[serde(deserialize_with = "normalize")]
|
|
||||||
vault_id: VaultId,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new document in case a document with the same doesn't exist
|
|
||||||
/// already. If a document with the same path exists, a new version is created
|
|
||||||
/// with their content merged.
|
|
||||||
#[axum::debug_handler]
|
|
||||||
pub async fn create_document(
|
|
||||||
Path(CreateDocumentPathParams { vault_id }): Path<CreateDocumentPathParams>,
|
|
||||||
Extension(user): Extension<User>,
|
|
||||||
TypedHeader(device_id): TypedHeader<DeviceIdHeader>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
TypedMultipart(request): TypedMultipart<CreateDocumentVersion>,
|
|
||||||
) -> Result<Json<DocumentVersionWithoutContent>, SyncServerError> {
|
|
||||||
debug!("Creating document in vault `{vault_id}`");
|
|
||||||
|
|
||||||
let mut transaction = state
|
|
||||||
.database
|
|
||||||
.create_write_transaction(&vault_id)
|
|
||||||
.await
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
let document_id = match request.document_id {
|
|
||||||
Some(document_id) => {
|
|
||||||
let existing_version = state
|
|
||||||
.database
|
|
||||||
.get_latest_document(&vault_id, &document_id, Some(&mut transaction))
|
|
||||||
.await
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
if existing_version.is_some() {
|
|
||||||
return Err(client_error(anyhow::anyhow!(
|
|
||||||
"Document with the same ID `{document_id}` already exists"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
document_id
|
|
||||||
}
|
|
||||||
None => uuid::Uuid::new_v4(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let last_update_id = state
|
|
||||||
.database
|
|
||||||
.get_max_update_id_in_vault(&vault_id, Some(&mut transaction))
|
|
||||||
.await
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
let sanitized_relative_path = sanitize_path(&request.relative_path);
|
|
||||||
let deduped_path = find_first_available_path(
|
|
||||||
&vault_id,
|
|
||||||
&sanitized_relative_path,
|
|
||||||
&state.database,
|
|
||||||
&mut transaction,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
if deduped_path != sanitized_relative_path {
|
|
||||||
info!(
|
|
||||||
"Document already exists at new location: `{sanitized_relative_path}` when trying to create it in vault `{vault_id}`, deconflicting by creating at `{deduped_path}`"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_version = StoredDocumentVersion {
|
|
||||||
vault_update_id: last_update_id + 1,
|
|
||||||
document_id,
|
|
||||||
relative_path: deduped_path,
|
|
||||||
content: request.content.contents.to_vec(),
|
|
||||||
updated_date: chrono::Utc::now(),
|
|
||||||
is_deleted: false,
|
|
||||||
user_id: user.name,
|
|
||||||
device_id: device_id.0,
|
|
||||||
has_been_merged: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
state
|
|
||||||
.database
|
|
||||||
.insert_document_version(&vault_id, &new_version, Some(transaction))
|
|
||||||
.await
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
Ok(Json(new_version.into()))
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
use anyhow::Context;
|
|
||||||
use axum::{
|
|
||||||
Extension, Json,
|
|
||||||
extract::{Path, State},
|
|
||||||
};
|
|
||||||
use axum_extra::TypedHeader;
|
|
||||||
use log::{debug, info};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use super::{device_id_header::DeviceIdHeader, requests::DeleteDocumentVersion};
|
|
||||||
use crate::{
|
|
||||||
app_state::{
|
|
||||||
AppState,
|
|
||||||
database::models::{
|
|
||||||
DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
config::user_config::User,
|
|
||||||
errors::{SyncServerError, server_error},
|
|
||||||
utils::{normalize::normalize, sanitize_path::sanitize_path},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct DeleteDocumentPathParams {
|
|
||||||
#[serde(deserialize_with = "normalize")]
|
|
||||||
vault_id: VaultId,
|
|
||||||
|
|
||||||
document_id: DocumentId,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[axum::debug_handler]
|
|
||||||
pub async fn delete_document(
|
|
||||||
Path(DeleteDocumentPathParams {
|
|
||||||
vault_id,
|
|
||||||
document_id,
|
|
||||||
}): Path<DeleteDocumentPathParams>,
|
|
||||||
Extension(user): Extension<User>,
|
|
||||||
TypedHeader(device_id): TypedHeader<DeviceIdHeader>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(request): Json<DeleteDocumentVersion>,
|
|
||||||
) -> Result<Json<DocumentVersionWithoutContent>, SyncServerError> {
|
|
||||||
debug!("Deleting document `{document_id}` in vault `{vault_id}`");
|
|
||||||
|
|
||||||
let mut transaction = state
|
|
||||||
.database
|
|
||||||
.create_write_transaction(&vault_id)
|
|
||||||
.await
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
let last_update_id = state
|
|
||||||
.database
|
|
||||||
.get_max_update_id_in_vault(&vault_id, Some(&mut transaction))
|
|
||||||
.await
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
let latest_version = state
|
|
||||||
.database
|
|
||||||
.get_latest_document(&vault_id, &document_id, Some(&mut transaction))
|
|
||||||
.await
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
if let Some(latest_version) = &latest_version
|
|
||||||
&& latest_version.is_deleted
|
|
||||||
{
|
|
||||||
transaction
|
|
||||||
.rollback()
|
|
||||||
.await
|
|
||||||
.context("Failed to roll back transaction")
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
info!("Document `{document_id}` has already been deleted",);
|
|
||||||
return Ok(Json(latest_version.clone().into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let latest_content = latest_version.map_or_else(Vec::new, |version| version.content); // in case the document has never existed before deleting it
|
|
||||||
|
|
||||||
let new_version = StoredDocumentVersion {
|
|
||||||
vault_update_id: last_update_id + 1,
|
|
||||||
document_id,
|
|
||||||
relative_path: sanitize_path(&request.relative_path),
|
|
||||||
content: latest_content, // copy the content from the latest version
|
|
||||||
updated_date: chrono::Utc::now(),
|
|
||||||
is_deleted: true,
|
|
||||||
user_id: user.name,
|
|
||||||
device_id: device_id.0,
|
|
||||||
has_been_merged: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
state
|
|
||||||
.database
|
|
||||||
.insert_document_version(&vault_id, &new_version, Some(transaction))
|
|
||||||
.await
|
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
Ok(Json(new_version.into()))
|
|
||||||
}
|
|
||||||
7
sync-server/src/server/endpoints.rs
Normal file
7
sync-server/src/server/endpoints.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
pub mod fetch_document_version_content;
|
||||||
|
pub mod fetch_latest_document_version;
|
||||||
|
pub mod fetch_latest_documents;
|
||||||
|
pub mod index;
|
||||||
|
pub mod ping;
|
||||||
|
pub mod push_document;
|
||||||
|
pub mod websocket;
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue