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. 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 is the live-replica runtime, Rindle’s commercial high-concurrency layer on top of this same engine. There you write ordinary SQL through one controlled writer, apreupdatehook derives the deltas for you, and readers scale out across threads.
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 matches.
# 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 returns — 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.