Rindle keeps a query’s result set current by emitting the difference after every committed write, never by re-running the query. This page is the precise contract for those differences: the change types the engine produces, the shape of each delta, and how a delta crosses the library boundary into your code.
Everything below maps to real types. Prose says “Rindle”; the code uses the
crate names you actually import — rindle (the engine) and rindle_replica (the
live-query wrapper over SQLite).
Two kinds of change
There are two distinct change types, and conflating them is the first mistake to avoid. One is the input to the engine; the other is the output you consume.
| Direction | Rust type | Where it comes from |
|---|---|---|
| Input (a row mutation) | rindle::SourceChange |
a write to a base table |
| Output (a result delta) | rindle::CaughtChange |
the engine, per affected query |
When you write SQL through rindle_replica, you never construct a SourceChange
yourself — the preupdate hook captures it from your statement. You only ever
read the output side, which rindle_replica re-exports under a friendlier name:
// rindle-replica/src/lib.rs
pub use rindle::CaughtChange as ChangeEvent;
pub use rindle::CaughtNode as NodeData;
So rindle_replica::ChangeEvent is rindle::CaughtChange, and
rindle_replica::NodeData is rindle::CaughtNode. The rest of this page uses the
rindle_replica names, since that is the crate an application depends on.
The input side: SourceChange
A SourceChange is one row-level mutation against a base table. It has three
variants:
// rindle/src/change.rs
pub enum SourceChange {
Add(Row),
Remove(Row),
Edit { row: Row, old: Row },
}
Add and Remove carry a single row. Edit is an in-place change whose
primary key is unchanged: it carries both the new row and the old row it
replaces. (Row is rindle::OwnedRow, an owned slice of cells — see
Crates for the value types.)
You do not build these by hand in the replica flow. They are mentioned here so the output deltas below make sense.
The output side: ChangeEvent
A ChangeEvent (rindle::CaughtChange) is one delta to a query’s result set. It
is fully owned — every lazy relationship has been drained into a concrete
tree — so it can safely cross a callback, thread, or process boundary. There
are four variants:
// rindle/src/changes.rs (re-exported as rindle_replica::ChangeEvent)
pub enum CaughtChange {
Add(CaughtNode),
Remove(CaughtNode),
Edit {
old: OwnedRow,
row: OwnedRow,
},
Child {
row: OwnedRow,
rel: RelId,
change: Box<CaughtChange>,
},
}
Note the asymmetry with SourceChange, which trips people up:
Add/Removecarry a wholeCaughtNode(row plus its relationship subtrees).Editcarries only the two rows —oldandrow— not nodes. A row’s identity is unchanged on an edit, so its relationships did not move; there is nothing to re-materialize.Childis the variant that has no analog on the input side. It means “a row already in the result set had one of its nested relationships change.” It carries the parentrow, the relationship slotrel(aRelId, an index — not a string name), and a boxed nestedchangedescribing what happened inside that relationship.
There is no Insert, Delete, or Update variant, and no
Update { old, new } shape. The names are Add, Remove, Edit, Child.
NodeData (a caught node)
The node carried by Add / Remove is a NodeData (rindle::CaughtNode):
// rindle/src/changes.rs (re-exported as rindle_replica::NodeData)
pub struct CaughtNode {
pub row: OwnedRow,
pub relationships: BTreeMap<RelId, Vec<CaughtNode>>,
}
row is the row’s cells. relationships maps each in-view relationship slot to
its children, keyed by RelId so the map is slot-stable; the child Vec
preserves the operator’s sort order (do not re-sort it). A flat query with no
nested relationships simply has an empty relationships map.
Reconstruction invariant
Applying the full stream of ChangeEvents in order reconstructs exactly the
result you would get by running the query from scratch. That equivalence — view
after the deltas equals a fresh query — is the engine’s core correctness
contract. A subscriber that folds the stream into its own state therefore stays
bit-for-bit consistent with the database.
Receiving deltas: Update and subscribe
You do not iterate a change stream. You register a callback with
Query::subscribe, and the engine calls it. The callback receives an Update,
which wraps a batch of ChangeEvents plus the transaction id they belong to:
// rindle-replica/src/lib.rs
pub enum Update {
Hydrated {
tx_id: TxId,
changes: Vec<ChangeEvent>,
},
Changed {
tx_id: TxId,
changes: Vec<ChangeEvent>,
},
}
Hydrated fires once, immediately on subscribe, carrying the initial result
set as a batch of Adds. Changed fires after every committed write that
affects the query, carrying that transaction’s deltas. TxId is a transparent
pub u64 wrapper, so read its value with .0.
subscribe takes a closure, not an iterator:
// rindle-replica/src/query.rs
pub fn subscribe(&self, mut cb: impl FnMut(&Update) + 'static) { /* ... */ }
The callback runs on the writer thread during commit for Changed events, so
keep it cheap (forward to a channel, fold into local state) and do not re-enter
the Db from inside it.
End to end
Putting it together with the real API — open a replica, register a base table, register a query (built from the fluent builder, not a SQL string), subscribe, then write ordinary SQL through the single controlled writer:
use rindle::OwnedValue;
use rindle_replica::{ChangeEvent, Db, QueryId, Update};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = Db::open("app.db")?;
// Create the app table with ordinary SQL, then register it with the engine.
db.read(|c| {
c.execute_batch("CREATE TABLE issues (id INTEGER PRIMARY KEY, title TEXT)")
})?;
db.register_table("issues")?;
// A query is a built AST, not a SQL string.
let query = db.query(QueryId(1), rindle::table("issues").build())?;
// Register a callback: Hydrated once now, then Changed per committed write.
query.subscribe(|update: &Update| {
let (label, changes) = match update {
Update::Hydrated { changes, .. } => ("hydrated", changes),
Update::Changed { changes, .. } => ("changed", changes),
};
for change in changes {
match change {
ChangeEvent::Add(node) => println!("[{label}] + {:?}", node.row),
ChangeEvent::Remove(node) => println!("[{label}] - {:?}", node.row),
ChangeEvent::Edit { old, row } => {
println!("[{label}] ~ {old:?} -> {row:?}")
}
ChangeEvent::Child { row, change, .. } => {
println!("[{label}] * child of {row:?}: {change:?}")
}
}
}
});
// Write through the one controlled writer. Parameters are OwnedValues.
let mut w = db.write()?;
w.exec(
"INSERT INTO issues VALUES (?, ?)",
&[OwnedValue::Int(1), OwnedValue::str("first issue")],
)?;
let tx = w.commit()?; // derives + delivers the delta; returns the new TxId
println!("committed tx {}", tx.0);
Ok(())
}
The write side of the contract:
WriteTxn::exec(&mut self, sql: &str, params: &[OwnedValue])runs one statement and returns the number of rows changed; the preupdate hook captures the row deltas as it runs.WriteTxn::commit(self)runs the write-then-abort derivation against a pre-commit snapshot, makes the data durable, then deliversUpdate::Changedto affected subscribers. It returns the newTxId.WriteTxn::rollback(self)(or simply dropping theWriteTxn) leaves every view untouched and delivers nothing.
OwnedValue is the cell type for bound parameters; its variants are Null,
Bool, Int, Float, Str, and Json, with a OwnedValue::str(&str) helper
for the common string case.
Snapshot boundaries
Deltas are cut at commit boundaries. The engine derives a transaction’s change
against the pre-commit snapshot and delivers it only after the durable COMMIT,
so a subscriber never observes a partial transaction and never sees a tx that
later failed. Each Update is tagged with the TxId it reflects.
Next steps
- How it works — the write-then-abort derivation and the one-writer model in depth.
- Replica and views — driving
Db, registering queries, and folding deltas into application state. - Supported queries — the query shapes the builder can lower today.
- Crates —
rindle,rindle_replica, andrindle_sqlite, and the value types (OwnedValue,OwnedRow,RelId) referenced above.