The engine

Rust quickstart

Stand up a live, incrementally-maintained SQLite query with the open engine — register a source, hydrate a view, and watch a write-through push update it by the delta.

View as Markdown

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) and rindle-sqlite (the SQLite backend) — both Apache-2.0, at github.com/rindle-sh/rindle. Link them freely. The turnkey version — write ordinary SQL through one controlled writer and let a preupdate hook 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::SourceChangeAdd, 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