Skip to content

feat: table properties, temporary tables, join completions, and MCP write access - #35

Merged
Phamthang1997 merged 13 commits into
mainfrom
feat/table-properties-and-temp-tables
Sep 7, 2026
Merged

feat: table properties, temporary tables, join completions, and MCP write access#35
Phamthang1997 merged 13 commits into
mainfrom
feat/table-properties-and-temp-tables

Conversation

@Phamthang1997

Copy link
Copy Markdown
Owner

Summary

Adds dedicated Table Properties and Temporary Tables panels, ranks joinable tables with automatic ON clause insertion in SQL Editor autocomplete, converts Database Overview into a dense data grid, and introduces MCP V2 write access with interactive approvals and encrypted hash-chained audit logging.

Type of change

  • Feature
  • Bugfix
  • Refactor / cleanup
  • Performance
  • Build / CI / dependency
  • Docs
  • Security
  • i18n / localization

Linked issue

Closes #

Key changes

  • Table Properties & Temporary Tables: Added get_table_properties backend queries across MySQL, Postgres, and SQLite, surfaced as a properties view mode tab in DataGrid. Added get_temporary_tables for session-scoped temporary table management.
  • Database Overview Grid: Redesigned modal into a dense, spreadsheet-style data grid with detailed table/view metadata, compact summary metrics, and alternating zebra striping.
  • SQL Editor JOIN Autocomplete: Ranked joinable tables via foreign key graph relationships with inline ON condition expansion, plus catalog prime synchronization and batched condition resolution.
  • MCP Server V2: Added write-access gating with per-connection permissions and per-statement approval dialogs, along with local encrypted hash-chained audit log storage.
  • Export Dialog: Derived selection preview directly to eliminate effect re-render overhead.

Technical decisions

  • Temporary tables query on PostgreSQL targets pg_my_temp_schema() rather than nspname LIKE 'pg_temp%' to avoid leaking other concurrent session relations.
  • Foreign key ranking in SQL Editor reads solely from the primed catalog cache to prevent autocomplete IPC latency, with a 1.2s timeout fallback.
  • Encrypted audit log lines are tamper-evident and chained sequentially via cryptographic hashes on disk.

Out of scope / follow-ups

  • None

Testing

Verified:

  • Happy path
  • Empty or invalid input
  • Error path
  • Regression on the surrounding features

Ran against:

  • SQLite
  • PostgreSQL
  • MySQL
  • Redis
  • N/A — no database code touched

How to re-verify:

  1. Open Table Properties via right-click on any table in the sidebar across SQLite/Postgres/MySQL.
  2. In SQL Editor, type SELECT * FROM orders JOIN and verify candidate tables rank FK-related items first with automatic ON clause suggestions.
  3. Test MCP write access with a mutation query and observe the approval prompt and encrypted audit log.
  4. Run test suites: npx oxlint, npx tsc --noEmit, npm test, and cargo test --lib.

Cross-cutting checks

Open if this PR crosses the Rust ↔ TypeScript boundary, or touches the editor / build config

Nothing enforces the pairs below. They drift silently, and the failure shows up as
wrong behaviour rather than as a build error.

  • New #[tauri::command] → registered in src-tauri/src/app/handlers.rs
  • New backend capability → matching method in src/utils/dbHelper.ts
  • New redis_* command → classified in src/utils/safeMode.ts
  • Statement splitting changed → src-tauri/src/database/splitter.rs and src/sql/statements.ts
  • A Rust error message reworded → src/utils/backendErrors.ts
  • Redis SSL handling changed → redis_ssl_mode() and REDIS_SSL_MODES in ConnectionManager.tsx
  • The JSON shape a command returns changed → types in dbHelper.ts
  • Touched an editor component or vite.config.mts → the monaco chunk is still absent from the modulepreload list in dist/index.html after npm run build-frontend

Impact & risk

  • Breaking change: no
  • Migration, schema, config or env change: no
  • Needs a native rebuild or an app version bump: yes (new Tauri backend commands added)
  • Rollback plan: Revert commit sequence or checkout base branch.

