Stand up a live, incrementally-maintained SQLite query in a few minutes, using only the open-source engine. You open a SQLite database, register a table as a source, build a query, hydrate a materialized view over it, and push changes — and each push is write-through: it persists to SQLite and updates the view by the delta, never re-running the query.
Open source. This quickstart uses only
rindle(the IVM engine) andrindle-sqlite(the SQLite backend) — both Apache-2.0, atgithub.com/rindle-sh/rindle. Link them freely. The turnkey version — write ordinary SQL through one controlled writer and let apreupdatehook derive the deltas for you, plus multi-threaded reader scale-out — is the live-replica runtime, Rindle’s commercial high-concurrency layer on top of this same engine.
Add the crates
The engine crates aren’t on crates.io yet — depend on them from git (or a local
checkout). rindle-sqlite links SQLite through rusqlite’s bundled feature; use
the same rusqlite version so the Connection type lines up.
# Cargo.toml
[dependencies]
rindle = { git = "https://github.com/rindle-sh/rindle" }
rindle-sqlite = { git = "https://github.com/rindle-sh/rindle" }
rusqlite = { version = "0.32", features = ["bundled"] }
You build queries with rindle’s fluent builder, register a SQLite table with
rindle-sqlite’s TableSource, and drive everything through a rindle::Graph.
Open a database and register a source
Open a SQLite database and create your ordinary schema. The source uses the
primary key for row identity, so it requires a UNIQUE index on it — create one
explicitly (the live-replica runtime does this for you).
Then wrap the table in a TableSource and add it to a Graph.
use std::collections::HashMap;
use std::rc::Rc;
use rindle::{Graph, SourceSchema, ValueType};
use rindle_sqlite::{ColumnDef, GraphTableSourceExt, TableSource};
use rusqlite::Connection;
// 1. An ordinary SQLite database + schema, with a UNIQUE index on the primary key.
let db = Rc::new(Connection::open("app.db")?);
db.execute_batch(
"CREATE TABLE issues (id INTEGER NOT NULL, title TEXT, open BOOLEAN);
CREATE UNIQUE INDEX issues_pk ON issues (id);",
)?;
// 2. Describe the columns (name, engine type, nullable) and the primary key.
let columns = vec![
ColumnDef { name: "id".into(), ty: ValueType::Number, optional: false },
ColumnDef { name: "title".into(), ty: ValueType::String, optional: true },
ColumnDef { name: "open".into(), ty: ValueType::Boolean, optional: true },
];
// The SourceSchema (columns, primary-key indices, default sort) is what queries
// resolve against — reused below for both lowering and the view.
let schema = SourceSchema::new(vec!["id", "title", "open"], vec![0], vec![(0, true)]);
// 3. Register the table as a source on the graph.
let mut graph = Graph::new();
let issues_src = graph.add_table_source(
TableSource::new_with_schema(db.clone(), "issues", columns, vec![0], schema.clone()),
);
// `resolve` maps each table name to (source NodeId, SourceSchema).
let mut sources = HashMap::new();
sources.insert("issues", (issues_src, schema));
let resolve = |name: &str| sources.get(name).cloned();
Define a query
A query is a rindle::Ast, built with the fluent builder. The engine watches the
source and keeps the result up to date as rows change — no polling, no re-running.
use rindle::table;
// All OPEN issues.
let ast = table("issues").r#where("open", true).build();
More shapes: nesting, aggregates, projection
The builder also nests a correlated child, counts it, and projects columns — all detailed on supported queries:
// nested — every issue with its comments (correlate the child via `r.col`)
let with_comments = table("issues")
.sub_as("comments", |r| table("comments").r#where("issue_id", r.col("id")))
.build();
// aggregate — each issue with a live scalar count of its comments
let counted = table("issues")
.count_as("commentCount", |r| table("comments").r#where("issue_id", r.col("id")))
.build();
// projection — sync only selected columns
let slim = table("issues").select("id").select("title").build();
(A nested or joined query resolves every table it names, so register a
TableSource for each — comments as well as issues — the same way.)
Build the pipeline and hydrate a view
build_pipeline lowers the Ast into a wired dataflow graph; view_schema
derives the view’s hierarchical schema from the same resolve. Add a View over
the pipeline’s top, wire the final edge, and hydrate to materialize the initial
result.
use rindle::{build_pipeline, view_schema};
let top = build_pipeline(&mut graph, &ast, &resolve).expect("build the pipeline");
let vschema = view_schema(&ast, &resolve).expect("derive the view schema");
let view = graph.add_view(top, vschema);
graph.set_sink_edge(top, view);
graph.try_hydrate(view)?; // materialize the initial result set
// Read the materialized result. `ViewData` is a reference-stable list of entries;
// each `Entry` carries its `row` (and, for nested queries, child `rels`). Cells are
// read positionally with `row.col(c)`, or as a `Vec` with `row.to_value_vec()`.
let data = graph.view_data(view);
for entry in &data.items {
println!("{:?}", entry.row.to_value_vec());
}
In production prefer the fallible try_* entry points (TableSource::try_new*,
try_hydrate, try_source_push below) — they return RindleError instead of
panicking; their infallible peers .expect and are for tests and prototyping.
Push a change — write-through
A mutation is a rindle::SourceChange — Add, Remove, or Edit. Pushing one
through the SQLite source is write-through: it runs the underlying
INSERT / UPDATE / DELETE and folds the delta into every view over that
source, in one call. The cost is proportional to the change, not the table size.
use rindle::{owned_row, OwnedValue, SourceChange};
// Add two open issues — each push writes SQLite AND folds the delta into the view.
graph.try_source_push(issues_src, SourceChange::Add(
owned_row(vec![OwnedValue::Int(1), OwnedValue::str("first"), OwnedValue::Bool(true)]),
))?;
graph.try_source_push(issues_src, SourceChange::Add(
owned_row(vec![OwnedValue::Int(2), OwnedValue::str("second"), OwnedValue::Bool(true)]),
))?;
// Close issue 2 — an Edit that no longer matches `open = true`, so the view drops it.
graph.try_source_push(issues_src, SourceChange::Edit {
old: owned_row(vec![OwnedValue::Int(2), OwnedValue::str("second"), OwnedValue::Bool(true)]),
row: owned_row(vec![OwnedValue::Int(2), OwnedValue::str("second"), OwnedValue::Bool(false)]),
})?;
graph.flush_view(view); // close the transaction and fire the view's listeners
let data = graph.view_data(view); // now holds exactly issue 1
The view now equals what a fresh SELECT ... WHERE open = 1 would return — reached
by the delta alone, with no rescan. That equivalence — view-after-write ==
fresh-query — is the whole contract.
The complete program ships as a runnable example:
cargo run -p rindle-sqlite --example live_query
What you get
| Property | Guarantee |
|---|---|
| Correctness | The maintained view equals a freshly-hydrated query |
| Latency | Proportional to the change, not the table size |
| Openness | rindle + rindle-sqlite are Apache-2.0 — link freely |
Next steps
- Fold the delta stream yourself — subscribe to the raw
CaughtChangedeltas, maintain your own view, and prove it equals a fresh query. - How it works — the build → lower → hydrate → push data path.
- The change model — the
Add/Remove/Edit/Childdeltas in depth. - The live-replica runtime — write ordinary SQL, get the deltas derived for you, and scale readers out.