Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RouchDB

A local-first document database for Rust with CouchDB replication protocol support.

RouchDB is the Rust equivalent of PouchDB — it gives you a local JSON document store that syncs bidirectionally with Apache CouchDB and compatible servers.

Why RouchDB?

  • No equivalent exists in Rust. Crates like couch_rs provide CouchDB HTTP clients, but none offer local storage with replication. RouchDB fills this gap.
  • Offline-first by design. Your app works without a network connection. When connectivity returns, RouchDB syncs changes automatically.
  • Full CouchDB compatibility. Implements the CouchDB replication protocol, revision tree model, and Mango query language.

Features

  • CRUD operationsput, get, update, remove, bulk_docs, all_docs
  • Mango queries$eq, $gt, $in, $regex, $or, and 15+ operators
  • Map/reduce views — Rust closures with built-in Sum, Count, Stats reduces
  • Changes feed — one-shot and live streaming of document mutations
  • Replication — push, pull, and bidirectional sync with CouchDB
  • Conflict resolution — deterministic winner algorithm, conflict detection utilities
  • Pluggable storage — in-memory, persistent (redb), or remote (HTTP)
  • Pure Rust — no C dependencies, compiles everywhere Rust does

Target Use Cases

  • Desktop apps with Tauri that need offline sync
  • CLI tools that store data locally and sync to a server
  • Backend services that replicate between CouchDB instances
  • Any Rust application that needs a local document database

Quick Example

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let db = Database::memory("mydb");

    // Create a document
    let result = db.put("user:alice", serde_json::json!({
        "name": "Alice",
        "age": 30
    })).await?;

    // Read it back
    let doc = db.get("user:alice").await?;
    println!("{}: {}", doc.id, doc.data);

    Ok(())
}

Ready to start? Head to Installation.

Installation

Full Package

Add RouchDB to your project with all features:

[dependencies]
rouchdb = "0.1"
tokio = { version = "1", features = ["full"] }
serde_json = "1"

This gives you local storage (redb), HTTP client, replication, queries, and the high-level Database API.

Minimal Setup

If you only need local storage without replication or HTTP:

[dependencies]
rouchdb-core = "0.1"
rouchdb-adapter-redb = "0.1"
tokio = { version = "1", features = ["full"] }
serde_json = "1"

Individual Crates

Pick exactly what you need:

CrateWhat it adds
rouchdb-coreTypes, traits, revision tree, collation, errors
rouchdb-adapter-memoryIn-memory adapter (testing, ephemeral data)
rouchdb-adapter-redbPersistent local storage via redb
rouchdb-adapter-httpCouchDB HTTP client adapter
rouchdb-changesChanges feed (one-shot and live streaming)
rouchdb-replicationCouchDB replication protocol
rouchdb-queryMango selectors and map/reduce views
rouchdb-viewsDesign documents and persistent view engine
rouchdbUmbrella crate — re-exports everything above

Async Runtime

RouchDB is built on Tokio. All database operations are async and require a Tokio runtime:

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let db = rouchdb::Database::memory("mydb");
    // ... your code here
    Ok(())
}

Verifying the Installation

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let db = Database::memory("test");

    let result = db.put("hello", serde_json::json!({"msg": "it works!"})).await?;
    assert!(result.ok);

    let doc = db.get("hello").await?;
    println!("{}", doc.data["msg"]); // "it works!"

    Ok(())
}

If this compiles and prints "it works!", you’re all set. Head to the Quickstart.

Quickstart

This guide walks you through the core features of RouchDB in 5 minutes.

Create a Database

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    // In-memory (data lost when dropped — great for testing)
    let db = Database::memory("mydb");

    // Persistent (stored on disk via redb)
    // let db = Database::open("mydb.redb", "mydb")?;

    // Remote CouchDB
    // let db = Database::http("http://admin:password@localhost:5984/mydb");

    Ok(())
}

Put and Get Documents

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let db = Database::memory("mydb");

    // Create a document
    let result = db.put("user:alice", serde_json::json!({
        "name": "Alice",
        "email": "alice@example.com",
        "age": 30
    })).await?;

    println!("Created with rev: {}", result.rev.unwrap());

    // Read it back
    let doc = db.get("user:alice").await?;
    println!("Name: {}", doc.data["name"]); // "Alice"

    Ok(())
}

Update and Delete

Every update requires the current revision to prevent conflicts:

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let db = Database::memory("mydb");

    // Create
    let r1 = db.put("user:alice", serde_json::json!({"name": "Alice", "age": 30})).await?;
    let rev = r1.rev.unwrap();

    // Update (must provide current rev)
    let r2 = db.update("user:alice", &rev, serde_json::json!({
        "name": "Alice",
        "age": 31
    })).await?;

    // Delete (must provide current rev)
    let rev2 = r2.rev.unwrap();
    db.remove("user:alice", &rev2).await?;

    Ok(())
}

Query with Mango

Find documents matching a selector:

use rouchdb::{Database, FindOptions};

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let db = Database::memory("mydb");

    db.put("alice", serde_json::json!({"name": "Alice", "age": 30})).await?;
    db.put("bob", serde_json::json!({"name": "Bob", "age": 25})).await?;
    db.put("carol", serde_json::json!({"name": "Carol", "age": 35})).await?;

    // Find users older than 28
    let result = db.find(FindOptions {
        selector: serde_json::json!({"age": {"$gte": 28}}),
        ..Default::default()
    }).await?;

    for doc in &result.docs {
        println!("{}: age {}", doc["name"], doc["age"]);
    }
    // Alice: age 30
    // Carol: age 35

    Ok(())
}

Sync Two Databases

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let local = Database::memory("local");
    let remote = Database::memory("remote");

    // Add data to each side
    local.put("doc1", serde_json::json!({"from": "local"})).await?;
    remote.put("doc2", serde_json::json!({"from": "remote"})).await?;

    // Bidirectional sync
    let (push, pull) = local.sync(&remote).await?;
    println!("Push: {} docs written", push.docs_written);
    println!("Pull: {} docs written", pull.docs_written);

    // Both databases now have both documents
    let info = local.info().await?;
    println!("Local has {} docs", info.doc_count); // 2

    Ok(())
}

Next Steps

Core Concepts

Understanding these five concepts will make everything else in RouchDB click.

Documents

A document is a JSON object identified by a unique _id. This is the fundamental unit of data in RouchDB.

{
  "_id": "user:alice",
  "_rev": "1-abc123...",
  "name": "Alice",
  "email": "alice@example.com",
  "age": 30
}
  • _id — unique identifier you choose (or RouchDB generates a UUID)
  • _rev — revision string managed by RouchDB (never set this manually)
  • Everything else is your data, stored as serde_json::Value

In Rust, you work with the Document struct:

#![allow(unused)]
fn main() {
pub struct Document {
    pub id: String,
    pub rev: Option<Revision>,
    pub deleted: bool,
    pub data: serde_json::Value,
    pub attachments: HashMap<String, AttachmentMeta>,
}
}

Revisions

Every time a document is created or updated, RouchDB assigns it a new revision. Revisions look like {generation}-{hash}:

1-9a2c3b4d5e6f...    (first version)
2-7f8e9d0c1b2a...    (after first update)
3-3a4b5c6d7e8f...    (after second update)
  • The generation (1, 2, 3…) counts how many times the document has been modified.
  • The hash is an MD5 digest of the document’s content, making it deterministic.

Why revisions matter:

  1. Conflict detection — when you update a document, you must provide the current _rev. If someone else updated it first, you get a Conflict error.
  2. Replication — revisions let two databases figure out which changes they’re missing.
  3. History — RouchDB keeps a tree of revisions (not the full data — old revisions are compacted away).

Conflicts

Conflicts happen when the same document is modified on two different replicas before they sync.

         Replica A              Replica B
            |                      |
        doc rev 1-abc          doc rev 1-abc
            |                      |
        update to 2-def        update to 2-ghi
            |                      |
            +-------  sync  -------+
            |                      |
        conflict! two rev-2 branches

RouchDB handles this the same way CouchDB does:

  1. Deterministic winner — one revision is picked as the “winner” using a consistent algorithm (non-deleted beats deleted, higher generation wins, ties broken by lexicographic hash comparison).
  2. No data loss — the “losing” revision is kept as a conflict. You can read it and resolve it.
  3. Application-level resolution — your code decides how to merge conflicting changes.

Read more in the Conflict Resolution guide.

Adapters

An adapter is a storage backend. RouchDB provides three built-in adapters behind a single Adapter trait:

AdapterConstructorUse case
MemoryDatabase::memory("name")Tests, ephemeral caches
RedbDatabase::open("path.redb", "name")Persistent local storage
HTTPDatabase::http("http://...")Remote CouchDB server

All three implement the same trait, so your code works identically regardless of the backend:

#![allow(unused)]
fn main() {
// This function works with any adapter
async fn count_docs(db: &Database) -> rouchdb::Result<u64> {
    let info = db.info().await?;
    Ok(info.doc_count)
}
}

You can also implement the Adapter trait yourself for custom backends (SQLite, S3, etc.).

Changes Feed

Every mutation (create, update, delete) gets a sequence number. The changes feed lets you query “what changed since sequence X?”:

#![allow(unused)]
fn main() {
use rouchdb::{Database, ChangesOptions, Seq};

async fn example(db: &Database) -> rouchdb::Result<()> {
    let changes = db.changes(ChangesOptions {
        since: Seq::Num(0), // from the beginning
        include_docs: true,
        ..Default::default()
    }).await?;

    for change in &changes.results {
        println!("seq {}: {} {}",
            change.seq,
            change.id,
            if change.deleted { "(deleted)" } else { "" }
        );
    }

    Ok(())
}
}

The changes feed is also the backbone of replication — it’s how two databases discover what they need to sync.

For live updates (streaming changes as they happen), see the Changes Feed guide.

CRUD Operations

RouchDB provides a familiar document database API for creating, reading, updating, and deleting JSON documents. Every document has an _id (string identifier) and a _rev (revision string) that tracks its edit history.

Creating a Database

#![allow(unused)]
fn main() {
use rouchdb::Database;

// In-memory (data lost when dropped; great for tests)
let db = Database::memory("mydb");

// Persistent storage backed by redb
let db = Database::open("path/to/mydb.redb", "mydb")?;

// Remote CouchDB instance
let db = Database::http("http://localhost:5984/mydb");
}

Put (Create)

Use put() to create a new document with a given ID. The data is any serde_json::Value.

#![allow(unused)]
fn main() {
use serde_json::json;

let result = db.put("user:alice", json!({
    "name": "Alice",
    "email": "alice@example.com",
    "age": 30
})).await?;

assert!(result.ok);
assert_eq!(result.id, "user:alice");

// The new revision string (e.g. "1-a3f2b...")
let rev = result.rev.unwrap();
}

The returned DocResult contains:

  • ok – whether the write succeeded.
  • id – the document ID.
  • rev – the new revision string (if successful).
  • error / reason – error details (if failed).

Get (Read)

Retrieve a document by its ID.

#![allow(unused)]
fn main() {
let doc = db.get("user:alice").await?;

println!("Name: {}", doc.data["name"]);
println!("Rev:  {}", doc.rev.as_ref().unwrap());
}

The Document struct contains:

  • id – the document ID.
  • rev – the current Revision (pos + hash).
  • deleted – whether this document is deleted.
  • data – the JSON body as serde_json::Value.
  • attachments – a map of AttachmentMeta entries.

Get with Options

Use get_with_opts() when you need a specific revision or conflict information.

#![allow(unused)]
fn main() {
use rouchdb::GetOptions;

let doc = db.get_with_opts("user:alice", GetOptions {
    rev: Some("1-a3f2b...".to_string()),
    conflicts: true,
    ..Default::default()
}).await?;
}

GetOptions fields:

  • rev – fetch a specific revision instead of the winner.
  • conflicts – include conflicting revision IDs.
  • open_revs – return all open (leaf) revisions.
  • revs – include the full revision history chain.

Update

To update a document you must supply the current revision. This prevents lost updates when multiple writers are active.

#![allow(unused)]
fn main() {
let r1 = db.put("user:alice", json!({"name": "Alice", "age": 30})).await?;
let rev = r1.rev.unwrap();

let r2 = db.update("user:alice", &rev, json!({
    "name": "Alice",
    "age": 31,
    "email": "alice@newdomain.com"
})).await?;

assert!(r2.ok);
// r2.rev is now "2-..."
}

If you pass a stale revision, RouchDB returns RouchError::Conflict.

Remove (Delete)

Deletion in CouchDB-compatible databases is a soft delete: the document is marked with _deleted: true and a new revision is created.

#![allow(unused)]
fn main() {
let doc = db.get("user:alice").await?;
let rev = doc.rev.unwrap().to_string();

let result = db.remove("user:alice", &rev).await?;
assert!(result.ok);
}

After deletion, db.get("user:alice") returns RouchError::NotFound. The document still participates in replication so that deletions propagate to other replicas.

Bulk Docs

Write multiple documents in a single atomic operation. This is more efficient than individual puts, and it is the only way to write documents with explicit revision control.

#![allow(unused)]
fn main() {
use rouchdb::{Document, BulkDocsOptions};
use std::collections::HashMap;

let docs = vec![
    Document {
        id: "user:bob".into(),
        rev: None,
        deleted: false,
        data: json!({"name": "Bob"}),
        attachments: HashMap::new(),
    },
    Document {
        id: "user:carol".into(),
        rev: None,
        deleted: false,
        data: json!({"name": "Carol"}),
        attachments: HashMap::new(),
    },
];

let results = db.bulk_docs(docs, BulkDocsOptions::new()).await?;

for r in &results {
    println!("{}: ok={}", r.id, r.ok);
}
}

BulkDocsOptions has one key field:

  • new_edits (default true) – when true, the adapter generates new revision IDs and checks for conflicts. Set to false via BulkDocsOptions::replication() during replication, where revisions are accepted as-is.

All Docs

Query all documents in the database, optionally filtered by key range.

#![allow(unused)]
fn main() {
use rouchdb::AllDocsOptions;

let response = db.all_docs(AllDocsOptions {
    include_docs: true,
    start_key: Some("user:a".into()),
    end_key: Some("user:d".into()),
    limit: Some(10),
    ..AllDocsOptions::new()
}).await?;

for row in &response.rows {
    println!("{} rev={}", row.id, row.value.rev);
    if let Some(ref doc) = row.doc {
        println!("  data: {}", doc);
    }
}
}

AllDocsOptions fields:

  • start_key / end_key – define the key range (string-sorted).
  • key – fetch a single key.
  • keys – fetch a specific set of keys.
  • include_docs – embed full document bodies in results.
  • conflicts – include conflict information for each document.
  • update_seq – include the database update sequence in the response.
  • descending – reverse the sort order.
  • skip – number of rows to skip.
  • limit – maximum number of rows.
  • inclusive_end – whether to include end_key (default true).

The response is an AllDocsResponse with total_rows, offset, and a rows vector of AllDocsRow.

Error Handling

RouchDB uses the RouchError enum for all errors. The most common ones in CRUD operations:

#![allow(unused)]
fn main() {
use rouchdb::RouchError;

match db.get("nonexistent").await {
    Ok(doc) => println!("Found: {}", doc.id),
    Err(RouchError::NotFound(msg)) => {
        println!("Document not found: {}", msg);
    }
    Err(e) => println!("Other error: {}", e),
}

// Conflict when updating with a stale revision
match db.update("user:alice", "1-stale", json!({})).await {
    Err(RouchError::Conflict) => {
        println!("Conflict! Re-read and retry.");
    }
    _ => {}
}
}

Key error variants:

  • RouchError::NotFound(String) – document or resource does not exist.
  • RouchError::Conflict – document update conflict (stale revision).
  • RouchError::BadRequest(String) – malformed input.
  • RouchError::InvalidRev(String) – unparseable revision string.
  • RouchError::MissingId – document ID was not provided.
  • RouchError::Unauthorized / RouchError::Forbidden(String) – access denied.
  • RouchError::DatabaseError(String) – storage-level errors.

Idiomatic Update-Retry Pattern

Because conflicts are expected in a multi-replica system, a common pattern is to re-read and retry:

#![allow(unused)]
fn main() {
loop {
    let doc = db.get("counter").await?;
    let rev = doc.rev.unwrap().to_string();
    let mut count = doc.data["count"].as_u64().unwrap_or(0);
    count += 1;

    match db.update("counter", &rev, json!({"count": count})).await {
        Ok(_) => break,
        Err(RouchError::Conflict) => continue, // retry
        Err(e) => return Err(e),
    }
}
}

Querying

RouchDB provides two query mechanisms, both compatible with CouchDB semantics:

  1. Mango queries – declarative, JSON-based selectors (like MongoDB’s query language).
  2. Map/reduce views – programmatic queries using Rust closures with optional aggregation.

Mango Queries

Mango is the simplest way to find documents. You provide a selector (a JSON object describing the match criteria) and the engine scans all documents, returning those that match.

Basic Find

#![allow(unused)]
fn main() {
use rouchdb::{Database, FindOptions};
use serde_json::json;

let db = Database::memory("mydb");
db.put("alice", json!({"name": "Alice", "age": 30, "city": "NYC"})).await?;
db.put("bob", json!({"name": "Bob", "age": 25, "city": "LA"})).await?;
db.put("carol", json!({"name": "Carol", "age": 35, "city": "NYC"})).await?;

let result = db.find(FindOptions {
    selector: json!({"age": {"$gte": 28}}),
    ..Default::default()
}).await?;

// Returns Alice (30) and Carol (35)
for doc in &result.docs {
    println!("{}", doc["name"]);
}
}

The FindResponse contains a single field: docs, a Vec<serde_json::Value> of matching documents with _id and _rev included.

FindOptions

#![allow(unused)]
fn main() {
use rouchdb::{FindOptions, SortField};
use std::collections::HashMap;

let opts = FindOptions {
    selector: json!({"city": "NYC"}),
    fields: Some(vec!["name".into(), "age".into()]),
    sort: Some(vec![
        SortField::Simple("age".into()),
        // Or with explicit direction:
        SortField::WithDirection(HashMap::from([
            ("name".into(), "desc".into())
        ])),
    ]),
    limit: Some(10),
    skip: Some(0),
};
}
  • selector – the query (see operators below).
  • fields – field projection; only these fields (plus _id) are returned.
  • sort – sort by one or more fields, ascending ("asc") or descending ("desc").
  • limit – maximum number of results.
  • skip – number of results to skip (for pagination).

Comparison Operators

OperatorDescriptionExample
$eqEqual (also the implicit default){"age": {"$eq": 30}} or {"age": 30}
$neNot equal{"status": {"$ne": "archived"}}
$gtGreater than{"age": {"$gt": 20}}
$gteGreater than or equal{"age": {"$gte": 21}}
$ltLess than{"price": {"$lt": 100}}
$lteLess than or equal{"price": {"$lte": 99.99}}
$inValue is in array{"color": {"$in": ["red", "blue"]}}
$ninValue is not in array{"color": {"$nin": ["green"]}}

You can combine multiple operators on the same field to express ranges:

#![allow(unused)]
fn main() {
// Documents where 20 < age < 40
let selector = json!({"age": {"$gt": 20, "$lt": 40}});
}

Existence and Type Operators

OperatorDescriptionExample
$existsField exists (or not){"email": {"$exists": true}}
$typeField is a specific JSON type{"age": {"$type": "number"}}

Supported type names: "null", "boolean", "number", "string", "array", "object".

String Operators

OperatorDescriptionExample
$regexMatches a regular expression{"name": {"$regex": "^Ali"}}

Array Operators

OperatorDescriptionExample
$allArray contains all listed elements{"tags": {"$all": ["rust", "db"]}}
$sizeArray has exactly N elements{"tags": {"$size": 3}}
$elemMatchAt least one element matches sub-selectorSee below

$elemMatch example with an array of objects:

#![allow(unused)]
fn main() {
let selector = json!({
    "scores": {
        "$elemMatch": {
            "subject": "math",
            "grade": {"$gt": 80}
        }
    }
});
}

Arithmetic Operators

OperatorDescriptionExample
$modModulo: [divisor, remainder]{"n": {"$mod": [3, 1]}}

Logical Operators

OperatorDescriptionExample
$andAll sub-selectors must match{"$and": [{"age": {"$gte": 18}}, {"active": true}]}
$orAt least one sub-selector must match{"$or": [{"city": "NYC"}, {"city": "LA"}]}
$notNegate a selector{"$not": {"status": "archived"}}
$norNone of the sub-selectors match{"$nor": [{"status": "banned"}, {"age": {"$lt": 13}}]}

Note: multiple fields in the same selector object are an implicit $and:

#![allow(unused)]
fn main() {
// Equivalent to $and
let selector = json!({"name": "Alice", "age": {"$gte": 25}});
}

$not can also be used at the field level:

#![allow(unused)]
fn main() {
// Field-level negation: age is NOT greater than 30
let selector = json!({"age": {"$not": {"$gt": 30}}});
}

Nested Fields

Use dot notation to query nested objects:

#![allow(unused)]
fn main() {
let selector = json!({"address.city": "NYC"});
}

Map/Reduce Views

Map/reduce gives you full programmatic control. You provide a map function (a Rust closure) that receives each document and emits key-value pairs. An optional reduce function aggregates the emitted values.

Map-Only Query

#![allow(unused)]
fn main() {
use rouchdb::{query_view, ViewQueryOptions};

let result = query_view(
    db.adapter(),
    &|doc| {
        // Emit the city as the key, 1 as the value
        let city = doc.get("city").cloned().unwrap_or(json!(null));
        vec![(city, json!(1))]
    },
    None, // no reduce
    ViewQueryOptions::new(),
).await?;

for row in &result.rows {
    println!("key={}, id={}", row.key, row.id.as_deref().unwrap_or(""));
}
}

The map closure receives a &serde_json::Value (the full document including _id and _rev) and returns a Vec<(serde_json::Value, serde_json::Value)> of emitted key-value pairs. You can emit zero, one, or multiple pairs per document.

Results are sorted by key using CouchDB’s collation order.

Key Filtering

#![allow(unused)]
fn main() {
let result = query_view(
    db.adapter(),
    &|doc| {
        let name = doc.get("name").cloned().unwrap_or(json!(null));
        vec![(name, json!(1))]
    },
    None,
    ViewQueryOptions {
        key: Some(json!("Bob")),       // exact key match
        ..ViewQueryOptions::new()
    },
).await?;
}

ViewQueryOptions fields:

  • key – return only rows with this exact key.
  • start_key / end_key – define a key range (inclusive by default).
  • inclusive_end – whether to include the end key.
  • descending – reverse the sort order.
  • skip / limit – pagination.
  • include_docs – embed full documents.
  • reduce – whether to run the reduce function.
  • group – group reduced results by key.
  • group_level – for array keys, group by the first N elements.

Built-In Reduce Functions

RouchDB provides three built-in reduce functions matching CouchDB:

#![allow(unused)]
fn main() {
use rouchdb::ReduceFn;

// Sum all emitted numeric values
let sum_result = query_view(
    db.adapter(),
    &|doc| {
        let age = doc.get("age").cloned().unwrap_or(json!(0));
        vec![(json!(null), age)]
    },
    Some(&ReduceFn::Sum),
    ViewQueryOptions {
        reduce: true,
        ..ViewQueryOptions::new()
    },
).await?;
// sum_result.rows[0].value == 90.0 (30 + 25 + 35)
}
  • ReduceFn::Sum – sums all numeric values.
  • ReduceFn::Count – counts the number of rows.
  • ReduceFn::Stats – computes {"sum", "count", "min", "max", "sumsqr"}.

Group By

Group reduce results by key:

#![allow(unused)]
fn main() {
let result = query_view(
    db.adapter(),
    &|doc| {
        let city = doc.get("city").cloned().unwrap_or(json!(null));
        vec![(city, json!(1))]
    },
    Some(&ReduceFn::Count),
    ViewQueryOptions {
        reduce: true,
        group: true,
        ..ViewQueryOptions::new()
    },
).await?;

// Returns: [{"key": "LA", "value": 1}, {"key": "NYC", "value": 2}]
}

For compound (array) keys, use group_level to control grouping granularity:

#![allow(unused)]
fn main() {
let result = query_view(
    db.adapter(),
    &|doc| {
        let year = doc.get("year").cloned().unwrap_or(json!(null));
        let month = doc.get("month").cloned().unwrap_or(json!(null));
        vec![(json!([year, month]), json!(1))]
    },
    Some(&ReduceFn::Count),
    ViewQueryOptions {
        reduce: true,
        group: true,
        group_level: Some(1), // group by year only
        ..ViewQueryOptions::new()
    },
).await?;
}

Custom Reduce

For aggregations not covered by the built-ins, use ReduceFn::Custom:

#![allow(unused)]
fn main() {
let max_reduce = ReduceFn::Custom(Box::new(|_keys, values, _rereduce| {
    let max = values
        .iter()
        .filter_map(|v| v.as_f64())
        .fold(f64::NEG_INFINITY, f64::max);
    json!(max)
}));

let result = query_view(
    db.adapter(),
    &|doc| {
        let age = doc.get("age").cloned().unwrap_or(json!(0));
        vec![(json!(null), age)]
    },
    Some(&max_reduce),
    ViewQueryOptions {
        reduce: true,
        ..ViewQueryOptions::new()
    },
).await?;
}

The custom function receives (keys, values, rereduce). When rereduce is true, the function is being called to combine previously-reduced results.

Mango vs Map/Reduce: When to Use Each

Use CaseRecommendation
Simple field equality/range filtersMango
Field projection (return only some fields)Mango
Aggregation (sums, counts, statistics)Map/reduce
Complex key transformationsMap/reduce
Grouping by compound keysMap/reduce
Quick ad-hoc queriesMango
Custom sort by computed valueMap/reduce

Both approaches scan all documents (no persistent indexes yet), so performance is similar for small to medium databases. Map/reduce is more powerful but requires writing Rust closures, while Mango selectors can be built from JSON configuration at runtime.

Changes Feed

The changes feed is a core CouchDB concept: it provides an ordered log of every document modification in the database. Each change has a sequence number (Seq) that increases monotonically. You can ask for all changes since a given sequence, making the changes feed the foundation for replication, live queries, and reactive UIs.

One-Shot Changes

The simplest usage fetches all changes that have occurred since a given sequence, then returns.

#![allow(unused)]
fn main() {
use rouchdb::{Database, ChangesOptions, Seq};
use serde_json::json;

let db = Database::memory("mydb");
db.put("a", json!({"v": 1})).await?;
db.put("b", json!({"v": 2})).await?;
db.put("c", json!({"v": 3})).await?;

let response = db.changes(ChangesOptions {
    since: Seq::default(), // start from the beginning
    include_docs: true,
    ..Default::default()
}).await?;

for event in &response.results {
    println!("seq={} id={} deleted={}", event.seq, event.id, event.deleted);
    if let Some(ref doc) = event.doc {
        println!("  doc: {}", doc);
    }
    for change_rev in &event.changes {
        println!("  rev: {}", change_rev.rev);
    }
}

println!("Last seq: {}", response.last_seq);
}

ChangesOptions