Checklist

  • Rust CI green (build, test, clippy)
  • Frontend checks run locally: npx oxlint, npx tsc --noEmit, npm run test
  • Read the whole diff myself
  • Added or updated tests for the changed behaviour
  • Updated docs / doc comments where behaviour changed
  • Every user-facing string goes through i18n — a key in en.ts + vi.ts + ja.ts, never a literal
  • No leftover debug code, console.log, throwaway unwrap() or stray TODO
  • No secrets, tokens or connection credentials — screenshots included

Notes for the reviewer

  • Check stats/table_properties.rs and mcp/audit_file.rs for the new backend features and security chaining.

Implements docs/table-properties-and-temporary-tables-plan.md.

Properties pane
- New `get_table_properties` (stats/table_properties.rs) answers per dialect in
  one round trip: information_schema.TABLES + SHOW CREATE/COLUMNS/INDEX on
  MySQL, pg_class/pg_stat_all_tables/pg_sequences on Postgres, sqlite_master +
  the PRAGMAs + dbstat on SQLite. It reads through execute_raw_sql_generic so a
  manual transaction routes it to the pinned connection, and every secondary
  lookup is .ok()-tolerant: a missing dbstat blanks one row, not the panel.
- New `properties` view mode in DataGrid, so this is a workspace tab rather than
  a modal and two tables' properties can sit side by side.
- Rows whose value is null are dropped instead of rendered as "-": "unknown" and
  "not applicable" are different answers.

Temporary tables
- New `get_temporary_tables` lists what the SESSION owns. Postgres matches
  pg_my_temp_schema() rather than nspname LIKE 'pg_temp%' — the catalogue holds
  every session's temp relations, and offering another session's would open a
  tab whose every query fails. MySQL's INNODB_TEMP_TABLE_INFO reports InnoDB's
  internal names, so those are filtered out and the section is usually empty
  there, which is the honest answer for a pooled connection.
- The section renders only while the count is above zero and clears on a
  connection or database change.
- TableItem.schema is threaded from the sidebar row through the tab to the reads,
  because a Postgres temp table lives in pg_temp_N and qualified() would look for
  it in the connection's schema. get_table_data, get_table_schema, drop_table and
  get_table_properties take an optional schema override that defaults to the
  connection's schema, so every pre-existing caller is unchanged.

Also
- SqlEditor dispatches `schema-changed` next to invalidateCatalog(), so DDL typed
  into a query tab refreshes the sidebar. It previously only reacted to renames
  and restores, so a CREATE TABLE left the list stale until a manual refresh.
- 5 Rust unit tests, 66 i18n keys across en/vi/ja.

Verified: tsc clean, 796 frontend tests pass, build keeps monaco out of the
startup preload list, oxlint adds no new findings. The Rust is NOT
compiler-verified — tauri dev holds the target dir, so only the pure helpers were
checked via standalone rustc.
- The four headline figures are centred in their tracks (`align-items` plus
  `text-align`, and `justify-content` on the icon+label line).
- Each label line takes one hue, set on `.tp-metric-head` and inherited by both
  children so a tile is one declaration: accent for storage, green for rows,
  violet for columns, amber for indexes — the meanings the Sidebar already
  attaches to those colours. The number stays `--win-text-primary`; four coloured
  numbers would compete with each other rather than stand out from the labels.
- The hues are declared PER THEME as local `--tp-*` properties on
  `.tp-container`, not as literals in the rules that use them. A single hex is
  correct in at most one of the two themes, and amber forced the issue: `#f59e0b`
  on a 10px uppercase label over the light ground is roughly 2:1 contrast. Same
  fix applied to the three colours left over from earlier rounds
  (`.tp-badge-temp`, `.tp-badge-view`, `.tp-str`), so the file no longer holds a
  bare hex outside a theme block. Scoped to the panel deliberately: defining
  `--win-warning` globally would restyle the ~51 sites that reference it with a
  fallback.
`cargo test --lib` reports 130. The 131 in the previous commit came from grepping
for `#[test]`/`#[tokio::test]`, which also counted a COMMENT in
`database/ident.rs:94` that mentions the attribute in prose. The count is now the
one an actual run produced, alongside `cargo clippy --all-targets` at zero
warnings.
…ation

