The engine

The change model

Two change vocabularies: SourceChange goes in, CaughtChange comes out — plus the change sink that delivers them and the replay-equivalence invariant.

View as Markdown

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 / Remove carry a whole CaughtNode (row plus its relationship subtrees).
  • Edit carries only the two rows — old and row — not nodes. A row’s identity is unchanged on an edit, so its relationships did not move; there is nothing to re-materialize.
  • Child is 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 parent row, the relationship slot rel (a RelId, an index — not a string name), and a boxed nested change describing 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 UpdateHydrated { 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 / Update delivery wrapper and the write-then-abort derivation model.
  • Cratesrindle, rindle-replica, and rindle-sqlite, and the value types (OwnedValue, OwnedRow, RelId) referenced above.