#![allow(unused)]
fn main() {
use rouchdb::{ChangesOptions, Seq};

let opts = ChangesOptions {
    since: Seq::Num(5),                          // start after sequence 5
    limit: Some(100),                             // return at most 100 changes
    descending: false,                            // chronological order
    include_docs: true,                           // embed full document bodies
    live: false,                                  // one-shot mode
    doc_ids: Some(vec!["user:alice".into()]),     // only these document IDs
    selector: None,                               // Mango selector filter
};
}
FieldTypeDescription
sinceSeqReturn changes after this sequence. Seq::Num(0) or Seq::default() means from the beginning.
limitOption<u64>Maximum number of change events to return.
descendingboolReverse the order (newest first).
include_docsboolInclude the full document body in each event.
liveboolUsed internally by the adapter; for live streaming, use LiveChangesStream.
doc_idsOption<Vec<String>>Filter changes to only these document IDs.
selectorOption<serde_json::Value>Mango selector — only changes matching this selector are returned.

ChangesResponse and ChangeEvent

The response has two fields:

  • results – a Vec<ChangeEvent>, each containing:
    • seq – the sequence identifier for this change.
    • id – the document ID.
    • changes – a list of ChangeRev structs (each has a rev string).
    • deletedtrue if this change was a deletion.
    • doc – the document body (when include_docs is true).
  • last_seq – the sequence of the last event in the batch. Pass this as since in subsequent calls.

Incremental Polling

Fetch changes in pages by saving last_seq:

#![allow(unused)]
fn main() {
let mut since = Seq::default();

loop {
    let response = db.changes(ChangesOptions {
        since: since.clone(),
        limit: Some(50),
        ..Default::default()
    }).await?;

    if response.results.is_empty() {
        break; // caught up
    }

    for event in &response.results {
        process_change(event);
    }

    since = response.last_seq;
}
}

The Seq Type

Sequence identifiers differ between adapters:

  • Local adapters (memory, redb) use numeric sequences: Seq::Num(1), Seq::Num(2), etc.
  • CouchDB 3.x uses opaque string sequences: Seq::Str("13-g1AAAA...").

The Seq enum handles both transparently:

#![allow(unused)]
fn main() {
use rouchdb::Seq;

let local_seq = Seq::Num(42);
let couch_seq = Seq::Str("13-g1AAAABXeJzLYWBg...".into());

// Extract the numeric value (parses the prefix for CouchDB strings)
let n: u64 = couch_seq.as_num(); // 13

// Format for HTTP query parameters
let qs: String = couch_seq.to_query_string();

// The default is Seq::Num(0) (the beginning)
let start = Seq::default();
}

Always pass last_seq back as-is to since rather than trying to parse or increment it. This ensures correct behavior with CouchDB’s opaque sequences.

Live Changes Stream

For real-time reactivity, LiveChangesStream yields change events continuously, blocking when there are no new changes.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use rouchdb_changes::{LiveChangesStream, ChangesStreamOptions, ChangeSender};
use rouchdb::Seq;

let db = Arc::new(rouchdb_adapter_memory::MemoryAdapter::new("mydb"));

// Create a broadcast channel for instant notifications
let (sender, receiver) = ChangeSender::new(64);

let mut stream = LiveChangesStream::new(
    db.clone(),
    Some(receiver),
    ChangesStreamOptions {
        since: Seq::default(),
        live: true,
        include_docs: true,
        limit: None,          // no limit; run forever
        ..Default::default()
    },
);

// In a loop, await the next change
loop {
    match stream.next_change().await {
        Some(event) => {
            println!("Change: {} seq={}", event.id, event.seq);
            if event.deleted {
                println!("  (deleted)");
            }
        }
        None => {
            println!("Stream ended");
            break;
        }
    }
}
}

ChangesStreamOptions

ChangesStreamOptions extends the one-shot options for live mode:

#![allow(unused)]
fn main() {
use std::time::Duration;

let opts = ChangesStreamOptions {
    since: Seq::default(),
    live: true,                                // keep listening
    include_docs: false,
    doc_ids: None,
    limit: Some(1000),                         // stop after 1000 total events
    poll_interval: Duration::from_millis(500), // fallback polling interval
};
}

The poll_interval is used only when no broadcast channel is provided. When a ChangeReceiver is available, the stream blocks on the broadcast channel for instant notification instead of polling.

How It Works

The LiveChangesStream operates through a simple state machine:

  1. FetchingInitial – on first call, fetches all changes since the given sequence.
  2. Yielding – returns buffered change events one at a time.
  3. Waiting – when the buffer is exhausted, waits for a notification (via the broadcast channel) or polls on a timer.
  4. Done – when the limit is reached or the channel closes.

ChangeSender / ChangeReceiver

The ChangeSender and ChangeReceiver pair provides a Tokio broadcast channel for notifying live streams when documents change.

#![allow(unused)]
fn main() {
use rouchdb_changes::ChangeSender;
use rouchdb::Seq;

// Create the channel pair (capacity = max buffered notifications)
let (sender, initial_receiver) = ChangeSender::new(64);

// Create additional subscribers
let another_receiver = sender.subscribe();

// Notify all subscribers that a document changed
sender.notify(Seq::Num(42), "user:alice".into());
}

When integrating with a custom adapter, call sender.notify() after every successful write so that all LiveChangesStream instances wake up immediately instead of waiting for the poll interval.

Custom Filter Closures

For flexible client-side filtering, pass a ChangesFilter closure that receives each ChangeEvent and returns true to include or false to skip:

#![allow(unused)]
fn main() {
use rouchdb::{Database, ChangesStreamOptions, ChangesFilter};
use std::sync::Arc;
use std::time::Duration;

let db = Database::memory("mydb");

let filter: ChangesFilter = Arc::new(|event| {
    event.id.starts_with("user:")
});

let (mut rx, handle) = db.live_changes(ChangesStreamOptions {
    filter: Some(filter),
    poll_interval: Duration::from_millis(200),
    ..Default::default()
});

while let Some(event) = rx.recv().await {
    // Only user: docs arrive here
    println!("User changed: {}", event.id);
}

handle.cancel();
}

Database::live_changes()

The Database struct provides a high-level live_changes() method that returns an mpsc::Receiver<ChangeEvent> and a ChangesHandle. This is the recommended way to consume live changes:

#![allow(unused)]
fn main() {
use rouchdb::{Database, ChangesStreamOptions};
use std::time::Duration;

let db = Database::memory("mydb");

let (mut rx, handle) = db.live_changes(ChangesStreamOptions {
    poll_interval: Duration::from_millis(200),
    ..Default::default()
});

// Receive events from the channel
while let Some(event) = rx.recv().await {
    println!("Change: {} seq={}", event.id, event.seq);
}

// Cancel the stream when done
handle.cancel();
}

Dropping the ChangesHandle also cancels the stream automatically.

Database::live_changes_events()

For applications that need lifecycle events (active, paused, errors) in addition to document changes, use live_changes_events(). It returns ChangesEvent enum variants instead of raw ChangeEvent structs:

#![allow(unused)]
fn main() {
use rouchdb::{Database, ChangesStreamOptions, ChangesEvent};
use std::time::Duration;

let db = Database::memory("mydb");

let (mut rx, handle) = db.live_changes_events(ChangesStreamOptions {
    include_docs: true,
    poll_interval: Duration::from_millis(200),
    ..Default::default()
});

while let Some(event) = rx.recv().await {
    match event {
        ChangesEvent::Change(ce) => {
            println!("Doc changed: {} seq={}", ce.id, ce.seq);
        }
        ChangesEvent::Complete { last_seq } => {
            println!("Caught up at seq {}", last_seq);
        }
        ChangesEvent::Error(msg) => {
            eprintln!("Error: {}", msg);
        }
        ChangesEvent::Paused => {
            println!("Waiting for new changes...");
        }
        ChangesEvent::Active => {
            println!("Processing changes...");
        }
    }
}

handle.cancel();
}

ChangesEvent Variants

VariantDescription
Change(ChangeEvent)A document was created, updated, or deleted.
Complete { last_seq: Seq }All current changes have been processed.
Error(String)An error occurred while fetching changes.
PausedWaiting for new changes (no pending changes).
ActiveResumed processing after a pause.

Filtering by Document IDs

Both one-shot and live changes support filtering:

#![allow(unused)]
fn main() {
let response = db.changes(ChangesOptions {
    doc_ids: Some(vec!["user:alice".into(), "user:bob".into()]),
    include_docs: true,
    ..Default::default()
}).await?;

// Only changes to user:alice and user:bob are returned
}

This is useful for building reactive views that only care about a subset of documents.

Filtering by Mango Selector

You can filter changes using a Mango selector — only changes to documents matching the selector are returned:

#![allow(unused)]
fn main() {
use rouchdb::{Database, ChangesOptions};

let db = Database::memory("mydb");

// One-shot: only changes for documents where type == "user"
let changes = db.changes(ChangesOptions {
    selector: Some(serde_json::json!({"type": "user"})),
    include_docs: true,
    ..Default::default()
}).await?;

for event in &changes.results {
    println!("{}: {:?}", event.id, event.doc);
}
}

For live changes with selector filtering:

#![allow(unused)]
fn main() {
use rouchdb::{Database, ChangesStreamOptions};
use std::time::Duration;

let db = Database::memory("mydb");

let (mut rx, handle) = db.live_changes(ChangesStreamOptions {
    selector: Some(serde_json::json!({"type": "user"})),
    include_docs: true,
    poll_interval: Duration::from_millis(200),
    ..Default::default()
});

while let Some(event) = rx.recv().await {
    // Only user documents arrive here
    println!("User changed: {}", event.id);
}

handle.cancel();
}

When using the HTTP adapter (CouchDB), the selector is passed natively via filter=_selector for server-side filtering. For local adapters (memory, redb), documents are fetched with include_docs: true internally and filtered in Rust.

Replication

Replication is what makes RouchDB a local-first database. It implements the CouchDB replication protocol, allowing bidirectional sync between any two databases – local to local, local to remote CouchDB, or even remote to remote.

Quick Start

#![allow(unused)]
fn main() {
use rouchdb::Database;
use serde_json::json;

let local = Database::open("data/myapp.redb", "myapp")?;
let remote = Database::http("http://localhost:5984/myapp");

// Push local changes to CouchDB
local.replicate_to(&remote).await?;

// Pull remote changes to local
local.replicate_from(&remote).await?;

// Or do both directions at once
let (push_result, pull_result) = local.sync(&remote).await?;
}

Setting Up CouchDB with Docker

A minimal docker-compose.yml for local development:

version: "3"
services:
  couchdb:
    image: couchdb:3
    ports:
      - "5984:5984"
    environment:
      COUCHDB_USER: admin
      COUCHDB_PASSWORD: password
    volumes:
      - couchdata:/opt/couchdb/data

volumes:
  couchdata:

Start it and create a database:

docker compose up -d

# Create the database
curl -X PUT http://admin:password@localhost:5984/myapp

Then connect from RouchDB:

#![allow(unused)]
fn main() {
let remote = Database::http("http://admin:password@localhost:5984/myapp");
}

Replication Methods

replicate_to

Push documents from this database to a target.

#![allow(unused)]
fn main() {
let result = local.replicate_to(&remote).await?;
println!("Pushed {} docs", result.docs_written);
}

replicate_from

Pull documents from a source into this database.

#![allow(unused)]
fn main() {
let result = local.replicate_from(&remote).await?;
println!("Pulled {} docs", result.docs_written);
}

sync

Bidirectional sync: pushes first, then pulls. Returns both results as a tuple.

#![allow(unused)]
fn main() {
let (push, pull) = local.sync(&remote).await?;

println!("Push: {} written, Pull: {} written",
    push.docs_written, pull.docs_written);
}

replicate_to_with_opts

Push with custom replication options.

#![allow(unused)]
fn main() {
use rouchdb::ReplicationOptions;

let result = local.replicate_to_with_opts(&remote, ReplicationOptions {
    batch_size: 50,
    ..Default::default()
}).await?;
}

ReplicationOptions

#![allow(unused)]
fn main() {
use rouchdb::ReplicationOptions;

let opts = ReplicationOptions {
    batch_size: 100,                                   // documents per batch (default: 100)
    batches_limit: 10,                                 // max batches to buffer (default: 10)
    filter: None,                                      // optional filter (default: None)
    live: false,                                       // continuous mode (default: false)
    retry: false,                                      // auto-retry on failure (default: false)
    poll_interval: std::time::Duration::from_millis(500), // live mode poll interval
    back_off_function: None,                           // custom backoff for retries
    ..Default::default()
};
}
FieldDefaultDescription
batch_size100Number of documents to process in each replication batch. Smaller values mean more frequent checkpoints.
batches_limit10Maximum number of batches to buffer. Controls memory usage for large replications.
filterNoneOptional ReplicationFilter for selective replication. See Filtered Replication.
sinceNoneOverride the starting sequence instead of reading from checkpoint. Useful for replaying changes from a known point.
checkpointtrueSet to false to disable checkpoint saving. Each replication will start from the beginning (or since).
livefalseEnable continuous replication that keeps running and picks up new changes.
retryfalseAutomatically retry on network or transient errors (live mode).
poll_interval500msHow frequently to poll for new changes in live mode.
back_off_functionNoneCustom backoff function for retries. Receives retry count, returns delay.

Filtered Replication

You can replicate a subset of documents using ReplicationFilter. Three filter types are available:

Filter by Document IDs

Replicate only specific documents by their IDs. This is the most efficient filter – it pushes the filtering down to the changes feed.

#![allow(unused)]
fn main() {
use rouchdb::{ReplicationOptions, ReplicationFilter};

let result = local.replicate_to_with_opts(&remote, ReplicationOptions {
    filter: Some(ReplicationFilter::DocIds(vec![
        "user:alice".into(),
        "user:bob".into(),
    ])),
    ..Default::default()
}).await?;
}

Filter by Mango Selector

Replicate documents matching a Mango query selector. The selector is evaluated against each document’s data after fetching.

#![allow(unused)]
fn main() {
use rouchdb::{ReplicationOptions, ReplicationFilter};

let result = local.replicate_to_with_opts(&remote, ReplicationOptions {
    filter: Some(ReplicationFilter::Selector(serde_json::json!({
        "type": "invoice",
        "status": "pending"
    }))),
    ..Default::default()
}).await?;
}

Filter by Custom Closure

Pass a Rust closure that receives each ChangeEvent and returns true to replicate or false to skip.

#![allow(unused)]
fn main() {
use rouchdb::{ReplicationOptions, ReplicationFilter};

let result = local.replicate_to_with_opts(&remote, ReplicationOptions {
    filter: Some(ReplicationFilter::Custom(std::sync::Arc::new(|change| {
        change.id.starts_with("public:")
    }))),
    ..Default::default()
}).await?;
}

Note: Checkpoints advance past all processed changes regardless of filtering. This means re-running filtered replication won’t re-evaluate previously seen changes.

How the Replication Protocol Works

RouchDB implements the standard CouchDB replication protocol. Each replication run follows these steps:

  1. Read checkpoint – Load the last successfully replicated sequence from the local document store. This allows replication to resume where it left off.

  2. Fetch changes – Query the source’s changes feed starting from the checkpoint sequence, limited to batch_size changes per request.

  3. Compute revs_diff – Send the changed document IDs and their revisions to the target. The target responds with which revisions it is missing, avoiding redundant transfers.

  4. Fetch missing documents – Use bulk_get to retrieve only the documents and revisions the target does not have.

  5. Write to target – Write the missing documents to the target using bulk_docs with new_edits: false (replication mode), which preserves the original revision IDs and merges them into the target’s revision trees.

  6. Save checkpoint – Persist the last replicated sequence so the next run can start from where this one ended.

Steps 2-6 repeat in a loop until no more changes remain.

ReplicationResult

Every replication call returns a ReplicationResult:

#![allow(unused)]
fn main() {
use rouchdb::ReplicationResult;

let result = local.replicate_to(&remote).await?;

if result.ok {
    println!("Replication succeeded");
} else {
    println!("Replication had errors:");
    for err in &result.errors {
        println!("  - {}", err);
    }
}

println!("Documents read:    {}", result.docs_read);
println!("Documents written: {}", result.docs_written);
println!("Last sequence:     {}", result.last_seq);
}
FieldTypeDescription
okbooltrue if no errors occurred.
docs_readu64Number of change events read from the source.
docs_writtenu64Number of documents written to the target.
errorsVec<String>Descriptions of any errors during replication.
last_seqSeqThe source sequence up to which replication completed.

Note that docs_read may be greater than docs_written when the target already has some of the documents (incremental replication).

Incremental Replication

Replication is incremental by default. Checkpoints are stored as local documents (prefixed with _local/) that are not themselves replicated. After an initial full sync, subsequent calls only transfer new changes:

#![allow(unused)]
fn main() {
// First run: syncs everything
let r1 = local.replicate_to(&remote).await?;
println!("Initial: {} docs written", r1.docs_written); // e.g. 500

// Add some new documents
local.put("new_doc", json!({"data": "hello"})).await?;

// Second run: only syncs the delta
let r2 = local.replicate_to(&remote).await?;
println!("Incremental: {} docs written", r2.docs_written); // 1
}

Replication Events

Use replicate_to_with_events() to receive progress events during replication:

#![allow(unused)]
fn main() {
use rouchdb::ReplicationEvent;

let (result, mut rx) = local.replicate_to_with_events(
    &remote,
    ReplicationOptions::default(),
).await?;

// Drain events after replication completes
while let Ok(event) = rx.try_recv() {
    match event {
        ReplicationEvent::Active => println!("Replication started"),
        ReplicationEvent::Change { docs_read } => {
            println!("Progress: {} docs read", docs_read);
        }
        ReplicationEvent::Complete(result) => {
            println!("Done: {} written", result.docs_written);
        }
        ReplicationEvent::Error(msg) => println!("Error: {}", msg),
        ReplicationEvent::Paused => println!("Waiting for changes..."),
    }
}
}

Event Variants

VariantDescription
ActiveReplication has started or resumed processing.
Change { docs_read }A batch of changes was processed.
PausedWaiting for more changes (live mode).
Complete(ReplicationResult)Replication finished (one-shot or one cycle in live mode).
Error(String)An error occurred during replication.

Live (Continuous) Replication

Live replication keeps running in the background, continuously polling for new changes and replicating them. This is the equivalent of PouchDB’s { live: true } option.

#![allow(unused)]
fn main() {
use rouchdb::{ReplicationOptions, ReplicationEvent};

let (mut rx, handle) = local.replicate_to_live(&remote, ReplicationOptions {
    live: true,
    poll_interval: std::time::Duration::from_millis(500),
    retry: true,
    ..Default::default()
});

// Process events in a loop
tokio::spawn(async move {
    while let Some(event) = rx.recv().await {
        match event {
            ReplicationEvent::Complete(r) => {
                println!("Batch done: {} docs written", r.docs_written);
            }
            ReplicationEvent::Paused => {
                println!("Up to date, waiting for new changes...");
            }
            ReplicationEvent::Error(msg) => {
                eprintln!("Replication error: {}", msg);
            }
            _ => {}
        }
    }
});

// ... later, when you want to stop:
handle.cancel();
}

ReplicationHandle

The ReplicationHandle returned by replicate_to_live() controls the background replication:

  • handle.cancel() – Stops the replication gracefully.
  • Dropping the handle also cancels the replication (via Drop implementation).

Retry and Backoff

When retry: true is set, live replication will automatically retry after transient errors. You can customize the backoff strategy:

#![allow(unused)]
fn main() {
let (rx, handle) = local.replicate_to_live(&remote, ReplicationOptions {
    live: true,
    retry: true,
    back_off_function: Some(Box::new(|retry_count| {
        // Exponential backoff: 1s, 2s, 4s, 8s, max 30s
        let delay = std::cmp::min(1000 * 2u64.pow(retry_count), 30_000);
        std::time::Duration::from_millis(delay)
    })),
    ..Default::default()
});
}

Complete Example: Local-to-CouchDB Sync

use rouchdb::{Database, ReplicationOptions};
use serde_json::json;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    // Open persistent local database
    let local = Database::open("data/todos.redb", "todos")?;

    // Connect to CouchDB
    let remote = Database::http("http://admin:password@localhost:5984/todos");

    // Create some local documents
    local.put("todo:1", json!({
        "title": "Buy groceries",
        "done": false
    })).await?;

    local.put("todo:2", json!({
        "title": "Write documentation",
        "done": true
    })).await?;

    // Push to CouchDB with custom batch size
    let push = local.replicate_to_with_opts(&remote, ReplicationOptions {
        batch_size: 50,
        ..Default::default()
    }).await?;

    println!("Push complete: {} docs written", push.docs_written);

    // Pull any changes others made on CouchDB
    let pull = local.replicate_from(&remote).await?;
    println!("Pull complete: {} docs written", pull.docs_written);

    // Check local state
    let info = local.info().await?;
    println!("Local database: {} docs, seq {}",
        info.doc_count, info.update_seq);

    Ok(())
}

Local-to-Local Replication

Replication works between any two adapters, not just local and remote. This is useful for backup, migration, or testing:

#![allow(unused)]
fn main() {
let db_a = Database::memory("a");
let db_b = Database::memory("b");

db_a.put("doc1", json!({"from": "a"})).await?;
db_b.put("doc2", json!({"from": "b"})).await?;

// Sync both directions
let (push, pull) = db_a.sync(&db_b).await?;

// Both databases now have both documents
assert_eq!(db_a.info().await?.doc_count, 2);
assert_eq!(db_b.info().await?.doc_count, 2);
}

Conflict Resolution

Conflicts are a natural part of distributed databases. When the same document is edited on two replicas before they sync, both replicas create new revisions that branch from the same parent. CouchDB (and RouchDB) handle this gracefully: rather than rejecting one edit, both revisions are preserved. The system deterministically picks a winner so every replica agrees on what db.get() returns, while the losing revisions remain accessible for manual resolution.

Why Conflicts Happen

Consider two replicas, A and B, that have both synced document todo:1 at revision 1-abc:

Replica A: todo:1 @ 1-abc  -->  update  -->  2-def
Replica B: todo:1 @ 1-abc  -->  update  -->  2-ghi

When A and B sync, the revision tree for todo:1 becomes:

1-abc --> 2-def  (branch from replica A)
      --> 2-ghi  (branch from replica B)

Both 2-def and 2-ghi are valid. The system picks one as the winner; the other becomes a conflict.

The Deterministic Winner Algorithm

CouchDB uses a deterministic algorithm so that every replica independently arrives at the same winner without any coordination:

  1. Non-deleted leaves beat deleted leaves. A live document always wins over a tombstone at the same generation.
  2. Higher position (generation) wins. If one branch has more edits, it wins.
  3. Lexicographically greater hash breaks ties. When two leaves have the same generation and deletion status, the one with the larger hash string wins.

This means if 2-ghi and 2-def are both non-deleted at generation 2, then 2-ghi wins because "ghi" > "def" lexicographically.

RouchDB exposes this algorithm through the winning_rev() function:

#![allow(unused)]
fn main() {
use rouchdb::winning_rev;

// Given a RevTree (the full revision tree for a document)
let winner = winning_rev(&rev_tree);
// Returns Option<Revision> -- the winning leaf revision
}

Detecting Conflicts

Reading Conflicts with get_with_opts

To see whether a document has conflicts, use get_with_opts with conflicts: true:

#![allow(unused)]
fn main() {
use rouchdb::{Database, GetOptions};

let db = Database::memory("mydb");

// ... after replication creates a conflict ...

let doc = db.get_with_opts("todo:1", GetOptions {
    conflicts: true,
    ..Default::default()
}).await?;

// The document body is the winning revision
println!("Winner: {} rev={}", doc.id, doc.rev.as_ref().unwrap());

// Check the _conflicts field in the returned JSON
let json = doc.to_json();
if let Some(conflicts) = json.get("_conflicts") {
    println!("Conflicting revisions: {}", conflicts);
}
}

Using collect_conflicts

If you have access to the document’s revision tree (from the adapter’s internal metadata), you can use the collect_conflicts utility:

#![allow(unused)]
fn main() {
use rouchdb_core::merge::collect_conflicts;

// rev_tree: RevTree -- the document's full revision tree
let conflicts = collect_conflicts(&rev_tree);

for conflict_rev in &conflicts {
    println!("Conflict: {}", conflict_rev);
    // conflict_rev is a Revision { pos, hash }
}
}

collect_conflicts returns all non-winning, non-deleted leaf revisions. Deleted leaves are excluded because a delete inherently resolves that branch.

Using is_deleted

Check whether the winning revision of a document is a deletion:

#![allow(unused)]
fn main() {
use rouchdb_core::merge::is_deleted;

if is_deleted(&rev_tree) {
    println!("The document's winning revision is deleted");
}
}

Resolving Conflicts

The standard resolution strategy is:

  1. Read the winning revision and all conflicting revisions.
  2. Merge the data as your application sees fit.
  3. Update the winner with the merged data.
  4. Delete each losing revision.

After this, the document has a single non-deleted leaf and no more conflicts.

Complete Example

#![allow(unused)]
fn main() {
use rouchdb::{Database, GetOptions, RouchError};
use serde_json::json;

async fn resolve_conflicts(db: &Database, doc_id: &str) -> rouchdb::Result<()> {
    // Step 1: Read the winner with conflicts
    let doc = db.get_with_opts(doc_id, GetOptions {
        conflicts: true,
        ..Default::default()
    }).await?;

    let winner_rev = doc.rev.as_ref().unwrap().to_string();
    let winner_data = doc.data.clone();

    // Extract conflict revisions from the JSON representation
    let doc_json = doc.to_json();
    let conflict_revs: Vec<String> = doc_json
        .get("_conflicts")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    if conflict_revs.is_empty() {
        println!("No conflicts to resolve");
        return Ok(());
    }

    // Step 2: Read each conflicting revision
    let mut all_versions = vec![winner_data.clone()];
    for rev in &conflict_revs {
        let conflict_doc = db.get_with_opts(doc_id, GetOptions {
            rev: Some(rev.clone()),
            ..Default::default()
        }).await?;
        all_versions.push(conflict_doc.data);
    }

    // Step 3: Merge the data (application-specific logic)
    // This example takes the winner and appends notes from losers
    let merged = merge_application_data(&all_versions);

    // Step 4: Update the winner with merged data
    let update_result = db.update(doc_id, &winner_rev, merged).await?;
    let new_rev = update_result.rev.unwrap();
    println!("Updated winner to rev {}", new_rev);

    // Step 5: Delete each losing revision
    for rev in &conflict_revs {
        db.remove(doc_id, rev).await?;
        println!("Deleted conflict rev {}", rev);
    }

    println!("All conflicts resolved for {}", doc_id);
    Ok(())
}

fn merge_application_data(versions: &[serde_json::Value]) -> serde_json::Value {
    // Your merge logic here. Common strategies:
    // - Last-write-wins (pick the one with the latest timestamp field)
    // - Field-level merge (combine non-overlapping fields)
    // - Domain-specific (e.g., union of tags, max of counters)

    // Simple example: take the first version's data
    // and merge "tags" arrays from all versions
    let mut result = versions[0].clone();
    let mut all_tags: Vec<serde_json::Value> = Vec::new();

    for version in versions {
        if let Some(tags) = version.get("tags").and_then(|t| t.as_array()) {
            for tag in tags {
                if !all_tags.contains(tag) {
                    all_tags.push(tag.clone());
                }
            }
        }
    }

    if !all_tags.is_empty() {
        result["tags"] = serde_json::Value::Array(all_tags);
    }

    result
}
}

