Reference

Supported query shapes

The honest matrix of what builds, pushes, and materializes today — written in the Rust rindle::table builder. The explicit list of what does not work yet.

View as Markdown

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.

Writing TypeScript? Every shape here is in the TypeScript query builder reference too — same verdicts, the @rindle/client builder.

  • 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:?}");
            }
        }
    });

    // Write through the single controlled writer. `commit` derives the delta and
    // delivers `Update::Changed` to the subscriber above before returning.
    let mut tx = db.write()?;
    tx.exec_batch(
        "INSERT INTO issues (id, title, open, priority, created_at)
         VALUES (42, 'ship it', 1, 7, 1700000000)",
    )?;
    tx.commit()?;

    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.

Aggregates

count_as attaches a live count to each parent row. A query can also be the aggregate: count() reshapes the result to count rows instead of materializing them, group_by keys it, and having filters the post-aggregation rows — all maintained incrementally, like any other shape:

// one [count] row, maintained as rows enter and leave the filter
let ast = table("issue").r#where("open", true).count().build();

// one [status, count] row per distinct status, HAVING count > 3
let ast = table("issue")
    .group_by("status")
    .count()
    .having(|c| c.where_op("count", ">", 3))
    .build();

// filter the PARENT by a child count — issues with more than 10 comments.
// The alias must name a `count_as` relationship already on this query.
let ast = table("issue")
    .count_as("comments", |row| table("comment").r#where("issue_id", row.col("id")))
    .having_count("comments", ">", 10)
    .build();

having addresses the aggregate’s output columns — the group_by columns and the synthetic count — while where filters base rows below the aggregation. having_count gates a parent row by a child relationship’s count; v1 accepts high-pass predicates only (see the matrix and rejections below).

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 (correlated EXISTS) the engine picks the cheaper drive side (parent- or child-driven) internally
where_not_exists (NOT EXISTS)
Top-level OR fan of EXISTS conditions ✅¹
Nested OR/AND mix of EXISTS conditions ✅¹ including AND-within-AND
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
Top-level count() (global aggregate) reshapes the result to one [count] row instead of materializing rows
group_by + count() (grouped aggregate) one [group…, count] row per distinct value-tuple, keyed and sorted by the group columns
having (filter post-aggregation rows) clauses address the group_by columns and the synthetic count column
having_count (filter a parent by a child count) ⚠️ ⚠️ ⚠️ gates a parent by a count_as alias, maintained incrementally; v1: high-pass predicates only — see rejections below
Scalar subquery (exists with scalar: true) —² a build-time snapshot: a unique-key match is folded to a literal and the join is removed
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
Static / bound parameters a deprecated upstream (ZQL) form, not represented — permission subqueries instead carry the AST’s system: "permissions" provenance tag and are pruned from sync

¹ EXISTS under a union fan, on push — a deliberate divergence from upstream. For one internal lowering of an EXISTS under a top-level OR fan, Zero’s JS engine (which Rindle ports) emits a push result that violates the IVM contract; Rindle upholds view-after-push == fresh-query, pinned by a dedicated consistency test (union_fan_consistency). So the ✅ is real, but on this one shape Rindle intentionally does not match upstream ZQL output.

² 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 (commentscomments_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:

  • A root aggregate combined with row-shaping — pairing count() with select / sub / count_as / order_by / limit / one is rejected: a count() query’s result is the aggregate output (group_by columns + count), not rows.
  • A low-pass having_count predicate — one that is true at count 0 (<= n, < n for n ≥ 1, = 0, >= 0) → BuildError::Unsupported. A childless parent forms no group, so those need row-widening (deferred); the high-pass set (> n; >= / = / != for n ≥ 1) builds and maintains.
  • A child-driven NOT EXISTS — the engine maintains NOT EXISTS parent-driven only, and nothing in the fluent builder or the planner ever asks for the child-driven form; a hand-authored wire Ast that marks one is rejected with BuildError::Unsupported("flipped NOT EXISTS is not lowered").
  • An EXISTS subquery carrying a paging bound or a nested relationshipBuildError::Unsupported.
  • A bare top-level EXISTS aliased the same as a materialized relationshipBuildError::Unsupported (one relationship per slot). A bare EXISTS is not alias-uniquified, so it collides with a sub of the same name; two EXISTS under a top-level and / or are 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