# 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.

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](/docs/fragments). The alias becomes a scalar field on each result row,
and an empty child reads `0`:

```ts
// 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](/docs/fragments), pass the relationship instead of
the explicit key mapping:

```ts
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:

```ts
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:

```ts
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](/docs/supported-queries-ts#aggregates)
for the full matrix.

## In the wild — Strut

[Strut](https://github.com/tantaman/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:

```ts
// 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](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/shared/queries.ts#L36-L45) · Strut*

The relationship it counts is a one-liner — `deck` to `slide` on `deck_id`:

```ts
// shared/app-def.ts
export const rels = defineRelationships({
  deckSlides: rel(deck, slide, { id: 'deck_id' }),
  // …
})
```

*[`shared/app-def.ts` L75–76](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/shared/app-def.ts#L75-L76) · 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](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/src/routes/index.tsx#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`](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/src/rindle/DecksKeepalive.tsx)) so the count stays authoritative without materializing every slide.

## See also

- [Query shapes](/docs/supported-queries-ts#aggregates) — the full aggregate surface (`countAs`,
  `count`, `groupBy`, `having`) and what's in/out of scope.
- [Compose the UI with fragments](/docs/fragments) — attaching a `countAs` to a component's fragment.

---

[View this page on Rindle](https://rindle.sh/docs/live-aggregates)