Common Merge Strategies

Last-Write-Wins (LWW)

If your documents include a modified_at timestamp, pick the most recent version:

#![allow(unused)]
fn main() {
fn lww_merge(versions: &[serde_json::Value]) -> serde_json::Value {
    versions.iter()
        .max_by_key(|v| {
            v.get("modified_at")
                .and_then(|t| t.as_str())
                .unwrap_or("")
                .to_string()
        })
        .cloned()
        .unwrap_or(json!({}))
}
}

Field-Level Merge

Combine non-overlapping changes from different replicas:

#![allow(unused)]
fn main() {
fn field_merge(
    base: &serde_json::Value,
    a: &serde_json::Value,
    b: &serde_json::Value,
) -> serde_json::Value {
    let mut result = base.clone();
    if let Some(obj) = result.as_object_mut() {
        // For each field, if only one side changed it, take that change
        for (key, b_val) in b.as_object().unwrap_or(&serde_json::Map::new()) {
            let base_val = base.get(key);
            let a_val = a.get(key);
            if a_val == base_val && Some(b_val) != base_val {
                obj.insert(key.clone(), b_val.clone());
            }
        }
        for (key, a_val) in a.as_object().unwrap_or(&serde_json::Map::new()) {
            let base_val = base.get(key);
            if Some(a_val) != base_val {
                obj.insert(key.clone(), a_val.clone());
            }
        }
    }
    result
}
}

Prevention: Reducing Conflicts

While conflicts are handled gracefully, you can reduce their frequency:

  • Sync frequently. Shorter intervals between replications mean less opportunity for divergent edits.
  • Use fine-grained documents. Instead of one big document, split data into smaller documents that are less likely to be edited concurrently.
  • Design for commutative operations. CRDTs and append-only patterns naturally avoid conflicts.

Adapters

RouchDB uses an adapter pattern to separate the database API from the underlying storage engine. The Database struct wraps any implementation of the Adapter trait, so the same high-level API works identically whether data lives in memory, on disk, or on a remote CouchDB server.

Built-In Adapters

RouchDB ships with three adapters. Each has a convenience constructor on the Database type.

MemoryAdapter

Stores everything in memory. Data is lost when the Database is dropped.

#![allow(unused)]
fn main() {
use rouchdb::Database;

let db = Database::memory("mydb");
}

When to use:

  • Unit and integration tests.
  • Temporary scratch databases.
  • Prototyping without setting up storage.
  • As a replication target for in-process data transformation.

RedbAdapter

Persistent storage backed by redb, a pure-Rust embedded key-value store. No C dependencies, no FFI, no runtime configuration.

#![allow(unused)]
fn main() {
use rouchdb::Database;

let db = Database::open("path/to/mydb.redb", "mydb")?;
}

The first argument is the filesystem path for the redb file. The second is the logical database name (used in replication checkpoints and db.info()).

When to use:

  • Production local-first applications.
  • Any scenario where data must survive process restarts.
  • Offline-capable applications that sync when connectivity returns.

HttpAdapter

Connects to a remote CouchDB (or compatible) server over HTTP. All operations are translated to CouchDB REST API calls.

#![allow(unused)]
fn main() {
use rouchdb::Database;

// Without authentication
let db = Database::http("http://localhost:5984/mydb");

// With basic auth embedded in the URL
let db = Database::http("http://admin:password@localhost:5984/mydb");
}

When to use:

  • Connecting to a CouchDB cluster.
  • Using CouchDB as the “source of truth” server.
  • As a replication source or target.
  • When you need CouchDB’s built-in features (Mango indexes, design documents, etc.) directly.

Choosing an Adapter

ScenarioAdapter
TestsDatabase::memory()
Desktop / mobile / CLI appDatabase::open() (redb)
Server talking to CouchDBDatabase::http()
Local-first with syncDatabase::open() locally, Database::http() for the remote, then sync()

Using Database::from_adapter

If you have an adapter instance created outside the convenience constructors (for example, a custom adapter or one configured with special options), use from_adapter:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use rouchdb::{Database, MemoryAdapter};

let adapter = Arc::new(MemoryAdapter::new("custom"));
let db = Database::from_adapter(adapter);

// Works exactly like Database::memory("custom")
db.put("doc1", serde_json::json!({"hello": "world"})).await?;
}

Accessing the Underlying Adapter

You can get a reference to the underlying adapter for operations that go through the Adapter trait directly:

#![allow(unused)]
fn main() {
let db = Database::memory("mydb");

// Returns &dyn Adapter
let adapter = db.adapter();

// Use adapter methods directly (e.g., for map/reduce)
use rouchdb::{query_view, ViewQueryOptions};

let result = query_view(
    adapter,
    &|doc| {
        let name = doc.get("name").cloned().unwrap_or(serde_json::json!(null));
        vec![(name, serde_json::json!(1))]
    },
    None,
    ViewQueryOptions::new(),
).await?;
}

This is particularly useful for query_view(), attachment operations, and local document storage, which take an &dyn Adapter parameter.

Implementing a Custom Adapter

To create your own storage backend, implement the Adapter trait from rouchdb_core. Here is the full trait signature:

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use rouchdb_core::adapter::Adapter;
use rouchdb_core::document::*;
use rouchdb_core::error::Result;
use std::collections::HashMap;

pub struct MyAdapter {
    // your storage state
}

#[async_trait]
impl Adapter for MyAdapter {
    async fn info(&self) -> Result<DbInfo> { todo!() }

    async fn get(&self, id: &str, opts: GetOptions) -> Result<Document> { todo!() }

    async fn bulk_docs(
        &self,
        docs: Vec<Document>,
        opts: BulkDocsOptions,
    ) -> Result<Vec<DocResult>> { todo!() }

    async fn all_docs(&self, opts: AllDocsOptions) -> Result<AllDocsResponse> { todo!() }

    async fn changes(&self, opts: ChangesOptions) -> Result<ChangesResponse> { todo!() }

    async fn revs_diff(
        &self,
        revs: HashMap<String, Vec<String>>,
    ) -> Result<RevsDiffResponse> { todo!() }

    async fn bulk_get(&self, docs: Vec<BulkGetItem>) -> Result<BulkGetResponse> { todo!() }

    async fn put_attachment(
        &self,
        doc_id: &str,
        att_id: &str,
        rev: &str,
        data: Vec<u8>,
        content_type: &str,
    ) -> Result<DocResult> { todo!() }

    async fn get_attachment(
        &self,
        doc_id: &str,
        att_id: &str,
        opts: GetAttachmentOptions,
    ) -> Result<Vec<u8>> { todo!() }

    async fn remove_attachment(
        &self,
        doc_id: &str,
        att_id: &str,
        rev: &str,
    ) -> Result<DocResult> { todo!() }

    async fn get_local(&self, id: &str) -> Result<serde_json::Value> { todo!() }

    async fn put_local(&self, id: &str, doc: serde_json::Value) -> Result<()> { todo!() }

    async fn remove_local(&self, id: &str) -> Result<()> { todo!() }

    async fn compact(&self) -> Result<()> { todo!() }

    async fn destroy(&self) -> Result<()> { todo!() }

    // close, purge, get_security, put_security have default implementations
    // Override them if your adapter needs custom behavior.
}
}

Key Implementation Notes

bulk_docs is the most complex method. When opts.new_edits is true, you must:

  • Generate new revision IDs for each document.
  • Check that the provided _rev matches the current winning revision (otherwise return a conflict error).
  • Merge the new revision into the document’s revision tree.

When opts.new_edits is false (replication mode), you must:

  • Accept revision IDs as-is without generating new ones.
  • Merge incoming revisions into the existing revision tree using merge_tree() from rouchdb_core::merge.
  • Never reject a write due to conflicts.

changes must return events ordered by sequence number. Each write to the database increments the sequence.

get_local / put_local / remove_local manage local documents (prefixed with _local/ in CouchDB). These are used by the replication protocol to store checkpoints. They do not participate in the changes feed or replication.

revs_diff is used during replication. Given a map of {doc_id: [rev1, rev2, ...]}, return which revisions the adapter does not have. This avoids transferring documents the target already has.

Using Your Custom Adapter

#![allow(unused)]
fn main() {
use std::sync::Arc;
use rouchdb::Database;

let my_adapter = Arc::new(MyAdapter::new(/* ... */));
let db = Database::from_adapter(my_adapter);

// Now use db normally -- put, get, replicate, etc.
db.put("doc1", serde_json::json!({"works": true})).await?;
}

Because the Database type erases the adapter behind Arc<dyn Adapter>, all RouchDB features (replication, queries, changes feed) work transparently with any conforming adapter.

Attachments

RouchDB supports binary attachments on documents, following the CouchDB attachment model. Attachments are binary blobs (images, PDFs, any file) stored alongside a document and identified by a name. Each attachment has a content type, a length, and a digest for deduplication.

Overview

Attachments are accessed through the Database methods put_attachment, get_attachment, get_attachment_with_opts, and remove_attachment.

Storing an Attachment

Use put_attachment to add or replace an attachment on a document. You must provide the current document revision, just like an update.

#![allow(unused)]
fn main() {
use rouchdb::Database;
use serde_json::json;

let db = Database::memory("mydb");

// First, create the document
let result = db.put("photo:1", json!({"title": "Sunset"})).await?;
let rev = result.rev.unwrap();

// Attach a binary file
let image_bytes: Vec<u8> = std::fs::read("sunset.jpg")?;

let att_result = db.put_attachment(
    "photo:1",       // document ID
    "sunset.jpg",    // attachment name
    &rev,            // current document revision
    image_bytes,     // raw bytes
    "image/jpeg",    // MIME content type
).await?;

assert!(att_result.ok);
println!("New rev after attachment: {}", att_result.rev.unwrap());
}

The put_attachment method signature:

#![allow(unused)]
fn main() {
async fn put_attachment(
    &self,
    doc_id: &str,
    att_id: &str,
    rev: &str,
    data: Vec<u8>,
    content_type: &str,
) -> Result<DocResult>;
}
ParameterDescription
doc_idThe document ID to attach to.
att_idThe attachment name (e.g., "photo.png", "report.pdf").
revThe current revision of the document. A new revision is created.
dataThe raw binary data as Vec<u8>.
content_typeMIME type string (e.g., "image/png", "application/pdf").

The returned DocResult contains the new revision string. Subsequent updates to the document or its attachments must use this new revision.

Retrieving an Attachment

Use get_attachment to read the raw bytes of an attachment:

#![allow(unused)]
fn main() {
use rouchdb::GetAttachmentOptions;

let bytes = db.get_attachment_with_opts(
    "photo:1",
    "sunset.jpg",
    GetAttachmentOptions::default(),
).await?;

println!("Attachment size: {} bytes", bytes.len());

// Write to a file
std::fs::write("downloaded_sunset.jpg", &bytes)?;
}

The get_attachment method signature:

#![allow(unused)]
fn main() {
async fn get_attachment(
    &self,
    doc_id: &str,
    att_id: &str,
    opts: GetAttachmentOptions,
) -> Result<Vec<u8>>;
}

GetAttachmentOptions

#![allow(unused)]
fn main() {
use rouchdb::GetAttachmentOptions;

let opts = GetAttachmentOptions {
    rev: Some("2-abc123...".into()), // fetch from a specific revision
};
}
FieldTypeDescription
revOption<String>Retrieve the attachment from a specific document revision. If None, uses the current winning revision.

AttachmentMeta

When you retrieve a document, its attachments field is a HashMap<String, AttachmentMeta>. The metadata describes each attachment without including the raw data (unless explicitly requested):

#![allow(unused)]
fn main() {
use rouchdb::AttachmentMeta;

let doc = db.get("photo:1").await?;

for (name, meta) in &doc.attachments {
    println!("Attachment: {}", name);
    println!("  Content-Type: {}", meta.content_type);
    println!("  Digest:       {}", meta.digest);
    println!("  Length:        {} bytes", meta.length);
    println!("  Stub:         {}", meta.stub);
}
}

The AttachmentMeta fields:

FieldTypeDescription
content_typeStringThe MIME type (e.g., "image/png").
digestStringA content-addressed hash of the data (e.g., "md5-abc123...").
lengthu64Size of the attachment in bytes.
stubboolIf true, the data field is absent and only metadata is present. This is the common case when reading documents.
dataOption<Vec<u8>>The raw binary data, present only when explicitly included.

Digest-Based Deduplication

Attachments are stored by their content digest. If two documents have the same attachment data, the bytes are stored only once. This is particularly beneficial during replication: if the target already has the attachment data (identified by digest), it does not need to be transferred again.

The digest is computed when the attachment is written and stored in the AttachmentMeta. It follows CouchDB’s format (e.g., "md5-<base64hash>").

Multiple Attachments

A document can have any number of named attachments. Each put_attachment call creates a new document revision:

#![allow(unused)]
fn main() {
let r1 = db.put("report:q1", json!({"title": "Q1 Report"})).await?;
let rev1 = r1.rev.unwrap();

// Add first attachment
let r2 = db.adapter().put_attachment(
    "report:q1", "summary.pdf", &rev1,
    pdf_bytes, "application/pdf",
).await?;
let rev2 = r2.rev.unwrap();

// Add second attachment (using the new revision)
let r3 = db.adapter().put_attachment(
    "report:q1", "charts.png", &rev2,
    png_bytes, "image/png",
).await?;

// The document now has two attachments
let doc = db.get("report:q1").await?;
assert_eq!(doc.attachments.len(), 2);
}

Attachments in Document JSON

When converting a document to JSON with doc.to_json(), attachments appear under the _attachments key:

{
    "_id": "photo:1",
    "_rev": "2-def456...",
    "title": "Sunset",
    "_attachments": {
        "sunset.jpg": {
            "content_type": "image/jpeg",
            "digest": "md5-abc123...",
            "length": 524288,
            "stub": true
        }
    }
}

When creating documents from JSON using Document::from_json(), the _attachments field is automatically parsed into the attachments HashMap.

Inline Base64 Attachments

When creating documents from JSON (e.g., from CouchDB responses or imported data), attachments can be included inline as Base64-encoded strings under the _attachments key:

{
    "_id": "photo:1",
    "title": "Sunset",
    "_attachments": {
        "image.txt": {
            "content_type": "text/plain",
            "data": "SGVsbG8gV29ybGQ="
        }
    }
}

When Document::from_json() encounters a data field as a Base64 string, it automatically decodes it into the AttachmentMeta.data field as Vec<u8>. This is compatible with CouchDB’s inline attachment format.

#![allow(unused)]
fn main() {
let json = serde_json::json!({
    "_id": "doc1",
    "_attachments": {
        "note.txt": {
            "content_type": "text/plain",
            "data": "SGVsbG8gV29ybGQ="
        }
    }
});

let doc = Document::from_json(json)?;
let att = &doc.attachments["note.txt"];
assert_eq!(att.data.as_ref().unwrap(), b"Hello World");
assert_eq!(att.content_type, "text/plain");
}

Removing an Attachment

Use remove_attachment() to remove an attachment from a document, creating a new revision:

#![allow(unused)]
fn main() {
let result = db.remove_attachment("photo:1", "sunset.jpg", &rev).await?;
// The document now has one fewer attachment
}

Attachments and Replication

Attachments participate in replication automatically. When a document with attachments is replicated, the attachment data is included in the transfer. Thanks to digest-based deduplication, attachments that already exist on the target are not transferred again, which saves bandwidth for large binary files.

Design Documents & Views

Design documents are special documents with IDs starting with _design/. They define views, filters, and validation functions. In RouchDB, design documents are stored as DesignDocument structs and can be used with the ViewEngine for Rust-native map/reduce queries.

Design Document CRUD

Creating a Design Document

#![allow(unused)]
fn main() {
use rouchdb::{Database, DesignDocument, ViewDef};
use std::collections::HashMap;

let db = Database::memory("mydb");

let ddoc = DesignDocument {
    id: "_design/myapp".into(),
    rev: None,
    views: {
        let mut v = HashMap::new();
        v.insert("by_type".into(), ViewDef {
            map: "function(doc) { emit(doc.type, 1); }".into(),
            reduce: Some("_count".into()),
        });
        v
    },
    filters: HashMap::new(),
    validate_doc_update: None,
    shows: HashMap::new(),
    lists: HashMap::new(),
    updates: HashMap::new(),
    language: Some("javascript".into()),
};

let result = db.put_design(ddoc).await?;
assert!(result.ok);
}

Reading a Design Document

Pass the short name (without _design/ prefix):

#![allow(unused)]
fn main() {
let ddoc = db.get_design("myapp").await?;
println!("ID: {}", ddoc.id); // "_design/myapp"
println!("Views: {:?}", ddoc.views.keys().collect::<Vec<_>>());
}

Updating a Design Document

Read the document, modify it, and put it back with the current revision:

#![allow(unused)]
fn main() {
let mut ddoc = db.get_design("myapp").await?;
ddoc.views.insert("by_name".into(), ViewDef {
    map: "function(doc) { emit(doc.name); }".into(),
    reduce: None,
});
let result = db.put_design(ddoc).await?;
}

Deleting a Design Document

#![allow(unused)]
fn main() {
let ddoc = db.get_design("myapp").await?;
let rev = ddoc.rev.unwrap();
db.delete_design("myapp", &rev).await?;
}

DesignDocument Fields

FieldTypeDescription
idStringMust start with _design/.
revOption<String>Current revision (set after reading).
viewsHashMap<String, ViewDef>Named view definitions with map and optional reduce.
filtersHashMap<String, String>Named filter functions.
validate_doc_updateOption<String>Validation function source.
showsHashMap<String, String>Show functions.
listsHashMap<String, String>List functions.
updatesHashMap<String, String>Update handler functions.
languageOption<String>Language for the functions (e.g., "javascript").

ViewEngine (Rust-Native Views)

For local databases, RouchDB provides a ViewEngine that runs map/reduce using Rust closures instead of JavaScript. This is faster and type-safe.

Registering a View

#![allow(unused)]
fn main() {
use rouchdb::{Database, ViewEngine, ViewQueryOptions, query_view, ReduceFn};

let db = Database::memory("mydb");
db.put("alice", serde_json::json!({"type": "user", "name": "Alice", "age": 30})).await?;
db.put("bob", serde_json::json!({"type": "user", "name": "Bob", "age": 25})).await?;
db.put("inv1", serde_json::json!({"type": "invoice", "amount": 100})).await?;

let mut engine = ViewEngine::new();

// Register a Rust map function for "myapp/by_type"
engine.register_map("myapp", "by_type", |doc| {
    let doc_type = doc.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
    vec![(serde_json::json!(doc_type), serde_json::json!(1))]
});
}

Updating and Querying a View

The ViewEngine builds an index by scanning the changes feed. Call update_index() to build or refresh, then get_index() to access the results:

#![allow(unused)]
fn main() {
// Build/refresh the index
engine.update_index(db.adapter(), "myapp", "by_type").await?;

// Access the index entries
if let Some(index) = engine.get_index("myapp", "by_type") {
    for (doc_id, entries) in &index.entries {
        for (key, value) in entries {
            println!("{}: key={} value={}", doc_id, key, value);
        }
    }
}
}

For full map/reduce queries (with reduce, grouping, sorting), use the standalone query_view() function:

#![allow(unused)]
fn main() {
let map_fn = |doc: &serde_json::Value| -> Vec<(serde_json::Value, serde_json::Value)> {
    let doc_type = doc.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
    vec![(serde_json::json!(doc_type), serde_json::json!(1))]
};

let results = query_view(
    db.adapter(),
    &map_fn,
    Some(&ReduceFn::Count),
    ViewQueryOptions {
        reduce: true,
        group: true,
        ..ViewQueryOptions::new()
    },
).await?;

for row in &results.rows {
    println!("{}: {} documents", row.key, row.value);
}
}

Incremental Updates

The ViewEngine tracks the last sequence number and only processes new/changed documents on subsequent update_index() calls, making it efficient for large databases.

Cleanup

Remove unused view indexes:

#![allow(unused)]
fn main() {
db.view_cleanup().await?;
}

Plugins

RouchDB has a plugin system that lets you hook into the document lifecycle. Plugins can modify documents before they are written, react to writes after they happen, and perform cleanup when a database is destroyed.

The Plugin Trait

#![allow(unused)]
fn main() {
use rouchdb::{Plugin, Document, DocResult, Result};
use async_trait::async_trait;

#[async_trait]
pub trait Plugin: Send + Sync {
    /// The plugin name (used for identification).
    fn name(&self) -> &str;

    /// Called before documents are written. Can modify or reject documents.
    async fn before_write(&self, docs: &mut Vec<Document>) -> Result<()> {
        Ok(()) // default: no-op
    }

    /// Called after documents are written with the results.
    async fn after_write(&self, results: &[DocResult]) -> Result<()> {
        Ok(()) // default: no-op
    }

    /// Called when the database is destroyed.
    async fn on_destroy(&self) -> Result<()> {
        Ok(()) // default: no-op
    }
}
}

Adding Plugins

Use with_plugin() to register a plugin on a database:

#![allow(unused)]
fn main() {
use rouchdb::Database;
use std::sync::Arc;

let my_plugin = Arc::new(TimestampPlugin);
let db = Database::memory("mydb").with_plugin(my_plugin);
}

Note: with_plugin() consumes self and returns a new Database, so use the builder pattern.

Multiple plugins can be added. They execute in registration order.

Example: Automatic Timestamps

#![allow(unused)]
fn main() {
use rouchdb::{Plugin, Document, Result};
use async_trait::async_trait;

struct TimestampPlugin;

#[async_trait]
impl Plugin for TimestampPlugin {
    fn name(&self) -> &str { "timestamp" }

    async fn before_write(&self, docs: &mut Vec<Document>) -> Result<()> {
        for doc in docs.iter_mut() {
            if let Some(obj) = doc.data.as_object_mut() {
                obj.insert(
                    "updated_at".into(),
                    serde_json::json!(chrono::Utc::now().to_rfc3339()),
                );
            }
        }
        Ok(())
    }
}
}

Example: Validation Plugin

Return an error from before_write to reject the entire batch:

#![allow(unused)]
fn main() {
use rouchdb::{Plugin, Document, Result, RouchError};
use async_trait::async_trait;

struct RequireTypeField;

#[async_trait]
impl Plugin for RequireTypeField {
    fn name(&self) -> &str { "require-type" }

    async fn before_write(&self, docs: &mut Vec<Document>) -> Result<()> {
        for doc in docs {
            if doc.data.get("type").is_none() && !doc.deleted {
                return Err(RouchError::Forbidden(
                    "All documents must have a 'type' field".into(),
                ));
            }
        }
        Ok(())
    }
}
}

Plugin Hooks

HookWhenCan Modify?Can Reject?
before_writeBefore bulk_docs writesYes (mutable &mut Vec<Document>)Yes (return Err)
after_writeAfter successful writesNo (read-only &[DocResult])Yes (return Err)
on_destroyWhen db.destroy() is calledN/AYes (return Err)

Plugins are called for all write paths: put(), update(), remove(), post(), and bulk_docs().

Partitioned Databases

Partitions let you scope queries to a subset of documents that share a common ID prefix. This is useful for multi-tenant applications or logical grouping.

How It Works

Documents are partitioned by their ID prefix. A document with ID "tenant-a:invoice-001" belongs to the "tenant-a" partition. All queries on a partition are automatically scoped to documents whose ID starts with "{partition}:".

Creating a Partition

#![allow(unused)]
fn main() {
use rouchdb::Database;

let db = Database::memory("mydb");

// Insert documents with partition prefixes
db.put("tenant-a:doc1", serde_json::json!({"name": "Alice"})).await?;
db.put("tenant-a:doc2", serde_json::json!({"name": "Bob"})).await?;
db.put("tenant-b:doc1", serde_json::json!({"name": "Charlie"})).await?;

// Create a partition view
let partition = db.partition("tenant-a");
}

Partition Operations

Get

Retrieve a document within the partition. The partition prefix is added automatically:

#![allow(unused)]
fn main() {
let partition = db.partition("tenant-a");

// These are equivalent:
let doc = partition.get("doc1").await?;
let doc = partition.get("tenant-a:doc1").await?;
}

Put

Create or update a document in the partition:

#![allow(unused)]
fn main() {
let partition = db.partition("tenant-a");
partition.put("doc3", serde_json::json!({"name": "Diana"})).await?;
// Creates document with ID "tenant-a:doc3"
}

All Docs

Query all documents in the partition:

#![allow(unused)]
fn main() {
use rouchdb::AllDocsOptions;

let partition = db.partition("tenant-a");
let result = partition.all_docs(AllDocsOptions {
    include_docs: true,
    ..AllDocsOptions::new()
}).await?;

// Only returns tenant-a documents
for row in &result.rows {
    assert!(row.id.starts_with("tenant-a:"));
}
}

Find (Mango Query)

Run a Mango query scoped to the partition:

#![allow(unused)]
fn main() {
use rouchdb::FindOptions;

let partition = db.partition("tenant-a");
let result = partition.find(FindOptions {
    selector: serde_json::json!({"name": {"$regex": "^A"}}),
    ..Default::default()
}).await?;

// Only searches within tenant-a documents
}

Partition Isolation

Partitions are enforced at query time. Documents from other partitions are never included in results:

#![allow(unused)]
fn main() {
let a = db.partition("tenant-a");
let b = db.partition("tenant-b");

let a_docs = a.all_docs(AllDocsOptions::new()).await?;
let b_docs = b.all_docs(AllDocsOptions::new()).await?;

// No overlap between partitions
}

Database API Reference

The Database struct is the primary entry point for all RouchDB operations. It wraps any Adapter implementation behind an Arc<dyn Adapter>, providing a high-level API similar to PouchDB’s JavaScript interface.

#![allow(unused)]
fn main() {
use rouchdb::Database;
}

Constructors

