Example
Here we build a small todo app to show how coven fits into a host. The app owns its schema, its UI, and its product policy. coven owns the SQLite connections, change capture, encrypted sync, membership, and blob transfer. The two meet at one call to open the database and a handful of methods after that.
The data model: a workspaces table holds lists, a list holds todos, and a list has a boolean shared column. Lists marked shared (and the todos under them) reach teammates; the rest stay on the device that wrote them.
Open the store
coven owns the connections. The host opens one handle with Coven::builder, handing over the set of tables that sync and the migration ladder that creates the app's own tables. On the first open of a database, coven creates its complete bookkeeping schema, version ledger, and initialization marker in one SQLite transaction. Every later writer and read-only open requires that marker and an exact known bookkeeping schema. The host explicitly authorizes a writer to apply pending Coven migrations or requires it to refuse them; readers always refuse. The writer advances the Coven ladder before any host migration rungs, then seeds its clock off the rows already on disk, attaches the change-capture session to the synced tables, and spawns the threads that own the connections — one writer and a pool of read-only connections that backs handle.read.
use coven::{Coven, CovenMigrationPolicy, Migration, RowIdentity, SyncedTable};
const SCHEMA: &str = "
CREATE TABLE workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
_updated_at TEXT NOT NULL
) STRICT;
CREATE TABLE lists (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id),
name TEXT NOT NULL,
shared INTEGER NOT NULL DEFAULT 0,
_updated_at TEXT NOT NULL
) STRICT;
CREATE TABLE todos (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id),
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
_updated_at TEXT NOT NULL
) STRICT;
";
let handle = Coven::builder(store_dir, config)
.synced_tables(vec![
SyncedTable::new("workspaces", RowIdentity::IndependentUuid),
SyncedTable::new("lists", RowIdentity::IndependentUuid).gated_by("shared"),
SyncedTable::new("todos", RowIdentity::IndependentUuid),
])
.coven_migration_policy(CovenMigrationPolicy::ApplyPending)
.migrations(vec![Migration::sql(1, "initial", SCHEMA)])
.open()?;Every synced table is declared STRICT, carries an id text primary key at column 0, and has an _updated_at TEXT NOT NULL column. (table, id) is the logical row identity on every device. These app rows are independently created, so their ids are canonical UUIDv4 or UUIDv7 values. A table instead uses RowIdentity::SharedKey only when equal application keys intentionally merge as one row. SQLite represents a primary-key change as deleting the old identity and inserting the new validated identity in the same transaction. The SyncedTable values declare how each table is gated: new syncs every row, remote_root syncs every row and makes blobs on those rows and descendants always Remote, gated_by makes a row sync only while its boolean column is true, and gated_by_descendants keeps an ancestor row alive only while a gated descendant survives. Here lists is a gated root, and todos inherit that gate down the foreign key. Tables you don't pass are local-only and never leave the device. The gating rules are in Local data.
The handle's SQL context mints _updated_at stamps from a clock already seeded past every value on disk, so a write made right after a restart can't mint a stamp that sorts behind an existing row.
Write a row
The host runs SQL through handle.write, an async method that takes a closure over a SQL context. coven re-exports the exact rusqlite it owns, so use coven::rusqlite rather than depending on rusqlite directly. Bind sql.stamp() into the _updated_at column of every synced-row write; that value is coven's register for ordering writes across devices. The transaction's rows, captured changeset, dependency frontier, write id, and initial publication status commit together.
use coven::rusqlite::params;
let receipt = handle.write(move |sql| {
let todo_id = uuid::Uuid::new_v4().to_string();
sql.execute(
"INSERT INTO todos (id, list_id, title, done, _updated_at)
VALUES (?1, ?2, ?3, 0, ?4)",
params![todo_id, list_id, title, sql.stamp()],
)?;
Ok(())
})
.await?;receipt.value is the closure's result. receipt.write_id remains stable across restart, and receipt.status is LocalOnly when no shared rows changed or Pending when this transaction awaits publication. One successful write call creates one write id and one Store commit.
Don't read the stamp as a wall-clock time or compare two of them as dates. It is an opaque clock value coven advances past pulled rows so a later local write always sorts after them.
Read a row
Reads go through handle.read, which runs the closure in one transaction on a read-only connection from a bounded pool. Independent reads can run concurrently with each other and the writer, while every statement in one closure sees a consistent snapshot. The closure gets a SqlReadContext with query operations but no retained connection (and no stamp to mint on a read). SQLite refuses writes on that connection. A read issued after an awaited write sees that write.
let titles: Vec<String> = handle
.read(move |conn| {
let mut stmt = conn.prepare(
"SELECT title FROM todos WHERE list_id = ?1 ORDER BY _updated_at",
)?;
let rows = stmt
.query_map([list_id], |row| row.get::<_, String>(0))?
.collect::<Result<_, _>>()?;
Ok(rows)
})
.await?;If building the result requires expensive parsing or computation, use handle.read(fetch).process(transform).await: fetch every database input as owned values in fetch, then build the result in transform. Processing runs on separate bounded workers after the read transaction has ended. For values that must stay current, handle.subscribe(fetch).process(transform) repeats the same extraction and processing when a relevant committed change occurs.
A local-only app stops here: open the handle, write through handle.write, read through handle.read, and never build any of the sync machinery below.
Turn on sync
Sync needs a master key and a cloud provider. The master key is protected by custody — where it's unlocked from, and where it's written when established — which defaults to the OS keyring and can be selected on the builder with key_custody before open(); the open call above didn't set one, so it got the default. Either way, the host names its keyring service once at startup with set_keyring_service — coven never reads or writes a keyring entry without it. See Keys for every preset and what each one protects against.
coven::set_keyring_service("todos")?;A fresh store has no master key yet. The atomic cloud-home setup generates one for an opaque home, prepares the provider connection under that proposed key, then commits the key, provider credentials, and replacement connection as one operation. A failed setup restores the previous custody state and leaves the previous connection installed. This store also needs its own signing identity — coven never mints one implicitly (see Keys) — initialize_identity establishes it explicitly, the same way, for a store created fresh (joining or restoring an existing store establishes its identity as part of that instead).
handle.initialize_identity()?;
let connected = handle
.setup_s3_cloud_home(cloud_home, access_key, secret_key)
.await?;
// Persist `connected.cloud_home` through the host's ConfigProvider source.Persist the returned cloud-home config only after setup succeeds. Existing stores reconnect their already-committed config with connect_sync. Which rows carry blobs is declared on the synced tables passed to open (see Attachments), not here.
After a write, nudge the loop with sync_now so the local edit goes out promptly; the loop also runs on its own timer, so a missed trigger still syncs.
handle.sync_now();The trigger is a no-op until the loop is running, so the host can call it after every write without checking.
React to remote changes
The host reads the current SyncLoopStatus through handle.subscribe_sync_status(). The watch receiver immediately contains the current value and may coalesce intermediate values. A successful cycle's row_changes is therefore a refresh hint: re-read the named rows instead of treating it as a complete event history.
let mut status = handle.subscribe_sync_status();
tokio::spawn(async move {
while status.changed().await.is_ok() {
match status.borrow_and_update().clone() {
coven::SyncLoopStatus::Synchronized(cycle) => {
if let Some(changes) = cycle.row_changes {
// Re-read the tables and rows named by this refresh hint.
}
if let Some(message) = cycle.alerts.primary_message() {
// Show the warning for this successful cycle.
}
}
coven::SyncLoopStatus::Failed { error } => {
// Show the whole-cycle failure.
}
coven::SyncLoopStatus::Blocked { success, writes } => {
// Refresh from success, then show the prerequisite each write names.
}
coven::SyncLoopStatus::Offline
| coven::SyncLoopStatus::CheckingStorage
| coven::SyncLoopStatus::Publishing => {}
}
}
});Offline is reserved for provider and network transport failures. A remote blob that fails its signed content hash, or a local cache destination that cannot be written, remains failed or held work and does not report a lost connection.
For write-specific UI, handle.pending_writes() lists every unpublished write with affected table/primary-key identities. handle.write_status(&write_id) and handle.subscribe_write_status(&write_id) expose its current durable state, including its exact published device position or a typed semantic block. handle.blocked_writes() lists only blocked records. After the prerequisite is repaired, handle.retry_blocked_write(&write_id) requeues them and wakes sync. handle.discard_blocked_write(&write_id) atomically reverses that write and every later unpublished write whose local rows depend on it. If candidate objects may already exist remotely, discard publishes signed nonactivation authority and verifies exact cleanup before reversing local rows.
Attachments
If a todo carries a file, that file is a blob. coven moves blobs with the rows that reference them, and it learns which rows carry one from a per-table declaration, not a runtime callback. The host marks the blob-bearing synced table with carries_blob when it builds the set it passes to open, naming the columns that locate each blob plus its cloud namespace, a BlobScope (Master for a key every member holds, Derived for a fixed per-scope key), a Provenance (the Local story: UserProvided for the user's own file, HostProvided for data coven keeps), and a CacheFill (the Remote story: CacheEager to fetch it into the cache on pull, CacheLazy to fetch on first read):
use coven::{BlobDecl, CacheFill, Provenance};
// In the set you pass to `Coven::builder(...).synced_tables(...)`, declare the blob on `todos`:
// blob id = the row's primary key; opaque home, so no cloud_path column;
// master-scoped; the user's own file, fetched into every device's cache on pull.
SyncedTable::new("todos", RowIdentity::IndependentUuid).carries_blob(
BlobDecl::new("todo-files", Provenance::UserProvided, CacheFill::CacheEager),
)coven resolves the declaration against the live schema each cycle and derives every blob a row references itself: what to upload on push, what to download on pull, whose cache to drop on a delete, and what to backfill after a snapshot bootstrap. It encrypts the file on upload; on pull it downloads, decrypts, and writes the plaintext into its own cache, which the host reads back through handle.read_blob. Where the bytes come from is the blob's provenance: the user's own file (user-provided) or coven's local store (host-provided). Use handle.write_with_blobs(...) when writing a row together with host-provided bytes. A blob table under remote_root() has no Local state: rows sync normally, host-provided blobs upload before the row is pushed, and reads resolve through the cache/cloud. The Blobs page covers the declaration, the outbox, and the cloud layout, and the Cache page covers the device-local read side.
Share with a teammate
A store starts with one member, the device that created it. To add a teammate, the owner starts one LAN pairing session and displays its code:
let pairing = handle.start_device_pairing().await?;
show_qr(pairing.offer().encode());The joining device scans that code, completes provider authorization when the provider requires it, and opens a durable PreparedDevicePairing. That object mints and retains the pending device identity and repeatedly submits the same signed, encrypted request to the endpoints in the code.
The owner receives the verified public key and provider account from pairing.wait_for_request(), shows them for approval, then calls handle.approve_device_pairing(...). Approval seals cloud access and the Store key to that exact identity. The invitation returns through the encrypted LAN session; the remaining signed registration artifacts use create-once objects in the Store cloud home. join_with_device_pairing(...) resumes the joining side from its journal until the Store configuration is saved. No code travels back to the joining device by camera or clipboard.
handle.remove_member(...) appends a fresh key generation the removed member never receives. The signed membership chain, key wrapping, and the join flow are covered in Sharing.
Keep some rows to a subset of the store
Membership grants the whole store. To keep a subset of rows private to some members inside it, use a Circle: a named audience whose rows only its members receive. Declare an audience column on the root, then address rows to a Circle by updating that column.
// declared: SyncedTable::new("lists", RowIdentity::IndependentUuid).scoped_by("audience")
let family = handle.circles().create("Family").await?;
handle.circles().add_member(family, &housemate_pubkey_hex).await?;
handle.write(move |sql| {
sql.execute(
"UPDATE lists SET audience = ?1 WHERE id = ?2",
params![family.to_string(), list_id],
)?;
Ok(())
}).await?;The whole Circle lifecycle — states, epoch close on member removal, control conflict resolution, deletion, and the privacy limits — is on the Circles page.