Skip to content

fix(tabs): bind each tab to its own database instead of the sidebar's selection (#2026) - #2027

Merged
datlechin merged 3 commits into
mainfrom
fix/2026-tab-database-binding
Aug 6, 2026
Merged

fix(tabs): bind each tab to its own database instead of the sidebar's selection (#2026)#2027
datlechin merged 3 commits into
mainfrom
fix/2026-tab-database-binding

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #2026. Also covers the part of #2015 that survived its fix.

The bug

Three reports, one cause:

  1. Open table t on database A, switch the sidebar to B: the tab stays open (correct, per Query tabs are lost when switching schemas #1669/feat: close all the tabs #1972) but errors Table 'B.t' doesn't exist.
  2. Saving a table structure change jumps the sidebar and toolbar to the edited tab's database. This is Modifying table structure while multiple connections are open can cause connection corruption #2015, which 0.63.0 was supposed to fix.
  3. Found while investigating, never reported: row edits could commit into the wrong database silently.

Root cause

An operation was addressed by connectionId alone. A connection id does not say which database, so the database came from ConnectionSession.currentDatabase, one variable doing three unrelated jobs:

  • the user's browse cursor, which drives the sidebar list, the toolbar chip, favorites and recents, and the connection's persisted default,
  • the physical position of the connection's single shared driver (USE db),
  • the key every metadata read used to pick a pooled connection.

Tabs carry their own (database, schema). The two disagreed the moment the sidebar moved, and the app reconciled them backwards: it moved the global to satisfy the tab.

  • Reads had no scope at all. withMetadataDriver(connectionId:) hardcoded session.activeDatabase, so all 35 call sites read ambient state. TableStructureView was handed the tab's database and never used it for the fetch. That is symptom 1.
  • Writes to window state stood in for reads of tab state. Seven sites moved the cursor to satisfy a tab, four of them persisting it to disk. pinDatabaseBeforeSchemaChange, added by the Modifying table structure while multiple connections are open can cause connection corruption #2015 fix, never restored. That is symptom 2.
  • Writes had no binding at all. Row-edit commits, multi-statement runs and fetch-all ran unqualified SQL on the shared driver with no pin, and trackOperation is a counter rather than a lock, so even the pinned paths raced across windows. That is symptom 3.

The fix

A tab's target becomes a value, resolved once at creation and never re-derived.

DatabaseScope { connectionId, database, schema }. resolvedScope freezes a tab's identity the way resolvedSchemaName already did one tier down for schema (#1774). An empty database is a legal value meaning "the server", not "unbound", because a connection with no database selected is a supported state.

Then a split by operation kind:

  • Metadata reads take a pooled connection keyed by that exact tuple. MetadataConnectionPool was already keyed this way, which is why the Tree sidebar layout never had symptom 1. The connectionId-only overload is deleted, so every call site now states tab-scoped or browse-scoped: 17 each, plus one dialog-scoped.
  • SQL execution stays on the session driver, which holds the user's transaction, temp tables and the handle Stop cancels, behind SessionDriverGate, a per-connection FIFO mutex. The switch is issued every time rather than cached, because nothing tracks where the driver is: a reconnect, a Redis SELECT, another window or a typed USE all move it. The pin runs inside the gate and re-validates there, so a route decided before the caller queued cannot let a statement land on the wrong database, and a failed pin throws before the body runs.
  • The browse cursor now means two things and nothing else: what the sidebar lists, and which database a new tab opens in. The seven tab-driven writes are gone.
  • Refreshes carry a scope, so a save on a tab bound to A no longer refetches a window browsing B.

Cross-database tabs on PostgreSQL, Redshift and CockroachDB run on a pooled connection for their own database, so they work rather than silently returning another database's rows. They do not share temp tables, session variables or an open transaction with the query editor, which is documented. PGlite cannot open a second connection, so it names the database in an error instead.

Why a refactor and not a patch

Threading database: into the seven structure fetches makes the reported error disappear in about 20 lines. It leaves the data tab's schema fetch, three row-count paths, the column-scope cache key, fetch-all, the ER diagram, row-edit DML, multi-statement DML, column reorder, trigger DDL and create-table all ambient, and it fixes neither of the other two symptoms.

This is also the model every mature client uses. DataGrip's Database Explorer never retargets an open console, DBeaver makes "Link with editor" an explicit keystroke, TablePlus opens a new workspace, and Postico seeds only new tabs from the current database. Beekeeper Studio is the one window-scoped client and it produces this exact error string in an issue open since 2022.

Design invariant

A macOS window tab is a full NSWindow the user can tear out at any time via moveTabToNewWindow(_:), and userTabbingPreference decides whether an open even becomes a tab. So "Move Tab to New Window" must be semantically a no-op. scope(for:) reads no window, toolbar or coordinator state, and TabScopeIsWindowIndependentTests asserts it.

Also fixed along the way

  • Merely activating a tab rewrote the connection's saved default database on disk.
  • Hidden columns were silently skipped on any tab not on the browse database.
  • The schema-column cache was keyed on the sidebar, so two tabs on same-named tables in different databases shared one entry.
  • Query history attributed a query to the browsed database rather than the one it ran on.
  • MetadataConnectionPool.runSerially awaited an unstructured task, so cancelling the caller neither cancelled nor propagated. That is the shape behind Every 2nd Refresh cancels the Query #2021, and it matters now that user queries run there.
  • Following a foreign key from a tab on A opened the target on the browsed database.
  • MCP and AI chat tools moved the user's sidebar and rewrote the saved default just to scope a read.

Testing

New: DatabaseScopeTests, SessionDriverGateTests, TableStructureLoaderScopeTests, ScopedDriverRoutingTests, DataRefreshScopeTests.

TableStructureLoaderScopeTests.everyReadUsesTheTabsDatabase is the test that would have caught this: browse cursor on B, loader on A, every recorded scope must be A and browseScope must never be consulted.

DatabaseManagerSchemaChangeRoutingTests asserted the bug as correct behaviour (currentDatabase == "orders" after a save on an inventory-cursored session). That assertion is inverted, and the fixture now uses three distinct values so "the saved default was not rewritten" is a real assertion rather than a tautology.

Verification

swiftlint lint --strict passes across all 1244 files, so everything parses. The project was not built, so type checking is unverified. Please build before reviewing in depth and I will fix whatever the compiler reports.

A read-only audit was run over the whole diff before opening this. It confirmed 24 findings (3 refuted), all fixed here. The ones worth knowing about:

  • Two real compile errors: the database tree still called the pool's deleted signature.
  • Run did nothing on a connection with no database selected, a legal MySQL state. An empty database is now a server scope rather than an unbound one.
  • Stop cancelled the wrong handle: one tracking slot per connection was being written by every metadata read. Only user SQL registers now, keyed per operation.
  • The original bug relocated into the sidebar: the object list, procedures, functions and autocomplete still took the raw shared driver, which a tab's execution now moves and never restores.
  • A restored table tab lost its saved sort and page when the connection was still connecting, because the deleted switch was the only path into the first-load work.
  • A browse switch landing between the route decision and the lease could still write to the other database. The pin now re-validates inside the gate, and the browse switch takes the same gate.

Not in this change

  • SchemaService is still keyed by connection alone, so two windows on one connection cannot list different databases. Connecting to a new schema will retain the tabs from the previous connection #2026 is fixed without it.
  • DatabaseManager.switchDatabase should become selectBrowseDatabase to match the renamed state it writes.
  • Import execution still reads the ambient driver.
  • Streaming export resolves the browse scope rather than the exporting tab's, because ExportMode carries no tab identity.
  • MySQL's driver ignores the schema: argument and reads its own active database, so export reads on that family follow the pinned driver rather than a per-table override. That is a plugin-side fix.
  • The ER diagram recovers its schema by decoding the composite schemaKey, because an ER tab never stores a schema on its tab context.

https://claude.ai/code/session_018VQbD2VKEBFifVpa9ojAYQ

@mintlify

mintlify Bot commented Aug 6, 2026

Copy link
Copy Markdown

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

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Aug 6, 2026, 12:19 PM

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

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin
datlechin merged commit 204e82b into main Aug 6, 2026
1 of 5 checks passed
@datlechin
datlechin deleted the fix/2026-tab-database-binding branch August 6, 2026 16:07
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.

Connecting to a new schema will retain the tabs from the previous connection

1 participant