MethodSignatureDescription
memoryfn memory(name: &str) -> SelfCreate an in-memory database. Data is lost when the Database is dropped. Useful for testing.
openfn open(path: impl AsRef<Path>, name: &str) -> Result<Self>Open or create a persistent database backed by redb. Returns an error if the file cannot be opened or created.
httpfn http(url: &str) -> SelfConnect to a remote CouchDB-compatible server. The URL should include the database name (e.g., http://localhost:5984/mydb).
http_with_authfn http_with_auth(url: &str, auth: &AuthClient) -> SelfConnect to CouchDB with cookie authentication. The AuthClient must have been logged in via auth.login() first.
from_adapterfn from_adapter(adapter: Arc<dyn Adapter>) -> SelfCreate a Database from any custom adapter implementation. Use this when you need to provide your own storage backend.

Examples

#![allow(unused)]
fn main() {
// In-memory (for tests)
let db = Database::memory("mydb");

// Persistent (redb file)
let db = Database::open("path/to/mydb.redb", "mydb")?;

// Remote CouchDB
let db = Database::http("http://localhost:5984/mydb");
}

Document Operations

These methods correspond to CouchDB’s core document API. See the Core Types Reference for details on the option and response structs.

MethodSignatureReturn TypeDescription
infoasync fn info(&self)Result<DbInfo>Get database metadata: name, document count, and current update sequence.
getasync fn get(&self, id: &str)Result<Document>Retrieve a document by its _id. Returns RouchError::NotFound if the document does not exist or has been deleted.
get_with_optsasync fn get_with_opts(&self, id: &str, opts: GetOptions)Result<Document>Retrieve a document with options: specific revision, conflict info, all open revisions, or full revision history.
postasync fn post(&self, data: serde_json::Value)Result<DocResult>Create a new document with an auto-generated UUID v4 as the ID. Equivalent to PouchDB’s db.post().
putasync fn put(&self, id: &str, data: serde_json::Value)Result<DocResult>Create a new document. If a document with the same _id already exists and has no previous revision, this creates it; otherwise it may conflict.
updateasync fn update(&self, id: &str, rev: &str, data: serde_json::Value)Result<DocResult>Update an existing document. You must provide the current _rev string. Returns RouchError::Conflict if the rev does not match.
removeasync fn remove(&self, id: &str, rev: &str)Result<DocResult>Delete a document by marking it as deleted. Requires the current _rev. The document remains in the database as a deletion tombstone.
bulk_docsasync fn bulk_docs(&self, docs: Vec<Document>, opts: BulkDocsOptions)Result<Vec<DocResult>>Write multiple documents atomically. See BulkDocsOptions for user mode vs. replication mode.
all_docsasync fn all_docs(&self, opts: AllDocsOptions)Result<AllDocsResponse>Query all documents, optionally filtered by key range. Supports pagination, descending order, and including full document bodies.
changesasync fn changes(&self, opts: ChangesOptions)Result<ChangesResponse>Get the list of changes since a given sequence. Used for change tracking, live feeds, and replication.

Examples

#![allow(unused)]
fn main() {
// Post (auto-generated ID)
let result = db.post(json!({"name": "Alice", "age": 30})).await?;
println!("Generated ID: {}", result.id);

// Put (explicit ID)
let result = db.put("user:alice", json!({"name": "Alice", "age": 30})).await?;
let doc = db.get("user:alice").await?;

// Update (requires current rev)
let updated = db.update("user:alice", &result.rev.unwrap(), json!({"name": "Alice", "age": 31})).await?;

// Delete
db.remove("user:alice", &updated.rev.unwrap()).await?;

// Bulk write
let docs = vec![
    Document { id: "a".into(), rev: None, deleted: false, data: json!({}), attachments: HashMap::new() },
    Document { id: "b".into(), rev: None, deleted: false, data: json!({}), attachments: HashMap::new() },
];
let results = db.bulk_docs(docs, BulkDocsOptions::new()).await?;

// All docs with options
let response = db.all_docs(AllDocsOptions {
    include_docs: true,
    limit: Some(10),
    ..AllDocsOptions::new()
}).await?;
}

Attachment Operations

MethodSignatureReturn TypeDescription
put_attachmentasync fn put_attachment(&self, doc_id: &str, att_id: &str, rev: &str, data: Vec<u8>, content_type: &str)Result<DocResult>Add or replace an attachment on a document. Creates a new revision.
get_attachmentasync fn get_attachment(&self, doc_id: &str, att_id: &str)Result<Vec<u8>>Retrieve raw attachment bytes using the current revision.
get_attachment_with_optsasync fn get_attachment_with_opts(&self, doc_id: &str, att_id: &str, opts: GetAttachmentOptions)Result<Vec<u8>>Retrieve raw attachment bytes with options (e.g., specific revision).
remove_attachmentasync fn remove_attachment(&self, doc_id: &str, att_id: &str, rev: &str)Result<DocResult>Remove an attachment from a document. Creates a new revision with the attachment removed.

Example

#![allow(unused)]
fn main() {
// Put attachment
let att_result = db.put_attachment("doc1", "photo.jpg", &rev, data, "image/jpeg").await?;

// Get attachment
let bytes = db.get_attachment("doc1", "photo.jpg").await?;

// Remove the attachment
let rm = db.remove_attachment("doc1", "photo.jpg", &att_result.rev.unwrap()).await?;
}

Query Operations

MethodSignatureReturn TypeDescription
findasync fn find(&self, opts: FindOptions)Result<FindResponse>Run a Mango find query with selectors, field projection, sorting, and pagination. If a matching index exists, it will be used. See FindOptions.

Example

#![allow(unused)]
fn main() {
let result = db.find(FindOptions {
    selector: json!({"age": {"$gte": 21}}),
    fields: Some(vec!["name".into(), "age".into()]),
    sort: Some(vec![SortField::Simple("age".into())]),
    limit: Some(25),
    ..Default::default()
}).await?;

for doc in &result.docs {
    println!("{}", doc);
}
}

Index Operations

MethodSignatureReturn TypeDescription
create_indexasync fn create_index(&self, def: IndexDefinition)Result<CreateIndexResponse>Create a Mango index for faster queries. The index is built immediately by scanning all documents. Returns "created" or "exists".
get_indexesasync fn get_indexes(&self)Vec<IndexInfo>List all indexes defined on this database.
delete_indexasync fn delete_index(&self, name: &str)Result<()>Delete an index by name. Returns NotFound if the index does not exist.

Example

#![allow(unused)]
fn main() {
use rouchdb::{IndexDefinition, SortField};

// Create an index on the "age" field
let result = db.create_index(IndexDefinition {
    name: String::new(), // auto-generated as "idx-age"
    fields: vec![SortField::Simple("age".into())],
    ddoc: None,
}).await?;
println!("Index: {} ({})", result.name, result.result);

// Queries on "age" now use the index instead of a full scan
let found = db.find(FindOptions {
    selector: json!({"age": {"$gte": 21}}),
    ..Default::default()
}).await?;

// List indexes
let indexes = db.get_indexes().await;

// Delete an index
db.delete_index("idx-age").await?;
}

Replication

All replication methods implement the CouchDB replication protocol: checkpoint reading, changes feed, revision diff, bulk document fetch, and checkpoint saving. See the Replication chapter for a conceptual overview.

MethodSignatureReturn TypeDescription
replicate_toasync fn replicate_to(&self, target: &Database)Result<ReplicationResult>One-shot push replication from this database to the target. Uses default options (batch size 100).
replicate_fromasync fn replicate_from(&self, source: &Database)Result<ReplicationResult>One-shot pull replication from the source into this database.
replicate_to_with_optsasync fn replicate_to_with_opts(&self, target: &Database, opts: ReplicationOptions)Result<ReplicationResult>Push replication with custom ReplicationOptions (batch size, batches limit).
replicate_to_with_eventsasync fn replicate_to_with_events(&self, target: &Database, opts: ReplicationOptions)Result<(ReplicationResult, Receiver<ReplicationEvent>)>Push replication with event streaming. Returns the result and a channel receiver for progress events.
replicate_to_livefn replicate_to_live(&self, target: &Database, opts: ReplicationOptions)(Receiver<ReplicationEvent>, ReplicationHandle)Start continuous (live) replication. Returns an event receiver and a handle to cancel. Dropping the handle also cancels.
syncasync fn sync(&self, other: &Database)Result<(ReplicationResult, ReplicationResult)>Bidirectional sync: pushes to other, then pulls from other. Returns a tuple of (push_result, pull_result).

ReplicationOptions

FieldTypeDefaultDescription
batch_sizeu64100Number of documents to process per batch.
batches_limitu6410Maximum number of batches to buffer.
filterOption<ReplicationFilter>NoneOptional filter for selective replication.
sinceOption<Seq>NoneOverride the starting sequence. Skips checkpoint and starts from this sequence.
checkpointbooltrueSet to false to disable checkpoint saving/reading.
liveboolfalseEnable continuous replication (used with replicate_to_live).
retryboolfalseAutomatically retry on failure (live mode).
poll_intervalDuration500msHow often to poll for new changes in live mode.
back_off_functionOption<Box<dyn Fn(u32) -> Duration>>NoneCustom backoff function for retries. Receives retry count, returns delay.

ReplicationFilter

VariantDescription
DocIds(Vec<String>)Replicate only the listed document IDs. Filtering at the changes feed level (most efficient).
Selector(serde_json::Value)Replicate documents matching a Mango selector. Evaluated after fetching documents.
Custom(Arc<dyn Fn(&ChangeEvent) -> bool + Send + Sync>)Replicate documents passing a custom predicate applied to each change event.

ReplicationResult

FieldTypeDescription
okbooltrue if replication completed with no errors.
docs_readu64Total number of documents read from the source changes feed.
docs_writtenu64Total number of documents written to the target.
errorsVec<String>List of error messages encountered during replication.
last_seqSeqThe last sequence processed, used as the checkpoint for the next replication.

Example

#![allow(unused)]
fn main() {
let local = Database::open("local.redb", "mydb")?;
let remote = Database::http("http://localhost:5984/mydb");

// Push local changes to CouchDB
let push = local.replicate_to(&remote).await?;
println!("Pushed {} docs", push.docs_written);

// Full bidirectional sync
let (push, pull) = local.sync(&remote).await?;
}

Query Planning

MethodSignatureReturn TypeDescription
explainasync fn explain(&self, opts: FindOptions)ExplainResponseAnalyze a Mango query and return which index would be used, without executing the query. Useful for optimizing queries.

Example

#![allow(unused)]
fn main() {
let explanation = db.explain(FindOptions {
    selector: serde_json::json!({"age": {"$gt": 20}}),
    ..Default::default()
}).await;

println!("Index: {} ({})", explanation.index.name, explanation.index.index_type);
}

Design Document Operations

MethodSignatureReturn TypeDescription
put_designasync fn put_design(&self, ddoc: DesignDocument)Result<DocResult>Create or update a design document.
get_designasync fn get_design(&self, name: &str)Result<DesignDocument>Retrieve a design document by short name (without _design/ prefix).
delete_designasync fn delete_design(&self, name: &str, rev: &str)Result<DocResult>Delete a design document.
view_cleanupasync fn view_cleanup(&self)Result<()>Remove unused view indexes.

See the Design Documents & Views guide for details.


Security

MethodSignatureReturn TypeDescription
get_securityasync fn get_security(&self)Result<SecurityDocument>Retrieve the database security document (admins and members).
put_securityasync fn put_security(&self, doc: SecurityDocument)Result<()>Update the database security document.

Changes Feed (Event-Based)

MethodSignatureReturn TypeDescription
live_changesfn live_changes(&self, opts: ChangesStreamOptions)(Receiver<ChangeEvent>, ChangesHandle)Start a live changes stream returning raw change events.
live_changes_eventsfn live_changes_events(&self, opts: ChangesStreamOptions)(Receiver<ChangesEvent>, ChangesHandle)Start a live changes stream returning lifecycle events (Active, Paused, Complete, Error, Change).

See the Changes Feed guide for details.


Partitioned Queries

MethodSignatureReturn TypeDescription
partitionfn partition(&self, name: &str)Partition<'_>Create a partitioned view scoped to documents with ID prefix "{name}:".

The returned Partition supports get(), put(), all_docs(), and find(). See the Partitioned Databases guide.


Plugin System

MethodSignatureDescription
with_pluginfn with_plugin(self, plugin: Arc<dyn Plugin>) -> SelfRegister a plugin that hooks into the document lifecycle. Consumes and returns self (builder pattern).

See the Plugins guide for details.


Maintenance

MethodSignatureReturn TypeDescription
closeasync fn close(&self)Result<()>Close the database connection. No-op for HTTP adapter.
compactasync fn compact(&self)Result<()>Compact the database: removes old revisions and cleans up unreferenced attachment data.
purgeasync fn purge(&self, id: &str, revs: Vec<String>)Result<PurgeResponse>Permanently remove specific revisions of a document. Unlike remove(), purged revisions do not replicate.
destroyasync fn destroy(&self)Result<()>Destroy the database and all its data. This is irreversible.

Accessing the Adapter

MethodSignatureReturn TypeDescription
adapterfn adapter(&self) -> &dyn Adapter&dyn AdapterGet a reference to the underlying adapter. Useful when you need to call adapter-level methods not exposed on Database (e.g., revs_diff, bulk_get, local documents).

Example

#![allow(unused)]
fn main() {
let db = Database::memory("test");

// Access adapter directly for replication-level operations
let diff = db.adapter().revs_diff(rev_map).await?;
let local = db.adapter().get_local("_local/my-checkpoint").await?;
}

Adapter Trait Reference

The Adapter trait is the storage abstraction at the heart of RouchDB. Every storage backend – in-memory, redb, CouchDB HTTP – implements this trait. The high-level Database struct delegates all operations to its underlying Adapter.

This trait mirrors PouchDB’s internal adapter interface (the underscore-prefixed methods in JavaScript), where each method corresponds to a CouchDB API endpoint.

#![allow(unused)]
fn main() {
use rouchdb_core::adapter::Adapter;
}

Full Trait Definition

#![allow(unused)]
fn main() {
#[async_trait]
pub trait Adapter: Send + Sync {
    async fn info(&self) -> Result<DbInfo>;

    async fn get(&self, id: &str, opts: GetOptions) -> Result<Document>;

    async fn bulk_docs(
        &self,
        docs: Vec<Document>,
        opts: BulkDocsOptions,
    ) -> Result<Vec<DocResult>>;

    async fn all_docs(&self, opts: AllDocsOptions) -> Result<AllDocsResponse>;

    async fn changes(&self, opts: ChangesOptions) -> Result<ChangesResponse>;

    async fn revs_diff(
        &self,
        revs: HashMap<String, Vec<String>>,
    ) -> Result<RevsDiffResponse>;

    async fn bulk_get(&self, docs: Vec<BulkGetItem>) -> Result<BulkGetResponse>;

    async fn put_attachment(
        &self,
        doc_id: &str,
        att_id: &str,
        rev: &str,
        data: Vec<u8>,
        content_type: &str,
    ) -> Result<DocResult>;

    async fn get_attachment(
        &self,
        doc_id: &str,
        att_id: &str,
        opts: GetAttachmentOptions,
    ) -> Result<Vec<u8>>;

    async fn remove_attachment(
        &self,
        doc_id: &str,
        att_id: &str,
        rev: &str,
    ) -> Result<DocResult>;

    async fn get_local(&self, id: &str) -> Result<serde_json::Value>;

    async fn put_local(&self, id: &str, doc: serde_json::Value) -> Result<()>;

    async fn remove_local(&self, id: &str) -> Result<()>;

    async fn compact(&self) -> Result<()>;

    async fn destroy(&self) -> Result<()>;

    // Methods with default implementations:

    async fn close(&self) -> Result<()> { Ok(()) }

    async fn purge(
        &self,
        req: HashMap<String, Vec<String>>,
    ) -> Result<PurgeResponse> { /* default: error */ }

    async fn get_security(&self) -> Result<SecurityDocument> { /* default: empty */ }

    async fn put_security(&self, doc: SecurityDocument) -> Result<()> { /* default: no-op */ }
}
}

Method Reference

Database Information

MethodSignatureDescription
infoasync fn info(&self) -> Result<DbInfo>Returns the database name, document count, and current update sequence.

Behavior contract: Must always succeed for a valid, non-destroyed database. The update_seq should reflect the latest write.


Document Retrieval

MethodSignatureDescription
getasync fn get(&self, id: &str, opts: GetOptions) -> Result<Document>Retrieve a single document by its _id.

Behavior contract:

  • With default GetOptions: returns the winning revision of the document. Returns RouchError::NotFound if the document does not exist or the winning revision is a deletion.
  • With opts.rev = Some(rev): returns the specific revision, even if it is not the winner. Returns NotFound if that revision does not exist.
  • With opts.conflicts = true: the returned document includes information about conflicting leaf revisions.
  • With opts.open_revs = Some(OpenRevs::All): returns all leaf revisions of the document.
  • With opts.revs = true: includes full revision history in the response.

When it is called: Every Database::get and Database::get_with_opts call delegates here. Also called internally during replication to fetch specific revisions.


Document Writing

MethodSignatureDescription
bulk_docsasync fn bulk_docs(&self, docs: Vec<Document>, opts: BulkDocsOptions) -> Result<Vec<DocResult>>Write multiple documents in a single atomic operation.

Behavior contract:

This method has two distinct modes controlled by opts.new_edits:

User mode (new_edits: true – the default)

This is the mode used for normal application writes. The adapter:

  1. Generates a new revision ID for each document (incrementing the pos and computing a new hash).
  2. Checks for conflicts – if the document already exists, the provided _rev must match the current winning revision. If it does not match, the adapter returns a DocResult with ok: false and an error of "conflict".
  3. Appends the new revision as a child of the provided parent revision in the document’s revision tree.
  4. Increments the database sequence number for each successful write.

Replication mode (new_edits: false)

This is the mode used by the replication protocol. The adapter:

  1. Accepts revision IDs as provided – does not generate new revisions.
  2. Does not check for conflicts – merges the incoming revision tree with the existing one.
  3. Inserts the revision into the tree at the correct position, potentially creating new branches.
  4. Determines the new winning revision using the deterministic algorithm (highest pos, then lexicographic hash comparison).

This distinction is critical for correct replication behavior. When documents arrive from a remote peer, they carry their own revision history and must be merged without conflict rejection.

When it is called: All Database::put, Database::update, Database::remove, and Database::bulk_docs calls delegate here. The replication protocol calls this with BulkDocsOptions::replication().


Querying

MethodSignatureDescription
all_docsasync fn all_docs(&self, opts: AllDocsOptions) -> Result<AllDocsResponse>Query all documents, optionally filtered by key range.
changesasync fn changes(&self, opts: ChangesOptions) -> Result<ChangesResponse>Get changes since a given sequence number.

all_docs behavior contract:

  • Returns documents sorted by _id in CouchDB collation order.
  • Supports key range filtering via start_key, end_key, key, and keys.
  • When include_docs is true, the full document body is included in each row.
  • Deleted documents are excluded from results unless requested by specific key.
  • Supports descending order, skip, and limit for pagination.

changes behavior contract:

  • Returns change events in sequence order, starting after opts.since.
  • Each ChangeEvent contains the document _id, the sequence number, and the list of changed revisions.
  • When include_docs is true, the full document body is included.
  • Supports limit to cap the number of results, descending for reverse order, and doc_ids to filter by specific document IDs.
  • The last_seq in the response can be passed as since in the next call for incremental polling.

When they are called: all_docs is called by Database::all_docs and internally by the Mango query engine and map/reduce views. changes is called by Database::changes and is the starting point of every replication cycle.


Replication Support

MethodSignatureDescription
revs_diffasync fn revs_diff(&self, revs: HashMap<String, Vec<String>>) -> Result<RevsDiffResponse>Compare document revision sets against the adapter’s local state to determine which revisions are missing.
bulk_getasync fn bulk_get(&self, docs: Vec<BulkGetItem>) -> Result<BulkGetResponse>Fetch multiple documents by ID and optionally by specific revision in a single request.

revs_diff behavior contract:

  • Input: a map of document IDs to lists of revision strings.
  • Output: for each document, the list of revisions the adapter does not have, plus any possible_ancestors (revisions the adapter does have that could be ancestors of the missing ones).
  • This avoids transferring documents the target already has during replication.

bulk_get behavior contract:

  • Input: a list of BulkGetItem structs, each with an id and an optional rev.
  • Output: for each requested item, the document JSON (in ok) or an error (in error).
  • When rev is None, the winning revision is returned.
  • Used during replication to efficiently fetch all missing documents in a single round trip.

When they are called: Both are called by the replication protocol. revs_diff is called in step 3 (after fetching changes from the source), and bulk_get is called in step 4 (to fetch the actual missing documents).


Attachments

MethodSignatureDescription
put_attachmentasync fn put_attachment(&self, doc_id: &str, att_id: &str, rev: &str, data: Vec<u8>, content_type: &str) -> Result<DocResult>Store binary attachment data on a document.
get_attachmentasync fn get_attachment(&self, doc_id: &str, att_id: &str, opts: GetAttachmentOptions) -> Result<Vec<u8>>Retrieve raw binary attachment data.
remove_attachmentasync fn remove_attachment(&self, doc_id: &str, att_id: &str, rev: &str) -> Result<DocResult>Remove an attachment from a document. Creates a new revision.

put_attachment behavior contract:

  • Requires a valid current rev for the document (same conflict semantics as bulk_docs in user mode).
  • Creates or replaces the named attachment.
  • Returns a DocResult with the new revision.

get_attachment behavior contract:

  • Returns the raw bytes of the attachment.
  • Optionally accepts a specific revision via GetAttachmentOptions.
  • Returns RouchError::NotFound if the document, revision, or attachment does not exist.

Local Documents

Local documents are a special class of documents that are never replicated. They exist only in the local adapter and are used primarily for storing replication checkpoints.

MethodSignatureDescription
get_localasync fn get_local(&self, id: &str) -> Result<serde_json::Value>Retrieve a local document by its ID.
put_localasync fn put_local(&self, id: &str, doc: serde_json::Value) -> Result<()>Write a local document. Creates or overwrites.
remove_localasync fn remove_local(&self, id: &str) -> Result<()>Delete a local document.

Behavior contract:

  • Local documents do not participate in the changes feed.
  • Local documents do not have revision trees – they are simple key-value entries.
  • The id does not need the _local/ prefix; the adapter handles namespacing internally.
  • get_local returns RouchError::NotFound if the local document does not exist.

Role in replication checkpoints:

The replication protocol uses local documents to store checkpoints on both the source and target databases. The checkpoint contains the last replicated sequence number, enabling incremental replication:

#![allow(unused)]
fn main() {
// The replication protocol does this internally:
let checkpoint_id = "_local/replication-checkpoint-abc123";

// Read the last checkpoint
let last_seq = adapter.get_local(checkpoint_id).await
    .map(|doc| doc["last_seq"].clone())
    .unwrap_or(Seq::zero());

// After replicating, save the new checkpoint
adapter.put_local(checkpoint_id, json!({
    "last_seq": current_seq,
    "session_id": session_id,
})).await?;
}

This is why local documents are excluded from replication – each side maintains its own checkpoint independently.


Maintenance

MethodSignatureDescription
compactasync fn compact(&self) -> Result<()>Remove old (non-leaf) revisions and clean up unreferenced attachment data.
destroyasync fn destroy(&self) -> Result<()>Destroy the database and all its data. After calling this, the adapter should not be used.
closeasync fn close(&self) -> Result<()>Release resources (default: no-op).
purgeasync fn purge(&self, req: HashMap<String, Vec<String>>) -> Result<PurgeResponse>Permanently remove specific revisions. Purged revisions do not replicate. Default returns an error.
get_securityasync fn get_security(&self) -> Result<SecurityDocument>Get the database security document (default: empty document).
put_securityasync fn put_security(&self, doc: SecurityDocument) -> Result<()>Set the database security document (default: no-op).

When they are called: compact is called by Database::compact, typically as a periodic maintenance task. destroy is called by Database::destroy when the user wants to permanently delete the database. close, purge, get_security, and put_security have default implementations so existing adapters don’t need to implement them.


Built-in Adapter Implementations

RouchDB ships with three adapter implementations:

AdapterCrateDescription
MemoryAdapterrouchdb-adapter-memoryIn-memory storage. Fast, no persistence. Ideal for tests.
RedbAdapterrouchdb-adapter-redbPersistent storage backed by redb. Pure Rust, no C dependencies.
HttpAdapterrouchdb-adapter-httpRemote CouchDB client using HTTP/JSON via reqwest.

Implementing a Custom Adapter

To implement your own storage backend, implement all methods of the Adapter trait:

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use rouchdb_core::adapter::Adapter;
use rouchdb_core::document::*;
use rouchdb_core::error::Result;
use std::collections::HashMap;

pub struct MyAdapter { /* ... */ }

#[async_trait]
impl Adapter for MyAdapter {
    async fn info(&self) -> Result<DbInfo> {
        // ...
    }

    async fn get(&self, id: &str, opts: GetOptions) -> Result<Document> {
        // ...
    }

    // ... implement all other methods
}
}

Then use it with Database:

#![allow(unused)]
fn main() {
let adapter = Arc::new(MyAdapter::new());
let db = Database::from_adapter(adapter);
}

Core Types Reference

All core types are defined in the rouchdb-core crate and re-exported from the top-level rouchdb crate. You can import them with:

#![allow(unused)]
fn main() {
use rouchdb::*; // Re-exports all types from rouchdb-core::document
}

Document

The fundamental unit of data in RouchDB.

#![allow(unused)]
fn main() {
pub struct Document {
    pub id: String,
    pub rev: Option<Revision>,
    pub deleted: bool,
    pub data: serde_json::Value,
    pub attachments: HashMap<String, AttachmentMeta>,
}
}
FieldTypeDescription
idStringThe document’s unique identifier (_id in CouchDB).
revOption<Revision>The current revision. None for new documents that have not yet been written.
deletedboolWhether this document is a deletion tombstone (_deleted in CouchDB).
dataserde_json::ValueThe document body as a JSON value. Does not include underscore-prefixed CouchDB metadata fields (_id, _rev, _deleted, _attachments).
attachmentsHashMap<String, AttachmentMeta>Map of attachment names to their metadata.

Methods

MethodSignatureDescription
from_jsonfn from_json(value: serde_json::Value) -> Result<Self>Parse a CouchDB-style JSON object. Extracts _id, _rev, _deleted, and _attachments from the value and puts the remaining fields in data. Returns RouchError::BadRequest if the value is not a JSON object.
to_jsonfn to_json(&self) -> serde_json::ValueConvert back to a CouchDB-style JSON object with underscore-prefixed metadata fields included.

Example

#![allow(unused)]
fn main() {
// Parse from CouchDB JSON
let doc = Document::from_json(json!({
    "_id": "user:alice",
    "_rev": "3-abc123",
    "name": "Alice",
    "age": 30
}))?;

assert_eq!(doc.id, "user:alice");
assert_eq!(doc.data["name"], "Alice");

// Convert back
let json = doc.to_json();
assert_eq!(json["_id"], "user:alice");
assert_eq!(json["_rev"], "3-abc123");
}

Revision

A CouchDB revision identifier in the format {pos}-{hash}.

#![allow(unused)]
fn main() {
pub struct Revision {
    pub pos: u64,
    pub hash: String,
}
}
FieldTypeDescription
posu64The generation number. Starts at 1, increments with each edit.
hashStringA hex digest string identifying this specific revision.

Trait Implementations

TraitBehavior
DisplayFormats as "{pos}-{hash}" (e.g., "3-abc123").
FromStrParses from "{pos}-{hash}" format. Returns RouchError::InvalidRev on failure.
Ord / PartialOrdOrders by pos first, then by hash lexicographically. This is the deterministic winning revision algorithm used by CouchDB.
Eq / HashTwo revisions are equal if both pos and hash match.
Serialize / DeserializeSerializes as a JSON object with pos and hash fields.

Example

