Recipes

Live counts & aggregates

Attach a correlated child count to each parent row with `.countAs(alias, rel)` — a `commentCount` or `slideCount` the engine keeps exact on every insert and delete, without rescanning. Or make the query itself the aggregate with `.count()` / `.groupBy().count().having()`, all maintained incrementally.

View as Markdown

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 todaysum / 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 slideCount is a server-computed aggregate whose underlying slide rows the dashboard doesn’t otherwise sync, it keeps that coverage warm with a background useSyncQuery (DecksKeepalive.tsx) so the count stays authoritative without materializing every slide.

See also