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
|
||||
|
||||
## 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
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// generated by `sqlx migrate build-script`
|
||||
fn main() {
|
||||
// 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 log::info;
|
||||
use models::{
|
||||
DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, VaultUpdateId,
|
||||
};
|
||||
use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions, types::chrono::Utc};
|
||||
|
||||
pub mod models;
|
||||
use models::VaultId;
|
||||
use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions};
|
||||
use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::Instant;
|
||||
use uuid::fmt::Hyphenated;
|
||||
|
||||
use super::websocket::{
|
||||
broadcasts::Broadcasts,
|
||||
models::{WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate},
|
||||
};
|
||||
pub mod models;
|
||||
|
||||
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;
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -176,305 +179,6 @@ impl Database {
|
|||
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
|
||||
async fn cleanup_idle_pools(&self) {
|
||||
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,
|
||||
updated_date TIMESTAMP 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
|
||||
|
|
@ -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
|
||||
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 user_id: UserId,
|
||||
pub device_id: DeviceId,
|
||||
#[allow(dead_code)] // This is for manual analysis
|
||||
pub has_been_merged: bool,
|
||||
}
|
||||
|
||||
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 SUPPORTED_API_VERSION: u32 = 2;
|
||||
pub const SUPPORTED_API_VERSION: u32 = 3;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
pub mod auth;
|
||||
mod create_document;
|
||||
mod delete_document;
|
||||
mod device_id_header;
|
||||
mod fetch_document_version;
|
||||
mod fetch_document_version_content;
|
||||
mod fetch_latest_document_version;
|
||||
mod fetch_latest_documents;
|
||||
mod index;
|
||||
mod ping;
|
||||
mod endpoints;
|
||||
mod requests;
|
||||
mod responses;
|
||||
mod update_document;
|
||||
mod websocket;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use auth::auth_middleware;
|
||||
|
|
@ -21,7 +12,7 @@ use axum::{
|
|||
http::{self, HeaderValue, Method},
|
||||
middleware,
|
||||
response::IntoResponse,
|
||||
routing::{IntoMakeService, delete, get, post, put},
|
||||
routing::{IntoMakeService, get},
|
||||
};
|
||||
use device_id_header::DEVICE_ID_HEADER_NAME;
|
||||
use log::info;
|
||||
|
|
@ -53,9 +44,12 @@ pub async fn create_server(config: Config) -> Result<()> {
|
|||
|
||||
let app = Router::new()
|
||||
.nest("/", get_authed_routes(app_state.clone()))
|
||||
.route("/", get(index::index))
|
||||
.route("/vaults/:vault_id/ping", get(ping::ping))
|
||||
.route("/vaults/:vault_id/ws", get(websocket::websocket_handler))
|
||||
.route("/", get(endpoints::index::index))
|
||||
.route("/vaults/:vault_id/ping", get(endpoints::ping::ping))
|
||||
.route(
|
||||
"/vaults/:vault_id/ws",
|
||||
get(endpoints::websocket::websocket_handler),
|
||||
)
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(
|
||||
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,
|
||||
DEVICE_ID_HEADER_NAME.clone(),
|
||||
])
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]),
|
||||
.allow_methods([Method::GET, Method::PUT]),
|
||||
)
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
|
|
@ -102,35 +96,16 @@ fn get_authed_routes(app_state: AppState) -> Router<AppState> {
|
|||
Router::new()
|
||||
.route(
|
||||
"/vaults/:vault_id/documents",
|
||||
get(fetch_latest_documents::fetch_latest_documents),
|
||||
)
|
||||
.route(
|
||||
"/vaults/:vault_id/documents",
|
||||
post(create_document::create_document),
|
||||
get(endpoints::fetch_latest_documents::fetch_latest_documents),
|
||||
)
|
||||
.route(
|
||||
"/vaults/:vault_id/documents/:document_id",
|
||||
get(fetch_latest_document_version::fetch_latest_document_version),
|
||||
)
|
||||
.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),
|
||||
get(endpoints::fetch_latest_document_version::fetch_latest_document_version)
|
||||
.put(endpoints::push_document::push_document),
|
||||
)
|
||||
.route(
|
||||
"/vaults/:vault_id/documents/:document_id/versions/:vault_update_id/content",
|
||||
get(fetch_document_version_content::fetch_document_version_content),
|
||||
)
|
||||
.route(
|
||||
"/vaults/:vault_id/documents/:document_id",
|
||||
delete(delete_document::delete_document),
|
||||
get(endpoints::fetch_document_version_content::fetch_document_version_content),
|
||||
)
|
||||
.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