Add WebSocket support (#12)

This commit is contained in:
Andras Schmelczer 2025-03-29 10:17:46 +00:00 committed by GitHub
parent 3d27b7f313
commit 1aad0fce31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 2578 additions and 993 deletions

View file

@ -28,7 +28,7 @@ jobs:
cargo install sqlx-cli wasm-pack cargo install sqlx-cli wasm-pack
cd backend cd backend
sqlx database create --database-url sqlite://db.sqlite3 sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source sync_server/src/database/migrations --database-url sqlite://db.sqlite3 sqlx migrate run --source sync_server/src/app_state/database/migrations --database-url sqlite://db.sqlite3
- name: Build wasm - name: Build wasm
run: | run: |

View file

@ -28,7 +28,7 @@ jobs:
cargo install sqlx-cli wasm-pack cargo install sqlx-cli wasm-pack
cd backend cd backend
sqlx database create --database-url sqlite://db.sqlite3 sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source sync_server/src/database/migrations --database-url sqlite://db.sqlite3 sqlx migrate run --source sync_server/src/app_state/database/migrations --database-url sqlite://db.sqlite3
- name: Build wasm - name: Build wasm
run: | run: |
@ -38,7 +38,7 @@ jobs:
- name: E2E tests - name: E2E tests
run: | run: |
cd backend cd backend
RUST_BACKTRACE=1 cargo run -p sync_server config-e2e.yml & cargo run -p sync_server config-e2e.yml --color never &
cd .. cd ..
scripts/update-api-types.sh scripts/update-api-types.sh

2
.gitignore vendored
View file

@ -14,4 +14,4 @@ backend/databases
*.log *.log
plugin/coverage *.sqlx

17
backend/Cargo.lock generated
View file

@ -459,6 +459,16 @@ dependencies = [
"clap_derive", "clap_derive",
] ]
[[package]]
name = "clap-verbosity-flag"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2678fade3b77aa3a8ff3aae87e9c008d3fb00473a41c71fbf74e91c8c7b37e84"
dependencies = [
"clap",
"log",
]
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.32" version = "4.5.32"
@ -2069,9 +2079,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.133" version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@ -2524,6 +2534,8 @@ dependencies = [
"axum_typed_multipart", "axum_typed_multipart",
"chrono", "chrono",
"clap", "clap",
"clap-verbosity-flag",
"futures",
"log", "log",
"rand", "rand",
"reconcile", "reconcile",
@ -2531,6 +2543,7 @@ dependencies = [
"sanitize-filename", "sanitize-filename",
"schemars", "schemars",
"serde", "serde",
"serde_json",
"serde_yaml", "serde_yaml",
"sqlx", "sqlx",
"sync_lib", "sync_lib",

View file

@ -22,6 +22,7 @@ thiserror = { version = "1.0.66", default-features = false }
codegen-units = 1 codegen-units = 1
lto = true lto = true
opt-level = 3 opt-level = 3
strip="debuginfo" # Keep some info for better panics
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"

View file

@ -8,7 +8,7 @@ RUN cargo install sqlx-cli
COPY . . COPY . .
RUN sqlx database create --database-url sqlite://db.sqlite3 RUN sqlx database create --database-url sqlite://db.sqlite3
RUN sqlx migrate run --source sync_server/src/database/migrations --database-url sqlite://db.sqlite3 RUN sqlx migrate run --source sync_server/src/app_state/database/migrations --database-url sqlite://db.sqlite3
RUN cargo build --package sync_server --release --target x86_64-unknown-linux-musl RUN cargo build --package sync_server --release --target x86_64-unknown-linux-musl

View file

@ -1,10 +1,13 @@
database: database:
databases_directory_path: databases databases_directory_path: databases
max_connections: 12 max_connections: 12
server: server:
host: 0.0.0.0 host: 0.0.0.0
port: 3000 port: 3000
max_body_size_mb: 512 max_body_size_mb: 512
max_clients_per_vault: 256
users: users:
user_tokens: user_tokens:
- name: admin - name: admin

View file

@ -34,6 +34,9 @@ sanitize-filename = "0.6.0"
axum-jsonschema = { version = "0.8.0", features = ["aide"] } axum-jsonschema = { version = "0.8.0", features = ["aide"] }
regex = "1.11.1" regex = "1.11.1"
clap = { version = "4.5.32", features = ["derive"] } clap = { version = "4.5.32", features = ["derive"] }
futures = "0.3.31"
serde_json = "1.0.140"
clap-verbosity-flag = "3.0.2"
[lints] [lints]
workspace = true workspace = true

View file

@ -1,4 +1,4 @@
cargo install sqlx-cli cargo install sqlx-cli
rm db.sqlite3; sqlx database create --database-url sqlite://db.sqlite3 rm db.sqlite3; sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source sync_server/src/database/migrations --database-url sqlite://db.sqlite3 sqlx migrate run --source sync_server/src/app_state/database/migrations --database-url sqlite://db.sqlite3

View file

@ -1,13 +1,19 @@
pub mod broadcasts;
pub mod database;
use std::ffi::OsString; use std::ffi::OsString;
use anyhow::Result; use anyhow::Result;
use broadcasts::Broadcasts;
use database::Database;
use crate::{config::Config, consts::DEFAULT_CONFIG_PATH, database::Database}; use crate::{config::Config, consts::DEFAULT_CONFIG_PATH};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AppState { pub struct AppState {
pub config: Config, pub config: Config,
pub database: Database, pub database: Database,
pub broadcasts: Broadcasts,
} }
impl AppState { impl AppState {
@ -17,7 +23,12 @@ impl AppState {
let config = Config::read_or_create(&path).await?; let config = Config::read_or_create(&path).await?;
let database = Database::try_new(&config.database).await?; let database = Database::try_new(&config.database).await?;
let broadcasts = Broadcasts::new(&config.server);
Ok(Self { config, database }) Ok(Self {
config,
database,
broadcasts,
})
} }
} }

View file

