Rindle keeps a query result current by incremental view maintenance (IVM): you build a query, hydrate a materialized view from it, push the changes that happen to the underlying tables, and the view updates by the difference — never by re-running the query. The contract is view-after-push == fresh-query: applying the change stream leaves the view exactly equal to running the query from scratch.
Under the hood this is the open-source rindle engine (Apache-2.0), with the
rindle-sqlite backend when your sources are SQLite tables. This page walks the
data path end to end. For the shapes a query may take, see
Supported queries; for the change stream itself, see
Change model.
The data path
A query travels through four stages:
- Build an
Ast— either with Deltic, the fluentrindle::tablequery builder, or by deserializing the engine’s JSON wire format. - Lower that
Astinto a wired dataflowGraphof operators withrindle::build_pipeline. - Hydrate a
Viewover the graph to materialize the initial result set. - Push source changes and read the incrementally-maintained
ViewData.
1. Build a query
The fluent builder produces an Ast. Each method returns the builder, and
build finishes it:
use rindle::table;
let ast = table("issue")
.r#where("open", true)
.build();
r#where takes a field name and a value. build consumes the builder and returns
the Ast. (r#where is spelled with the raw-identifier escape because where is a
Rust keyword.)
You can also deserialize an Ast directly from its JSON wire format via serde, which is
how a JS client hands a query to the engine.
2. Lower it into a graph
rindle::build_pipeline walks the Ast and allocates the operator graph. Its
signature is:
pub fn build_pipeline(
graph: &mut rindle::Graph,
ast: &rindle::Ast,
resolve: &impl Fn(&str) -> Option<(rindle::NodeId, rindle::SourceSchema)>,
) -> Result<rindle::NodeId, rindle::BuildError>;
You first register each base table as a source on the Graph, then pass a resolve
closure that maps a table name to its source NodeId and SourceSchema — the
table-registration type (columns, primary key, default sort; deliberately no
relationships, which are a property of a query). build_pipeline returns the
NodeId of the pipeline’s top operator (or a BuildError if the Ast is outside
the supported subset):
use std::collections::HashMap;
use rindle::{build_pipeline, table, Graph, NodeId, SourceSchema};
let mut graph = Graph::new();
// Register the base table as an in-memory source. `SourceSchema::new` takes the
// columns, the primary-key column indices, and the table's default sort.
let schema = SourceSchema::new(vec!["id", "open"], vec![0], vec![(0, true)]);
let issue_src: NodeId = graph.add_source(schema.clone(), Vec::new());
// `resolve` maps each table name to (source NodeId, SourceSchema).
let mut sources: HashMap<&str, (NodeId, SourceSchema)> = HashMap::new();
sources.insert("issue", (issue_src, schema));
let resolve = |name: &str| sources.get(name).cloned();
let ast = table("issue").r#where("open", true).build();
let top: NodeId = build_pipeline(&mut graph, &ast, &resolve)
.expect("build the pipeline");
In production prefer the fallible Graph::try_add_source, which validates ingest row
widths and returns a RindleError instead of panicking on a malformed row.
3. Hydrate a view
A View is the materialized sink; it takes the richer Schema type (sort,
relationship slots, singularity), which rindle::view_schema derives from the
Ast and the same resolve — one derivation, shared with the wasm client. Add
the view over the pipeline’s top operator with Graph::add_view, wire the final
edge with Graph::set_sink_edge, then hydrate:
use rindle::view_schema;
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.hydrate(view);
// Read the materialized result: a reference-stable list of `Entry`s — each an
// owned `row` plus, for nested queries, child `rels`. Iterate `data.items`.
let data = graph.view_data(view);
Graph::hydrate drains the input pipeline once to build the initial result. As with
push, there is a fallible peer, Graph::try_hydrate, that returns Result<(), RindleError> — use it in a server.
4. Push changes
A mutation to a base table is a rindle::SourceChange. There are exactly three:
pub enum SourceChange {
Add(OwnedRow),
Remove(OwnedRow),
Edit { row: OwnedRow, old: OwnedRow },
}
Push one with Graph::source_push, then flush_view to close the transaction and
fire the view’s listeners:
use rindle::{owned_row, OwnedValue, SourceChange};
graph.source_push(
issue_src,
SourceChange::Add(owned_row(vec![OwnedValue::Int(7), OwnedValue::Bool(true)])),
);
graph.flush_view(view);
let updated = graph.view_data(view); // reflects the new row
The cost of the push is proportional to the change, not to the table size. In a
server, drive mutations through the fallible Graph::try_source_push (returns
RindleError); the infallible source_push / hydrate shown above .expect the result
and are for tests and prototyping. The rindle crate-level rustdoc documents the
full survivability contract.
The engine is single-threaded by design: one
Graphper thread (it is!Send). Scale with N independent graphs and message passing, never a sharedArc<Mutex<Graph>>.
Query planner
Some queries can be lowered more than one way. A correlated EXISTS can be driven
from the parent (filter the parent, probe the child) or flipped to be driven
from the child (stream matching children up to their parents) — and which is cheaper
depends on the data. Rindle ships a cost-based planner (rindle-planner) that picks.
Planning is a pure Ast → Ast step that runs before lowering (step 2
above): it annotates each flippable EXISTS with a flip decision and changes
nothing else. It is result-preserving — the flipped and unflipped plans return
the same rows; only the work differs. The public seam is rindle_planner::plan_ast
— lower the planned AST instead of the raw one:
use rindle_planner::plan_ast;
// `cost` is a `ConnectionCostModel`; `rindle-sqlite` provides a real-SQLite one.
let top = build_pipeline(&mut graph, &plan_ast(&ast, cost), &resolve)?;
The cost model is a real-SQLite model in rindle-sqlite — it reads SQLite’s own
scan-status and table statistics rather than guessing. (The
live-replica runtime runs planning by default.) See
run the daemon for where planning fits in a
deployment.
Driving it from a database
Wiring the source, resolve, and view by hand is the low-level path — and it is the
whole open engine. The Rust quickstart does exactly this over a
real SQLite table (via rindle-sqlite’s write-through TableSource), and
fold the delta stream yourself swaps the View for a raw
change sink.
Turning ordinary SQL writes into the change stream automatically (a preupdate
hook behind one controlled writer), plus multi-threaded scale-out across readers, is
the live-replica runtime — Rindle’s commercial
high-concurrency layer on top of this same engine.
Next steps
- Overview — what Rindle is and when to reach for it.
- Quickstart — stand up a live query end to end.
- Change model — the
Add/Remove/Edit/Childdeltas in depth. - Fold the delta stream yourself — the raw change sink.
- Supported queries — the matrix of what builds and pushes.
- The live-replica runtime — SQL writes in, deltas out, readers scaled.