Concepts

The change model

Two change vocabularies: SourceChange goes in, ChangeEvent comes out — plus the hydrate-then-incremental subscription and the replay-equivalence invariant.

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 / 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.

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 delivers Update::Changed to affected subscribers. It returns the new TxId.
  • WriteTxn::rollback(self) (or simply dropping the WriteTxn) 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.
  • Cratesrindle, rindle_replica, and rindle_sqlite, and the value types (OwnedValue, OwnedRow, RelId) referenced above.