syncular-client 0.1.0

Developer-facing Rust SDK for Syncular SQLite-backed offline sync clients.
Documentation
use anyhow::Result;
use clap::{Parser, Subcommand, ValueEnum};
use syncular_client::client::{SyncularClient, SyncularClientConfig};
use syncular_client::diesel_sqlite::DieselSqliteStore;
use syncular_client::fixtures::todo::app_schema as demo_todo_app_schema;
use syncular_client::fixtures::todo::rusqlite_sqlite::RusqliteStore;
use syncular_client::store::{DemoTaskStore, SyncStateStore, SyncStore};
use syncular_client::transport::RealtimeEvent;
use syncular_client::transport::{HttpSyncTransport, SyncTransportConfig};

#[derive(Parser, Debug)]
#[command(about = "Syncular native client storage POC")]
struct Cli {
    #[arg(long, default_value = ".context/syncular-client.sqlite")]
    db: String,
    #[arg(long, default_value = "http://localhost:9811/api/sync")]
    base_url: String,
    #[arg(long, default_value = "rust-client")]
    client_id: String,
    #[arg(long, default_value = "user-rust")]
    actor_id: String,
    #[arg(long)]
    project_id: Option<String>,
    #[arg(long, value_enum, default_value_t = StoreBackend::Diesel)]
    store: StoreBackend,
    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Clone, ValueEnum)]
enum StoreBackend {
    Diesel,
    Rusqlite,
}

#[derive(Subcommand, Debug)]
enum Command {
    AddTask {
        title: String,
        #[arg(long)]
        id: Option<String>,
    },
    PatchTaskTitle {
        id: String,
        title: String,
    },
    Sync,
    SyncWs,
    Watch {
        #[arg(long, default_value_t = 30)]
        seconds: u64,
    },
    ListTasks,
    Migrations,
    Outbox,
    Conflicts,
    ResolveConflict {
        id: String,
        resolution: String,
    },
    RetryConflictKeepLocal {
        id: String,
    },
}

impl Cli {
    fn into_config(&self) -> SyncularClientConfig {
        SyncularClientConfig {
            db_path: self.db.clone(),
            base_url: self.base_url.clone(),
            client_id: self.client_id.clone(),
            actor_id: self.actor_id.clone(),
            project_id: self.project_id.clone(),
        }
    }
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    let config = cli.into_config();

    match cli.store {
        StoreBackend::Diesel => {
            let app_schema = demo_todo_app_schema();
            let store = DieselSqliteStore::open_with_schema(&config.db_path, app_schema)?;
            let transport = HttpSyncTransport::new(SyncTransportConfig::new(
                config.base_url.clone(),
                config.client_id.clone(),
                config.actor_id.clone(),
            ))
            .with_schema_version(app_schema.current_schema_version());
            run_client(
                SyncularClient::with_app_schema_parts(config, store, transport, app_schema),
                cli.command,
            )
        }
        StoreBackend::Rusqlite => {
            let store = RusqliteStore::open(&config.db_path)?;
            let app_schema = demo_todo_app_schema();
            let transport = HttpSyncTransport::new(SyncTransportConfig::new(
                config.base_url.clone(),
                config.client_id.clone(),
                config.actor_id.clone(),
            ))
            .with_schema_version(app_schema.current_schema_version());
            run_client(
                SyncularClient::with_app_schema_parts(config, store, transport, app_schema),
                cli.command,
            )
        }
    }
}

fn run_client<S>(mut client: SyncularClient<S>, command: Command) -> Result<()>
where
    S: SyncStore + SyncStateStore + DemoTaskStore,
{
    match command {
        Command::AddTask { title, id } => {
            let task_id = client.add_task(title, id)?;
            println!("queued task {task_id}");
        }
        Command::PatchTaskTitle { id, title } => {
            client.patch_task_title(id.clone(), title)?;
            println!("queued task title patch {id}");
        }
        Command::Sync => {
            client.sync_http()?;
            println!("sync complete");
        }
        Command::SyncWs => {
            client.sync_ws()?;
            println!("websocket push sync complete");
        }
        Command::Watch { seconds } => {
            println!("watching websocket for {seconds}s");
            client.watch(seconds, |event| match event {
                RealtimeEvent::Sync => println!("ws event: sync"),
                RealtimeEvent::Presence(event) => {
                    println!("ws event: presence {} {}", event.action, event.scope_key)
                }
                RealtimeEvent::Other(event) => println!("ws event: {event}"),
            })?;
        }
        Command::ListTasks => {
            for task in client.list_tasks()? {
                println!(
                    "{} [{}] user={} project={} v{}",
                    task.title,
                    task.id,
                    task.user_id,
                    task.project_id.unwrap_or_else(|| "-".to_string()),
                    task.server_version
                );
            }
        }
        Command::Migrations => {
            for migration in client.applied_migrations()? {
                println!(
                    "{} {} {} {}",
                    migration.version, migration.name, migration.checksum, migration.applied_at
                );
            }
        }
        Command::Outbox => {
            for commit in client.outbox_summaries()? {
                println!(
                    "{} {} schema={}",
                    commit.client_commit_id, commit.status, commit.schema_version
                );
            }
        }
        Command::Conflicts => {
            for conflict in client.conflict_summaries()? {
                println!(
                    "{} {} op={} {} code={} server_version={} {}",
                    conflict.id,
                    conflict.client_commit_id,
                    conflict.op_index,
                    conflict.result_status,
                    conflict.code.unwrap_or_else(|| "-".to_string()),
                    conflict
                        .server_version
                        .map(|value| value.to_string())
                        .unwrap_or_else(|| "-".to_string()),
                    conflict.message
                );
            }
        }
        Command::ResolveConflict { id, resolution } => {
            client.resolve_conflict(&id, &resolution)?;
            println!("resolved conflict {id} as {resolution}");
        }
        Command::RetryConflictKeepLocal { id } => {
            let client_commit_id = client.retry_conflict_keep_local(&id)?;
            println!("resolved conflict {id} as keep-local and queued retry {client_commit_id}");
        }
    }

    Ok(())
}