#![allow(unused)]
fn main() {
// Create directly
let rev = Revision::new(3, "abc123".into());
assert_eq!(rev.to_string(), "3-abc123");

// Parse from string
let parsed: Revision = "3-abc123".parse()?;
assert_eq!(parsed.pos, 3);
assert_eq!(parsed.hash, "abc123");

// Ordering (deterministic winner)
let r1 = Revision::new(2, "aaa".into());
let r2 = Revision::new(2, "bbb".into());
assert!(r1 < r2); // Same pos, "bbb" > "aaa" lexicographically
}

Seq

A database sequence identifier. Local adapters use numeric sequences; CouchDB 3.x uses opaque string sequences.

#![allow(unused)]
fn main() {
pub enum Seq {
    Num(u64),
    Str(String),
}
}
VariantDescription
Num(u64)Numeric sequence, used by local adapters (memory, redb). Starts at 0.
Str(String)Opaque string sequence, used by CouchDB 3.x. Must be passed back as-is.

Methods

MethodSignatureDescription
zerofn zero() -> SelfReturns Seq::Num(0) – the starting point (beginning of changes).
as_numfn as_num(&self) -> u64Extract the numeric value. For Str variants, parses the numeric prefix before the first - (e.g., "13-g1A..." returns 13). Returns 0 if unparseable.
to_query_stringfn to_query_string(&self) -> StringFormat for use in HTTP query parameters. Num becomes its decimal string, Str is returned as-is.

Trait Implementations

TraitBehavior
DefaultReturns Seq::Num(0).
DisplayFormats the numeric value or string.
From<u64>Creates Seq::Num(n).
Serialize / DeserializeUses #[serde(untagged)] – serializes as either a number or string in JSON.

DbInfo

Database metadata returned by Adapter::info().

#![allow(unused)]
fn main() {
pub struct DbInfo {
    pub db_name: String,
    pub doc_count: u64,
    pub update_seq: Seq,
}
}
FieldTypeDescription
db_nameStringThe name of the database.
doc_countu64Number of non-deleted documents.
update_seqSeqThe current update sequence number. Increments with every write.

DocResult

The result of a single document write operation.

#![allow(unused)]
fn main() {
pub struct DocResult {
    pub ok: bool,
    pub id: String,
    pub rev: Option<String>,
    pub error: Option<String>,
    pub reason: Option<String>,
}
}
FieldTypeDescription
okbooltrue if the write succeeded.
idStringThe document ID.
revOption<String>The new revision string if the write succeeded.
errorOption<String>Error type (e.g., "conflict") if the write failed.
reasonOption<String>Human-readable error description.

AttachmentMeta

Metadata for a document attachment.

#![allow(unused)]
fn main() {
pub struct AttachmentMeta {
    pub content_type: String,
    pub digest: String,
    pub length: u64,
    pub stub: bool,
    pub data: Option<Vec<u8>>,
}
}
FieldTypeDescription
content_typeStringMIME type (e.g., "image/png").
digestStringContent digest for deduplication.
lengthu64Size in bytes.
stubboolIf true, only metadata is present (no inline data). Defaults to false.
dataOption<Vec<u8>>Inline binary data, if available. Omitted from serialization when None.

DocMetadata

Internal metadata stored per document in an adapter. Not typically used in application code.

#![allow(unused)]
fn main() {
pub struct DocMetadata {
    pub id: String,
    pub rev_tree: RevTree,
    pub seq: u64,
}
}
FieldTypeDescription
idStringThe document ID.
rev_treeRevTreeThe full revision tree for this document.
sequ64The last sequence number at which this document was modified.

PutResponse

Simple response for a successful document write (used in some internal paths).

#![allow(unused)]
fn main() {
pub struct PutResponse {
    pub ok: bool,
    pub id: String,
    pub rev: String,
}
}
FieldTypeDescription
okboolAlways true for success.
idStringThe document ID.
revStringThe new revision string.

Options Structs

GetOptions

Options for document retrieval.

#![allow(unused)]
fn main() {
pub struct GetOptions {
    pub rev: Option<String>,
    pub conflicts: bool,
    pub open_revs: Option<OpenRevs>,
    pub revs: bool,
    pub revs_info: bool,
    pub latest: bool,
    pub attachments: bool,
}
}
FieldTypeDefaultDescription
revOption<String>NoneRetrieve a specific revision instead of the winner.
conflictsboolfalseInclude conflicting revisions in the response.
open_revsOption<OpenRevs>NoneReturn all open (leaf) revisions.
revsboolfalseInclude full revision history.
revs_infoboolfalseInclude revision info with status (available, missing, deleted) for each revision.
latestboolfalseIf rev is specified and is not a leaf, return the latest leaf revision instead.
attachmentsboolfalseInclude inline Base64 attachment data in the response.

OpenRevs

#![allow(unused)]
fn main() {
pub enum OpenRevs {
    All,
    Specific(Vec<String>),
}
}
VariantDescription
AllReturn all leaf revisions.
Specific(Vec<String>)Return only these specific revisions.

BulkDocsOptions

Options for bulk document writes.

#![allow(unused)]
fn main() {
pub struct BulkDocsOptions {
    pub new_edits: bool,
}
}
FieldTypeDefaultDescription
new_editsbooltrue (via BulkDocsOptions::new())When true, the adapter generates new revisions and checks for conflicts. When false (replication mode), revisions are accepted as-is and merged into the revision tree.

Note: The Default trait implementation sets new_edits to false. Use BulkDocsOptions::new() for user-mode writes (which sets new_edits: true) and BulkDocsOptions::replication() for replication-mode writes (which sets new_edits: false).

Constructornew_edits ValueUse Case
BulkDocsOptions::new()trueNormal application writes.
BulkDocsOptions::replication()falseReplication protocol writes.

AllDocsOptions

Options for querying all documents.

#![allow(unused)]
fn main() {
pub struct AllDocsOptions {
    pub start_key: Option<String>,
    pub end_key: Option<String>,
    pub key: Option<String>,
    pub keys: Option<Vec<String>>,
    pub include_docs: bool,
    pub descending: bool,
    pub skip: u64,
    pub limit: Option<u64>,
    pub inclusive_end: bool,
    pub conflicts: bool,
    pub update_seq: bool,
}
}
FieldTypeDefaultDescription
start_keyOption<String>NoneStart of the key range (inclusive).
end_keyOption<String>NoneEnd of the key range (inclusive by default, see inclusive_end).
keyOption<String>NoneReturn only the row matching this exact key.
keysOption<Vec<String>>NoneReturn only the rows matching these exact keys.
include_docsboolfalseInclude the full document body in each row.
descendingboolfalseReturn rows in descending key order.
skipu640Number of rows to skip before returning results.
limitOption<u64>NoneMaximum number of rows to return.
inclusive_endbooltrue (via AllDocsOptions::new())Whether the end_key is included in the range.
conflictsboolfalseInclude _conflicts for each document (requires include_docs).
update_seqboolfalseInclude the current update_seq in the response.

Note: Use AllDocsOptions::new() instead of Default::default() to get inclusive_end: true, which matches CouchDB’s default behavior.


ChangesOptions

Options for the changes feed.

#![allow(unused)]
fn main() {
pub struct ChangesOptions {
    pub since: Seq,
    pub limit: Option<u64>,
    pub descending: bool,
    pub include_docs: bool,
    pub live: bool,
    pub doc_ids: Option<Vec<String>>,
    pub selector: Option<serde_json::Value>,
    pub conflicts: bool,
    pub style: ChangesStyle,
}
}
FieldTypeDefaultDescription
sinceSeqSeq::Num(0)Return changes after this sequence. Use Seq::zero() for all changes.
limitOption<u64>NoneMaximum number of change events to return.
descendingboolfalseReturn changes in reverse sequence order.
include_docsboolfalseInclude the full document body in each change event.
liveboolfalseEnable continuous (live) changes feed.
doc_idsOption<Vec<String>>NoneFilter changes to only these document IDs.
selectorOption<serde_json::Value>NoneMango selector to filter changes. Only changes matching the selector are returned.
conflictsboolfalseInclude conflicting revisions per change event.
styleChangesStyleMainOnlyMainOnly returns only the winning revision; AllDocs returns all leaf revisions.

FindOptions

Options for a Mango find query (from rouchdb-query).

#![allow(unused)]
fn main() {
pub struct FindOptions {
    pub selector: serde_json::Value,
    pub fields: Option<Vec<String>>,
    pub sort: Option<Vec<SortField>>,
    pub limit: Option<u64>,
    pub skip: Option<u64>,
}
}
FieldTypeDefaultDescription
selectorserde_json::ValueValue::NullThe Mango selector (query) to match documents against. Must be a JSON object.
fieldsOption<Vec<String>>NoneField projection – only include these fields in results. _id is always included.
sortOption<Vec<SortField>>NoneSort specification. Each entry is a field name or a {field: direction} map.
limitOption<u64>NoneMaximum number of matching documents to return.
skipOption<u64>NoneNumber of matching documents to skip.

SortField

#![allow(unused)]
fn main() {
pub enum SortField {
    Simple(String),
    WithDirection(HashMap<String, String>),
}
}
VariantExample JSONDescription
Simple(String)"name"Sort by field in ascending order.
WithDirection(HashMap<String, String>){"age": "desc"}Sort by field with explicit direction ("asc" or "desc").

ViewQueryOptions

Options for map/reduce view queries (from rouchdb-query).

#![allow(unused)]
fn main() {
pub struct ViewQueryOptions {
    pub key: Option<serde_json::Value>,
    pub keys: Option<Vec<serde_json::Value>>,
    pub start_key: Option<serde_json::Value>,
    pub end_key: Option<serde_json::Value>,
    pub inclusive_end: bool,
    pub descending: bool,
    pub skip: u64,
    pub limit: Option<u64>,
    pub include_docs: bool,
    pub reduce: bool,
    pub group: bool,
    pub group_level: Option<u64>,
    pub stale: StaleOption,
}
}
FieldTypeDefaultDescription
keyOption<serde_json::Value>NoneReturn only rows with this exact key.
keysOption<Vec<serde_json::Value>>NoneReturn only rows matching any of these keys, in the given order.
start_keyOption<serde_json::Value>NoneStart of key range (inclusive).
end_keyOption<serde_json::Value>NoneEnd of key range (inclusive by default).
inclusive_endbooltrue (via ViewQueryOptions::new())Whether the end_key is included in the range.
descendingboolfalseReverse row order.
skipu640Number of rows to skip.
limitOption<u64>NoneMaximum number of rows to return.
include_docsboolfalseInclude full document body in each row.
reduceboolfalseWhether to run the reduce function.
groupboolfalseGroup results by key (requires reduce: true).
group_levelOption<u64>NoneGroup to this many array elements of the key (requires reduce: true).
staleStaleOptionFalseFalse rebuilds the index before querying (default). Ok uses a potentially stale index. UpdateAfter returns stale results then rebuilds.

GetAttachmentOptions

Options for retrieving attachments.

#![allow(unused)]
fn main() {
pub struct GetAttachmentOptions {
    pub rev: Option<String>,
}
}
FieldTypeDefaultDescription
revOption<String>NoneRetrieve the attachment from a specific revision.

Response Structs

AllDocsResponse

#![allow(unused)]
fn main() {
pub struct AllDocsResponse {
    pub total_rows: u64,
    pub offset: u64,
    pub rows: Vec<AllDocsRow>,
    pub update_seq: Option<Seq>,
}
}
FieldTypeDescription
total_rowsu64Total number of non-deleted documents in the database.
offsetu64Number of rows skipped.
rowsVec<AllDocsRow>The result rows.
update_seqOption<Seq>The current update sequence, present when update_seq: true was requested.

AllDocsRow

#![allow(unused)]
fn main() {
pub struct AllDocsRow {
    pub id: String,
    pub key: String,
    pub value: AllDocsRowValue,
    pub doc: Option<serde_json::Value>,
}
}
FieldTypeDescription
idStringThe document ID.
keyStringThe row key (same as id for all_docs).
valueAllDocsRowValueContains the revision and optional deletion flag.
docOption<serde_json::Value>Full document body, present only when include_docs is true.

AllDocsRowValue

#![allow(unused)]
fn main() {
pub struct AllDocsRowValue {
    pub rev: String,
    pub deleted: Option<bool>,
}
}
FieldTypeDescription
revStringThe winning revision string.
deletedOption<bool>Set to Some(true) for deleted documents. Omitted from JSON when None.

ChangesResponse

#![allow(unused)]
fn main() {
pub struct ChangesResponse {
    pub results: Vec<ChangeEvent>,
    pub last_seq: Seq,
}
}
FieldTypeDescription
resultsVec<ChangeEvent>The list of change events.
last_seqSeqThe sequence of the last change. Pass this as since for the next poll.

ChangeEvent

#![allow(unused)]
fn main() {
pub struct ChangeEvent {
    pub seq: Seq,
    pub id: String,
    pub changes: Vec<ChangeRev>,
    pub deleted: bool,
    pub doc: Option<serde_json::Value>,
    pub conflicts: Option<Vec<String>>,
}
}
FieldTypeDescription
seqSeqThe sequence number for this change.
idStringThe document ID.
changesVec<ChangeRev>List of changed revision strings.
deletedbooltrue if the document was deleted. Defaults to false.
docOption<serde_json::Value>Full document body, present only when include_docs is true.
conflictsOption<Vec<String>>Conflicting revisions, present when conflicts: true was requested.

ChangeRev

#![allow(unused)]
fn main() {
pub struct ChangeRev {
    pub rev: String,
}
}
FieldTypeDescription
revStringThe revision string for this change.

FindResponse

Result of a Mango find query (from rouchdb-query).

#![allow(unused)]
fn main() {
pub struct FindResponse {
    pub docs: Vec<serde_json::Value>,
}
}
FieldTypeDescription
docsVec<serde_json::Value>Matching documents as JSON values. Includes _id and _rev fields. If fields was specified in FindOptions, only the projected fields are present (plus _id).

ViewResult

Result of a map/reduce view query (from rouchdb-query).

#![allow(unused)]
fn main() {
pub struct ViewResult {
    pub total_rows: u64,
    pub offset: u64,
    pub rows: Vec<ViewRow>,
}
}
FieldTypeDescription
total_rowsu64Total number of rows emitted by the map function (before skip/limit).
offsetu64Number of rows skipped.
rowsVec<ViewRow>The result rows.

ViewRow

#![allow(unused)]
fn main() {
pub struct ViewRow {
    pub id: Option<String>,
    pub key: serde_json::Value,
    pub value: serde_json::Value,
    pub doc: Option<serde_json::Value>,
}
}
FieldTypeDescription
idOption<String>The source document ID. None for reduce results.
keyserde_json::ValueThe emitted key.
valueserde_json::ValueThe emitted value (or reduced value).
docOption<serde_json::Value>Full document body, present only when include_docs is true.

Replication Types

BulkGetItem

A request to fetch a specific document (optionally at a specific revision).

#![allow(unused)]
fn main() {
pub struct BulkGetItem {
    pub id: String,
    pub rev: Option<String>,
}
}
FieldTypeDescription
idStringThe document ID to fetch.
revOption<String>Specific revision to fetch. If None, returns the winning revision.

BulkGetResponse

#![allow(unused)]
fn main() {
pub struct BulkGetResponse {
    pub results: Vec<BulkGetResult>,
}
}

BulkGetResult

#![allow(unused)]
fn main() {
pub struct BulkGetResult {
    pub id: String,
    pub docs: Vec<BulkGetDoc>,
}
}

BulkGetDoc

#![allow(unused)]
fn main() {
pub struct BulkGetDoc {
    pub ok: Option<serde_json::Value>,
    pub error: Option<BulkGetError>,
}
}
FieldTypeDescription
okOption<serde_json::Value>The document JSON if fetch succeeded.
errorOption<BulkGetError>Error details if fetch failed.

BulkGetError

#![allow(unused)]
fn main() {
pub struct BulkGetError {
    pub id: String,
    pub rev: String,
    pub error: String,
    pub reason: String,
}
}
FieldTypeDescription
idStringThe document ID that failed.
revStringThe revision that was requested.
errorStringError type (e.g., "not_found").
reasonStringHuman-readable error description.

RevsDiffResponse

#![allow(unused)]
fn main() {
pub struct RevsDiffResponse {
    pub results: HashMap<String, RevsDiffResult>,
}
}

