Clean up Rust by renaming functions, extracting logic, and auto into-ing errors
This commit is contained in:
parent
90e3cc9124
commit
419c29aa9d
29 changed files with 415 additions and 298 deletions
|
|
@ -1 +0,0 @@
|
||||||
DATABASE_URL=sqlite://db.sqlite3
|
|
||||||
|
|
@ -21,7 +21,7 @@ tower-http = { version = "0.6.1", features = ["cors", "trace", "limit", "timeout
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"]}
|
tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"]}
|
||||||
humantime-serde = "1.1.1"
|
humantime-serde = "1.1.1"
|
||||||
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] }
|
sqlx = { version = "0.8.6", default-features = false, features = ["sqlite", "runtime-tokio", "uuid", "chrono", "macros"] }
|
||||||
chrono = { version = "0.4.41", features = ["serde"] }
|
chrono = { version = "0.4.41", features = ["serde"] }
|
||||||
rand = "0.9.0"
|
rand = "0.9.0"
|
||||||
regex = "1.12.2"
|
regex = "1.12.2"
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ impl AppState {
|
||||||
let database = Database::try_new(&config.database).await?;
|
let database = Database::try_new(&config.database).await?;
|
||||||
let cursors: Cursors = Cursors::new(&config.database, &broadcasts);
|
let cursors: Cursors = Cursors::new(&config.database, &broadcasts);
|
||||||
|
|
||||||
Cursors::start_background_task(cursors.clone());
|
Cursors::start_cursor_expiration_task(cursors.clone());
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
config,
|
config,
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ impl Cursors {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_background_task(self) {
|
pub fn start_cursor_expiration_task(self) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
self.remove_expired_cursors().await;
|
self.remove_expired_cursors().await;
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,14 @@ use anyhow::{Context as _, Result};
|
||||||
use log::info;
|
use log::info;
|
||||||
use models::VaultId;
|
use models::VaultId;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions};
|
use sqlx::{ConnectOptions, Executor, sqlite::SqliteConnectOptions};
|
||||||
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;
|
||||||
|
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|
||||||
|
mod history;
|
||||||
mod mutations;
|
mod mutations;
|
||||||
mod queries;
|
mod queries;
|
||||||
|
|
||||||
|
|
@ -83,7 +84,7 @@ impl Database {
|
||||||
Ok(database)
|
Ok(database)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn database_path(config: &DatabaseConfig, vault: &str) -> PathBuf {
|
fn get_database_path(config: &DatabaseConfig, vault: &str) -> PathBuf {
|
||||||
// Hash the normalized vault name into a fixed-length ASCII filename so
|
// Hash the normalized vault name into a fixed-length ASCII filename so
|
||||||
// filesystem case/Unicode aliasing (e.g. composed vs. decomposed é on
|
// filesystem case/Unicode aliasing (e.g. composed vs. decomposed é on
|
||||||
// macOS) cannot make separately authorized vaults share a database.
|
// macOS) cannot make separately authorized vaults share a database.
|
||||||
|
|
@ -149,7 +150,7 @@ impl Database {
|
||||||
let vault = normalize_string(vault);
|
let vault = normalize_string(vault);
|
||||||
validate_vault_id(&vault)?;
|
validate_vault_id(&vault)?;
|
||||||
|
|
||||||
if let Some(pool) = self.existing_pool(&vault).await {
|
if let Some(pool) = self.find_cached_pool(&vault).await {
|
||||||
return Ok(pool);
|
return Ok(pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,11 +161,11 @@ impl Database {
|
||||||
|
|
||||||
let _opening = opening.lock().await;
|
let _opening = opening.lock().await;
|
||||||
|
|
||||||
if let Some(pool) = self.existing_pool(&vault).await {
|
if let Some(pool) = self.find_cached_pool(&vault).await {
|
||||||
return Ok(pool);
|
return Ok(pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_name = Self::database_path(&self.config, &vault);
|
let file_name = Self::get_database_path(&self.config, &vault);
|
||||||
let pool = Self::open_database(&self.config, &file_name).await?;
|
let pool = Self::open_database(&self.config, &file_name).await?;
|
||||||
|
|
||||||
self.connection_pools.lock().await.insert(
|
self.connection_pools.lock().await.insert(
|
||||||
|
|
@ -177,7 +178,7 @@ impl Database {
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn existing_pool(&self, vault: &VaultId) -> Option<Pool<Sqlite>> {
|
async fn find_cached_pool(&self, vault: &VaultId) -> Option<Pool<Sqlite>> {
|
||||||
self.connection_pools
|
self.connection_pools
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
48
sync-server/src/app_state/database/history.rs
Normal file
48
sync-server/src/app_state/database/history.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
//! Checkpoints distinguish restored histories even when numeric event IDs are reused.
|
||||||
|
use super::Database;
|
||||||
|
use super::models::VaultId;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
const EMPTY_HISTORY_CHECKPOINT: &str = "0:empty";
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub async fn get_history_checkpoint(&self, vault: &VaultId) -> Result<String> {
|
||||||
|
let mut tx = self.create_readonly_transaction(vault).await?;
|
||||||
|
|
||||||
|
let row: Option<(i64, String)> = sqlx::query_as(
|
||||||
|
"SELECT event_id, event_token FROM events ORDER BY event_id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(row.map_or_else(
|
||||||
|
|| EMPTY_HISTORY_CHECKPOINT.to_owned(),
|
||||||
|
|(id, token)| format!("{id}:{token}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn contains_checkpoint(&self, vault: &VaultId, checkpoint: &str) -> Result<bool> {
|
||||||
|
if checkpoint == EMPTY_HISTORY_CHECKPOINT {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((id, token)) = checkpoint.split_once(':') else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(id) = id.parse::<i64>() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tx = self.create_readonly_transaction(vault).await?;
|
||||||
|
let exists: bool = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM events WHERE event_id = ? AND event_token = ?)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(token)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(exists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -125,9 +125,8 @@ pub struct EventBatch {
|
||||||
pub head_event_id: VaultUpdateId,
|
pub head_event_id: VaultUpdateId,
|
||||||
|
|
||||||
/// Last event in this page; `head_event_id` is the vault's current head.
|
/// Last event in this page; `head_event_id` is the vault's current head.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[ts(as = "f64")]
|
||||||
#[ts(optional, as = "Option<f64>")]
|
pub end_event_id: VaultUpdateId,
|
||||||
pub end_event_id: Option<VaultUpdateId>,
|
|
||||||
|
|
||||||
pub events: Vec<EventRecord>,
|
pub events: Vec<EventRecord>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@ use anyhow::{Context as _, Result, ensure};
|
||||||
|
|
||||||
use crate::consts::MAX_EVENTS_PAGE_BYTES;
|
use crate::consts::MAX_EVENTS_PAGE_BYTES;
|
||||||
|
|
||||||
const EMPTY_HISTORY_CHECKPOINT: &str = "0:empty";
|
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
pub async fn get_missing_document(
|
pub async fn get_missing_document(
|
||||||
tx: &mut Transaction<'_>,
|
tx: &mut Transaction<'_>,
|
||||||
|
|
@ -36,9 +34,10 @@ impl Database {
|
||||||
.fetch_optional(&mut **tx)
|
.fetch_optional(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
match row {
|
match row {
|
||||||
Some((fingerprint, event_id)) => {
|
Some((fingerprint, event_id)) => Ok(Some((
|
||||||
Ok(Some((fingerprint, Self::event_by_id(tx, event_id).await?)))
|
fingerprint,
|
||||||
}
|
Self::get_event_by_id(tx, event_id).await?,
|
||||||
|
))),
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +95,7 @@ impl Database {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn current_file_manifest(tx: &mut Transaction<'_>) -> Result<FileManifest> {
|
pub async fn get_current_file_manifest(tx: &mut Transaction<'_>) -> Result<FileManifest> {
|
||||||
let Some(file_manifest_id) = sqlx::query_scalar::<_, VaultUpdateId>(
|
let Some(file_manifest_id) = sqlx::query_scalar::<_, VaultUpdateId>(
|
||||||
"SELECT file_manifest_id FROM file_manifests ORDER BY file_manifest_id DESC LIMIT 1",
|
"SELECT file_manifest_id FROM file_manifests ORDER BY file_manifest_id DESC LIMIT 1",
|
||||||
)
|
)
|
||||||
|
|
@ -106,12 +105,12 @@ impl Database {
|
||||||
return Ok(FileManifest::default());
|
return Ok(FileManifest::default());
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::file_manifest_by_id(tx, file_manifest_id)
|
Self::get_file_manifest_by_id(tx, file_manifest_id)
|
||||||
.await?
|
.await?
|
||||||
.context("Latest file manifest is missing")
|
.context("Latest file manifest is missing")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn file_manifest_by_id(
|
async fn get_file_manifest_by_id(
|
||||||
tx: &mut Transaction<'_>,
|
tx: &mut Transaction<'_>,
|
||||||
file_manifest_id: VaultUpdateId,
|
file_manifest_id: VaultUpdateId,
|
||||||
) -> Result<Option<FileManifest>> {
|
) -> Result<Option<FileManifest>> {
|
||||||
|
|
@ -140,7 +139,10 @@ impl Database {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn event_by_id(tx: &mut Transaction<'_>, event_id: VaultUpdateId) -> Result<VaultEvent> {
|
async fn get_event_by_id(
|
||||||
|
tx: &mut Transaction<'_>,
|
||||||
|
event_id: VaultUpdateId,
|
||||||
|
) -> Result<VaultEvent> {
|
||||||
let document = sqlx::query_as::<_, DocumentVersionWithoutContent>(
|
let document = sqlx::query_as::<_, DocumentVersionWithoutContent>(
|
||||||
"SELECT vault_update_id, document_id, updated_date, user_id, device_id,
|
"SELECT vault_update_id, document_id, updated_date, user_id, device_id,
|
||||||
length(content) AS content_size
|
length(content) AS content_size
|
||||||
|
|
@ -152,13 +154,13 @@ impl Database {
|
||||||
if let Some(document) = document {
|
if let Some(document) = document {
|
||||||
return Ok(VaultEvent::Content { document });
|
return Ok(VaultEvent::Content { document });
|
||||||
}
|
}
|
||||||
let file_manifest = Self::file_manifest_by_id(tx, event_id)
|
let file_manifest = Self::get_file_manifest_by_id(tx, event_id)
|
||||||
.await?
|
.await?
|
||||||
.context("Event has no normalized content or file manifest")?;
|
.context("Event has no normalized content or file manifest")?;
|
||||||
Ok(VaultEvent::FileManifest { file_manifest })
|
Ok(VaultEvent::FileManifest { file_manifest })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn latest_event_id(tx: &mut Transaction<'_>) -> Result<VaultUpdateId> {
|
pub async fn get_latest_event_id(tx: &mut Transaction<'_>) -> Result<VaultUpdateId> {
|
||||||
Ok(
|
Ok(
|
||||||
sqlx::query_scalar("SELECT COALESCE(MAX(event_id), 0) FROM events")
|
sqlx::query_scalar("SELECT COALESCE(MAX(event_id), 0) FROM events")
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
|
|
@ -166,10 +168,10 @@ impl Database {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn vault_snapshot(&self, vault: &VaultId) -> Result<VaultSnapshot> {
|
pub async fn get_vault_snapshot(&self, vault: &VaultId) -> Result<VaultSnapshot> {
|
||||||
let mut tx = self.create_readonly_transaction(vault).await?;
|
let mut tx = self.create_readonly_transaction(vault).await?;
|
||||||
let head_event_id = Self::latest_event_id(&mut tx).await?;
|
let head_event_id = Self::get_latest_event_id(&mut tx).await?;
|
||||||
let file_manifest = Self::current_file_manifest(&mut tx).await?;
|
let file_manifest = Self::get_current_file_manifest(&mut tx).await?;
|
||||||
|
|
||||||
let documents = sqlx::query_as::<_, DocumentVersionWithoutContent>(
|
let documents = sqlx::query_as::<_, DocumentVersionWithoutContent>(
|
||||||
"SELECT d.vault_update_id, d.document_id, d.updated_date, d.user_id, d.device_id,
|
"SELECT d.vault_update_id, d.document_id, d.updated_date, d.user_id, d.device_id,
|
||||||
|
|
@ -189,14 +191,14 @@ impl Database {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn events_after(
|
pub async fn get_events_after(
|
||||||
&self,
|
&self,
|
||||||
vault: &VaultId,
|
vault: &VaultId,
|
||||||
after: VaultUpdateId,
|
after: VaultUpdateId,
|
||||||
page_size: usize,
|
page_size: usize,
|
||||||
) -> Result<EventBatch> {
|
) -> Result<EventBatch> {
|
||||||
let mut tx = self.create_readonly_transaction(vault).await?;
|
let mut tx = self.create_readonly_transaction(vault).await?;
|
||||||
let head_event_id = Self::latest_event_id(&mut tx).await?;
|
let head_event_id = Self::get_latest_event_id(&mut tx).await?;
|
||||||
|
|
||||||
ensure!(
|
ensure!(
|
||||||
(0..=head_event_id).contains(&after),
|
(0..=head_event_id).contains(&after),
|
||||||
|
|
@ -218,7 +220,7 @@ impl Database {
|
||||||
let event = EventRecord {
|
let event = EventRecord {
|
||||||
event_id,
|
event_id,
|
||||||
request_id: request_id.parse()?,
|
request_id: request_id.parse()?,
|
||||||
event: Self::event_by_id(&mut tx, event_id).await?,
|
event: Self::get_event_by_id(&mut tx, event_id).await?,
|
||||||
};
|
};
|
||||||
let size = serde_json::to_vec(&event)?.len();
|
let size = serde_json::to_vec(&event)?.len();
|
||||||
// One manifest is atomic and may exceed this budget. Always allow
|
// One manifest is atomic and may exceed this budget. Always allow
|
||||||
|
|
@ -233,50 +235,8 @@ impl Database {
|
||||||
|
|
||||||
Ok(EventBatch {
|
Ok(EventBatch {
|
||||||
head_event_id,
|
head_event_id,
|
||||||
end_event_id: Some(end_event_id),
|
end_event_id,
|
||||||
events,
|
events,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
|
||||||
pub async fn history_checkpoint(&self, vault: &VaultId) -> Result<String> {
|
|
||||||
let mut tx = self.create_readonly_transaction(vault).await?;
|
|
||||||
|
|
||||||
let row: Option<(i64, String)> = sqlx::query_as(
|
|
||||||
"SELECT event_id, event_token FROM events ORDER BY event_id DESC LIMIT 1",
|
|
||||||
)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(row.map_or_else(
|
|
||||||
|| EMPTY_HISTORY_CHECKPOINT.to_owned(),
|
|
||||||
|(id, token)| format!("{id}:{token}"),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn contains_checkpoint(&self, vault: &VaultId, checkpoint: &str) -> Result<bool> {
|
|
||||||
if checkpoint == EMPTY_HISTORY_CHECKPOINT {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some((id, token)) = checkpoint.split_once(':') else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(id) = id.parse::<i64>() else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut tx = self.create_readonly_transaction(vault).await?;
|
|
||||||
let exists: bool = sqlx::query_scalar(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM events WHERE event_id = ? AND event_token = ?)",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(token)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(exists)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ async fn rejects_invalid_vault_names_at_the_database_boundary() {
|
||||||
"a\0b",
|
"a\0b",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
database.vault_snapshot(&name.to_owned()).await.is_err(),
|
database.get_vault_snapshot(&name.to_owned()).await.is_err(),
|
||||||
"accepted {name:?}"
|
"accepted {name:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -96,15 +96,15 @@ async fn rejects_invalid_vault_names_at_the_database_boundary() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn migrations_can_be_reapplied_without_losing_requests_or_content() {
|
async fn schema_initialization_preserves_existing_requests_and_content() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
let config = config(&directory);
|
let config = config(&directory);
|
||||||
let database = Database::try_new(&config).await.unwrap();
|
let database = Database::try_new(&config).await.unwrap();
|
||||||
let vault = "migrations".to_owned();
|
let vault = "initialization".to_owned();
|
||||||
let version = insert_content(&database, &vault, uuid::Uuid::new_v4(), b"only copy").await;
|
let version = insert_content(&database, &vault, uuid::Uuid::new_v4(), b"only copy").await;
|
||||||
let pool = database.get_connection_pool(&vault).await.unwrap();
|
let pool = database.get_connection_pool(&vault).await.unwrap();
|
||||||
Database::run_migrations(&pool).await.unwrap();
|
Database::initialize_schema(&pool).await.unwrap();
|
||||||
Database::run_migrations(&pool).await.unwrap();
|
Database::initialize_schema(&pool).await.unwrap();
|
||||||
let reopened = Database::try_new(&config).await.unwrap();
|
let reopened = Database::try_new(&config).await.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reopened
|
reopened
|
||||||
|
|
@ -117,7 +117,7 @@ async fn migrations_can_be_reapplied_without_losing_requests_or_content() {
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reopened
|
reopened
|
||||||
.events_after(&vault, 0, DEFAULT_EVENTS_PAGE_SIZE)
|
.get_events_after(&vault, 0, DEFAULT_EVENTS_PAGE_SIZE)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.events
|
.events
|
||||||
|
|
@ -155,7 +155,7 @@ async fn snapshot_returns_latest_referenced_metadata_and_byte_lengths() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tx.commit().await.unwrap();
|
tx.commit().await.unwrap();
|
||||||
|
|
||||||
let snapshot = database.vault_snapshot(&vault).await.unwrap();
|
let snapshot = database.get_vault_snapshot(&vault).await.unwrap();
|
||||||
assert_eq!(snapshot.head_event_id, manifest.file_manifest_id);
|
assert_eq!(snapshot.head_event_id, manifest.file_manifest_id);
|
||||||
assert_eq!(snapshot.file_manifest.entries, manifest.entries);
|
assert_eq!(snapshot.file_manifest.entries, manifest.entries);
|
||||||
assert_eq!(snapshot.documents.len(), 2);
|
assert_eq!(snapshot.documents.len(), 2);
|
||||||
|
|
@ -179,7 +179,7 @@ async fn replay_pages_are_bounded_and_cover_every_event_in_order() {
|
||||||
let mut after = 0;
|
let mut after = 0;
|
||||||
while after < 150 {
|
while after < 150 {
|
||||||
let batch = database
|
let batch = database
|
||||||
.events_after(&vault, after, DEFAULT_EVENTS_PAGE_SIZE)
|
.get_events_after(&vault, after, DEFAULT_EVENTS_PAGE_SIZE)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(batch.events.len() <= 64, "catchup must bound each page");
|
assert!(batch.events.len() <= 64, "catchup must bound each page");
|
||||||
|
|
@ -192,7 +192,7 @@ async fn replay_pages_are_bounded_and_cover_every_event_in_order() {
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
database
|
database
|
||||||
.events_after(&vault, after, DEFAULT_EVENTS_PAGE_SIZE)
|
.get_events_after(&vault, after, DEFAULT_EVENTS_PAGE_SIZE)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.events
|
.events
|
||||||
|
|
@ -204,7 +204,10 @@ async fn replay_pages_are_bounded_and_cover_every_event_in_order() {
|
||||||
async fn idle_cleanup_does_not_wait_for_an_active_vault() {
|
async fn idle_cleanup_does_not_wait_for_an_active_vault() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
let database = Database::try_new(&config(&directory)).await.unwrap();
|
let database = Database::try_new(&config(&directory)).await.unwrap();
|
||||||
database.vault_snapshot(&"other".to_owned()).await.unwrap();
|
database
|
||||||
|
.get_vault_snapshot(&"other".to_owned())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let vault = "slow".to_owned();
|
let vault = "slow".to_owned();
|
||||||
let transaction = database.create_write_transaction(&vault).await.unwrap();
|
let transaction = database.create_write_transaction(&vault).await.unwrap();
|
||||||
database
|
database
|
||||||
|
|
@ -219,7 +222,7 @@ async fn idle_cleanup_does_not_wait_for_an_active_vault() {
|
||||||
.expect("cleanup must leave an active pool alone");
|
.expect("cleanup must leave an active pool alone");
|
||||||
let other = tokio::time::timeout(
|
let other = tokio::time::timeout(
|
||||||
Duration::from_millis(200),
|
Duration::from_millis(200),
|
||||||
database.vault_snapshot(&"other".to_owned()),
|
database.get_vault_snapshot(&"other".to_owned()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("other vaults must remain accessible")
|
.expect("other vaults must remain accessible")
|
||||||
|
|
@ -299,16 +302,13 @@ async fn replay_byte_budget_prevents_accumulating_large_historical_manifests() {
|
||||||
}
|
}
|
||||||
tx.commit().await.unwrap();
|
tx.commit().await.unwrap();
|
||||||
let batch = database
|
let batch = database
|
||||||
.events_after(&vault, 0, DEFAULT_EVENTS_PAGE_SIZE)
|
.get_events_after(&vault, 0, DEFAULT_EVENTS_PAGE_SIZE)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!batch.events.is_empty());
|
assert!(!batch.events.is_empty());
|
||||||
assert!(batch.events.len() < 8);
|
assert!(batch.events.len() < 8);
|
||||||
assert!(serde_json::to_vec(&batch).unwrap().len() < MAX_EVENTS_PAGE_BYTES + 1024);
|
assert!(serde_json::to_vec(&batch).unwrap().len() < MAX_EVENTS_PAGE_BYTES + 1024);
|
||||||
assert_eq!(
|
assert_eq!(batch.end_event_id, batch.events.last().unwrap().event_id);
|
||||||
batch.end_event_id,
|
|
||||||
batch.events.last().map(|event| event.event_id)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -316,11 +316,14 @@ async fn opening_a_locked_vault_does_not_block_unrelated_vaults() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
let config = config(&directory);
|
let config = config(&directory);
|
||||||
let database = Database::try_new(&config).await.unwrap();
|
let database = Database::try_new(&config).await.unwrap();
|
||||||
database.vault_snapshot(&"other".to_owned()).await.unwrap();
|
database
|
||||||
|
.get_vault_snapshot(&"other".to_owned())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let vault = "locked-opening".to_owned();
|
let vault = "locked-opening".to_owned();
|
||||||
// A second process holds the writer lock while this process lazily checks
|
// A second process holds the writer lock while this process initializes
|
||||||
// migrations. Even already-open, unrelated vaults must remain available.
|
// the schema. Even already-open, unrelated vaults must remain available.
|
||||||
let external = Database::open_database(&config, &Database::database_path(&config, &vault))
|
let external = Database::open_database(&config, &Database::get_database_path(&config, &vault))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let transaction = external.begin_with("BEGIN IMMEDIATE").await.unwrap();
|
let transaction = external.begin_with("BEGIN IMMEDIATE").await.unwrap();
|
||||||
|
|
@ -330,7 +333,7 @@ async fn opening_a_locked_vault_does_not_block_unrelated_vaults() {
|
||||||
let opening_is_blocked = !opening.is_finished();
|
let opening_is_blocked = !opening.is_finished();
|
||||||
let other = tokio::time::timeout(
|
let other = tokio::time::timeout(
|
||||||
Duration::from_millis(300),
|
Duration::from_millis(300),
|
||||||
database.vault_snapshot(&"other".to_owned()),
|
database.get_vault_snapshot(&"other".to_owned()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
transaction.rollback().await.unwrap();
|
transaction.rollback().await.unwrap();
|
||||||
|
|
@ -346,7 +349,7 @@ async fn opening_a_locked_vault_does_not_block_unrelated_vaults() {
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
other
|
other
|
||||||
.expect("a blocked migration must not lock every vault")
|
.expect("a blocked database open must not lock every vault")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.head_event_id,
|
.head_event_id,
|
||||||
0
|
0
|
||||||
|
|
@ -360,7 +363,7 @@ async fn concurrent_vault_opens_share_a_pool_and_cancelled_opens_can_retry() {
|
||||||
config.max_connections_per_vault = 1;
|
config.max_connections_per_vault = 1;
|
||||||
let database = Database::try_new(&config).await.unwrap();
|
let database = Database::try_new(&config).await.unwrap();
|
||||||
let vault = "cancelled-opening".to_owned();
|
let vault = "cancelled-opening".to_owned();
|
||||||
let external = Database::open_database(&config, &Database::database_path(&config, &vault))
|
let external = Database::open_database(&config, &Database::get_database_path(&config, &vault))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let transaction = external.begin_with("BEGIN IMMEDIATE").await.unwrap();
|
let transaction = external.begin_with("BEGIN IMMEDIATE").await.unwrap();
|
||||||
|
|
@ -401,7 +404,7 @@ async fn concurrent_vault_opens_share_a_pool_and_cancelled_opens_can_retry() {
|
||||||
async fn one_corrupt_vault_does_not_prevent_startup_or_healthy_vault_access() {
|
async fn one_corrupt_vault_does_not_prevent_startup_or_healthy_vault_access() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
let config = config(&directory);
|
let config = config(&directory);
|
||||||
let bad = Database::database_path(&config, "broken");
|
let bad = Database::get_database_path(&config, "broken");
|
||||||
tokio::fs::create_dir_all(bad.parent().unwrap())
|
tokio::fs::create_dir_all(bad.parent().unwrap())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -409,7 +412,12 @@ async fn one_corrupt_vault_does_not_prevent_startup_or_healthy_vault_access() {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let database = Database::try_new(&config).await.unwrap();
|
let database = Database::try_new(&config).await.unwrap();
|
||||||
assert!(database.vault_snapshot(&"broken".to_owned()).await.is_err());
|
assert!(
|
||||||
|
database
|
||||||
|
.get_vault_snapshot(&"broken".to_owned())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
let saved = insert_content(&database, "healthy", uuid::Uuid::new_v4(), b"safe").await;
|
let saved = insert_content(&database, "healthy", uuid::Uuid::new_v4(), b"safe").await;
|
||||||
assert_eq!(saved.content, b"safe");
|
assert_eq!(saved.content, b"safe");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ impl Broadcasts {
|
||||||
limit.try_acquire_owned().ok()
|
limit.try_acquire_owned().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sender(&self, vault: VaultId) -> broadcast::Sender<Notification> {
|
async fn get_or_create_sender(&self, vault: VaultId) -> broadcast::Sender<Notification> {
|
||||||
self.senders
|
self.senders
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
|
|
@ -49,12 +49,12 @@ impl Broadcasts {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_receiver(&self, vault: VaultId) -> broadcast::Receiver<Notification> {
|
pub async fn get_receiver(&self, vault: VaultId) -> broadcast::Receiver<Notification> {
|
||||||
self.sender(vault).await.subscribe()
|
self.get_or_create_sender(vault).await.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send(&self, vault: VaultId, notification: Notification) {
|
pub async fn send(&self, vault: VaultId, notification: Notification) {
|
||||||
// No listeners (or lagging listeners) is normal; they replay the log.
|
// No listeners (or lagging listeners) is normal; they replay the log.
|
||||||
let _ = self.sender(vault).await.send(notification);
|
let _ = self.get_or_create_sender(vault).await.send(notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn notify_about_vault_update(&self, vault: VaultId) {
|
pub async fn notify_about_vault_update(&self, vault: VaultId) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use crate::{
|
||||||
app_state::{AppState, database::models::VaultId},
|
app_state::{AppState, database::models::VaultId},
|
||||||
config::user_config::User,
|
config::user_config::User,
|
||||||
errors::{SyncServerError, server_error, unauthenticated_error},
|
errors::{SyncServerError, server_error, unauthenticated_error},
|
||||||
server::auth::auth,
|
server::auth::authenticate_user,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct AuthenticatedWebSocketHandshake {
|
pub struct AuthenticatedWebSocketHandshake {
|
||||||
|
|
@ -28,7 +28,7 @@ pub fn get_authenticated_handshake(
|
||||||
|
|
||||||
match message {
|
match message {
|
||||||
WebSocketClientMessage::Handshake(handshake) => {
|
WebSocketClientMessage::Handshake(handshake) => {
|
||||||
let user = auth(state, handshake.token.trim(), vault_id)?;
|
let user = authenticate_user(state, handshake.token.trim(), vault_id)?;
|
||||||
Ok(AuthenticatedWebSocketHandshake { handshake, user })
|
Ok(AuthenticatedWebSocketHandshake { handshake, user })
|
||||||
}
|
}
|
||||||
WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error(
|
WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error(
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,26 @@ pub enum SyncServerError {
|
||||||
PermissionDeniedError(#[source] anyhow::Error),
|
PermissionDeniedError(#[source] anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Infrastructure failures are server errors. Request validation and missing
|
||||||
|
// resources opt into their HTTP status explicitly at the point of validation.
|
||||||
|
impl From<anyhow::Error> for SyncServerError {
|
||||||
|
fn from(error: anyhow::Error) -> Self {
|
||||||
|
server_error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for SyncServerError {
|
||||||
|
fn from(error: sqlx::Error) -> Self {
|
||||||
|
server_error(error.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<tokio::task::JoinError> for SyncServerError {
|
||||||
|
fn from(error: tokio::task::JoinError) -> Self {
|
||||||
|
server_error(error.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SyncServerError {
|
impl SyncServerError {
|
||||||
pub fn serialize(&self) -> SerializedError {
|
pub fn serialize(&self) -> SerializedError {
|
||||||
match self {
|
match self {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ pub async fn create_server(config: Config) -> Result<()> {
|
||||||
let server_config = app_state.config.server.clone();
|
let server_config = app_state.config.server.clone();
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/", get_authed_routes(app_state.clone()))
|
.nest("/", create_authenticated_routes(app_state.clone()))
|
||||||
.route("/", get(endpoints::index::index))
|
.route("/", get(endpoints::index::index))
|
||||||
.route("/vaults/:vault_id/ping", get(endpoints::ping::ping))
|
.route("/vaults/:vault_id/ping", get(endpoints::ping::ping))
|
||||||
.route(
|
.route(
|
||||||
|
|
@ -100,7 +100,7 @@ pub async fn create_server(config: Config) -> Result<()> {
|
||||||
start_server(app, &server_config).await
|
start_server(app, &server_config).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_authed_routes(app_state: AppState) -> Router<AppState> {
|
fn create_authenticated_routes(app_state: AppState) -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/vaults/:vault_id/file-manifest",
|
"/vaults/:vault_id/file-manifest",
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,18 @@ pub async fn auth_middleware(
|
||||||
.ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing vault_id")))?,
|
.ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing vault_id")))?,
|
||||||
);
|
);
|
||||||
|
|
||||||
let user = auth(&state, token, &vault_id)?;
|
let user = authenticate_user(&state, token, &vault_id)?;
|
||||||
|
|
||||||
req.extensions_mut().insert(user);
|
req.extensions_mut().insert(user);
|
||||||
|
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result<User, SyncServerError> {
|
pub fn authenticate_user(
|
||||||
|
state: &AppState,
|
||||||
|
token: &str,
|
||||||
|
vault_id: &VaultId,
|
||||||
|
) -> Result<User, SyncServerError> {
|
||||||
validate_vault_id(vault_id).map_err(client_error)?;
|
validate_vault_id(vault_id).map_err(client_error)?;
|
||||||
let user = state
|
let user = state
|
||||||
.config
|
.config
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ pub async fn events(
|
||||||
|
|
||||||
state
|
state
|
||||||
.database
|
.database
|
||||||
.events_after(
|
.get_events_after(
|
||||||
&vault_id,
|
&vault_id,
|
||||||
query.after,
|
query.after,
|
||||||
query.page_size.unwrap_or(DEFAULT_EVENTS_PAGE_SIZE),
|
query.page_size.unwrap_or(DEFAULT_EVENTS_PAGE_SIZE),
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
database::models::{DocumentId, VaultUpdateId},
|
database::models::{DocumentId, VaultUpdateId},
|
||||||
},
|
},
|
||||||
errors::{SyncServerError, not_found_error, server_error},
|
errors::{SyncServerError, not_found_error},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[axum::debug_handler]
|
#[axum::debug_handler]
|
||||||
|
|
@ -30,16 +30,12 @@ pub async fn fetch_document_version_content(
|
||||||
let result = state
|
let result = state
|
||||||
.database
|
.database
|
||||||
.get_document_version(&vault_id, vault_update_id, None)
|
.get_document_version(&vault_id, vault_update_id, None)
|
||||||
.await
|
.await?
|
||||||
.map_err(server_error)?
|
.ok_or_else(|| {
|
||||||
.map_or_else(
|
not_found_error(anyhow!(
|
||||||
|| {
|
|
||||||
Err(not_found_error(anyhow!(
|
|
||||||
"Document with vault update id `{vault_update_id}` not found",
|
"Document with vault update id `{vault_update_id}` not found",
|
||||||
)))
|
))
|
||||||
},
|
})?;
|
||||||
Ok,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
if result.document_id != document_id {
|
if result.document_id != document_id {
|
||||||
return Err(not_found_error(anyhow!(
|
return Err(not_found_error(anyhow!(
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use crate::{
|
||||||
models::{DocumentId, DocumentVersionWithoutContent},
|
models::{DocumentId, DocumentVersionWithoutContent},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
errors::{SyncServerError, not_found_error, server_error},
|
errors::{SyncServerError, not_found_error},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[axum::debug_handler]
|
#[axum::debug_handler]
|
||||||
|
|
@ -27,12 +27,10 @@ pub async fn fetch_latest_document_metadata(
|
||||||
let mut tx = state
|
let mut tx = state
|
||||||
.database
|
.database
|
||||||
.create_readonly_transaction(&vault_id)
|
.create_readonly_transaction(&vault_id)
|
||||||
.await
|
.await?;
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
let metadata = Database::get_latest_document_metadata(&mut tx, &document_id)
|
let metadata = Database::get_latest_document_metadata(&mut tx, &document_id)
|
||||||
.await
|
.await?
|
||||||
.map_err(server_error)?
|
|
||||||
.ok_or_else(|| not_found_error(anyhow!("Document does not exist")))?;
|
.ok_or_else(|| not_found_error(anyhow!("Document does not exist")))?;
|
||||||
|
|
||||||
Ok(Json(metadata))
|
Ok(Json(metadata))
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
database::models::{DocumentId, DocumentVersion},
|
database::models::{DocumentId, DocumentVersion},
|
||||||
},
|
},
|
||||||
errors::{SyncServerError, not_found_error, server_error},
|
errors::{SyncServerError, not_found_error},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[axum::debug_handler]
|
#[axum::debug_handler]
|
||||||
|
|
@ -24,16 +24,8 @@ pub async fn fetch_latest_document_version(
|
||||||
let latest_version = state
|
let latest_version = state
|
||||||
.database
|
.database
|
||||||
.get_latest_document_version(&vault_id, &document_id, None)
|
.get_latest_document_version(&vault_id, &document_id, None)
|
||||||
.await
|
.await?
|
||||||
.map_err(server_error)?
|
.ok_or_else(|| not_found_error(anyhow!("Document with id `{document_id}` not found")))?;
|
||||||
.map_or_else(
|
|
||||||
|| {
|
|
||||||
Err(not_found_error(anyhow!(
|
|
||||||
"Document with id `{document_id}` not found",
|
|
||||||
)))
|
|
||||||
},
|
|
||||||
Ok,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(Json(latest_version.into()))
|
Ok(Json(latest_version.into()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,9 @@ pub async fn get_file_manifest(
|
||||||
let mut transaction = state
|
let mut transaction = state
|
||||||
.database
|
.database
|
||||||
.create_readonly_transaction(&vault_id)
|
.create_readonly_transaction(&vault_id)
|
||||||
.await
|
.await?;
|
||||||
.map_err(server_error)?;
|
|
||||||
|
|
||||||
Database::current_file_manifest(&mut transaction)
|
Database::get_current_file_manifest(&mut transaction)
|
||||||
.await
|
.await
|
||||||
.map(Json)
|
.map(Json)
|
||||||
.map_err(server_error)
|
.map_err(server_error)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use axum_extra::{
|
||||||
use log::debug;
|
use log::debug;
|
||||||
|
|
||||||
use super::VaultPath;
|
use super::VaultPath;
|
||||||
use crate::server::{auth::auth, responses::PingResponse};
|
use crate::server::{auth::authenticate_user, responses::PingResponse};
|
||||||
use crate::{app_state::AppState, consts::SUPPORTED_API_VERSION, errors::SyncServerError};
|
use crate::{app_state::AppState, consts::SUPPORTED_API_VERSION, errors::SyncServerError};
|
||||||
|
|
||||||
#[axum::debug_handler]
|
#[axum::debug_handler]
|
||||||
|
|
@ -20,8 +20,9 @@ pub async fn ping(
|
||||||
) -> Result<Json<PingResponse>, SyncServerError> {
|
) -> Result<Json<PingResponse>, SyncServerError> {
|
||||||
debug!("Pinging vault `{vault_id}`");
|
debug!("Pinging vault `{vault_id}`");
|
||||||
|
|
||||||
let is_authenticated = maybe_auth_header
|
let is_authenticated = maybe_auth_header.is_some_and(|auth_header| {
|
||||||
.is_some_and(|auth_header| auth(&state, auth_header.token(), &vault_id).is_ok());
|
authenticate_user(&state, auth_header.token(), &vault_id).is_ok()
|
||||||
|
});
|
||||||
|
|
||||||
Ok(Json(PingResponse {
|
Ok(Json(PingResponse {
|
||||||
server_version: env!("CARGO_PKG_VERSION").to_owned(),
|
server_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue