Skip to content

fix(tx): order the whole transaction set, not just the loaded page - #284

Merged
argszero merged 1 commit into
mainfrom
fix/tx-sort-describes-the-dataset
Sep 22, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/tx-sort-describes-the-dataset

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The transactions table's column headers carry a sort arrow, and that arrow is a claim about the whole dataset: the pager under the table counts the backend's total ("1 2 3 / 3 · 30 rows") and the summary card above it is a backend aggregate. The ordering, however, only ever applied to the page in hand.

#135 turned the list into a server-paged table (serverPaging = backend total, one page of rows per request, LIMIT/OFFSET over a hardcoded ORDER BY t.id DESC), while sorting stayed where it was born in the static prototype (#7, d70e032): client-side, over whatever rows the caller passed in — a page. Clicking "Points" ▲ therefore reordered the ten rows on screen and painted ▲, while the row that actually holds the minimum sat on page 2/3, was never fetched, and the request never changed at all. The column filters travelled the other half of that road long ago (#232 / e7bfc6f: "local filter of the current page" → "backend full-dataset filter"); the sort half never did.

Related Issue

None — found while scanning the transactions view (same family as #232/#77).

Changes

  • ui/js/app.js — one claim, three carriers:
    • txSortParams() projects the single sort state (txTable.sort) into the list request only. The trend endpoint buckets by time; row order means nothing to it.
    • txQuerySig() now covers the sort, so the reload guard actually refetches on a header click. Without this line the arrow would move over a list that does not — worse than the defect being fixed (C2146's family).
    • the local sort steps aside when the call site declares serverSort: if (state.sort.length && !(serverPaging && serverSort)).
  • src/routes/wallet.rs — the user string never reaches SQL: TX_SORT_KEYS (11 keys, exactly the sortable columns of TX_COLUMNS), tx_sort_expr() and tx_order_by(). The ORDER BY fragment is rendered from the whitelist, always with a trailing , t.id DESC (a non-unique sort makes LIMIT/OFFSET page boundaries indeterminate — one row twice, another never), and an unknown key / mismatched key–direction count / bad direction is a 400. Validation happens before the DB lock, as type does. Sort expressions match what the cell shows (pts via signed_pts_expr, the four token columns on their raw values, model / key byte-identical to the filter expressions).
  • ui/index.html — cache-bust token bumped for the app.js change.
  • ui/README.md — the convention ("one claim, three carriers" + the whitelist rule) and the gate's honest lexical scope.

Tests

  • cargo test — 342 passed / 0 failed (baseline 52aec72: 336; +6 = 3 gate tests + 3 wallet behaviour tests)
  • cargo fmt --check — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • New tests added:
    • state_gate::the_sort_indicator_and_the_order_by_share_one_source (4 rules, each with its own mutant leg), the_r164_rules_have_teeth (self-proof over self-contained mini sources), the_r164_roster_is_real (positive control);
    • src/routes/wallet.rs: the whole set is ordered (not the page), ties are stable across pages, anything outside the whitelist is rejected.
  • Two-instrument discrimination, both directions:
    • compile gate — the fragment compiles and runs against an explicitly materialized pre-fix tree (git archive 52aec72 src ui): declared red there = { the_sort_indicator_and_the_order_by_share_one_source, the_r164_roster_is_real }, actual red = the same two; on the landed tree, 47/47 green. rustfmt + clippy-driver -D warnings legs on both trees.
    • jsdom probe (7 legs, real index.html + the four real scripts, a paging/sortable fetch stub over a 30-row fixture where the global min and max sit on pages 2 and 3): pre-fix tree → the two global-extremum legs and the "did the request carry sort" leg fail (as declared); landed bytes → all seven pass. The two competing "fixes" — keep sorting the page and caption the arrow " (this page)", or stop painting the arrow — are rejected by the same legs.

Honest scope: the gate is lexical — it proves the three carriers agree and that the whitelist IS the column roster. It does not prove that the rows on screen are globally ordered (that is the jsdom probe's job; there is no JS runner in cargo test), nor the ORDER BY semantics themselves (those are the wallet behaviour tests). Recorded in ui/README.md.

Checklist

  • Branch name follows the convention (fix/…)
  • Commit message uses Conventional Commits
  • Single purpose, minimal change (no opportunistic refactoring; the sort expressions reuse the existing signed_pts_expr / filter expressions)

The transactions table's column headers carry a sort arrow, and that arrow
is a claim about the WHOLE dataset: the pager below it counts the backend's
`total` (30 rows, "1 2 3 / 3 · 30 rows") and the summary card above it is a
backend aggregate. The ordering, however, only ever applied to the page in
hand.

`#135` turned the list into a server-paged table (`serverPaging` = backend
total, one page of rows per request, `LIMIT/OFFSET` over
`ORDER BY t.id DESC`), while sorting stayed where it was born in the static
prototype (`#7`, `d70e032`): client-side, over whatever rows the caller
passed in --

    if (state.sort.length) { data = data.slice().sort(...) }
    const pageRows = serverPaging ? data : data.slice(...)

a page. So clicking "Points" ▲ reordered the ten rows on screen and painted
▲, while the row that actually holds the minimum sat on page 2/3 and was
never fetched; the request never changed. The column FILTERS travelled the
other half of that road long ago (`#232` `e7bfc6f` / C2114: "local filter of
the current page" -> "backend full-dataset filter"); the sort half never did.

Fix: one claim, three carriers, and a server that renders `ORDER BY` from a
whitelist.

  * `ui/js/app.js` — `txSortParams()` projects the single sort state
    (`txTable.sort`) into the LIST request only (the trend endpoint buckets
    by time; row order means nothing to it). `txQuerySig()` now covers the
    sort, so the reload guard refetches when a header is clicked -- without
    that line the arrow would move over a list that does not, which is worse
    than the defect being fixed. The local sort steps aside when the call
    site declares `serverSort`: `if (state.sort.length && !(serverPaging &&
    serverSort))`.
  * `src/routes/wallet.rs` — `TX_SORT_KEYS` (11 keys, exactly the sortable
    columns of `TX_COLUMNS`) + `tx_sort_expr()` + `tx_order_by()`. The user
    string never reaches SQL; the `ORDER BY` fragment is rendered from the
    whitelist, always with a trailing `, t.id DESC` (a non-unique sort makes
    `LIMIT/OFFSET` page boundaries indeterminate -- one row twice, another
    never), and an unknown key / mismatched key-direction count / bad
    direction is a 400. Validation runs before the DB lock, as `type` does.
    Expressions match what the cell shows (`pts` via `signed_pts_expr`, the
    four token columns on their raw values, `model`/`key` byte-identical to
    the filter expressions).

Tests:

  * `state_gate::the_sort_indicator_and_the_order_by_share_one_source` —
    four rules, each with its own mutant leg, plus a
    `the_r164_rules_have_teeth` self-proof over self-contained mini sources
    and a `the_r164_roster_is_real` positive control. Roster == whitelist ==
    match arms, `q.sort`/`q.dir` bound in exactly one place, the guard reads
    the WHOLE condition (a `[^)]*` reader cannot see `!(a && b)`) and a
    control rule keeps the filter half of the same road honest.
  * `src/routes/wallet.rs` — three behaviour tests: the whole set is ordered
    (not the page), ties are stable across pages, and anything outside the
    whitelist is rejected.
  * jsdom probe (7 legs, both directions): on the pre-fix tree the two
    global-extremum legs and the "did the request carry sort" leg fail; on
    the landed bytes all seven pass. The two competing "fixes" (keep sorting
    the page and caption the arrow " (this page)"; stop painting the arrow)
    are rejected by the same legs.

Scope, stated honestly: the gate is lexical -- it proves the three carriers
agree and that the whitelist IS the column roster; it does not prove that
the rows on screen are globally ordered (that is the jsdom probe's job, and
there is no JS runner in `cargo test`), nor the `ORDER BY` semantics
themselves (those are the wallet behaviour tests). Recorded in
`ui/README.md`.
@argszero

Copy link
Copy Markdown
Owner Author

Self-review (own PR — recorded as a plain comment because a formal review cannot be submitted on one's own PR).

What I checked

  1. The claim is one claim with three carriers, and the gate reads all three. txTable.sort is the only sort state; txSortParams() projects it into the list request, txQuerySig() covers it, and the local sort steps aside when the call site declares serverSort. Each of the four rules has a mutant leg that flips only itself (including the two reverse legs: the trend request must NOT carry the sort, and q.sort/q.dir must be bound in exactly one place).
  2. The whitelist is the roster, in both directions. TX_SORT_KEYS (11) == the key fields of TX_COLUMNS == the match arms of tx_sort_expr, with the declared array length checked against the literal count, and a positive-control test that fails loudly if the roster drifts rather than letting "whitelist == roster" hold vacuously on two empty sets.
  3. No user string reaches SQL. The placeholder is ORDER BY {order} and order is bound once from tx_order_by(), whose body goes through tx_sort_expr() (whitelist). Unknown key, key/direction count mismatch, or a direction outside {asc, desc} ⇒ 400 before the DB lock.
  4. The sort expressions speak the same language as the cells. pts uses signed_pts_expr (income positive, spend negative) — the same convention the cell and the pts filter use; the four token columns use the same raw values as the front-end sortVal; model / key are byte-identical to tx_where's filter expressions. A sort that disagrees with what the user sees is the same class of defect as the one being fixed.
  5. Discrimination in both directions, with two instruments that have different blind spots. Compile gate: pre-fix tree (git archive 52aec72 src ui) → exactly the two declared tests red, everything else green; landed tree → 47/47. jsdom probe: pre-fix → the two global-extremum legs and the request leg fail; landed bytes → 7/7. The two competing "fixes" (caption the arrow " (this page)" and keep sorting the page; stop painting the arrow) are rejected by the same legs — so the fix direction is not merely a way to make the symptom go away.
  6. Regression surface. Full suite 342 passed / 0 failed (baseline 336: +3 wallet behaviour tests, +3 gate tests), cargo fmt --check clean, cargo clippy --all-targets -- -D warnings clean. No other table's sorting path is touched (serverSort is declared by exactly one call site; buildDataTable's other five tables keep the old behaviour).
  7. Honest scope. The gate is lexical (shape), the probe is behavioural (facts), and neither proves the other's half. The limit is written into ui/README.md next to the convention, not only into the test.

Known non-goals, deliberately untouched

  • The trend endpoint's own ORDER BY (time buckets — row order has no meaning there).
  • TX_COLUMNS' time entry has no filter/serverFilter (it was removed on purpose in an earlier round); it stays sortable, which is why the whitelist has 11 keys rather than the 6 filterable columns.

@argszero
argszero merged commit e1cd51b into main Sep 22, 2026
1 check passed
@argszero
argszero deleted the fix/tx-sort-describes-the-dataset branch September 22, 2026 01:30
@argszero argszero mentioned this pull request Sep 24, 2026
10 tasks
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