Rindle keeps a query’s result set current by emitting the difference after every 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 in the open rindle engine. The
live-replica runtime re-exports the output types under
friendlier names (noted where relevant), but the shapes are the engine’s.
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 |
In the open engine you push a SourceChange yourself — through a write-through
TableSource it also persists the row — and read the
CaughtChanges the engine emits. The live-replica runtime flips the input side:
its preupdate hook captures the SourceChange from your SQL statement so you
never build one, and it re-exports the output types as friendlier aliases:
// 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 — same types either way.
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 push these yourself in the open engine (the quickstart does); the live-replica runtime derives them from your SQL for you. Either way, the output deltas below are what you consume.
The output side: CaughtChange
A 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.
CaughtNode
The node carried by Add / Remove is a CaughtNode (re-exported as
rindle_replica::NodeData):
// rindle/src/changes.rs
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 CaughtChanges 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 source.
Receiving deltas: the change sink
At the open-engine layer you attach a change sink over the pipeline’s top and
drain it. hydrate_change_sink returns the cold-start snapshot (the initial result
as a batch of Adds); after each batch of source pushes, take_sink_changes
drains that transaction’s deltas:
use rindle::CaughtChange;
let sink = graph.add_change_sink(top);
graph.set_sink_edge(top, sink);
let initial: Vec<CaughtChange> = graph.hydrate_change_sink(sink); // once
// ... after a batch of graph.source_push(..):
let delta: Vec<CaughtChange> = graph.take_sink_changes(sink); // per transaction
A full fold-and-prove walkthrough is in fold the delta stream yourself.
The live-replica runtime wraps exactly this in a
callback. Query::subscribe delivers an Update — Hydrated { tx_id, changes }
once immediately (the initial set as Adds), then Changed { tx_id, changes }
after every committed write, each carrying a Vec<ChangeEvent> (i.e.
Vec<CaughtChange>) and the TxId it reflects. The callback runs on the writer
thread during commit, so keep it cheap and do not re-enter the writer from
inside it.
Snapshot boundaries
Deltas are cut at transaction boundaries. A batch of pushes drains as one set of
deltas (take_sink_changes); the runtime cuts the same boundary at each durable
COMMIT, deriving the transaction’s change against the pre-commit snapshot and
delivering it only after the commit — so a subscriber never observes a partial
transaction and never sees a tx that later failed. Each runtime Update is tagged
with the TxId it reflects.
Next steps
- How it works — the build → lower → hydrate → push lifecycle these deltas flow through.
- Fold the delta stream yourself — attach a change sink, fold the deltas into your own view, and prove it equals a fresh query.
- The live-replica runtime — the
Query::subscribe/Updatedelivery wrapper and the write-then-abort derivation model. - Crates —
rindle,rindle-replica, andrindle-sqlite, and the value types (OwnedValue,OwnedRow,RelId) referenced above.