The results field is flattened during serialization (#[serde(flatten)]), so it serializes as a flat JSON object keyed by document ID.

RevsDiffResult

#![allow(unused)]
fn main() {
pub struct RevsDiffResult {
    pub missing: Vec<String>,
    pub possible_ancestors: Vec<String>,
}
}
FieldTypeDescription
missingVec<String>Revisions the adapter does not have.
possible_ancestorsVec<String>Revisions the adapter does have that could be ancestors of the missing ones. Empty vec is omitted from JSON.

ReduceFn

Built-in reduce functions for map/reduce views (from rouchdb-query).

#![allow(unused)]
fn main() {
pub enum ReduceFn {
    Sum,
    Count,
    Stats,
    Custom(Box<dyn Fn(&[serde_json::Value], &[serde_json::Value], bool) -> serde_json::Value>),
}
}
VariantDescription
SumSum all numeric values.
CountCount the number of rows.
StatsCompute statistics: sum, count, min, max, sumsqr.
Custom(Fn)Custom reduce function. Arguments: (keys, values, rereduce).

Error Handling

RouchDB uses a single error enum, RouchError, for all error conditions across every crate in the workspace. All fallible functions return Result<T>, which is an alias for std::result::Result<T, RouchError>.

#![allow(unused)]
fn main() {
use rouchdb::{Result, RouchError};
}

The Result Type

#![allow(unused)]
fn main() {
pub type Result<T> = std::result::Result<T, RouchError>;
}

Every async method on Database and every Adapter trait method returns Result<T>. This makes error handling consistent throughout the entire API.


RouchError Variants

#![allow(unused)]
fn main() {
#[derive(Debug, Error)]
pub enum RouchError {
    NotFound(String),
    Conflict,
    BadRequest(String),
    Unauthorized,
    Forbidden(String),
    InvalidRev(String),
    MissingId,
    DatabaseExists(String),
    DatabaseError(String),
    Io(#[from] std::io::Error),
    Json(#[from] serde_json::Error),
}
}

Variant Reference

VariantDisplay FormatWhen It Occurs
NotFound(String)"not found: {0}"A document, revision, attachment, or local document does not exist. The string contains the ID or a descriptive message.
Conflict"conflict: document update conflict"An update or delete was attempted without the correct current _rev. The document has been modified since the revision you provided.
BadRequest(String)"bad request: {0}"The request is malformed. Examples: document body is not a JSON object, invalid query parameters, or invalid selector syntax.
Unauthorized"unauthorized"Authentication is required but not provided. Returned by the HTTP adapter when CouchDB responds with 401.
Forbidden(String)"forbidden: {0}"The authenticated user does not have permission for this operation. Returned by the HTTP adapter when CouchDB responds with 403.
InvalidRev(String)"invalid revision format: {0}"A revision string could not be parsed. Revisions must be in {pos}-{hash} format where pos is a positive integer (e.g., "3-abc123").
MissingId"missing document id"A document write was attempted without a document ID.
DatabaseExists(String)"database already exists: {0}"An attempt was made to create a database that already exists.
DatabaseError(String)"database error: {0}"A general database-level error (storage corruption, adapter failure, unexpected internal state).
Io(std::io::Error)"io error: {0}"An I/O error from the underlying storage layer (file system, network). Automatically converted from std::io::Error via #[from].
Json(serde_json::Error)"json error: {0}"A JSON serialization or deserialization error. Automatically converted from serde_json::Error via #[from].

Matching on Specific Errors

Use Rust’s match expression to handle different error conditions:

#![allow(unused)]
fn main() {
use rouchdb::{Database, RouchError};

async fn handle_get(db: &Database, id: &str) {
    match db.get(id).await {
        Ok(doc) => {
            println!("Found: {}", doc.data);
        }
        Err(RouchError::NotFound(_)) => {
            println!("Document {} does not exist", id);
        }
        Err(RouchError::Unauthorized) => {
            eprintln!("Authentication required");
        }
        Err(e) => {
            eprintln!("Unexpected error: {}", e);
        }
    }
}
}

Common Error Patterns

Conflict Resolution

The most common error in document databases is the update conflict. It occurs when you try to update a document but someone else has modified it since you last read it.

#![allow(unused)]
fn main() {
use rouchdb::{Database, RouchError};

async fn safe_update(db: &Database, id: &str) -> rouchdb::Result<()> {
    loop {
        // Read the current version
        let doc = db.get(id).await?;
        let rev = doc.rev.as_ref().unwrap().to_string();

        // Modify the data
        let mut data = doc.data.clone();
        data["counter"] = json!(data["counter"].as_i64().unwrap_or(0) + 1);

        // Attempt the update
        match db.update(id, &rev, data).await {
            Ok(result) => {
                println!("Updated to rev {}", result.rev.unwrap());
                return Ok(());
            }
            Err(RouchError::Conflict) => {
                // Someone else updated the doc -- retry with the new version
                println!("Conflict detected, retrying...");
                continue;
            }
            Err(e) => return Err(e),
        }
    }
}
}

Create-if-not-exists

#![allow(unused)]
fn main() {
use rouchdb::{Database, RouchError};

async fn ensure_doc(db: &Database, id: &str) -> rouchdb::Result<()> {
    match db.get(id).await {
        Ok(_) => {
            // Document already exists, nothing to do
            Ok(())
        }
        Err(RouchError::NotFound(_)) => {
            // Document does not exist, create it
            db.put(id, json!({"created_at": "2026-02-07"})).await?;
            Ok(())
        }
        Err(e) => Err(e),
    }
}
}

Handling Bulk Write Results

bulk_docs does not return an error for individual document failures. Instead, check each DocResult:

#![allow(unused)]
fn main() {
let results = db.bulk_docs(docs, BulkDocsOptions::new()).await?;

for result in &results {
    if result.ok {
        println!("Wrote {} at rev {}", result.id, result.rev.as_deref().unwrap_or("?"));
    } else {
        eprintln!(
            "Failed to write {}: {} - {}",
            result.id,
            result.error.as_deref().unwrap_or("unknown"),
            result.reason.as_deref().unwrap_or("no reason"),
        );
    }
}
}

Using the ? Operator

Since RouchError implements std::error::Error (via thiserror), it works seamlessly with the ? operator and with error types from other crates:

#![allow(unused)]
fn main() {
async fn process(db: &Database) -> rouchdb::Result<()> {
    let doc = db.get("config").await?;         // RouchError on failure
    let info = db.info().await?;               // RouchError on failure
    db.put("status", json!({"ok": true})).await?; // RouchError on failure
    Ok(())
}
}

Converting from External Errors

RouchError has automatic From implementations for common external error types:

Source TypeConverts To
std::io::ErrorRouchError::Io
serde_json::ErrorRouchError::Json

This means I/O and JSON errors from third-party code are automatically converted when using ?:

#![allow(unused)]
fn main() {
async fn read_and_store(db: &Database, path: &str) -> rouchdb::Result<()> {
    let content = std::fs::read_to_string(path)?;  // io::Error -> RouchError::Io
    let value: serde_json::Value = serde_json::from_str(&content)?;  // -> RouchError::Json
    db.put("imported", value).await?;
    Ok(())
}
}

Replication Error Handling

Replication errors are handled differently. The replicate function returns a ReplicationResult rather than failing outright for individual document errors:

#![allow(unused)]
fn main() {
let result = local.replicate_to(&remote).await?;

if result.ok {
    println!("Replication complete: {} docs written", result.docs_written);
} else {
    eprintln!("Replication completed with errors:");
    for err in &result.errors {
        eprintln!("  - {}", err);
    }
}
}

The top-level Result only returns Err for catastrophic failures (network down, source database unreachable). Individual document write failures are collected in result.errors, and result.ok is false if any errors occurred.


Display and Debug

RouchError implements both Display (for user-facing messages) and Debug (for developer diagnostics):

#![allow(unused)]
fn main() {
let err = RouchError::NotFound("doc123".into());

// Display: "not found: doc123"
println!("{}", err);

// Debug: NotFound("doc123")
println!("{:?}", err);
}

All variants produce clear, actionable error messages suitable for logging.

Architecture Overview

RouchDB is a local-first document database for Rust that implements the CouchDB replication protocol. It is the Rust equivalent of PouchDB: applications store data locally (in-memory, on disk, or against a remote CouchDB) and synchronize between replicas using the same protocol that CouchDB uses natively.

Design Philosophy

Local-first. Reads and writes hit local storage. The network is only involved when you explicitly replicate.

Adapter pattern. Every storage backend implements a single Adapter trait. Application code does not depend on a concrete backend – you can swap in-memory storage for disk-backed storage for a remote CouchDB endpoint without changing a line of business logic.

CouchDB compatibility. Revision trees, the winning-rev algorithm, the _changes feed, _revs_diff, _bulk_get, new_edits=false – all of the machinery required by the CouchDB replication protocol is present and matches CouchDB/PouchDB semantics exactly.

serde_json::Value as the document model. Documents are dynamic JSON. The crate does not impose any schema; users may layer typed Rust structs on top via Serde when desired.

Crate Dependency Graph

                      +------------------+
                      |     rouchdb      |  (umbrella: re-exports everything)
                      +------------------+
                     /  |    |    |    \   \
                    /   |    |    |     \   \
          +-----------+ +--------+ +----------+ +-----------+ +--------+
          | adapter-  | | changes| | replica- | |   query   | | views  |
          |   redb    | |        | |   tion   | |           | |        |
          +-----------+ +--------+ +----------+ +-----------+ +--------+
          |  adapter- |      |        |   \         |           |
          |  memory   |      |        |    +-----+  |           |
          +-----------+      |        |          |  |           |
          |  adapter- |      |        |          |  |           |
          |   http    |      |        |          |  |           |
          +-----------+      |        |          |  |           |
                \            |       /          /  /           /
                 \           |      /          /  /           /
                  +----------+-----+----------+--+-----------+
                  |               rouchdb-core                 |
                  +--------------------------------------------+

All arrows point downward. Every crate ultimately depends on rouchdb-core. The rouchdb umbrella crate depends on all other eight crates and re-exports their public APIs.

The Nine Crates

1. rouchdb-core

The foundation. Contains:

  • Adapter trait – the async interface every storage backend implements (info, get, bulk_docs, changes, revs_diff, bulk_get, put_local/get_local, compact, destroy).
  • Document typesDocument, Revision, Seq, and all the option/response structs (GetOptions, BulkDocsOptions, ChangesOptions, AllDocsOptions, etc.).
  • Revision tree (rev_tree.rs) – RevTree, RevPath, RevNode, traversal helpers, leaf collection, ancestry lookup.
  • Merge algorithm (merge.rs) – merge incoming revision paths, determine the winning rev, collect conflicts, stem old revisions.
  • Collation (collation.rs) – CouchDB-compatible comparison and lexicographic encoding of JSON values for ordered storage.
  • Error typesRouchError enum with thiserror.

External dependencies: async-trait, base64, serde, serde_json, thiserror.

2. rouchdb-adapter-memory

An in-memory Adapter implementation. All state lives in Arc<RwLock<...>> behind a Tokio read-write lock. Primarily used for testing and as a reference implementation.

Dependencies: rouchdb-core, tokio, md-5, uuid, base64.

3. rouchdb-adapter-redb

Persistent local storage backed by redb, a pure-Rust embedded key-value store with ACID transactions. Six tables store document metadata, revision data, the changes log, local documents, attachments, and global metadata.

Dependencies: rouchdb-core, redb, tokio, serde, serde_json, md-5, uuid.

See Storage Layer for full table schema documentation.

4. rouchdb-adapter-http

A remote Adapter that talks to a CouchDB (or CouchDB-compatible) server over HTTP using reqwest. Translates each Adapter method into the corresponding CouchDB REST endpoint.

Dependencies: rouchdb-core, reqwest (with json and cookies features), percent-encoding, serde, serde_json.

5. rouchdb-changes

Implements the live/continuous changes feed. Wraps an Adapter’s one-shot changes() call in a Tokio stream that polls for new changes, emitting ChangeEvent items as they arrive.

Dependencies: rouchdb-core, tokio, tokio-util, serde_json.

6. rouchdb-replication

Implements the CouchDB replication protocol: reading and writing checkpoints, computing revision diffs, fetching missing documents, and writing them to the target with new_edits=false.

Dependencies: rouchdb-core, rouchdb-query, tokio, tokio-util, serde, serde_json, md-5, uuid.

See Replication Protocol for a step-by-step walkthrough.

7. rouchdb-query

Mango selectors ($eq, $gt, $in, $regex, etc.) and map/reduce view support. Evaluates selectors against serde_json::Value documents using CouchDB collation order.

Dependencies: rouchdb-core, regex, serde, serde_json.

8. rouchdb-views

Design documents and the persistent view engine. Stores DesignDocument structs (with views, filters, validate_doc_update) and provides ViewEngine for incrementally-updated Rust-native map/reduce indexes.

Dependencies: rouchdb-core, serde, serde_json.

9. rouchdb (umbrella)

The crate users add to their Cargo.toml. Re-exports types from all eight inner crates so consumers do not need to depend on individual sub-crates.

Dependencies: all of the above, plus serde_json, uuid, async-trait.

Data Flow: Writing a Document

Application
    |
    v
rouchdb::Database::put(doc)
    |
    v
Adapter::bulk_docs(docs, BulkDocsOptions { new_edits: true })
    |
    |-- generate revision hash (MD5 of prev_rev + deleted flag + JSON body)
    |-- load existing DocRecord from storage
    |-- build new RevPath from [new_hash, prev_hash]
    |-- merge_tree(existing_tree, new_path, rev_limit)
    |     |
    |     |-- try_merge_path: find overlap, graft new nodes
    |     |-- stem: prune revisions beyond rev_limit
    |     '-- winning_rev: determine deterministic winner
    |
    |-- write DocRecord (rev tree + seq) to DOC_TABLE
    |-- write RevDataRecord (JSON body) to REV_DATA_TABLE
    |-- write ChangeRecord to CHANGES_TABLE
    '-- increment update_seq in META_TABLE

Data Flow: Replication

Source Adapter                         Target Adapter
      |                                      |
      |--- 1. read checkpoint (both) --------|
      |                                      |
      |--- 2. source.changes(since) -------->|
      |                                      |
      |<-- 3. target.revs_diff(revs) --------|
      |                                      |
      |--- 4. source.bulk_get(missing) ----->|
      |                                      |
      |--- 5. target.bulk_docs(new_edits=false) ->|
      |                                      |
      |--- 6. write checkpoint (both) -------|
      |                                      |
      v          (repeat per batch)          v

Both source and target are dyn Adapter. This means replication works between any combination of backends: memory-to-disk, disk-to-CouchDB, CouchDB-to-memory, and so on.

Revision Trees

CouchDB’s multi-version concurrency control (MVCC) model stores the entire revision history of a document as a tree. Every edit creates a new leaf node. Concurrent edits from different replicas create branches (conflicts). A deterministic algorithm picks the “winning” revision so that every replica independently converges on the same answer.

RouchDB implements this model in rouchdb-core, split across two files:

  • rev_tree.rs – data structures and traversal helpers
  • merge.rs – merging incoming revisions, winning-rev selection, conflict collection, and stemming

Data Structures

RevNode

A single node in the revision tree.

#![allow(unused)]
fn main() {
pub struct RevNode {
    pub hash: String,          // the hash portion of "pos-hash"
    pub status: RevStatus,     // Available | Missing
    pub opts: NodeOpts,        // metadata flags (e.g. deleted)
    pub children: Vec<RevNode>,// child revisions
}
}
  • hash – the hex MD5 digest that, together with pos, forms the full revision ID (e.g., "3-a1b2c3...").
  • statusAvailable means the full document body for this revision is still stored; Missing means only the hash remains (after compaction or stemming).
  • opts.deleted – whether this revision marks a deletion (_deleted: true).
  • children – zero children means this is a leaf; two or more children means a conflict branch point.

RevPath

A rooted sub-tree within the revision forest.

#![allow(unused)]
fn main() {
pub struct RevPath {
    pub pos: u64,        // generation number of the root node
    pub tree: RevNode,   // the root node of this sub-tree
}
}

pos is the generation number (the integer before the dash in "3-abc"). If a tree has been stemmed and its earliest stored revision is generation 5, then pos = 5.

RevTree

#![allow(unused)]
fn main() {
pub type RevTree = Vec<RevPath>;
}

A document’s full revision history. Most documents have a single RevPath entry. Multiple entries appear when stemming creates disjoint sub-trees that are later reconnected during replication.

LeafInfo

#![allow(unused)]
fn main() {
pub struct LeafInfo {
    pub pos: u64,
    pub hash: String,
    pub deleted: bool,
    pub status: RevStatus,
}
}

A lightweight snapshot of a leaf node’s identity and state, used by collect_leaves and the winning-rev algorithm.

Tree Structure: ASCII Examples

Linear History (No Conflicts)

  1-aaa --> 2-bbb --> 3-ccc

Represented as:

RevTree = [
    RevPath {
        pos: 1,
        tree: RevNode("aaa", children: [
            RevNode("bbb", children: [
                RevNode("ccc", children: [])   // <-- leaf
            ])
        ])
    }
]

One root at position 1, one leaf at position 3.

Conflict (Two Branches)

Two replicas independently edit generation 1, producing two conflicting generation-2 revisions:

            +-- 2-bbb   (branch A)
            |
  1-aaa ----+
            |
            +-- 2-ccc   (branch B)
RevTree = [
    RevPath {
        pos: 1,
        tree: RevNode("aaa", children: [
            RevNode("bbb", children: []),   // leaf A
            RevNode("ccc", children: []),   // leaf B
        ])
    }
]

Both 2-bbb and 2-ccc are leaves. The one with the lexicographically greater hash ("ccc" > "bbb") wins.

Divergent History After Stemming

After aggressive stemming, a document might have two disjoint sub-trees:

  3-ddd --> 4-eee --> 5-fff       (sub-tree A)

  3-ggg --> 4-hhh                 (sub-tree B, re-introduced by replication)
RevTree = [
    RevPath { pos: 3, tree: RevNode("ddd", ...) },
    RevPath { pos: 3, tree: RevNode("ggg", ...) },
]

Two roots, each with their own pos. This is normal and handled correctly by all traversal and merge operations.

Traversal Helpers

traverse_rev_tree

Depth-first traversal of every node in the forest. The callback receives (absolute_pos, &RevNode, root_pos) so it always knows each node’s full generation number.

collect_leaves

Returns all leaf nodes (nodes with no children), sorted by the winning-rev ordering:

  1. Non-deleted before deleted
  2. Higher generation first
  3. Lexicographically greater hash first

This means collect_leaves(&tree)[0] is always the winner.

root_to_leaf

Decomposes the tree into every root-to-leaf path. Used during replication to provide _revisions ancestry data.

rev_exists

Checks whether a specific (pos, hash) pair exists anywhere in the tree. Used by revs_diff to determine which revisions the target is missing.

find_rev_ancestry

Given a target (pos, hash), walks the tree and returns the chain of hashes from the target back to the root: [target_hash, parent_hash, grandparent_hash, ...]. This is the data that populates the _revisions field in bulk_get responses.

build_path_from_revs

Constructs a single-path RevPath from an array of revision hashes. The input is newest-first (leaf-first): [newest_hash, ..., oldest_hash]. The leaf gets RevStatus::Available; all ancestors get RevStatus::Missing. This is how incoming revisions from replication are turned into a structure that can be merged.

The Merge Algorithm

When a new revision arrives (either from a local write or from replication), it must be merged into the existing tree. The entry point is merge_tree:

#![allow(unused)]
fn main() {
pub fn merge_tree(
    tree: &RevTree,
    new_path: &RevPath,
    rev_limit: u64,
) -> (RevTree, MergeResult)
}

MergeResult is one of:

VariantMeaning
NewLeafThe new path extended an existing branch (normal edit)
NewBranchThe new path created a new branch (conflict)
InternalNodeThe new path’s leaf already existed (duplicate / no-op)

Step-by-step walkthrough

1. Try each existing root. For each RevPath in the tree, call try_merge_path to see if the new path overlaps with it.

2. Find the overlap. find_overlap flattens the new path into a linear chain of (pos, hash) pairs, then searches the existing tree for any matching node. If found, it records the navigation path (a Vec<usize> of child indices) and the remaining new nodes that need to be grafted.

Existing:     1-aaa --> 2-bbb
New path:     2-bbb --> 3-ccc

Overlap at:   2-bbb
Remainder:    [RevNode("ccc")]

3. Graft the remainder. graft_nodes navigates to the overlap node and appends the remainder as children.

After graft:  1-aaa --> 2-bbb --> 3-ccc

If the overlap node already had a different child, the new node becomes an additional child – creating a conflict branch:

Before:       1-aaa --> 2-bbb
New path:     1-aaa --> 2-ccc

After:        1-aaa --+--> 2-bbb
                      |
                      +--> 2-ccc    (new branch = conflict)

4. No overlap: new root. If no existing root shares any node with the new path, the new path is pushed onto the RevTree as a separate root. This happens when stemmed trees diverge during offline replication.

5. Stemming. After the merge, stem is called with the configured rev_limit (default 1000). Stemming walks from the root toward the leaves and removes nodes once the tree exceeds the depth limit.

Example: Full Merge Sequence

Starting state (empty tree):

[]

Write doc1 – local write generates 1-a1b2:

merge([], RevPath{pos:1, tree: RevNode("a1b2")})
  -> [RevPath{pos:1, tree: RevNode("a1b2")}]
  -> MergeResult::NewBranch   (first revision always creates a new root)

Update doc1 with _rev: "1-a1b2" – generates 2-c3d4:

merge(tree, RevPath{pos:1, tree: RevNode("a1b2" -> "c3d4")})
  -> overlap at 1-a1b2, graft "c3d4" as child
  -> 1-a1b2 --> 2-c3d4
  -> MergeResult::NewLeaf

Meanwhile replica B writes doc1 independently, producing 2-e5f6. Replication delivers it:

merge(tree, RevPath{pos:1, tree: RevNode("a1b2" -> "e5f6")})
  -> overlap at 1-a1b2, graft "e5f6" as second child
  -> 1-a1b2 --+--> 2-c3d4
              |
              +--> 2-e5f6
  -> MergeResult::NewBranch

Winning Revision Algorithm

#![allow(unused)]
fn main() {
pub fn winning_rev(tree: &RevTree) -> Option<Revision>
}

The algorithm is completely deterministic – every replica arrives at the same answer without communication:

  1. Non-deleted beats deleted. A leaf that is not marked _deleted: true always wins over a leaf that is deleted, regardless of generation or hash.

  2. Higher generation wins. Among leaves with the same deleted status, the one with the higher pos (generation number) wins.

  3. Lexicographic hash breaks ties. If two leaves have the same pos and same deleted status, the one with the lexicographically greater hash wins.

Implementation-wise, winning_rev calls collect_leaves (which sorts by exactly this ordering) and returns the first element.

Examples

1-aaa --+--> 2-bbb
        |
        +--> 2-ccc

Winner: 2-ccc   (same pos, "ccc" > "bbb")
1-aaa --+--> 2-bbb --> 3-ddd
        |
        +--> 2-ccc

Winner: 3-ddd   (pos 3 > pos 2)
1-aaa --+--> 2-bbb           (not deleted)
        |
        +--> 2-zzz [deleted]

Winner: 2-bbb   (non-deleted beats deleted, even though "zzz" > "bbb")

Collecting Conflicts

#![allow(unused)]
fn main() {
pub fn collect_conflicts(tree: &RevTree) -> Vec<Revision>
}

Returns all non-winning, non-deleted leaf revisions. These are the revisions that a user or application must resolve. The function calls collect_leaves, skips the first entry (the winner), and filters out deleted leaves.

Stemming (Pruning)

#![allow(unused)]
fn main() {
pub fn stem(tree: &mut RevTree, depth: u64) -> Vec<String>
}

Over time, revision trees grow indefinitely. Stemming prunes ancestor nodes that are more than depth generations away from any leaf.

The algorithm:

  1. For each RevPath, compute the maximum depth from root to any leaf.
  2. If the depth exceeds the limit, remove nodes from the root until the deepest path has at most depth nodes.
  3. When the root is removed, the root’s single child becomes the new root and pos is incremented by 1.
  4. Stemming stops at branch points – you cannot remove a node that has multiple children, because that would disconnect branches.
  5. Any RevPath that becomes empty is removed from the tree.
Before (depth limit = 3):

  1-aaa --> 2-bbb --> 3-ccc --> 4-ddd --> 5-eee

After:

  3-ccc --> 4-ddd --> 5-eee
  (pos adjusted from 1 to 3; "aaa" and "bbb" are returned as stemmed)

The default rev_limit in the redb adapter is 1000, which matches CouchDB’s default. PouchDB defaults to 1000 as well.

Revision Hash Generation

When new_edits=true (normal local write), the adapter generates revision hashes deterministically:

#![allow(unused)]
fn main() {
fn generate_rev_hash(
    doc_data: &serde_json::Value,
    deleted: bool,
    prev_rev: Option<&str>,
) -> String
}

The hash is MD5 over: previous_rev_string + ("1" if deleted else "0") + JSON-serialized body.

This means the same edit on the same predecessor always produces the same revision ID, which is important for idempotency.

When new_edits=false (replication mode), the adapter accepts the revision ID as-is from the source and merges it into the tree without generating a new hash.

CouchDB Collation

CouchDB defines a specific ordering for JSON values that differs from naive lexicographic comparison of serialized JSON strings. This ordering is inherited from Erlang’s term comparison and is used everywhere keys are compared: view indexes, _all_docs key ranges, Mango query evaluation, and internal storage engine key encoding.

RouchDB implements this ordering in rouchdb-core/src/collation.rs.

Type Ordering

Different JSON types sort in this fixed order, from lowest to highest:

null  <  boolean  <  number  <  string  <  array  <  object

A null value is always less than false, which is always less than -1000, which is always less than "", and so on. The type boundary is absolute – there is no number large enough to sort after even the empty string.

Internally, each type is assigned a numeric rank:

JSON TypeRank
null1
boolean2
number3
string4
array5
object6

When two values have different ranks, the comparison is immediate. Same-rank values are compared with type-specific rules described below.

Within-Type Comparison Rules

Null

All nulls are equal.

Boolean

false < true.

Number

Compared by numeric value as IEEE 754 f64. -100 < -1 < 0 < 1 < 1.5 < 2.

String

Standard lexicographic (Unicode codepoint) ordering. "a" < "aa" < "b".

Array

Element-by-element using recursive collate. If all shared elements are equal, the shorter array sorts first.

[]       < [1]
[1]      < [2]
[1]      < [1, 2]
[1, "a"] < [1, "b"]

Object

Keys are sorted alphabetically first, then compared key-by-key. For each key pair, the key strings are compared; if equal, the values are compared recursively. If all shared key-value pairs are equal, the object with fewer keys sorts first.

{}           < {"a": 1}
{"a": 1}     < {"a": 2}
{"a": 1}     < {"b": 1}
{"a": 1}     < {"a": 1, "b": 2}

The collate Function

#![allow(unused)]
fn main() {
pub fn collate(a: &Value, b: &Value) -> Ordering
}

This is the primary comparison entry point. It first compares type ranks, then delegates to type-specific comparison. It can be used anywhere you need CouchDB-compatible ordering of arbitrary JSON values.

Usage Examples

#![allow(unused)]
fn main() {
use serde_json::json;
use rouchdb_core::collation::collate;
use std::cmp::Ordering;

// Cross-type: null < number
assert_eq!(collate(&json!(null), &json!(42)), Ordering::Less);

// Cross-type: number < string
assert_eq!(collate(&json!(9999), &json!("")), Ordering::Less);

// Same type: numeric comparison
assert_eq!(collate(&json!(-1), &json!(0)), Ordering::Less);

// Same type: array element-by-element
assert_eq!(collate(&json!([1, 2]), &json!([1, 3])), Ordering::Less);
}

Indexable String Encoding

Storage engines like redb store keys as byte arrays and compare them lexicographically. To preserve CouchDB collation order in a byte-ordered key-value store, JSON values must be encoded into strings that sort lexicographically in the same order as collate.

#![allow(unused)]
fn main() {
pub fn to_indexable_string(v: &Value) -> String
}

Encoding Scheme

Each value is encoded with a type-prefix character that preserves the cross-type ordering:

TypePrefixEncoding
Null1Just the prefix character
Boolean22F for false, 2T for true
Number33 + encoded number (see below)
String44 + the raw string value
Array55 + encoded elements separated by null bytes (\0)
Object66 + sorted key-value pairs separated by null bytes

Because the prefix characters are 1 through 6, the inter-type ordering is automatically correct: any null-encoded string ("1...") sorts before any boolean-encoded string ("2..."), and so on.

Number Encoding

Numbers require special treatment because naive string representations do not sort correctly ("9" > "10" lexicographically). The encoding uses a scheme matching PouchDB’s numToIndexableString:

Zero: Encoded as "1".

Positive numbers: Prefix "2", followed by a 5-digit zero-padded exponent (offset by 10000 to keep it positive), followed by the mantissa (normalized to [1, 10)).

encode(1)    -> "3" + "2" + "10000" + "1."
encode(100)  -> "3" + "2" + "10002" + "1."
encode(1.5)  -> "3" + "2" + "10000" + "1.5"

Since the exponent field is fixed-width and the mantissa is a decimal in [1, 10), larger numbers always produce lexicographically later strings.

Negative numbers: Prefix "0", followed by the inverted exponent (10000 - exponent), followed by the inverted mantissa (10 - mantissa). Inversion ensures that numbers closer to zero (larger negatives) sort after more-negative numbers.

encode(-1)   -> "3" + "0" + "10000" + "9."
encode(-100) -> "3" + "0" + "09998" + "9."

The full encoding for a number is: "3" (type prefix) + sign/magnitude encoding.

The ordering is:

-100      ->  "3" + "0" + "09998..."   (sorts first)
-1        ->  "3" + "0" + "10000..."
 0        ->  "3" + "1"
 1        ->  "3" + "2" + "10000..."
 100      ->  "3" + "2" + "10002..."   (sorts last)

Array and Object Encoding

Arrays encode each element recursively with null-byte separators. Because \0 is the lowest byte value, a shorter array with a matching prefix will always sort before a longer one.

Objects sort their keys alphabetically, then encode alternating key-value pairs separated by null bytes.

Why This Matters

Views

Map/reduce views emit keys that are stored in sorted order. The storage engine must compare these keys in CouchDB collation order. By encoding them with to_indexable_string, ordinary byte-level comparison produces the correct ordering.

_all_docs Key Ranges

The startkey/endkey parameters on _all_docs use CouchDB collation. The adapter encodes the boundary values and performs byte-range scans.

Mango Queries

Mango $gt, $gte, $lt, $lte operators compare values according to CouchDB collation. The rouchdb-query crate uses collate for these comparisons.

Replication Correctness

If two replicas sort the same view index differently, they will produce different results for the same query. CouchDB collation ensures all replicas agree on ordering.

Verifying Correctness

The test suite confirms that to_indexable_string preserves collate ordering across the full type spectrum:

#![allow(unused)]
fn main() {
let values = vec![
    json!(null), json!(false), json!(true),
    json!(0), json!(1), json!(100),
    json!("a"), json!("b"),
    json!([]), json!({}),
];
let encoded: Vec<String> = values.iter().map(to_indexable_string).collect();

for i in 0..encoded.len() {
    for j in (i + 1)..encoded.len() {
        assert!(encoded[i] < encoded[j]);
    }
}
}

The negative-number tests further verify that -100 < -1 < 0 is preserved in the encoded representation.

Replication Protocol

RouchDB implements the CouchDB replication protocol, enabling bidirectional synchronization between any two Adapter implementations. The protocol is defined in rouchdb-replication/src/protocol.rs (the replication loop) and rouchdb-replication/src/checkpoint.rs (checkpoint management).

Because both source and target are dyn Adapter, replication works between any combination of backends: memory-to-memory, redb-to-CouchDB, CouchDB-to-memory, and so on.

Overview

The replication protocol is a batched, checkpoint-based, one-directional data transfer. To achieve bidirectional sync, you run two replications in opposite directions.

Sequence Diagram

 Source                  Replicator                    Target
   |                        |                            |
   |                   1. Generate replication ID        |
   |                        |                            |
   |<-- get_local(rep_id) --|-- get_local(rep_id) ------>|
   |--- checkpoint_doc ---->|<--- checkpoint_doc --------|
   |                        |                            |
   |                   2. Compare checkpoints            |
   |                      (find `since` seq)             |
   |                        |                            |
   |                   === batch loop ===                |
   |                        |                            |
   |<-- changes(since,      |                            |
   |        limit=batch) ---|                            |
   |--- change_events ----->|                            |
   |                        |                            |
   |                        |-- revs_diff(revs) -------->|
   |                        |<--- missing revs ----------|
   |                        |                            |
   |<-- bulk_get(missing) --|                            |
   |--- docs + _revisions ->|                            |
   |                        |                            |
   |                        |-- bulk_docs(docs,          |
   |                        |     new_edits=false) ----->|
   |                        |<--- write results ---------|
   |                        |                            |
   |<-- put_local(cp) ------|---- put_local(cp) -------->|
   |                        |                            |
   |                   === end batch loop ===            |
   |                        |                            |
   |                   Return ReplicationResult          |

The Seven Steps

Step 1: Generate Replication ID

Before anything else, the replicator generates a deterministic replication identifier by hashing the source and target database names with MD5:

#![allow(unused)]
fn main() {
fn generate_replication_id(source_id: &str, target_id: &str) -> String {
    let mut hasher = Md5::new();
    hasher.update(source_id.as_bytes());
    hasher.update(target_id.as_bytes());
    format!("{:x}", hasher.finalize())
}
}

This ID is used as the key for checkpoint documents on both sides. The same source+target pair always produces the same ID, so a replication that is stopped and restarted will find its previous checkpoint.

A random session_id (UUID v4) is also generated per replication run to detect stale checkpoints.

Step 2: Read Checkpoints

The replicator reads checkpoint documents from both source and target using get_local(replication_id). Checkpoint documents are stored as local documents (_local/{replication_id}), which are not replicated themselves.

The checkpoint document structure:

{
  "last_seq": 42,
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "version": 1,
  "replicator": "rouchdb",
  "history": [
    { "last_seq": 42, "session_id": "550e8400-..." },
    { "last_seq": 30, "session_id": "previous-session-..." }
  ]
}

The compare_checkpoints function finds the last agreed-upon sequence:

  1. Same session: If source and target have the same session_id, take the minimum of their two last_seq values. This handles the case where one side’s checkpoint write succeeded but the other’s failed.

  2. Different sessions: Walk through the history arrays looking for a common session_id. When found, use the minimum last_seq from that shared session.

  3. No common session: Return Seq::zero() (start from the beginning).

If either checkpoint read fails (e.g., first replication, or checkpoint was deleted), the replicator starts from sequence 0.

Step 3: Fetch Changes

The replicator calls source.changes() starting from the checkpoint sequence:

#![allow(unused)]
fn main() {
let changes = source.changes(ChangesOptions {
    since: current_seq,
    limit: Some(opts.batch_size),  // default: 100
    include_docs: false,
    ..Default::default()
}).await?;
}

The changes response contains a list of ChangeEvent items, each with a document ID and its current leaf revision(s). The last_seq field marks the high-water mark for this batch.

If the response is empty, replication is complete.

Step 4: Compute revs_diff

The replicator builds a map of doc_id -> [rev_strings] from the changes and sends it to the target:

#![allow(unused)]
fn main() {
let diff = target.revs_diff(rev_map).await?;
}

The target checks its own revision trees and returns only the revisions it is missing. This avoids transferring documents the target already has. The response also includes possible_ancestors – revisions the target does have that are ancestors of the missing ones, which helps the source minimize the revision history it sends.

If the diff is empty (target has everything), the batch is skipped and the replicator advances to the next batch.

Step 5: Fetch Missing Documents with bulk_get

For each missing revision, the replicator constructs a BulkGetItem and fetches the documents from the source:

#![allow(unused)]
fn main() {
let bulk_get_response = source.bulk_get(bulk_get_items).await?;
}

The source returns full document bodies along with _revisions metadata – the chain of revision hashes from the requested revision back to the root. This ancestry data is critical: it allows the target to reconstruct the revision tree and merge it correctly.

A typical bulk_get response for one document looks like:

{
  "_id": "doc1",
  "_rev": "3-ccc",
  "name": "Alice",
  "_revisions": {
    "start": 3,
    "ids": ["ccc", "bbb", "aaa"]
  }
}

Step 6: Write to Target with new_edits=false

The fetched documents are written to the target using the replication mode of bulk_docs:

#![allow(unused)]
fn main() {
let write_results = target.bulk_docs(
    docs_to_write,
    BulkDocsOptions::replication(),  // new_edits: false
).await?;
}

When new_edits=false:

  • The target does not generate new revision IDs. It accepts the revision IDs from the source as-is.
  • The target does not check for conflicts in the traditional sense. Instead, it uses the _revisions ancestry to reconstruct a RevPath and merges it into the existing revision tree using merge_tree.
  • If the revision already exists (was previously replicated), the merge returns MergeResult::InternalNode and the write is a no-op.
  • If the revision extends an existing branch, the merge returns MergeResult::NewLeaf.
  • If the revision creates a new branch (concurrent edit on a different replica), the merge returns MergeResult::NewBranch, creating a conflict that users can resolve later.

Step 7: Save Checkpoint

After each batch is successfully written, the replicator saves a checkpoint document to both source and target:

#![allow(unused)]
fn main() {
checkpointer.write_checkpoint(source, target, current_seq).await;
}

The checkpoint is written to both sides so that either side can resume even if the other is unavailable. If one write fails but the other succeeds, the replication can still resume (the compare_checkpoints function handles asymmetric checkpoints gracefully).

Checkpoint writes are fire-and-forget within a batch – the replicator only fails if both writes fail.

Batching

The replication loop processes changes in batches of batch_size (default 100). This provides:

  • Progress tracking: Checkpoints are saved after each batch, so a replication that is interrupted can resume from the last completed batch rather than starting over.
  • Memory management: Only one batch worth of documents is held in memory at a time.
  • Incremental progress: The ReplicationResult tracks docs_read and docs_written across all batches.

The loop terminates when a changes response returns fewer results than batch_size, indicating all changes have been consumed.

Configuration

#![allow(unused)]
fn main() {
pub struct ReplicationOptions {
    pub batch_size: u64,                   // default: 100
    pub batches_limit: u64,                // default: 10
    pub filter: Option<ReplicationFilter>, // default: None
    pub since: Option<Seq>,                // default: None (use checkpoint)
    pub checkpoint: bool,                  // default: true
    pub live: bool,                        // default: false
    pub retry: bool,                       // default: false
    pub poll_interval: Duration,           // default: 500ms
    pub back_off_function: Option<Box<dyn Fn(u32) -> Duration + Send + Sync>>,
}
}
  • batch_size – number of change events to process per iteration.
  • batches_limit – maximum number of batches to buffer (reserved for future pipelining).
  • filter – optional filter for selective replication. Supports DocIds(Vec<String>), Selector(serde_json::Value), or Custom(Arc<dyn Fn(&ChangeEvent) -> bool + Send + Sync>). When DocIds is used, filtering happens at the changes feed level. Selector filters after bulk_get. Custom filters after fetching changes.
  • live – enable continuous replication mode.
  • retry – automatically retry on transient errors in live mode.
  • poll_interval – how often to poll for new changes in live mode.
  • back_off_function – custom backoff for retries; receives retry count, returns delay duration.

Result

#![allow(unused)]
fn main() {
pub struct ReplicationResult {
    pub ok: bool,              // true if no errors occurred
    pub docs_read: u64,        // total change events processed
    pub docs_written: u64,     // total documents written to target
    pub errors: Vec<String>,   // individual doc errors (non-fatal)
    pub last_seq: Seq,         // final sequence reached
}
}

Individual document parse errors or write errors are collected in the errors vector but do not abort the replication. The ok field is true only when errors is empty.

Error Handling

Network errors: If source.changes(), target.revs_diff(), source.bulk_get(), or target.bulk_docs() returns an Err, the entire replication aborts and the error propagates to the caller. The last successfully saved checkpoint allows the next attempt to resume.

Document-level errors: If a document cannot be parsed from the bulk_get response, or if a bulk_docs write reports ok: false for a specific document, the error message is appended to ReplicationResult.errors but replication continues with the remaining documents.

Checkpoint errors: If writing a checkpoint fails on one side, the replicator continues. If both sides fail, the error is returned. On the next replication attempt, compare_checkpoints falls back to the most recent common session in the history array, or to sequence 0 if no common point exists.

Auth errors: These manifest as adapter-level Err results (e.g., HTTP 401/403 from the http adapter) and abort the replication immediately.

Incremental Replication

Because checkpoints are persisted, subsequent replications between the same source and target are incremental. They start from the last checkpointed sequence and only process new changes. This is the key to efficient ongoing synchronization:

First replication:   0 -----> 50  (processes 50 changes)
                              ^ checkpoint saved

Second replication: 50 -----> 53  (processes only 3 new changes)
                              ^ checkpoint updated

Live (Continuous) Replication

The replicate_live() function extends the one-shot protocol into a continuous loop:

 ┌─────────────────────────────────────────────────┐
 │              Live Replication Loop               │
 │                                                  │
 │  ┌──────────────┐                                │
 │  │  One-shot    │──(changes found)──→ emit       │
 │  │  replicate() │       Complete event           │
 │  └──────┬───────┘                                │
 │         │                                        │
 │    (no changes)                                  │
 │         │                                        │
 │    emit Paused                                   │
 │         │                                        │
 │    sleep(poll_interval)                          │
 │         │                                        │
 │    ┌────▼────┐                                   │
 │    │cancelled?│──yes──→ stop                     │
 │    └────┬────┘                                   │
 │         │ no                                     │
 │         └───────→ loop back                      │
 └─────────────────────────────────────────────────┘

Key implementation details:

  • CancellationToken from tokio_util controls the loop. The caller receives a ReplicationHandle that wraps the token. Calling handle.cancel() or dropping the handle stops the loop.
  • Event channel: A tokio::sync::mpsc::Sender<ReplicationEvent> streams progress events (Active, Change, Complete, Paused, Error) to the caller.
  • Retry with backoff: When retry: true and an error occurs, the loop sleeps for back_off_function(retry_count) before retrying. The retry counter resets after a successful cycle.
  • Poll interval: Between successful cycles where no changes were found, the loop sleeps for poll_interval before checking again.

Event Streaming

The replicate_with_events() function is a one-shot replication that emits events through an mpsc channel as it progresses:

  • Active – emitted when replication starts.
  • Change { docs_read } – emitted after each batch of changes is written to the target.
  • Complete(ReplicationResult) – emitted when replication finishes.
  • Error(String) – emitted on per-document errors (non-fatal).

This enables UI progress tracking, logging, and monitoring without blocking the replication process.

Bidirectional Sync

The protocol is unidirectional by design. To achieve bidirectional sync, run two replications:

#![allow(unused)]
fn main() {
// A -> B
replicate(&adapter_a, &adapter_b, opts.clone()).await?;

// B -> A
replicate(&adapter_b, &adapter_a, opts).await?;
}

Each direction maintains its own checkpoint (different replication_id because the source/target order is reversed in the hash). Conflicts created by concurrent edits on both sides are handled naturally by the revision tree merge algorithm – both branches are preserved and the deterministic winning-rev algorithm ensures convergence.

Storage Layer

The rouchdb-adapter-redb crate provides persistent local storage backed by redb, a pure-Rust embedded key-value store with ACID transactions. This document describes the table schema, key/value formats, serialization approach, and transactional guarantees.

Why redb

  • Pure Rust, no C dependencies. Eliminates build complexity and cross-compilation issues.
  • ACID transactions. Crash-safe reads and writes out of the box.
  • Typed tables. redb::TableDefinition encodes key and value types at compile time.
  • Single-file database. One .redb file per database, easy to manage.

Table Schema

The adapter defines six tables:

+-------------------+---------------+-----------------+
| Table             | Key Type      | Value Type      |
+-------------------+---------------+-----------------+
| DOC_TABLE         | &str          | &[u8]           |
| REV_DATA_TABLE    | &str          | &[u8]           |
| CHANGES_TABLE     | u64           | &[u8]           |
| LOCAL_TABLE       | &str          | &[u8]           |
| ATTACHMENT_TABLE  | &str          | &[u8]           |
| META_TABLE        | &str          | &[u8]           |
+-------------------+---------------+-----------------+

All value types are &[u8] – the adapter serializes Rust structs to JSON bytes using serde_json::to_vec and deserializes with serde_json::from_slice.

DOC_TABLE ("docs")

Purpose: Stores document metadata, including the full revision tree and the current sequence number.

Key: Document ID as a string (&str).

Value: JSON-serialized DocRecord:

#![allow(unused)]
fn main() {
struct DocRecord {
    rev_tree: Vec<SerializedRevPath>,
    seq: u64,
}

struct SerializedRevPath {
    pos: u64,
    tree: SerializedRevNode,
}

struct SerializedRevNode {
    hash: String,
    status: String,      // "available" or "missing"
    deleted: bool,
    children: Vec<SerializedRevNode>,
}
}

The DocRecord contains:

  • rev_tree – the complete revision tree, serialized as a recursive JSON structure. Each node stores its hash, availability status, deleted flag, and children. This is a direct serialization of the in-memory RevTree / RevPath / RevNode types.
  • seq – the most recent change sequence number for this document. Used to update the changes table when the document is modified (the old change entry at this sequence is removed and a new one is inserted).

Example stored value:

{
  "rev_tree": [
    {
      "pos": 1,
      "tree": {
        "hash": "a1b2c3d4e5f6...",
        "status": "missing",
        "deleted": false,
        "children": [
          {
            "hash": "f7e8d9c0b1a2...",
            "status": "available",
            "deleted": false,
            "children": []
          }
        ]
      }
    }
  ],
  "seq": 42
}

REV_DATA_TABLE ("rev_data")

Purpose: Stores the actual JSON body for each revision.

Key: Composite key "{doc_id}\0{rev_str}" – the document ID and full revision string (e.g., "3-abc123") separated by a null byte. The null byte ensures that keys for the same document are contiguous in the table.

#![allow(unused)]
fn main() {
fn rev_data_key(doc_id: &str, rev_str: &str) -> String {
    format!("{}\0{}", doc_id, rev_str)
}
}

Value: JSON-serialized RevDataRecord:

#![allow(unused)]
fn main() {
struct RevDataRecord {
    data: serde_json::Value,
    deleted: bool,
}
}
  • data – the document body (everything except _id, _rev, _deleted, _attachments, and _revisions).
  • deleted – whether this specific revision is a deletion tombstone.

Note that the data field stores the user’s JSON as-is. CouchDB underscore fields (_id, _rev, etc.) are stripped before storage and re-injected on read.

CHANGES_TABLE ("changes")

Purpose: Implements the changes feed. Each entry represents the most recent change for a document.

Key: Sequence number (u64). This is a monotonically increasing integer, incremented by 1 on every document write.

Value: JSON-serialized ChangeRecord:

#![allow(unused)]
fn main() {
struct ChangeRecord {
    doc_id: String,
    deleted: bool,
}
}

When a document is updated, the adapter:

  1. Removes the old change entry at the document’s previous sequence number
  2. Inserts a new entry at the new sequence number

This means each document appears at most once in the changes table, at its most recent sequence. Querying changes(since: N) performs a range scan over (N+1..).

Example table state after 5 writes:

Seq | doc_id   | deleted
----|----------|--------
  3 | "doc1"   | false      (doc1 was written at seq 1, updated at seq 3)
  4 | "doc2"   | false
  5 | "doc3"   | true       (doc3 was deleted)

Sequences 1 and 2 no longer appear because those entries were replaced when their documents were updated.

LOCAL_TABLE ("local_docs")

Purpose: Stores local documents that are not replicated. The primary use case is replication checkpoints (_local/{replication_id}).

Key: Local document ID as a string (&str). The _local/ prefix used in CouchDB’s HTTP API is stripped – the key is just the ID portion.

Value: Raw JSON bytes (serde_json::Value serialized with to_vec).

Local documents do not have revision trees or sequence numbers. They are simple key-value pairs that can be read, written, and deleted. They do not appear in the changes feed or in _all_docs results.

ATTACHMENT_TABLE ("attachments")

Purpose: Stores raw attachment binary data, keyed by content digest.

Key: Content digest as a string (&str), e.g., "md5-abc123...".

Value: Raw bytes of the attachment (&[u8]).

Content-addressable storage means identical attachments are stored only once regardless of how many documents reference them.

Note: Attachment support in the redb adapter is not yet fully implemented. The table is created on initialization but the put_attachment and get_attachment methods currently return errors.

META_TABLE ("metadata")

Purpose: Global database metadata.

Key: Always the string "meta" (single-row table).

Value: JSON-serialized MetaRecord:

#![allow(unused)]
fn main() {
struct MetaRecord {
    update_seq: u64,
    db_uuid: String,
}
}
  • update_seq – the current highest sequence number. Incremented on every document write. Used by info() to report the database’s update sequence.
  • db_uuid – a random UUID generated when the database is first created. Reset when the database is destroyed.

Serialization Approach

All structured data is serialized to JSON bytes using serde_json::to_vec and deserialized with serde_json::from_slice. This was chosen over binary formats (bincode, MessagePack) for several reasons:

  1. Debuggability. JSON values can be inspected with standard tools.
  2. Compatibility. The serialized format closely mirrors what CouchDB stores and returns.
  3. Flexibility. Document bodies are already serde_json::Value, so no format conversion is needed.

The revision tree requires a separate set of “serialized” types (SerializedRevPath, SerializedRevNode) because the in-memory types (RevPath, RevNode) use an enum for RevStatus and a struct for NodeOpts. The serialized types flatten these into simple strings and bools:

RevStatus::Available  ->  "available"
RevStatus::Missing    ->  "missing"
NodeOpts { deleted }  ->  bool field `deleted`

Conversion functions handle the mapping:

#![allow(unused)]
fn main() {
fn rev_tree_to_serialized(tree: &RevTree) -> Vec<SerializedRevPath>
fn serialized_to_rev_tree(paths: &[SerializedRevPath]) -> RevTree
}

Write Serialization

All document writes go through bulk_docs, which acquires a Tokio RwLock before beginning a redb write transaction:

#![allow(unused)]
fn main() {
pub struct RedbAdapter {
    db: Arc<Database>,
    name: String,
    write_lock: Arc<RwLock<()>>,
}
}

The write lock is necessary because document writes are read-modify-write operations: they must read the current DocRecord, merge the new revision into the tree, and write the updated record back. Without the lock, two concurrent writes to the same document could read the same tree, merge independently, and one would overwrite the other’s changes.

redb provides its own transaction isolation (write transactions are serialized at the redb level), but the Tokio lock ensures that the Rust-level read-modify-write sequence is atomic.

The flow within a single bulk_docs call:

1. Acquire write_lock
2. Begin redb write transaction
3. Read META_TABLE to get current update_seq
4. For each document:
   a. Read existing DocRecord from DOC_TABLE (if any)
   b. Deserialize revision tree
   c. Generate new revision (or accept as-is for replication)
   d. merge_tree(existing_tree, new_path, rev_limit)
   e. Increment update_seq
   f. Remove old CHANGES_TABLE entry (if document existed before)
   g. Write updated DocRecord to DOC_TABLE
   h. Write RevDataRecord to REV_DATA_TABLE
   i. Write ChangeRecord to CHANGES_TABLE
5. Write updated MetaRecord to META_TABLE
6. Commit transaction
7. Release write_lock

If any step fails, the redb transaction is not committed and all changes are rolled back. The write lock is released when the _lock guard is dropped.

Two Write Modes

new_edits=true (Normal Writes)

Used for local application writes. The adapter:

  1. Checks for conflicts – the provided _rev must match the current winning revision.
  2. Generates a new revision hash from MD5(prev_rev + deleted + json_body).
  3. Builds a RevPath with [new_hash, prev_hash] and merges it.

new_edits=false (Replication Writes)

Used during replication. The adapter:

  1. Does not check for conflicts.
  2. Accepts the revision ID from the source document as-is.
  3. If the document includes _revisions metadata, builds a full-ancestry RevPath using build_path_from_revs and merges the entire chain.
  4. Strips _revisions from the stored document body.

This mode allows the target to reconstruct the source’s revision tree faithfully, including branches that represent conflicts.

Transactional Guarantees

  • Atomicity. All documents in a single bulk_docs call are written in one redb transaction. Either all succeed or none do.
  • Durability. Once commit() returns, data is persisted to disk (redb uses fsync).
  • Consistency. The sequence number in META_TABLE always matches the highest key in CHANGES_TABLE. The revision tree in DOC_TABLE always reflects all revisions whose data exists in REV_DATA_TABLE.
  • Isolation. Concurrent reads (using begin_read) see a consistent snapshot and are not blocked by writes.

Initialization

When RedbAdapter::open is called:

  1. Database::create opens or creates the .redb file.
  2. A write transaction creates all six tables (redb creates tables on first open within a write transaction).
  3. If META_TABLE has no "meta" entry, a fresh MetaRecord is inserted with update_seq: 0 and a new UUID.

Destroy

destroy() drains all entries from all six tables and resets the metadata to update_seq: 0 with a fresh UUID. The database file itself is not deleted – it remains on disk but is empty.

Revision Hash Generation

#![allow(unused)]
fn main() {
fn generate_rev_hash(
    doc_data: &serde_json::Value,
    deleted: bool,
    prev_rev: Option<&str>,
) -> String
}

The hash is computed as:

MD5( [prev_rev_string] + ("1" if deleted else "0") + json_serialized_body )

This is deterministic: the same edit on the same predecessor always produces the same hash. The hex-encoded MD5 digest becomes the hash portion of the revision ID (e.g., "2-a1b2c3d4...").

Key Format Summary

DOC_TABLE:         "doc1"                   -> DocRecord JSON
REV_DATA_TABLE:    "doc1\03-a1b2c3..."      -> RevDataRecord JSON
CHANGES_TABLE:     42                        -> ChangeRecord JSON
LOCAL_TABLE:       "replication-id-hash"     -> arbitrary JSON
ATTACHMENT_TABLE:  "md5-digest..."           -> raw bytes
META_TABLE:        "meta"                    -> MetaRecord JSON

Development Setup

This guide walks you through setting up a local development environment for RouchDB.

Prerequisites

Rust Toolchain

RouchDB requires a recent stable Rust toolchain. Install it via rustup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Verify your installation:

rustc --version
cargo --version

The workspace uses edition 2024 and resolver version 3, so you need Rust 1.85 or later.

Docker (for integration tests)

Integration tests run against a real CouchDB instance. You need Docker and Docker Compose installed:

Docker is not required for building the project or running unit tests.

Clone and Build

git clone https://github.com/RubyLabApp/rouchdb.git
cd rouchdb
cargo build

The workspace compiles all 8 crates:

CratePurpose
rouchdb-coreTypes, traits, revision tree, merge, collation, errors
rouchdb-adapter-memoryIn-memory adapter (testing and ephemeral use)
rouchdb-adapter-redbPersistent local storage via redb
rouchdb-adapter-httpCouchDB HTTP client adapter
rouchdb-changesStreaming changes feed
rouchdb-replicationCouchDB replication protocol
rouchdb-queryMango selectors and map/reduce views
rouchdbUmbrella crate that re-exports everything

Running Unit Tests

Unit tests live inside each crate as #[cfg(test)] modules. They do not require any external services:

cargo test

This runs all unit tests across every crate in the workspace. To test a single crate:

cargo test -p rouchdb-core
cargo test -p rouchdb-adapter-memory

Docker Compose Setup for CouchDB

The project includes a docker-compose.yml at the repository root that starts a CouchDB 3 instance:

services:
  couchdb:
    image: couchdb:3
    ports:
      - "15984:5984"
    environment:
      COUCHDB_USER: admin
      COUCHDB_PASSWORD: password
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5984/_up"]
      interval: 3s
      timeout: 5s
      retries: 10

Start CouchDB:

docker compose up -d

Wait for it to be healthy:

docker compose ps

You should see the couchdb service listed as healthy. CouchDB is now accessible at http://localhost:15984 with credentials admin:password.

To stop CouchDB when you are done:

docker compose down

Running Integration Tests

Integration tests verify RouchDB against a real CouchDB server. They are marked #[ignore] so they do not run during normal cargo test. Run them explicitly:

docker compose up -d
cargo test -p rouchdb --test couchdb_integration -- --ignored

Environment Variables

COUCHDB_URL

Override the default CouchDB connection URL used by integration tests. The default is:

http://admin:password@localhost:15984

To use a different CouchDB instance:

export COUCHDB_URL="http://myuser:mypass@couchdb.example.com:5984"
cargo test -p rouchdb --test couchdb_integration -- --ignored

Editor Setup

RouchDB is a standard Cargo workspace. Any editor with rust-analyzer support works well:

  • VS Code with the rust-analyzer extension
  • RustRover (JetBrains)
  • Neovim / Helix with rust-analyzer LSP

No special configuration files are needed beyond what Cargo provides.

Crate Guide

RouchDB is structured as a Cargo workspace with 9 crates. This guide helps you figure out where to add new code.

Where Do I Add Code?

Use this decision tree to find the right crate:

Is it a new document type, revision type, or error variant?
  --> rouchdb-core

Is it a new storage backend?
  --> New crate implementing the Adapter trait (see "Adding an Adapter" below)

Is it a change to how queries work (Mango selectors, map/reduce)?
  --> rouchdb-query

Is it a change to design documents or the persistent view engine?
  --> rouchdb-views

Is it a change to the replication protocol or checkpointing?
  --> rouchdb-replication

Is it a change to the changes feed or live streaming?
  --> rouchdb-changes

Is it a change to the high-level Database API?
  --> rouchdb (the umbrella crate)

Is it a change to how CouchDB HTTP communication works?
  --> rouchdb-adapter-http

Is it a change to the in-memory storage implementation?
  --> rouchdb-adapter-memory

Is it a change to the persistent redb storage implementation?
  --> rouchdb-adapter-redb

Crate Descriptions

rouchdb-core

The foundation crate. Everything else depends on it.

Responsibility: Core types, traits, and algorithms shared across the entire project.

Key files:

  • src/adapter.rs – The Adapter trait that all storage backends implement. Defines methods like get, bulk_docs, all_docs, changes, revs_diff, bulk_get, and local document operations.
  • src/document.rs – Document types (Document, Revision, DocResult, DbInfo, AllDocsResponse, ChangesResponse, Seq, etc.).
  • src/rev_tree.rs – Revision tree data structures (RevTree, RevPath, RevNode) and tree traversal utilities.
  • src/merge.rs – Revision tree merging algorithm, winning revision selection (winning_rev), and conflict detection.
  • src/collation.rs – CouchDB-compatible collation ordering for view keys.
  • src/error.rsRouchError enum and Result type alias.

rouchdb-adapter-memory

Responsibility: In-memory adapter for fast testing and ephemeral databases. Stores documents in HashMap behind a RwLock.

Key files:

  • src/lib.rs – Full Adapter trait implementation with StoredDoc internal structure, revision tree management, and sequence tracking.

rouchdb-adapter-redb

Responsibility: Persistent local storage using redb, a pure-Rust embedded key-value store.

Key files:

  • src/lib.rsRedbAdapter struct implementing the Adapter trait with durable on-disk storage.

rouchdb-adapter-http

Responsibility: Remote CouchDB communication. Maps each Adapter method to the corresponding CouchDB REST API endpoint using reqwest.

Key files:

  • src/lib.rsHttpAdapter struct, CouchDB JSON response deserialization types, and the full Adapter trait implementation.

rouchdb-changes

Responsibility: Streaming changes feed with one-shot and live/continuous modes.

Key files:

  • src/lib.rsChangeSender, ChangeReceiver, ChangeNotification, and LiveChangesStream. Uses Tokio broadcast channels for real-time change notification.

rouchdb-replication

Responsibility: CouchDB replication protocol implementation. Handles checkpoint-based incremental replication between any two adapters.

Key files:

  • src/protocol.rs – The core replication loop: read changes from source, diff against target, fetch missing documents, write to target.
  • src/checkpoint.rs – Checkpoint management using _local documents to track replication progress.
  • src/lib.rs – Public API: replicate() function, ReplicationOptions, ReplicationResult, ReplicationEvent.

rouchdb-query

Responsibility: Mango query selectors and map/reduce view queries.

Key files:

  • src/mango.rs – Mango selector parsing and matching (matches_selector). Supports operators like $eq, $gt, $in, $regex, etc.
  • src/mapreduce.rs – Map/reduce view execution (query_view). Takes a map function, optional reduce function, and query options.
  • src/lib.rs – Re-exports: find, FindOptions, FindResponse, ViewQueryOptions, ViewResult, ReduceFn, SortField.

rouchdb-views

Responsibility: Design documents and the persistent view engine.

Key files:

  • src/lib.rsDesignDocument struct (with views, filters, validate_doc_update) and ViewEngine for persistent map/reduce indexes.

rouchdb

Responsibility: The umbrella crate that end users depend on. Provides the high-level Database struct and re-exports types from all other crates.

Key files:

  • src/lib.rsDatabase struct with user-friendly methods (put, get, update, remove, replicate_to, replicate_from, find, all_docs, changes). Wraps any Adapter behind an Arc<dyn Adapter>.
  • tests/*.rs – Integration tests against a real CouchDB instance (multiple test files: replication.rs, http_crud.rs, changes_feed.rs, etc.).

Adding an Adapter

To add a new storage backend (e.g., SQLite, IndexedDB via wasm):

  1. Create a new crate: crates/rouchdb-adapter-yourbackend/
  2. Add it to the workspace members list in the root Cargo.toml
  3. Depend on rouchdb-core for the Adapter trait and document types
  4. Implement the Adapter trait from rouchdb_core::adapter::Adapter
  5. Add the crate as a dependency in the umbrella rouchdb crate and re-export it

The Adapter trait requires implementing these async methods:

  • info() – Database metadata
  • get() – Fetch a single document
  • bulk_docs() – Write multiple documents atomically
  • all_docs() – List/query all documents
  • changes() – Get changes since a sequence
  • revs_diff() – Compare revision sets (for replication)
  • bulk_get() – Fetch multiple documents by ID and revision
  • get_local() / put_local() / remove_local() – Local (non-replicated) document operations
  • get_attachment() / put_attachment() / remove_attachment() – Attachment storage
  • close() – Clean up resources

Look at rouchdb-adapter-memory as a reference implementation – it is the simplest complete adapter.

Dependency Graph

rouchdb (umbrella)
  |-- rouchdb-core
  |-- rouchdb-adapter-memory   --> rouchdb-core
  |-- rouchdb-adapter-redb     --> rouchdb-core
  |-- rouchdb-adapter-http     --> rouchdb-core
  |-- rouchdb-changes          --> rouchdb-core
  |-- rouchdb-query            --> rouchdb-core
  |-- rouchdb-views            --> rouchdb-core
  |-- rouchdb-replication      --> rouchdb-core, rouchdb-query

All crates depend on rouchdb-core. The umbrella rouchdb crate depends on all of them and re-exports their public APIs.

Testing

RouchDB has two categories of tests: unit tests that run without external dependencies, and integration tests that require a running CouchDB instance.

Unit Tests

Unit tests are defined as #[cfg(test)] modules inside each crate’s source files. They cover internal logic without any external services.

Running All Unit Tests

cargo test

This runs every unit test across all 9 workspace crates.

Running Tests for a Single Crate

cargo test -p rouchdb-core
cargo test -p rouchdb-adapter-memory
cargo test -p rouchdb-query

Running a Specific Test

cargo test -p rouchdb-core winning_rev_simple

Integration Tests

Integration tests live in crates/rouchdb/tests/ across multiple test files (http_crud.rs, replication.rs, changes_feed.rs, mango_queries.rs, etc.). They verify RouchDB against a real CouchDB server to ensure protocol compliance and end-to-end correctness.

Prerequisites

Start CouchDB via Docker Compose:

docker compose up -d

Wait for the health check to pass (the service should report healthy):

docker compose ps

The default connection URL is http://admin:password@localhost:15984.

Running Integration Tests

All integration tests are marked #[ignore] so they are skipped during cargo test. Run them with:

cargo test -p rouchdb --test '*' -- --ignored

To run a single integration test by name:

cargo test -p rouchdb --test http_crud http_put_and_get -- --ignored

Custom CouchDB URL

To point tests at a different CouchDB instance, set the COUCHDB_URL environment variable:

COUCHDB_URL="http://user:pass@myhost:5984" \
  cargo test -p rouchdb --test '*' -- --ignored

Writing New Unit Tests

Unit tests go in a #[cfg(test)] module at the bottom of the source file they are testing.

Synchronous Tests (pure logic)

For functions that do not involve async I/O, use standard #[test]:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn my_pure_logic_test() {
        let tree = build_some_rev_tree();
        let winner = winning_rev(&tree).unwrap();
        assert_eq!(winner.pos, 3);
        assert_eq!(winner.hash, "abc");
    }
}
}

This pattern is used extensively in rouchdb-core for revision tree operations, merge algorithms, and collation ordering.

Async Tests (adapter operations)

For tests that exercise adapter methods, use #[tokio::test]:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use rouchdb_core::document::{AllDocsOptions, BulkDocsOptions, GetOptions};

    async fn new_db() -> MemoryAdapter {
        MemoryAdapter::new("test")
    }

    #[tokio::test]
    async fn put_and_get_document() {
        let db = new_db().await;

        let doc = Document {
            id: "doc1".into(),
            rev: None,
            deleted: false,
            data: serde_json::json!({"name": "Alice"}),
            attachments: HashMap::new(),
        };

        let results = db
            .bulk_docs(vec![doc], BulkDocsOptions::new())
            .await
            .unwrap();
        assert!(results[0].ok);

        let fetched = db.get("doc1", GetOptions::default()).await.unwrap();
        assert_eq!(fetched.data["name"], "Alice");
    }
}
}

Guidelines for Unit Tests

  • Place tests in the same file as the code they exercise.
  • Use a helper function (e.g., new_db()) to create a fresh adapter instance per test.
  • Test both the success path and error conditions.
  • Keep tests focused – one logical assertion per test function when practical.

Writing New Integration Tests

Integration tests go in crates/rouchdb/tests/ as separate test files. They test the high-level Database API against a real CouchDB instance.

Structure of an Integration Test

Every integration test follows this pattern:

#![allow(unused)]
fn main() {
#[tokio::test]
#[ignore]
async fn my_couchdb_test() {
    // 1. Create a fresh database with a unique name
    let url = fresh_remote_db("my_test_prefix").await;
    let db = Database::http(&url);

    // 2. Perform operations
    let result = db.put("doc1", serde_json::json!({"key": "value"})).await.unwrap();
    assert!(result.ok);

    // 3. Verify results
    let doc = db.get("doc1").await.unwrap();
    assert_eq!(doc.data["key"], "value");

    // 4. Clean up the database
    delete_remote_db(&url).await;
}
}

Key points:

  • Always add #[ignore] so the test does not run in cargo test.
  • Always use fresh_remote_db() to get a uniquely-named database. This prevents test interference.
  • Always call delete_remote_db() at the end to clean up.
  • The fresh_remote_db() helper creates the database via the CouchDB REST API and returns its full URL.

When to Write an Integration Test

Add an integration test when you need to verify:

  • HTTP adapter correctness against a real CouchDB server
  • Replication between a local adapter and CouchDB
  • Protocol-level compatibility (e.g., _revs_diff, _bulk_get responses)
  • Edge cases that depend on CouchDB-specific behavior

Test Patterns

Memory Adapter for Fast Tests

The MemoryAdapter is the primary tool for fast, isolated unit tests. It implements the full Adapter trait in memory with no I/O, making tests instant and deterministic.

Use MemoryAdapter when testing:

  • Replication protocol logic (by replicating between two memory adapters)
  • Changes feed behavior
  • Query/selector matching
  • Any feature that works at the Adapter trait level

Example from the replication crate:

#![allow(unused)]
fn main() {
let source = MemoryAdapter::new("source");
let target = MemoryAdapter::new("target");

// ... write docs to source ...

replicate(&source, &target, ReplicationOptions::default()).await.unwrap();

// ... verify docs appear in target ...
}

Real CouchDB for Protocol Compliance

Integration tests with a real CouchDB instance catch issues that in-memory tests cannot:

  • JSON serialization/deserialization mismatches
  • HTTP header requirements
  • CouchDB-specific revision handling quirks
  • Sequence format differences between CouchDB versions
  • Attachment encoding edge cases

Helper Functions in Integration Tests

The integration test files share three common helpers:

  • couchdb_url() – Returns the CouchDB base URL, respecting the COUCHDB_URL environment variable.
  • fresh_remote_db(prefix) – Creates a new CouchDB database with a UUID-based name and returns its URL.
  • delete_remote_db(url) – Deletes a CouchDB database by URL.

Continuous Integration

When running tests in CI, use two stages:

# Stage 1: Unit tests (no services needed)
cargo test

# Stage 2: Integration tests (CouchDB required)
docker compose up -d --wait
cargo test -p rouchdb --test '*' -- --ignored
docker compose down

The --wait flag tells Docker Compose to block until the health check passes before proceeding.

RouchDB

Una base de datos de documentos local-first para Rust con soporte del protocolo de replicacion de CouchDB.

RouchDB es el equivalente en Rust de PouchDB — te da un almacen local de documentos JSON que se sincroniza bidireccionalmente con Apache CouchDB y servidores compatibles.

Por que RouchDB?

  • No existe un equivalente en Rust. Crates como couch_rs proveen clientes HTTP para CouchDB, pero ninguno ofrece almacenamiento local con replicacion. RouchDB llena este vacio.
  • Disenado para funcionar sin conexion. Tu aplicacion funciona sin red. Cuando vuelve la conectividad, RouchDB sincroniza los cambios automaticamente.
  • Compatibilidad total con CouchDB. Implementa el protocolo de replicacion de CouchDB, el modelo de arbol de revisiones y el lenguaje de consultas Mango.

Caracteristicas

  • Operaciones CRUDput, get, update, remove, bulk_docs, all_docs
  • Consultas Mango$eq, $gt, $in, $regex, $or y mas de 15 operadores
  • Vistas map/reduce — closures de Rust con reduces integrados Sum, Count, Stats
  • Feed de cambios — consulta unica y streaming en vivo de mutaciones de documentos
  • Replicacion — push, pull y sincronizacion bidireccional con CouchDB
  • Resolucion de conflictos — algoritmo determinista de ganador, utilidades de deteccion de conflictos
  • Almacenamiento flexible — en memoria, persistente (redb) o remoto (HTTP)
  • Rust puro — sin dependencias de C, compila en todas las plataformas donde Rust compila

Casos de uso

  • Aplicaciones de escritorio con Tauri que necesitan sincronizacion offline
  • Herramientas CLI que almacenan datos localmente y sincronizan con un servidor
  • Servicios backend que replican entre instancias de CouchDB
  • Cualquier aplicacion Rust que necesite una base de datos de documentos local

Ejemplo rapido

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let db = Database::memory("mydb");

    // Crear un documento
    let result = db.put("user:alice", serde_json::json!({
        "name": "Alice",
        "age": 30
    })).await?;

    // Leerlo de vuelta
    let doc = db.get("user:alice").await?;
    println!("{}: {}", doc.id, doc.data);

    Ok(())
}

Listo para empezar? Ve a Inicio Rapido.

Inicio Rapido

Esta guia te lleva por las funcionalidades principales de RouchDB en 5 minutos.

Instalacion

Agrega RouchDB a tu proyecto:

[dependencies]
rouchdb = "0.1"
tokio = { version = "1", features = ["full"] }
serde_json = "1"

Crear una base de datos

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    // En memoria (datos se pierden al soltar — ideal para pruebas)
    let db = Database::memory("mydb");

    // Persistente (almacenado en disco via redb)
    // let db = Database::open("mydb.redb", "mydb")?;

    // CouchDB remoto
    // let db = Database::http("http://admin:password@localhost:5984/mydb");

    Ok(())
}

Crear y leer documentos

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let db = Database::memory("mydb");

    // Crear un documento
    let result = db.put("user:alice", serde_json::json!({
        "name": "Alice",
        "email": "alice@example.com",
        "age": 30
    })).await?;

    println!("Creado con rev: {}", result.rev.unwrap());

    // Leerlo de vuelta
    let doc = db.get("user:alice").await?;
    println!("Nombre: {}", doc.data["name"]); // "Alice"

    Ok(())
}

Actualizar y eliminar

Cada actualizacion requiere la revision actual para prevenir conflictos:

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let db = Database::memory("mydb");

    // Crear
    let r1 = db.put("user:alice", serde_json::json!({"name": "Alice", "age": 30})).await?;
    let rev = r1.rev.unwrap();

    // Actualizar (debe proveer la rev actual)
    let r2 = db.update("user:alice", &rev, serde_json::json!({
        "name": "Alice",
        "age": 31
    })).await?;

    // Eliminar (debe proveer la rev actual)
    let rev2 = r2.rev.unwrap();
    db.remove("user:alice", &rev2).await?;

    Ok(())
}

Consultas con Mango

Encuentra documentos que coincidan con un selector:

use rouchdb::{Database, FindOptions};

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let db = Database::memory("mydb");

    db.put("alice", serde_json::json!({"name": "Alice", "age": 30})).await?;
    db.put("bob", serde_json::json!({"name": "Bob", "age": 25})).await?;
    db.put("carol", serde_json::json!({"name": "Carol", "age": 35})).await?;

    // Encontrar usuarios mayores de 28
    let result = db.find(FindOptions {
        selector: serde_json::json!({"age": {"$gte": 28}}),
        ..Default::default()
    }).await?;

    for doc in &result.docs {
        println!("{}: edad {}", doc["name"], doc["age"]);
    }
    // Alice: edad 30
    // Carol: edad 35

    Ok(())
}

Sincronizar dos bases de datos

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    let local = Database::memory("local");
    let remote = Database::memory("remote");

    // Agregar datos a cada lado
    local.put("doc1", serde_json::json!({"desde": "local"})).await?;
    remote.put("doc2", serde_json::json!({"desde": "remote"})).await?;

    // Sincronizacion bidireccional
    let (push, pull) = local.sync(&remote).await?;
    println!("Push: {} docs escritos", push.docs_written);
    println!("Pull: {} docs escritos", pull.docs_written);

    // Ambas bases de datos ahora tienen ambos documentos
    let info = local.info().await?;
    println!("Local tiene {} docs", info.doc_count); // 2

    Ok(())
}

Siguientes pasos

Operaciones CRUD

Esta guia cubre las operaciones fundamentales de documentos en RouchDB.

Crear un documento

Usa put para crear un documento con un ID especifico:

#![allow(unused)]
fn main() {
use rouchdb::Database;

let db = Database::memory("mydb");

let result = db.put("user:1", serde_json::json!({
    "name": "Alice",
    "email": "alice@example.com"
})).await?;

assert!(result.ok);
println!("Rev: {}", result.rev.unwrap()); // "1-abc123..."
}

El resultado contiene:

  • oktrue si la operacion fue exitosa
  • id — el ID del documento
  • rev — la cadena de revision asignada

Leer un documento

#![allow(unused)]
fn main() {
let doc = db.get("user:1").await?;

println!("ID: {}", doc.id);
println!("Rev: {}", doc.rev.unwrap());
println!("Datos: {}", doc.data);
println!("Nombre: {}", doc.data["name"]); // "Alice"
}

Si el documento no existe, se retorna RouchError::NotFound.

Actualizar un documento

Para actualizar, debes proveer la revision actual. Esto previene conflictos de escritura:

#![allow(unused)]
fn main() {
// Leer primero para obtener la revision actual
let doc = db.get("user:1").await?;
let rev = doc.rev.unwrap().to_string();

// Actualizar con la revision actual
let result = db.update("user:1", &rev, serde_json::json!({
    "name": "Alice Smith",
    "email": "alice.smith@example.com"
})).await?;

println!("Nueva rev: {}", result.rev.unwrap()); // "2-def456..."
}

Si la revision proporcionada no coincide con la actual, se retorna RouchError::Conflict.

Eliminar un documento

La eliminacion es un “soft delete” — marca el documento como eliminado pero mantiene el historial de revisiones:

#![allow(unused)]
fn main() {
let doc = db.get("user:1").await?;
let rev = doc.rev.unwrap().to_string();

let result = db.remove("user:1", &rev).await?;
assert!(result.ok);

// Intentar leer ahora retorna NotFound
let err = db.get("user:1").await;
assert!(err.is_err());
}

Operaciones en lote

bulk_docs escribe multiples documentos en una sola operacion:

#![allow(unused)]
fn main() {
use rouchdb::{Document, BulkDocsOptions};
use std::collections::HashMap;

let docs = vec![
    Document {
        id: "user:1".into(),
        rev: None,
        deleted: false,
        data: serde_json::json!({"name": "Alice"}),
        attachments: HashMap::new(),
    },
    Document {
        id: "user:2".into(),
        rev: None,
        deleted: false,
        data: serde_json::json!({"name": "Bob"}),
        attachments: HashMap::new(),
    },
];

let results = db.bulk_docs(docs, BulkDocsOptions::new()).await?;

for r in &results {
    println!("{}: ok={}", r.id, r.ok);
}
}

Listar todos los documentos

all_docs lista documentos con opciones de paginacion y filtrado:

#![allow(unused)]
fn main() {
use rouchdb::AllDocsOptions;

// Todos los documentos con sus datos
let response = db.all_docs(AllDocsOptions {
    include_docs: true,
    ..AllDocsOptions::new()
}).await?;

println!("Total: {} documentos", response.total_rows);

for row in &response.rows {
    println!("{}: rev {}", row.id, row.value.rev);
    if let Some(ref doc) = row.doc {
        println!("  datos: {}", doc);
    }
}
}

Paginacion y rangos

#![allow(unused)]
fn main() {
// Documentos del 10 al 20
let page = db.all_docs(AllDocsOptions {
    skip: 10,
    limit: Some(10),
    ..AllDocsOptions::new()
}).await?;

// Solo documentos con IDs que empiezan con "user:"
let users = db.all_docs(AllDocsOptions {
    start_key: Some("user:".into()),
    end_key: Some("user:\u{ffff}".into()),
    include_docs: true,
    ..AllDocsOptions::new()
}).await?;

// Documentos especificos por ID
let specific = db.all_docs(AllDocsOptions {
    keys: Some(vec!["user:1".into(), "user:2".into()]),
    include_docs: true,
    ..AllDocsOptions::new()
}).await?;
}

Manejo de errores

#![allow(unused)]
fn main() {
use rouchdb::RouchError;

match db.get("no-existe").await {
    Ok(doc) => println!("Encontrado: {}", doc.data),
    Err(RouchError::NotFound(_)) => println!("Documento no encontrado"),
    Err(e) => eprintln!("Error inesperado: {}", e),
}

// Manejar conflictos de actualizacion
match db.update("user:1", "rev-incorrecta", serde_json::json!({})).await {
    Ok(_) => println!("Actualizado"),
    Err(RouchError::Conflict) => println!("Conflicto: el documento fue modificado"),
    Err(e) => eprintln!("Error: {}", e),
}
}

Consultas

RouchDB ofrece dos formas de consultar documentos: consultas Mango (selectores declarativos) y vistas map/reduce (funciones personalizadas).

Consultas Mango

Mango te permite buscar documentos usando selectores JSON, similar a MongoDB:

#![allow(unused)]
fn main() {
use rouchdb::{Database, FindOptions};

let db = Database::memory("mydb");

// Insertar datos de ejemplo
db.put("alice", serde_json::json!({"name": "Alice", "age": 30, "role": "admin"})).await?;
db.put("bob", serde_json::json!({"name": "Bob", "age": 25, "role": "user"})).await?;
db.put("carol", serde_json::json!({"name": "Carol", "age": 35, "role": "admin"})).await?;

// Buscar admins mayores de 28
let result = db.find(FindOptions {
    selector: serde_json::json!({
        "role": "admin",
        "age": {"$gte": 28}
    }),
    ..Default::default()
}).await?;

for doc in &result.docs {
    println!("{}", doc["name"]); // Alice, Carol
}
}

Operadores disponibles

OperadorDescripcionEjemplo
$eqIgual a{"name": {"$eq": "Alice"}} o {"name": "Alice"}
$neNo igual a{"role": {"$ne": "admin"}}
$gtMayor que{"age": {"$gt": 25}}
$gteMayor o igual que{"age": {"$gte": 25}}
$ltMenor que{"age": {"$lt": 30}}
$lteMenor o igual que{"age": {"$lte": 30}}
$inEsta en la lista{"role": {"$in": ["admin", "mod"]}}
$ninNo esta en la lista{"role": {"$nin": ["banned"]}}
$existsEl campo existe{"email": {"$exists": true}}
$regexCoincide con regex{"name": {"$regex": "^A"}}
$orLogico OR{"$or": [{"age": 25}, {"age": 30}]}
$andLogico AND{"$and": [{"age": {"$gt": 20}}, {"role": "admin"}]}
$notNegacion{"age": {"$not": {"$gt": 30}}}
$elemMatchCoincide en array{"tags": {"$elemMatch": {"$eq": "rust"}}}
$allContiene todos{"tags": {"$all": ["rust", "db"]}}
$sizeTamano del array{"tags": {"$size": 3}}
$modModulo{"age": {"$mod": [5, 0]}}
$typeTipo de valor{"name": {"$type": "string"}}

Proyeccion de campos

Selecciona solo los campos que necesitas:

#![allow(unused)]
fn main() {
let result = db.find(FindOptions {
    selector: serde_json::json!({"role": "admin"}),
    fields: Some(vec!["name".into(), "age".into()]),
    ..Default::default()
}).await?;
// Los documentos solo contienen _id, name, age
}

Ordenamiento

#![allow(unused)]
fn main() {
use rouchdb::SortField;

let result = db.find(FindOptions {
    selector: serde_json::json!({"age": {"$gt": 0}}),
    sort: Some(vec![
        SortField::Simple("age".into()),  // ascendente por defecto
    ]),
    ..Default::default()
}).await?;
}

Paginacion

#![allow(unused)]
fn main() {
let result = db.find(FindOptions {
    selector: serde_json::json!({"age": {"$gt": 0}}),
    skip: Some(10),
    limit: Some(5),
    ..Default::default()
}).await?;
}

Vistas Map/Reduce

Para consultas mas complejas, usa query_view con funciones map y reduce de Rust:

#![allow(unused)]
fn main() {
use rouchdb::{query_view, ViewQueryOptions, ReduceFn};

// Map: emite pares clave-valor por cada documento
let result = query_view(
    db.adapter(),
    &|doc| {
        // Emitir el rol como clave y 1 como valor
        if let Some(role) = doc.get("role").and_then(|r| r.as_str()) {
            vec![(serde_json::json!(role), serde_json::json!(1))]
        } else {
            vec![]
        }
    },
    Some(&ReduceFn::Count),  // Contar por grupo
    ViewQueryOptions {
        reduce: true,
        group: true,
        ..ViewQueryOptions::new()
    },
).await?;

for row in &result.rows {
    println!("{}: {} usuarios", row.key, row.value);
}
// "admin": 2 usuarios
// "user": 1 usuarios
}

Reduces integrados

FuncionDescripcion
ReduceFn::SumSuma todos los valores numericos
ReduceFn::CountCuenta el numero de filas
ReduceFn::StatsCalcula estadisticas: sum, count, min, max, sumsqr
ReduceFn::Custom(fn)Funcion reduce personalizada

Rangos de claves

#![allow(unused)]
fn main() {
let result = query_view(
    db.adapter(),
    &|doc| {
        let age = doc.get("age").cloned().unwrap_or(serde_json::json!(null));
        let name = doc.get("name").cloned().unwrap_or(serde_json::json!(null));
        vec![(age, name)]
    },
    None,
    ViewQueryOptions {
        start_key: Some(serde_json::json!(25)),
        end_key: Some(serde_json::json!(35)),
        ..ViewQueryOptions::new()
    },
).await?;
}

Mango vs Map/Reduce: cuando usar cada uno

CriterioMangoMap/Reduce
SimplicidadSelectores JSON, facil de usarRequiere escribir closures
FlexibilidadLimitado a operadores predefinidosLogica arbitraria de Rust
AgregacionesNo soportadoSum, Count, Stats, Custom
AgrupamientoNo soportadogroup, group_level
Uso tipicoFiltrar documentosReportes y analisis

Replicacion

RouchDB implementa el protocolo de replicacion de CouchDB, permitiendo sincronizacion bidireccional entre bases de datos locales y remotas.

Conceptos basicos

La replicacion copia cambios de una base de datos fuente a una base de datos destino. Es incremental: solo transfiere los documentos que han cambiado desde la ultima sincronizacion.

Replicacion basica

Push (local a remoto)

#![allow(unused)]
fn main() {
use rouchdb::Database;

let local = Database::open("mydb.redb", "mydb")?;
let remote = Database::http("http://admin:password@localhost:5984/mydb");

let result = local.replicate_to(&remote).await?;
println!("Docs leidos: {}", result.docs_read);
println!("Docs escritos: {}", result.docs_written);
}

Pull (remoto a local)

#![allow(unused)]
fn main() {
let result = local.replicate_from(&remote).await?;
}

Sync (bidireccional)

#![allow(unused)]
fn main() {
let (push, pull) = local.sync(&remote).await?;
println!("Push: {} escritos", push.docs_written);
println!("Pull: {} escritos", pull.docs_written);
}

Configurar CouchDB

Para pruebas, usa Docker Compose:

docker compose up -d

Esto levanta CouchDB en http://localhost:15984 con credenciales admin:password.

Para crear una base de datos en CouchDB:

curl -X PUT http://admin:password@localhost:15984/mydb

Opciones de replicacion

#![allow(unused)]
fn main() {
use rouchdb::ReplicationOptions;

let result = local.replicate_to_with_opts(&remote, ReplicationOptions {
    batch_size: 50,
    ..Default::default()
}).await?;
}
CampoTipoDefaultDescripcion
batch_sizeu64100Numero de cambios a procesar por iteracion
batches_limitu6410Maximo numero de lotes a buffear
filterOption<ReplicationFilter>NoneFiltro opcional para replicacion selectiva
sinceOption<Seq>NoneSecuencia inicial (en vez de leer checkpoint)
checkpointbooltrueDeshabilitar con false para no guardar/leer checkpoints
liveboolfalseHabilitar replicacion continua
retryboolfalseReintentar automaticamente en caso de error
poll_intervalDuration500msIntervalo de sondeo en modo continuo
back_off_functionOption<Box<dyn Fn(u32) -> Duration + Send + Sync>>NoneFuncion de backoff para reintentos

Replicacion filtrada

Se puede replicar un subconjunto de documentos usando ReplicationFilter:

#![allow(unused)]
fn main() {
use rouchdb::{ReplicationOptions, ReplicationFilter};

// Por IDs de documento
let result = local.replicate_to_with_opts(&remote, ReplicationOptions {
    filter: Some(ReplicationFilter::DocIds(vec!["doc1".into(), "doc2".into()])),
    ..Default::default()
}).await?;

// Por selector Mango
let result = local.replicate_to_with_opts(&remote, ReplicationOptions {
    filter: Some(ReplicationFilter::Selector(serde_json::json!({"type": "invoice"}))),
    ..Default::default()
}).await?;

// Por closure personalizado
let result = local.replicate_to_with_opts(&remote, ReplicationOptions {
    filter: Some(ReplicationFilter::Custom(std::sync::Arc::new(|change| {
        change.id.starts_with("public:")
    }))),
    ..Default::default()
}).await?;
}

Checkpoints

Los checkpoints permiten que la replicacion se reanude despues de una interrupcion. RouchDB guarda el progreso en documentos locales (_local/{replication_id}) en ambos lados.

Cuando la replicacion se reinicia:

  1. Lee el checkpoint de ambos lados
  2. Encuentra la ultima secuencia comun
  3. Reanuda desde ahi (no necesita empezar desde cero)

Resultado de la replicacion

#![allow(unused)]
fn main() {
let result = local.replicate_to(&remote).await?;

if result.ok {
    println!("Replicacion exitosa");
    println!("  Docs leidos: {}", result.docs_read);
    println!("  Docs escritos: {}", result.docs_written);
    println!("  Ultima secuencia: {:?}", result.last_seq);
} else {
    println!("Replicacion con errores:");
    for err in &result.errors {
        println!("  - {}", err);
    }
}
}
CampoTipoDescripcion
okbooltrue si no hubo errores
docs_readu64Cambios procesados
docs_writtenu64Documentos escritos en el destino
errorsVec<String>Errores individuales por documento
last_seqSeqUltima secuencia alcanzada

Ejemplo completo

Sincronizar una base de datos local redb con CouchDB:

use rouchdb::Database;

#[tokio::main]
async fn main() -> rouchdb::Result<()> {
    // Base de datos local persistente
    let local = Database::open("app.redb", "app")?;

    // CouchDB remoto
    let remote = Database::http("http://admin:password@localhost:5984/app");

    // Agregar datos localmente (funciona offline)
    local.put("nota:1", serde_json::json!({
        "titulo": "Mi primera nota",
        "contenido": "Hola desde RouchDB!"
    })).await?;

    // Cuando hay conexion, sincronizar
    let (push, pull) = local.sync(&remote).await?;

    println!("Sincronizacion completa:");
    println!("  Push: {} docs", push.docs_written);
    println!("  Pull: {} docs", pull.docs_written);

    Ok(())
}

Replicacion con eventos

Usa replicate_to_with_events() para recibir eventos de progreso durante la replicacion:

#![allow(unused)]
fn main() {
use rouchdb::ReplicationEvent;

let (result, mut rx) = local.replicate_to_with_events(
    &remote,
    ReplicationOptions::default(),
).await?;

while let Ok(event) = rx.try_recv() {
    match event {
        ReplicationEvent::Active => println!("Replicacion iniciada"),
        ReplicationEvent::Change { docs_read } => {
            println!("Progreso: {} docs leidos", docs_read);
        }
        ReplicationEvent::Complete(r) => {
            println!("Completado: {} escritos", r.docs_written);
        }
        ReplicationEvent::Error(msg) => println!("Error: {}", msg),
        ReplicationEvent::Paused => println!("Esperando cambios..."),
    }
}
}

Replicacion continua (live)

La replicacion continua se ejecuta en segundo plano, sondeando periodicamente por nuevos cambios. Equivalente a { live: true } en PouchDB.

#![allow(unused)]
fn main() {
use rouchdb::{ReplicationOptions, ReplicationEvent};

let (mut rx, handle) = local.replicate_to_live(&remote, ReplicationOptions {
    live: true,
    poll_interval: std::time::Duration::from_millis(500),
    retry: true,
    ..Default::default()
});

// Procesar eventos en un loop
tokio::spawn(async move {
    while let Some(event) = rx.recv().await {
        match event {
            ReplicationEvent::Complete(r) => {
                println!("Lote completado: {} docs escritos", r.docs_written);
            }
            ReplicationEvent::Paused => {
                println!("Actualizado, esperando nuevos cambios...");
            }
            _ => {}
        }
    }
});

// Cancelar cuando sea necesario
handle.cancel();
}

El ReplicationHandle controla la replicacion:

  • handle.cancel() detiene la replicacion.
  • Si se descarta el handle (Drop), la replicacion tambien se cancela.

Manejo de errores

  • Error de red: la replicacion se detiene. El checkpoint guarda el progreso para reanudar despues.
  • Error de autenticacion (401/403): la replicacion se detiene inmediatamente.
  • Error en documento individual: se registra en result.errors pero la replicacion continua con los demas documentos.
  • Conflicto de checkpoint (409): se reintenta con la ultima revision.