Skip to content

feat(postgres): browse multiple databases on one connection - #822

Draft
aesslinger wants to merge 45 commits into
TabularisDB:mainfrom
aesslinger:feat/postgres-multi-database-v2
Draft

aesslinger wants to merge 45 commits into
TabularisDB:mainfrom
aesslinger:feat/postgres-multi-database-v2

Conversation

@aesslinger

@aesslinger aesslinger commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Closes #340. Addresses #725.

What this does

PostgreSQL connections can now opt into browsing multiple databases from one connection, the same way MySQL already does. Pick databases in the connection dialog's "Databases" tab and each one shows up as a top-level node in the sidebar expanding into its own schemas and tables. All browsing, mutation, and tooling actions are fully wired: Create/Drop table, column, index, FK, view, trigger; Run/Edit/Drop routine; Edit/Drop trigger; New Console; ER diagram with in-window schema switcher; Dump/Import. The ER diagram window shows a schema-picker dropdown so users can switch which schema they're viewing without reopening. Every one of these actions now has the same feature parity in the nested database tree that it already has for single-database and flat (MySQL-style) multi-database connections.

This builds on work started in #402, re-scoped against the current plugin architecture rather than the deprecated in-tree driver. The plan was shared with @debba before starting: #402 (comment)

How it works