- Transform Database Overview modal into a dense, spreadsheet-style data grid with detailed table and view metadata (schema, kind, charset, collation, engine, rows, sizes, comment).
- Compact top summary cards into a slim 34px horizontal metric strip and merge filter pills with search/sort into a unified toolbar.
- Add right-click context menu on Tables header in sidebar with 'Item overview' and 'Show diagram'.
- Consolidate all component stylesheets (database_info, process_monitor, table_properties) into src/index.css and eliminate scattered CSS imports.
…bol border

- Add subtle alternating even/odd row backgrounds for the Database Overview data grid.
- Fix spurious vertical line on estimated_row approximate prefix by replacing td class with dbi-grid-approx.
… clause

After `JOIN`, the completion list was the whole catalog ordered by usage
frequency, and the FK knowledge only appeared once `ON` had been typed.

A candidate the FK graph connects to a table already in scope now sorts into
a new `1z_` tier above the plain table tier, says so in its detail line, and
- unless an `ON` already follows the caret in the same statement - is
inserted with the condition attached (`address a ON a.city_id = c.city_id`).

Ranking reads the catalog cache only, so it costs no IPC and reports nothing
until the background prime lands. `buildJoinConditions` gained an `fkOnly`
option for it: the same-name fallback it uses at the caret would call every
table joinable in a schema where each one has an `id`.
…JOIN

The FK ranking added in the previous commit reads the catalog cache, and the
first JOIN of a session is exactly when that cache is still cold: getTables()
fires primeCatalog() and walks away, so the ranking silently did nothing
until the user retyped.

primeCatalog() now hands back the in-flight promise instead of a bare "already
priming" return, and ensureCatalogPrimed() awaits it on that one path. The
wait is capped at 1.2s with the prime left running past the cap, so a slow
connection costs one frequency-ordered popup, never a popup that hangs.

Drops the `priming` flag, which inFlight replaces, and clears inFlight in
invalidateCatalog so nobody awaits a prime filling a discarded cache.
… an effect

`npx oxlint` was exiting 1 on a single correctness error: the preview effect
in ExportTableDialog wrote the picked rows into state synchronously, while
the fetch path queues its loading flag in a microtask - so the two orderings
had to be reasoned about together, and a comment explained why the early
return had to sit where it did.

Those rows are already in memory, so the whole branch is derivable. The
effect now returns without touching state, and rows/totalRows/loading are
computed from `pickedRows`. The ordering question is gone rather than
documented.

Renames the fetch-path state (fetchedRows/fetchedTotal/fetching) so the
derived names stay the ones the JSX reads, and swaps `onlySelected` for
`pickedRows` in the effect deps. Lint is back to exit 0, with 90 warnings
left in the two groups CLAUDE.md triages.
feature-roadmap-plan.md listed 7 open items, 5 of which have real code:
Quick Copy, Live Processlist, ER Diagram, Query Result Charts and .xlsx
export. Only the Tauri auto-updater and DuckDB are actually unstarted.
performance-optimization-guide.md was the same story for steps 1-3, and two
details of its step 2 are now wrong: under rolldown an object `manualChunks`
is ignored silently, and `drop: ['console']` is dead config without esbuild.

CLAUDE.md's lint numbers had drifted too - ~190 warnings is now ~90, and the
exhaustive-effect-dependencies backlog it described as untriaged is at zero.

Each ticked item names where the code lives, so the next reader can check the
claim instead of taking it.
… approval

The plan required §3.5 to be decided before any code. Decided and recorded in
the document: share the MECHANISM with Safe Mode, not the policy.

Safe Mode cannot gate this even in principle - it lives in dbHelper's local
invoke() on the frontend, and an MCP request arrives on an axum task in Rust
without passing through it. So mcp/approval.rs builds the park-emit-await-
resume machinery safeMode.ts deliberately skipped. The gate is always on and
has no silent mode: "stop asking about my own statements" does not extend to
a statement an outside service wrote, and a policy that cannot be configured
is a policy that cannot drift from Safe Mode's.

Three layers, none of them a substitute for another:

- ConnEntry.mcp_write, a second per-connection tick, default off and cleared
  whenever the share tick goes off. Sharing a connection so a model can read
  the schema must not carry write access, or every shared connection becomes
  a source of dialogs - and fatigue is how a guard stops working.
