This reference covers the maintained queries exposed by @rindle/client.
The browser, native Node, and remote stores share this builder. Their data and
write lifecycles differ; see backends.
The Rust builder uses the same engine, but exposes
additional aggregate methods. The current TypeScript builder supports counts.
Rust also exposes sum and avg.
These limits apply to maintained queries. Rindle SQL runs ordinary SQL requests; its expression support does not define the query builder.
How you express a query
store.query.<table> builds a query against your schema. Each method returns a
new builder. materialize() creates a live view, and view.data reads its current
result. This complete example uses an in-memory browser store:
import {
boolean, createSchema, createWasmStore, gt, number, string, table,
} from "@rindle/wasm";
const issue = table("issue").columns({
id: number(),
title: string(),
priority: number(),
open: boolean(),
createdAt: number(),
status: string(),
projectId: number(),
}).primaryKey("id");
const comment = table("comment").columns({
id: number(), issueId: number(), body: string(), spam: boolean(),
}).primaryKey("id");
const project = table("project").columns({
id: number(), name: string(),
}).primaryKey("id");
const schema = createSchema({ tables: [issue, comment, project] });
const store = await createWasmStore(schema);
const view = store.query.issue
.where.open(true)
.where.priority(gt(3))
.orderBy("createdAt", "desc")
.limit(50)
.materialize();
const unsubscribe = view.subscribe((rows) => console.log(rows));
await store.write((tx) => tx.add("issue", {
id: 1, title: "Ship it", priority: 7, open: true,
createdAt: 1700000000, status: "todo", projectId: 1,
}));
unsubscribe();
view.destroy();
Use the browser setup to install the package and load WebAssembly. Later examples reuse these tables and the store. Destroy each view when its consumer no longer needs it.
A view listener receives the complete current result immediately, then when it
changes. These are result arrays, not Rust Hydrated or Changed events. The
store applies the deltas for you. Local writes and optimistic predictions can
change a view before any server commit exists.
where accepts either a condition or a typed field accessor:
import { or, eq, gt } from "@rindle/client";
store.query.issue.where(or(issue.priority(gt(8)), issue.open(eq(true))));
store.query.issue.where.open(true);
store.query.issue.whereOpen(true);
.ast() returns the query’s wire representation. In a synced app, a bare
store.query reads local rows only. A named query requests an
authorized server subscription. The builder does not acquire remote rows merely
because it references a table.
Correlated relationships and EXISTS
Relationships are correlated subqueries. You give the correlation as
{ parent: [...], child: [...] } — the parent columns on the left, the child
columns they match on the right.
import { exists, notExists } from "@rindle/client";
// issues, each carrying its comments (a materialized relationship)
const withComments = store.query.issue
.sub("comments", comment, { parent: ["id"], child: ["issueId"] })
.materialize();
// rows: ( …Issue & { comments: Comment[] } )[]
// only issues that have at least one comment (an EXISTS filter)
const commented = store.query.issue
.where(exists(comment, { parent: ["id"], child: ["issueId"] }))
.materialize();
// the negation
const uncommented = store.query.issue
.where(notExists(comment, { parent: ["id"], child: ["issueId"] }))
.materialize();
sub takes an optional final builder to shape the child ((c) => c.orderBy("id", "asc")),
and exists / notExists take an optional builder to filter the gate
((c) => c.where.spam(false)).
If you don’t want to restate the same correlation at every call site, declare each
join once with defineRelationships and pass the named relationship to sub /
countAs / exists instead of the { parent, child } object. It lowers to the
exact same correlated subquery:
import { defineRelationships, rel } from "@rindle/client";
const rels = defineRelationships({
issueComments: rel(issue, comment, { id: "issueId" }), // issue.id → comment.issueId
});
store.query.issue.sub("comments", rels.issueComments).materialize();
store.query.issue.where(exists(rels.issueComments)).materialize();
Aggregates: a live count
countAs attaches a correlated child count to each parent row as a scalar,
maintained incrementally. Adding or removing a child increments or decrements
the count without re-scanning. An empty child reads 0. 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 } )[]
countAs preserves the parent result shape. The top-level count
method below instead returns aggregate rows. The current TypeScript builder does
not expose sum, avg, min, or max; see the Rust reference
for Rust’s additional aggregate methods.
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), pass { scalar: true }. 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.
import { exists } from "@rindle/client";
// only the project that owns issue #7 — resolved once, then a plain literal filter
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
countAs 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. groupBy keys it, and having filters the post-aggregation rows — all
maintained incrementally, like any other shape:
import { gt } from "@rindle/client";
// one { count } row, maintained as rows enter and leave the filter
store.query.issue.where.open(true).count().materialize();
// one { status, count } row per distinct status, HAVING count > 3.
// The `having` proxy addresses the aggregate's OUTPUT columns — the groupBy
// columns and the synthetic `count` (which lives on no base table).
store.query.issue
.groupBy("status")
.count()
.having((h) => h.count(gt(3)))
.materialize();
// filter the PARENT by a child count — issues with more than 10 comments.
// This `having(alias, op, n)` overload takes a `countAs` alias already on the query.
store.query.issue
.countAs("commentCount", comment, { parent: ["id"], child: ["issueId"] })
.having("commentCount", ">", 10)
.materialize();
having filters above the aggregation. where filters base rows below it.
The parent-by-child-count overload accepts high-pass predicates only in v1
(see the matrix and rejections below). The dropped parent’s visible
commentCount is untouched — a survivor still shows its real count.
Supported shapes
fetch means initial hydration, push means incremental maintenance, and view means materialized result support. ✅ is supported, ⚠️ has the stated restriction, and ❌ is unavailable through this builder.
| Query shape | fetch | push | view | Notes |
|---|---|---|---|---|
Simple where (=,!=,<,>,<=,>=) |
✅ | ✅ | ✅ | .where.field(v) / eq ne lt gt le ge |
is / isNot (null-aware equality) |
✅ | ✅ | ✅ | is(null) matches null; ordinary equality with null does not |
like / ilike / notIlike, incl. \%/\_/\\ escapes |
✅ | ✅ | ✅ | memory matcher agrees with SQLite |
and / or of leaf conditions |
✅ | ✅ | ✅ | and(...) / or(...) |
inList / notInList over a literal list |
✅ | ✅ | ✅ | .where.field(inList([...])) |
Sibling relationships (multiple sub on a row) |
✅ | ✅ | ✅ | |
Nested relationships (sub with its own sub) |
✅ | ✅ | ✅ | nest inside the child builder |
start paging bound |
✅ | ✅ | ✅ | .start(cursor, { exclusive }) |
limit (ordered take / exists cap) |
✅ | ✅ | ✅ | .limit(n) |
exists (correlated EXISTS) |
✅ | ✅ | ✅ | where(exists(child, corr)); the engine picks the cheaper drive side (parent- or child-driven) internally |
notExists (NOT EXISTS) |
✅ | ✅ | ✅ | where(notExists(child, corr)) |
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 |
✅ | ✅ | ✅ | slots uniquified to distinct query-local ids |
| Deepest-nested child push | ✅ | ✅ | ✅ | surfaces as a re-projected child subtree |
| 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 view’s .data is row | null |
Relationship-level .one() (a singular sub) |
⚠️ | ⚠️ | ⚠️ | view layer implemented + unit-tested, not yet reachable via a query (builds plural today) |
Aggregate: countAs of a correlated child |
✅ | ✅ | ✅ | a scalar count per parent row; empty children read 0 |
sum / avg / min / max |
❌ | ❌ | ❌ | not exposed by the current TypeScript builder |
Top-level count() (global aggregate) |
✅ | ✅ | ✅ | reshapes the result to one { count } row instead of materializing rows |
groupBy + count() (grouped aggregate) |
✅ | ✅ | ✅ | one { …group, count } row per distinct value-tuple, keyed and sorted by the group columns |
having((h) => …) (filter post-aggregation rows) |
✅ | ✅ | ✅ | the proxy addresses the groupBy columns and the synthetic count |
having(alias, op, n) (filter a parent by a child count) |
⚠️ | ⚠️ | ⚠️ | gates a parent by a countAs 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("id", "title") shapes the returned fields; the engine also needs keys and columns used by filters, ordering, and correlations |
Legacy ZQL static parameter nodes |
❌ | ❌ | ❌ | distinct from supported named-query arguments and server query-family sharing |
¹ 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. 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 also has internal requirements. The TypeScript result type and projected output follow the selected columns. The engine also retains columns needed to resolve filters, order, keys, and relationships. A narrow result does not guarantee a narrow SQLite disk read.
like is case-sensitive. ilike folds ASCII case only. The core matcher’s _
wildcard matches a byte, so non-ASCII single-character matching has a known limit.
Named query arguments are supported: the definition validates them and builds a
concrete AST. Server query families can share eligible queries that differ in
root equality values. These features do not use legacy static AST nodes.
Relationship slots are query-local
When two or more EXISTS conditions sit under a top-level and / or, the engine
uniquifies their internal slots to distinct query-local ids. You don’t name them
(only sub and countAs take an explicit alias). The slot layout is derived
from the query AST, not from any engine-level schema. That is why a
defineRelationships value is pure convenience over the same inline correlation,
and a production-shaped schema needs no synthesized gate slots. The slot order is
materialized relationships (sub / countAs) first, then the
exists gates in where-tree pre-order. That is the one tree shared by the dataflow
joins, the gates, and the view materialization, so their relationship ids agree
by construction.
Build-time rejections
These are genuine limitations. Two of them you hit before the query ever builds. A typed schema catches unknown table and column names during TypeScript checking. Untrusted or hand-written ASTs still need runtime validation. Unsupported shapes fail during materialization or server query resolution:
- A root aggregate combined with row-shaping — pairing
count()withselect/sub/countAs/orderBy/limit/oneis rejected: acount()query’s result is the aggregate output (groupBycolumns +count), not rows. Paging and correlated subqueries in a root aggregate’swhereorhavingare also unsupported. - A parent-by-child-count predicate that is true at zero — for example,
= 0,>= 0, or!= 1. A childless parent has no aggregate group, so these filters are rejected. Use a single numeric comparison that is false at zero, such as> 0,>= 2,= 2, or!= 0. - An
existssubquery carrying a paging bound (start) or a nested relationship (sub) →BuildError::Unsupported. - A bare top-level
existswhose implied slot collides with asubof the same name →BuildError::Unsupported(one relationship per slot). Twoexistsunder a top-leveland/orare uniquified to distinct slots and never collide.
A note on value types
Unlike the Rust delta stream — where cells arrive as an OwnedValue enum you
match on — the TypeScript view hands you plain typed JavaScript values,
shaped by your schema. A number() column reads back as number, string() as
string, boolean() as boolean, and an optional column can be null. Missing projected data differs from SQL
NULL; it does not become a complete row until the required columns arrive.
A json<T>() column reads as the JSON value typed by T:
import { table, string, number, boolean, json } from "@rindle/client";
const issue = table("issue")
.columns({
id: number(),
title: string(),
priority: number(),
open: boolean(),
tags: json<string[]>(), // read back as string[]
})
.primaryKey("id");
// view rows are fully typed — no enum matching, no manual coercion:
// { id: number; title: string; priority: number; open: boolean; tags: string[] }
The comparator that orders and dedupes rows is keyed on the column type, so the sort you get in TypeScript is byte-for-byte the sort the Rust engine produces.
For a standalone store, define the schema in TypeScript. For a database-backed application, generate it from SQL. Put application refinements in your own module; do not hand-edit the generated schema. SQL is the source of truth for persistent columns, types, and keys.
Next steps
- Reactive queries in the browser — build and materialize a query end to end on the in-process wasm engine.
- The browser client — the same builder in a synced app:
defineQuery, optimistic writes, live views. - Compose the UI with fragments — split a query across the
component tree with the same
select/sub/countAssurface. - The change model — the delta vocabulary the view folds for you.
- Synced-app quickstart — these shapes in a real React app.