A count next to each row — comments on an issue, slides in a deck, votes on a post — is the classic
thing you don’t want to recompute on every render or invalidate by hand. In Rindle it’s a query shape:
.countAs(...) attaches a correlated child count to each parent, and the engine maintains it
incrementally — one insert into the child table moves exactly that parent’s count, no rescan.
countAs: a live count per row
Add it to any query or fragment. The alias becomes a scalar field on each result row,
and an empty child reads 0:
// one commentCount per issue, correct on every comment add/remove
store.query.issue
.countAs("commentCount", comment, { parent: ["id"], child: ["issueId"] })
.materialize();
// rows: { id, title, …, commentCount: number }
If you’ve named the correlation as a relationship, pass the relationship instead of the explicit key mapping:
store.query.issue.include(IssueFragment).countAs("commentCount", rels.issueComments);
Make the query be the aggregate
Sometimes you want the count itself, not a count per row. .count() reshapes the result to a single
count row; .groupBy() keys it; .having() filters the aggregated rows — all maintained incrementally,
so the number ticks as rows enter and leave the filter:
import { gt } from "@rindle/client";
// one { count } row — open issues, live
store.query.issue.where.open(true).count().materialize();
// one { status, count } row per status, keeping only the busy ones
store.query.issue
.groupBy("status")
.count()
.having((h) => h.count(gt(3)))
.materialize();
You can also filter parents by a child count — issues with more than ten comments — with the
.having(alias, op, n) overload over a countAs alias:
store.query.issue
.countAs("commentCount", comment, { parent: ["id"], child: ["issueId"] })
.having("commentCount", ">", 10)
.materialize();
where filters base rows below the aggregation; having filters above it. count is the only
aggregate today — sum / avg / min / max are designed but not yet built, and the parent-by-count
having overload takes high-pass predicates only in v1. See query shapes
for the full matrix.
In the wild — Strut
Strut’s dashboard shows a live slide count on every deck card.
It’s a countAs on the deck-list query, over the deck→slide relationship:
// shared/queries.ts
export const decksQuery = defineQuery(
'decks',
(raw): { limit: number } => ({ limit: reqLimit(raw) }),
({ limit }: { limit: number }) =>
q.deck
.include(DeckFragment)
.orderBy('modified', 'desc')
.limit(limit)
.countAs('slideCount', rels.deckSlides),
)
shared/queries.ts L36–45 · Strut
The relationship it counts is a one-liner — deck to slide on deck_id:
// shared/app-def.ts
export const rels = defineRelationships({
deckSlides: rel(deck, slide, { id: 'deck_id' }),
// …
})
shared/app-def.ts L75–76 · Strut
The dashboard just reads the field — {d.slideCount} slide{d.slideCount === 1 ? '' : 's'} — and it stays
exact as slides are added or deleted, from any client:
src/routes/index.tsx L30, L171 · Strut
A subtlety Strut documents: because
slideCountis a server-computed aggregate whose underlying slide rows the dashboard doesn’t otherwise sync, it keeps that coverage warm with a backgrounduseSyncQuery(DecksKeepalive.tsx) so the count stays authoritative without materializing every slide.
See also
- Query shapes — the full aggregate surface (
countAs,count,groupBy,having) and what’s in/out of scope. - Compose the UI with fragments — attaching a
countAsto a component’s fragment.