Overview
Sync normally means a backend: a server you run and pay for, and a database that holds every user's data in the clear.
coven syncs without the server. Devices exchange end-to-end-encrypted changes through storage the user already has, and merge them locally.
The data is SQLite. You keep your schema; coven owns the connections and runs your queries through them, so it can capture each change with SQLite's session extension, encrypt and sign it, move it through the user's storage, and apply remote changes back.
The round trip
No server is needed because nothing in the loop below requires one. A write is captured, sealed, and parked in storage; every other device picks it up from there. The storage never has to understand the data, which is what lets it be storage the user already has.
The provider only ever holds ciphertext. It never sees a todo title, a file, or who is allowed to write.
In your code
The integration is one builder call and a handful of methods on the handle it returns. Two beats give the flavor; the whole tour, from open to invite, is the Example.
Open the store. Declare the tables that sync and the migration ladder that builds your schema; also choose whether this writer may apply pending changes from Coven's separate bookkeeping-schema ladder. open runs the authorized Coven migrations before your ladder and returns one handle. Tables you don't list stay local to the device.
use coven::{Coven, CovenMigrationPolicy, Migration, RowIdentity, SyncedTable};
let handle = Coven::builder(store_dir, config)
.synced_tables(vec![
SyncedTable::new("todos", RowIdentity::IndependentUuid),
SyncedTable::new("todo_attachments", RowIdentity::IndependentUuid),
])
.coven_migration_policy(CovenMigrationPolicy::ApplyPending)
.migrations(vec![Migration::sql(1, "initial", MY_SCHEMA)])
.open()?;The identity argument states what equal ids mean across devices. Use IndependentUuid with canonical UUIDv4 or UUIDv7 ids for independently created rows. Use SharedKey only when equal application keys intentionally name and merge as one logical row. Changing a primary key removes the old identity and inserts the new validated identity atomically.
Write normally, through the handle. Your closure gets a transaction; coven captures what changed when it commits. Synced rows carry an _updated_at you mint with sql.stamp(); that stamp is how edits order across devices.
let id = uuid::Uuid::new_v4().to_string();
let receipt = handle.write(move |sql| {
sql.execute(
"INSERT INTO todos (id, title, _updated_at) VALUES (?1, ?2, ?3)",
coven::rusqlite::params![id, title, sql.stamp()],
)?;
Ok(())
}).await?;The returned WriteReceipt names this transaction in coven's durable write ledger. Its initial status is LocalOnly when the transaction changed no shared rows, otherwise Pending. Separate calls produce separate write ids and Store commits.
Read through the handle. Pure reads go through handle.read, which uses four read-only connection workers. Independent reads run concurrently with each other and the writer. Each closure runs in one transaction, so all its SQL statements see a consistent snapshot. SQLite refuses writes on these connections. A read issued after an awaited write sees that write.
The readers share a queue holding at most 64 waiting operations. When it is full, callers wait for admission; cancelled operations that have not started are discarded. Live queries use the same pool.
let titles: Vec<String> = handle.read(|conn| {
conn.query("SELECT title FROM todos ORDER BY _updated_at", [], |row| {
row.get(0)
}).map_err(coven::CovenError::from)
}).await?;Read again when the result can change. handle.subscribe() uses the same read context and retains the query. The first next() returns the initial result. While it runs, SQLite reports every table and column the statement reads. In a single-table query without subqueries, equality, IN, and range predicates over binary-collated INTEGER, TEXT, and BLOB primary keys are also matched against their bound values. Other SQL falls back to table-and-column matching, so an unsupported predicate may rerun more often but cannot miss a relevant committed row change. Virtual-table dependencies return an error because SQLite sessions do not report their row changes. Changes already waiting are represented by the next result.
let mut titles = handle.subscribe(|conn| {
conn.query("SELECT title FROM todos ORDER BY _updated_at", [], |row| {
row.get(0)
}).map_err(coven::CovenError::from)
});
loop {
render(titles.next().await?);
}For expensive result assembly, handle.read(fetch).process(transform).await fetches owned values in the read transaction, then passes them to a separate bounded pool after releasing the connection. Fetch every database input in fetch; transform receives owned values without a SQL context. The live equivalents are subscribe(fetch).process(transform) and subscribe_reconfigurable(request, fetch).process(transform): dependencies stay attached to the read that produced the data, and superseded requests do not replace newer results.
Without .process(...), awaiting the read returns the fetched values directly. Reads begin when awaited; subscriptions begin when their next() is awaited.
Everything else follows the same ownership boundary: handle.write_with_blobs commits a row and its file bytes in one transaction, handle.pending_writes reconstructs unpublished writes after restart, handle.connect_sync starts the background loop, handle.subscribe_sync_status exposes its current state, and handle.start_device_pairing() returns the one code an existing device displays while it receives and approves the joining device's signed identity over the LAN.
Who owns what
The integration stays small because the boundary is strict: coven owns what sync needs to be correct, and the host owns the product.
coven owns the sync layer and the database connections:
- The SQLite connections: one writer, where coven runs the change-capture session and keeps its own bookkeeping, and a bounded pool of read-only connections for application queries. Callers use the handle's write and read methods; every connection stays owned by coven.
- Capturing local changes and applying remote ones.
- Encrypting, signing, and verifying everything that leaves the device.
- Moving rows and files through your storage.
- Membership, invites, and recovery codes.
You own the app:
- Your schema and your queries, run through coven's connections.
- Which tables sync and which stay local.
- Where user-provided blob files live on disk.
- Provider configuration and credentials.
- All UI and product policy.
Topics
In reading order; each page builds on the ones before it:
- Local data: the store on one device: schema conventions, which tables sync, which rows share.
- Sync: change capture, the cycle, push and pull.
- Merge: the clock, and how concurrent edits land on every device.
- Storage: the
CloudHomecontract and the providers. - Blobs: files that rows carry, where their bytes live, and how they move.
- Cache: the device-local copies of remote files: budgets, pinning, eviction.
- Sharing: membership, roles, invite, join, revoke.
- Circles: private audiences inside one store: who receives which rows, and the Circle lifecycle.
- Bootstrap: snapshots and how a new device joins or restores.
- Encryption: the keys, what is encrypted, what the provider sees.
- Keys: where each key lives on the device, the custody presets, and what a host has to set up per platform.
- Schema evolution: migrating the synced schema while devices upgrade at different times.
Status
coven is pre-1.0.