Aggregations (count / sum / avg / min / max + GROUP BY + window functions)
On-demand counts, sums, group-by, having, and window functions — without escaping to raw SQL.
The query builder's .count() / .aggregate({...}) / .groupBy() /
.having() covers nearly every analytic query a typical SaaS app
needs without raw SQL. Window functions (rowNumber, rank, lag,
lead, sumOver) live in the same surface for percentile-style and
running-total reads.
These are on-demand reads — they run when called, not on a
schedule. For pre-computed read models that refresh periodically see
Aggregates (the *.aggregate.ts file
convention).
Live — an on-demand aggregation as a computed reactive query: add or toggle a
todo and the { open, done, total } counts update with no refetch:
const stats = useSubscription('app', 'todos.stats') // reactive count roll-up
Quick start
import { count, sum, avg, max, eq, queryFor } from '@voltro/database'
// How many open todos does this user have right now?
const rows = await ctx.store.query(
queryFor(database.todos).where(eq('done', false)).count().descriptor,
)
const open = rows[0]!.count // → numberResult rows from any aggregate are always an array with one entry
per group (or exactly one entry when there's no groupBy). Reach
in via [0] for the ungrouped case.
count() — the most common case
// Count every row in the filtered set
queryFor(database.posts).where(eq('userId', uid)).count()
// Count distinct values of a column
import { countDistinct } from '@voltro/database'
queryFor(database.posts).aggregate({
authors: countDistinct('userId'),
})count() defaults to COUNT(*) — every matching row, including
NULLs. countDistinct(column) emits COUNT(DISTINCT column).
aggregate({...}) — bundle multiple aggregates in one query
import { count, sum, avg, max, min } from '@voltro/database'
const rows = await ctx.store.query(
queryFor(database.orders).where(eq('orgId', oid)).aggregate({
total: sum('amount'),
avgAmt: avg('amount'),
peak: max('createdAt'),
earliest: min('createdAt'),
cnt: count(),
}).descriptor,
)
const stats = rows[0]!
// { total: 12_345, avgAmt: 89.5, peak: Date, earliest: Date, cnt: 138 }The keys of the spec map become the column names on the result row. Each value is a helper:
| Helper | SQL | Returns |
|---|---|---|
count() |
COUNT(*) |
number |
count('col') |
COUNT(col) (non-null) |
number |
countDistinct('col') |
COUNT(DISTINCT col) |
number |
sum('col') |
SUM(col) |
number / null |
avg('col') |
AVG(col) |
number / null |
min('col') |
MIN(col) |
column type / null |
max('col') |
MAX(col) |
column type / null |
SQL semantics on empty result sets: count returns 0;
sum/avg/min/max return null. The framework matches this — don't
write if (rows.length === 0) defensive code, the row is always
there.
Fast path: .exists() for "is there any?"
When you only need a yes/no, avoid count() > 0 — .exists()
short-circuits with SELECT 1 ... LIMIT 1:
const rows = await ctx.store.query(
queryFor(database.users).where(eq('email', e)).exists().descriptor,
)
const emailTaken = rows[0]!.exists // → booleanOn a 10M-row table this is the difference between an index-only scan that stops at the first match and a full count.
groupBy(cols) — one row per group
const rows = await ctx.store.query(
queryFor(database.orders).where(eq('orgId', oid))
.groupBy(['status'])
.aggregate({ cnt: count(), total: sum('amount') })
.descriptor,
)
// rows: [{ cnt: 12, total: 4500 }, { cnt: 5, total: 1800 }] — one row per groupProject the group key with
column(). By default the result rows carry the aggregate aliases only. To fold aGROUP BYkey column into the same row, add acolumn(name)entry to the spec — it must also appear in.groupBy([...])(standard SQL):import { column, count, sum } from '@voltro/database' queryFor(database.orders).where(eq('orgId', oid)) .groupBy(['status']) .aggregate({ status: column<string>('status'), cnt: count(), total: sum('amount') }) // rows: [{ status: 'open', cnt: 12, total: 4500 }, …]Annotate the type (
column<string>(...)) for a precise result row, or rely on the defaultstring | number | boolean | Date | nullunion.
Chained .groupBy() calls append columns — group by orgId × status:
queryFor(database.orders)
.groupBy(['orgId'])
.groupBy(['status'])
.aggregate({ cnt: count() })Group-by works without aggregate() too (returns distinct combos),
but the typical pattern is grouping + aggregating together.
having(predicate) — filter groups after aggregation
having predicates run AFTER aggregation; they reference aggregate
aliases, not raw columns. Distinct from .where() (which becomes
WHERE and runs BEFORE grouping):
import { gt } from '@voltro/database'
interface PostCount { userId: string; postCount: number }
// "How many users posted more than 10 times in the last 30 days"
const heavy = await ctx.store.query(
queryFor(database.posts)
.where(gt('createdAt', daysAgo(30))) // WHERE — pre-aggregate
.groupBy(['userId'])
.having(gt<PostCount, 'postCount'>('postCount', 10)) // HAVING — post-aggregate
.aggregate({ postCount: count() })
.descriptor,
)
// rows: [{ postCount: 14 }, { postCount: 11 }, …] — one per qualifying userChained .having() AND-merges, same as .where().
Window functions
Window functions compute per-row aggregates over a "window" of rows
without collapsing the row set — different from groupBy, which
collapses. Use for ranking, running totals, "previous-row" deltas.
rowNumber() / rank() / denseRank()
import { rank, queryFor } from '@voltro/database'
const ranked = await ctx.store.query(
queryFor(database.players).aggregate({
rank: rank().over({
partitionBy: ['teamId'],
orderBy: [{ column: 'score', direction: 'desc' }],
}),
}).descriptor,
)
// Each row: { rank } — rank restarts at 1 per teamNote: to carry a plain column (
id,name) alongside the window expression, add acolumn(name)entry to the spec — it projects"col" AS "alias"into the same result row.
rowNumber() always returns sequential 1, 2, 3 — ties get distinct
numbers. rank() gives ties the same number, then skips
(1, 1, 3, 4…). denseRank() gives ties the same number, no skip
(1, 1, 2, 3…).
lag() / lead() — previous / next row
import { lag, lead } from '@voltro/database'
queryFor(database.scores).aggregate({
prevScore: lag('score').over({ partitionBy: ['userId'], orderBy: [{ column: 'createdAt', direction: 'asc' }] }),
nextScore: lead('score').over({ partitionBy: ['userId'], orderBy: [{ column: 'createdAt', direction: 'asc' }] }),
})Both accept an optional offset — lag('score', 3) looks 3 rows
back.
sumOver() / avgOver() — running totals
import { sumOver } from '@voltro/database'
queryFor(database.ledger).aggregate({
runningSum: sumOver('amount').over({
orderBy: [{ column: 'createdAt', direction: 'asc' }],
}),
})
// Each row: running total of `amount` up to and including this rowCombine partitionBy + orderBy for per-group running totals
("total spend per user, sorted by date").
Reactivity
count() and aggregate() subscriptions ARE reactive. The
dispatcher subscribes to the source table; any write triggers a
re-run of the same aggregate SQL.
Window-function queries are reactive — coarsely. The matcher can't bucket on a window result (a rank shifts when ANY partition peer changes), so the engine widens the dependency to the whole source table: a write to it re-runs the query. That's correct but coarser than a plain query's per-field pre-filter — every write to the source table re-evaluates the window. Keep the source set bounded (a top-N leaderboard, not a 10M-row scan), or materialise it on a schedule with an aggregate.
Cross-dialect
Standard SQL — postgres, mysql 8+, mariadb 10.2+, mssql, sqlite 3.25+, turso all support these forms with identical syntax. The framework's compiler doesn't dispatch per dialect for any of the helpers above.
See also
- Aggregates (scheduled) —
*.aggregate.tsfor precomputed read models that refresh periodically - Query builder — the chain methods these aggregates compose with (where, orderBy, limit, etc.)
- Sub-queries —
inSubquery/existsfor predicates that reference other tables - Set operations — UNION / INTERSECT / EXCEPT for combining multiple aggregates