Capability model. A new hasOptedIntoDatabaseSelection helper distinguishes "user explicitly opted into multi-database browsing" from "this connection just has a database set." MySQL uses capability alone as the gate (unchanged); Postgres requires an array or empty-string in the persisted database field as the opt-in signal, so no existing single-database connection is silently reclassified. isMultiDatabaseCapable (MySQL) is untouched. A new isSchemaBasedMultiDb/isSchemaBasedMultiDbCapable pair (src/utils/database.ts) identifies the nested (schema-per-database) case specifically, distinct from the flat multi-db case, so connection-level chrome (like the header's Import/Dump/ER-diagram actions) only shows where it's actually wired to work.

Backend. A database: Option<String> override — cloning ConnectionParams, filtering empty strings, overriding .database — is applied uniformly across every Tauri command that needs to target a specific database: table/column/index/FK/schema/view/trigger/routine metadata, query execution, explain, count, export, dump/import, ER diagram snapshot, and all DDL mutation commands. The same coercion is applied on the plugin RPC path (RpcDriver::with_primary_database) across every JSON-RPC call the driver makes, not just connection-test calls, so plugin-backed Postgres drivers get the same per-database targeting as the builtin client. Schema selection and preference persistence is scoped per-database via a composite storage key.

Dump/import works for plugin-backed drivers too. dump_database's table-listing and DDL-fetch previously matched driver.as_str() against hardcoded "postgres" strings, returning "Unsupported driver" for the plugin-registered "postgresql" driver id. Table listing and DDL now route through DriverTrait (get_tables, get_table_ddl — now implemented plugin-side in tabularis-postgresql-plugin#119). The data-export and import steps are now generalized too: for any driver that isn't one of the builtin SQL clients, export paginates through drv.execute_query/get_columns over the RPC boundary (tracking JSON/JSONB columns separately so their values are still escaped correctly instead of losing type fidelity), and import replays each parsed statement individually through drv.execute_query (non-atomic, since there's no cross-plugin transaction RPC). Several other driver == "postgres" literal-string checks in the dump path were widened to also match "postgresql".

Postgres trigger SQL dialect. Postgres has no inline trigger body — a trigger needs a backing function. src/utils/triggerSql.ts builds both statements (CREATE OR REPLACE FUNCTION ... RETURNS TRIGGER ... LANGUAGE plpgsql and CREATE TRIGGER ... EXECUTE FUNCTION), schema-qualifying the function name consistently in both since the two calls don't share a search_path. TriggerEditorModal executes the function-creation statement before calling create_trigger.

ER diagram works even when a plugin doesn't implement batch schema metadata. RpcDriver::get_schema_snapshot now falls back to composing the snapshot from get_tables/get_columns/get_foreign_keys when a plugin doesn't implement the batch RPC (tabularis-postgresql-plugin#121 tracks a real plugin-side implementation for better performance). The frontend also now surfaces schema-load errors instead of silently leaving the canvas blank.

Frontend. DatabaseProvider gained a nestedDatabaseDataMap and a connect() branch, with all writes going through a setState-based updater to avoid stale-closure races between concurrent per-database loads. A new SidebarNestedDatabaseItem renders each selected database's own schema picker, wires New Console and View ER Diagram actions, and reuses SidebarSchemaItem per schema (including its own New Console action). All 7 mutation modals, DumpDatabaseModal, ImportDatabaseModal, and the ER diagram window route correctly using both schema and database. Tab/AddTabInput/EditorNavigationRequest carry a new database field threaded through databaseObjectActions.ts and useDatabaseObjectNavigation. Refresh-after-mutation logic (create/drop view, trigger, routine, table) was extended to disambiguate nested (real database+schema), flat multi-db (schema field reused for a database name), and plain single-schema cases.

Follow-up local code-review pass. After the manual walkthrough above, a local multi-angle code review of this branch's diff surfaced several remaining gaps in database-override coverage that the manual test plan didn't happen to exercise: a tab's database field wasn't persisted across app restart (tabCleaner.ts), two RpcDriver call sites (get_table_query_template's RPC, and the metadata snapshot for_connection hands to call_with_connection) skipped the with_primary_database coercion every other call site has, the get_table_query_template Tauri command had no database parameter at all, and GenerateSQLModal never threaded a table's database into its metadata/template fetches or its cache key. Each is fixed with its own commit and a regression test that fails without the fix.

One of these follow-ups (BLOB download/preview for a table in a non-primary database) turned out to be a symptom of something broader: DataGrid never received schema/database as props at all — it read the connection's globally active schema from context instead of the specific tab's own schema. That's a latent bug independent of multi-database work (a single-schema-picker connection with two tabs open on different schemas could already misdirect a BLOB fetch if the sidebar's schema selection changed while a background tab was open), which this PR's database field surfaced rather than caused. Rather than bolt on a database-only patch beside a still-wrong schema path, both are being threaded properly as explicit props through DataGrid → RowEditorPanel → FieldEditor → BlobInput, sourced from the active tab.

Verification

Automated:

  • cargo test --lib and cargo test --test postgres_integration -- --ignored (live Postgres 16 container) pass, including tests added in this branch for the database-override routing, the JSON/JSONB-aware dump formatting, and the get_schema_snapshot fallback.
  • pnpm vitest run, pnpm run build, and pnpm lint are clean, including new tests for triggerSql.ts, the nested-tree New Console/ER-diagram actions, and the schema-list force-refresh fix.

Manually verified against a real Postgres 16 instance + the plugin-backed driver, on this branch:

  • Created a connection, opted into 2+ databases via the Databases tab — sidebar shows nested database → schema → table tree
  • Browsed tables/views/routines/triggers in a non-primary database
  • Created and dropped table, column, index, FK, view, trigger in a non-primary database's schema
  • Committed edits (Ctrl+S), exported CSV/JSON, dumped, and imported from a non-primary database
  • Opened ER Diagram from the nested tree; schema picker in the window switches schemas (store → analytics) without reopening
  • Existing single-database Postgres connections are unchanged (covered by automated suite; not separately re-walked manually this round)
  • MySQL multi-database flow is unaffected (covered by automated suite; not separately re-walked manually this round)

Bugs found during this manual pass were fixed directly in this branch rather than deferred, since they were feature-parity gaps between the new nested tree and the existing single-db/flat-multi-db behavior: refresh-after-mutation scoping, the trigger SQL dialect issue, the New Console and ER diagram actions missing from the nested tree, a permanent no-op on the nested tree's schema-list refresh button, and the ER diagram blank-canvas issue above. A plugin-side stub (get_schema_snapshot) that the host now works around is filed as tabularis-postgresql-plugin#121; a related plugin-side trigger validation gap is filed as tabularis#837 (fixed as part of this same branch since it surfaced during this testing pass).

…ow schema-based multi-db in connection UI

Stage 1-2 of closing TabularisDB#725/TabularisDB#340: add a database: Option<String> override
(mirroring the pattern already used by insert/update/delete_record) to
get_schemas, get_tables, get_columns, get_foreign_keys, get_indexes,
execute_query, execute_query_batch, explain_query_plan, and count_query,
plus a missing .filter(|d| !d.is_empty()) guard on the three existing
overrides. Add isSchemaBasedMultiDbCapable/isSchemaBasedMultiDb to
src/utils/database.ts and wire NewConnectionModal's Databases tab/picker
to schema-based drivers (Postgres), not just MySQL-style flat drivers.
…chema-based multi-db drivers

Stage 3 of closing TabularisDB#725/TabularisDB#340. Adds isSchemaBasedMultiDbCapable/isSchemaBasedMultiDb
and hasOptedIntoDatabaseSelection to src/utils/database.ts, a NestedDatabaseData
shape in DatabaseContext.ts, and DatabaseProvider.tsx provider functions
(loadNestedSchemas, setSelectedSchemasForDatabase, loadNestedSchemaData,
refreshNestedSchemaData) plus a connect() branch that pre-loads the first
database's schema/table data for a connection with an explicit multi-database
selection.

The critical fix: capability alone can't gate this the way it does for MySQL,
because every existing Postgres connection already stores a required non-empty
database string from the traditional single-database mode — identical in shape
to a one-element selection. hasOptedIntoDatabaseSelection requires an array (any
length) or an empty string ("all databases") for schema-based drivers, so a
plain string always means "the one database this connection always pointed
at." NewConnectionModal.tsx's save/edit/parse paths were updated to match
(never collapsing a single Databases-tab selection to a plain string for
schema-based drivers). Backend: extend the database: Option<String> override
already used by insert/update/delete_record to get_schemas, get_tables,
get_columns, get_foreign_keys, get_indexes, get_views, get_materialized_views,
get_triggers, get_routines, get_routine_definition, get_trigger_definition,
execute_query, execute_query_batch, explain_query_plan, and count_query
(discovered while wiring the sidebar's click-to-open path); scope schema
selection/preference persistence (set/get_selected_schemas,
set/get_schema_preference) per-database via a composite storage key so two
databases on the same connection don't share one selection.

Frontend: a new SidebarNestedDatabaseItem renders each selected database's
own schema picker and, once schemas are selected, a SidebarSchemaItem per
schema (same component the single-database layout already uses). Tab/
AddTabInput/EditorNavigationRequest gained a database field alongside schema,
threaded through databaseObjectActions.ts and useDatabaseObjectNavigation so
double-clicking a table/view/routine/trigger opens the right connection pool.
Table/view/routine/trigger CRUD actions (create/drop table, index, foreign
key, trigger) are intentionally not wired for the nested tree yet — surfaced
via a translated "not yet supported" message instead of a silent no-op or a
half-wired backend call; that work is scoped to the routing-correctness pass
next (PR TabularisDB#402's review checklist, re-verified against today's plugin
architecture).
…paths through database

Stage 4 of closing TabularisDB#725/TabularisDB#340 — PR TabularisDB#402's review checklist, re-verified against
today's code and fixed:

- getTableDataChangeScope gains a tabDatabase param and a first branch that
  returns {database, schema} together for a nested tab; a plain single-db
  Postgres tab (no tab.database) still falls through to the unchanged
  schemas-only branch, so the commit-path regression TabularisDB#402's reviewer caught
  can't recur here.
- execute_query (all three call sites), execute_query_batch, count_query, and
  export_query_to_file now pass tab.database (nested) ahead of the existing
  schema-as-database fallback (flat MySQL), instead of only ever resolving one
  qualifier.
- Visual EXPLAIN (useExplainPlan, VisualExplainModal, Editor.tsx) gained a
  database field alongside schema, threaded through to explain_query_plan.
- Sidebar context menus (table/index/foreign_key/view/materialized_view/
  routine/trigger/column) now carry `database` in ContextMenuData and forward
  it to every action — drop_index_action, drop_foreign_key_action, drop_view,
  drop_trigger, refresh_materialized_view, get_view_columns,
  get_materialized_view_columns, get_routine_parameters, get_routine_definition,
  get_trigger_definition, and the objectNavigation.open/count/newConsole/
  openRoutineDefinition/openTriggerDefinition chain all gained a database
  override (mirroring the pattern from Stage 1). Also fixed a pre-existing bug
  where "Drop View" ignored the view's own schema in favor of the global
  active schema.

Still not wired for the nested tree (unchanged from Stage 3, tracked as
follow-up): Run/Edit/Drop Routine and Edit/Drop Trigger still resolve their
target through modal state that doesn't carry a database field yet; the ER
diagram window and Dump Database modal don't have a schema/database picker of
their own. None of these regress existing single-database Postgres or MySQL
behavior — they're simply not yet reachable with the right routing from a
nested database's context menu.
…mbined data-change scope

Stage 5 of closing TabularisDB#725/TabularisDB#340.

- database.test.ts: getTableDataChangeScope's new combined {database, schema}
  branch (nested tab), its fallback to the active schema, and the two
  regression guards — a plain single-db Postgres tab (no tab.database) keeps
  returning {schema} only, and a flat multi-db driver (MySQL) ignores
  tabDatabase entirely since it never sets it.
- DatabaseProvider.test.tsx: a new "PostgreSQL Nested Multi-Database
  Selection" describe block exercises connect() end-to-end for a connection
  saved with an array `database` (the opt-in signal) — asserts
  nestedDatabaseDataMap gets the first database's schemas/tables pre-loaded
  with schema+database routed correctly to get_schemas/get_tables, that a
  database with no saved schema selection is marked needsSchemaSelection,
  and — the regression guard — that the connection-level (non-nested)
  schemas/selectedSchemas/schemaDataMap fields stay completely untouched.

Full suite: 5000 frontend tests passing, 1319 backend tests passing
(excluding 3 test modules — theme_packages, askpass, storage_location_tests —
that fail identically on a clean upstream/main checkout in this environment;
confirmed via git stash comparison, unrelated to this branch).

Not done in this pass, needs a human with the app running against a real
Postgres + the tabularis-postgresql-plugin: clicking through the actual
nested sidebar tree, exercising every Stage 4 checklist row end-to-end, and
confirming a plain single-database Postgres connection's UI is pixel-for-
pixel unchanged.
…ryEntry union

`ContextMenuData` includes `QueryHistoryEntry`, whose `database` field is
typed `string | null` (not `string | undefined`). Every
`"database" in contextMenu.data ? contextMenu.data.database : undefined`
extraction across the union therefore inferred as `string | null | undefined`
instead of `string | undefined`, which `objectNavigation.open/count/newConsole`
and `hasOptedIntoDatabaseSelection` don't accept.

`pnpm typecheck` (`tsc --noEmit`) didn't catch this because the root
tsconfig.json has `files: []` and relies on project references (`tsc -b`) —
running plain `tsc --noEmit` from the root checks nothing. Only `pnpm run
build` (`tsc -b && vite build`), which is what CI's `test` job actually runs,
exercises real typechecking. Confirmed `pnpm run build` is now clean.
… commands

Stage 6 (backend half) of closing PR TabularisDB#822's remaining gaps.

Added the standard database: Option<String> override to the 7 commands that
actually perform a live query/execution for view/trigger/routine mutations:
get_view_definition, create_view, alter_view, create_trigger,
get_routine_edit_script, drop_routine, get_materialized_view_definition (the
last one fixes a latent bug from PR TabularisDB#822's Stage 4 — ExplorerSidebar.tsx
already sends `database` to this invoke call, but the backend silently
dropped it since the param didn't exist).

Deliberately did NOT touch get_create_table_sql, get_add_column_sql,
get_alter_column_sql, get_create_index_sql, get_create_foreign_key_sql,
build_routine_call_sql, or get_routine_create_template — these are pure SQL-
text generators that never touch a ConnectionParams/live connection (verified
against each driver's trait impl; several don't even take `params`, and the
ones that do leave it unused, prefixed `_params`). A database override there
would be a no-op parameter with no effect. Only the schema for the identifier
text matters, and that already gets threaded via the frontend modal changes
in this stage.
…abase aware

Stage 6 (part 2) of closing PR TabularisDB#822's remaining gaps.

CreateTableModal already had an explicit schema prop; added database
alongside it and a new CreateTableTarget "nested" kind (schema + database
together, distinct from the existing "database" kind which is flat-MySQL
where schema holds the database name).

ModifyColumnModal, CreateIndexModal, CreateForeignKeyModal had NO explicit
schema prop at all — they pulled activeSchema from connection context, which
already mistargeted a non-active schema in today's single-connection
Postgres tree (e.g. right-clicking a table in a schema other than the one
currently expanded). Added explicit schema/database props to all three,
falling back to the context value only when omitted, and threaded database
into every live invoke() call inside them (get_columns, get_tables,
execute_query) — the pure SQL-text generators (get_create_index_sql,
get_create_foreign_key_sql) don't need it, confirmed against their driver
trait impls.

ExplorerSidebar.tsx: threaded schema (single-schema branch) / dbName (flat
multi-db branch) into every onAddColumn/onEditColumn/onAddIndex/
onAddForeignKey modal-opening call that was missing it, and did the same for
the folder_indexes/folder_fks context-menu entries which read tableName from
contextMenu.data but never its schema/database. The plain single-connection
flat branch is unchanged (no schema concept for those drivers).
…nd their context-menu wiring

Stage 6 (part 3) of closing PR TabularisDB#822's remaining gaps.

ViewEditorModal, TriggerEditorModal, RunRoutineModal gained a database prop
alongside their existing (or newly explicit) schema prop, threaded into every
live invoke() call inside them (get_view_definition, execute_query preview,
create_view, alter_view, get_trigger_definition, drop_trigger, create_trigger,
get_routine_parameters).

ExplorerSidebar.tsx: the routine/trigger context-menu blocks already
extracted routineDatabase/triggerDatabase (PR TabularisDB#822) but dropped them when
opening these modals — now threaded into setRunRoutineModal, the
get_routine_edit_script invoke, setRoutineDropConfirm, and
setTriggerEditorModal (previously only drop_trigger used it). Widened the
shared runQuery() helper with an optional database param so "Edit Routine"
and "Run Routine" open their console tab against the right database too.

Known, pre-existing, unchanged limitation: "New Routine" (routines-new
context menu, triggered with no contextMenu.data at all) still always
targets the connection's global active schema regardless of which schema's
folder was right-clicked — not a regression from this branch, left as-is.
…/FK/view/trigger actions

Stage 6 (final part) of closing PR TabularisDB#822's remaining gaps.

SidebarNestedDatabaseItem's 9 mutation callbacks (onAddColumn, onEditColumn,
onAddIndex, onDropIndex, onAddForeignKey, onDropForeignKey, onCreateTable,
onCreateView, onCreateTrigger) previously all routed to a single
onUnsupportedAction placeholder that showed a "not yet supported" toast.
Replaced with real callback signatures carrying (schema, database) alongside
the existing SidebarSchemaItem-shaped args, wired in ExplorerSidebar.tsx to
open the now schema+database-aware modals from the previous three commits —
the exact same modals and backend commands the single-schema and flat
multi-db branches already use, just closing over the nested tree's own
(schemaName, databaseName) pair instead of a single qualifier.

Removed handleUnsupportedNestedAction and the sidebar.nestedDbActionUnsupported
i18n key (all 11 locale files) now that nothing routes to them — no dead
code or unused strings left behind.

This closes out Stage 6 of the PR TabularisDB#822 follow-up plan (CRUD mutations for
the nested tree). Stages 7 (ER diagram routing) and 8 (dump/import plugin-
bypass fix + nested picker) still to come on this branch.
… the nested tree

Stage 7 of closing PR TabularisDB#822's remaining gaps.

get_schema_snapshot gained the standard database: Option<String> override.
open_er_diagram_window's database_name param stays display/window-label-only
as before; a new, separate database param carries the routing value and is
only appended to the window's URL when explicitly provided — so the flat
multi-db (MySQL) and plain single-database call sites (which never pass it)
are completely unaffected.

Threaded a new `database` query param end to end: SchemaDiagramPage.tsx
reads it (distinct from `databaseName`, which resolveDiagramSchema already
uses as a MySQL-specific schema fallback) → SchemaDiagram.tsx →
EditorProvider's getSchema(), whose schema cache key now also includes
`database` so two databases with the same schema name don't collide.

Only the table-level "View ER Diagram" context-menu action (the one
nested-tree call site that already extracts ctxDatabase, from PR TabularisDB#822) now
passes it through. The toolbar, flat multi-db, and database-type
context-menu call sites are unchanged. Did not add a schema-picker dropdown
to the diagram window itself — out of scope for this routing-correctness
fix, tracked separately if wanted.
…or the nested tree

Stage 8 of closing PR TabularisDB#822's remaining gaps.

Backend: dump_database's two hard-coded driver-string match blocks
(table-listing and DDL-fetch) now route through the DriverTrait instead of
calling mysql/postgres/sqlite module functions directly, fixing the
"Unsupported driver" failure for any plugin-registered driver. Added a new
get_table_ddl method to the DriverTrait with implementations for all three
in-tree drivers (delegating to their existing module-level get_table_ddl
functions) and for RpcDriver (dispatching a new "get_table_ddl" RPC call).
The export_table_data step is intentionally unchanged — it opens its own raw
per-driver connection from ConnectionParams regardless of which driver id is
registered, doesn't have the "Unsupported driver" failure mode, and
rerouting it through execute_query would risk losing the JSON/binary type
fidelity that raw column introspection currently provides. The plugin-side
RPC handler is tracked in a follow-up issue on tabularis-postgresql-plugin.

Frontend: DumpDatabaseModal gained explicit schema/database props for the
nested case, reads its table list from nestedDatabaseDataMap (already loaded
by the sidebar) instead of triggering a fresh fetch, and scopes the
dump_database invoke correctly. ImportDatabaseModal similarly gained an
explicit schema prop overriding the global activeSchema. SidebarNestedDatabaseItem
now has onDump/onImport optional callbacks (with Download/Upload icon buttons
in the database header when provided), wired in ExplorerSidebar. handleImportDatabase
widened to accept an explicit schema.
SchemaDiagramPage.tsx now fetches the available schemas for the connection
(scoped to the selected database when opened from a nested multi-db tree)
and renders a <select> in the window header so users can switch which schema
the diagram shows without reopening the window.

The picker is only shown for schema-capable drivers (identified by the
presence of an explicit `schema` URL param — MySQL flat-db connections
where "schema" is the database name don't have one and don't need a
schema switcher). Changing the selection re-renders the canvas immediately
via refreshTrigger. Falls back gracefully when get_schemas fails (keeps
the initial schema).
…base override

Three new #[ignore] tests that run against the live pg-tabularis-test
container (port 54320), validating the exact params.database = Single(db)
override pattern used by every Tauri command added in Stage 1-8:

- test_database_override_routes_to_secondary: starts with testdb, overrides
  params.database to tabularis_test_secondary, calls get_schemas — confirms
  secondary_schema is visible and test_schema (testdb-only) is not.
- test_get_tables_with_database_and_schema_override: same override, then
  get_tables for secondary_schema — confirms the remote_data table is
  reachable from a params object that originally pointed at testdb.
- test_empty_string_filter_prevents_maintenance_db_override: baseline check
  that testdb params still see testdb's own schemas, confirming the
  .filter(|d| !d.is_empty()) guard doesn't clobber valid connections.

All 10 multi_database integration tests pass against the live container.
…ping

Multi-database opt-in Postgres-plugin connections persisted `database` as
either an array ("Choose databases") or an empty string ("All databases",
the default mode). Both shapes reach the plugin's ConnectionParams.database:
Option<String> as either None (array silently lost by Value::as_str()) or
Some(""), and deadpool-postgres rejects both — "configuration property
"dbname" not found" / "...contains an empty string" — since test_connection
and ping run before any per-command database override exists.

Add with_primary_database() to coerce database to a single, non-empty name
before these two RPCs, falling back to the "postgres" maintenance db (same
convention the built-in driver already uses for get_databases) when the
selection is empty and the plugin's manifest declares a postgres/postgresql
engine. Gated on the manifest engine so it doesn't leak Postgres-specific
assumptions into other plugin drivers.
…ch other

setSelectedSchemasForDatabase, loadNestedSchemaData, refreshNestedSchemaData,
and loadNestedSchemas each rebuilt the whole nestedDatabaseDataMap from a
closure-captured connectionDataMap snapshot. Confirming a schema selection on
a non-primary database (in the nested multi-db tree) fired three sequential
updates from independently stale closures: the load kicked off by the
confirm, and the confirm's own activeSchema follow-up, both overwrote
needsSchemaSelection back to true right after it was cleared. The picker
reappeared with nothing checked and no schema ever loaded.

Add updateNestedDatabaseData(), which patches one database's nested state
through a functional setConnectionDataMap(prev => ...) updater instead of a
stale closure, and route all four functions through it. This also fixes a
related latent bug where those functions' own "fresh" re-reads after an
await were reading the same stale closure, not live state.

Add a regression test that reproduces the exact symptom; verified it fails
against the old code and passes with the fix.
The single-database Postgres sidebar has always had a gear icon next to
its schema header to re-open the checkbox picker and change the selection
after confirming. The nested multi-db tree's per-database schema picker
(added for PostgreSQL's opt-in multi-database mode) never got the same
affordance -- once confirmed, a database's schema selection was permanent
short of removing and re-adding it via the Databases tab.

Add the same gear-icon edit dropdown to SidebarNestedDatabaseItem, reusing
the already-fixed setSelectedSchemasForDatabase and the existing
sidebar.editSchemas/selectAll/deselectAll/confirmSelection i18n keys.
SidebarTableItem.refreshMetadata destructured `database` from props but
never forwarded it to get_columns/get_foreign_keys/get_indexes, unlike the
sibling SidebarViewItem which already does this correctly. For a table in
a non-primary database (the nested multi-db tree), all three queries ran
against the connection's primary database instead, where the table
typically doesn't exist -- so every table showed 0 columns, 0 foreign
keys, and 0 indexes despite the table itself loading real data.

Also add `database` to areTableItemPropsEqual's compared fields (same
reason the existing `capabilities` comparison was added): without it, a
database-only prop change on an otherwise-identical memoized instance
would be silently skipped by React.memo.

Add regression coverage: a component test asserting database is forwarded
to all three invoke calls (and omitted when absent, matching the existing
single-db behavior), a re-render test proving a database prop change
actually triggers a re-fetch, and a comparator test case for the new
compared field.
…t_connection

We've now hit the same crash twice: 'Pool creation failed: configuration
property "dbname" not found', first on connect (fixed in 314a5c9 for
ping/test_connection only), and now again from dropping a table in the
nested multi-db tree -- a schema refresh after the drop called get_tables
without a database override, sending the connection's raw
DatabaseSelection::Multiple(...)/empty Single("") straight to the plugin.

An audit found only 2 of 52 RpcDriver methods were protected. Every other
method independently forwards "params" to the plugin subprocess, so any
command missing a database override (there are several with none at all:
get_available_databases, get_ai_schema_context, build_routine_call_sql,
save_blob_to_file, fetch_blob_as_data_url, all user-management commands,
every MCP tool) -- or a frontend call site that omits one it should have
passed -- hits the same crash.

Rather than whack-a-mole individual methods as they're found, apply
with_primary_database() at every "params" JSON-RPC payload site in this
impl (50 call sites, including for_connection's get_connection_metadata --
the earliest point any of these commands reach the plugin). For an
already-resolved single database (the normal case for both single-db
connections and per-command multi-db overrides), primary() returns it
unchanged, so this is a no-op there; it only changes behavior for the
specific gap case, converting a connection-wide crash into a safe
fallback to the primary/first-selected database.

Add regression tests for get_schemas (the drop-table repro's actual
failing call) and get_tables, mirroring the existing ping/test_connection
coverage.
…porting

Both handlers called the generic connection-level refreshTables() after a
mutation that had itself correctly targeted a specific database in the
nested multi-db tree (drop-table already passed ctxDatabase/ctxSchema to
execute_query; the import modal already resolved its targetDatabase from
importModal.database/schema). refreshTables() calls get_tables with no
database override, so for a multi-db opt-in connection it sent the raw
unresolved database selection to the plugin -- before the driver.rs fix,
crashing the whole connection's sidebar with the same "dbname not found"
error the connect-time bug produced; now (with that fix) it silently
falls back to refreshing the primary database's tables instead of the one
actually mutated.

Route both through refreshNestedSchemaData(database, schema) instead when
the action targeted a nested database, leaving refreshTables() untouched
for the single-database and flat (schema-less) multi-db cases, which were
never affected. The import fix keys off importModal.schema rather than
importModal.database, because database always has a value (it falls back
to activeDatabaseName for a plain single-db import) while schema is only
ever set by the nested tree's own Import button.
findExistingTableTab deduplicated table tabs on (connectionId, activeTable,
schema) only. For a nested multi-db connection, the same table name +
schema can exist in more than one database (or a tab opened before
database was threaded through at all can be sitting in state with no
database set) -- double-clicking a table would reactivate that stale tab
instead of opening a fresh one, reusing its wrong/missing database. If
that tab's query then re-ran with no database override, it sent the
connection's raw multi-db selection to the plugin: the same 'Pool creation
failed: dbname not found' crash as the other two instances of this bug,
now from simply opening a table.

Add database to the match criteria and thread it through from addTab's
partial. Single-database and flat multi-db tabs are unaffected -- both
sides of the comparison are undefined there, same as before.
… or trigger

ViewEditorModal/TriggerEditorModal's onSuccess handlers always called the
generic connection-level refreshViews()/refreshTriggers(), which write into
the top-level views/triggers arrays. Those are only ever rendered for a
schema-less driver -- every schema-having case (a single connection's own
schema, a database in the nested multi-db tree, or a database in the flat
schema-less multi-db list) renders from schemaDataMap/nestedDatabaseDataMap/
databaseDataMap instead, which the generic refresh never touches. A newly
created view or trigger was therefore invisible in the sidebar for any of
those three cases -- confirmed via psql that the view actually exists;
the sidebar just never asked for it again.

Add refreshObjectScope(), shared by both onSuccess handlers, which
resolves the same nested/schema/database ambiguity refreshAfterCreateTable
already handles for tables (a flat multi-db modal reuses the schema field
to carry a database name; activeCapabilities?.schemas disambiguates it
from a real schema, since each is only reachable from the sidebar branch
gated on that same flag), falling back to each modal's original top-level
refresh when neither is set.
…tine

Same gap as view/trigger creation (94dcc3e), just on the drop side: the
drop-view, drop-trigger, and drop-routine context-menu actions already
scoped the drop_view/drop_trigger/drop_routine call correctly via
schema/database, but then refreshed with the generic connection-level
refreshViews()/refreshTriggers()/refreshRoutines() -- a no-op for any
schema-having case, so the dropped object stayed listed until a manual
refresh.

Route all three through the same refreshObjectScope() helper. Column/
index/FK actions on an existing table were never affected by this class
of bug -- they bump schemaVersion instead, which SidebarTableItem's own
(already-fixed) per-table metadata refetch picks up; only whole-object
create/drop for views, triggers, and routines goes through the schema's
own object list in schemaDataMap/nestedDatabaseDataMap/databaseDataMap.
…nline body

TriggerEditorModal's guided mode generated the same CREATE TRIGGER ...
BEGIN ... END template for every driver, but PostgreSQL has no inline
trigger body -- a trigger can only reference a separate trigger function.
Every PostgreSQL trigger created through guided mode failed with a syntax
error (TabularisDB#837, found while testing PR TabularisDB#822).

Extract the SQL generation into src/utils/triggerSql.ts (now unit-tested).
For postgres/postgresql, generate a CREATE OR REPLACE FUNCTION ... RETURNS
TRIGGER AS $$ ... $$ LANGUAGE plpgsql statement plus a CREATE TRIGGER ...
EXECUTE FUNCTION fn() referencing it, run as two separate calls (via
execute_query then create_trigger) since neither Postgres client here
supports multiple statements in one call. The function name is
deterministic (<trigger_name>_fn) and schema-qualified in *both*
statements -- qualifying only the table (matching the existing pattern)
left CREATE FUNCTION and EXECUTE FUNCTION as two calls that aren't
guaranteed to resolve an unqualified name against the same search_path,
which surfaced its own 'function ... does not exist' failure even after
the function had just been created successfully.

Also clean up the function when a trigger created this way is dropped
via the sidebar's context menu (DROP FUNCTION IF EXISTS, no CASCADE, and
the drop itself is not blocked if cleanup fails) -- otherwise every
drop would leave an orphaned function behind. CREATE OR REPLACE makes
re-saving/editing a trigger idempotent, so the modal's own edit flow
(drop trigger, recreate function + trigger under the same name) needed
no separate cleanup step.

MySQL and SQLite are unaffected: both still get the original inline
BEGIN/END body, which is valid for both. Verified end-to-end against a
live PostgreSQL connection -- create, verify SQL, drop, and confirm via
psql that both the trigger and its function are fully removed.
…ones

dump_database's structure export already went through the generic
DatabaseDriver trait (get_table_ddl, fixed for the plugin path by PR TabularisDB#119),
but three other spots in the same dump/import flow only matched the
builtin driver id "postgres" literally, silently breaking or hard-failing
for the "postgresql" plugin driver:

- format_table_ref (dump_utils.rs) fell through to its unqualified '_'
  arm for "postgresql", so a plugin-backed dump's DROP TABLE/INSERT
  statements silently lost their schema qualifier -- not just an error,
  a statement that could hit the wrong same-named table in a different
  schema.
- dump_database's schema_for_listing gate had the same literal check,
  so get_tables() would list tables from every schema.
- export_table_data's row-export match only had "mysql"/"postgres"/
  "sqlite" arms, each streaming rows through that driver's own local
  connection pool -- structurally impossible for a plugin, which owns its
  pool inside its subprocess. Any other driver id hit a hard
  'Unsupported driver' error.

Widen the two string checks to also match "postgresql" (same fix
pattern as TabularisDB#614, just in files that predated or were missed by it), and
replace export_table_data's error fallback with a generic RPC-based path:
paginate through execute_query, using get_columns beforehand to know
which columns are JSON/JSONB (QueryResult only carries column names, not
types, so a JSON column can't be told apart from a plain text column by
inspecting a cell's value alone). Extracted the column-classification and
row-formatting logic into pure, unit-tested helpers.

import_database has the identical shape of gap -- three builtin-only
transaction blocks with the same 'Unsupported driver' fallback -- fixed
with an analogous generic path via execute_query per statement. This one
is NOT atomic (no cross-plugin RPC for transaction begin/commit/rollback
exists yet, unlike the builtin drivers' single wrapped transaction);
schema-scoping doesn't depend on that, since it's passed per-call rather
than relying on session state persisting across pooled connections.

Verified end-to-end: dumped a nested multi-db PostgreSQL-plugin
connection's non-primary database, confirmed schema-qualified DDL/DML
and the composite-PK/view-exclusion checks from PR TabularisDB#822's own test plan.
…i-db tree

The nested multi-db tree (this PR's own new feature) had no way to open
an ad-hoc console scoped to a database with no existing table or routine
to anchor a context-menu action on -- confirmed while testing A.4's
import step against a freshly-created, still-empty target database. The
flat schema-less multi-db path (MySQL) already has this via the
console's own database-selector dropdown; that dropdown is deliberately
hidden for a schema-based driver like PostgreSQL (it reuses the `schema`
field to carry a database name, which would be wrong for a real schema),
and nothing was added to replace it for the new nested tree.

Add a "New Console" icon to SidebarSchemaItem's header, next to the
existing Refresh icon. Since SidebarSchemaItem already receives its
`database` prop from the nested tree (undefined for a plain
single-database connection), one handler in ExplorerSidebar works
correctly for both: a single-schema connection opens an unscoped console
exactly as before, and a nested database opens one correctly scoped to
both the schema and the database.
…he schema list

loadNestedSchemas guards on `schemasLoaded` to make expanding a database
lazy-load its schema list only once -- correct for that purpose, but the
same guard made the database row's own Refresh button (which calls this
same function) a permanent no-op after the very first load: clicking it
any number of times could never surface a schema created afterwards (hit
directly while testing A.4 -- a `store` schema created via the new
per-schema console, from the previous commit, never appeared no matter
how many times Refresh was clicked, even though psql confirmed it existed
in the right database).

Add a `force` parameter that bypasses the `schemasLoaded` check (but not
the concurrent-fetch guard) and wire the Refresh button to pass it. The
initial lazy-load-on-first-expand call site is unaffected -- it still
omits `force`, so it still loads a nested database's schemas exactly
once.
aesslinger and others added 19 commits September 29, 2026 10:58
The connection-level header's "View ER Diagram" (and Import/Dump) button
was only ever gated off for the flat schema-less multi-db case
(`!isMultiDb`, using `usesMultiDatabaseLayout` — schemas === false), never
for the nested schema-based case this PR adds. It stayed visible for a
nested connection and opened a diagram using activeDatabaseName/
activeSchema — the connection's top-level fields, which a nested
connection never populates (each database's own active schema lives in
nestedDatabaseDataMap instead). Confirmed live: clicking it opened a
diagram with no schema picker and no tables at all.

Gate it on `isNestedMultiDb` (isSchemaBasedMultiDb) too, matching the
flat case's own comment ("actions move to each database node"). The
nested tree has no context menu on the database/schema row to reach an
equivalent (only a table's context menu correctly passes database+schema,
once a table exists to right-click) -- add a "View ER Diagram" icon to
SidebarNestedDatabaseItem's header, next to its existing Import/Dump/
Refresh/New Console icons, using the row's own databaseName and
activeSchema so it's always correctly scoped.
…ks get_schema_snapshot

Opening the ER diagram for a nested multi-db PostgreSQL-plugin database
rendered a silently blank canvas: get_schema_snapshot is a batch RPC the
plugin doesn't implement (returns "method not found"), and the frontend's
catch block only logged the error to the console -- no user-facing message
at all, so an empty canvas was indistinguishable from "this schema
genuinely has no tables".

RpcDriver already has this exact fallback shape for other optional/batch
RPCs (get_materialized_views, save_blob_to_file, etc.) -- an empty-Vec
fallback when the plugin doesn't implement them. That shape doesn't fit
here: unlike materialized views, "no tables" is never a valid reading for
a schema snapshot. Compose the same TableSchema[] shape instead from
get_tables/get_columns/get_foreign_keys, which the plugin does implement
(confirmed throughout this branch's own manual testing).

Also stop swallowing the error silently on the frontend -- show an alert
with the actual failure reason, so a future gap in this class (a plugin
missing some other optional metadata RPC) fails loud instead of looking
like an empty result. New i18n key added to all 11 locales.

The plugin-side gap (get_schema_snapshot, and two related unused batch
methods, are all `not_implemented` stubs) is filed as its own issue for
the plugin repo separately, since the fix belongs there long-term for
performance (this fallback is O(tables) round trips, not one batch call).
cleanTabForStorage built an explicit object literal that omitted
Tab.database, so nested multi-db table tabs lost their database
routing after a restore, silently re-running queries against the
connection's primary database instead.
Every other RpcDriver call site coerces a Multiple/empty database
selection via with_primary_database before the JSON-RPC call;
get_table_query_template was the one exception, so a multi-db
Postgres-plugin connection generating a query template would send
the plugin a raw JSON array where it expects a string, or an empty
selection with no fallback.
for_connection stored the raw params.clone() as connection_params,
which call_with_connection injects verbatim into SQL-building RPCs
(create table/column/index/FK DDL templates, routine templates,
privilege catalog). A multi-db opt-in connection's Multiple(...)
selection reached the plugin as a bare JSON array instead of the
coerced primary database string every other call site already uses.
Every sibling metadata command (get_columns, get_foreign_keys, etc.)
accepts an optional database param to retarget a non-primary
database on a multi-db connection; get_table_query_template was
missing it, so Generate SQL templates always used the connection's
primary database regardless of which one the user was browsing.
TableTarget had no database field, so GenerateSQLModal's get_columns/
get_foreign_keys/get_indexes/get_tables/get_table_query_template
calls always resolved against the connection's primary database.
Generate SQL on a non-primary database in the nested multi-db tree
would silently show SQL for the wrong database's table. The request
cache key also omitted database, so switching tables of the same
name across databases could serve stale cached SQL.
…base through the row editor

save_blob_to_file/fetch_blob_as_data_url had no database parameter,
so BLOB download/preview always targeted the connection's primary
database on a multi-db connection.

Wiring that up surfaced a broader, pre-existing gap: DataGrid never
received schema/database as props at all, reading the connection's
globally active schema from context instead of the active tab's own
schema. That could already misdirect a BLOB fetch on a plain
single-schema-picker connection if the sidebar's selected schema
changed while a background tab stayed open — multi-db just adds a
second, parallel instance of the same problem for database.

Both are now threaded as explicit props through DataGrid ->
RowEditorPanel -> FieldEditor -> BlobInput, sourced from the active
tab (tab value wins, falls back to the global active schema to match
this codebase's existing tab.schema ?? activeSchema convention).
useCommandPaletteObjectItems gated its loading effect on hasSchemas
(capabilities.schemas === true), which is also true for a nested
multi-db connection, and unconditionally read the top-level
selectedSchemas/schemaDataMap — fields a nested connection never
populates, since its schema state lives per-database in
nestedDatabaseDataMap instead. The palette's object search silently
returned nothing for these connections.

Added a nested branch to the loading effect (loadNestedSchemaData
per selected database x schema), a third database-then-schema branch
in getNavigatorItems, and a database field on NavigatorItem/
NavigatorGroup/DatabaseObject so the palette's Inspect/New Console/
Generate SQL/Open/Count actions all target the right database -
reusing the database field already on TableTarget/DatabaseObjectTarget
from the dump/GenerateSQL fixes earlier in this pass.
…ump modal

The refresh-on-open effect explicitly excluded the schema-based
(nested multi-db) case, relying entirely on the sidebar having
already expanded that schema. If it hadn't, nestedDatabaseDataMap
had no entry for it and the dump modal's table list stayed
permanently empty with no loading state and no way to recover short
of expanding the schema elsewhere first.
get_schema_preference looked up only the database-scoped key
(connection_id::database). A connection's schema preference saved
before it opted into multi-database browsing lives under the old
flat connection_id key, so it was silently orphaned and lost the
first time each database was looked up under its new per-database
key. Extracted the lookup into a pure resolve_schema_preference so
it's unit-testable without an AppHandle.
…ing plugin drivers

export_table_data's plugin fallback defaulted a missing pagination
field to has_more=false, silently truncating any table with more
than one page (1000 rows) of data when a plugin's execute_query RPC
doesn't populate pagination - the dump looked complete but wasn't,
with no error raised. Extracted plugin_dump_has_more, mirroring
export.rs's existing fetched >= page_size fallback for the same gap.
get_table_ddl's error propagated via ? straight out of dump_database's
task, so a plugin that doesn't implement it (structure dumps enabled)
aborted the entire dump after the first table - no data for any table
got written either, even though every other RPC the dump needs works
fine.

Considered composing an approximate CREATE TABLE from get_columns/
get_foreign_keys/get_indexes (as the get_schema_snapshot fallback does
for the read-only ER diagram) and rejected it: a dump is meant to be
restored, and a plausible-looking but subtly wrong DDL statement is
worse than a visible gap. The structure step now writes a WARNING
comment for that table and the dump continues - data export for it,
and everything else, is unaffected.
… it's Postgres-flavored

Both export_table_data's and import_database's generic plugin
fallbacks unconditionally passed a public-defaulted schema to
execute_query/get_columns for every plugin driver, regardless of
engine. A non-Postgres plugin's schema RPC parameter may mean
something else entirely (e.g. a database selector for a MySQL-like
plugin with no real schema concept), so this could silently retarget
such a connection to a database that doesn't exist instead of
leaving the plugin's own default alone. Extracted is_postgres_driver
and gated both fallbacks on it.
… stringified message

is_method_not_found checked a stringified error for "method not
found"/"-32601", but PluginCallError's Display only ever writes
error.message - the JSON-RPC code is never present in that string at
all. A plugin returning a proper -32601 with any message other than
that exact English phrase (a different wording, a non-English
message) silently bypassed every one of the 13 method-not-found
fallbacks in this file, propagating a real error where a graceful
degradation was intended.

Switched every one of those 13 call sites from call() (which
discards the structured PluginCallError into a plain String before
the check ever runs) to call_detailed(), and is_method_not_found now
matches on the Remote variant's actual code - falling back to the
message-string heuristic only for Transport errors, which have no
JSON-RPC code at all. Added call_with_connection_detailed alongside
call_with_connection for the one fallback (routine_create_template)
that goes through that path instead of a direct process.call.

This branch has not been deployed

No deployments
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.

[Feat]: Browse/access all databases on same connection

1 participant