@ -0,0 +1,57 @@
use std::{collections::HashMap, sync::Arc};
use anyhow::Context;
use tokio::sync::{Mutex, broadcast};
use super::database::models::{DocumentVersionWithoutContent, VaultId};
use crate::{config::server_config::ServerConfig, errors::server_error};
#[derive(Debug, Clone)]
pub struct Broadcasts {
max_clients_per_vault: usize,
tx: Arc<Mutex<HashMap<VaultId, broadcast::Sender<DocumentVersionWithoutContent>>>>,
}
impl Broadcasts {
pub fn new(server_config: &ServerConfig) -> Self {
Self {
max_clients_per_vault: server_config.max_clients_per_vault,
tx: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn get_receiver(
&self,
vault: VaultId,
) -> broadcast::Receiver<DocumentVersionWithoutContent> {
let tx = self.get_or_create(vault).await;
tx.subscribe()
}
/// Sent a document update to all clients subscribed to the vault.
/// We ignore & log failures.
pub async fn send(&self, vault: VaultId, document: DocumentVersionWithoutContent) {
let tx = self.get_or_create(vault).await;
let result = tx
.send(document)
.context("Cannot broadcast update message to websocket listeners")
.map_err(server_error);
if result.is_err() {
log::debug!("Failed to send message: {result:?}");
}
}
async fn get_or_create(
&self,
vault: VaultId,
) -> broadcast::Sender<DocumentVersionWithoutContent> {
let mut tx = self.tx.lock().await;
tx.entry(vault)
.or_insert_with(|| broadcast::channel(self.max_clients_per_vault).0.clone())
.clone()
}
}

View file

@ -85,13 +85,13 @@ impl Database {
} }
async fn run_migrations(pool: &Pool<Sqlite>) -> Result<()> { async fn run_migrations(pool: &Pool<Sqlite>) -> Result<()> {
sqlx::migrate!("src/database/migrations") sqlx::migrate!("src/app_state/database/migrations")
.run(pool) .run(pool)
.await .await
.context("Cannot check for pending migrations") .context("Cannot check for pending migrations")
} }
async fn get_connection_pool(&mut self, vault: &VaultId) -> Result<Pool<Sqlite>> { async fn get_connection_pool(&self, vault: &VaultId) -> Result<Pool<Sqlite>> {
let mut pools = self.connection_pools.lock().await; let mut pools = self.connection_pools.lock().await;
if !pools.contains_key(vault) { if !pools.contains_key(vault) {
let pool = Self::create_vault_database(&self.config, vault).await?; let pool = Self::create_vault_database(&self.config, vault).await?;
@ -108,7 +108,7 @@ impl Database {
/// Attempting to write from this transaction might result in a /// Attempting to write from this transaction might result in a
/// database locked error. Use this transaction for read-only operations. /// database locked error. Use this transaction for read-only operations.
pub async fn create_readonly_transaction( pub async fn create_readonly_transaction(
&mut self, &self,
vault: &VaultId, vault: &VaultId,
) -> Result<Transaction<'static>> { ) -> Result<Transaction<'static>> {
self.get_connection_pool(vault) self.get_connection_pool(vault)
@ -118,10 +118,7 @@ impl Database {
.context("Cannot create transaction") .context("Cannot create transaction")
} }
pub async fn create_write_transaction( pub async fn create_write_transaction(&self, vault: &VaultId) -> Result<Transaction<'static>> {
&mut self,
vault: &VaultId,
) -> Result<Transaction<'static>> {
let mut transaction = self.create_readonly_transaction(vault).await?; let mut transaction = self.create_readonly_transaction(vault).await?;
// sqlx doesn't support immediate transactions for sqlite: https://github.com/launchbadge/sqlx/issues/481 // sqlx doesn't support immediate transactions for sqlite: https://github.com/launchbadge/sqlx/issues/481
@ -134,7 +131,7 @@ impl Database {
/// Return the latest state of all documents in the vault /// Return the latest state of all documents in the vault
pub async fn get_latest_documents( pub async fn get_latest_documents(
&mut self, &self,
vault: &VaultId, vault: &VaultId,
transaction: Option<&mut Transaction<'_>>, transaction: Option<&mut Transaction<'_>>,
) -> Result<Vec<DocumentVersionWithoutContent>> { ) -> Result<Vec<DocumentVersionWithoutContent>> {
@ -165,7 +162,7 @@ impl Database {
/// Return the latest state of all documents (including deleted) in the /// Return the latest state of all documents (including deleted) in the
/// vault which have changed since the given update id /// vault which have changed since the given update id
pub async fn get_latest_documents_since( pub async fn get_latest_documents_since(
&mut self, &self,
vault: &VaultId, vault: &VaultId,
vault_update_id: VaultUpdateId, vault_update_id: VaultUpdateId,
transaction: Option<&mut Transaction<'_>>, transaction: Option<&mut Transaction<'_>>,
@ -199,7 +196,7 @@ impl Database {
} }
pub async fn get_max_update_id_in_vault( pub async fn get_max_update_id_in_vault(
&mut self, &self,
vault: &VaultId, vault: &VaultId,
transaction: Option<&mut Transaction<'_>>, transaction: Option<&mut Transaction<'_>>,
) -> Result<i64> { ) -> Result<i64> {
@ -222,7 +219,7 @@ impl Database {
} }
pub async fn get_latest_document_by_path( pub async fn get_latest_document_by_path(
&mut self, &self,
vault: &VaultId, vault: &VaultId,
relative_path: &str, relative_path: &str,
transaction: Option<&mut Transaction<'_>>, transaction: Option<&mut Transaction<'_>>,
@ -258,7 +255,7 @@ impl Database {
} }
pub async fn get_latest_document( pub async fn get_latest_document(
&mut self, &self,
vault: &VaultId, vault: &VaultId,
document_id: &DocumentId, document_id: &DocumentId,
transaction: Option<&mut Transaction<'_>>, transaction: Option<&mut Transaction<'_>>,
@ -291,7 +288,7 @@ impl Database {
} }
pub async fn get_document_version( pub async fn get_document_version(
&mut self, &self,
vault: &VaultId, vault: &VaultId,
vault_update_id: VaultUpdateId, vault_update_id: VaultUpdateId,
transaction: Option<&mut Transaction<'_>>, transaction: Option<&mut Transaction<'_>>,
@ -322,7 +319,7 @@ impl Database {
} }
pub async fn insert_document_version( pub async fn insert_document_version(
&mut self, &self,
vault: &VaultId, vault: &VaultId,
version: &StoredDocumentVersion, version: &StoredDocumentVersion,
transaction: Option<&mut Transaction<'_>>, transaction: Option<&mut Transaction<'_>>,

View file

@ -1 +1,2 @@
pub mod args; pub mod args;
pub mod color_when;

View file

@ -1,38 +1,26 @@
use std::ffi::OsString; use std::ffi::OsString;
use clap::{Parser, ValueEnum}; use clap::Parser;
use clap_verbosity_flag::{InfoLevel, Verbosity};
/// Server for backing the VaultLink plugin use crate::cli::color_when::ColorWhen;
/// Server for backing the `VaultLink` plugin
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
pub struct Args { pub struct Args {
#[arg(index = 1)] #[arg(index = 1)]
pub config_path: Option<OsString>, pub config_path: Option<OsString>,
#[command(flatten)]
pub verbose: Verbosity<InfoLevel>,
#[arg( #[arg(
long, long,
require_equals = true,
value_name = "WHEN", value_name = "WHEN",
num_args = 0..=1,
default_value_t = ColorWhen::Auto, default_value_t = ColorWhen::Auto,
default_missing_value = "always", default_missing_value = "always",
value_enum value_enum
)] )]
pub color: ColorWhen, pub color: ColorWhen,
} }
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
pub enum ColorWhen {
Always,
Auto,
Never,
}
impl std::fmt::Display for ColorWhen {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.to_possible_value()
.expect("no values are skipped")
.get_name()
.fmt(f)
}
}

View file

@ -0,0 +1,31 @@
use std::io::IsTerminal;
use clap::ValueEnum;
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
pub enum ColorWhen {
Always,
Auto,
Never,
}
impl ColorWhen {
pub fn use_colors(self) -> bool {
match self {
ColorWhen::Always => true,
ColorWhen::Auto => {
std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
}
ColorWhen::Never => false,
}
}
}
impl std::fmt::Display for ColorWhen {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.to_possible_value()
.expect("no values are skipped")
.get_name()
.fmt(f)
}
}

View file

@ -1,7 +1,10 @@
use log::debug; use log::debug;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::consts::{DEFAULT_HOST, DEFAULT_MAX_BODY_SIZE_MB, DEFAULT_PORT}; use crate::consts::{
DEFAULT_HOST, DEFAULT_MAX_BODY_SIZE_MB, DEFAULT_MAX_CLIENTS_PER_VAULT, DEFAULT_PORT,
};
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ServerConfig { pub struct ServerConfig {
#[serde(default = "default_host")] #[serde(default = "default_host")]
@ -12,6 +15,9 @@ pub struct ServerConfig {
#[serde(default = "default_max_body_size_mb")] #[serde(default = "default_max_body_size_mb")]
pub max_body_size_mb: usize, pub max_body_size_mb: usize,
#[serde(default = "default_max_clients_per_vault")]
pub max_clients_per_vault: usize,
} }
fn default_host() -> String { fn default_host() -> String {
@ -29,12 +35,18 @@ fn default_max_body_size_mb() -> usize {
DEFAULT_MAX_BODY_SIZE_MB DEFAULT_MAX_BODY_SIZE_MB
} }
fn default_max_clients_per_vault() -> usize {
debug!("Using default max clients per vault: {DEFAULT_MAX_CLIENTS_PER_VAULT}");
DEFAULT_MAX_CLIENTS_PER_VAULT
}
impl Default for ServerConfig { impl Default for ServerConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
host: default_host(), host: default_host(),
port: default_port(), port: default_port(),
max_body_size_mb: default_max_body_size_mb(), max_body_size_mb: default_max_body_size_mb(),
max_clients_per_vault: default_max_clients_per_vault(),
} }
} }
} }

View file

@ -4,3 +4,4 @@ pub const DEFAULT_HOST: &str = "127.0.0.1";
pub const DEFAULT_PORT: u16 = 3000; pub const DEFAULT_PORT: u16 = 3000;
pub const DEFAULT_MAX_CONNECTIONS: u32 = 12; pub const DEFAULT_MAX_CONNECTIONS: u32 = 12;
pub const DEFAULT_MAX_BODY_SIZE_MB: usize = 4096; pub const DEFAULT_MAX_BODY_SIZE_MB: usize = 4096;
pub const DEFAULT_MAX_CLIENTS_PER_VAULT: usize = 256;

View file

@ -1,38 +1,79 @@
mod app_state;
mod cli; mod cli;
mod config; mod config;
mod consts; mod consts;
mod database;
mod errors; mod errors;
mod server; mod server;
mod utils; mod utils;
use std::process::ExitCode;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use clap::Parser; use clap::Parser;
use cli::args::Args; use cli::args::Args;
use errors::{SyncServerError, init_error}; use errors::{SyncServerError, init_error};
use log::info; use log::info;
use server::create_server; use server::create_server;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{EnvFilter, fmt::format, util::SubscriberInitExt};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), SyncServerError> { async fn main() -> ExitCode {
let args = Args::parse(); let args = Args::parse();
tracing_subscriber::registry() let mut result = set_up_logging(&args);
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { if result.is_ok() {
format!( result = start_server(args).await;
"{}=debug,tower_http=debug,axum::rejection=trace", }
env!("CARGO_CRATE_NAME")
) match result {
.into() Ok(()) => ExitCode::SUCCESS,
}), Err(e) => {
eprintln!("Failed to set up logging: {e}");
ExitCode::FAILURE
}
}
}
fn set_up_logging(args: &Args) -> Result<(), SyncServerError> {
let level_filter = match args.verbose.log_level_filter() {
// We don't want to allow disabling all logging
log::LevelFilter::Off | log::LevelFilter::Error => tracing::Level::ERROR,
log::LevelFilter::Warn => tracing::Level::WARN,
log::LevelFilter::Info => tracing::Level::INFO,
log::LevelFilter::Debug => tracing::Level::DEBUG,
log::LevelFilter::Trace => tracing::Level::TRACE,
};
let env_filter = EnvFilter::builder()
.with_default_directive(level_filter.into())
.from_env()
.context("Failed to create logging env filter")
.map_err(init_error)?;
let use_colors = args.color.use_colors();
let is_debug_mode = args.verbose.log_level_filter() >= log::LevelFilter::Debug;
tracing_subscriber::fmt()
.with_ansi(use_colors)
.with_env_filter(env_filter)
.event_format(
format()
.without_time()
.with_target(is_debug_mode)
.with_line_number(is_debug_mode)
.compact(),
) )
.with(tracing_subscriber::fmt::layer()) .finish()
.try_init() .try_init()
.context("Failed to initialise tracing") .context("Failed to initialise tracing")
.map_err(init_error)?; .map_err(init_error)?;
Ok(())
}
async fn start_server(args: Args) -> Result<(), SyncServerError> {
info!( info!(
"Starting VaultLink server version {}", "Starting VaultLink server version {}",
env!("CARGO_PKG_VERSION") env!("CARGO_PKG_VERSION")

View file

@ -1,3 +1,16 @@
mod auth;
mod create_document;
mod delete_document;
mod fetch_document_version;
mod fetch_document_version_content;
mod fetch_latest_document_version;
mod fetch_latest_documents;
mod ping;
mod requests;
mod responses;
mod update_document;
mod websocket;
use std::{ffi::OsString, sync::Arc}; use std::{ffi::OsString, sync::Arc};
use aide::{ use aide::{
@ -10,7 +23,6 @@ use aide::{
transform::TransformOpenApi, transform::TransformOpenApi,
}; };
use anyhow::{Context as _, Result, anyhow}; use anyhow::{Context as _, Result, anyhow};
use app_state::AppState;
use axum::{ use axum::{
Extension, Json, Extension, Json,
extract::{DefaultBodyLimit, Request}, extract::{DefaultBodyLimit, Request},
@ -32,21 +44,10 @@ use tower_http::{
use tracing::{Level, info_span}; use tracing::{Level, info_span};
use crate::{ use crate::{
app_state::AppState,
config::server_config::ServerConfig, config::server_config::ServerConfig,
errors::{SerializedError, not_found_error}, errors::{SerializedError, not_found_error},
}; };
mod app_state;
mod auth;
mod create_document;
mod delete_document;
mod fetch_document_version;
mod fetch_document_version_content;
mod fetch_latest_document_version;
mod fetch_latest_documents;
mod ping;
mod requests;
mod responses;
mod update_document;
pub async fn create_server(config_path: Option<OsString>) -> Result<()> { pub async fn create_server(config_path: Option<OsString>) -> Result<()> {
aide::r#gen::on_error(|err| error!("{err}")); aide::r#gen::on_error(|err| error!("{err}"));
@ -65,6 +66,7 @@ pub async fn create_server(config_path: Option<OsString>) -> Result<()> {
"/vaults/:vault_id/documents", "/vaults/:vault_id/documents",
get(fetch_latest_documents::fetch_latest_documents), get(fetch_latest_documents::fetch_latest_documents),
) )
.route("/vaults/:vault_id/ws", get(websocket::websocket_handler))
.api_route( .api_route(
"/vaults/:vault_id/documents", "/vaults/:vault_id/documents",
post(create_document::create_document_multipart), post(create_document::create_document_multipart),

View file

@ -1,9 +1,10 @@
use super::app_state::AppState;
use crate::{ use crate::{
app_state::AppState,
config::user_config::User, config::user_config::User,
errors::{SyncServerError, unauthorized_error}, errors::{SyncServerError, unauthorized_error},
}; };
// TODO: turn this into a middleware
pub fn auth(app_state: &AppState, token: &str) -> Result<User, SyncServerError> { pub fn auth(app_state: &AppState, token: &str) -> Result<User, SyncServerError> {
app_state app_state
.config .config

View file

@ -11,12 +11,16 @@ use serde::Deserialize;
use sync_lib::base64_to_bytes; use sync_lib::base64_to_bytes;
use super::{ use super::{
app_state::AppState,
auth::auth, auth::auth,
requests::{CreateDocumentVersion, CreateDocumentVersionMultipart}, requests::{CreateDocumentVersion, CreateDocumentVersionMultipart},
}; };
use crate::{ use crate::{
database::models::{DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}, app_state::{
AppState,
database::models::{
DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId,
},
},
errors::{SyncServerError, client_error, server_error}, errors::{SyncServerError, client_error, server_error},
utils::sanitize_path, utils::sanitize_path,
}; };
@ -77,7 +81,7 @@ pub async fn create_document_json(
async fn internal_create_document( async fn internal_create_document(
auth_header: Authorization<Bearer>, auth_header: Authorization<Bearer>,
mut state: AppState, state: AppState,
vault_id: VaultId, vault_id: VaultId,
document_id: Option<DocumentId>, document_id: Option<DocumentId>,
relative_path: String, relative_path: String,
@ -139,5 +143,10 @@ async fn internal_create_document(
.context("Failed to commit successful transaction") .context("Failed to commit successful transaction")
.map_err(server_error)?; .map_err(server_error)?;
state
.broadcasts
.send(vault_id, new_version.clone().into())
.await;
Ok(Json(new_version.into())) Ok(Json(new_version.into()))
} }

View file

@ -8,9 +8,14 @@ use axum_jsonschema::Json;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use super::{app_state::AppState, auth::auth, requests::DeleteDocumentVersion}; use super::{auth::auth, requests::DeleteDocumentVersion};
use crate::{ use crate::{
database::models::{DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}, app_state::{
AppState,
database::models::{
DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId,
},
},
errors::{SyncServerError, server_error}, errors::{SyncServerError, server_error},
utils::sanitize_path, utils::sanitize_path,
}; };
@ -29,7 +34,7 @@ pub async fn delete_document(
vault_id, vault_id,
document_id, document_id,
}): Path<DeleteDocumentPathParams>, }): Path<DeleteDocumentPathParams>,
State(mut state): State<AppState>, State(state): State<AppState>,
Json(request): Json<DeleteDocumentVersion>, Json(request): Json<DeleteDocumentVersion>,
) -> Result<Json<DocumentVersionWithoutContent>, SyncServerError> { ) -> Result<Json<DocumentVersionWithoutContent>, SyncServerError> {
auth(&state, auth_header.token())?; auth(&state, auth_header.token())?;
@ -67,5 +72,10 @@ pub async fn delete_document(
.context("Failed to commit successful transaction") .context("Failed to commit successful transaction")
.map_err(server_error)?; .map_err(server_error)?;
state
.broadcasts
.send(vault_id, new_version.clone().into())
.await;
Ok(Json(new_version.into())) Ok(Json(new_version.into()))
} }

View file

@ -8,9 +8,12 @@ use axum_jsonschema::Json;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use super::{app_state::AppState, auth::auth}; use super::auth::auth;
use crate::{ use crate::{
database::models::{DocumentId, DocumentVersion, VaultId, VaultUpdateId}, app_state::{
AppState,
database::models::{DocumentId, DocumentVersion, VaultId, VaultUpdateId},
},
errors::{SyncServerError, not_found_error, server_error}, errors::{SyncServerError, not_found_error, server_error},
}; };
@ -30,7 +33,7 @@ pub async fn fetch_document_version(
document_id, document_id,
vault_update_id, vault_update_id,
}): Path<FetchDocumentVersionPathParams>, }): Path<FetchDocumentVersionPathParams>,
State(mut state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<DocumentVersion>, SyncServerError> { ) -> Result<Json<DocumentVersion>, SyncServerError> {
auth(&state, auth_header.token())?; auth(&state, auth_header.token())?;

View file

@ -10,9 +10,12 @@ use axum_extra::{
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use super::{app_state::AppState, auth::auth}; use super::auth::auth;
use crate::{ use crate::{
database::models::{DocumentId, VaultId, VaultUpdateId}, app_state::{
AppState,
database::models::{DocumentId, VaultId, VaultUpdateId},
},
errors::{SyncServerError, not_found_error, server_error}, errors::{SyncServerError, not_found_error, server_error},
}; };
@ -32,7 +35,7 @@ pub async fn fetch_document_version_content(
document_id, document_id,
vault_update_id, vault_update_id,
}): Path<FetchDocumentVersionContentPathParams>, }): Path<FetchDocumentVersionContentPathParams>,
State(mut state): State<AppState>, State(state): State<AppState>,
) -> Result<Bytes, SyncServerError> { ) -> Result<Bytes, SyncServerError> {
auth(&state, auth_header.token())?; auth(&state, auth_header.token())?;

View file

@ -8,9 +8,12 @@ use axum_jsonschema::Json;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use super::{app_state::AppState, auth::auth}; use super::auth::auth;
use crate::{ use crate::{
database::models::{DocumentId, DocumentVersion, VaultId}, app_state::{
AppState,
database::models::{DocumentId, DocumentVersion, VaultId},
},
errors::{SyncServerError, not_found_error, server_error}, errors::{SyncServerError, not_found_error, server_error},
}; };
@ -28,7 +31,7 @@ pub async fn fetch_latest_document_version(
vault_id, vault_id,
document_id, document_id,
}): Path<FetchLatestDocumentVersionPathParams>, }): Path<FetchLatestDocumentVersionPathParams>,
State(mut state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<DocumentVersion>, SyncServerError> { ) -> Result<Json<DocumentVersion>, SyncServerError> {
auth(&state, auth_header.token())?; auth(&state, auth_header.token())?;

View file

@ -7,9 +7,12 @@ use axum_jsonschema::Json;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use super::{app_state::AppState, auth::auth, responses::FetchLatestDocumentsResponse}; use super::{auth::auth, responses::FetchLatestDocumentsResponse};
use crate::{ use crate::{
database::models::{VaultId, VaultUpdateId}, app_state::{
AppState,
database::models::{VaultId, VaultUpdateId},
},
errors::{SyncServerError, server_error}, errors::{SyncServerError, server_error},
}; };
@ -30,7 +33,7 @@ pub async fn fetch_latest_documents(
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>, TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Path(FetchLatestDocumentsPathParams { vault_id }): Path<FetchLatestDocumentsPathParams>, Path(FetchLatestDocumentsPathParams { vault_id }): Path<FetchLatestDocumentsPathParams>,
Query(QueryParams { since_update_id }): Query<QueryParams>, Query(QueryParams { since_update_id }): Query<QueryParams>,
State(mut state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<FetchLatestDocumentsResponse>, SyncServerError> { ) -> Result<Json<FetchLatestDocumentsResponse>, SyncServerError> {
auth(&state, auth_header.token())?; auth(&state, auth_header.token())?;

View file

@ -4,8 +4,8 @@ use axum_extra::{
headers::{Authorization, authorization::Bearer}, headers::{Authorization, authorization::Bearer},
}; };
use super::{app_state::AppState, auth::auth, responses::PingResponse}; use super::{auth::auth, responses::PingResponse};
use crate::errors::SyncServerError; use crate::{app_state::AppState, errors::SyncServerError};
#[axum::debug_handler] #[axum::debug_handler]
pub async fn ping( pub async fn ping(

View file

@ -4,7 +4,7 @@ use axum_typed_multipart::TryFromMultipart;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{self, Deserialize}; use serde::{self, Deserialize};
use crate::database::models::{DocumentId, VaultUpdateId}; use crate::app_state::database::models::{DocumentId, VaultUpdateId};
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View file

@ -1,7 +1,9 @@
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{self, Serialize}; use serde::{self, Serialize};
use crate::database::models::{DocumentVersion, DocumentVersionWithoutContent, VaultUpdateId}; use crate::app_state::database::models::{
DocumentVersion, DocumentVersionWithoutContent, VaultUpdateId,
};
/// Response to a ping request. /// Response to a ping request.
#[derive(Debug, Clone, Serialize, JsonSchema)] #[derive(Debug, Clone, Serialize, JsonSchema)]

View file

@ -12,13 +12,15 @@ use serde::Deserialize;
use sync_lib::{base64_to_bytes, is_file_type_mergable, merge}; use sync_lib::{base64_to_bytes, is_file_type_mergable, merge};
use super::{ use super::{
app_state::AppState,
auth::auth, auth::auth,
requests::{UpdateDocumentVersion, UpdateDocumentVersionMultipart}, requests::{UpdateDocumentVersion, UpdateDocumentVersionMultipart},
responses::DocumentUpdateResponse, responses::DocumentUpdateResponse,
}; };
use crate::{ use crate::{
database::models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, app_state::{
AppState,
database::models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId},
},
errors::{SyncServerError, client_error, not_found_error, server_error}, errors::{SyncServerError, client_error, not_found_error, server_error},
utils::{deduped_file_paths, sanitize_path}, utils::{deduped_file_paths, sanitize_path},
}; };
@ -83,7 +85,7 @@ pub async fn update_document_json(
#[allow(clippy::too_many_arguments, clippy::too_many_lines)] #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
async fn internal_update_document( async fn internal_update_document(
auth_header: Authorization<Bearer>, auth_header: Authorization<Bearer>,
mut state: AppState, state: AppState,
vault_id: VaultId, vault_id: VaultId,
document_id: DocumentId, document_id: DocumentId,
parent_version_id: VaultUpdateId, parent_version_id: VaultUpdateId,
@ -216,6 +218,11 @@ async fn internal_update_document(
.context("Failed to commit successful transaction") .context("Failed to commit successful transaction")
.map_err(server_error)?; .map_err(server_error)?;
state
.broadcasts
.send(vault_id, new_version.clone().into())
.await;
Ok(Json(if is_different_from_request_content { Ok(Json(if is_different_from_request_content {
DocumentUpdateResponse::MergingUpdate(new_version.into()) DocumentUpdateResponse::MergingUpdate(new_version.into())
} else { } else {

View file

@ -0,0 +1,147 @@
use anyhow::Context;
use axum::{
extract::{
Path, Query, State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::Response,
};
use futures::{
sink::SinkExt,
stream::{SplitSink, StreamExt},
};
use log::{error, info, warn};
use schemars::JsonSchema;
use serde::Deserialize;
use super::auth::auth;
use crate::{
app_state::{
AppState,
database::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId},
},
errors::{SyncServerError, server_error, unauthorized_error},
};
// This is required for aide to infer the path parameter types and names
#[derive(Deserialize, JsonSchema)]
pub struct WebsocketPathParams {
vault_id: VaultId,
}
// This is required for aide to infer the path parameter types and names
#[derive(Deserialize, JsonSchema)]
pub struct QueryParams {
since_update_id: Option<VaultUpdateId>,
}
pub async fn websocket_handler(
ws: WebSocketUpgrade,
Path(WebsocketPathParams { vault_id }): Path<WebsocketPathParams>,
Query(QueryParams { since_update_id }): Query<QueryParams>,
State(state): State<AppState>,
) -> Result<Response, SyncServerError> {
Ok(ws.on_upgrade(move |socket| websocket_wrapped(state, socket, vault_id, since_update_id)))
}
async fn websocket_wrapped(
state: AppState,
stream: WebSocket,
vault_id: VaultId,
since_update_id: Option<VaultUpdateId>,
) {
info!("Websocket connection opened on vault '{}'", vault_id);
let result = websocket(state, stream, vault_id.clone(), since_update_id).await;
if let Err(err) = result {
error!(
"Websocket connection error on vault '{}': {}",
vault_id, err
);
}
warn!("Websocket connection closed on vault '{}'", vault_id);
}
async fn websocket(
state: AppState,
stream: WebSocket,
vault_id: VaultId,
since_update_id: Option<VaultUpdateId>,
) -> Result<(), SyncServerError> {
let (mut sender, mut receiver) = stream.split();
if let Some(Ok(Message::Text(token))) = receiver.next().await {
auth(&state, &token)?;
} else {
return Err(unauthorized_error(anyhow::anyhow!(
"Failed to authenticate"
)));
}
let mut rx = state.broadcasts.get_receiver(vault_id.clone()).await;
let documents = if let Some(since_update_id) = since_update_id {
state
.database
.get_latest_documents_since(&vault_id, since_update_id, None)
.await
.map_err(server_error)
} else {
state
.database
.get_latest_documents(&vault_id, None)
.await
.map_err(server_error)
}?;
for document in documents {
send_document_over_websocket(document, &mut sender).await?;
}
let mut send_task = tokio::spawn(async move {
while let Ok(update) = rx.recv().await {
send_document_over_websocket(update, &mut sender).await?;
}
Ok::<(), SyncServerError>(())
});
let mut recv_task =
tokio::spawn(
async move { while let Some(Ok(Message::Text(_text))) = receiver.next().await {} },
);
tokio::select! {
_ = &mut send_task => recv_task.abort(),
_ = &mut recv_task => send_task.abort(),
};
send_task
.await
.context("Websocket send task failed")
.map_err(server_error)??;
recv_task
.await
.context("Websocket receive task failed")
.map_err(server_error)?;
Ok(())
}
async fn send_document_over_websocket(
document: DocumentVersionWithoutContent,
sender: &mut SplitSink<WebSocket, Message>,
) -> Result<(), SyncServerError> {
let serialized_update = serde_json::to_string(&document)
.context("Failed to serialize update")
.map_err(server_error)?;
sender
.send(Message::Text(serialized_update))
.await
.context("Failed to send message over websocket")
.map_err(server_error)
}

View file

@ -1,58 +0,0 @@
import type { SyncClient } from "sync-client";
import type { TAbstractFile } from "obsidian";
import { TFile } from "obsidian";
export class ObsidianFileEventHandler {
public constructor(private readonly client: SyncClient) {}
public async onCreate(file: TAbstractFile): Promise<void> {
if (file instanceof TFile) {
this.client.logger.info(`File created: ${file.path}`);
await this.client.syncLocallyCreatedFile(file.path);
} else {
this.client.logger.debug(`Folder created: ${file.path}, ignored`);
}
}
public async onDelete(file: TAbstractFile): Promise<void> {
if (file instanceof TFile) {
this.client.logger.info(`File deleted: ${file.path}`);
await this.client.syncLocallyDeletedFile(file.path);
} else {
this.client.logger.debug(`Folder deleted: ${file.path}, ignored`);
}
}
public async onRename(file: TAbstractFile, oldPath: string): Promise<void> {
if (file instanceof TFile) {
this.client.logger.info(`File renamed: ${oldPath} -> ${file.path}`);
await this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: file.path
});
} else {
this.client.logger.debug(
`Folder renamed: ${oldPath} -> ${file.path}, ignored`
);
}
}
public async onModify(file: TAbstractFile): Promise<void> {
if (file instanceof TFile) {
if (file.basename.startsWith("console-log.iPhone")) {
return;
}
this.client.logger.info(`File modified: ${file.path}`);
await this.client.syncLocallyUpdatedFile({
relativePath: file.path
});
} else {
this.client.logger.debug(`Folder modified: ${file.path}, ignored`);
}
}
}

View file

@ -1,23 +1,38 @@
import type { Stat, Vault } from "obsidian"; import type { Stat, Vault, Workspace } from "obsidian";
import { normalizePath } from "obsidian"; import { MarkdownView, normalizePath } from "obsidian";
import type { FileSystemOperations, RelativePath } from "sync-client"; import type { FileSystemOperations, RelativePath } from "sync-client";
export class ObsidianFileSystemOperations implements FileSystemOperations { export class ObsidianFileSystemOperations implements FileSystemOperations {
public constructor(private readonly vault: Vault) {} public constructor(
private readonly vault: Vault,
private readonly workspace: Workspace
) {}
public async listAllFiles(): Promise<RelativePath[]> { public async listAllFiles(): Promise<RelativePath[]> {
return this.vault.getFiles().map((file) => file.path); return this.vault.getFiles().map((file) => file.path);
} }
public async read(path: RelativePath): Promise<Uint8Array> { public async read(path: RelativePath): Promise<Uint8Array> {
return new Uint8Array( path = normalizePath(path);
await this.vault.adapter.readBinary(normalizePath(path)) const view = this.workspace.getActiveViewOfType(MarkdownView);
); if (view?.file?.path === path) {
return new TextEncoder().encode(view.editor.getValue());
}
return new Uint8Array(await this.vault.adapter.readBinary(path));
} }
public async write(path: RelativePath, content: Uint8Array): Promise<void> { public async write(path: RelativePath, content: Uint8Array): Promise<void> {
path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
view.editor.setValue(new TextDecoder().decode(content));
return;
}
return this.vault.adapter.writeBinary( return this.vault.adapter.writeBinary(
normalizePath(path), path,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
content.buffer as ArrayBuffer content.buffer as ArrayBuffer
); );
@ -27,7 +42,16 @@ export class ObsidianFileSystemOperations implements FileSystemOperations {
path: RelativePath, path: RelativePath,
updater: (currentContent: string) => string updater: (currentContent: string) => string
): Promise<string> { ): Promise<string> {
return this.vault.adapter.process(normalizePath(path), updater); path = normalizePath(path);
const view = this.workspace.getActiveViewOfType(MarkdownView);
if (view?.file?.path === path) {
const result = updater(view.editor.getValue());
view.editor.setValue(result);
return result;
}
return this.vault.adapter.process(path, updater);
} }
public async getFileSize(path: RelativePath): Promise<number> { public async getFileSize(path: RelativePath): Promise<number> {

View file

@ -1,185 +0,0 @@
@mixin number-card {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
&.good {
background-color: rgba(var(--color-green-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
}
.status-description {
margin: var(--p-spacing) 0;
.number {
@include number-card;
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
}
.error {
color: rgb(var(--color-red-rgb));
}
.warning {
color: rgb(var(--color-yellow-rgb));
}
}
.vault-link-settings {
h2 {
display: flex;
align-items: center;
font-size: var(--h2-size);
.version {
@include number-card;
margin: var(--size-2-2) 0 0 var(--size-4-2);
background-color: var(--color-base-30);
color: var(--color-base-70);
font-size: var(--font-ui-smaller);
}
}
.button-container {
display: flex;
gap: var(--size-4-2);
}
h3 {
font-size: var(--font-ui-large);
margin-top: var(--heading-spacing);
}
button,
input[type="range"],
.checkbox-container,
.slider::-webkit-slider-thumb {
cursor: pointer;
}
input[type="text"],
textarea {
width: 250px;
}
textarea {
resize: none;
height: 75px;
}
}
.sync-status {
display: flex;
gap: var(--size-4-2);
* {
display: block;
}
.initialize-button {
padding: 0 var(--size-4-2);
background: rgba(var(--color-red-rgb), 0.4);
cursor: pointer;
}
}
.logs-view {
display: flex;
flex-direction: column;
.logs-container {
max-width: 100%;
overflow-y: auto;
.log-message {
font: var(--font-monospace);
margin-bottom: var(--size-2-1);
overflow-wrap: break-word;
white-space: pre-wrap;
user-select: all;
.timestamp {
@include number-card;
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
margin-right: var(--size-4-1);
}
&.DEBUG {
color: var(--color-base-50);
}
&.INFO {
color: var(--color-green-rgb);
}
&.WARNING {
color: var(--color-yellow-rgb);
}
&.ERROR {
color: var(--color-red-rgb);
}
}
}
}
.history-card {
padding: var(--size-4-4);
margin: var(--size-4-2);
background-color: var(--color-base-00);
border-radius: var(--radius-l);
container-type: inline-size;
&.clickable {
cursor: pointer;
}
&.success {
background-color: rgba(var(--color-green-rgb), 0.2);
}
&.error {
background-color: rgba(var(--color-red-rgb), 0.2);
}
.history-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size-4-2);
gap: var(--size-4-2);
@container (max-width: 300px) {
flex-direction: column;
align-items: flex-start;
}
.history-card-title {
font: var(--font-monospace);
display: flex;
align-items: center;
gap: var(--size-4-2);
word-break: break-all;
margin: 0;
}
.history-card-timestamp {
font-size: var(--font-ui-small);
font-style: italic;
color: var(--italic-color);
}
}
.history-card-message {
font-size: var(--font-ui-medium);
color: var(--color-base-70);
margin: 0;
}
}

View file

@ -1,20 +1,25 @@
import type { WorkspaceLeaf } from "obsidian"; import type {
import { Platform, Plugin } from "obsidian"; Editor,
import "./styles.scss"; MarkdownFileInfo,
MarkdownView,
TAbstractFile,
WorkspaceLeaf
} from "obsidian";
import { Platform, Plugin, TFile } from "obsidian";
import "../manifest.json"; import "../manifest.json";
import { SyncSettingsTab } from "./views/settings-tab"; import { HistoryView } from "./views/history/history-view";
import { HistoryView } from "./views/history-view"; import { StatusBar } from "./views/status-bar/status-bar";
import { ObsidianFileEventHandler } from "./obisidan-event-handler"; import { LogsView } from "./views/logs/logs-view";
import { StatusBar } from "./views/status-bar"; import { StatusDescription } from "./views/status-description/status-description";
import { LogsView } from "./views/logs-view";
import { StatusDescription } from "./views/status-description";
import type { LogLine } from "sync-client"; import type { LogLine } from "sync-client";
import { SyncClient, LogLevel } from "sync-client"; import { SyncClient, LogLevel } from "sync-client";
import { ObsidianFileSystemOperations } from "./obsidian-file-system"; import { ObsidianFileSystemOperations } from "./obsidian-file-system";
import { SyncSettingsTab } from "./views/settings/settings-tab";
export default class VaultLinkPlugin extends Plugin { export default class VaultLinkPlugin extends Plugin {
private settingsTab: SyncSettingsTab | undefined; private settingsTab: SyncSettingsTab | undefined;
private client!: SyncClient; private client!: SyncClient;
private static registerConsoleForLogging(client: SyncClient): void { private static registerConsoleForLogging(client: SyncClient): void {
client.logger.addOnMessageListener((logLine: LogLine) => { client.logger.addOnMessageListener((logLine: LogLine) => {
const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`; const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`;
@ -38,7 +43,10 @@ export default class VaultLinkPlugin extends Plugin {
public async onload(): Promise<void> { public async onload(): Promise<void> {
this.client = await SyncClient.create({ this.client = await SyncClient.create({
fs: new ObsidianFileSystemOperations(this.app.vault), fs: new ObsidianFileSystemOperations(
this.app.vault,
this.app.workspace
),
persistence: { persistence: {
load: this.loadData.bind(this), load: this.loadData.bind(this),
save: this.saveData.bind(this) save: this.saveData.bind(this)
@ -80,35 +88,9 @@ export default class VaultLinkPlugin extends Plugin {
async (_: MouseEvent) => this.activateView(LogsView.TYPE) async (_: MouseEvent) => this.activateView(LogsView.TYPE)
); );
const eventHandler = new ObsidianFileEventHandler(this.client);
this.app.workspace.onLayoutReady(async () => { this.app.workspace.onLayoutReady(async () => {
this.client.logger.info("Initialising sync handlers"); this.registerEditorEvents();
[
this.app.vault.on(
"create",
eventHandler.onCreate.bind(eventHandler)
),
this.app.vault.on(
"modify",
eventHandler.onModify.bind(eventHandler)
),
this.app.vault.on(
"delete",
eventHandler.onDelete.bind(eventHandler)
),
this.app.vault.on(
"rename",
eventHandler.onRename.bind(eventHandler)
)
].forEach((event) => {
this.registerEvent(event);
});
void this.client.start(); void this.client.start();
this.client.logger.info("Sync handlers initialised");
}); });
} }
@ -145,4 +127,51 @@ export default class VaultLinkPlugin extends Plugin {
await workspace.revealLeaf(leaf); await workspace.revealLeaf(leaf);
} }
} }
private registerEditorEvents(): void {
[
this.app.workspace.on(
"editor-change",
async (
_editor: Editor,
info: MarkdownView | MarkdownFileInfo
) => {
const { file } = info;
if (file) {
await this.client.syncLocallyUpdatedFile({
relativePath: file.path
});
}
}
),
this.app.vault.on("create", async (file: TAbstractFile) => {
if (file instanceof TFile) {
await this.client.syncLocallyCreatedFile(file.path);
}
}),
this.app.vault.on("modify", async (file: TAbstractFile) => {
if (file instanceof TFile) {
await this.client.syncLocallyUpdatedFile({
relativePath: file.path
});
}
}),
this.app.vault.on("delete", async (file: TAbstractFile) => {
await this.client.syncLocallyDeletedFile(file.path);
}),
this.app.vault.on(
"rename",
async (file: TAbstractFile, oldPath: string) => {
if (file instanceof TFile) {
await this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: file.path
});
}
}
)
].forEach((event) => {
this.registerEvent(event);
});
}
} }

View file

@ -0,0 +1,53 @@
.history-card {
padding: var(--size-4-4);
margin: var(--size-4-2);
background-color: var(--color-base-00);
border-radius: var(--radius-l);
container-type: inline-size;
&.clickable {
cursor: pointer;
}
&.success {
background-color: rgba(var(--color-green-rgb), 0.2);
}
&.error {
background-color: rgba(var(--color-red-rgb), 0.2);
}
.history-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size-4-2);
gap: var(--size-4-2);
@container (max-width: 300px) {
flex-direction: column;
align-items: flex-start;
}
.history-card-title {
font: var(--font-monospace);
display: flex;
align-items: center;
gap: var(--size-4-2);
word-break: break-all;
margin: 0;
}
.history-card-timestamp {
font-size: var(--font-ui-small);
font-style: italic;
color: var(--italic-color);
}
}
.history-card-message {
font-size: var(--font-ui-medium);
color: var(--color-base-70);
margin: 0;
}
}

View file

@ -1,6 +1,7 @@
import "./history-view.scss";
import type { IconName, WorkspaceLeaf } from "obsidian"; import type { IconName, WorkspaceLeaf } from "obsidian";
import { ItemView, setIcon } from "obsidian"; import { ItemView, setIcon } from "obsidian";
import { intlFormatDistance } from "date-fns"; import { intlFormatDistance } from "date-fns";
import type { HistoryEntry, SyncClient } from "sync-client"; import type { HistoryEntry, SyncClient } from "sync-client";
import { SyncType } from "sync-client"; import { SyncType } from "sync-client";

View file

@ -0,0 +1,60 @@
.logs-view {
display: flex;
flex-direction: column;
.verbosity-selector {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: normal;
gap: var(--size-4-2);
margin: var(--size-4-4) var(--size-4-2);
h4 {
margin: 0;
}
select {
cursor: pointer;
}
}
.logs-container {
max-width: 100%;
overflow-y: auto;
.log-message {
font: var(--font-monospace);
margin-bottom: var(--size-2-1);
overflow-wrap: break-word;
white-space: pre-wrap;
user-select: all;
.timestamp {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
margin-right: var(--size-4-1);
}
&.DEBUG {
color: var(--color-base-50);
}
&.INFO {
color: var(--color-base-100);
}
&.WARNING {
color: rgb(var(--color-yellow-rgb));
}
&.ERROR {
color: rgb(var(--color-red-rgb));
}
}
}
}

View file

@ -1,3 +1,5 @@
import "./logs-view.scss";
import type { WorkspaceLeaf } from "obsidian"; import type { WorkspaceLeaf } from "obsidian";
import { ItemView } from "obsidian"; import { ItemView } from "obsidian";
import type { LogLine } from "sync-client"; import type { LogLine } from "sync-client";
@ -7,8 +9,11 @@ export class LogsView extends ItemView {
public static readonly TYPE = "logs-view"; public static readonly TYPE = "logs-view";
public static readonly ICON = "logs"; public static readonly ICON = "logs";
private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300;
private logsContainer: HTMLElement | undefined; private logsContainer: HTMLElement | undefined;
private readonly logLineToElement = new Map<LogLine, HTMLElement>(); private readonly logLineToElement = new Map<LogLine, HTMLElement>();
private minLogLevel: LogLevel = LogLevel.INFO;
public constructor( public constructor(
private readonly client: SyncClient, private readonly client: SyncClient,
@ -56,10 +61,43 @@ export class LogsView extends ItemView {
public async onOpen(): Promise<void> { public async onOpen(): Promise<void> {
const container = this.containerEl.children[1]; const container = this.containerEl.children[1];
container.addClass("logs-view"); container.addClass("logs-view");
container.createEl("h4", { text: "VaultLink logs" });
this.logsContainer = container.createDiv({ cls: "logs-container" });
this.updateView(); const logLevels = [
{ label: "Debug", value: LogLevel.DEBUG },
{ label: "Info", value: LogLevel.INFO },
{ label: "Warn", value: LogLevel.WARNING },
{ label: "Error", value: LogLevel.ERROR }
];
container.createDiv(
{
cls: "verbosity-selector"
},
(verbositySection) => {
verbositySection.createEl("h4", {
text: "VaultLink logs"
});
verbositySection.createEl("select", {}, (dropdown) => {
logLevels.forEach(({ label, value }) =>
dropdown.createEl("option", { text: label, value })
);
dropdown.value = this.minLogLevel;
dropdown.addEventListener("change", () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.minLogLevel = dropdown.value as LogLevel;
this.logsContainer?.empty();
this.logLineToElement.clear();
this.updateView();
});
});
}
);
this.logsContainer = container.createDiv({ cls: "logs-container" });
} }
private updateView(): void { private updateView(): void {
@ -68,13 +106,20 @@ export class LogsView extends ItemView {
return; return;
} }
const logs = this.client.logger.getMessages(LogLevel.DEBUG); const logs = this.client.logger.getMessages(this.minLogLevel);
if (this.logLineToElement.size === 0 && logs.length > 0) { if (this.logLineToElement.size === 0 && logs.length > 0) {
// Clear the "No logs available yet" message // Clear the "No logs available yet" message
container.empty(); container.empty();
} }
const shouldScroll =
container.scrollTop == 0 ||
container.scrollHeight -
container.clientHeight -
container.scrollTop <
LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX;
logs.forEach((message) => { logs.forEach((message) => {
if (this.logLineToElement.has(message)) { if (this.logLineToElement.has(message)) {
return; return;
@ -98,6 +143,8 @@ export class LogsView extends ItemView {
container.createEl("p", { container.createEl("p", {
text: "No logs available yet." text: "No logs available yet."
}); });
} else if (shouldScroll) {
container.scrollTop = container.scrollHeight;
} }
} }
} }

View file

@ -0,0 +1,57 @@
@mixin number-card {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
&.good {
background-color: rgba(var(--color-green-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
}
.vault-link-settings {
h2 {
display: flex;
align-items: center;
font-size: var(--h2-size);
.version {
@include number-card;
margin: var(--size-2-2) 0 0 var(--size-4-2);
background-color: var(--color-base-30);
color: var(--color-base-70);
font-size: var(--font-ui-smaller);
}
}
.button-container {
display: flex;
gap: var(--size-4-2);
}
h3 {
font-size: var(--font-ui-large);
margin-top: var(--heading-spacing);
}
button,
input[type="range"],
.checkbox-container,
.slider::-webkit-slider-thumb {
cursor: pointer;
}
input[type="text"],
textarea {
width: 250px;
}
textarea {
resize: none;
height: 75px;
}
}

View file

@ -1,10 +1,12 @@
import "./settings-tab.scss";
import type { App } from "obsidian"; import type { App } from "obsidian";
import { Notice, PluginSettingTab, Setting } from "obsidian"; import { Notice, PluginSettingTab, Setting } from "obsidian";
import type VaultLinkPlugin from "../vault-link-plugin"; import type VaultLinkPlugin from "src/vault-link-plugin";
import type { StatusDescription } from "./status-description";
import { LogsView } from "./logs-view";
import { HistoryView } from "./history-view";
import type { SyncClient, SyncSettings } from "sync-client"; import type { SyncClient, SyncSettings } from "sync-client";
import { HistoryView } from "../history/history-view";
import { LogsView } from "../logs/logs-view";
import type { StatusDescription } from "../status-description/status-description";
export class SyncSettingsTab extends PluginSettingTab { export class SyncSettingsTab extends PluginSettingTab {
private editedServerUri: string; private editedServerUri: string;
@ -220,7 +222,7 @@ export class SyncSettingsTab extends PluginSettingTab {
.addButton((button) => .addButton((button) =>
button.setButtonText("Test connection").onClick(async () => { button.setButtonText("Test connection").onClick(async () => {
new Notice( new Notice(
(await this.syncClient.checkConnection()).message (await this.syncClient.checkConnection()).serverMessage
); );
await this.statusDescription.updateConnectionState(); await this.statusDescription.updateConnectionState();
}) })
@ -246,29 +248,6 @@ export class SyncSettingsTab extends PluginSettingTab {
) )
); );
new Setting(containerEl)
.setName("Remote fetching frequency (seconds)")
.setDesc(
"Set how often should the plugin check for changes on the server. Lower values will increase the frequency of the checks making it easier to collaborate with others."
)
.setTooltip("todo, links to docs")
.addSlider((text) =>
text
.setLimits(0.5, 60, 0.5)
.setDynamicTooltip()
.setInstant(false)
.setValue(
this.syncClient.getSettings()
.fetchChangesUpdateIntervalMs / 1000
)
.onChange(async (value) =>
this.syncClient.setSetting(
"fetchChangesUpdateIntervalMs",
value * 1000
)
)
);
new Setting(containerEl) new Setting(containerEl)
.setName("Sync concurrency") .setName("Sync concurrency")
.setDesc( .setDesc(

View file

@ -0,0 +1,14 @@
.sync-status {
display: flex;
gap: var(--size-4-2);
* {
display: block;
}
.initialize-button {
padding: 0 var(--size-4-2);
background: rgba(var(--color-red-rgb), 0.4);
cursor: pointer;
}
}

View file

@ -1,5 +1,7 @@
import "./status-bar.scss";
import type { HistoryStats, SyncClient } from "sync-client"; import type { HistoryStats, SyncClient } from "sync-client";
import type VaultLinkPlugin from "../vault-link-plugin"; import type VaultLinkPlugin from "../../vault-link-plugin";
export class StatusBar { export class StatusBar {
private readonly statusBarItem: HTMLElement; private readonly statusBarItem: HTMLElement;

View file

@ -0,0 +1,32 @@
@mixin number-card {
padding: var(--size-2-1) var(--size-4-1);
border-radius: var(--radius-s);
background-color: var(--color-base-30);
font-size: var(--font-ui-small);
&.good {
background-color: rgba(var(--color-green-rgb), 0.35);
}
&.bad {
background-color: rgba(var(--color-red-rgb), 0.35);
}
}
.status-description {
margin: var(--p-spacing) 0;
.number {
@include number-card;
font-family: var(--font-monospace);
font-weight: var(--bold-weight);
}
.error {
color: rgb(var(--color-red-rgb));
}
.warning {
color: rgb(var(--color-yellow-rgb));
}
}

View file

@ -1,13 +1,15 @@
import "./status-description.scss";
import type { import type {
HistoryStats, HistoryStats,
CheckConnectionResult, NetworkConnectionStatus,
SyncClient SyncClient
} from "sync-client"; } from "sync-client";
export class StatusDescription { export class StatusDescription {
private lastHistoryStats: HistoryStats | undefined; private lastHistoryStats: HistoryStats | undefined;
private lastRemaining: number | undefined; private lastRemaining: number | undefined;
private lastConnectionState: CheckConnectionResult | undefined; private lastConnectionState: NetworkConnectionStatus | undefined;
private statusChangeListeners: (() => void)[] = []; private statusChangeListeners: (() => void)[] = [];
@ -26,9 +28,13 @@ export class StatusDescription {
} }
); );
this.syncClient.addOnSettingsChangeListener(() => { this.syncClient.addWebSocketStatusChangeListener(
void this.updateConnectionState(); () => void this.updateConnectionState()
}); );
this.syncClient.addOnSettingsChangeListener(
() => void this.updateConnectionState()
);
} }
public async updateConnectionState(): Promise<void> { public async updateConnectionState(): Promise<void> {
@ -59,7 +65,15 @@ export class StatusDescription {
if (!this.lastConnectionState.isSuccessful) { if (!this.lastConnectionState.isSuccessful) {
container.createSpan({ container.createSpan({
text: `VaultLink failed to connect to the remote server with the error "${this.lastConnectionState.message}"`, text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`,
cls: "error"
});
return;
}
if (!this.lastConnectionState.isWebSocketConnected) {
container.createSpan({
text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`,
cls: "error" cls: "error"
}); });
return; return;

View file

@ -6,7 +6,12 @@
"strict": true, "strict": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"lib": ["DOM", "ESNext"] "lib": [
"DOM",
"ESNext"
]
}, },
"exclude": ["./dist"] "exclude": [
} "./dist"
]
}

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,7 @@
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"jest": "^29.7.0", "jest": "^29.7.0",
"sync_lib": "file:../../backend/sync_lib/pkg",
"ts-jest": "^29.2.6", "ts-jest": "^29.2.6",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"tslib": "2.8.1", "tslib": "2.8.1",
@ -30,6 +31,6 @@
"webpack": "^5.98.0", "webpack": "^5.98.0",
"webpack-cli": "^6.0.1", "webpack-cli": "^6.0.1",
"webpack-merge": "^6.0.1", "webpack-merge": "^6.0.1",
"sync_lib": "file:../../backend/sync_lib/pkg" "ws": "^8.18.1"
} }
} }

View file

@ -1,65 +0,0 @@
import type { Logger } from "../tracing/logger";
import type { RelativePath } from "../persistence/database";
// Manages locks on documents to prevent concurrent modifications
// allowing the client's FileOperations implementation to be simpler.
// Locks are granted in a first-in-first-out order.
export class DocumentLocks {
private readonly locked = new Set<RelativePath>();
private readonly waiters = new Map<RelativePath, (() => void)[]>();
public constructor(private readonly logger: Logger) {}
public tryLockDocument(relativePath: RelativePath): boolean {
if (this.locked.has(relativePath)) {
return false;
}
this.locked.add(relativePath);
return true;
}
public async waitForDocumentLock(
relativePath: RelativePath
): Promise<void> {
if (this.tryLockDocument(relativePath)) {
return Promise.resolve();
}
this.logger.debug(`Waiting for lock on ${relativePath}`);
return new Promise((resolve) => {
let waiting = this.waiters.get(relativePath);
if (!waiting) {
waiting = [];
this.waiters.set(relativePath, waiting);
}
waiting.push(resolve);
});
}
public unlockDocument(relativePath: RelativePath): void {
if (!this.locked.has(relativePath)) {
throw new Error(
`Document ${relativePath} is not locked, cannot unlock`
);
}
// Remove the first element to ensure FIFO unblocking order
const nextWaiting = this.waiters.get(relativePath)?.shift();
if (nextWaiting) {
this.logger.debug(`Granted lock on ${relativePath}`);
nextWaiting();
} else {
this.locked.delete(relativePath);
}
}
public reset(): void {
this.locked.clear();
this.waiters.clear();
}
}

View file

@ -1,7 +1,7 @@
import type { RelativePath } from "../persistence/database"; import type { RelativePath } from "../persistence/database";
import type { FileSystemOperations } from "./filesystem-operations"; import type { FileSystemOperations } from "./filesystem-operations";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import { DocumentLocks } from "./document-locks"; import { Locks } from "../utils/locks";
import { FileNotFoundError } from "./file-not-found-error"; import { FileNotFoundError } from "./file-not-found-error";
/** /**
@ -10,13 +10,13 @@ import { FileNotFoundError } from "./file-not-found-error";
* single request in-flight for any one file through the use of locks. * single request in-flight for any one file through the use of locks.
*/ */
export class SafeFileSystemOperations implements FileSystemOperations { export class SafeFileSystemOperations implements FileSystemOperations {
private readonly locks: DocumentLocks; private readonly locks: Locks<RelativePath>;
public constructor( public constructor(
private readonly fs: FileSystemOperations, private readonly fs: FileSystemOperations,
private readonly logger: Logger private readonly logger: Logger
) { ) {
this.locks = new DocumentLocks(logger); this.locks = new Locks(logger);
} }
public async listAllFiles(): Promise<RelativePath[]> { public async listAllFiles(): Promise<RelativePath[]> {
@ -117,7 +117,7 @@ export class SafeFileSystemOperations implements FileSystemOperations {
: [pathOrPaths]; : [pathOrPaths];
await Promise.all( await Promise.all(
paths.map(async (path) => this.locks.waitForDocumentLock(path)) paths.map(async (path) => this.locks.waitForLock(path))
); );
try { try {
@ -125,7 +125,7 @@ export class SafeFileSystemOperations implements FileSystemOperations {
} finally { } finally {
await Promise.all( await Promise.all(
paths.map((path) => { paths.map((path) => {
this.locks.unlockDocument(path); this.locks.unlock(path);
}) })
); );
} }

View file

@ -5,10 +5,10 @@ export {
type HistoryEntry type HistoryEntry
} from "./tracing/sync-history"; } from "./tracing/sync-history";
export { Logger, LogLevel, LogLine } from "./tracing/logger"; export { Logger, LogLevel, LogLine } from "./tracing/logger";
export type { CheckConnectionResult } from "./services/sync-service";
export { type SyncSettings } from "./persistence/settings"; export { type SyncSettings } from "./persistence/settings";
export type { RelativePath, StoredDatabase } from "./persistence/database"; export type { RelativePath, StoredDatabase } from "./persistence/database";
export type { FileSystemOperations } from "./file-operations/filesystem-operations"; export type { FileSystemOperations } from "./file-operations/filesystem-operations";
export type { PersistenceProvider } from "./persistence/persistence"; export type { PersistenceProvider } from "./persistence/persistence";
export type { NetworkConnectionStatus } from "./sync-client";
export { SyncClient } from "./sync-client"; export { SyncClient } from "./sync-client";

View file

@ -1,11 +1,9 @@
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import { LogLevel } from "../tracing/logger";
export interface SyncSettings { export interface SyncSettings {
remoteUri: string; remoteUri: string;
token: string; token: string;
vaultName: string; vaultName: string;
fetchChangesUpdateIntervalMs: number;
syncConcurrency: number; syncConcurrency: number;
isSyncEnabled: boolean; isSyncEnabled: boolean;
maxFileSizeMB: number; maxFileSizeMB: number;
@ -15,7 +13,6 @@ const DEFAULT_SETTINGS: SyncSettings = {
remoteUri: "", remoteUri: "",
token: "", token: "",
vaultName: "default", vaultName: "default",
fetchChangesUpdateIntervalMs: 1000,
syncConcurrency: 1, syncConcurrency: 1,
isSyncEnabled: false, isSyncEnabled: false,
maxFileSizeMB: 10 maxFileSizeMB: 10

View file

@ -18,6 +18,7 @@ export interface CheckConnectionResult {
} }
export class SyncService { export class SyncService {
private static readonly NETWORK_RETRY_INTERVAL_MS = 1000;
private client: Client<paths>; private client: Client<paths>;
private pingClient: Client<paths>; private pingClient: Client<paths>;
private _fetchImplementation: typeof globalThis.fetch = globalThis.fetch; private _fetchImplementation: typeof globalThis.fetch = globalThis.fetch;
@ -284,17 +285,35 @@ export class SyncService {
public async checkConnection(): Promise<CheckConnectionResult> { public async checkConnection(): Promise<CheckConnectionResult> {
try { try {
const result = await this.ping(); const response = await this.pingClient.GET("/ping", {
params: {
header: {
authorization: `Bearer ${this.settings.getSettings().token}`
}
}
});
this.logger.debug(
`Ping response: ${JSON.stringify(response.data)}`
);
if (!response.data) {
throw new Error(
`Failed to ping server: ${SyncService.formatError(response.error)}`
);
}
const result = response.data;
if (result.isAuthenticated) { if (result.isAuthenticated) {
return { return {
isSuccessful: true, isSuccessful: true,
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated.` message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
}; };
} }
return { return {
isSuccessful: false, isSuccessful: false,
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate.` message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
}; };
} catch (e) { } catch (e) {
return { return {
@ -304,27 +323,6 @@ export class SyncService {
} }
} }
// No retries
private async ping(): Promise<components["schemas"]["PingResponse"]> {
const response = await this.pingClient.GET("/ping", {
params: {
header: {
authorization: `Bearer ${this.settings.getSettings().token}`
}
}
});
this.logger.debug(`Ping response: ${JSON.stringify(response.data)}`);
if (!response.data) {
throw new Error(
`Failed to ping server: ${SyncService.formatError(response.error)}`
);
}
return response.data;
}
/** /**
* Create a client and a ping client for the given remote URI. * Create a client and a ping client for the given remote URI.
*/ */
@ -355,8 +353,10 @@ export class SyncService {
throw e; throw e;
} }
this.logger.error(`Failed network call (${e}), retrying`); this.logger.error(
await sleep(1000); `Failed network call (${e}), retryingin ${SyncService.NETWORK_RETRY_INTERVAL_MS}ms`
);
await sleep(SyncService.NETWORK_RETRY_INTERVAL_MS);
} }
} }
} }

View file

@ -566,6 +566,10 @@ export interface components {
/** Format: int64 */ /** Format: int64 */
since_update_id?: number | null; since_update_id?: number | null;
}; };
QueryParams2: {
/** Format: int64 */
since_update_id?: number | null;
};
SerializedError: { SerializedError: {
causes: string[]; causes: string[];
message: string; message: string;
@ -587,6 +591,9 @@ export interface components {
parentVersionId: number; parentVersionId: number;
relativePath: string; relativePath: string;
}; };
WebsocketPathParams: {
vault_id: string;
};
}; };
responses: never; responses: never;
parameters: never; parameters: never;

View file

@ -8,7 +8,6 @@ import type { RelativePath, StoredDatabase } from "./persistence/database";
import { Database } from "./persistence/database"; import { Database } from "./persistence/database";
import type { SyncSettings } from "./persistence/settings"; import type { SyncSettings } from "./persistence/settings";
import { Settings } from "./persistence/settings"; import { Settings } from "./persistence/settings";
import type { CheckConnectionResult } from "./services/sync-service";
import { SyncService } from "./services/sync-service"; import { SyncService } from "./services/sync-service";
import { Syncer } from "./sync-operations/syncer"; import { Syncer } from "./sync-operations/syncer";
import type { FileSystemOperations } from "./file-operations/filesystem-operations"; import type { FileSystemOperations } from "./file-operations/filesystem-operations";
@ -16,9 +15,13 @@ import { FileOperations } from "./file-operations/file-operations";
import { ConnectionStatus } from "./services/connection-status"; import { ConnectionStatus } from "./services/connection-status";
import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer"; import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer";
export class SyncClient { export interface NetworkConnectionStatus {
private remoteListenerIntervalId: NodeJS.Timeout | null = null; isSuccessful: boolean;
serverMessage: string;
isWebSocketConnected: boolean;
}
export class SyncClient {
// eslint-disable-next-line @typescript-eslint/max-params // eslint-disable-next-line @typescript-eslint/max-params
private constructor( private constructor(
private readonly history: SyncHistory, private readonly history: SyncHistory,
@ -31,15 +34,6 @@ export class SyncClient {
) { ) {
this.settings.addOnSettingsChangeListener( this.settings.addOnSettingsChangeListener(
(newSettings, oldSettings) => { (newSettings, oldSettings) => {
if (
newSettings.fetchChangesUpdateIntervalMs !==
oldSettings.fetchChangesUpdateIntervalMs
) {
this.setRemoteEventListener(
newSettings.fetchChangesUpdateIntervalMs
);
}
if (newSettings.vaultName !== oldSettings.vaultName) { if (newSettings.vaultName !== oldSettings.vaultName) {
void this.reset(); void this.reset();
} }
@ -145,8 +139,13 @@ export class SyncClient {
return client; return client;
} }
public async checkConnection(): Promise<CheckConnectionResult> { public async checkConnection(): Promise<NetworkConnectionStatus> {
return this.syncService.checkConnection(); const server = await this.syncService.checkConnection();
return {
isSuccessful: server.isSuccessful,
serverMessage: server.message,
isWebSocketConnected: this.syncer.isWebSocketConnected
};
} }
public getHistoryEntries(): readonly HistoryEntry[] { public getHistoryEntries(): readonly HistoryEntry[] {
@ -161,20 +160,15 @@ export class SyncClient {
public async start(): Promise<void> { public async start(): Promise<void> {
await this.syncer.scheduleSyncForOfflineChanges(); await this.syncer.scheduleSyncForOfflineChanges();
this.setRemoteEventListener(
this.settings.getSettings().fetchChangesUpdateIntervalMs
);
} }
/// Clear all global state that has been touched by SyncClient.
public stop(): void { public stop(): void {
this.unsetRemoteEventListener(); this.syncer.stop();
} }
public async waitAndStop(): Promise<void> { public async waitAndStop(): Promise<void> {
await this.syncer.waitUntilFinished();
this.stop(); this.stop();
await this.syncer.waitUntilFinished();
} }
/// Wait for the in-flight operations to finish, reset all tracking, /// Wait for the in-flight operations to finish, reset all tracking,
@ -218,6 +212,10 @@ export class SyncClient {
this.syncer.addRemainingOperationsListener(listener); this.syncer.addRemainingOperationsListener(listener);
} }
public addWebSocketStatusChangeListener(listener: () => void): void {
this.syncer.addWebSocketStatusChangeListener(listener);
}
public async syncLocallyCreatedFile( public async syncLocallyCreatedFile(
relativePath: RelativePath relativePath: RelativePath
): Promise<void> { ): Promise<void> {
@ -242,21 +240,4 @@ export class SyncClient {
relativePath relativePath
}); });
} }
private setRemoteEventListener(intervalMs: number): void {
if (this.remoteListenerIntervalId !== null) {
clearInterval(this.remoteListenerIntervalId);
}
this.remoteListenerIntervalId = setInterval(
() => void this.syncer.applyRemoteChangesLocally(),
intervalMs
);
}
private unsetRemoteEventListener(): void {
if (this.remoteListenerIntervalId !== null) {
clearInterval(this.remoteListenerIntervalId);
}
}
} }

View file

@ -1,30 +1,40 @@
import type { Database, RelativePath } from "../persistence/database"; import type {
Database,
DocumentId,
RelativePath
} from "../persistence/database";
import type { SyncService } from "../services/sync-service"; import type { SyncService } from "../services/sync-service";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import PQueue from "p-queue"; import PQueue from "p-queue";
import { hash } from "../utils/hash"; import { hash } from "../utils/hash";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import type { components } from "../services/types"; import type { components } from "../services/types";
import type { Settings } from "../persistence/settings"; import type { Settings, SyncSettings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations"; import type { FileOperations } from "../file-operations/file-operations";
import { findMatchingFile } from "../utils/find-matching-file"; import { findMatchingFile } from "../utils/find-matching-file";
import type { UnrestrictedSyncer } from "./unrestricted-syncer"; import type { UnrestrictedSyncer } from "./unrestricted-syncer";
import { createPromise } from "../utils/create-promise"; import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "../services/sync-reset-error"; import { SyncResetError } from "../services/sync-reset-error";
import { Locks } from "../utils/locks";
export class Syncer { export class Syncer {
private readonly remoteDocumentsLock: Locks<DocumentId>;
private readonly remainingOperationsListeners: (( private readonly remainingOperationsListeners: ((
remainingOperations: number remainingOperations: number
) => void)[] = []; ) => void)[] = [];
private readonly webSocketStatusChangeListeners: (() => void)[] = [];
private readonly syncQueue: PQueue; private readonly syncQueue: PQueue;
private runningScheduleSyncForOfflineChanges: Promise<void> | undefined; private runningScheduleSyncForOfflineChanges: Promise<void> | undefined;
private runningApplyRemoteChangesLocally: Promise<void> | undefined; private refreshApplyRemoteChangesWebSocketInterval:
| NodeJS.Timeout
| undefined;
private applyRemoteChangesWebSocket: WebSocket | undefined;
public constructor( public constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly database: Database, private readonly database: Database,
settings: Settings, private readonly settings: Settings,
private readonly syncService: SyncService, private readonly syncService: SyncService,
private readonly operations: FileOperations, private readonly operations: FileOperations,
private readonly internalSyncer: UnrestrictedSyncer private readonly internalSyncer: UnrestrictedSyncer
@ -33,11 +43,23 @@ export class Syncer {
concurrency: settings.getSettings().syncConcurrency concurrency: settings.getSettings().syncConcurrency
}); });
this.updateWebSocket(settings.getSettings());
this.remoteDocumentsLock = new Locks<DocumentId>(this.logger);
settings.addOnSettingsChangeListener((newSettings, oldSettings) => { settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (newSettings.syncConcurrency === oldSettings.syncConcurrency) { if (
return; newSettings.remoteUri !== oldSettings.remoteUri ||
newSettings.vaultName !== oldSettings.vaultName ||
newSettings.token !== oldSettings.token ||
newSettings.isSyncEnabled !== oldSettings.isSyncEnabled
) {
this.updateWebSocket(newSettings);
}
if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) {
this.syncQueue.concurrency = newSettings.syncConcurrency;
} }
this.syncQueue.concurrency = newSettings.syncConcurrency;
}); });
this.syncQueue.on("active", () => { this.syncQueue.on("active", () => {
@ -45,6 +67,12 @@ export class Syncer {
listener(this.syncQueue.size); listener(this.syncQueue.size);
}); });
}); });
this.setWebSocketRefreshInterval();
}
public get isWebSocketConnected(): boolean {
return this.applyRemoteChangesWebSocket?.readyState === WebSocket.OPEN;
} }
public addRemainingOperationsListener( public addRemainingOperationsListener(
@ -53,6 +81,10 @@ export class Syncer {
this.remainingOperationsListeners.push(listener); this.remainingOperationsListeners.push(listener);
} }
public addWebSocketStatusChangeListener(listener: () => void): void {
this.webSocketStatusChangeListeners.push(listener);
}
public async syncLocallyCreatedFile( public async syncLocallyCreatedFile(
relativePath: RelativePath relativePath: RelativePath
): Promise<void> { ): Promise<void> {
@ -206,109 +238,139 @@ export class Syncer {
} }
} }
public async applyRemoteChangesLocally(): Promise<void> { public async waitUntilFinished(): Promise<void> {
if (this.runningApplyRemoteChangesLocally !== undefined) { await this.runningScheduleSyncForOfflineChanges;
this.logger.debug( return this.syncQueue.onEmpty();
"Applying remote changes locally is already in progress"
);
return this.runningApplyRemoteChangesLocally;
}
try {
this.runningApplyRemoteChangesLocally =
this.internalApplyRemoteChangesLocally();
await this.runningApplyRemoteChangesLocally;
this.logger.info("All remote changes have been applied locally");
} catch (e) {
if (e instanceof SyncResetError) {
this.logger.info(
"Failed to apply remote changes locally due to a reset"
);
return;
}
this.logger.error(`Failed to apply remote changes locally: ${e}`);
throw e;
} finally {
this.runningApplyRemoteChangesLocally = undefined;
}
} }
public async reset(): Promise<void> { public async reset(): Promise<void> {
await this.waitUntilFinished(); await this.waitUntilFinished();
this.internalSyncer.reset(); this.setWebSocketRefreshInterval();
this.updateWebSocket(this.settings.getSettings());
} }
public async waitUntilFinished(): Promise<void> { public stop(): void {
await Promise.allSettled([ clearInterval(this.refreshApplyRemoteChangesWebSocketInterval);
this.runningScheduleSyncForOfflineChanges, this.applyRemoteChangesWebSocket?.close();
this.runningApplyRemoteChangesLocally
]);
return this.syncQueue.onEmpty();
} }
private async internalApplyRemoteChangesLocally(): Promise<void> { private updateWebSocket(settings: SyncSettings): void {
const remote = await this.syncQueue.add(async () => this.applyRemoteChangesWebSocket?.close();
this.syncService.getAll(this.database.getLastSeenUpdateId())
);
if (!remote) { if (!settings.isSyncEnabled) {
throw new Error("Failed to fetch remote changes"); this.applyRemoteChangesWebSocket = undefined;
}
if (remote.latestDocuments.length === 0) {
this.logger.debug("No remote changes to apply");
return; return;
} }
this.logger.info("Applying remote changes locally"); const wsUri = new URL(settings.remoteUri);
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
wsUri.pathname = `/vaults/${settings.vaultName}/ws`;
await Promise.all(
remote.latestDocuments.map(this.syncRemotelyUpdatedFile.bind(this))
);
const lastSeenUpdateId = this.database.getLastSeenUpdateId();
if ( if (
lastSeenUpdateId === undefined || typeof globalThis !== "undefined" &&
lastSeenUpdateId < remote.lastUpdateId typeof globalThis.WebSocket === "undefined"
) { ) {
this.database.setLastSeenUpdateId(remote.lastUpdateId); // polyfill for WebSocket in Node.js
// eslint-disable-next-line
globalThis.WebSocket = require("ws");
} }
this.applyRemoteChangesWebSocket = new WebSocket(wsUri);
this.applyRemoteChangesWebSocket.onmessage = (event): void =>
void this.syncRemotelyUpdatedFile(event.data).catch(
(e: unknown) => {
this.logger.error(
`Failed to sync remotely updated file: ${e}`
);
}
);
// The JS WebSocket API doesn't support setting headers, so we have to send the token as a message
this.applyRemoteChangesWebSocket.onopen = (): void => {
this.applyRemoteChangesWebSocket?.send(settings.token);
this.webSocketStatusChangeListeners.forEach((listener) => {
listener();
});
};
this.applyRemoteChangesWebSocket.onclose = (event): void => {
this.logger.warn(
`WebSocket closed with code ${event.code}: ${event.reason}`
);
this.webSocketStatusChangeListeners.forEach((listener) => {
listener();
});
};
} }
private async syncRemotelyUpdatedFile( private setWebSocketRefreshInterval(): void {
remoteVersion: components["schemas"]["DocumentVersionWithoutContent"] this.refreshApplyRemoteChangesWebSocketInterval = setInterval(() => {
): Promise<void> { if (
this.applyRemoteChangesWebSocket?.readyState === WebSocket.OPEN
) {
return;
}
this.updateWebSocket(this.settings.getSettings());
}, 5000);
}
private async syncRemotelyUpdatedFile(message: string): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const remoteVersion = JSON.parse(
message
) as components["schemas"]["DocumentVersionWithoutContent"];
let document = this.database.getDocumentByDocumentId( let document = this.database.getDocumentByDocumentId(
remoteVersion.documentId remoteVersion.documentId
); );
const [promise, resolve, reject] = createPromise(); let hasLockToRelease = false;
if (document === undefined) { if (document === undefined) {
await this.syncQueue.add(async () => // Let's avoid the same documents getting created in parallel multiple times
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( await this.remoteDocumentsLock.waitForLock(
remoteVersion remoteVersion.documentId
)
); );
} else { hasLockToRelease = true;
document = await this.database.getResolvedDocumentByRelativePath( document = this.database.getDocumentByDocumentId(
document.relativePath, remoteVersion.documentId
promise
); );
}
try { try {
if (document === undefined) {
await this.syncQueue.add(async () => await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile(
remoteVersion, remoteVersion
document
) )
); );
} else {
const [promise, resolve, reject] = createPromise();
resolve(); document =
} catch (e) { await this.database.getResolvedDocumentByRelativePath(
reject(e); document.relativePath,
} finally { promise
this.database.removeDocumentPromise(promise); );
try {
await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile(
remoteVersion,
document
)
);
resolve();
} catch (e) {
reject(e);
} finally {
this.database.removeDocumentPromise(promise);
}
}
} finally {
if (hasLockToRelease) {
this.remoteDocumentsLock.unlock(remoteVersion.documentId);
} }
} }
} }

View file

@ -13,14 +13,11 @@ import type { components } from "../services/types";
import { deserialize } from "../utils/deserialize"; import { deserialize } from "../utils/deserialize";
import type { Settings } from "../persistence/settings"; import type { Settings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations"; import type { FileOperations } from "../file-operations/file-operations";
import { DocumentLocks } from "../file-operations/document-locks";
import { createPromise } from "../utils/create-promise"; import { createPromise } from "../utils/create-promise";
import { FileNotFoundError } from "../file-operations/file-not-found-error"; import { FileNotFoundError } from "../file-operations/file-not-found-error";
import { SyncResetError } from "../services/sync-reset-error"; import { SyncResetError } from "../services/sync-reset-error";
export class UnrestrictedSyncer { export class UnrestrictedSyncer {
private readonly locks: DocumentLocks;
public constructor( public constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly database: Database, private readonly database: Database,
@ -28,10 +25,7 @@ export class UnrestrictedSyncer {
private readonly syncService: SyncService, private readonly syncService: SyncService,
private readonly operations: FileOperations, private readonly operations: FileOperations,
private readonly history: SyncHistory private readonly history: SyncHistory
) { ) {}
this.locks = new DocumentLocks(logger);
}
public async unrestrictedSyncLocallyCreatedFile( public async unrestrictedSyncLocallyCreatedFile(
document: DocumentRecord document: DocumentRecord
): Promise<void> { ): Promise<void> {
@ -416,10 +410,6 @@ export class UnrestrictedSyncer {
} }
} }
public reset(): void {
this.locks.reset();
}
private tryIncrementVaultUpdateId(responseVaultUpdateId: number): void { private tryIncrementVaultUpdateId(responseVaultUpdateId: number): void {
if (this.database.getLastSeenUpdateId() === responseVaultUpdateId - 1) { if (this.database.getLastSeenUpdateId() === responseVaultUpdateId - 1) {
this.database.setLastSeenUpdateId(responseVaultUpdateId); this.database.setLastSeenUpdateId(responseVaultUpdateId);

View file

@ -21,7 +21,7 @@ export class LogLine {
} }
export class Logger { export class Logger {
private static readonly MAX_MESSAGES = 2000; private static readonly MAX_MESSAGES = 100000;
private readonly messages: LogLine[] = []; private readonly messages: LogLine[] = [];
private readonly onMessageListeners: ((message: LogLine) => void)[] = []; private readonly onMessageListeners: ((message: LogLine) => void)[] = [];

View file

@ -1,92 +1,88 @@
import { Logger } from "../tracing/logger"; import { Logger } from "../tracing/logger";
import type { RelativePath } from "../persistence/database"; import type { RelativePath } from "../persistence/database";
import { DocumentLocks } from "./document-locks"; import { Locks } from "./locks";
describe("Document lock", () => { describe("Document lock", () => {
const testPath: RelativePath = "test/document/path"; const testPath: RelativePath = "test/document/path";
const logger = new Logger(); const logger = new Logger();
let locks = new DocumentLocks(logger);
// eslint-disable-next-line @typescript-eslint/init-declarations
let locks: Locks<RelativePath>;
beforeEach(() => { beforeEach(() => {
locks = new DocumentLocks(logger); locks = new Locks<RelativePath>(logger);
}); });
test("should lock a document successfully", () => { test("should lock a document successfully", () => {
const result = locks.tryLockDocument(testPath); const result = locks.tryLock(testPath);
expect(result).toBe(true); expect(result).toBe(true);
}); });
test("should not lock a document that is already locked", () => { test("should not lock a document that is already locked", () => {
locks.tryLockDocument(testPath); locks.tryLock(testPath);
const result = locks.tryLockDocument(testPath); const result = locks.tryLock(testPath);
expect(result).toBe(false); expect(result).toBe(false);
}); });
test("should unlock a locked document", () => { test("should unlock a locked document", () => {
locks.tryLockDocument(testPath); locks.tryLock(testPath);
locks.unlockDocument(testPath); locks.unlock(testPath);
const result = locks.tryLockDocument(testPath); const result = locks.tryLock(testPath);
expect(result).toBe(true); expect(result).toBe(true);
locks.unlockDocument(testPath); locks.unlock(testPath);
}); });
test("should throw an error when unlocking a document that is not locked", () => { test("should throw an error when unlocking a document that is not locked", () => {
expect(() => { expect(() => {
locks.unlockDocument(testPath); locks.unlock(testPath);
}).toThrow(`Document ${testPath} is not locked, cannot unlock`); }).toThrow(`Document ${testPath} is not locked, cannot unlock`);
}); });
test("should wait for a document lock and resolve when unlocked", async () => { test("should wait for a document lock and resolve when unlocked", async () => {
locks.tryLockDocument(testPath); locks.tryLock(testPath);
let resolved = false; let resolved = false;
const waitPromise = locks.waitForDocumentLock(testPath).then(() => { const waitPromise = locks.waitForLock(testPath).then(() => {
resolved = true; resolved = true;
}); });
locks.unlockDocument(testPath); locks.unlock(testPath);
await waitPromise; await waitPromise;
expect(resolved).toBe(true); expect(resolved).toBe(true);
}); });
test("should resolve multiple waiters in FIFO order", async () => { test("should resolve multiple waiters in FIFO order", async () => {
locks.tryLockDocument(testPath); locks.tryLock(testPath);
let firstResolved = false; let firstResolved = false;
let secondResolved = false; let secondResolved = false;
let thirdResolved = false; let thirdResolved = false;
const firstWaitPromise = locks const firstWaitPromise = locks.waitForLock(testPath).then(() => {
.waitForDocumentLock(testPath) firstResolved = true;
.then(() => { });
firstResolved = true;
});
const secondWaitPromise = locks const secondWaitPromise = locks.waitForLock(testPath).then(() => {
.waitForDocumentLock(testPath) secondResolved = true;
.then(() => { });
secondResolved = true;
});
const thirdWaitPromise = locks const thirdWaitPromise = locks.waitForLock(testPath).then(() => {
.waitForDocumentLock(testPath) thirdResolved = true;
.then(() => { });
thirdResolved = true;
});
locks.unlockDocument(testPath); locks.unlock(testPath);
await firstWaitPromise; await firstWaitPromise;
expect(firstResolved).toBe(true); expect(firstResolved).toBe(true);
expect(secondResolved).toBe(false); expect(secondResolved).toBe(false);
expect(thirdResolved).toBe(false); expect(thirdResolved).toBe(false);
locks.unlockDocument(testPath); locks.unlock(testPath);
await secondWaitPromise; await secondWaitPromise;
expect(secondResolved).toBe(true); expect(secondResolved).toBe(true);
expect(thirdResolved).toBe(false); expect(thirdResolved).toBe(false);
locks.unlockDocument(testPath); locks.unlock(testPath);
await thirdWaitPromise; await thirdWaitPromise;
expect(thirdResolved).toBe(true); expect(thirdResolved).toBe(true);
}); });

View file

@ -0,0 +1,60 @@
import type { Logger } from "../tracing/logger";
// Manages locks on T to prevent concurrent modifications
// allowing the client's FileOperations implementation to be simpler.
// Locks are granted in a first-in-first-out order.
export class Locks<T> {
private readonly locked = new Set<T>();
private readonly waiters = new Map<T, (() => void)[]>();
public constructor(private readonly logger: Logger) {}
public tryLock(key: T): boolean {
if (this.locked.has(key)) {
return false;
}
this.locked.add(key);
return true;
}
public async waitForLock(key: T): Promise<void> {
if (this.tryLock(key)) {
return Promise.resolve();
}
this.logger.debug(`Waiting for lock on ${key}`);
return new Promise((resolve) => {
let waiting = this.waiters.get(key);
if (!waiting) {
waiting = [];
this.waiters.set(key, waiting);
}
waiting.push(resolve);
});
}
public unlock(key: T): void {
if (!this.locked.has(key)) {
throw new Error(`Document ${key} is not locked, cannot unlock`);
}
// Remove the first element to ensure FIFO unblocking order
const nextWaiting = this.waiters.get(key)?.shift();
if (nextWaiting) {
this.logger.debug(`Granted lock on ${key}`);
nextWaiting();
} else {
this.locked.delete(key);
}
}
public reset(): void {
this.locked.clear();
this.waiters.clear();
}
}

View file

@ -6,10 +6,12 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"lib": [ "lib": [
"DOM" // to get "fetch" "DOM" // to get `fetch` & `WebSocket`
], ],
"declaration": true, "declaration": true,
"declarationDir": "./dist/types" "declarationDir": "./dist/types"
}, },
"exclude": ["./dist"] "exclude": [
} "./dist"
]
}

View file

@ -20,7 +20,7 @@ const common = {
minimize: false minimize: false
}, },
resolve: { resolve: {
extensions: [".ts"], extensions: [".ts", ".js"],
alias: { alias: {
root: __dirname, root: __dirname,
src: path.resolve(__dirname, "src") src: path.resolve(__dirname, "src")
@ -42,6 +42,11 @@ module.exports = [
type: "umd" type: "umd"
}, },
globalObject: "this" globalObject: "this"
},
resolve: {
fallback: {
ws: false // Exclude `ws` from the browser bundle
}
} }
}), }),
merge(common, { merge(common, {
@ -50,6 +55,10 @@ module.exports = [
path: path.resolve(__dirname, "dist"), path: path.resolve(__dirname, "dist"),
filename: "sync-client.node.js", filename: "sync-client.node.js",
libraryTarget: "commonjs2" libraryTarget: "commonjs2"
},
externals: {
bufferutil: "bufferutil",
"utf-8-validate": "utf-8-validate" // required for ws: https://github.com/websockets/ws/issues/2245#issuecomment-2250318733
} }
}) })
]; ];

View file

@ -18,6 +18,7 @@
"typescript": "5.8.2", "typescript": "5.8.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"webpack": "^5.98.0", "webpack": "^5.98.0",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1",
"bufferutil": "^4.0.9"
} }
} }

View file

@ -88,8 +88,7 @@ export class MockAgent extends MockClient {
public async act(): Promise<void> { public async act(): Promise<void> {
const options: (() => Promise<unknown>)[] = [ const options: (() => Promise<unknown>)[] = [
this.createFileAction.bind(this), this.createFileAction.bind(this)
this.changeFetchChangesUpdateIntervalMsAction.bind(this)
]; ];
if (this.client.getSettings().isSyncEnabled) { if (this.client.getSettings().isSyncEnabled) {
@ -253,16 +252,6 @@ export class MockAgent extends MockClient {
return this.create(file, new TextEncoder().encode(` ${content} `)); return this.create(file, new TextEncoder().encode(` ${content} `));
} }
private async changeFetchChangesUpdateIntervalMsAction(): Promise<void> {
this.client.logger.info(
`Decided to change fetchChangesUpdateIntervalMs`
);
return this.client.setSetting(
"fetchChangesUpdateIntervalMs",
Math.random() * 2000 + 100
);
}
private async disableSyncAction(): Promise<void> { private async disableSyncAction(): Promise<void> {
this.client.logger.info(`Decided to disable sync`); this.client.logger.info(`Decided to disable sync`);
await this.client.setSetting("isSyncEnabled", false); await this.client.setSetting("isSyncEnabled", false);

View file

@ -1,4 +1,4 @@
import type { StoredDatabase } from "sync-client/dist/types/persistence/database"; import type { StoredDatabase } from "sync-client";
import { assert } from "../utils/assert"; import { assert } from "../utils/assert";
import { import {
type RelativePath, type RelativePath,
@ -23,9 +23,11 @@ export class MockClient implements FileSystemOperations {
}; };
public constructor( public constructor(
private readonly initialSettings: Partial<SyncSettings>, initialSettings: Partial<SyncSettings>,
protected readonly useSlowFileEvents: boolean protected readonly useSlowFileEvents: boolean
) {} ) {
this.data.settings = initialSettings;
}
public async init( public async init(
fetchImplementation: typeof globalThis.fetch fetchImplementation: typeof globalThis.fetch
@ -39,16 +41,6 @@ export class MockClient implements FileSystemOperations {
fetch: fetchImplementation fetch: fetchImplementation
}); });
await Promise.all(
Object.keys(this.initialSettings).map(async (key) => {
const settingKey = key as keyof SyncSettings; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
return this.client.setSetting(
settingKey,
this.initialSettings[settingKey]! // eslint-disable-line @typescript-eslint/no-non-null-assertion
);
})
);
await this.client.start(); await this.client.start();
} }

View file

@ -5,8 +5,13 @@
"target": "ES2022", "target": "ES2022",
"module": "CommonJS", "module": "CommonJS",
"esModuleInterop": true, "esModuleInterop": true,
"lib": ["DOM", "ESNext"], "lib": [
"DOM",
"ESNext"
],
"moduleResolution": "node" "moduleResolution": "node"
}, },
"exclude": ["./dist"] "exclude": [
} "./dist"
]
}