Use the engine directly

Rust quickstart

Stand up a live SQLite replica and watch an incremental change land, in about thirty lines.

Stand up a live, incrementally-maintained SQLite query in a few minutes. You open a SQLite database, register the tables you care about, register a query, write ordinary SQL through one controlled writer, and receive the raw incremental change events the engine emits after each commit.

Rindle ships as a set of Rust crates. The live-query wrapper is rindle-replica, which sits over the rindle IVM engine and SQLite.

Add the crate

Add rindle-replica to your workspace. It is a path/workspace member crate (not yet published to crates.io), so depend on it through the workspace:

# Cargo.toml
[dependencies]
rindle-replica = { path = "rindle-replica" }
rindle = { path = "." }

You build queries with rindle’s fluent builder and drive the live database through rindle_replica::Db.

Open a database and register tables

Db::open opens a file-backed wal2 SQLite database. Create your ordinary SQLite schema, then register each base table with the engine so it can derive incremental changes for queries over it.

use rindle_replica::{Db, QueryId};

let db = Db::open("app.db")?;

// Ordinary SQLite schema. `open` is a boolean column.
db.read(|c| {
    c.execute_batch(
        "CREATE TABLE issues  (id INTEGER PRIMARY KEY, title TEXT, open BOOLEAN);
         CREATE TABLE comments(id INTEGER PRIMARY KEY, issue_id INTEGER, body TEXT);",
    )
})?;

db.register_table("issues")?;
db.register_table("comments")?;

Define a query

A query is a rindle::Ast, built with the fluent builder. The engine watches the underlying tables and keeps the result up to date as rows change — no polling, no re-running the query. Build a flat query with r#where, or a nested query with sub_as (correlating the child to the parent via col).

// A flat live query: all OPEN issues. The first argument is a caller-chosen
// `QueryId` tag — your own opaque handle for this query (used to address it
// later, e.g. to tear it down).
let open_issues = db.query(
    QueryId(1),
    rindle::table("issues").r#where("open", true).build(),
)?;

// A nested live query: every issue with its comments.
let with_comments = db.query(
    QueryId(2),
    rindle::table("issues")
        .sub_as("comments", |r| {
            rindle::table("comments").r#where("issue_id", r.col("id"))
        })
        .build(),
)?;

More shapes: projection, aggregates, scalar subqueries

The builder also projects columns, counts a correlated child, and folds a unique lookup at build time — all detailed on supported queries:

use rindle::ExistsOpts;

// projection — sync only selected columns
let slim = rindle::table("issues").select("id").select("title").build();

// aggregate — each issue with a live scalar count of its comments
let counted = rindle::table("issues")
    .count_as("commentCount", |r| {
        rindle::table("comments").r#where("issue_id", r.col("id"))
    })
    .build();

// scalar subquery — fold a unique lookup to a literal at build time (a snapshot)
let owner = rindle::table("projects")
    .where_exists_with(
        |r| rindle::table("issues").r#where("id", 7).r#where("project_id", r.col("id")),
        ExistsOpts { scalar: true },
    )
    .build();

Subscribe to changes

Register a callback with Query::subscribe. It fires once immediately with Update::Hydrated (the initial result set, all Adds), then with Update::Changed after every committed write that affects the query. Each Update carries that transaction’s tx_id and a Vec<ChangeEvent> — the incremental delta, not a full re-fetch.

A ChangeEvent is one of four shapes: Add(node), Remove(node), Edit { old, row }, or Child { row, change, .. } (a change inside a nested relationship). Each node carries its row and, for nested queries, its relationships subtrees.

use rindle_replica::{ChangeEvent, Update};

fn print_event(ch: &ChangeEvent) {
    match ch {
        ChangeEvent::Add(n) => {
            println!("+ Add {:?}", n.row);
            for kids in n.relationships.values() {
                for kid in kids {
                    println!("    child {:?}", kid.row);
                }
            }
        }
        ChangeEvent::Remove(n) => println!("- Remove {:?}", n.row),
        ChangeEvent::Edit { old, row } => println!("~ Edit {:?} -> {:?}", old, row),
        ChangeEvent::Child { row, change, .. } => {
            println!("* Child of {:?}:", row);
            print_event(change);
        }
    }
}

open_issues.subscribe(|u: &Update| {
    let (kind, changes) = match u {
        Update::Hydrated { changes, .. } => ("HYDRATED", changes),
        Update::Changed { changes, .. } => ("CHANGED", changes),
    };
    println!("{kind} ({} event(s))", changes.len());
    for ch in changes {
        print_event(ch);
    }
});

Write through the one controlled writer

Every mutation flows through a single writer transaction opened with Db::write. Run ordinary SQL with WriteTxn::exec (positional parameters are rindle::OwnedValues), then WriteTxn::commit — which derives and delivers each affected query’s incremental events. Dropping the transaction, or calling WriteTxn::rollback, leaves every view untouched.

use rindle::OwnedValue;

// tx1: open two issues
let mut w = db.write()?;
w.exec(
    "INSERT INTO issues VALUES (?,?,?)",
    &[OwnedValue::Int(1), OwnedValue::str("first"), OwnedValue::Bool(true)],
)?;
w.exec(
    "INSERT INTO issues VALUES (?,?,?)",
    &[OwnedValue::Int(2), OwnedValue::str("second"), OwnedValue::Bool(true)],
)?;
w.commit()?;

// tx2: comment on issue 1, then close issue 2
let mut w = db.write()?;
w.exec(
    "INSERT INTO comments VALUES (?,?,?)",
    &[OwnedValue::Int(10), OwnedValue::Int(1), OwnedValue::str("nice")],
)?;
w.exec(
    "UPDATE issues SET open = ? WHERE id = ?",
    &[OwnedValue::Bool(false), OwnedValue::Int(2)],
)?;
w.commit()?;

// tx3: delete issue 1, then roll back — no events are delivered
let mut w = db.write()?;
w.exec("DELETE FROM issues WHERE id = ?", &[OwnedValue::Int(1)])?;
w.rollback();

println!("committed tx = {}", db.committed_tx_id().0);

The full program is in the repository as the live_issues example:

cargo run -p rindle-replica --example live_issues

What you get

Property Guarantee
Correctness The delivered events equal a freshly-hydrated query
Latency Proportional to the change, not the table size
Consistency Deltas are cut at commit boundaries (no partial txns)

Note — incremental maintenance runs against a pre-commit snapshot via the write-then-abort model, so the engine derives each query’s change without changing the latency profile of your durable writes.

Next steps