The view model
Part of the ccpool algorithm docs.
The view model — what status and tui render
Both surfaces render from one model, assembled by gatherView. It prefers the shared backend (everyone-included), falls back to the local state.json (instant, no network), and finally to a one-shot live poll so the view is never empty before the daemon’s first write.
The heavy half — the raw-row reads plus attribution — lives in core as computeSharedView (feeding the pure assembleSharedView) and produces the compact SharedView — a few KB, never raw rows:
interface SharedView {
generatedAt: string;
samples: UsageSample[]; // latest per cap → the header bars
shares: CapShares[]; // per-cap, per-person split (attributeShares)
members: MemberRollup[]; // per-name token totals + last-seen
users: string[]; // the roster
}
It reads a 7-day window once — latest samples, the sample trajectory, everyone’s messages, reset events (window bounds), markers, and the roster — then runs attributeShares (attribution) and summarizeMembers. Two guards matter: markers are fetched defensively (a DB missing the table degrades to “no markers” rather than blanking the view), and latest samples are merged into the windowed trajectory so a cap with a current-but-old reading is still attributed (falling to unknown) rather than skipped.
gatherView (apps/cli) wraps a ViewSource.fetchView() in the local decoration — daemon pid, state.json fallback, live-poll fallback, the cached account email.
The watermark — why a 2s refresh is cheap
The TUI refreshes every 2 seconds, but the ledger changes at most about once per minute (the daemon cadence). Re-reading a 7-day window of samples (~30k rows) and re-running attribution on every refresh was the original cost problem — hundreds of thousands of heavy queries a day per viewer. The fix is a write watermark:
- Every ledger mutation (
recordBatch,upsertUser,prune) bumps a single counter,ccpool_meta.writeSeq, inside the same transaction. Reading it (getChangeToken) is one single-row SELECT. - A computed view is cached under
viewCacheKey(token, now)— the token plus a 60-second time bucket. The bucket exists becauseattributeShareswindows slide withnow: without it, a group whose daemons stopped writing would be served a frozen split forever. Worst case is one recompute per minute even with zero writes; a healthy group writes ~1/min anyway, so the bucket adds ~nothing. - Server side:
StorageViewSource.fetchView()does the 1-row token read; only a changed key recomputes. The heavy read drops from every-2s to ~1/min per viewer (~30×), andreset_eventsscans sit behind a real index now. - Client side: the same key doubles as the ETag of
GET /v1/view. The client sendsIf-None-Match; the steady-state answer is a bodyless 304 backed by one single-row SELECT on the server. Only a real change re-sends the few-KB view (the server).
The ledger window — why even the ~1/min recompute reads no rows
The watermark bounds how often the heavy work runs; the LedgerWindow (packages/core/src/backend/window.ts) removes the heavy read itself. The server composes one per live group, shared by that group’s ingest sink and view source:
- Hydration, once: the first view read performs the same full-window scan
computeSharedViewwould, into in-memory maps keyed by the DB’s natural keys. - Append, ever after: the ingest sink already holds each tick’s rows when
recordBatchcommits, so it pushes them straight into the window. A steady-state recompute runsassembleSharedViewover memory — the only storage read left is the tiny roster (plus the 1-row watermark). - Byte-identical by construction: appends are insert-if-absent per natural key (a retried tick’s mutated values lose, matching
ON CONFLICT DO NOTHING); an un-hydrated window drops appends (the hydration read covers them) and a hydrating one buffers them; the window trims only when the sink’s prune actually deletes rows — never on a clock — solatestcan keep surfacing a cap whose only sample is older than the 7-day window, exactly like the SQL path. Late-arriving batches (a machine re-sending a retained tick) are appended like any other and the next recompute re-attributes the full window, so attribution self-heals identically in both paths. An equivalence suite (packages/core/test/window.test.ts) pins windowed == full-scan across hydrate/append/retry/prune/eviction races. - Eviction is free: tenants hold no connections (their
Storageis a facade over the one process pool), so the LRU just drops the window; the group re-hydrates with one scan on its next touch.
Retention rides the same path: rows older than the widest cap window (+1 day of slack — RETENTION_MS, 8 days) can never influence a view again, so the sink prunes them on a throttled sweep and every table stays bounded.
toDesignModel flattens this into one presentation model: caps (the header bars) and members (each person’s per-window share, joined with their token total and an active flag). active is deliberately simple — the member is holding more than 0% of the 5-hour window right now. The member list is keyed on the people attribution produced; unknown is always last.
Two surfaces render that model:
statusis a plain-string renderer (status-render.ts): one frame, coloured when stdout is a TTY and plain text when piped/redirected, sostatus | grepandstatus > filestay clean. It targets 70 columns and sheds columns (the per-member bar, then trailing caps) on narrower terminals. Bar colour comes from a calculated green→red ramp (heat.ts, hue 120°→0° in HSL); each member’s bar matches their name colour.tuire-runsgatherViewevery 2s (cheap — the watermark; the clock ticks every 1s so countdowns move), rendering the same model through one of three interchangeable Ink layouts — overview · split · mono, cycled with Tab (Shift+Tab reverses) — adding per-person token totals and scrolling for large groups. The views fill the terminal width and reflow live on resize (useTermSize).
Bare ccpool opens a TUI-first shell (tui/Root.tsx): unconfigured, it lands on a guided onboarding wizard (the interactive form of init); configured, it opens the live view, where c opens a tabbed configure screen (general · daemon, same Tab / Shift+Tab cycling). Configure writes config, tests a storage connection before saving, and starts/stops the daemon — all the interactive form of the flag commands, which stay as a scriptable fallback.
Edge states (token expired, daemon down, live-poll badge) render as footnotes. When the database is unreachable but the tank is still cached (offline), the member table can’t show the real split, so it degrades to a placeholder rather than an empty list: DISCONNECTED_ROWS grey xxxx rows whose shares are a random — but seed-stable, so they don’t flicker across re-renders — partition summing to each cached window, all marked idle, under a red (mono: white) “can’t reach the database” line. It reverts to the real per-person split the moment the DB is reachable again.
▐▛███▜▌ ccpool · status · you are sam
▝▜█████▛▘ account sam@example.com · 2 members (1 active)
▘▘ ▝▝ shared db · synced 12s ago · daemon running
overall
5h ███████████████████████████░░ 92% · resets 4h 02m
weekly ██████░░░░░░░░░░░░░░░░░░░░░░░░ 21% · resets 6d 4h
members
# member usage 5h wk state
1 sam ◂ █░░░░░░░░░░░░░░░░░░░░░░░░░░ 5% 1% active
2 unknown ██████████████████████████ 87% 20% idle