This page is the contract: which query shapes Rindle can lower into an incrementally-maintained pipeline, and what each shape does at the three stages of the engine.
- fetch — hydrate the pipeline and materialize the initial result.
- push — apply incremental source changes and emit the downstream change stream.
- view — the change reaches the materialized result and updates it in place.
Legend: ✅ supported · ⚠️ partial (see note) · ❌ unsupported. Every ✅ row is backed by a passing test in the engine repo.
How you express a query
A query is written in Deltic — Rindle’s query language — using the fluent
builder (rindle::table), which produces an
Ast. You hand that Ast to the live-query wrapper, subscribe, and receive the
incremental change stream — there is no SQL string and no separate CLI; the
query is a Rust value.
use rindle_replica::{ChangeEvent, Db, QueryId, Update};
fn main() -> Result<(), rindle_replica::ReplicaError> {
let db = Db::open("app.db")?;
db.register_table("issues")?;
// Build a query: open issues, ordered, capped. `rindle::table` returns a
// fluent `Query`; `.build()` consumes it and yields the `Ast`.
let ast = rindle::table("issues")
.r#where("open", true) // `field = value`
.where_op("priority", ">", 3) // explicit operator
.order_by("created_at", "desc")
.limit(50)
.build();
let query = db.query(QueryId(1), ast)?;
// Subscribe: the callback fires once with `Hydrated` (the initial set, all
// `Add`s), then with `Changed` after every committed write that affects it.
query.subscribe(|update: &Update| match update {
Update::Hydrated { changes, .. } => {
for ch in changes {
if let ChangeEvent::Add(node) = ch {
println!("initial row: {:?}", node.row);
}
}
}
Update::Changed { changes, .. } => {
for ch in changes {
println!("delta: {ch:?}");
}
}
});
Ok(())
}
ChangeEvent is the engine’s rindle::CaughtChange, re-exported by the replica.
Its four shapes are the entire incremental vocabulary:
pub enum CaughtChange {
Add(CaughtNode), // a row entered the result
Remove(CaughtNode), // a row left the result
Edit { old: OwnedRow, row: OwnedRow }, // a row's columns changed in place
Child { row: OwnedRow, rel: RelId, change: Box<CaughtChange> }, // a related row changed
}
See the change model for the full delta semantics and replica and views for the one-writer / write-then-abort mechanism that produces them.
Correlated relationships and EXISTS
Relationships are correlated subqueries. Inside a sub / sub_as /
where_exists closure, the closure receives the parent row; row.col("…")
references a parent column, and using it as a where value defines the
correlation (it is not a filter):
// issues, each carrying its comments (a materialized relationship)
let with_comments = rindle::table("issues")
.sub_as("comments", |row| {
rindle::table("comments").r#where("issue_id", row.col("id"))
})
.build();
// only issues that have at least one comment (an EXISTS filter)
let commented = rindle::table("issues")
.where_exists(|row| {
rindle::table("comments").r#where("issue_id", row.col("id"))
})
.build();
// the negation
let uncommented = rindle::table("issues")
.where_not_exists(|row| {
rindle::table("comments").r#where("issue_id", row.col("id"))
})
.build();
Aggregates: a live count
count_as attaches a correlated child count to each parent row as a scalar —
maintained incrementally, so adding or removing a child increments or decrements
the count without re-scanning. An empty child reads 0.
// each issue, carrying a live count of its comments
let with_count = rindle::table("issues")
.count_as("commentCount", |row| {
rindle::table("comments").r#where("issue_id", row.col("id"))
})
.build();
The same shape in the JS builder — the alias resolves to a number, not an array:
const view = store.query.issue
.countAs("commentCount", comment, { parent: ["id"], child: ["issueID"] })
.materialize();
// rows: ( …Issue & { commentCount: number } )[]
count is the only aggregate today; sum / avg / min / max are designed
but not yet built, and the aggregate is a relationship count keyed on the
correlation — a top-level count(table) over a whole table is not exposed.
Scalar subqueries: fold a unique lookup at build time
When an EXISTS child binds a statically-unique key (a primary key or a
unique index, fully pinned to constants), you can mark it scalar — the resolver
reads that one row once at build time, inlines its correlation value as a
literal, and deletes the join entirely. The parent pipeline never subscribes to
the child table.
use rindle::ExistsOpts;
// only the project that owns issue #7 — resolved once, then a plain literal filter
let owner = rindle::table("projects")
.where_exists_with(
|row| rindle::table("issues").r#where("id", 7).r#where("project_id", row.col("id")),
ExistsOpts { scalar: true },
)
.build();
import { exists } from "@rindle/client";
store.query.project.where(
exists(issue, { parent: ["id"], child: ["projectId"] }, (i) => i.where.id(7), { scalar: true }),
);
The trade-off is snapshot semantics: the inlined value is frozen for the
pipeline’s lifetime, so changes to the child after build do not propagate. Leave
scalar off (the default) for an ordinary live EXISTS join.
Supported shapes
| Query shape | fetch | push | view | Notes |
|---|---|---|---|---|
Simple where (=,!=,<,>,<=,>=) |
✅ | ✅ | ✅ | via .r#where / .where_op |
IS / IS NOT (null-aware, three-valued) |
✅ | ✅ | ✅ | matches SQLite’s three-valued logic |
LIKE / ILIKE / NOT ILIKE, incl. \%/\_/\\ escapes |
✅ | ✅ | ✅ | memory matcher agrees with SQLite |
AND / OR of leaf conditions |
✅ | ✅ | ✅ | |
IN / NOT IN over a literal list |
✅ | ✅ | ✅ | via .where_in |
Sibling relationships (multiple sub on a row) |
✅ | ✅ | ✅ | |
Nested relationships (sub with its own sub) |
✅ | ✅ | ✅ | |
start_at / start_after paging bound |
✅ | ✅ | ✅ | |
limit (ordered take / exists cap) |
✅ | ✅ | ✅ | via .limit |
where_exists (non-flipped) |
✅ | ✅ | ✅ | |
where_not_exists (non-flipped) |
✅ | ✅ | ✅ | |
Flipped where_exists (child-driven) |
✅ | ✅ | ✅ | |
Top-level OR fan of flipped + non-flipped EXISTS |
✅ | ✅¹ | ✅ | |
Nested OR/AND mix of flipped + non-flipped EXISTS |
✅ | ✅¹ | ✅ | including AND-within-AND carrying a flipped EXISTS |
Multi-EXISTS under top-level AND/OR |
✅ | ✅ | ✅ | aliases uniquified to distinct query-local slots |
| Deepest-nested child push | ✅ | ✅ | ✅ | emits CaughtChange::Child |
| Self-join (reentrant fetch-during-push) | ✅ | ✅ | ✅ | |
| Many-to-many through a junction table | ✅ | ✅ | ✅ | nest sub through the junction; junction rows materialize uncollapsed (no hidden-edge magic) |
Top-level .one() (singular root) |
✅ | ✅ | ✅ | caps the query to limit 1; the JS view unwraps to row | null |
Relationship-level .one() (a singular sub) |
⚠️ | ⚠️ | ⚠️ | view layer implemented + unit-tested, not yet reachable via a query (builds plural today) |
Aggregate: count of a correlated child (count_as) |
✅ | ✅ | ✅ | a scalar count per parent row, incrementally maintained as the child changes. sum / avg / min / max are designed but not built yet |
Scalar subquery (exists with scalar: true) |
✅ | —² | ✅ | a build-time snapshot: a unique-key match is folded to a literal and the join is removed |
Flipped NOT EXISTS (anti-join) |
❌ | ❌ | ❌ | BuildError::Unsupported — needs a child-driven anti-join operator |
Projection / column pruning (select) |
✅ | ✅ | ✅³ | .select drives what syncs — over the wire, into the client engine, and out to the view; a query never resolves a row from a column it did not select |
| Cost-based query planner (EXISTS flip) | ✅ | ✅ | ✅ | rindle-planner, with a real-SQLite cost model in rindle-sqlite; an opt-in plan_ast step before lowering — see run the daemon |
| Static / bound parameters | ❌ | ❌ | ❌ | not represented (permissions lower via the system tag) |
¹ Non-flipped EXISTS under a union fan, on push. When a non-flipped EXISTS
sits under a top-level OR fan — a shape that’s easy to get subtly wrong —
Rindle’s push result still upholds the IVM contract view-after-push ==
fresh-query. fetch and flipped-push are unaffected.
² A scalar subquery does not push. That is the point: it is resolved once,
at build time, inlined to a literal, and the join is deleted — so the parent
pipeline never subscribes to the child table and later changes to it do not
propagate. Opt in per-condition (scalar: true); leave it off for a live join.
³ Projection’s two remaining follow-ups. The selection already shapes what
syncs end to end. Still outstanding (pure optimizations, not correctness): the
SQLite leaf read-narrowing (the server still SELECTs the full declared
column list from disk) and narrowing the local view’s reported column set.
Neither changes results.
Relationship slots are query-local
When two or more EXISTS conditions sit under a top-level AND / OR, the
builder uniquifies their aliases to distinct slots (comments → comments_0,
the next → _1, …). The slot layout is derived from the query AST, not from
the source schema’s declared relationships — so a production-shaped schema that
declares only the table’s real relationship names (or none) builds these shapes,
and the wasm path needs no synthesized gate slots. The slot order is
materialized relationships first, then EXISTS gates in where-tree pre-order —
the one tree shared by the dataflow joins, the EXISTS gates, and the view
materialization, so their relationship ids agree by construction.
Build-time rejections
These are genuine limitations surfaced as a BuildError, not normalization
artifacts:
- Flipped
NOT EXISTS→BuildError::Unsupported("flipped NOT EXISTS is not lowered"). - An EXISTS subquery carrying a paging bound or a nested relationship →
BuildError::Unsupported. - A bare top-level EXISTS aliased the same as a materialized relationship →
BuildError::Unsupported(one relationship per slot). A bare EXISTS is not alias-uniquified, so it collides with asubof the same name; two EXISTS under a top-leveland/orare uniquified to distinct slots and never collide.
Unknown tables and columns are also build-time errors: referencing a table you
never registered fails with a BuildError at Db::query time, and an undeclared
relationship name surfaces as BuildError::UnknownRelationship.
A note on value types
Source values arrive from SQLite, where the engine’s number domain vends as
OwnedValue::Float — even a column you populated with an integer literal reads
back as OwnedValue::Float in the change stream. The other owned cell shapes are
OwnedValue::Int, OwnedValue::Bool, OwnedValue::Null, and the string
constructor OwnedValue::str("…"). Match defensively:
use rindle::OwnedValue;
fn id_of(row: &[OwnedValue]) -> i64 {
match &row[0] {
OwnedValue::Float(f) => *f as i64,
OwnedValue::Int(i) => *i,
other => panic!("unexpected id cell: {other:?}"),
}
}
Next steps
- Quickstart — stand up a live query end to end.
- The change model —
Add/Remove/Edit/Childin depth. - Replica and views — how deltas are derived and delivered.
- Crates —
rindle,rindle-replica, andrindle-sqlite.