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
- Example: fold the delta stream yourself — maintain your own view from the diffs and prove it equals a fresh query.
- Read the change model for the delta shapes in depth.
- See replica and views for how subscriptions stay in sync.
- Browse the crates (
rindle,rindle-replica,rindle-sqlite) for the full API.