Skip to content

fix(tabs): reload only the tabs showing a changed table, never interrupt one holding edits, and read SQLite result columns after the first step - #3148

Merged
datlechin merged 1 commit into
mainfrom
fix/refresh-written-table-tabs
Sep 26, 2026
Merged

datlechin merged 1 commit into
mainfrom
fix/refresh-written-table-tabs

Conversation

@datlechin

Copy link
Copy Markdown
Member

Root cause

The table a change named was used to pick which tabs to reload, but only by the object channel. Everything else used AppCommands.refreshData, and each window answered it by reloading its selected tab, whatever table that tab showed. That covered row import, Create Table, structure saves, rebuilds, column moves, trigger edits and Insert Document. Before reloading, the window committed any half-typed cell and asked "Discard Unsaved Changes?". On confirm it also cleared the window's sidebar-staged drops and truncates. Background tabs on the changed table were never visited. They kept their rows, and canAutoLoadTableTab refused to reload them when shown, because they had rows and had already run.

The object channel (DatabaseObjectChange.rows, used by Refresh Materialized View) reached background tabs only through memory eviction. Eviction discards rows and refuses a tab whose last result was empty. Its selected-tab path went through handleRefresh, which prompted, and which in Structure mode refreshed only the structure. So switching back to Data showed the old rows and columns.

A grid Save announced nothing, so a second tab on the same table, in this window or another, kept the pre-save rows.

A reload after a structure change reused the metadata the tab already held. isMetadataCached only asked whether the tab had metadata, never whether the definition had changed since. So the old primary key, defaults, nullability, generated columns and row-match policy were applied to the new columns. When the new result reported no primary key, the old one was carried over, so a dropped key survived and the next edit was built against it.

A tab's column-scoped SELECT is built from a cached column list before anything fetches the table. A SQL file import, a new enum label and a session context switch never dropped that list, before this branch or after it. So a tab with hidden columns reloaded with a select list that named a dropped column or left out an added one. A load that started before a second definition change could also write its old column list back into the cache after the change had dropped it.

A row count that started before a change could finish after it, while its tab was in the background, and put the old total back. The reload that answered the change kept that total. Above the automatic-count threshold it skipped counting, so Next and Last stayed bounded by the table as it was.

A structure session left on DDL or Triggers was marked stale, but its next mount fetched only columns, indexes, foreign keys and checks. The mount does not change the selected sub-tab, so nothing fetched the one on screen. Applying staged edits from a background tab had the same gap before this branch.

The selected tab put a reload off while a cell editor or viewer was open, or while it held edits, and nothing asked again once they were gone. Closing a read-only viewer, cancelling an editor with Escape, or discarding the edits left the tab showing old rows until Cmd+R or a tab switch.

The SQLite UI test exposed one more cause. SQLiteLocalBackend read column names right after sqlite3_prepare_v2, which compiles against the connection's cached schema. Only the first sqlite3_step notices that another connection altered the table and prepares the statement again. The structure editor alters on a pooled connection, so the first query on the session connection after every structure save named the old columns and dropped the new column's values. libSQL's local backend had the same read.

Fix

  • TableFreshness on each TabSession: a stale mark separate from eviction. It keeps the rows, and a tab with no rows can be marked.
    • Each change is stamped when it is made, after its write (DatabaseObjectChange.changedAt, DataRefreshRequest.changedAt).
    • A committed read clears only changes stamped at or before the moment its query claimed the tab, so a load that started before the write leaves its tab marked.
    • A definition change clears only on a read that also fetched the definition.
    • It reports whether a read answered a rows change, and which change a tab still owes (pendingChange).
  • canAutoLoadTableTab treats a stale tab like an evicted one, after its error and pending-edit guards.
  • TableRowsRefreshPlan: a pure planner. It marks every addressed table tab and leaves out the tab that made the change when that tab reloads itself. It reloads the selected tab only when that tab holds no edits and has no cell editor or viewer open.
    • A running load is left alone only when it claimed the tab after a rows change.
    • A load that started earlier is stopped and started again. So is any running load after a definition change, since it chose its metadata before the mark existed.
    • A load not yet claimed, and Fetch All, are left alone.
    • A Structure-mode tab reloads behind the structure view.
  • MainContentCoordinator.refreshTableTabs, applyDataRefresh and applyObjectChange share that plan, and none of them asks a question.
  • resumeDeferredTableRefresh() runs a reload the selected tab put off, once what was in the way is gone. It asks the plan again (TableRowsRefreshPlan.action(for:state:owing:)) with the change the tab still owes, so a new overlay, a staged edit or the tab's own load still stands in the way.
    • It runs one turn after a cell editor or viewer closes (DataGridViewDelegate.dataGridDidCloseCellOverlay). An editor records its commit after it removes itself, which is why it waits a turn.
    • It also runs after Discard Changes. It starts nothing while the window is being torn down.
  • New DatabaseObjectChange.Kind.structure, sent by structure save, rebuild, column move and trigger create, edit and drop for their one table. It marks a definition change and marks clean background structure sessions stale.
  • Every definition change drops the cached column list of each tab it reaches before any reload builds its SQL.
    • The broad DataRefreshRequest names no table, so it drops the whole cache.
    • A load writes the definition it fetched into the cache only when it started after the tab's last definition change (TableFreshness.definitionIsCurrent).
  • After a definition change, isMetadataCached answers false. The reload waits for the table's schema and commits it with the rows in phase 1, and the same fetch refills the column list.
    • Primary keys go through QueryExecutionCoordinator.resolvedPrimaryKeys, which never carries the old keys across a definition change.
  • The read that answers a rows change retires the tab's derived total in applyPhase1Result, before phase 2 counts. That reload's claim already cancels and fences any older count, so nothing can put the old total back after it.
  • StructureEditingSession.tabsFetchedOnMount lists the sub-tabs the change manager is baselined from, then the selected one, and loadInitialData fetches that list.
  • .rows is now sent by row import, Insert Document, Create Table and the grid Save.
    • Save covers the tables it wrote and truncated, and leaves out the saving tab when that tab reloads itself.
    • Create Table stamps its change before it opens the new tab, so that tab's first load is not started twice.
  • The broad DataRefreshRequest is kept for SQL file import, session context switches and enum labels. It marks a definition change, because each of those can change one.
  • isApplyingStagedStructureEdits is removed. It existed only to stand down against the apply's own broad broadcast, which no longer exists.
  • SQLiteResultColumns.stepFirst reads column names and declared types after the first step.
    • It lives in TableProSQLiteCore and imports CSQLite, never the SDK's SQLite3, whose link "sqlite3" put macOS's library on the plugin's link line.
    • The SQLite plugin (runStatement, streamQuery) and the libSQL local backend both use it.

