Split the server into three crates (#56)

This will make it easier to build variations on the server, or embed it
into larger projects.
This commit is contained in:
Dustin J. Mitchell 2024-11-17 15:12:42 -05:00 committed by GitHub
parent 5769781553
commit 47ce4c1e3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1243 additions and 57 deletions

View file

@ -62,11 +62,24 @@ jobs:
override: true
minimal: true
- uses: actions-rs/cargo@v1.0.3
- name: taskchampion-sync-server
uses: actions-rs/cargo@v1.0.3
with:
command: rustdoc
args: -p taskchampion-sync-server --all-features -- -Z unstable-options --check -Dwarnings
- name: taskchampion-sync-server-core
uses: actions-rs/cargo@v1.0.3
with:
command: rustdoc
args: -p taskchampion-sync-server-core --all-features -- -Z unstable-options --check -Dwarnings
- name: taskchampion-sync-server-storage-sqlite
uses: actions-rs/cargo@v1.0.3
with:
command: rustdoc
args: -p taskchampion-sync-server-storage-sqlite --all-features -- -Z unstable-options --check -Dwarnings
fmt:
runs-on: ubuntu-latest
name: "Formatting"

41
Cargo.lock generated
View file

@ -687,9 +687,9 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.30"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
@ -1406,9 +1406,36 @@ dependencies = [
"futures",
"log",
"pretty_assertions",
"rusqlite",
"serde",
"serde_json",
"taskchampion-sync-server-core",
"taskchampion-sync-server-storage-sqlite",
"tempfile",
"thiserror",
"uuid",
]
[[package]]
name = "taskchampion-sync-server-core"
version = "0.4.1"
dependencies = [
"anyhow",
"chrono",
"env_logger",
"log",
"pretty_assertions",
"uuid",
]
[[package]]
name = "taskchampion-sync-server-storage-sqlite"
version = "0.4.1"
dependencies = [
"anyhow",
"chrono",
"pretty_assertions",
"rusqlite",
"taskchampion-sync-server-core",
"tempfile",
"thiserror",
"uuid",
@ -1429,18 +1456,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.0"
version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15291287e9bff1bc6f9ff3409ed9af665bec7a5fc8ac079ea96be07bca0e2668"
checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.0"
version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22efd00f33f93fa62848a7cab956c3d38c8d43095efda1decfc2b3a5dc0b8972"
checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568"
dependencies = [
"proc-macro2",
"quote",

View file

@ -1,11 +1,12 @@
[package]
name = "taskchampion-sync-server"
version = "0.4.1"
authors = ["Dustin J. Mitchell <dustin@mozilla.com>"]
edition = "2021"
publish = false
[workspace]
resolver = "2"
members = [
"core",
"server",
"sqlite",
]
[dependencies]
[workspace.dependencies]
uuid = { version = "^1.11.0", features = ["serde", "v4"] }
actix-web = "^4.9.0"
anyhow = "1.0"
@ -18,8 +19,6 @@ log = "^0.4.17"
env_logger = "^0.11.5"
rusqlite = { version = "0.32", features = ["bundled"] }
chrono = { version = "^0.4.38", features = ["serde"] }
[dev-dependencies]
actix-rt = "2"
tempfile = "3"
pretty_assertions = "1"

View file

@ -13,6 +13,12 @@ This repository was spun off from Taskwarrior itself after the 3.0.0
release. It is still under development and currently best described as
a reference implementation of the Taskchampion sync protocol.
It is comprised of three crates:
- `taskchampion-sync-server-core` implements the core of the protocol
- `taskchmpaion-sync-server-sqlite` implements an SQLite backend for the core
- `taskchampion-sync-server` implements a simple HTTP server for the protocol
## Installation
### From binary

15
core/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "taskchampion-sync-server-core"
version = "0.4.1"
authors = ["Dustin J. Mitchell <dustin@mozilla.com>"]
edition = "2021"
[dependencies]
uuid.workspace = true
anyhow.workspace = true
log.workspace = true
env_logger.workspace = true
chrono.workspace = true
[dev-dependencies]
pretty_assertions.workspace = true

8
core/README.md Normal file
View file

@ -0,0 +1,8 @@
# taskchampion-sync-server-core
This crate implements the core logic of the taskchampion sync protocol.
This should be considered a reference implementation, with [the protocol
documentation](https://gothenburgbitfactory.org/taskchampion/sync-protocol.html).
representing the authoritative definition of the protocol. Other
implementations are encouraged.

View file

@ -1,6 +1,7 @@
use super::{Client, Snapshot, Storage, StorageTxn, Uuid, Version};
use super::{Client, Snapshot, Storage, StorageTxn, Version};
use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard};
use uuid::Uuid;
struct Inner {
/// Clients, indexed by client_id
@ -16,6 +17,11 @@ struct Inner {
children: HashMap<(Uuid, Uuid), Uuid>,
}
/// In-memory storage for testing and experimentation.
///
/// This is not for production use, but supports testing of sync server implementations.
///
/// NOTE: this does not implement transaction rollback.
pub struct InMemoryStorage(Mutex<Inner>);
impl InMemoryStorage {
@ -32,9 +38,6 @@ impl InMemoryStorage {
struct InnerTxn<'a>(MutexGuard<'a, Inner>);
/// In-memory storage for testing and experimentation.
///
/// NOTE: this does not implement transaction rollback.
impl Storage for InMemoryStorage {
fn txn<'a>(&'a self) -> anyhow::Result<Box<dyn StorageTxn + 'a>> {
Ok(Box::new(InnerTxn(self.0.lock().expect("poisoned lock"))))

32
core/src/lib.rs Normal file
View file

@ -0,0 +1,32 @@
//! This crate implements the core logic of the taskchampion sync protocol.
//!
//! This should be considered a reference implementation, with [the protocol
//! documentation](https://gothenburgbitfactory.org/taskchampion/sync-protocol.html). representing
//! the authoritative definition of the protocol. Other implementations are encouraged.
//!
//! This crate uses an abstract storage backend. Note that this does not implement the
//! HTTP-specific portions of the protocol, nor provide any storage implementations.
//!
//! ## API Methods
//!
//! The following API methods are implemented. These methods are documented in more detail in
//! the protocol documentation.
//!
//! * [`add_version`]
//! * [`get_child_version`]
//! * [`add_snapshot`]
//! * [`get_snapshot`]
//!
//! Each API method takes:
//!
//! * [`StorageTxn`] to access storage. Methods which modify storage will commit the transaction before returning.
//! * [`ServerConfig`] providing basic configuration for the server's behavior.
//! * `client_id` and a [`Client`] providing the client metadata.
mod inmemory;
mod server;
mod storage;
pub use inmemory::*;
pub use server::*;
pub use storage::*;

1037
core/src/server.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,7 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[cfg(debug_assertions)]
mod inmemory;
#[cfg(debug_assertions)]
pub use inmemory::InMemoryStorage;
mod sqlite;
pub use self::sqlite::SqliteStorage;
/// A representation of stored metadata about a client.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Client {
/// The latest version for this client (may be the nil version)
@ -18,6 +10,7 @@ pub struct Client {
pub snapshot: Option<Snapshot>,
}
/// Metadata about a snapshot, not including the snapshot data itself.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Snapshot {
/// ID of the version at which this snapshot was made
@ -32,11 +25,19 @@ pub struct Snapshot {
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Version {
/// The uuid identifying this version.
pub version_id: Uuid,
/// The uuid identifying this version's parent.
pub parent_version_id: Uuid,
/// The data carried in this version.
pub history_segment: Vec<u8>,
}
/// A transaction in the storage backend.
///
/// Transactions must be sequentially consistent. That is, the results of transactions performed
/// in storage must be as if each were executed sequentially in some order. In particular, the
/// `Client.latest_version` must not change between a call to `get_client` and `add_version`.
pub trait StorageTxn {
/// Get information about the given client
fn get_client(&mut self, client_id: Uuid) -> anyhow::Result<Option<Client>>;

26
server/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "taskchampion-sync-server"
version = "0.4.1"
authors = ["Dustin J. Mitchell <dustin@mozilla.com>"]
edition = "2021"
publish = false
[dependencies]
taskchampion-sync-server-core = { path = "../core" }
taskchampion-sync-server-storage-sqlite = { path = "../sqlite" }
uuid.workspace = true
actix-web.workspace = true
anyhow.workspace = true
thiserror.workspace = true
futures.workspace = true
serde_json.workspace = true
serde.workspace = true
clap.workspace = true
log.workspace = true
env_logger.workspace = true
chrono.workspace = true
[dev-dependencies]
actix-rt.workspace = true
tempfile.workspace = true
pretty_assertions.workspace = true

View file

@ -1,8 +1,8 @@
use crate::api::{client_id_header, failure_to_ise, ServerState, SNAPSHOT_CONTENT_TYPE};
use crate::server::{add_snapshot, VersionId, NIL_VERSION_ID};
use actix_web::{error, post, web, HttpMessage, HttpRequest, HttpResponse, Result};
use futures::StreamExt;
use std::sync::Arc;
use taskchampion_sync_server_core::{add_snapshot, VersionId, NIL_VERSION_ID};
/// Max snapshot size: 100MB
const MAX_SIZE: usize = 100 * 1024 * 1024;
@ -77,10 +77,10 @@ pub(crate) async fn service(
mod test {
use super::*;
use crate::api::CLIENT_ID_HEADER;
use crate::storage::{InMemoryStorage, Storage};
use crate::Server;
use actix_web::{http::StatusCode, test, App};
use pretty_assertions::assert_eq;
use taskchampion_sync_server_core::{InMemoryStorage, Storage};
use uuid::Uuid;
#[actix_rt::test]

View file

@ -2,10 +2,12 @@ use crate::api::{
client_id_header, failure_to_ise, ServerState, HISTORY_SEGMENT_CONTENT_TYPE,
PARENT_VERSION_ID_HEADER, SNAPSHOT_REQUEST_HEADER, VERSION_ID_HEADER,
};
use crate::server::{add_version, AddVersionResult, SnapshotUrgency, VersionId, NIL_VERSION_ID};
use actix_web::{error, post, web, HttpMessage, HttpRequest, HttpResponse, Result};
use futures::StreamExt;
use std::sync::Arc;
use taskchampion_sync_server_core::{
add_version, AddVersionResult, SnapshotUrgency, VersionId, NIL_VERSION_ID,
};
/// Max history segment size: 100MB
const MAX_SIZE: usize = 100 * 1024 * 1024;
@ -105,10 +107,10 @@ pub(crate) async fn service(
#[cfg(test)]
mod test {
use crate::api::CLIENT_ID_HEADER;
use crate::storage::{InMemoryStorage, Storage};
use crate::Server;
use actix_web::{http::StatusCode, test, App};
use pretty_assertions::assert_eq;
use taskchampion_sync_server_core::{InMemoryStorage, Storage};
use uuid::Uuid;
#[actix_rt::test]

View file

@ -2,9 +2,9 @@ use crate::api::{
client_id_header, failure_to_ise, ServerState, HISTORY_SEGMENT_CONTENT_TYPE,
PARENT_VERSION_ID_HEADER, VERSION_ID_HEADER,
};
use crate::server::{get_child_version, GetVersionResult, VersionId};
use actix_web::{error, get, web, HttpRequest, HttpResponse, Result};
use std::sync::Arc;
use taskchampion_sync_server_core::{get_child_version, GetVersionResult, VersionId};
/// Get a child version.
///
@ -57,11 +57,10 @@ pub(crate) async fn service(
#[cfg(test)]
mod test {
use crate::api::CLIENT_ID_HEADER;
use crate::server::NIL_VERSION_ID;
use crate::storage::{InMemoryStorage, Storage};
use crate::Server;
use actix_web::{http::StatusCode, test, App};
use pretty_assertions::assert_eq;
use taskchampion_sync_server_core::{InMemoryStorage, Storage, NIL_VERSION_ID};
use uuid::Uuid;
#[actix_rt::test]

View file

@ -1,9 +1,9 @@
use crate::api::{
client_id_header, failure_to_ise, ServerState, SNAPSHOT_CONTENT_TYPE, VERSION_ID_HEADER,
};
use crate::server::get_snapshot;
use actix_web::{error, get, web, HttpRequest, HttpResponse, Result};
use std::sync::Arc;
use taskchampion_sync_server_core::get_snapshot;
/// Get a snapshot.
///
@ -42,11 +42,11 @@ pub(crate) async fn service(
#[cfg(test)]
mod test {
use crate::api::CLIENT_ID_HEADER;
use crate::storage::{InMemoryStorage, Snapshot, Storage};
use crate::Server;
use actix_web::{http::StatusCode, test, App};
use chrono::{TimeZone, Utc};
use pretty_assertions::assert_eq;
use taskchampion_sync_server_core::{InMemoryStorage, Snapshot, Storage};
use uuid::Uuid;
#[actix_rt::test]

View file

@ -1,7 +1,5 @@
use crate::server::ClientId;
use crate::storage::Storage;
use crate::ServerConfig;
use actix_web::{error, http::StatusCode, web, HttpRequest, Result, Scope};
use taskchampion_sync_server_core::{ClientId, ServerConfig, Storage};
mod add_snapshot;
mod add_version;

View file

@ -3,8 +3,9 @@
use actix_web::{middleware::Logger, App, HttpServer};
use clap::{arg, builder::ValueParser, value_parser, Command};
use std::ffi::OsString;
use taskchampion_sync_server::storage::SqliteStorage;
use taskchampion_sync_server::{Server, ServerConfig};
use taskchampion_sync_server::Server;
use taskchampion_sync_server_core::ServerConfig;
use taskchampion_sync_server_storage_sqlite::SqliteStorage;
#[actix_web::main]
async fn main() -> anyhow::Result<()> {
@ -62,7 +63,7 @@ async fn main() -> anyhow::Result<()> {
mod test {
use super::*;
use actix_web::{test, App};
use taskchampion_sync_server::storage::InMemoryStorage;
use taskchampion_sync_server_core::InMemoryStorage;
#[actix_rt::test]
async fn test_index_get() {

View file

@ -1,15 +1,11 @@
#![deny(clippy::all)]
mod api;
mod server;
pub mod storage;
use crate::storage::Storage;
use actix_web::{get, middleware, web, Responder};
use api::{api_scope, ServerState};
use std::sync::Arc;
pub use server::ServerConfig;
use taskchampion_sync_server_core::{ServerConfig, Storage};
#[get("/")]
async fn index() -> impl Responder {
@ -47,13 +43,9 @@ impl Server {
#[cfg(test)]
mod test {
use super::*;
use crate::storage::InMemoryStorage;
use actix_web::{test, App};
use pretty_assertions::assert_eq;
pub(crate) fn init_logging() {
let _ = env_logger::builder().is_test(true).try_init();
}
use taskchampion_sync_server_core::InMemoryStorage;
#[actix_rt::test]
async fn test_cache_control() {

17
sqlite/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "taskchampion-sync-server-storage-sqlite"
version = "0.4.1"
authors = ["Dustin J. Mitchell <dustin@mozilla.com>"]
edition = "2021"
[dependencies]
taskchampion-sync-server-core = { path = "../core" }
uuid.workspace = true
anyhow.workspace = true
thiserror.workspace = true
rusqlite.workspace = true
chrono.workspace = true
[dev-dependencies]
tempfile.workspace = true
pretty_assertions.workspace = true

4
sqlite/README.md Normal file
View file

@ -0,0 +1,4 @@
# taskchampion-sync-server-storage-sqlite
This crate implements a SQLite storage backend for the
`taskchampion-sync-server-core`.

View file

@ -1,9 +1,11 @@
use super::{Client, Snapshot, Storage, StorageTxn, Uuid, Version};
//! Tihs crate implements a SQLite storage backend for the TaskChampion sync server.
use anyhow::Context;
use chrono::{TimeZone, Utc};
use rusqlite::types::{FromSql, ToSql};
use rusqlite::{params, Connection, OptionalExtension};
use std::path::Path;
use taskchampion_sync_server_core::{Client, Snapshot, Storage, StorageTxn, Version};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
enum SqliteError {
@ -31,7 +33,7 @@ impl ToSql for StoredUuid {
}
}
/// An on-disk storage backend which uses SQLite
/// An on-disk storage backend which uses SQLite.
pub struct SqliteStorage {
db_file: std::path::PathBuf,
}
@ -41,6 +43,10 @@ impl SqliteStorage {
Ok(Connection::open(&self.db_file)?)
}
/// Create a new instance using a database at the given directory.
///
/// The database will be stored in a file named `taskchampion-sync-server.sqlite3` in the given
/// directory.
pub fn new<P: AsRef<Path>>(directory: P) -> anyhow::Result<SqliteStorage> {
std::fs::create_dir_all(&directory)
.with_context(|| format!("Failed to create `{}`.", directory.as_ref().display()))?;