- policy::ensure_single_write: one statement, and not a read. A SELECT sent
  here would raise a dialog for something tablegrid_query answers without
  one. DDL passes: §0.3 already settled that keyword classification is the
  wrong mechanism, and the human reading the statement is the filter.
- approval::ask: parks on a oneshot keyed by request id, emits to
  McpApprovalGate.tsx, refuses after 60s. The dialog says the write commits
  immediately and Rollback cannot reach it, because it runs pooled.

reject_if_manual is asked AGAIN after approval - 60 seconds is long enough
for the user to switch to manual-commit mode, and running then would issue
BEGIN on a session they own.

cargo check + clippy (zero warnings) + 131 lib tests; tsc, oxlint and 798
frontend tests. Remaining V2 item: the audit log to a file.
…teration

The FK ranking added an await inside the table loop, taking oxlint from 89
warnings to 90. That warning did not belong to the triaged group: CLAUDE.md
leaves no-await-in-loop alone because every site is a sequential DB read, and
Promise.all there would fire N queries at the user's production database. This
one reads getCachedSchema - a Map already in memory - so the rule's advice is
right here and the exception did not apply.

Aliases are now assigned in their own pass (each already used an isolated
`taken` set, so the loop was order-independent already), the conditions are
resolved with Promise.all, and the item loop reads both from a Map.

89 warnings, exit 0. Also corrects the count in CLAUDE.md, which I had just
written as ~90 without noticing my own commit had moved it.
Closes the last V2 item. The in-memory log dies with the process, which is
the wrong lifetime for the only record of a third party writing to a live
database: "what did it change last Tuesday" is asked after something goes
wrong, never during the same run.

Encrypted because the entries carry the SQL verbatim, and SQL carries data -
an INSERT names the values, a WHERE names the person. Plaintext on disk would
hand that content to every backup agent, sync folder and search indexer on
the machine, none of which knew it was handling database contents.

AES-256-GCM, key in the OS keyring beside the bearer token. aes-gcm was
already in Cargo.lock via pkcs5, so declaring it adds no crate to the tree -
the same argument base64 carries.

Per line, not per file: a log is append-only, so re-encrypting the whole file
each request would be quadratic and a crash mid-rewrite would lose everything
rather than one line. Each line's AAD is the previous line's tag, so removing,
reordering or splicing lines makes every later line fail to open.

Three limits are written into the module doc rather than left to be found:
the key is readable by anything running as this user (a local app cannot hide
a key from its own user - what this stops is the file travelling where the
keyring did not), tail truncation stays undetectable, and line count and
timing are not hidden.

A dedicated writer thread, not spawn_blocking per entry: the chain makes write
ORDER part of the format, and pool tasks have no order. The hot path only
serialises and sends; an entry is dropped rather than allowed to block the
request it records.

Read back through mcp_audit_file_read, with a This run / Saved on disk switch
in the Logs tab. Undecryptable lines are counted next to the list instead of
being swallowed, and Clear is disabled over the disk view because it does not
touch the file.

cargo check + clippy (zero warnings) + 134 lib tests; tsc, oxlint 89/exit 0,
798 frontend tests.
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails
cargo/aes-gcm >= 0.11.0, < 0.12.0 UnknownUnknown

Scanned Files

  • src-tauri/Cargo.toml

Comment thread src-tauri/src/mcp/audit_file.rs Fixed
Comment thread src-tauri/src/mcp/audit_file.rs Fixed
CodeQL flagged two hard-coded cryptographic values on PR #35, both in this
module's own tests: a [7u8; 32] key in the helper and a [9u8; 32] one in the
wrong-key case. The alerts turned the PR's CodeQL check red.

They are worth acting on rather than dismissing as test-only. Nothing at the
call site tells a reader that a 32-byte literal is a test key, which is the
same reason the rule exists - and no test here depends on the key's value.
Generating one per cipher says that outright, and the wrong-key test now asks
for a second generated key, which reads better than a second magic array.

cargo fmt + clippy (zero warnings) + 134 lib tests.
@Phamthang1997
Phamthang1997 merged commit 88510ad into main Sep 7, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants