Skip to content

feat(transactions): serve the transactions list from the tx_facts read model - #406

Merged
argszero merged 1 commit into
mainfrom
feat/tx-list-reads-read-model
Oct 15, 2026
Merged

argszero merged 1 commit into
mainfrom
feat/tx-list-reads-read-model

Conversation

@argszero

Copy link
Copy Markdown
Owner

What changed

GET /api/transactions now reads the read model tx_facts for both its rows and its total, instead of the raw transactions table. 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. counterpart had no consumer in the page (ui/js/app.js never 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 of transactions_rollup covers all 11 displayed fields, and input = tokens − cached − output is 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 ×N beside the type when it exceeds 1, with the hover text tx.row.bucket, so a minute roll-up is never presented as a single call.

total is COUNT(*) over the view = the number of rows the pager actually pages through; summary.entries stays SUM(row_count) = the number of ledger records. The two are deliberately different numbers, and the gap is the folding itself.

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 documented

Folding proceeds in id batches, 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 in src/tx_facts.rs (module doc), ui/README.md and the PR.

  • no record is counted twice and none is lost: the halves are disjoint, and SUM(row_count) over that minute still equals its real record count;
  • the boundary rows can only belong to the minute the fold watermark sits in, and the moment folding passes that minute its tail rows are folded too, so the two rows become one.

Pinned by a new test: tx_facts.rs::a_minute_folded_in_half_still_adds_up.

Sort

The default order moves from t.id DESC to t.time DESC, t.id DESC. The two arms keep separate id spaces (each starts at 1), so ordering by id would 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 :00 to a minute-only source string (a roll-up row has no seconds; rounding them in would be invented precision).

Gate

src/tx_facts_gate.rs is rewritten, because its old R2 forbade exactly what the list now does.

  • R1 spelling: the view name lives only in tx_facts.rs.
  • R2 every view read must declare its grain — aggregate (SUM(), a per-row read carrying row_count, or an explicit row count — must not touch detail-only columns, and must be readable.
  • R3 time-window alignment inside fn tx_where(.
  • R4 (new) the list must actually read the view: one per-row read (with 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

  • R5 — rewriting 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 of tx.empty.archived still says "days" — both belong to the piece that changes the default window to 10 minutes.
  • The 10-minute default itself (acceptance item 1).

Verification

  • cargo test — 562 passed, 0 failed (561 before, +1 new seam test).
  • cargo fmt --check clean; cargo clippy --all-targets -- -D warnings rc=0; cargo build rc=0.
  • Live dev verification (acceptance items 2 and 3: empty fields, filter / sort / paging / CSV on the new source) follows after this lands and the default window moves, since both need a running instance.

…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
argszero merged commit 0560240 into main Oct 15, 2026
2 checks passed
@argszero
argszero deleted the feat/tx-list-reads-read-model branch October 15, 2026 10:37
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.
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.
@argszero argszero mentioned this pull request Oct 15, 2026
10 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant