bothy 0.4.0

Serverless, local-first sync engine that uses the user's own cloud storage (Google Drive) as the transport. Early development.
Documentation

bothy

Serverless, local-first sync engine that uses the user's own cloud storage as the transport.

⚠️ Early release (0.2). The API and the on-remote format can still change before 1.0.

Each device keeps a local SQLite database as the source of truth and works offline. Devices exchange immutable files through the user's own Google Drive (or a shared directory). There is no backend and no account of ours.

Example

use std::sync::Arc;
use bothy::{Config, Database, remote::FolderRemote};
use serde_json::json;

let remote = Arc::new(FolderRemote::new("/mnt/shared/notes")?);
let db = Database::open(Config::new("notes.db", "com.example.notes"), remote).await?;
db.init_or_join().await?;

let notes = db.collection("notes")?;
notes.put("n1", json!({"title": "Hello", "pinned": false})).await?;   // whole document
notes.patch("n1", json!({"pinned": true}), &[]).await?;               // some fields
let note = notes.get("n1").await?;

let report = db.sync().await?;              // push local changes, pull remote ones
let mut events = db.subscribe();            // local and remote change events
let auto = db.start_auto_sync(Default::default());   // inside an async context

For Google Drive, implement bothy::auth::AccessTokenProvider (OAuth stays in your app) and use remote::drive::DriveRemote::appdata(provider) for the hidden app folder (scope drive.appdata), or DriveRemote::folder(provider, "notes") for a visible Bothy/notes folder (scope drive.file, also reachable from the device sign-in flow of headless boards).

Encryption

// First device: creates the database encrypted with this password.
let db = Database::open(Config::new("notes.db", APP).encrypt(&password), remote).await?;
// Other devices: join (or unlock on open) with the same password.
let db = Database::open(Config::new("notes.db", APP).password(&password), remote).await?;
// Keep the data key in secure storage to skip the password next time.
let key = db.export_key()?;
let db = Database::open(Config::new("notes.db", APP).key(key), remote).await?;

Segment bodies, snapshot chunks and device.json are encrypted with XChaCha20-Poly1305 under a random data key, which per-device key files wrap with an Argon2id key derived from the password.

  • Wrong secret: a wrong password fails with ErrorKind::WrongPassword.
  • Locked database: opened without a secret, an encrypted database is locked. Local reads and writes still work; sync fails with KeyRequired until unlock.
  • Password change: change_password takes effect on every device, and kept data keys stay valid.
  • What stays visible: file names, sizes, times and the app id. A forgotten password cannot be recovered.

How it merges

  • Every change gets a hybrid logical clock stamp. Fields merge separately, and the larger (hlc, device) stamp wins.
  • Edits to different fields of the same document both survive. On the same field the later write wins; Database::conflicts (and Collection::conflicts per document) return the losing value. Config::conflict_log sets how many records are kept, or turns the log off.
  • Deletes keep a tombstone. An edit made before the delete never revives the document; an edit made after it does, with only the new fields.
  • Nested objects and arrays are one value. Model concurrent lists as separate documents.

Feature flags

Feature Default What it adds
drive yes remote::drive::DriveRemote (reqwest over rustls with the ring provider; no OpenSSL)
blocking yes bothy::blocking, a synchronous API with its own runtime
crypto yes encrypted databases (Config::encrypt/password/key)
test-util no remote::faulty::FaultyRemote for fault-injection tests

Links

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.