Repository navigation
fix(browse): offer Next past a remembered last page once the table's count moves - #1958
Merged
dawsontoth merged 17 commits intoOct 15, 2026
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request enhances the pagination and row navigation logic in DatabaseTableView by ensuring that the 'last page proof' (which prevents redundant probing of the next page) is invalidated if the total record count changes. The state variables knownLastPage and pendingRecordStep now track totalRecords alongside the page index, and inactive queries are cleared from the cache to guarantee fresh data when stepping between pages. Additionally, a comprehensive test suite has been added to verify these behaviors under various scenarios, and the design documentation has been updated accordingly. There are no review comments to address, and the implementation looks solid.
dawsontoth
force-pushed
the
claude/1940-last-page-proof-resets
branch
from
October 14, 2026 19:41
ae02e00 to
27cc6b4
Compare
dawsontoth
marked this pull request as ready for review
October 14, 2026 21:48
…unt moves The row editor's Next probes the next page past a full unfiltered page, and an empty answer is remembered (knownLastPage) so the dead probe isn't offered again. Only the view's own Refresh retired that proof, so after a window-focus refetch or an import's refresh Next stayed disabled on the last record even when records had been added. The proof is about how many records the table holds, so it now carries the record count the step set out with and holds only while the count is the same. A focus refetch or an import's refresh reads describe_table again; a count that moved voids it. A probe whose count moved while it was out proves nothing, so it neither stores a proof nor says "This is the last record". Reading a page doesn't, so the read on the way back from the probe keeps the proof however slow the probe. A step that turns the page now first drops the table's cached pages that aren't on screen, so it reads the next one afresh. Otherwise Next could reuse the probe's cached empty answer, or join a read of the page that started before the change, and prove the old last page again; and a step could open a row from a copy older than the page on screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…save each file on its own A save settled in its `mutate()` callback, which TanStack Query drops once the page unmounts, so a save that landed after you left kept the file marked unsaved and its stale copy could be saved back later. It now settles in the mutation's own `onSuccess`, from the request alone, and cancels a read of the file that was already in flight so it can't put the old contents back. A save that lands after sign-out cleared the caches settles nothing. The cached size becomes the UTF-8 length of what was written, and the cached time is dropped, since set_component_file reports neither. The .env secrets view's whole-file write now goes through the same save (`writeFile`, which rejects instead of toasting), so it is tracked, refused like any other, and settles after the page is gone. "Saving" was one flag for the whole page, so Save on another file did nothing while one saved. Saves are now tracked per file in the mutation cache, so another file stays editable and saves alongside, and a second save of the same file is refused with a toast when it held newer changes. A visual schema put back to the saved one during a save stays unsaved, so the save landing no longer replaces it. A save and a rename or move of an instance's files no longer overlap: a save that landed after the rename read the file dropped the saved text with the original. `useRenameFiles` refuses while an editor save is in flight, `saveFile` refuses while a rename is, and the Rename dialog is disabled during a save, so F2 and the tree's menu wait like the toolbar did. A folder rename holds off saves until its old folder is dropped too. Closes #1945 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aced with the same text The text editor dropped a model's history only when the text it was handed changed without being typed. Replacing the open file with the text it already showed (type B, save, upload B) changed nothing it was handed, and the reload can even land before the provider shows the contents missing. So Ctrl+Z brought back the earlier text and Save wrote it over the upload. editorPathState now counts each delete, replace and move away per path, and the text editor clears the attached model's history once the contents after a counted replacement are loaded, whatever the text. It tracks per model which file and count the history follows, so a switch to another file and back before the old model is disposed still clears it, and the same path on another instance, which shares the model, no longer undoes into this instance's file. Typing, saving and switching files keep their history. The editor also subscribes to its file's query for its read-only check: a reload that brings back the text the provider already holds changed nothing else it rendered from, which could leave it read-only. The test double now applies a `value` to a read-only editor with `setValue` even when the text is unchanged, as @monaco-editor/react 4.7.0 does, and a test on real Monaco pins that `setValue` with unchanged text drops the model's undo history. Closes #1951 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Browse asked four questions with window.confirm(): before a filter no index can answer, before Cleanup Orphan Blobs, before Delete Selected, and before the row editor's Previous/Next discards an unsaved draft. Each now asks in an AlertDialog. The full-scan one names the table(s) and the unindexed columns, with Scan anyway and Cancel. Nothing blocks while a dialog is open, so each question is a pending request holding what it would act on (the filter's conditions, the selected keys, the step's direction). In DatabaseTableView it also holds the view's context (instance, database, table, deep-link filters): the view stays mounted when the route moves to another table, and a request from elsewhere is cleared, not answered. The editor's question is cleared once its draft is gone, and Delete Selected's once the rows it named leave the screen. A yes clears the request and runs it once; Cancel or Escape leaves everything as it was. Applying or declining a filter now resets every filter input it doesn't name to empty. Left out of the reset, an input fell back to an earlier filter's value, which a declined deep link then sent with the next Apply. A filter an index can answer still applies at once, with no dialog and no change to applyFilters' synchronous shape. fullScanWarning becomes fullScanQuestion, which returns a title and a description. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cleanup Orphan Blobs is disabled while a cleanup is running, but its handler was on onClick, which Radix runs even for a disabled menu item; only CSS kept a pointer off it. A screen reader's activation or a synthetic click therefore still asked to start a second cleanup. The handler moves to onSelect, which Radix gates on disabled, as #1953 did for Export CSV. No other disabled-capable menu item under src/features/instance/databases uses onClick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tree and overview menus offered Drop Table whenever the database had more than one table, even for a table a refetch had just dropped from the map (another tab or user removed it while its menu was open). Like Add Records, Import and Export CSV since #1953, it now waits for the map to hold the table. The menu reads the map's own keys, so a table named `constructor` no longer finds Object.prototype's and counts as present. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ct member resolveDatabasesRedirect sends the view away from a database or table the map no longer holds, and that redirect is what keeps the table view's toolbar from offering Drop Table for a dropped table. It looked the names up with a plain property read, so a table named `constructor` or `toString` found Object.prototype's member, counted as present, and the view stayed on a table that was gone. It now checks own keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Browse looked tables and databases up in the describe_all map and the schema-relationship map with plain property reads, so a name such as `constructor` or `toString` found Object.prototype's member. Import Data then took a new table called `constructor` for an existing one and said nothing about creating it, and a relationship lookup could be handed a function where it expected an array. describedTable.ts adds ownEntry, describedDatabase, describedTable and declaredRelationships, which read own keys only. The table menus, the table view, the overview, Import Data, Add Records, the CSV export, the relationship lookup and resolveDatabasesRedirect now go through them. The redirect also treats a database entry with no describe as missing instead of throwing on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parseSchemaRelationships filled plain objects keyed by database and table names read from the instance's component schema files. A database named `__proto__` therefore wrote its tables onto Object.prototype, and one named `constructor` wrote them onto Object itself. Both levels of the map are now null-prototype objects, where those names are ordinary keys, and the query turns off structural sharing, which would rebuild a changed level as a plain object on the next refetch. ownEntry also returns undefined, not null, for an entry that holds nothing. The databases tree also reads each database's tables through describedDatabase, so every by-name read of the describe map in Browse goes through the own-key helpers, as DESIGN.md says. Listing works the same way: the tree, the overview, the redirect's fallback and both "last table" checks use describedDatabaseNames and describedTableNames, which name only entries that have a describe, so they agree with the lookups on an entry that has none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… servers, then forget them A self-hosted cluster's servers may outlive its removal from Fabric, so #1954 kept its remembered sign-ins rather than forget them with no logout. Removing one from Studio now ends those sessions instead. authStore.signOutEntities(ids) starts one best-effort logout per remembered id, built the way app sign-out builds them (the remembered credential, a 10s timeout, no response interceptors), forgets every id locally whatever the servers answer, and never rejects. terminateCluster calls it for a self-hosted cluster's own sign-in and each instance's once the DELETE succeeds, without making the removal wait. A Fabric cluster's terminate is unchanged, and a removed self-hosted cluster is still never cleared on render. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
onInstanceLogoutSubmit clears the entity's Basic Auth credential and Fabric Connect state once the logout answers. App sign-out and cluster removal forget the entity before they send, so by the time a late answer lands, anything recorded for it is a newer sign-in, and the clear wiped it: a new Basic Auth credential (from this tab or another), a new Fabric Connect flag, or the token of a Fabric Connect still verifying. sendBestEffortLogout now snapshots what the answer would clear when it sends: the stored Basic Auth credential and Fabric Connect flag, read raw from localStorage so another tab's writes count, and the connection generation, which moves when a Fabric Connect token is claimed. It hands onInstanceLogoutSubmit a signedInSince check, which skips the clear once the snapshot has changed and clears again otherwise. Explicit sign-outs pass no check and clear as before. signOutEntities also reads the sign-in markers from storage, not only this tab's copy, so removing a self-hosted cluster logs out a sign-in another tab made after this one loaded, and drops that sign-in's marker from storage too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arkers Each tab read Studio:PotentiallyAuthenticated once at startup and wrote that whole copy back on every sign-in and sign-out. That erased every marker another tab had added since. An app sign-out from the stale tab then sent those sign-ins no logout, and a sign-out of an entity only another tab held left its marker behind. - flagKeyAsSignedIn and flagKeyAsSignedOut now change one entity in the record as stored now, and leave the rest alone. A sign-in updates this tab's copy only once its write lands, so a refused write is retried at the next sign-in. - A passing check of a sign-in this tab already marked still writes nothing, and a failing one removes the marker only when it still holds the URL it checked. Neither undoes another tab's change. - When storage refuses to take a signed-out entity out of the record, this tab's later writes leave it out until one lands, instead of trusting or discarding the whole record. - App sign-out, the local full sign-out and signOutEntities act on this tab's markers and every stored one, and log out each URL an entity is marked at, since another tab may have signed in again at a newer one. - signOutEntities' forgetStoredMarkers is gone: flagKeyAsSignedOut now drops another tab's marker for the entity it signs out, so managed cluster termination, which signs out locally, reaches it too. - A stored record that is not an object is treated as unreadable. - DESIGN.md records what remains: storage has no atomic read-modify-write, so two tabs writing at once can still lose one change, and a marker removed elsewhere stays removed. Closes #1968 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The markers lived in one shared Studio:PotentiallyAuthenticated record, so every write read and rewrote the whole of it. Two tabs writing at once could still lose a change, a refused write could take every marker with it, and a full sign-out rewrote the record once per entity. - Each marker now has its own key, Studio:PotentiallyAuthenticated:<id>, holding the URL signed into. A sign-in or sign-out touches only its entity's key, and sweeps enumerate the keys by prefix. - On load, the legacy record is copied into per-entity keys once per storage, and a marker already stored per entity wins. A record that is not one (an array, or not JSON) is removed at every load, since the previous release cannot start on it. - For one release, every marker change is mirrored into the legacy record, and sign-out sweeps read it. A tab still on the previous release, or a rollback, then still sees this release's sign-ins and sign-outs, and an app sign-out here reaches a sign-in only such a tab recorded. The copy is never repeated: that tab writes its whole load-time record back, which would bring back what this release signed out. - A sign-in storage refuses to record no longer throws out of setUserForIdAndKey or a check. Its next sign-in or check records it. A marker storage would not remove is retried at every marker write. - A check's answer is dropped when this tab signed in again at another URL while the check was out. - Unit tests seed and read markers through signInMarkersTestHelpers, and the e2e anon specs through e2e/tests/signInMarkers.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng silently - The sign-out wipe keeps the migration flag, so a previous-release tab writing its record back after a full sign-out isn't migrated again. - Until a migration lands, as when a full quota refuses it, the legacy record also seeds this tab's markers, so its sign-ins still check out. - Migration decides from one read, and also drops entries that aren't markers from the legacy record. A sign-in leaves a legacy record it can't read alone instead of replacing other tabs' markers. - A sign-in always records itself, so signing in again restores a marker another tab removed; only a check's passing answer skips it. - A check's answer is dropped when this tab signed in again while it was out, at the same URL or another, using a per-entity sign-in revision. - A marker storage refused to remove is retried only while it still holds the value it had, so another tab's sign-in there survives, and a marker that couldn't be read at sign-out is still removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A failed check is a probe, not a sign-out: when it can't read the marker it leaves it, and a mirror write it can't make no longer takes the whole legacy record. - For the app sign-in, which every /User/current answer re-signs, those answers no longer write the marker, so they can't put back one another tab's failed check dropped. - A failing app check that was out during a new app sign-in is dropped, as an entity's already was. - This tab's own sign-in cancels a removal of its marker still owed from a refused write. - Migration checks its flag before the best-effort cleanup, so a refused cleanup on a migrated storage can't seed signed-out entities. The sign-out wipe marks the storage migrated, as nothing of the legacy record survives it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page-load app check still out when the user signed in again applied its answer for the old session over the new sign-in: getCurrentUser synced it into the store, and loadUser applied it again. - The store's app check now fetches through fetchCurrentUser, which doesn't sync, so loadUser applies the answer itself, and the sign-in revision guard drops a stale passing answer as it drops a failing one. - getCurrentUser syncs its answer only if no sign-in landed while it was in flight, using the store's new getSignInRevision. - DESIGN.md links #1970 for removing the legacy mirror. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- getCurrentUser syncs through the new syncAppUser, which writes no marker this tab already holds and leaves the sign-in revision alone. A sync landing during the store's own app check therefore no longer makes that check drop its answer and skip its sign-out re-ask. - setUserForIdAndKey treats every sign-in, the app's included, as one that always records itself. - A removal storage still owes is cancelled up front only when the kept marker already holds the URL being signed in, and otherwise once the new marker is written. - A migration flag that can't be read is reported as migrated, so a previous-release write-back can't seed that load. - getSignInRevision sits under its own doc comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dawsontoth
force-pushed
the
claude/1940-last-page-proof-resets
branch
from
October 15, 2026 13:33
27cc6b4 to
ac4e62e
Compare
dawsontoth
changed the base branch from
stage
to
claude/1939-import-outcome-unknown
October 15, 2026 13:34
dawsontoth
merged commit Oct 15, 2026
e7ae19f
into
claude/1939-import-outcome-unknown
8 checks passed
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.
⊙ Problem
In Browse, the record editor's Next checks the next page after a full page and remembers an empty result. Only the table's Refresh button cleared that result. After a window-focus refetch, an import finishing, or records added from another tab, Next stayed disabled on that last record until the user pressed Refresh.
🤔 Should the step's fresh read ride along?
The issue asks only to forget the last-page proof. That alone let Next reuse the probe's cached empty answer and prove the old last page again. A step that turns the page now drops cached pages and reads the next one afresh, one read per page turn. It also stops stale rows opening; this could be a separate change.
💡 Solution
🤔 Is the count a good enough signal?
For large tables
describe_tableonly estimates the count, so growth that leaves the estimate unchanged keeps Next disabled until Refresh. A count that first arrives or shrinks costs one more probe. Watching page reads instead can't tell a read on return from the probe from a real refetch.⚖️ Alternatives
dataUpdatedAtto the proof's reset dependencies: it differs per page, so the proof would clear when the step returns.🔧 Changes
DatabaseTableView.tsx: the proof records the count the step set out with and holds only while it matches. A step stamps that count; a probe whose count moved while it was out stores nothing and says nothing, and otherwise the proof keeps it. Before turning the page, a step drops the table's inactive cached pages, which also cancels a read of them still in flight (removeQueriesdestroys the query).DatabaseTableView.lastPageProof.test.tsx: it covers an import's refresh, a focus refetch, a count that moves on another page or while the probe is out, joining an older read, and a cached neighbour whose read succeeds or fails.DESIGN.md: the proof's contract and its known costs.✅ Verification
DatabaseTableView.lastPageProof.test.tsxkeeps React Query, the grid and the paging logic real. It mocks the instance transport, the editor, the router, auth, permissions and the column picker.3e9e1608. The eighth guards the slow-probe case and passes there too. Each change was reverted alone and made at least one case fail.935d1529: the pre-commit hook'svitest run(553 files, 5,496 passed),oxlint,dprint check --stagedandcommitlint, plustsc -banddprint check, all exit 0. One run hit an unrelated Radix timer error incardLinksDuringPendingNavigation.test.tsx; it passes alone, and the rerun was clean.Closes #1940
🤖 Generated with Claude Code by Anthropic Claude Opus 5.5; posted via @dawsontoth.
Related PRs: #1961 independent (both edit DatabaseTableView.tsx, in different hunks)
Complexity: medium
Deferred findings
a3b75b0e1d3e87f1nit correctness —src/features/instance/databases/components/DatabaseTableView.tsx— strict count equality retires a valid proof when the count first arrives or shrinks — filed Browse: keep a proven last page when the record count arrives or shrinks, not only when it's unchanged #1963Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; blocked=cursor-muse(no-receipt),claude(fallback)(out-of-budget); declined=cursor-grok,cursor-kimi,muse; rounds=11; full=5 @ 27cc6b4
Review-Attention: read ~3m (decisions: proof-bound-to-count, evict-inactive-table-prefix, tests-invalidate-keys-directly, evict-whole-table-prefix, count-stamped-proof, voided-probe-silent) @ 27cc6b4