This is the low-altitude path, on the open engine (rindle + rindle-sqlite).
In the browser and Node the ArrayView folds the change stream
into a materialized array for you. Here you do it yourself: instead of adding a
View, you attach a change sink and the engine hands you the raw incremental
CaughtChange stream. You decide what to do with the diffs —
fold them into your own view, forward them over the wire, persist them.
This example does the first, then proves the payoff: a view maintained from the
diff stream alone equals a fresh SELECT, with no re-querying. That equivalence —
view-after-write == fresh-query — is the whole point of IVM. The complete program
ships as a runnable example:
cargo run -p rindle-sqlite --example fold_deltas
Your own view, kept current from diffs
The consumer owns a BTreeMap keyed by primary key. Every delivered
CaughtChange folds into it.
use std::collections::BTreeMap;
use serde_json::json;
use rindle::{CaughtChange, OwnedRow, Value};
const COLS: [&str; 4] = ["id", "title", "priority", "open"];
struct Consumer {
view: BTreeMap<i64, serde_json::Value>, // id -> the row, as you'd render it
}
impl Consumer {
fn apply(&mut self, changes: &[CaughtChange]) {
for ch in changes {
match ch {
// A row entered the result set.
CaughtChange::Add(n) => { self.view.insert(pk(&n.row), row_to_json(&n.row)); }
// A row left it (deleted, or no longer matches the filter).
CaughtChange::Remove(n) => { self.view.remove(&pk(&n.row)); }
// A row that stayed but changed. The key can move, so drop old then insert new.
CaughtChange::Edit { old, row } => {
self.view.remove(&pk(old));
self.view.insert(pk(row), row_to_json(row));
}
// Nested-relationship diffs — only a `sub_as(..)` query emits these.
CaughtChange::Child { .. } => {}
}
}
}
}
Cells are positional — in schema order — and read with row.col(c), which
returns a borrowed Value (Int / Float / Bool / Str / Null). A number may
be Int or Float, so match both when you extract a key; Str is raw bytes, so
validate to &str at the edge:
fn pk(row: &OwnedRow) -> i64 {
match row.col(0) {
Value::Int(i) => i,
Value::Float(f) => f as i64,
other => panic!("non-numeric primary key: {other:?}"),
}
}
fn row_to_json(row: &OwnedRow) -> serde_json::Value {
let mut obj = serde_json::Map::new();
for (name, cell) in COLS.iter().zip(row.cells()) {
let v = match cell {
Value::Int(i) => json!(i),
Value::Float(f) if f.fract() == 0.0 => json!(f as i64),
Value::Float(f) => json!(f),
Value::Bool(b) => json!(b),
Value::Str(s) => json!(std::str::from_utf8(s).unwrap()),
_ => serde_json::Value::Null,
};
obj.insert((*name).to_string(), v);
}
serde_json::Value::Object(obj)
}
Drive it and prove it
Register the SQLite table as a source, build the query, and — instead of a View —
add a change sink over the pipeline’s top with Graph::add_change_sink.
hydrate_change_sink returns the cold-start snapshot (all Adds); after each push
batch, take_sink_changes drains that transaction’s deltas.
use std::collections::HashMap;
use std::rc::Rc;
use rindle::{build_pipeline, owned_row, table, Graph, OwnedValue, SourceChange, SourceSchema, ValueType};
use rindle_sqlite::{ColumnDef, GraphTableSourceExt, TableSource};
use rusqlite::Connection;
let db = Rc::new(Connection::open_in_memory()?);
db.execute_batch(
"CREATE TABLE issues (id INTEGER NOT NULL, title TEXT NOT NULL,
priority INTEGER NOT NULL, open BOOLEAN NOT NULL);
CREATE UNIQUE INDEX issues_pk ON issues (id);",
)?;
let columns = vec![
ColumnDef { name: "id".into(), ty: ValueType::Number, optional: false },
ColumnDef { name: "title".into(), ty: ValueType::String, optional: false },
ColumnDef { name: "priority".into(), ty: ValueType::Number, optional: false },
ColumnDef { name: "open".into(), ty: ValueType::Boolean, optional: false },
];
let schema = SourceSchema::new(vec!["id", "title", "priority", "open"], vec![0], vec![(0, true)]);
let mut graph = Graph::new();
let issues_src = graph.add_table_source(
TableSource::new_with_schema(db.clone(), "issues", columns, vec![0], schema.clone()),
);
let sources = HashMap::from([("issues", (issues_src, schema))]);
let resolve = |t: &str| sources.get(t).cloned();
// The live query: the OPEN issues, maintained incrementally.
let ast = table("issues").r#where("open", true).build();
let top = build_pipeline(&mut graph, &ast, &resolve).expect("build");
// A change sink hands you the raw delta stream (instead of a materialized View).
let sink = graph.add_change_sink(top);
graph.set_sink_edge(top, sink);
let mut consumer = Consumer { view: BTreeMap::new() };
consumer.apply(&graph.hydrate_change_sink(sink)); // fires once: the cold-start snapshot
Push ordinary changes through the write-through source — each source_push writes
SQLite and emits the delta into the sink — then drain and fold:
// Insert three issues (one already closed).
graph.try_source_push(issues_src, SourceChange::Add(owned_row(vec![
OwnedValue::Int(1), OwnedValue::str("login button misaligned"), OwnedValue::Int(2), OwnedValue::Bool(true),
])))?;
graph.try_source_push(issues_src, SourceChange::Add(owned_row(vec![
OwnedValue::Int(2), OwnedValue::str("slow dashboard query"), OwnedValue::Int(1), OwnedValue::Bool(true),
])))?;
graph.try_source_push(issues_src, SourceChange::Add(owned_row(vec![
OwnedValue::Int(3), OwnedValue::str("typo in footer"), OwnedValue::Int(3), OwnedValue::Bool(false), // closed → never enters
])))?;
consumer.apply(&graph.take_sink_changes(sink));
// Close issue 1 — the consumer sees a Remove for #1, no rescan.
graph.try_source_push(issues_src, SourceChange::Edit {
old: owned_row(vec![OwnedValue::Int(1), OwnedValue::str("login button misaligned"), OwnedValue::Int(2), OwnedValue::Bool(true)]),
row: owned_row(vec![OwnedValue::Int(1), OwnedValue::str("login button misaligned"), OwnedValue::Int(2), OwnedValue::Bool(false)]),
})?;
consumer.apply(&graph.take_sink_changes(sink));
Finally, the payoff — the delta-folded view must equal a fresh query over the same SQLite database the write-through pushes populated:
let mut stmt = db.prepare(
"SELECT id, title, priority, open FROM issues WHERE open = 1 ORDER BY id",
)?;
let fresh: Vec<serde_json::Value> = stmt
.query_map([], |r| Ok(json!({
"id": r.get::<_, i64>(0)?, "title": r.get::<_, String>(1)?,
"priority": r.get::<_, i64>(2)?, "open": r.get::<_, bool>(3)?,
})))?
.collect::<rusqlite::Result<Vec<_>>>()?;
let folded: Vec<serde_json::Value> = consumer.view.values().cloned().collect();
assert_eq!(folded, fresh, "the delta-folded view must equal a fresh query — the IVM guarantee");
The view we maintained from diffs alone is equal, row for row, to a fresh SELECT —
and we never rescanned SQLite to keep it that way.
Why this layer
You drop to the raw change sink when you want the engine without the opinions: the
incremental delta stream to route, fold, or persist however you like. It is exactly
the stream the higher tiers are built on — the JS ArrayView
folds it into an array, and the live-replica runtime
re-exports CaughtChange as its ChangeEvent and derives it from ordinary SQL
writes for you.
The open rindle-sqlite examples exercise
this same TableSource + source_push path end to end — e.g.
cargo run -p rindle-sqlite --example bench_zero_throughput_relational_rs.
Next steps
- Rust quickstart — the same engine with a materialized
Viewinstead of a raw sink. - The change model —
Add/Remove/Edit/Childand the replay-equivalence invariant. - The live-replica runtime — the same deltas, derived from SQL writes and scaled across readers.