Repository navigation
feat(transactions): serve the transactions list from the tx_facts read model - #406
Merged
Merged
Conversation
…d model The detail-retention window is about to shrink from days to minutes, and until now the page could not survive that: `GET /api/transactions` read raw `transactions`, so "details are kept for a short while" meant "the transactions page can only answer that short while". Everything the list renders already exists on the read model — `counterpart` has no consumer in the page, and the aggregate row's primary key is never read back. The list and its `total` now both come from `tx_facts` (folded minute roll-ups UNION the not-yet-folded tail), so older history is still listed — as minute roll-ups — instead of disappearing. `total` counts view rows (`COUNT(*)`), which is exactly what the pager pages through, while `summary.entries` keeps counting ledger records (`SUM(row_count)`); the two are deliberately different numbers and the gap is the folding itself. Each row now carries `row_count` (how many real records it stands for; the detail tail is always 1) and the page renders `×N` next to the type when it exceeds 1, so a roll-up row is never presented as a single call. A row's `id` is row identity for LIMIT/OFFSET only — it is not "which transaction", and the front-end does not read it. The seam between the two arms is deliberate and documented: because folding goes in `id` batches, one minute can be half folded, and that minute then appears as a roll-up row plus its tail rows. The halves are disjoint, no record is counted twice, and `SUM(row_count)` over the minute still equals its real record count (`tx_facts.rs::a_minute_folded_in_half_still_adds_up` pins this). Default sort moves from `t.id DESC` to `t.time DESC, t.id DESC`: the two arms keep separate id spaces, so ordering by id would sort by the roll-up table's primary key rather than by time. `fmtPrecise()` no longer invents seconds for a minute-only source string, and the `tx_facts` gate is rewritten — R2 now requires every view read to declare its grain (aggregate / per-row with `row_count` / explicit row count) and the new R4 requires the list to actually read the view, so moving it back to the raw table turns the gate red.
argszero
added a commit
that referenced
this pull request
Oct 15, 2026
With the transactions page reading the read model (#406), the detail window no longer decides how far back the page can answer — the folded minute roll-ups carry that history. So the window can now be set to what the record actually needs, and the host's ruling of 2026-10-15 is that no one queries individual records at all: ten minutes. `[rollup] retain_minutes` therefore defaults to 10 instead of 43200 (30 days), and the example config follows. The deletion itself is unchanged — rows are still only deleted once they are folded *and* archived, so nothing is lost: the folded part stays answerable as minute roll-ups and the original rows stay in the JSONL archive. The transactions note no longer converts the retention boundary into days. It used to round the boundary to whole days, which was already lossy and would now be wrong in a visible way — a ten-minute window rounds to "the last 0 days". The sentence names the boundary instant instead, which is unit-free and needs no second calculation on the client (the server computes the boundary from the same expression the deletion uses). `tx_retention_gate` R1 is rewritten, because its premise was exactly what #406 removed ("the list can always only answer from the detail table"). It now has two halves: - the window must be expressible in minutes — a config that has gone back to spelling it in days turns this red, because day granularity cannot express ten minutes at all: below one day there is only 0, and 0 already means "keep no detail", a different rule; - when the window is shallower than the deepest preset the list offers, the difference can only be carried by the list no longer reading the detail table at all — the ledger is that a page answering from the raw table would silently answer less, with nothing on screen saying so. The rule is not relaxed to nothing: putting the list back on the raw table makes it apply again. Both halves were verified by mutation on the real corpus (config back to days => only R1 goes red; list back to the raw table => only R1 goes red). Tests: `cargo test` 562 passed, 0 failed. `cargo fmt --check` clean, `cargo clippy --all-targets -- -D warnings` rc=0, `cargo build` rc=0.
This was referenced Oct 15, 2026
Merged
argszero
added a commit
that referenced
this pull request
Oct 15, 2026
…409) #406 moved the transactions page onto the `tx_facts` read model, which retired the premise "the list needs per-row identity (`id`/`counterpart`), the roll-up has none, so the list can always only be answered from the detail table". #407's own commit message says its premise "was exactly what #406 removed", and `ui/README.md` calls it the premise that disappeared this round - but the two module docs that state it were never updated, so both still carry it in the present tense. - `src/tx_retention_gate.rs:6-7` opens the rule list with it, so the file argues from a premise that its own bullet, ten lines later, says is gone. - `src/usage_retention_gate.rs:4-5` reasons across files from it, and names the rule wrong as well (that rule was rewritten in #407). Both sentences now say what holds today: the window must be expressible in minutes, and the history outside it is carried by the minute roll-ups. Comments only - no behaviour, no gate rule, no config changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
GET /api/transactionsnow reads the read modeltx_factsfor both its rows and itstotal, instead of the rawtransactionstable. This is acceptance item 2 of the detail-retention rant (2026-10-15T16:25:04), and it is the prerequisite for the window shrinking from days to minutes: until now, "details are kept for a short while" also meant "the transactions page can only answer that short while".Nothing the list renders is missing from the read model.
counterparthad no consumer in the page (ui/js/app.jsnever reads it, and it is not among the 11 columns the table and the CSV export declare), and the aggregate row's primary key was never read back. The dimension set oftransactions_rollupcovers all 11 displayed fields, andinput = tokens − cached − outputis the same formula on both arms.Rows now declare their own grain
Each row carries
row_count— how many real records it stands for (the not-yet-folded tail is always 1). The page renders×Nbeside the type when it exceeds 1, with the hover texttx.row.bucket, so a minute roll-up is never presented as a single call.totalisCOUNT(*)over the view = the number of rows the pager actually pages through;summary.entriesstaysSUM(row_count)= the number of ledger records. The two are deliberately different numbers, and the gap is the folding itself.A row's
idis row identity forLIMIT/OFFSETonly. It is not "which transaction", and the front-end does not read it.The seam between the two arms is documented
Folding proceeds in
idbatches, so one minute can be half folded: part of it is inside a roll-up row and part is still in the tail, which means that minute appears in the list as more than one row (one roll-up plus its tail rows). This is the presentation the rant asks not to be silent about, and it is stated insrc/tx_facts.rs(module doc),ui/README.mdand the PR.SUM(row_count)over that minute still equals its real record count;Pinned by a new test:
tx_facts.rs::a_minute_folded_in_half_still_adds_up.Sort
The default order moves from
t.id DESCtot.time DESC, t.id DESC. The two arms keep separate id spaces (each starts at 1), so ordering byidwould sort by the roll-up table's primary key rather than by time — the newest rows would end up last.(time, id)is unique because the two arms' time strings differ in length (16 vs 19 characters).fmtPrecise()no longer appends:00to a minute-only source string (a roll-up row has no seconds; rounding them in would be invented precision).Gate
src/tx_facts_gate.rsis rewritten, because its old R2 forbade exactly what the list now does.tx_facts.rs.SUM(), a per-row read carryingrow_count, or an explicit row count — must not touch detail-only columns, and must be readable.fn tx_where(.row_count) and one row count (COUNT() must exist.R1–R3 are all prohibitions, so on their own they would stay green if the list were moved back to the raw table. R4 is the rule that carries this change, and it has a mutation leg (
a_list_that_leaves_the_view_reddens_the_list_rule).Not in this PR
src/tx_retention_gate.rs, whose two rules ("the window must cover every preset the list offers", "an unbounded range must state its boundary to the page") rest on a premise that is now gone. They are still green today (stale premise, not violated), and the wording oftx.empty.archivedstill says "days" — both belong to the piece that changes the default window to 10 minutes.Verification
cargo test— 562 passed, 0 failed (561 before, +1 new seam test).cargo fmt --checkclean;cargo clippy --all-targets -- -D warningsrc=0;cargo buildrc=0.