Skip to content
Guides contents

GuidesCore concepts

How it works

Follow the Rust engine lifecycle: create sources, build a query pipeline, hydrate a view, and apply changes.

View as Markdown

Rindle maintains a query result from changes to its source rows. It evaluates the query to create an initial result, then updates the affected operators after a write. The correctness contract is view-after-write == fresh-query over the same data.

Incremental does not mean constant work. One source change can affect many result rows, and operators can fetch supporting rows through indexes. Initial hydration, recovery, and full result serialization also have costs.

This page explains the engine beneath Rindle’s APIs. You can use the embedded database runtime or browser store without managing a graph yourself.

The data path

Stage Meaning Raw Rust entry point
Register sources Supply base rows and table schemas Graph::try_add_source or SQLite TableSource
Describe a query Build its filters, order, projection, and relationships rindle::table(...).build()
Build the pipeline Resolve tables and create connected operators rindle::build_pipeline
Hydrate a consumer Fetch the initial result A View or a change sink
Apply changes Update affected operators and publish result changes Graph::try_source_push and consumer-specific delivery

An AST is the query description. A graph stores the operators that execute it. A source supplies rows for a table. A view stores the result, while a change sink gives the result deltas to your own consumer.

1. Build a query

The fluent builder produces a rindle::Ast:

let ast = rindle::table("issues")
    .r#where("open", true)
    .order_by("id", "asc")
    .build();

r#where uses a raw identifier because where is a Rust keyword. With the serde feature, an AST can also come from the JSON wire representation. The Rust and TypeScript query builders target that common representation.

A builder query differs from a SQL request. Its supported operators are listed in query shapes. The SQL client runs ordinary statements and does not automatically maintain their results.

2. Lower it into a graph

build_pipeline resolves every named table through a closure and creates the operators needed by the AST. The closure returns the source’s NodeId and SourceSchema.

The public types live at these paths:

use rindle::graph::{Graph, NodeId};
use rindle::value::SourceSchema;
use rindle::{build_pipeline, Ast, BuildError};

build_pipeline(&mut graph, &ast, &resolve) returns Result<NodeId, BuildError>. The returned node is the pipeline’s top operator. A materialized view or change sink must still be attached to it.

The core engine can use in-memory sources without SQLite or a C toolchain. rindle-sqlite supplies the SQLite source implementation. The raw quickstart shows complete source registration and pipeline construction.

3. Hydrate a view

The built-in View materializes rows and their nested relationships. Its schema includes result order and relationship slots. view_schema(&ast, &resolve) derives that schema from the query and source schemas.

let top = rindle::build_pipeline(&mut graph, &ast, &resolve)?;
let view = graph.add_view(top, rindle::view_schema(&ast, &resolve)?);
graph.set_sink_edge(top, view);
graph.try_hydrate(view)?;
let data = graph.view_data(view);

ViewData::items contains entries. Each entry carries its row and nested relationship results. Rows use positional cells, read through row.col(index).

For a consumer that owns its own result collection, attach a change sink instead. Hydration then supplies initial CaughtChange::Add events. See the fold example.

4. Push changes

A source push describes one base-table mutation:

use rindle::change::SourceChange;
use rindle::value::{owned_row, OwnedValue};

let row = owned_row(vec![OwnedValue::str("i1"), OwnedValue::Bool(true)]);
graph.try_source_push(source, SourceChange::Add(row))?;
graph.flush_view(view);

This fragment assumes a source whose columns are id and open, in that order. An edit supplies both the complete previous row and its replacement. The engine propagates the resulting changes through filters, joins, ordering, and aggregates.

flush_view notifies view listeners. It is not a database commit. With a raw change sink, take_sink_changes drains the accumulated deltas instead. The change model distinguishes input changes from result changes.

Use the fallible try_* methods to receive RindleError values. A Graph is !Send, so it stays on one thread. The replica runtime scales through independent worker graphs and message passing.

Query planner

A correlated EXISTS can sometimes run from either the parent side or the child side. The cheaper direction depends on the data and available indexes. rindle-planner chooses a result-equivalent plan before pipeline construction.

Its public entry point is rindle_planner::plan_ast. It takes an AST and an Rc<dyn ConnectionCostModel>, then returns a planned AST. The SQLite cost model is rindle_sqlite::SqliteCostModel.

Planning changes the work, not the query result. The replica runtime enables planning by default and keeps that plan for the registration’s lifetime. Raw graph callers choose whether to run the planner.

Driving it from a database

rindle-replica connects this engine to a controlled SQLite writer. Its preupdate hook captures row changes while SQL runs. A separate connection reads the pre-commit snapshot, and a batch overlay makes earlier changes in that transaction visible to later derivation steps.

The derivation connection is read-only. It does not replay the SQL writes. Db delivers callbacks synchronously after the database commits.

Cluster can stream provisional changes before commit. Each worker sends a Progressed marker after its transaction commits and all its changes are sent. A consumer must stage those changes until the relevant workers progress. It must discard provisional state for a faulted query and register that query again. The cluster guide explains this delivery contract and the continuous event drain.

A query remains an in-process object until an application adds transport. The daemon and synced app build network and authorization layers above the runtime. They are optional for embedded users.

Next steps