fix(ui): give the dashboard its own slot so the transaction cache has one writer - #250
Merged
Merged
Conversation
… one writer
`Live.transactions` — the transaction view's payload cache — had two writers.
`loadTransactions` writes it and, in the same function, records the validity
evidence (`txTable.loadedPage` / `loadedPageSize` / `loadedFilterSig`) that
`renderTransactions`' cache guard compares. `loadDashboard` also wrote it, for
the throwaway `/api/transactions?page=1&page_size=1` request it makes only to
read `total`, and touched none of that evidence.
So the guard compared the *other* writer's ledger and let a payload produced by
a different query through. Re-entering the transaction view (`renderView` renders
synchronously, then fetches asynchronously) made three things disagree at once:
- the table showed 1 row (`page_size=1`);
- the summary card was labelled "current filter" while showing the all-time
aggregate (that request carries no time range);
- the trend card claimed "trend data failed to load" — a request that was never
sent and never failed (`trend` is only attached by `loadTransactions`).
Fix: `loadDashboard` keeps its number in its own slot, `Live.tradeCount`, so
`Live.transactions` is back to a single writer.
A DOM probe alone cannot pin this: the competing fix — make the guard also
compare the payload's `page_size` — turns every DOM assertion green, because a
probe can prove the screen no longer shows the foreign payload but not that the
cache no longer holds it. `exportTxCsv` reads `Live.transactions.items`
directly, with no guard at all. `src/state_gate.rs` therefore pins slot
ownership statically (test-only module, zero new dependencies): it asserts
`writes(Live.transactions) == {loadTransactions}` and that this equals the set
of functions writing `txTable.loaded*`, that the dashboard still makes its count
query and keeps it in its own slot, and it self-checks its extractor and its
read/write discrimination with synthetic input.
A/B (jsdom probe: real `index.html` + the four real scripts, only `fetch`
stubbed, freezing the transaction view's own request to hold the mid-state):
- pre-change tree (byte-exact, md5 f919ae82…): gate red (2 of 4), probe red
`{B1,B2,B3}`, positive-control legs green;
- competing fix (expand the guard): gate still red, probe axis `{B1,B2,B3}`
green — the probe cannot pin the direction;
- fix: gate 4/4, probe 9/9.
`cargo test` 265 -> 269, `cargo fmt --check` clean, clippy unchanged (only the
pre-existing local `collapsible_match` false positive at `src/protocol.rs:662`).
argszero
added a commit
that referenced
this pull request
Sep 15, 2026
Ships the 18 PRs merged since v0.7.24 (#242-#259). Schema 14 -> 15 (two covering indexes, applied at startup). No config change, so no deployment-side config.toml edit is needed. Two themes: Perf on the NFS dev database - #259: stop mapping the db (PRAGMA mmap_size 64MB -> 0) and stop a real write per request (dao::touch_api_key gains a 60s guard). Measured on the live dev db: mmap=64MB 1.7-3.1s per COUNT / 250 MiB read vs mmap=0 ~10.5ms / 80 KiB; mmap=0 alone still leaves ~1.2s behind any write, so the pair is required. - #242: codify the two emergency indexes in a v15 migration and gate the conditional joins at the plan level. - #243: read the sharing page's earn total from one batched aggregate. Frontend: display must equal what it filters on, and one fact, one source - #250 one writer for the transaction cache; #251 clear every session slot at the identity boundary and give the wallet view a loader; #253 one shared writer for the wallet/dashboard month-changes; #254 boot loads only the destination view; #255 a model row's identity is the model, not its index; #256 the marketplace source follows the session, not whether data arrived; #257 the sidebar advertises only digits that work; #258 the admin total-balance card sums the gift amount its caption names. i18n - #249 every backend error reaches the wordlist, and the comment stripper stops mangling UTF-8; #252 the backend stops inventing Chinese display labels in response data fields. Forms and robustness - #244 a non-auth boot failure no longer looks like being logged out; #245 a credential 401 is no longer read as a session expiry; #246 wire timestamps reach the renderer unsliced; #247 inline cards submit from every field; #248 a market row's availability label comes from that row. - Cargo.toml / Cargo.lock: 0.7.24 -> 0.7.25. - CHANGELOG.md: v0.7.25 entry. - ui/index.html: cache-bust left as-is; the UI PRs in this release already advanced it past the value deployed with v0.7.24 (app.js 20260915-13, i18n.js 20260915-3). cargo test 288 passed; cargo fmt --check clean; clippy unchanged.
8 tasks
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.
Summary
Live.transactions— the transaction view's payload cache — had two writers:loadTransactions(which, in the same function, also records the validityevidence
txTable.loadedPage/loadedPageSize/loadedFilterSig) andloadDashboard(which records none of it).renderTransactions' cache guard compares only that evidence, so the dashboard'spayload — produced by a different query (
page=1&page_size=1, no time range,fetched only to read
total) — passed the guard and was rendered as if it werethe page's own. Re-entering the transaction view (
renderViewrenderssynchronously, then fetches asynchronously) showed three contradictory things at
once:
page_size=1);aggregate (that request carries no range);
never sent and never failed (
trendis only attached byloadTransactions).Fix:
loadDashboardkeeps its number in its own slot,Live.tradeCount, soLive.transactionsis back to a single writer.Related Issue
None — found by recon; the repository has no open issues.
Changes
ui/js/app.js—loadDashboardstores the transaction count in the newLive.tradeCountslot instead of writingLive.transactions;renderDashboardreads that slot.
Live's field table now says which of the two slots belongs towhich view.
src/state_gate.rs(new; test-only) — a static gate pinning slot ownership.src/main.rs— register the gate module under#[cfg(test)].ui/index.html— cache-bustapp.js?v=20260915-4.ui/README.md— new convention section ("one slot, one writer, and the writerrecords the evidence").
Tests
cargo test— 269 passed (was 265; +4 new gate tests)cargo fmt --checkcleanWhy a static gate and not just a DOM probe
The competing fix — make the guard also compare the payload's
page_size—turns every DOM assertion green. A probe can prove the screen no longer shows
the foreign payload; it cannot prove the cache no longer holds it.
exportTxCsvreads
Live.transactions.itemsdirectly, with no guard at all, so a pollutedcache still exports the wrong row set.
Evidence (A/B)
jsdom probe: real
ui/index.html+ the four real scripts, onlyfetchstubbed,freezing the transaction view's own request to hold the mid-state.
src/state_gate.rs)f919ae82…){B1,B2,B3}, positive-control legs green{B1,B2,B3}greenThe gate asserts
writes(Live.transactions) == {loadTransactions}, that thisequals the set of functions writing
txTable.loaded*, that the dashboard stillmakes its count query and keeps it in its own slot (so "just delete the request"
does not pass), and it self-checks its function-body extractor and its read/write
discrimination on synthetic input — calls,
Live.transactions.itemsreads,=== nullcomparisons and the guard's own!==comparisons must not count aswrites.
Checklist
fix/)