Verified

  • Build: TablePro and AllPlugins (all 40 plugins) PASS.
  • Unit tests: 366 cases across 35 suites passed.
    • Touched: TableFreshnessTests, TabSessionRegistryTests, TableRowsRefreshPlanTests, CatalogChangeWindowTests, DataRefreshScopeTests, StructureEditingSessionTests, KeyHandlingTableViewOverlayTests.
    • Neighbours include MainContentCoordinatorLazyLoadTests, DatabaseManagerSchemaChangeRoutingTests, ResolvedPrimaryKeysTests, CellOverlayEditorMovementTests, ValueFilterChangeGuardTests, Phase2RowCountGuardTests, SchemaColumnStoreTests, DatabaseObjectToolsTests, OpenTableTabTests, SQLiteResultColumnsTests and VendoredSQLiteImportTests.
  • Mutation checks. Restoring the old behaviour of each fix turned exactly its new tests red:
    • All four review findings at once: 47 cases, 5 failed. Those were refreshForgetsCachedColumnsBeforeTheReload, answeringReadRetiresALateTotal, deferredChangeReloadsOnceTheEditsAreGone, mountFetchesTheSelectedSubTab and closingAnOverlayTellsTheOwnerOnTheNextTurn.
    • Letting a pre-change definition into the column cache: aPreChangeDefinitionStaysOutOfTheColumnCache failed.
    • Resuming during teardown: resumingDuringTeardownReloadsNothing failed.
  • UI tests, TableChangeReloadUITests (SQLite sample, fixture re-seeded at each launch), 5 of 5 passed on the final code:
    • A save in a second tab reaches the first tab on the same table without Cmd+R, and the first tab keeps its own sort.
    • A column added in the Structure view shows in the Data view without Cmd+R.
    • A column added from a second tab shows in the DDL the first tab left open. With the mount fix reverted, this case failed and the other two passed.
    • A column added from a second tab while the first tab holds a staged column: the first tab keeps its staged column, and shows the other tab's column once its own is removed.
    • Undoing the last edit in one window reloads rows another window saved meanwhile.
  • StructureTabIdentityUITests, StructureForeignKeyEditUITests, SidebarTableTabUITests, InspectorEditReachesGridUITests and ValueFilterEditUITests: 7 of 7 passed. They ran before the last two small edits, a parameter refactor and the teardown guard.
  • Link, measured on the built plugins:
    • Neither SQLiteDriver nor LibSQLDriverPlugin has a libsqlite3 load command.
    • sqlite3_step and the column functions resolve inside each binary as non-external symbols from the vendored archive.
    • Each links SQLiteResultColumns.stepFirst.
  • swiftlint lint --strict on the 48 Swift files in the commit: 0 violations. verify.sh docs passed.
  • Codex review, three rounds, every finding fixed here. Round 3 found that a structure change arriving while edits were staged was dropped rather than owed (now owed and fetched once the edits are applied, removed or discarded), that an unmounted Structure view missed the broad refresh, and that a reload put off for edits did not resume when those edits were undone.

Deliberately not fixed here

  • The libSQL change ships with that plugin's next registry release, not with the app.
  • Statements run from a query tab, and MCP or AI writes, announce nothing. The docs say to press Cmd+R.
  • Row import and SQL file import announce only on success.
  • Fetch All that is running when its table changes is left to finish. Its rows are from before the change, and the tab stays marked until its next query.
  • A tab that holds edits while a load it started before a structure change completes keeps that load's old metadata until its next query, which then fetches the definition.
  • DatabaseObjectChange.matches still compares the stored schema, not the resolved scope.

No UI test for the MongoDB two-window flows: CI has no MongoDB server. No UI test for a reload resuming when a cell overlay closes: an overlay closes as soon as its window loses key or its tab is switched, so only a change that finishes in the background, such as a long import, lands while one is open, and no deterministic UI flow reaches that. Grid and coordinator unit tests cover both.

…upt one holding edits, and read SQLite result columns after the first step
@mintlify

mintlify Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 26, 2026, 4:16 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

This branch was successfully deployed

1 active deployment
staging - docs — fa99fb0d Deployed Sep 26, 2026 by mintlify[bot]
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