Rollups (stored aggregates) ¶
A ledger sheet answers “what did we spend in March?” with an aggregate:
sheet.aggregate walks March’s records and folds
them. That is fine for one question. When a dashboard asks it on every page
load, or you want the monthly totals to show up in git diff next to the
transactions that moved them, store them: declare a rollup sheet.
A rollup sheet holds one record per partition of a source sheet (a year, a month, a domain, or the whole sheet). gitsheets writes those records in the same commit as the record changes that affect them, and stamps each with the git tree id of the partition it summarizes, so a stale one is a single hash comparison away.
The source ¶
# .gitsheets/transactions.toml
[gitsheet]
root = 'transactions'
path = '${{ postedAt: YYYY/MM }}/${{ id }}'
Records land at transactions/2026/03/t-1042.toml. The month directory is the
partition the rollup rides on.
The rollup ¶
# .gitsheets/transactions-monthly.toml
[gitsheet]
root = 'transactions-monthly'
path = '${{ postedAt: YYYY/MM }}' # a proper prefix of the source's path
[gitsheet.rollup]
from = 'transactions'
[gitsheet.rollup.aggregates]
count = { count = true }
total = { sum = 'amountCents' }
largest = { max = 'amountCents' }
posted = { sum = 'amountCents', where = { status = 'posted' } }
byCategory = { sum = 'amountCents', groupBy = 'category' }
Commit both configs, then write transactions as usual:
import { openRepo } from 'gitsheets';
const repo = await openRepo();
await repo.transact({ message: 'import March statement' }, async (tx) => {
const ledger = tx.sheet('transactions');
await ledger.upsert({ id: 't-1042', postedAt: new Date('2026-03-05T09:00:00Z'), amountCents: 1200, status: 'posted', category: 'groceries' });
await ledger.upsert({ id: 't-1043', postedAt: new Date('2026-03-20T09:00:00Z'), amountCents: 240000, status: 'posted', category: 'rent' });
});
That one commit carries the two transactions and:
# transactions-monthly/2026/03.toml
configTree = "41d07a…"
count = 2
largest = 240000
posted = 241200
postedAt = 2026-03-01
sourceTree = "9f8e1c…"
total = 241200
[byCategory]
groceries = 1200
rent = 240000
postedAtis the key field: the first day of the period, as a TOML local date. A plain-field partition (${{ domain }}) carries the field’s value instead.sourceTreeandconfigTreeare the stamp —git rev-parse HEAD:transactions/2026/03andgit rev-parse HEAD:.gitsheets. A record is fresh only while both match, so editing any sheet config makes reads recompute until the next gitsheets commit (which regenerates every rollup) orgit sheet rollup.- An aggregate with nothing to fold is left out, except
countandsum(0).
Only the partitions a commit touches are regenerated. A transaction that adds
one February record rewrites transactions-monthly/2026/02.toml and nothing
else; deleting a month’s last record deletes its rollup in the same commit.
Reading ¶
A rollup sheet reads like any sheet, and pruning works on the key field:
const monthly = await repo.openSheet('transactions-monthly');
const q1 = await monthly.queryAll({ postedAt: { $gte: '2026-01-01', $lt: '2026-04-01' } });
const march = await monthly.get('2026/03');
Reads are always correct. For each partition, gitsheets compares the stored
record’s stamp with the partition’s and the configs’ current tree ids: a match is served
as stored (one small blob read); a mismatch is recomputed from the
transactions on the fly, without writing. Pass { stale: 'serve' } to see
exactly what the commit stored instead. monthly.aggregate(…) folds over
those same read-through records, and an equality filter on the key field
accepts the day as '2026-03-01' or new Date('2026-03-01T00:00:00Z').
Tenants (prefix) ¶
A source written under prefixes (tx.sheet('transactions', { prefix: 'acme' }))
gets per-tenant rollups at transactions-monthly/acme/2026/03.toml. gitsheets
works out each changed record’s prefix from its depth — whatever lies above
the segments the source template renders — so every commit, merge, and sync
replay keeps every tenant’s rollups fresh. Read them with
repo.openSheet('transactions-monthly', { prefix: 'acme' }).
Keeping them fresh after outside writes ¶
Every commit gitsheets makes keeps the rollups fresh: transactions, permissive
writes, repo.merge / git sheet merge, and every commit repo.sync
replays. A plain git merge, a hand edit, or an import by another tool leaves
stale stamps. Reads still self-heal; to persist the fix:
git sheet rollup transactions-monthly --check # CI gate: lists stale / missing / orphaned, exit 1 if any
git sheet rollup transactions-monthly # recompute + delete orphans, one commit
or await monthly.materialize() from code. The same command backfills a
rollup added to a sheet with history (a gitsheets transaction that adds the
config backfills in that commit too).
Partitions you can declare ¶
- A shorter date bucket —
${{ postedAt: YYYY }}over aYYYY/MM/DDsource rolls up by year. AYYYY/WW(ISO week) bucket can only be rolled up whole. - A plain field —
${{ domain }}over${{ domain }}/${{ slug }}. - The whole sheet — a literal path,
path = 'all', yields one record. Every transaction that touches the source rescans it, so prefer integer aggregates (count, integersum,min,max) there.
Expression components, recursive ** components, and anything that is not a
prefix of the source’s path are rejected when the sheet is opened.
Things to know ¶
- Removing a rollup config leaves its records. Nothing reads them as a rollup any more; delete the directory in the same commit if they should go.
- A bad rollup config only blocks commits that touch it — its source’s
records, or the configs — with the
ConfigErroropening it would raise. - Rollup roots stay clear of every rollup source’s root and every other
rollup’s (
config_invalidotherwise), so a rollup never lands inside a partition it stamps. A source sheet needs its ownroot(not.) to have a rollup; ordinary sheets may overlap as usual. - Raw writes can forge a stamp. A
tx.writeFileunder a rollup root is served as-is while its stamps match;git sheet rollup --checkcompares bytes and catches it.
- Rollup sheets are read-only.
upsert,delete,patch,clear, and the attachment writers throwTransactionError(sheet_derived). - A failing aggregate aborts the commit. Mixed kinds under
max, an overflowing integersum, or a throwingreduceraiseAggregationErrornaming the rollup sheet, the aggregate, and the partition — and nothing is committed. reduceis allowed and deterministic. It runs in the core’s embedded engine with no clock or randomness (Date.now(),Math.random(), and an argument-lessnew Date()throw), so Node and Python commit identical bytes.- Floats don’t compose. A yearly float total is not promised to equal the sum of its monthly float totals. Store money in integer cents.
- Add a schema to catch typos. A
[gitsheet.schema]on the rollup sheet validates every generated record, so a misnamed aggregate fails loudly.